Control project agents across workspaces
What it is · Install · Configure · Tools · Library surface · Claude Desktop · Development
Workspace agent bus for OpenCode. A control agent — an ordinary OpenCode TUI running with this plugin installed — tasks dedicated agents in each project on your roster over a single opencode serve/harness serve instance, using per-request directory routing. A thin stdio MCP facade exposes the same tools to Claude Desktop.
Prerequisites: an opencode serve or harness serve instance already running; Bun if you're using the Claude Desktop MCP bin (bunx).
Add the plugin to opencode.json:
{
"plugin": ["@fro.bot/space-bus"]
}Then add a spacebus.json roster in the same directory:
{
"server": {
"baseUrl": "http://127.0.0.1:4096"
},
"projects": [
{
"name": "my-project",
"path": "~/src/my-project",
"description": "My project's agent runtime and API"
}
]
}Space Bus reads a spacebus.json roster from the workspace directory (the directory OpenCode was launched in).
Fields:
server.baseUrl— must resolve to localhost (127.0.0.1,::1, orlocalhost); non-local hosts are refused so bus credentials never leave the machine.projects[].name— identifier passed tobus_task'sprojectargument.projects[].path— filesystem path to the project; supports~expansion.projects[].description— shown inbus_rosteroutput.
Set SPACE_BUS_CONFIG to override roster discovery — it must be an absolute path or start with ~ (URLs and bare-relative paths are rejected). The roster is read fresh on every tool call — no caching, so edits apply immediately.
bus_roster— List the space-bus manifest projects with live session status per project.bus_task— Dispatch a prompt to an agent in the given space-bus manifest project, or steer an existing session by passingsessionId(answers its pending question, else sends a follow-up prompt). Returns immediately; does not wait for completion. Alongside the formatted text, results carry structured{sessionId, project, mode}metadata — pluginToolResult.metadataon the tool surface, MCPstructuredContenton the stdio surface — for callers that want the dispatch outcome without parsing the string.bus_status— Report a space-bus session's status plus a summary of its latest todo and diff. Also reports when the session is blocked on an interactive question awaiting a reply.bus_result— Return a completed space-bus session's final assistant message and diff. Errors if the session is still running — usebus_statusto check first.bus_wait— Block until any of the given sessions needs attention (completes, blocks on a question, fails, or is not found) or a bounded timeout elapses. Returns each watched session's normalized state and which session(s) woke the wait; on timeout returns the current snapshot, not an error.
Two roster modes for server, mutually exclusive:
server.baseUrl— externally-managed, attach-only. Today's default behavior: you startopencode serve/harness serveyourself; the plugin just connects.server.managed— plugin-managed lifecycle. The plugin spawns and supervises the server for you.
{
"server": {
"managed": {
"command": ["harness", "serve"],
"cwd": "~/src/my-workspace",
"port": 0
}
},
"projects": [{ "name": "my-project", "path": "~/src/my-project" }]
}command, cwd, and port are all optional (defaults: harness serve — set managed.command for a custom command such as opencode serve — the roster's directory, and an ephemeral port, respectively).
First-caller-spawns: whichever consumer touches the managed roster first spawns the server on demand — a generated password and a 0600 discovery file land under the state dir ($XDG_STATE_HOME|~/.local/state/space-bus/<hash>/discovery.json), every subsequent caller attaches to the same instance. It's a persistent daemon, not a request-scoped process — it outlives the caller and there's no auto-restart if it dies; the next ensure call notices the stale discovery file and heals by spawning fresh.
CLI (space-bus, wraps the same lifecycle):
space-bus serve [--foreground] [--json] [--config <path>]
space-bus status [--json] [--config <path>]
space-bus stop [--json] [--config <path>]MCP is attach-only by default — it never spawns. Set SPACE_BUS_MCP_SPAWN=1 in the MCP server's env to opt it into ensuring a managed roster's server on startup.
Security posture: each spawn gets a freshly generated password (never reused, never in argv, never logged); the loopback guard travels with the discovery handshake, so an attached endpoint is re-validated as localhost-only regardless of source. Same-user process compromise is out of scope — anyone who can read your state dir or ptrace the child already has what they need.
Programmatic consumers (e.g. Mothership) that want to drive the lifecycle directly without the CLI can import @fro.bot/space-bus/managed-server (Node-only; ensureServer/serverStatus/stopServer).
Reboot-persistent daemon on top of the managed server: registers a per-user launchd agent that wraps serve --foreground, so the roster's server survives host reboots and crashes without a live operator noticing it died.
space-bus service install [--json] [--config <path>] install the launchd agent, start it now
space-bus service uninstall [--json] [--config <path>] bootout the job and remove the plist
space-bus service status [--json] [--config <path>] installed / loaded / running, with pid
space-bus service stop [--json] [--config <path>] bootout the loaded job (plist stays)
space-bus service start [--json] [--config <path>] bootstrap + kickstart the jobSemantics:
- Starts at login, not boot — it's a
gui/$UIDlaunchd agent, not a system daemon. - Restarts only on abnormal exit (
KeepAlive.SuccessfulExit=false), consuming the existingserve --foregroundexit-code contract (0 = deliberate stop, no restart; 1 = death, restart). Restarts are throttled to once per 10s to avoid a crash loop. service stopderegisters the job (bootout) but leaves the plist in place;service startre-registers it (bootstrap+kickstart). A stopped service comes back automatically at the next login (RunAtLoad) unless youservice startit back sooner — there's no persistent "disabled" latch in v1.- Logs land at the roster's state dir:
service.log(stdout) /service.err.log(stderr), created 0600. - Upgrades: the plist pins absolute paths to the runtime and CLI entry resolved at install time. After a version bump (or if you moved/reinstalled the runtime), re-run
space-bus service install— it's idempotent and refreshes the pinned paths. - macOS only in v1; every verb fails fast with an explicit error on other platforms.
install/uninstallare explicit operator actions only — nothing auto-installs the service on your behalf.
Experimental subpath exports expose the bus's internals directly — the functions the four tools run on, plus the managed-server lifecycle and the attach resolver — for renderers and other consumers that want structured state instead of formatted strings. Subpaths are experimental: shapes may change in minor releases; pin the version if you adopt them.
@fro.bot/space-bus/core— browser-safe; no Node builtins, no ambient env reads. Every exported function takes acontext: BusContext({ roster, credentials? }) that you build yourself and pass in — core never resolves it for you. Includessnapshot(), a one-call composite of roster + per-project status + pending questions with bounded fan-out.@fro.bot/space-bus/config— Node-only.loadContext(directory?)readsspacebus.json(honoringSPACE_BUS_CONFIGthe same as the plugin) and returns a ready-to-useBusContext, with per-projectexistsflags and env-derived credentials attached. Build a fresh context per call; it's per-call/short-lived by contract, not meant to be cached across filesystem changes.@fro.bot/space-bus/contract— the zod schemas (and inferred types) behind the OpenCode API andBusContext, for consumers hitting the server directly and wanting the same shapes. Schemas are zod v4.@fro.bot/space-bus/format— the pure formatters the tools use to render output, for tool-identical text.@fro.bot/space-bus/managed-server— Node-only. The managed-server lifecycle (ensureServer/serverStatus/stopServer) for consumers driving spawn/attach/stop directly — see Managed server.@fro.bot/space-bus/attach— browser-safe.resolveManagedServer(workspaceDir, seams)resolves a managed roster's running endpoint by reading the discovery file through injected filesystem/env/crypto seams — re-checking the localhost guard and probing liveness — so an external attacher such as a Tauri webview can attach without anynode:*imports or reimplementing the discovery contract. Returns{ baseUrl, credentials, alive }or an actionable error.
Node example (/config + /core):
import { loadContext } from "@fro.bot/space-bus/config";
import { snapshot } from "@fro.bot/space-bus/core";
const context = loadContext(); // resolves spacebus.json from SPACE_BUS_CONFIG or a directory you pass — no cwd fallback
const result = await snapshot({ context });
if (result.ok) {
for (const project of result.projects) {
console.log(project.name, project.exists);
}
}Browser consumers: /core, /contract, /format, and /attach are browser-safe (their module graphs are bundle-tested in CI to exclude node:* imports and never reach /config). /config is Node-only — browser code can't call loadContext, so credentials must be injected explicitly by whatever server-side process builds the BusContext (never read ambiently by core). The localhost guard travels with the context: it's re-checked at core's single validation gate on every call, so a context built from a non-local baseUrl is rejected there, not just at config's load time.
{
"mcpServers": {
"space-bus": {
"command": "bunx",
"args": ["--package=@fro.bot/space-bus", "space-bus-mcp"],
"env": {
"SPACE_BUS_CONFIG": "/absolute/path/to/spacebus.json"
}
}
}
}Requires opencode serve/harness serve already running on the roster's baseUrl.
bun install
bun run fixture # generates gitignored fixtures/dev-workspace/ (opencode.json + spacebus.json)
harness serve --port 4096 & # or: opencode serve --port 4096
opencode --dir fixtures/dev-workspace # or open that directory directly
bun run smoke # canary: directory-routing isolation against the live server
bun run test
bun run typecheck
bun run lint
bun run dev # watch build to dist/@opencode-ai/* versions are pinned lockstep with the OpenCode CLI. Upgrade both together. Set OPENCODE_SERVER_PASSWORD (and optionally OPENCODE_SERVER_USERNAME) to enable HTTP Basic auth on every bus request.
- The session store is global across directory headers:
GET /session/{id}resolves regardless of which project directory is sent. The bus attributes a session to its owning project via the session's owndirectoryfield, not the probe header.GET /session(list) and/session/statusare directory-scoped. - Upstream opencode #30127 (v1.16.0) zeroes session-level diff summaries, so
GET /session/{id}/diffalways returns[]. Per-turn diffs on user messages (GET /session/{id}/message) stay intact and include untracked files, sobus_status/bus_resultaggregate those instead (last turn wins per file, à la upstream PR #33444). Harness builds ≥1.17.13+harness.ee55e157carry #33444 directly, soGET /session/{id}'ssummary.diffsfield is populated and serves diffs without per-turn aggregation (still labeleddiffSource: "session"); stock binaries leave it empty and fall through to per-turn aggregation.GET /vcs/statusremains a last-ditch repo-wide fallback, labeled working tree. /session/statuscan report a session idle a beat before its final message is queryable;scripts/smoke.tsabsorbs this with a bounded retry on the message fetch.- Dogfooding surfaced a need for a steering path that isn't raw API — delegates block on interactive questions. That steering path ended up as an optional
sessionIdonbus_taskrather than a fifth tool: passing it answers a pending question or sends a follow-up prompt on an existing session.bus_statusalso surfaces pending interactive questions (pendingQuestion/ ablocked:line) so a blocked delegate isn't mistaken for one actively working.
PRs land a changeset via bunx changeset. On merge to main, CI opens a version PR; merging that PR publishes to npm through trusted publishing (OIDC) — no NPM_TOKEN involved.
Part of the Fro Bot ecosystem