@geohar/opencode-sharedserverOpenCode plugin: manage shared backend processes via the sharedserver CLI.
6
近 30 天 +1
1,493
近 7 天 446
48.4
生态多维模型
5 小时前
2026-08-20
快速安装与配置
opencode.json写入当前项目的 opencode.json,只对这个仓库生效。
opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@geohar/opencode-sharedserver@0.8.2"]
}写入 ~/.config/opencode/opencode.json,对所有项目生效。
~/.config/opencode/opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["@geohar/opencode-sharedserver@0.8.2"]
}若你要在本地改造这个插件,先装到项目里再从本地路径引用。
shell
pnpm add -D @geohar/opencode-sharedserveropencode 启动时会通过内嵌运行时自动加载 npm 依赖并缓存至本地目录,无需手动在全局环境执行安装。
sharedserver
one warm server process, shared across every client — reference-counted, with grace periods and dead-client detection
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
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
sharedserveron 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):--piddefaults to parent process (the caller) - Admin commands:
--piddefaults 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 (
watchmanabove) comes up for every profile. A config with noprofilesat all therefore behaves exactly as before — every server is universal, so any caller brings up everything. Addingprofilesis opt-in and backward compatible. - Deterministic.
downre-resolves the same selection, so it releases precisely whatupstarted — no state is tracked between them. uphonourslazy(attach-only, never starts) andskipIfEnv(skip when another host already launched the server), and tolerates a single server failing without aborting the rest.--profile-optional. By defaultup/downwarn when the named profile doesn't exist. Pass--profile-optionalto 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 error —lookupthenregister --if-absentis how well-behaved plugins cooperatively avoid it. - Atomic & lossless. Edits are JSON operations on the single
servers.jsonunder 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;
unregistercascades a removed server out of every profile. config lookup/list/show(add--json) inspect;config validateflags 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, andstart_time(an opaque/procstart stamp used to detect PID reuse). Created at start, deleted at final teardown.<name>.clients.json— the clients side:refcountand 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, alongsideserver.json.<name>.invocations.log— append-only audit log read byadmin 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
- ACTIVE: refcount > 0, server running normally
- GRACE: refcount = 0 (
clients.jsonpresent 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 |
stop— stop 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 --force— stop 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 atkill.kill— the 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
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:
<plugin-dir>/rust/target/release/sharedserver~/.local/bin/sharedserver/usr/local/bin/sharedserver/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
profileis configured, also runssharedserver up --profile <name>
On VimLeave:
- Automatically decrements refcount for all attached servers
- Runs
sharedserver down --profile <name>if aprofileis 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, orlsof -i :PORT - Stale lockfiles:
sharedserver admin doctorto validate and clean up
See DEBUGGING.md for the full troubleshooting guide, and EXAMPLES.md for more configuration patterns.
License
MIT