Instructions for AI agents (OpenCode, Claude, etc.) operating in this repository.
opencode-fleet is an MCP server written in TypeScript. It allows a master OpenCode instance to coordinate multiple remote OpenCode nodes by exposing fleet_* MCP tools. The master agent calls these tools; the fleet server translates them into OpenCode REST API calls on each remote node.
src/
config.ts — CLI argument parsing → FleetConfig
node.ts — HTTP client for one remote OpenCode node (REST + persistent SSE)
session.ts — Per-node session lifecycle (create, reuse, reset)
tools.ts — MCP tool definitions and handlers
index.ts — MCP Server stdio entry point
dist/ — Compiled JS output (tsc, gitignored)
tests/
node.test.ts — Unit tests (mocked fetch, 17 tests)
tools.test.ts — Unit tests (mocked SessionManager, 9 tests)
e2e/
helpers/
env.ts — Env var reading, skipIf guards, prompt factories
harness.ts — before/after hooks for session cleanup
node.e2e.ts — Live-node tests: ping, sessions, messages, waitForIdle, getSessionStatus
session.e2e.ts — SessionManager live tests: lazy create, reuse, 404 rebuild, timeout
tools.e2e.ts — All 11 fleet_* tool handlers against live nodes + dual-node tests
vitest.e2e.config.ts — E2E vitest config (60s timeout, forks, verbose)
.env.e2e.example — Template for E2E environment variables
examples/
opencode.json — Sample master opencode.json with fleet MCP config
npm run build # tsc, output → dist/Run automated tests with:
npm test # vitest, 26 unit tests (zero dependency, mocked HTTP)
npm run test:e2e # vitest, 33 live tests (requires a running opencode slave node)E2E tests require environment variables — see .env.e2e.example. If no nodes are configured, all E2E tests are skipped gracefully.
The status tracking mirrors the OpenCode desktop architecture (packages/app/src/context/server-sdk.tsx):
- Persistent connection: One
GET /eventSSE stream is opened perOpenCodeNodeinstance on construction (active start, not lazy). The connection is shared by all callers — no new HTTP request perwaitForIdle()call. - Local status cache: Every
session.statusevent writes intostatusCache: Map<sessionID, SessionStatus>.getSessionStatus()reads this cache — O(1), zero network. This mirrors the desktop'ssession_statusstore. - Idle waiters:
waitForIdle(sessionId, timeoutMs)registers a one-shot callback inidleWaiters. The moment the shared SSE stream emitssession.status: idlefor that session, the waiter resolves. No race condition — all callers share the same stream. - Heartbeat & reconnect: 15s silence triggers an abort + reconnect with fixed 250ms delay — identical constants to the desktop's
HEARTBEAT_TIMEOUT_MSandRECONNECT_DELAY_MS. - Optimistic busy:
sendPromptAsync()immediately writes{ type: "busy" }to the cache after the HTTP call returns, before the first SSE event arrives. This eliminates the race window between sending a prompt and the SSE event propagating — mirroringsubmit.ts:60in the desktop. - Destroy:
node.destroy()aborts the SSE stream and rejects all pending waiters.
Why not /api/session/active: The desktop client never uses this endpoint — it relies entirely on SSE events. Our implementation now does the same. The /api/session/active endpoint was removed from getSessionStatus() after testing showed it is unreliable across opencode deployments.
One session per node, created lazily on the first send(). The session ID is cached in a Map<nodeName, sessionId>. If sendPromptAsync returns 404, the session is recreated automatically and the prompt is retried once. fleet_reset_session clears the cache entry manually.
HTTP Basic Auth using --password / --username CLI args or FLEET_PASSWORD / FLEET_USERNAME env vars. The auth header is constructed in the OpenCodeNode constructor and attached to every request.
| Endpoint | Purpose |
|---|---|
GET /global/health |
Health check / ping |
GET /session |
List sessions (no /api prefix — returns bare array) |
POST /session |
Create session |
DELETE /session/:id |
Delete session |
GET /api/model |
List available models |
POST /session/:id/prompt_async |
Send prompt (non-blocking, returns 204) |
GET /session/:id/message?limit=N |
Fetch newest N messages (ascending order; no /api prefix) |
GET /event |
SSE stream — persistent, shared by all callers on the same OpenCodeNode instance |
POST /api/session/:id/interrupt |
Interrupt a running session |
Important path quirks (confirmed against opencode 1.18.4):
- Endpoints with
/apiprefix return{data: [...], cursor: {...}}wrapped responses - Endpoints without
/apiprefix return bare arrays/objects (legacy format) GET /session/:id/messagereturns newest N messages in ascending order (oldest-in-slice first, newest at index N-1)
GET /event emits JSON payloads in data: lines. The event shape:
{
"id": "evt_xxx",
"type": "session.status",
"properties": {
"sessionID": "ses_xxx",
"status": { "type": "busy" } // or { "type": "idle" }
}
}Both properties (legacy GET /event) and data (v2 GET /api/event) field names are checked in applyEvent(). The deprecated session.idle event type is also handled.
Returns MessageWithParts[] — each element is { info: Message, parts: Part[] }, not a flat Message[]. The info field carries role/error; parts carries TextPart, StepStartPart, StepFinishPart, ToolPart, and other variants.
- Add a new tool definition object to the
TOOL_DEFINITIONSarray insrc/tools.ts. - Add a handler function (
handleMyTool) in the same file. - Add a
case "my_tool":branch indispatchTool(). - Add a unit test in
tests/tools.test.ts(mockedFleetContext). - Add an E2E test in
tests/e2e/tools.e2e.ts(against a live node). - Run
npm run buildto verify types.
This section is directed at AI agents (Claude, OpenCode) acting as the master in a fleet session.
A fleet_send_message call blocks until the slave becomes idle or a timeout fires. A timeout is not a failure — it means the slave is still working and simply did not finish within the allotted window. The slave session is still alive and should not be discarded.
The tool itself encodes this guidance in its return value. When fleet_send_message returns status: "timeout_still_busy", follow the hint field — it will say to wait and check status, not to reset.
The tool returns structured status so you do not need to infer intent from raw text:
status field |
Meaning | What to do |
|---|---|---|
completed |
Slave finished, reply is ready | Use the reply |
completed + empty reply |
Agent was mid-step (tool call in progress) | Check fleet_get_session_messages for context |
timeout_still_busy |
Slave is still working, timeout elapsed | Wait, then call fleet_get_session_status — do NOT reset |
queued |
Slave was busy, message has been queued | Wait for current task to finish before sending more |
error |
Slave returned an error | Inspect reply, decide to retry or fix |
- Timeout means busy, not broken. When
statusistimeout_still_busy, the slave is still working. Wait. - Escalate to human if the slave is stuck and you cannot determine why after two attempts. Do not loop indefinitely. The tool will set
escalate_hint: truewhen this threshold is reached.
| Situation | Tool to use |
|---|---|
| Understand what a node can do before dispatching | fleet_describe_node |
| Check if slave finished | fleet_get_session_status |
| See what slave did / is doing | fleet_get_session_messages |
| Stop a running task (keep session) | fleet_interrupt_session |
| Stop a task after master restart (cache lost) | fleet_list_sessions to find session ID, then fleet_interrupt_session with session_id |
| Discard session and start fresh | fleet_reset_session (last resort, only when session is idle) |
- Persistent SSE, not per-call: The
StatusStreamis opened once perOpenCodeNodeinstance and shared.waitForIdle()registers a waiter on the shared stream rather than opening a new HTTP connection. - SSE stream is global:
GET /eventemits events for all sessions on the node. Always filter bysessionIDbefore acting on an event. - Bootstrap: No separate bootstrap phase needed — the SSE stream receives
session.status: busyevents the moment any prompt starts executing. Combined with the optimistic busy write insendPromptAsync(), there is no time window wheregetSessionStatus()returns a staleidle. - Node 18+ required: The implementation uses native
fetchwithReadableStream. Do not polyfill or replace withnode-fetch. destroy()required: In long-running processes, callnode.destroy()when a node is no longer needed to close the SSE connection and prevent resource leaks. The MCP server entry point (index.ts) does not call destroy — fine for short-lived stdio servers. Tests and long-lived apps should.- Testing: Unit tests use
vitestmocking;getSessionStatustests inject status directly viainjectStatusForTesting(). E2E tests hit live opencode slave nodes and requireE2E_NODE_*environment variables — skipped silently if unset. - Dual-node E2E tests: The concurrent send and cross-node isolation tests require both nodes configured. They use
describe.skipIf(skipIfNotBothNodes).