Skip to content
    ↑↓ select↵ openesc close
    English中文
    georgeharker

    Sharedserver

    v0.8.2Tools & Commands
    @geohar/opencode-sharedserver

    OpenCode plugin: manage shared backend processes via the sharedserver CLI.

    GitHub stars

    6

    +1 in 30 days

    Monthly installs

    1,493

    446 in 7 days

    Composite scoreSCORE

    48.4

    Multi-signal model

    Last commit

    5 hours ago

    2026-08-20

    Install and configure

    opencode.json

    Writes to this project's opencode.json — applies to this repository only.

    opencode.json

    {
      "$schema": "https://opencode.ai/config.json",
      "plugin": ["@geohar/opencode-sharedserver@0.8.2"]
    }

    opencode loads npm dependencies through its embedded runtime on startup and caches them locally — no manual global install needed.

    sharedserver logo

    sharedserver

    one warm server process, shared across every client — reference-counted, with grace periods and dead-client detection

    crates.io

    A shared process manager with reference counting, grace periods, and dead-client detection. Use it standalone from the command line or integrate it with Neovim for automatic server lifecycle management.

    📖 Rendered documentation: docs.georgeharker.com/sharedserver

    Overview

    sharedserver architecture: multiple clients share a single managed server process, coordinated through lockfiles with reference counting.

    One server process, shared across any number of clients. When the last client disconnects, an optional grace period keeps the server warm before shutdown.

    Standalone CLI

    Install

    Prebuilt binaries — no Rust toolchain needed (macOS and Linux, x86_64 and arm64):

    curl --proto '=https' --tlsv1.2 -LsSf \
      https://github.com/georgeharker/sharedserver/releases/latest/download/sharedserver-installer.sh | sh
    

    With cargo, if you have a toolchain:

    cargo install sharedserver
    

    Using the Claude Code or OpenCode plugin? You don't need to install anything by hand. Those plugins fetch a matching sharedserver on first use when one isn't already present — see Claude Code and OpenCode.

    Or build from source:

    git clone https://github.com/georgeharker/sharedserver
    cd sharedserver/rust
    cargo build --release
    # binary at rust/target/release/sharedserver
    

    Quick Start

    # Start or attach to a server (starts if not running)
    sharedserver use myserver -- python -m http.server 8000
    
    # Detach when done (server stays alive if other clients are attached)
    sharedserver unuse myserver
    
    # Check status
    sharedserver info myserver
    sharedserver list
    

    The use command increments the refcount (starting the server if needed), and unuse decrements it. When refcount hits zero, the server enters a grace period or shuts down immediately.

    Grace Periods

    Keep servers warm after the last client disconnects:

    # Start with a 30-minute grace period
    sharedserver use myserver --grace-period 30m -- ./expensive-server
    
    # All clients disconnect -> server survives 30 minutes
    # New client attaches during grace -> grace cancelled, back to active
    # Grace expires -> server receives SIGTERM
    

    Duration formats: 30s, 5m, 1h, 2h30m.

    Shell Script Integration

    #!/bin/bash
    # Ensure ChromaDB is running, share it across scripts
    sharedserver use chroma --grace-period 1h -- chroma run --path ~/.local/share/chromadb
    
    # Do work...
    curl http://localhost:8000/api/v1/heartbeat
    
    # Detach when done
    sharedserver unuse chroma
    

    Replace fragile pkill/pgrep patterns:

    # Instead of this:
    pkill -f "python -m http.server" || true
    python -m http.server 8000 &
    # ...work...
    pkill -f "python -m http.server"
    
    # Use sharedserver:
    sharedserver use webserver -- python -m http.server 8000
    # ...work...
    sharedserver unuse webserver  # server stays alive if others need it
    

    CLI Commands

    Everyday commands:

    Command Description
    use <name> [-- <cmd> [args...]] Attach to server (starts if needed)
    unuse <name> Detach from server
    up --profile <p> [--pid <pid>] Bring up every server in a profile (see Profiles)
    down --profile <p> [--pid <pid>] Release every server in a profile
    config <register|unregister|lookup|list|show|validate|profile> Edit/inspect server defs & profiles (see Self-define)
    list Show all managed servers
    info <name> [--json] Server details (formatted or JSON)
    check <name> Test if server exists (exit: 0=active, 1=grace, 2=stopped, 3=defunct)
    completion <shell> Generate shell completions (bash/zsh/fish)

    Admin commands (troubleshooting):

    Command Description
    admin start <name> -- <cmd> Manually start a server with no clients (refcount 0)
    admin stop <name> [--force] [--timeout DUR] SIGTERM, then wait for full teardown (--force escalates to SIGKILL)
    admin incref <name> --pid <pid> Manual refcount increment
    admin decref <name> --pid <pid> Manual refcount decrement
    admin debug <name> Show invocation logs
    admin doctor [name] Validate state, clean genuinely-stale lockfiles
    admin kill <name> Hard kill (SIGKILL watcher + server) and clean up — the floor

    See Stopping a server for when to use each.

    PID behavior:

    • User commands (use, unuse): --pid defaults to parent process (the caller)
    • Admin commands: --pid defaults to current process

    Profiles

    A profile names a set of servers, so one config can serve several callers and each brings up only its own slice. A host identity — claude, opencode, pi, neovim — is just a reserved profile name; there is no separate host axis.

    // ~/.config/sharedserver/servers.json
    {
      "servers": {
        "chroma":   { "command": "chroma", "args": ["run"] },
        "pi-thing": { "command": "pi-thing" },
        "watchman": { "lazy": true }
      },
      "profiles": {
        "opencode": ["chroma"],
        "pi":       ["pi-thing"]
      }
    }
    

    up brings a profile up as one unit and down releases it; the binary resolves each def from the config and fans out to use/unuse:

    sharedserver up   --profile opencode --pid $$   # starts chroma + watchman
    sharedserver down --profile opencode --pid $$   # releases them
    
    • Universal servers. A server named by no profile (watchman above) comes up for every profile. A config with no profiles at all therefore behaves exactly as before — every server is universal, so any caller brings up everything. Adding profiles is opt-in and backward compatible.
    • Deterministic. down re-resolves the same selection, so it releases precisely what up started — no state is tracked between them.
    • up honours lazy (attach-only, never starts) and skipIfEnv (skip when another host already launched the server), and tolerates a single server failing without aborting the rest.
    • --profile-optional. By default up/down warn when the named profile doesn't exist. Pass --profile-optional to treat a missing profile as normal (bring up only universal servers, no warning) — for a program asking for its own host profile that the user may not have defined.

    Self-define

    Rather than hand-editing servers.json, a program (or you) can register server defs into it as scoped, atomic JSON edits — so a plugin in any repo can define the servers it needs and tag them into its host profile:

    # Define a server owned by a scope and tag it into a profile — one atomic edit.
    sharedserver config register --scope my-plugin chroma --profile opencode -- chroma run
    
    # Coordination pattern: check first, define only if nobody has (still tags the profile).
    sharedserver config lookup chroma --json
    sharedserver config register --scope my-plugin chroma --if-absent --profile opencode -- chroma run
    
    # Tag an existing server into a profile without redefining it.
    sharedserver config profile add opencode watchman
    
    # Withdraw everything a scope registered (cascades out of every profile).
    sharedserver config unregister --scope my-plugin
    
    • Scoped & attributable. Each entry is stamped with its --scope. A name already owned by a different scope is a hard errorlookup then register --if-absent is how well-behaved plugins cooperatively avoid it.
    • Atomic & lossless. Edits are JSON operations on the single servers.json under a lock; keys the tool doesn't model are preserved.
    • Profiles union. Many callers may add the same server to the same profile without clashing; unregister cascades a removed server out of every profile.
    • config lookup / list / show (add --json) inspect; config validate flags dangling profile members.

    Shell Completions

    # Bash
    sharedserver completion bash > ~/.local/share/bash-completion/completions/sharedserver
    
    # Zsh
    sharedserver completion zsh > ~/.zsh/completions/_sharedserver
    
    # Fish
    sharedserver completion fish > ~/.config/fish/completions/sharedserver.fish
    

    How It Works

    Two-Lockfile Architecture

    Each server uses two JSON state files plus an append-only log (default location $XDG_RUNTIME_DIR/sharedserver/ or /tmp/sharedserver/). Each JSON file is both the data and its own flock mutex — there is no separate lock file.

    • <name>.server.json — the server side: pid, command (argv only, not env vars), grace_period, watcher_pid, started_at, and start_time (an opaque /proc start stamp used to detect PID reuse). Created at start, deleted at final teardown.
    • <name>.clients.json — the clients side: refcount and a map of client PID → {attached_at, metadata}. Created at start and kept for the whole life of the server; refcount 0 means grace (the file stays with an empty client map — it is not deleted when the last client leaves). Deleted only at final teardown, alongside server.json.
    • <name>.invocations.log — append-only audit log read by admin debug.

    refcount is always kept equal to the number of distinct client PIDs, so a repeat attach from the same PID is idempotent. Override the directory with SHAREDSERVER_LOCKDIR.

    States

    Server state machine: STOPPED transitions to ACTIVE on use/incref; ACTIVE to GRACE on unuse/decref when refcount hits 0; GRACE back to ACTIVE on use/incref (grace cancelled) or to STOPPED when grace expires (SIGTERM, cleanup).

    • ACTIVE: refcount > 0, server running normally
    • GRACE: refcount = 0 (clients.json present with an empty client map), server alive but countdown running
    • STOPPED: both JSON files deleted, server terminated
    • DEFUNCT: Server process has died but the lockfiles haven't been removed yet (the process is a zombie awaiting reap). Transient: the watcher reaps it and removes the lockfiles, after which the state becomes STOPPED. Commands that need a running server (incref, use, …) refuse a defunct server and ask you to retry shortly.

    The watcher owns the lifecycle

    Each running server has a watcher process (its parent). The watcher is the single owner of the server's lifecycle:

    • It polls every 500 ms, checking each client PID (Linux: /proc/<pid> state; macOS: proc_pidinfo()). Dead clients are removed from the refcount; if all clients die, the grace period starts automatically (no refcount leaks).
    • It reaps the server (waitpid) when it exits, so no zombie lingers.
    • It is the only thing that deletes the lockfiles on the normal path, keyed to the server PID it owns — so a stale watcher can never clobber a freshly restarted instance that reused the same name.

    stop/stop --force cooperate with this by signalling and waiting rather than deleting lockfiles themselves; kill is the exception (see below).

    Stopping a server: stop vs stop --force vs kill

    First signal Graceful wait Escalates to SIGKILL Kills the watcher Deletes lockfiles
    stop SIGTERM yes (--timeout) no — errors, leaves state intact no watcher does
    stop --force SIGTERM yes (--timeout) yes, then waits again no watcher does
    kill SIGKILL none n/a (starts at SIGKILL) yes itself
    • stopstop cleanly now. Sends SIGTERM, then waits until the watcher has reaped the server, removed the lockfiles, and exited. If the server ignores SIGTERM within --timeout (default 10s) it errors and changes nothing — use --force.
    • stop --forcestop cleanly, else absolutely stop. Same graceful path, then escalates to SIGKILL and waits again. On failure it reports exactly what survived (server / watcher / lockfile) and points you at kill.
    • killthe floor: absolutely stop now. Never depends on the watcher (use it when the watcher is wedged): SIGKILLs the watcher first, then the server's process group, then removes the lockfiles itself. The orphaned server is reaped by init.

    Because stop/--force wait for full teardown before returning, an immediate restart with the same name is safe — there is no surviving watcher to race.

    Lifecycle Timeline

    Lifecycle timeline of refcount over time: attach #1 at T+0 (refcount 1), attach #2 at T+5 (2), detach #1 at T+10 (1), detach #2 at T+15 (0, grace starts), attach #3 at T+20 (1, grace cancelled), detach #3 at T+25 (0, grace starts again), grace expires at T+55 (SIGTERM, cleanup).


    Neovim Integration

    For the full guide — building from source, health monitoring, status UI details, manual Lua usage, lazy loading, notification config — see docs/NEOVIM.md.

    Requirements

    • Neovim 0.10+

    Installation

    Using lazy.nvim:

    {
        "georgeharker/sharedserver",
        build = "cargo install --path rust",
        config = function()
            require("sharedserver").setup({
                servers = {
                    chroma = {
                        command = "chroma",
                        args = { "run", "--path", "~/.local/share/chromadb" },
                        idle_timeout = "30m",
                    },
                }
            })
        end
    }
    

    The plugin searches for the sharedserver binary in order:

    1. <plugin-dir>/rust/target/release/sharedserver
    2. ~/.local/bin/sharedserver
    3. /usr/local/bin/sharedserver
    4. /opt/homebrew/bin/sharedserver

    It does not search $PATH, so a binary that only lives in ~/.cargo/bin won't be found — build with the build command above, or copy it to one of the locations listed.

    What the Plugin Does

    On VimEnter:

    • Non-lazy servers: checks if running → attaches (incref) or starts
    • Lazy servers: attaches if running, otherwise does nothing
    • If a profile is configured, also runs sharedserver up --profile <name>

    On VimLeave:

    • Automatically decrements refcount for all attached servers
    • Runs sharedserver down --profile <name> if a profile is configured

    This means multiple Neovim instances share the same server process, and the server survives editor restarts within the grace period.

    Neovim is a hybrid: its inline servers table is self-driven in-process, and it can additionally opt into a shared profile — the same config-file profiles the Claude/OpenCode/Pi hosts use — via setup{ profile = "neovim" }. See Profiles.

    Server Configuration

    require("sharedserver").setup({
        servers = {
            myserver = {
                command = "myserver",           -- required: command to run
                args = { "--port", "8080" },    -- optional: arguments
                env = { DEBUG = "1" },          -- optional: extra env vars (additive)
                working_dir = "/path/to/dir",   -- optional: working directory
                log_file = "/tmp/myserver.log", -- optional: capture stdout/stderr
                lazy = false,                   -- optional: only attach if already running
                idle_timeout = "30m",           -- optional: grace period after last client
                on_start = function(pid) end,   -- optional: callback on start
            },
        },
        commands = true,  -- create user commands (default: true)
        notify = {
            on_start = true,   -- notify when starting new server
            on_attach = false,  -- notify when attaching to existing
            on_stop = false,    -- notify when stopping
            on_error = true,    -- always notify on errors
        },
    })
    

    Commands

    Command Description
    :ServerStart <name> Start a named server
    :ServerStop <name> Stop a named server
    :ServerRestart <name> Restart a named server
    :ServerStatus [name] Show status in floating window
    :ServerList List all registered servers
    :ServerStopAll Stop all servers
    :ServerUp [profile] Bring up a profile (defaults to the configured profile)
    :ServerDown [profile] Release a profile

    :ServerStatus shows a floating window with status indicators:

    • Running (active or in grace period)
    • Stopped

    The single-server view (:ServerStatus <name>) additionally flags servers in their grace period.

    Lua API

    local ss = require("sharedserver")
    
    ss.setup({ servers = { ... } })   -- initialize
    ss.register(name, config)          -- add server after setup
    ss.start(name)                     -- manual start
    ss.stop(name)                      -- manual stop
    ss.restart(name)                   -- restart
    ss.stop_all()                      -- stop all servers
    ss.status(name)                    -- { running, pid, refcount, attached, lazy }
    ss.status_all()                    -- all server statuses
    ss.list()                          -- registered server names
    

    Health Check

    :checkhealth sharedserver
    

    Verifies binary installation, lock directory access, and server status.


    Editor Integrations: OpenCode & Claude Code

    OpenCode and Claude Code have the same lifecycle problem Neovim does: several editor sessions want to share one backend process. Two sibling plugins wire this CLI into their lifecycles — sharedserver use on session start, sharedserver unuse on session end — so servers come up with the editor and tear down cleanly when the last session leaves. Both live here as plain in-tree directories under plugins/:

    Plugin Host Directory Guide Published as
    opencode-sharedserver OpenCode plugins/opencode docs/OPENCODE.md npm @geohar/opencode-sharedserver
    claude-sharedserver Claude Code plugins/claude docs/CLAUDE_CODE.md Claude Code plugin marketplace

    Their per-server config (command, args, env, gracePeriod, logFile, metadata, lazy) is intentionally compatible — a servers map copies across OpenCode, Claude Code, and the Neovim config without changes. An optional top-level profiles map groups servers so a caller can bring up just its own slice with up/down — see Profiles.

    // OpenCode — ~/.config/opencode/config.json
    {
        "plugin": [
            ["@geohar/opencode-sharedserver@latest", {
                "servers": {
                    "chroma": {
                        "command": "chroma",
                        "args": ["run", "--path", "{env:HOME}/.local/share/chromadb"],
                        "gracePeriod": "30m"
                    }
                }
            }]
        ]
    }
    
    // Claude Code — ~/.config/sharedserver/servers.json
    {
        "servers": {
            "chroma": {
                "command": "chroma",
                "args": ["run", "--path", "${HOME}/.local/share/chromadb"],
                "gracePeriod": "30m"
            }
        }
    }
    

    See each plugin's guide above for the full option reference, diagnostics, and local-development instructions.

    Working with the plugins

    The plugins are plain in-tree directories (plugins/opencode, plugins/claude), so a plain clone already contains their full source — no submodule init needed:

    git clone https://github.com/georgeharker/sharedserver
    

    To change a plugin, edit its files under plugins/ directly and commit as normal:

    $EDITOR plugins/opencode/src/index.ts   # or plugins/claude/...
    git add plugins/opencode && git commit -m "feat(opencode): ..."
    

    Use Cases

    Development databases -- ChromaDB, Redis, PostgreSQL shared across editor instances with grace periods for quick restarts.

    Project dev servers -- frontend/backend servers that survive editor restarts.

    Expensive services -- ML inference servers with lazy = true, started only when needed.

    CI/test infrastructure -- shell scripts managing shared test services with automatic cleanup.

    Why Not systemd/launchd?

    System Service sharedserver
    Always running Starts when needed, stops when done
    Requires root/system config User-space, no sudo
    Global config files Per-project config
    Manual start/stop Automatic lifecycle
    One instance system-wide Multiple isolated instances

    Use system services for production/always-on infrastructure. Use sharedserver for on-demand development services tied to your workflow.

    Debugging

    Capture Server Output

    -- Option 1: log_file option
    {
        command = "myserver",
        log_file = "/tmp/myserver.log",
    }
    
    -- Option 2: shell redirect
    {
        command = "bash",
        args = { "-c", "myserver 2>&1 | tee /tmp/myserver.log" },
    }
    

    Common Issues

    • Server exits immediately: capture output with log_file, check environment, use absolute paths
    • Command not found: use absolute path in command
    • Port in use: check :ServerStatus, sharedserver list, or lsof -i :PORT
    • Stale lockfiles: sharedserver admin doctor to validate and clean up

    See DEBUGGING.md for the full troubleshooting guide, and EXAMPLES.md for more configuration patterns.

    License

    MIT