The Fleet Control Plane runs coding workers (OpenCode v2 servers) in isolated Git / jj worktrees, lets a human steer a long-lived Director session from a chat sidebar, and gates every worker diff...
Overview
The Fleet Control Plane runs coding workers (OpenCode v2 servers) in isolated Git / jj worktrees, lets a human steer a long-lived Director session from a chat sidebar, and gates every worker diff behind an Ed25519 signature from a pinned human key. One dark-mode WebUI shows the whole fleet through five UX pillars. Requirements: Bun 1.4.2, Git, and the opencode CLI (v2, opencode serve) on PATH with a provider configured. Optional: a NATS server with JetStream. Dispatcher flags (also documented in the header of dispatcher/src/main.ts): 1. Messages go to one long-lived OpenCode session (agent director) started in the repository with its own isolated OpenCode database. The reply is shown in chat. Cards are created only from a fenced block the Director emits, validated with zod (strict; unknown fields, bad deps, or invalid JSON create nothing and are reported in chat): tasks [{"key":"api","title":"Query API","objective":"…","paths":["src/api/"],"acceptance":["…
README
Agency Control Plane — Fleet Dispatcher Daemon + High-Fidelity WebUI
The Fleet Control Plane runs coding workers (OpenCode v2 servers) in
isolated Git / jj worktrees, lets a human steer a long-lived Director
session from a chat sidebar, and gates every worker diff behind an
Ed25519 signature from a pinned human key. One dark-mode WebUI shows the
whole fleet through five UX pillars.
Run it
Requirements: Bun 1.4.2, Git, and the opencode CLI (v2, opencode serve)
on PATH with a provider configured. Optional: a NATS server with
JetStream.
bun install --frozen-lockfile --ignore-scripts
# 1. The human approval key (once). Same format as epistemic-factory es-core:
# human.key.enc (scrypt + AES-256-GCM sealed Ed25519 key, 0600) + human.pub.
bun run keygen -- --out ~/.local/state/agency-control-plane
# Already have an es-core key? Point the dispatcher at it instead:
# --human-pub /path/to/human.pub (or put human.pub in the state dir).
# 2. Build the WebUI and start the dispatcher on the repository to work on.
bun run build:web
bun run dispatcher -- --repo /path/to/repo --port 7600
# (or `bun run serve -- --repo ...` to build and start in one go)
# 3. Open http://127.0.0.1:7600 — the UI and /api share one loopback origin.
Dispatcher flags (also documented in the header of dispatcher/src/main.ts):
| Flag / env | Default | Meaning |
|---|---|---|
--repo / FLEET_REPO |
cwd | Repository the fleet works on |
--state-dir / FLEET_STATE_DIR |
~/.local/state/agency-control-plane |
SQLite board + worktree register (dispatcher.db, WAL, busy_timeout=15000), worktrees/, audit.jsonl, human.pub |
--port / FLEET_PORT |
7600 |
Loopback port (never binds anything but 127.0.0.1) |
--human-pub |
/human.pub |
Pinned human public key. Missing or invalid → approvals are disabled and the approval routes answer 503 (fail closed) |
--worker-cmd / FLEET_WORKER_CMD |
opencode serve --port 0 --hostname 127.0.0.1 |
Worker server command (JSON array or words) |
--director-cmd / FLEET_DIRECTOR_CMD |
worker command | Director server command |
--director-agent |
director |
OpenCode agent of the Director session (define it in your OpenCode config) |
--task-agent |
server default | OpenCode agent for task sessions |
--fleet-cap |
4 |
Max concurrent workers (scheduler cap) |
--per-expert-cap |
fleet cap | Max concurrent workers per expert when its role pack sets no maxConcurrent |
--roles-dir / FLEET_ROLES_DIR |
unset | Role packs for expert tasks (see Role packs below) |
--tier-models / FLEET_TIER_MODELS |
unset | JSON {"tier-1-opencode": "provider/model", "tier-2-commercial": …, "tier-3-local": …}. New worker sessions run on the active tier’s model; unset tiers use the worker’s default model |
--task-timeout-ms |
30 min | Per-task worker timeout |
--no-workers, --no-director |
Disable the launcher / Director | |
NATS_URL |
unset | Mirror events + board into NATS JetStream / KV (start fails if set but unreachable) |
How work flows
-
Director chat. Messages go to one long-lived OpenCode session (agent
director) started in the repository with its own isolated OpenCode database. The reply is shown in chat. Cards are created only from a fenced block the Director emits, validated with zod (strict; unknown fields, bad deps, or invalid JSON create nothing and are reported in chat):```tasks [{"key":"api","title":"Query API","objective":"…","paths":["src/api/*"],"acceptance":["…"]}, {"key":"docs","title":"Docs","deps":["api"],"paths":["docs/*"]}] ```/shipis the explicit manual fallback: each bullet of the spec becomes one card, no Director involved. -
Scheduler + launcher. On every board change the pure scheduler (deps,
all_success/one_success, path-overlap serialization, fleet / per-expert caps) picks tasks to start. For each one the launcher allocates the worktree (fleet/branch, two-phase crash-safe register), pins the base commit, spawns the worker command in it withOPENCODE_DB=/.opencode/opencode.dband a randomOPENCODE_SERVER_PASSWORD, parsesserver listening on, creates a session (POST /api/session), sends the task prompt (POST /api/session/:id/prompt), and followsGET /api/event:session.tool.*become the drill-down live tool log,session.status/session.text.endedare republished on the dispatcher bus,session.execution.succeeded|failed|interrupted(or the process exiting / timing out) ends the run. Successful runs are committed onto the task branch (never the.opencodestate); a non-empty diff moves the card to Waiting Approval / QA, an empty one to Done. Failures follow the task’s retry policy, except quota-shaped provider errors (limits.ts): those move the fleet to the next model tier and re-queue the task without spending an attempt; quota ontier-3-localholds new launches until the provider’s retry time. Shutdown (SIGINT/SIGTERM) kills every worker and re-queues interrupted tasks. -
Approval. The human opens the card, checks the diff, and mints a ticket. The server computes
subjectHash = sha256(concatenated per-file patches of base...fleet/)and returns the exact payload to sign: canonical JSON of{ runId: , stage: "APPROVE" | "REJECT", subjectHash, timestamp }— the same payload shape and key format as es-coresignStageApproval/verifyStageApproval, so one human key serves both systems (the disjoint stage names stop a record for one system being replayed on the other). The browser recomputes the hash from the diff it displays and refuses to sign on mismatch, unlocks the chosenhuman.key.enc(scrypt via@noble/hashes, AES-GCM + Ed25519 via WebCrypto) or a PKCS#8 key file, checks the signature against the pinned key, and sends only{ ticket, approve, subjectHash, signatureHex }. The dispatcher burns the single-use 60 s ticket, recomputes the diff hash, and verifies against the pinnedhuman.pubonly (any client key or passphrase is ignored). APPROVE lands the task:fleet/is merged--no-ffintofleet/integration/in a throwaway worktree (landing.ts), the task moves todonewith itslandedSha. A merge conflict leaves the integration branch untouched and the task in Waiting Approval / QA (409, conflicting files in the response and the task reason); resolve on the task branch and approve again. REJECT →failed. The full es-core-shaped signed record goes into the hash-chained audit ledger. -
Worktree release. When a task reaches
done,failed, orcancelledand no worker runs in it, its worktree is removed (the branch stays). Branches offailed/cancelledtasks are then anchored atrefs/fleet-salvage/and deleted, so no work is lost. A restart releases worktrees of tasks that finished while the dispatcher was down. -
Persistence. Tasks, missions, chat, counters and per-task tool logs live in
/dispatcher.db; a restart resumes the board (tasks that were running are re-queued). -
NATS (optional). With
NATS_URL, every bus event is published to JetStream subjectfleet.events.on streamFLEET_EVENTS(drop-oldest at 10 000 messages) and every task document is mirrored to KV bucketfleet-board([email protected]). The in-process ring buffer remains the SSE source of truth.
Role packs (--roles-dir)
A task’s expert names a role pack at //, laid out
as an OpenCode config home:
//
role.json optional: {"maxConcurrent": 2}
opencode/opencode.json copied (not symlinked) into the worktree .opencode/
opencode/agent/.md role prompt, frontmatter stripped, put in the task prompt
opencode/skill//SKILL.md listed in the task prompt
The worker runs with XDG_CONFIG_HOME=/, so OpenCode
also picks the role’s agents and skills up as its global config.
agents/ and skills/ work as plural aliases. Only slug role ids
resolve; an expert without a pack runs with the generic prompt and a
role.missing event.
Control API (dispatcher/src/server.ts, Bun HTTP + SSE, 127.0.0.1 only)
| Method & Path | Purpose |
|---|---|
GET /api/board |
Columns, tasks, worktrees, tier, DAG edges, chat, scheduler plan, approval/Director availability. |
POST /api/director/message |
/ship → cards now (200); otherwise → Director turn (202; reply + cards arrive as director.message). 503 without a Director. |
GET /api/tasks/:id/drilldown |
Session, stage gates, tool log, diff hunks, subjectHash. |
POST /api/approvals/:taskId/ticket |
Single-use 60 s ticket; server-computed subjectHash, payloads to sign, pinned key fingerprint. 503 without a pinned key, 409 unless waitingHuman. |
POST /api/approvals/:taskId/sign |
Burn ticket, re-hash diff, verify Ed25519 against the pinned key; APPROVE lands the task (done + landedSha, or 409 landing-failed with conflicting files and the task kept waitingHuman), REJECT → failed. |
GET /api/events |
SSE (ring-buffered, Last-Event-ID replay, gap markers). |
GET /api/csrf |
CSRF token for POSTs. |
GET /* |
The built WebUI (web/dist) with a strict same-origin CSP. |
Layout
dispatcher/src/—model.ts,wildcard.ts,scheduler.ts,limits.ts,audit.ts,worktrees.ts,landing.ts,event-bus.ts,approval-tickets.ts,human-key.ts(es-core-compatible pinned key),board-store.ts(SQLite board),opencode-client.ts(worker process + OpenCode v2 HTTP client behindWorkerApi),worker-session.ts,worker-launcher.ts,roles.ts(role packs),director.ts,nats-bridge.ts,server.ts,main.ts(daemon),keygen.ts.dispatcher/test/fixtures/fake-opencode.ts— fakeopencode serveimplementing the routes above, used by the launcher/Director tests and the browser smoke test.fixtures/es-core-vector/— a key + record produced by es-core itself.web/— React UI;web/src/main.tsxis the browser entry,scripts/build-web.tsbundlesweb/index.htmlintoweb/dist.scripts/accept/wave-{1..4}.sh,scripts/e2e/ui-smoke.ts(Chromium viaplaywright-core; screenshot indocs/evidence/ui.png).
Checks
make check # install, typecheck, bun test
make accept # waves 1-4; wave 4 builds the UI and runs the Chromium smoke test
The NATS integration test uses NATS_URL or spawns nats-server from
PATH / ~/go/bin (go install github.com/nats-io/nats-server/[email protected]);
without either it is skipped. The UI smoke test needs Chromium for
[email protected] (PLAYWRIGHT_BROWSERS_PATH, or
./node_modules/.bin/playwright-core install chromium). CI
(.github/workflows/ci.yml) installs both and runs make check and
make accept.
Security Invariant I1 (binding)
The passphrase and key-file bytes are read from uncontrolled inputs at
submit time and live only in local variables of approveWithKeyFile
(web/src/security/approval-client.ts) for the duration of the sign
request; byte buffers (file, scrypt output, decrypted PKCS#8) are
zero-filled and every reference nulled in finally, the signing key is a
non-extractable CryptoKey, and nothing reaches fleet-store,
localStorage, sessionStorage, or DOM attributes. Proof:
web/test/approval-invariant.test.ts, the I1 section of
scripts/accept/wave-3.sh, and the in-browser sink check of
scripts/e2e/ui-smoke.ts.
Recommended Tools
Try a different keyword or remove a filter.
Install
npx skillfish add heretek-ai/agency-control-plane