Imported from orkestrator-ai/orkestrator-v2 (
AGENTS.md). Install upstream withnpx skills add orkestrator-ai/orkestrator-v2. Copyright stays with the author.
Agent Instructions
.
This file provides specific guidance for AI agents working on this codebase.
The living documentation catalog is docs/README.md.
Orkestrator AI is an Electron desktop application for managing isolated Docker-based and local-worktree development environments for Claude Code, Codex, OpenCode, Cursor Agent, Grok Build, and Pi.
Read before changing these areas
This file holds only the rules that apply everywhere. Area-specific rules live next to the code. Read the matching file before editing in that area; Claude Code loads them automatically when it reads a file there, other agents may not.
| Area | Read first |
|---|---|
Any bridge, or backend code that calls bridge routes (http-bridge-provider*, bridge-session-close.ts, the activity sweep) |
bridges/AGENTS.md |
bridges/claude-bridge/, the coordinator shell allowlist |
bridges/claude-bridge/AGENTS.md |
bridges/codex-bridge/ |
bridges/codex-bridge/AGENTS.md |
bridges/pi-bridge/ |
bridges/pi-bridge/AGENTS.md |
bridges/cursor-bridge/ |
bridges/cursor-bridge/AGENTS.md |
bridges/acp-bridge/ |
bridges/acp-bridge/AGENTS.md |
docker/, container lifecycle, firewall, Playwright in containers |
docker/AGENTS.md |
OpenCode provider, SDK pin or patches/ |
docs/architecture/agent-engines.md |
Coordinator qualification (coordinator-providers.ts) |
docs/architecture/coordinator.md |
| Slash-command discovery or dispatch | docs/architecture/native-agent-commands.md |
| Agent SDK/CLI version bumps | docs/development/upgrade-agents.md |
| Rendered UI or browser-visible behavior | docs/development/agent-testing.md |
Writing or debugging tests, mock.module() |
docs/development/testing-guide.md |
| Lint findings and disable directives | docs/development/linting.md |
Main Branch and Pull Request Policy
All changes to main must be integrated through a pull request. Agents must
never commit or push directly to main, and must not merge, squash, or rebase a
pull request into main themselves. Agents may prepare a feature branch, push
that branch explicitly, and open a pull request for review; the final merge into
main must be left to a human maintainer.
Before pushing, verify both the current branch and its configured upstream. If
either operation would update main directly, stop and correct the branch or
upstream configuration instead of pushing.
Background Work and Transport Invariants
Environments keep doing work while another environment is active in the UI. Do not assume the active React tree is mounted, subscribed to events, or able to receive every Electron IPC/SSE/tmux update. When changing background behavior (agent sessions, tmux, terminals, local servers, Docker, file watchers, PR monitoring, build pipelines) or gateway, bridge, terminal, streaming, replay, compression, or synchronization code, preserve these invariants:
- Long-running state lives in the backend, bridge, persistent store, or external process, not only in mounted React state. Foreground components rehydrate from an authoritative snapshot when they mount or become active.
- A component unmount or inactive environment does not stop background work. Unmount means "not visible", not "cancel"; tie cleanup to an explicit stop.
- Live events are incremental updates over authoritative snapshots, never the only source of truth.
- Every missed event is detectable through a revision gap, generation change, expired cursor, or explicit reconciliation frame.
- Terminal output may be dropped only under bounded backpressure, with an explicit desync signal and exact snapshot recovery.
- Authoritative state events must not be silently dropped.
- Replay subscribes before it calculates and flushes the replay range.
- A connected SSE frame echoes the client's cursor; it must not jump the client to the latest server revision before replay completes.
- Codex app-server's stdout loop never awaits rendering, SSE writes, browser work, or other consumers.
- Approval timeout, disconnect, malformed answers, and generation death deny rather than approve.
- Every queue, replay ring, decoded request, rewritten response, and compression buffer has explicit byte and count bounds.
- Metrics and logs never contain prompts, terminal contents, file contents, credentials, tokens, or attachment data.
- Closing a tab never deletes the conversation. Never call Claude's
DELETE /session/:idor SDKdeleteSession, Codexthread/delete, or OpenCodeclient.session.deletefrom a close path. - A transcript row is one model response (one step of the agent loop),
never a whole turn. A turn kept alive by background work runs for hours,
and a multi-megabyte row fits no transcript window: readers get only a
trimmed copy and lose its start once newer rows push it out of the live
tail. Late updates find the row holding their call, a changed row outside
the live tail still reaches readers (
revisedMessages), and everything that leaves the live tail stays reachable through history paging. Seebridges/AGENTS.md("Transcript rows"); each bridge'sAGENTS.mdnames its row boundary.
Test the inactive-environment path: start work, switch away, let it progress or finish, return, and verify status, messages, pending prompts and controls.
Unhandled Rejections Are Fatal
Under Bun an unhandled promise rejection terminates the process once the entry
module has finished evaluating. The backend and all five bridges install
installFatalRejectionGuard (packages/protocol/src/fatal-rejections.ts) at
startup; install it in any new long-lived entrypoint. It is inert under
bun test (NODE_ENV=test) so tests still fail on unhandled rejections.
The guard is a floor, not a licence: a backend exit takes Local offline for the
session. When aborting an AbortController whose signal is shared with
in-flight work, check that every consumer of that signal has a rejection
handler. A guard log line every turn is still a bug.
Stack and Layout
React 19, TypeScript, Tailwind CSS v4, shadcn/ui and Zustand in apps/web;
Electron in apps/desktop; the standalone Bun backend in apps/backend
(src/core/); shared contracts in packages/protocol; the pinned agent toolchain
(manifest, installer) in packages/toolchain; the published
orkestrator CLI in packages/cli; native-mode bridge servers in bridges/;
the container image in docker/.
- UI uses shadcn/ui (
apps/web/src/components/ui/) and Tailwind CSS v4. Check for an existing component first. - Zustand for global state (
apps/web/src/stores/), React Context for component-tree state (apps/web/src/contexts/). Stores use theMap<string, T>pattern for per-environment/per-session state. - Register backend commands in
apps/backend/src/core/commands.tsthroughcreateCommandRegistry(), using the existingCommandContextandStorageServicepatterns rather than renderer-only state. - Desktop
BrowserWindowpreloads are bundled as ESM, so the main window and toolchain bootstrap windows keepsandbox: false. Browser preview views stay sandboxed. - Application data lives in
~/Library/Application Support/orkestrator-v2/(macOS) or${XDG_CONFIG_HOME:-~/.config}/orkestrator-v2/(Linux).
Splitting large classes
Split a large class by responsibility into collaborators (modules or small
classes that take a shared context object), not into extends layers. A class
cut into -base / -core / -extra files chained by extends and held
together by abstract forward declarations is still one class, just harder to
read. tests/unit/inheritance-chains.test.ts fails on any new project-class
inheritance; a deliberate one-level subclass (an error refinement, a hook
override) goes in its INTENTIONAL_SUBCLASSES list with a reason.
OpenCode SDK v2 - CRITICAL
Always use v2 of the @opencode-ai/sdk package.
// CORRECT - v2 API
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2/client";
// WRONG - v1 API (different parameter structure, missing features)
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
The SDK is patched (patches/). Read the OpenCode maintenance notes in
docs/architecture/agent-engines.md before bumping it.
Task Runner - mise; Package Manager - Bun
Use mise for repository-level tasks and Bun for package management and focused commands. Never use npm or yarn.
bun install # NOT npm install
mise run <task> # Repository-level task; NOT npm run
mise run test # Complete suite; NOT bare root-level `bun test`
bun test <explicit-path> # Focused Bun test-runner invocation only
bunx <package> # NOT npx
bun <file> # NOT node <file>
mise run dev # Run the Electron application
mise run build # Build for production
Bun automatically loads .env files. At the repository root, bare bun test
performs direct discovery and can collect Playwright/E2E specifications and
unrelated fixtures, so it is never a substitute for mise run test.
Before selecting or diagnosing a test workflow, read
docs/development/testing-guide.md. It is
the source of truth for suite scope, changed-only testing, concurrency, caching,
watchdogs, leases, failure artifacts, and escalation to browser, agent, Docker,
or iOS validation.
Choosing validation for a change
Review preparation (the Build pipeline's package preparation and Multi Review) reads this file to decide which checks a change needs. Scale the plan to what the changed paths can affect instead of always running every suite:
- Documentation-only changes (
*.md,docs/**,plans/**) need no test suite:mise run format:checkis enough. - Every change to code or configuration runs
mise run format:check,mise run lint, andmise run typecheck. - Code changes under
apps/,bridges/,packages/,scripts/, ortests/runmise run test(the complete non-iOS suite). - Treat dependency, lockfile,
mise.toml, Turbo, test-runner, orpackages/protocolchanges as wide-reaching even when the diff is small; they always needmise run test. - Add a scenario suite from the testing guide's Verification by change type
table only when the change touches that area: browser-visible renderer flows
(
mise run test:browser, ormise run test:agent:browser:isolatedfor real-stack flows), the design canvas (mise run test:agent:design:isolated), Electron main/preload/IPC (mise run test:agent:electron), Docker lifecycle (mise run test:agent:docker), the published CLI (mise run test:cli:scenarios), and iOS (mise run test:all, macOS only). Omit them otherwise and say so in one limitation.
Running tests
Run every test, typecheck, build verification, or smoke suite through
test:logged, one command per suite so each exit status maps to one check. It
streams output to a private bounded file, preserves the exit status, deletes
passing output, and compresses failing evidence. Do not add a second tee.
mise run test:changed # Fast affected-only feedback; not final handoff proof.
mise run test:logged -- --name root-tests -- bun test ./tests --parallel=4 --only-failures
mise run test:logged -- --name web-typecheck -- bun run --cwd apps/web typecheck
The exit status is authoritative; if a tool buffer maxes out, read the
compressed artifact the runner prints with bounded reads (gzip -cd ... | tail)
rather than inferring the result. Always pass --parallel to a direct suite.
--parallel implies --isolate, so a test that fails only in parallel usually
depended on a global set by a sibling file; run it alone before calling it a race.
Bun's mock.module() is global at the module-cache level and mock.restore()
does not undo it. Register mocks every suite needs once in tests/setup.ts, put
reusable mock functions in tests/mocks/*, mock narrow dependencies rather than
shared app modules, and never add a competing global mock for a module another
suite imports for real.
Flakes are tracked only in
docs/tests/flaky-tests/0000-index.md.
Search the index by test name or owning file; do not read every case file. A
failure is a flake only if the owning file passes when rerun alone; record it in
the same change, and never hide one by skipping or loosening the test.
For real-stack UI QA use isolated dev:test profiles with --fixture, never a
production instance or this checkout as the project. Never print, paste, or save
the gateway token — sign in with mise run dev:login. Never use broad cleanup
(docker prune, killing by name or port); stop and reset the profile with
dev:stop/dev:reset when done. The
orkestrator CLI can drive the same profiles
without the UI.
Updating Bun and lockfiles
When bumping the Bun runtime, changing a dependency, or changing package
metadata that Bun records (including a workspace package's version),
regenerate lockfiles with the Bun version pinned by mise.toml. Do not hand-edit
bun.lock, and do not omit a lockfile change just because no dependency version
changed.
Regenerate every tracked lockfile from the directory that owns it. The root install does not replace the standalone Claude bridge lockfile:
mise exec -- bun install
mise exec -- bun install --cwd bridges/claude-bridge
Review the lockfile diff, then prove both files are current by repeating the installs in frozen mode and running the drift test:
git diff -- bun.lock bridges/claude-bridge/bun.lock
mise exec -- bun install --frozen-lockfile
mise exec -- bun install --cwd bridges/claude-bridge --frozen-lockfile
mise exec -- bun test tests/unit/version-drift.test.ts
If another standalone bun.lock is added later, add its owning directory to
both command lists and to the lockfile coverage in
tests/unit/version-drift.test.ts.
Formatting and Linting - oxc
Formatting is oxfmt and linting is oxlint; run them from the repo root with
mise run format, mise run format:check, mise run lint, mise run lint:fix,
or mise run check (format, lint and typecheck). Markdown and docs/** are not
reformatted. Rationale and exclusions are in
docs/development/linting.md.
- Only
correctnessis enabled, as errors.no-unused-varsand the seven React Compiler rules (react/refs,react/set-state-in-effect, …; listed in.oxlintrc.json) are warnings for the pre-existing backlog: clean up files you touch, never add new findings or silence them, and raise a rule toerroronly once its backlog is gone. Warnings do not fail the build, so read the output. - Use a disable directive only when the rule is wrong about that site, with the
reason above it.
oxlint-disable-next-linesits above the reported line;react-hooks/exhaustive-depsneeds a blockoxlint-disable/oxlint-enablepair. Check first whether the dependency is genuinely missing — most were. - Iterate a collection you are about to mutate with
for (const x of Array.from(collection)), not a spread; lint's suggested fix for the spread form silently skips entries. - Module boundaries are lint errors: no runtime import cycles
(
import/no-cycle; type-only imports are exempt), no newexport let, nonode:/bun:or@orkestrator/protocol/node/*value imports in the renderer, no raw@/lib/native/backendin components, and@orkestrator/protocol/testing/*only from tests. Top-levelpackages/protocol/src/*.tsis shared with the browser; Node- or Bun-dependent protocol code goes insrc/node/, fixtures and fakes insrc/testing/. Fix a finding by moving code, not with a disable directive.
Application Version Bumps
When bumping the Orkestrator version, keep the top-level version field in all
of these package manifests synchronized:
package.jsonapps/backend/package.jsonapps/desktop/package.jsonapps/web/package.jsonapps/web-public/package.jsonbridges/acp-bridge/package.jsonbridges/cursor-bridge/package.jsonbridges/claude-bridge/package.jsonbridges/codex-bridge/package.jsonbridges/pi-bridge/package.jsonpackages/cli/package.jsonpackages/protocol/package.jsonpackages/toolchain/package.json
After a bump, run the following to verify every package manifest was included and has the intended version:
rg -n '"version"\s*:' --glob 'package.json' --glob '!node_modules/**'
Then follow Updating Bun and lockfiles above. A version-only bump still
changes the workspace metadata in the root bun.lock; leaving the old values
there causes a later non-frozen bun install to dirty an otherwise clean build
worktree.
Public CLI contract
The orkestrator client commands reach the backend only through the
public_action registry command (apps/backend/src/core/public-api/); the
contract lives in packages/protocol/src/public-api*.ts and the guide in
docs/architecture/public-cli.md. When
touching it:
- Admit before acting. A mutation's request key is reserved and its operation record published (under the store lock) before any side effect. Same key + same intent replays; different intent conflicts. Never mint a new key to retry ambiguous work.
- Never treat missing history as "never ran". Retired namespaces answer
namespace-expired/history-expired; a corrupted store refuses admission; capacity refuses new work instead of evicting records. - Completion is request-specific. A run settles only on its own dispatch
journal, observed turn activity and turn-outcome record. An idle environment
or the agent's wording is not evidence; missing evidence stays
unknown. Only providers listed as qualified inpublic-api/providers.tsmay report completion. - Client commands never start a backend and the launcher decides service
versus client before importing
dist/main.js. Keepdist/client.jsfree of backend imports. Add every new service flag toapps/backend/src/server-flags.ts. - Receipts and summaries are content-free. No prompts, tokens, file contents or provider session IDs in receipts, summaries, manifests or logs.
Key Files Reference
Frontend
| File | Purpose |
|---|---|
apps/web/src/components/terminal/TerminalContainer.tsx |
xterm.js integration |
apps/web/src/components/native-agent/AgentNativeTab.tsx |
Shared Native Mode chat |
apps/web/src/stores/agentModelCatalogStore.ts |
Model catalogues per agent platform |
apps/web/src/stores/nativeAgentProjectionStore.ts |
Native agent projections for every native platform (Claude, Codex, OpenCode, Cursor, Grok, Pi) |
apps/web/src/lib/native/backend.ts |
Native IPC command wrapper |
Claude bridge
bridges/claude-bridge drives the Claude Agent SDK and serves the shared HTTP
session surface. When touching the session catalogue
(session-manager-catalog.ts):
- A control that is still set is not necessarily usable.
session.queryControllives until the turn'sfinally, but the CLI's stdin closes earlier, at the result boundary. Every control request issued in that window races the process exit and is rejected withQuery closed before response received. Reads go throughreadableControl, which skips a control markedqueryControlDraining.closeTurnInput()insession-manager-prompt-turn-lifecycle.tsis the one place that sets the marker, so a new stdin-close site cannot forget it. - Reads fall back, writes conflict.
isClosedTransportErrormaps a dead transport onto the cached answer for/commandsand/mcp, and onto 409 for/config— a settings change that never reached the CLI must not look like it succeeded. Every other error still propagates; do not widen that match. - The no-control fallback costs a process.
createProbe()spawns a whole Claude CLI, socommandInventorycaches the catalogue the waymcpInventoryalready did. The backend re-reads both on every session projection (native-agent-service-projection.ts), so an uncached fallback is one spawn per turn boundary rather than a one-off. - Every SDK frame has a declared fate.
HANDLED_SDK_MESSAGE_TYPESandSYSTEM_SUBTYPE_DISPOSITIONS(src/types/index.ts) areRecords over the SDK's own unions, so an SDK bump that adds a message type orsystemsubtype fails the typecheck until someone decides whether it is handled, kept as a health notice, or ignored as inventory. Content blocks the parser has no branch for are counted asblock:<type>drift. - The native tab never sees SSE. The backend polls
GET /session/:idand the transcript routes;session.updatedframes only reach the legacy web client. Turn-scoped state the tab must show (activity, thinking estimate, background tasks) has to be in that snapshot. A row derived from a record the rollout keeps (a task report, an interruption marker) must be produced by both the live loop andnormalizePersistedSessionMessages, or a reload will disagree with the live tab. DELETE /session/:idis destructive. It calls the SDK'sdeleteSession, which removes the{sessionId}.jsonlrollout. Tab close usesPOST /session/:id/close(closeSessionRetainingHistory), which stops the query and keeps the rollout; nothing may fall back from close to DELETE. Close answers 503 pending, keeping the session registered and fenced, when it cannot prove the query stopped (Query.close()threw or did not settle, or a racing dispatch claim did not settle).app.onErroris registered on purpose. Hono's default handler passes the raw error toconsole.error, which under Bun prints a source-context dump of whichever minified vendor file threw, with no indication of which request produced it.
Codex bridge
The bridge supervises one persistent codex app-server --stdio child per
environment and talks to it over JSON-RPC on private stdio. There is no second
engine and no feature flag: the per-turn codex exec path and the
@openai/codex-sdk dependency were both removed once app-server reached parity.
See docs/architecture/agent-engines.md.
session-titles.ts is the deliberate exception — it still spawns its own hermetic
codex exec with a custom model catalog, read-only sandbox and user config
ignored, so title generation cannot inherit the user's tools or instructions.
| File | Purpose |
|---|---|
bridges/codex-bridge/src/index.ts |
Routes, SSE, composition root |
bridges/codex-bridge/src/app-server-runtime.ts |
Session surface for the app-server engine |
bridges/codex-bridge/src/app-server-runtime-rows.ts |
A turn's assistant rows: open, render, publish |
bridges/codex-bridge/src/event-ring.ts |
Bounded SSE replay buffer + cursor parsing |
bridges/codex-bridge/src/app-server/process-supervisor.ts |
Child lifecycle, generations, restart policy |
bridges/codex-bridge/src/app-server/jsonl-rpc-client.ts |
Transport; must never await consumer work |
bridges/codex-bridge/src/app-server/approvals.ts |
Approval descriptors + per-method response mapping |
bridges/codex-bridge/src/app-server/server-request-router.ts |
Answers every server request, exactly once |
bridges/codex-bridge/src/app-server/notification-recorder.ts |
Opt-in capture of the inbound stream for fixtures |
bridges/codex-bridge/src/sessions/dispatch-journal.ts |
At-most-once prompt dispatch |
bridges/codex-bridge/src/messages/normalization.ts |
Item → normalized part rendering |
bridges/codex-bridge/src/messages/diff-budget.ts |
Caps the diff state, the largest memory consumer |
bridges/codex-bridge/src/codex-item-types.ts |
Local thread-item types (was the Codex SDK) |
bridges/codex-bridge/src/testing/replay-recording.ts |
Replays a recording through the real pipeline |
When touching the app-server engine:
- Never let the stdout read loop await a render, an SSE write, or the browser — app-server's outbound queue is bounded, so that stalls every thread.
- Never auto-retry an ambiguous dispatch. Only an explicit
-32001overload means the turn definitely did not run; anything else must reconcile viathread/read. - Never report
idleforcancelling/recovering. Both map torunning, which is what stops the build pipeline advancing on a turn that may still be executing. - Never call
thread/delete. Closing a session unsubscribes; deleting would destroy the user's rollout and its descendants. - Never let a metadata scan read whole rollout files.
getSessionMetaFromTranscriptPathreads only the head; full reads are for hydrating one specific thread. A 1.6GB Codex home cost ~5.3GB of retained heap before this. - Idle threads are detached (
thread/unsubscribe+ state freed) and re-attached transparently on the next request. Detaching an unmaterialized thread must clear its id: it has no rollout, sothread/resumewould fail forever. - Never rely on
thread/resumeto change a loaded thread's configuration. app-server rejoins it and ignores every override,mcp_servers.*included, so the thread keeps the previous attempt's workflow-result MCP credential.reloadThreadunsubscribes first so the resume rebuilds it from its rollout; an unmaterialized thread is replaced instead. A result turn whose thread still does not list its submit tool is refused with 424 before journaling. Verified against codex 0.158.0. - Never poll a tab-facing route from a background reconciler.
/session/:idand/session/:id/statusare liveness touches — the codex bridge refresheslastAccessed(which is whatdetachableThreadsreads) and the claude bridge additionally hydrates the transcript. The backend's activity sweep runs every two seconds for every persisted session, so polling those would put idle detaching and transcript eviction permanently out of reach. Both bridges exposeGET /session/:id/activityfor exactly this: no touch, no hydration, no re-attach. Anything else the backend wants to poll needs the same treatment. /session/:id/activityanswers an unknown session in band as{"activity":"missing"}and never 404s. The backend reads a 404 there as "this bridge predates the route" and fails the environment; if 404 also meant "session gone" it would delete a live session mapping against an older bridge. For the same reason the claude bridge answers a failed existence probeidle, nevermissing— an error is not evidence of deletion. Every HTTP bridge'sPOST /sessions/activitybatch route answers each id from the exact same no-touch read (never omitting one: an unreadable id isunavailable), and only a 404/405 on that route means "older bridge".GET /session/:id/dispatch?requestId=answersdispatchedonly on an explicit positive from that bridge's own dispatch journal. No record, a record that predates a bridge restart (the ACP journal'sambiguous, the codex journal'sprepared), an unreadable journal and a missing route are allunknown. The backend clears a parked dispatch ondispatchedalone, so reporting a lost record as "never sent" would have it run the same turn twice. Like/activity, it must never touch liveness, hydrate or re-attach.- Prompt dispatch and
POST /session/:id/attachshare the same client timeout, because they do the same work. A bridge with no attached agent process pays a full spawn plusinitializeplussession/loadon whichever request arrives first; budgeting the prompt at the 30s default aborted cold dispatches mid-flight and reported them to the user as unresolvable. Attach exists to move that cost outside the at-most-once window, where a failure is unambiguous — nothing journaled, no prompt written. It must never dispatch a turn, and callers must treat it as best-effort: the prompt request performs the same work and is the one that answers authoritatively. - A parked dispatch blocks its whole session, not just the prompt that created it: storage refuses every other request id until it is settled. Surface both ways out — retry under the same idempotency key, or discard — rather than the storage-level refusal, which names an invariant the user cannot act on.
- Agent version bumps follow
docs/development/upgrade-agents.md; the generated protocol underapp-server/generated/is a lockfile. - Never resolve an approval to "approved" by default. Every timeout, disconnect, generation death and unparseable answer denies. Approving on a technicality would run a command the user never saw.
- Never answer an approval belonging to a dead generation. app-server has
forgotten the request; withdraw the card and say so in the transcript instead
(
abandonGeneration). Conversely a live child must always be answered — closing a session declines on the way out rather than just forgetting. - Never let the fast server-request backstop fire on a parked approval. It exists for a branch that failed to answer; a request awaiting a human has legitimately not answered yet, and answering there resolves a prompt the user is reading.
- Never treat an approval as visible just because the SSE frame was emitted. The
tab may have been unmounted;
/session/:id/approvalsis the authoritative rehydration path and reconcile must call it. - SSE frames carry
id: <revision>. Theconnectedframe must echo the client's own cursor, not the latest revision: a browser EventSource adopts every id it sees, so anchoring at the latest would permanently skip the frames it just asked to be replayed if the socket died mid-handshake. - Subscribe before computing an SSE replay, buffering into an array, then flush past the replayed range. Replaying first and subscribing second drops anything emitted in between — the exact gap the cursor exists to close.
- Recordings (
CODEX_BRIDGE_RECORD_NOTIFICATIONS, armed byCODEX_BRIDGE_RECORD_CONFIRM=1) contain prompts, file contents and absolute paths. Always runscripts/scrub-codex-recording.tsand read the diff before committing one as a fixture; a test scrubs the fixtures directory and fails on any hit, but the scrubber only catches secrets and identity — it does not redact prompt or file content unless you pass--strip-content. The recorder itself must stay O(1) in the read loop — buffer and flush off-loop, never await a write.
Pi bridge
bridges/pi-bridge drives Pi's own TypeScript SDK
(@earendil-works/pi-coding-agent) in process and serves the same HTTP routes
and the same transcript shape as every other bridge, so the backend, the store
and the renderer cannot tell which engine is behind a session.
Pi differs from the other platforms in one way that matters, and it is confined to two files. Pi is a harness, not a vendor: it fronts fifteen-odd model providers using the user's own credentials. So a model is identified by a pair rather than a name, and "signed in" is one answer per provider.
models.tsencodes the pair asprovider/modelId, split on the first slash only — an OpenRouter id contains its own. This is the same encoding OpenCode already uses, so one convention covers both pickers.- The reasoning axis is Pi's thinking level, and the supported set per model
comes from Pi's own
getSupportedThinkingLevelsrather than being derived fromthinkingLevelMaphere. The rule is not the obvious one: anullmapping excludes a level, butxhighandmaxadditionally require an explicit mapping, so an absent key excludes those two and includes every other. Getting that wrong offers controls the model then clamps away, and a clamped turn succeeds — it simply thinks less than the user asked.offis a real level, not an absence; the shareddefaultid has no Pi equivalent. - A fresh session's level is resolved the way Pi resolves it — per-model
setting, then global default, then
medium— reading the samesettings.jsonthat/thinkingwrites, so one preference serves both the model picker and a Pi terminal tab.thinking_level_changedis echoed back into the composer so the picker shows the level actually in force after Pi clamps it. credentials.tsreports per-provider status and deliberately implements no sign-in. Pi's login is an interactive multi-step prompt flow with no counterpart in Orkestrator's session surface, and the credential it writes is account-wide rather than per-environment. Users sign in with/loginin a Pi terminal tab or by writingauth.json; containers are handed the resulting directory as a bind mount.
When touching the Pi bridge:
- The engine boundary is
src/translate.tsandsrc/tool-rendering.ts. Every Pi-specific shape stops there; nothing downstream should learn a new field to render a Pi turn. applySessionEventnever awaits. It runs on the SDK's own listener, so an await there would let a large transcript back-pressure a live run.- A tool variant Pi adds — or a custom tool from a project extension, whose shape this bridge cannot know at all — must degrade to a plain card, never throw. These branches run mid-turn.
- The conversation lives in Pi's own JSONL session file, not in this bridge.
Losing the bridge's state costs a rendered transcript; it never costs the
conversation. That is why
detachSessionkeepssessionFileand why a restart re-attaches to the same session rather than starting a new one. - Approvals are off unless
PI_BRIDGE_REQUIRE_APPROVAL=1, matching the permissive default every other bridge here uses. When on, every timeout, disconnect, closing session and unparseable answer denies. A turn that ends with a call still parked denies it too — leaving it unanswered wedges the turn and, with it, the environment's activity state. - A prompt claims a process-local token (
promptClaim) synchronously at route entry, before its body is read, when nothing else owns the session; an unused reservation (validation error, duplicate, local answer, busy refusal) is released with its own cancel record only. A cancel that arrives before Pi has produced a cancel handle is parked against that claim and answered 202{ cancelled: false, pending: true }, nevercancelled; so is a provider abort that hangs or rejects (the next request retries it). The prompt route checks for it after every preparation await and settles without calling Pi. In Pi's preflight the cancel callssession.abort()at once (pinned SDK 1.2 aborts an auto-compaction and pre-empts a run_startRunis preparing; that abort waits for the run to settle, so it is never awaited) and again when Pi accepts, because_startRunresets the abort flag when it begins. A run Pi reports settled withaborted: true— whoever aborted it, including an extension — ends the turn cancelled (idle, structured resultinterrupted), never completed. Acceptance is bounded byPI_BRIDGE_STARTUP_TIMEOUT_MS(default 5 min, floor 30 s): past it the route answers 424 with an explicit error, but the claim anddispatchingare kept until Pi settles the prompt — a late acceptance is aborted and observed — so every status route reports running while something can still reach Pi. Config and compaction take no claim, so a cancel during them cannot stop a later prompt. Close and DELETE mark the session closed before their first await: new prompts get 409, one still preparing settles as cancelled, and/closekeeps the session registered until its removal is published (503 pending otherwise). - Project-local
.pi/resources are opt-in throughPI_BRIDGE_PROJECT_RESOURCES, and only the container launcher opts in. A Pi extension is arbitrary TypeScript this process would execute, so cloning a repository must not be enough to run its code — the same boundaryACP_APPROVE_PROJECT_MCPSdraws for the ACP bridge. - Pi's SDK sessions load no MCP client (only its CLI loads the built-in
mcpextension) and have no plan/build mode. The bridge owns an MCP client (src/mcp.ts) and registers tools through the inlineorkestrator-mcpextension: Orkestrator from env / per-tabagentMcp, user servers from~/.pi/agent/mcp.json, and project.pi/mcp.jsononly when the execution policy opts into project resources. A session records a fingerprint of the MCP files it was built from and rebuilds at the next turn start when they change (never mid-turn), which is how saved edits from the MCP servers settings reach a live session. Settings-pane discovery still reports an empty MCP list (pre-session fallback). The composer reportsmode: falsebecause plan/build is still something an extension adds.agentMailCapabilities("agent-native", "pi")is on; terminalpistays off. - The SDK and the pinned
pibinary are the same program, so they are pinned to the same version andtests/unit/version-drift.test.tsenforces it — a bump that moves one and not the other gives a user two different agents behind one platform name.
Cursor bridge
bridges/cursor-bridge is the only Cursor engine. It drives Cursor's TypeScript
SDK (@cursor/sdk) in process and serves the shared HTTP session surface on
container port 4099. Cursor has no managed CLI or terminal mode; the ACP bridge
is Grok-only.
When touching the SDK bridge:
- The engine boundary is
src/translate.tsandsrc/tool-rendering.ts. Every Cursor-specific shape stops there; nothing downstream should learn a new field to render a Cursor turn. applyInteractionUpdatenever awaits. It runs on the SDK's own callback, so an await there would let a large transcript back-pressure a live run. It also enforces the display budget itself (boundTranscriptDuringStreaming) after every top-level update — an inactive tab issues no reads, so a bound that waited for one would never run. Charge appended bytes as encoded upper bounds and name new parts withnextPartOrdinal, neverparts.length.- A tool variant the SDK adds must degrade to a plain card, never throw. The SDK is a fast-moving dependency and these branches run mid-turn.
- Sign-in runs the bridge's own
--loginmode as a short-lived child. Keeping@cursor/sdkout of the backend is deliberate: it is a five-megabyte bundle with native helpers, and a login needs no environment and no session. - The credential lives in Orkestrator's data directory, not the SDK's default
~/.cursor/sdk/auth.json, so a container can be handed exactly one file. - Project settings (
.cursor/) are read inside containers and not on the host, so cloning a repository is not enough to run its code on the user's machine. - The Orkestrator Agent MCP server is injected from a per-tab
agentMcpbody or, as fallback,ORKESTRATOR_AGENT_MCP_URL/ORKESTRATOR_AGENT_MCP_TOKENasAgentOptions.mcpServers.orkestrator(src/mcp.ts). Host runs still do not load a repo's.cursor/mcp.json. Native Cursor mail is on ({canPull,canSend,canInject}=true). A rotated tab token detaches and re-attaches the SDK agent; the bearer is never persisted. - The host launcher spawns the bridge in its own package directory, never in
the worktree.
bunreadsbunfig.toml—preloadincluded — and.envfrom its working directory before the entrypoint runs, so spawning there hands a cloned repository arbitrary code execution inside a process holding the credential path, the bridge token and the agent MCP token. The SDK's Shell tool does default toprocess.cwd(), but the bridge enters the workspace itself afterwards (applyWorkingDirectoryinconfig.ts, called at module load and again fromstart()), which is whyindex.tsexports./config.jsbefore anything that loads@cursor/sdk. - Permanent close (
DELETE /session/:idandPOST /session/:id/close, which are the same non-destructive operation here) lives insrc/session-close.ts. It setsstate.closedbefore its first await, and every admission path — prompt, attach, config, steer, same-key create, resume — refuses a closed session. A late attach is disposed instead of installed, and a lateagent.sendresult is cancelled and followed. The close answers 503pendinguntil owned work has actually stopped, and it publishes the removal before it answers success. Idle detach (detachAgent) is not a close and never sets the marker. A method the bridge does not serve on a session it does have answers 405, so a real gap cannot hide as a missing session. A close a previous process recorded but did not finish (a restored tombstone) is finished bysrc/session-tombstones.ts, and resuming the same conversation waits for it first. persistBarrier()is a mandatory publication: it rejects when nothing reached disk, and prompt/steer dispatch, create, resume and identity-changing attach all wait for it.schedulePersist()is best-effort. Both share one serialized queue, so never write the state file directly. When the aggregate state outgrowsMAX_STATE_FILE_BYTES, persisted transcript copies are cut to their newest whole messages, oldest-touched sessions first (src/persistence-budget.ts). If the recovery metadata alone does not fit, the publication fails with a typed error rather than being skipped. Each write uses a unique temporary file, flushed before the rename, and the directory is flushed after it: process-crash durable everywhere, power-loss durable on Linux.- The steer journal is bounded by count and bytes (
src/steer-journal.ts). Records that could still be retried against the running turn are never evicted. A new steer that does not fit is refused with 429{ outcome: "rejected" }before anything is journaled or sent. Do not replace this with a FIFO: an evicted record would turn an exact retry into a second delivery. - Never compress a response the client did not ask for.
jsonreadsAccept-Encodingonce per request; this repository already has a hop that asks foridentityon purpose. Compression defers the write past the caller's return, so every write also has to survive a socket that is already gone. - Giving up on a turn is not the same as the run stopping. Anything that fails
a turn without the run acknowledging it — the prompt timeout above all — has
to cancel that run, or it keeps writing to the workspace while
/activityanswers idle andcancelTurnhas already been cleared. - A cancel that arrives before
agent.sendresolves has no run to act on. It parks against the sequence of the turn it meant to stop and is honoured the moment the handle exists; answering it ascancelledwould tell the user a turn stopped while it carried on. - Every bridge that builds has to be listed in the root
package.json'sbuild.extraResources.getBridgePathfalls back toresourceRoot/<name>outside development and fails only at the moment a user selects it, while containers carry on working from/opt/<name>.tests/unit/bridge-packaging.test.tsenumerates them so the next one cannot be forgotten.
A background sub-agent is settled when its parent run ends. The SDK reports
children only through nested updates on that run, so once it is over there is
no channel left to observe them on — the card says the child was detached
rather than claiming it completed, because holding it active would report the
environment as permanently busy. The same applies across a restart: the live
child registry is deliberately not persisted, so a card restored at active
would spin forever with nothing left that could settle it, and loadPersistedState
closes those out on the way in.
Tab close and conversation retention
Closing a tab retains the conversation on every platform. Every managed bridge
(Claude, Codex, Cursor, Grok/ACP, Pi) serves POST /session/:id/close; backend
tab teardown and HttpBridgeProvider.closeSession both go through
closeBridgeSessionRetaining (apps/backend/src/core/bridge-session-close.ts).
When touching any of them:
- Close is non-destructive. It stops owned work, denies parked approvals,
questions and plan approvals, settles dispatch claims, and releases the
bridge's live mapping. It never deletes vendor history: no Claude SDK
deleteSession, no Codexthread/delete, no OpenCodeclient.session.delete. - Only a 2xx that affirms
closed: trueis a confirmed close. 200{ closed: true, retained: true }or{ closed: true, missing: true }. Anything else, including an empty or malformed 2xx, is not a close. - An unknown session is answered in band, never 404. 200
{ closed: true, missing: true }. A 404/405 from the close route means the bridge predates it; it is never evidence that the session is gone. - 503
{ closed: false, pending: true, error }keeps everything. The bridge keeps the session registered (and refuses new work on it); the backend keeps its durable teardown intent and the tab mapping, and the periodic reconcile sweep retries. Errors are fixed, content-free strings. - Legacy fallback is per bridge. On 404/405 the backend may send the old
DELETE /session/:idonly whereLEGACY_DELETE_RETAINS_HISTORYistrue(Codex, Cursor, Grok, Pi). Never for Claude, whose DELETE deletes the rollout: that teardown stays pending with abridge-upgrade-requiredfailure the renderer shows as a restart notice. Do not add a platform to that table without evidence from every released version of its DELETE. - The last owner closes. Two tabs can map to one provider session. Backend teardown is serialized per (environment, agent, provider session) and only the tab that finds no other mapping performs the provider close.
- Transcript v2 reads share one source and answer in band. Every HTTP
bridge serves
GET /session/:id/transcript?version=2(summaries with detail locators,@orkestrator/protocol/node/bridge-transcript-summary) plus/transcript/detailand/transcript/page, all built from one per-bridge read source viabridge-transcript-routes. Detail/page answer an unknown session 200missing/expired, never 404; absentversionstays v1.
Native slash commands
The contract is docs/architecture/native-agent-commands.md.
When touching command discovery or dispatch:
- Never seed or guess rows. A catalogue lists only what the provider
reported and what its executor can run. A successful empty list stays
empty; a failed read is
staleorunavailable, neverready: []. - A selected command never becomes a prompt. Removed, changed, forged or
unverifiable selections are refused before journaling (bridges answer 422
command-unavailable). Retries and queued items carry the resolved intent. - Private bindings stay private. Skill paths, template bodies and provider command defaults never appear in a public descriptor.
- Catalogue reads are metadata. They must not touch
lastAccessed, hydrate a transcript or re-attach an idle session; push freshness rides oncommandRevisionin the snapshot the backend already reads.
Coordinator qualification
apps/backend/src/core/coordinator-providers.ts is the single table deciding
which platforms may run a coordinator, and at which tier. Every gate consults
it: the workspace service, the runtime resolver, the bridge launcher and the
trusted session input. Do not reintroduce a platform literal at any of those
call sites — that is what previously let a platform be half-qualified, allowed
to hold a conversation but refused a bridge.
Moving a platform to enforced is a claim that the provider or the OS blocks
the mutation whatever the agent attempts. It requires both:
- a translation of
capabilityPolicyinto that provider's own vocabulary, in the bridge, applied on create, resume, config and every turn; and ORKESTRATOR_BRIDGE_EXECUTION_POLICY=coordinator-read-onlyhonoured as process authority, so a request body or a persisted record cannot widen a live conversation across a restart.
toolPolicy cannot carry that translation. Its strings are Codex's tool names
and it is also the user-editable override surface, so the same list means
"Write, Edit" on one bridge and nothing at all on another. Use
capabilityPolicy, which names the operation rather than the tool.
Where a platform cannot honour an axis, report it in the policy's note and
leave the tier at provider-configured. Do not silently drop the axis, and do
not claim a boundary the bridge is not holding — the tier is shown to the user
next to the platform they are choosing.
delegation is derived, not declared per platform. It is an MCP client and an
injectable native mailbox, because launch_environment goes out over MCP while
the worker's reply comes back as agent mail. Native Claude, Codex, OpenCode,
Pi, Cursor, and Grok all have both. Cursor and Grok consume per-tab
agentMcp the same way Claude and Pi do; the process-env token is only the
fallback. Deriving delegation from the outbound half alone is what would
let the coordinator prompt promise workers whose mailbox cannot receive the
answer.
Coordinator shell allowlist
bridges/claude-bridge/src/services/read-only-policy.ts holds the read-only
boundary for shell, because no tool-name rule can separate git log from
git commit — which is why capabilityPolicy's shell.mutate maps to no tool
names at all. Two invariants keep it honest:
- The command checked must be the command that runs.
COMPOSITION_PATTERNrefuses anything that can become several commands, and a newline counts: the shell treats it exactly as;does, while a whitespace split would reducels\nrm -rf .to a harmless-lookingls. - The program name is not the whole command. A program that launches another
program does not belong in
READ_ONLY_COMMANDSwhatever it is called —envis absent for that reason. Where a reading tool has a writing flag, name it inMUTATING_ARGUMENTS(find -delete,sort -o,yq -i) rather than dropping the tool. Git subcommands whose effect depends on their arguments belong inCONDITIONAL_GIT_SUBCOMMANDS, not the flat read-only set:branch,tag,remoteandconfigall read in one form and write in another.
Backend
| File | Purpose |
|---|---|
apps/backend/src/core/commands.ts |
Backend command registry and Docker/local env management |
apps/backend/src/core/tmux.ts |
Claude tmux mode backend |
apps/backend/src/core/storage.ts |
StorageService (SQLite-backed; see docs/architecture/storage.md) |
apps/desktop/electron/ipc.ts |
Main-process IPC handlers |
apps/desktop/electron/preload-api.ts |
Renderer-facing native API |
Desktop BrowserWindow preloads are bundled as ESM, so the main window and
toolchain bootstrap windows keep sandbox: false. Sandboxed Chromium evaluates
preloads as CommonJS and cannot load import from electron. Browser preview
views stay sandboxed.
Docker
| File | Purpose |
|---|---|
docker/Dockerfile |
Base image definition |
docker/entrypoint.sh |
Container entrypoint |
docker/workspace-setup.sh |
Repo clone, .env files, project config, shown in terminal |
docker/init-firewall.sh |
Network firewall rules applied at startup |
docker/update-firewall.sh |
Operator-only allowlist edits via docker exec --user root |
docker/runtime-env.sh |
PATH/env snapshot so docker exec sees setup-time tools |
docker/git-branch-helpers.sh |
Makes a bare git push publish and track the branch |
docker/verify-playwright.cjs |
Launches Chromium; run at build time and on demand |
Docker Base Image
The image is built from oven/bun:1.4.3-debian, pinned by its multi-architecture
index digest on both stages, matching the Bun version managed in mise.toml
for development and CI. Refresh the digest deliberately (docker buildx imagetools inspect oven/bun:<tag>) for a Bun bump or a base security update;
mise run docker:check-base reports when the tag has moved past the pin.
tests/unit/version-drift.test.ts requires both FROM lines to agree. After
any image change, bash docker/tests/final-image-smoke.sh <image> runs the
built image (CI runs it on both architectures). Every
agent CLI version below is pinned by an ARG in docker/Dockerfile, which is
its container source of truth.
The bridges are built in a separate bridge-build stage (manifests and
patches first, then a --frozen-lockfile filtered install, then sources), and
the delivered image receives only each bridge's runtime directory. Do not move
bridge builds back into the final stage: the workspace install and build
layers would ship in the image's history even when deleted later.
Runtimes:
- Bun, installed in mise's shared system tool directory. The matching base-image runtime bootstraps the image build before mise is installed.
- Node.js 24 LTS, installed over the base image and verified against the published checksum. The agent CLIs need genuine Node, not bun's node shim.
Agent CLIs, one per supported platform:
- Claude Code (
claude), Codex (codex), OpenCode (opencode), and Pi (pi), whose runtime paths the backend reads fromCLAUDE_CLI_PATH,CODEX_CLI_PATH,OPENCODE_CLI_PATH, andPI_CLI_PATH. - Grok Build (
grok) is downloaded as a hash-verified pinned artifact. Pi is downloaded the same way, as a bundle rather than a single file. - The image build fails immediately if any managed CLI is not runnable, and if
Codex did not vendor
codex-code-mode-hostbeside its binary.
Prebuilt bridge servers, so a container never builds them at runtime:
/opt/claude-bridge,/opt/codex-bridge,/opt/cursor-bridge,/opt/pi-bridge, and/opt/acp-bridge(used only by Grok Build).
Developer tooling:
- Git, GitHub CLI (
gh), git-delta, and SSH with GitHub/GitLab/Bitbucket host keys already known. mise, installed from a pinned, checksum-verified release./workspaceis a trusted mise config path because repository setup already runs inside the container boundary. Thenodeuser's interactive zsh loads the hook, and backend-launched processes promote mise shims through the shared runtime-env helper; root terminals have the CLI but do not load the interactive hook.- Playwright CLI plus its pinned Chromium build (see below).
- ripgrep, fzf, jq, tmux, nano, vim, less, curl, wget, and
en_US.UTF-8.
Users and isolation:
- Non-root
nodeuser (uid/gid 1000, matching the workspace bind mount), with Zsh and a pinned, checksum-verified Starship prompt. The image installs the shared hook through/etc/zsh/zshrc, reads prompt configuration from/etc/starship.tomlthroughSTARSHIP_CONFIG, and retains repository-owned Git aliases plus native Zsh completion without Oh My Zsh. orkroot, a uid-0 user for root terminal sessions. The backend opens those terminals withdocker exec --user orkroot;nodehas no sudoers path to becomeorkroot.- Network firewall (iptables/ipset) for security isolation.
nodehas passwordless sudo for exactly three things: the image entrypoint (/usr/local/bin/network-policy-entrypoint.sh, which records the network policy once, root-owned, before dropping tonode),/usr/local/bin/init-firewall.sh, and/usr/local/bin/run-root-setup.sh(the latter only when the recorded mode isfull). The container boundary, not a reusable root shell, is what isolates an agent. Runtime allowlist edits usedocker exec --user root /usr/local/bin/update-firewall.sh.
Playwright
The image installs playwright globally and pre-downloads its Chromium build to
the shared PLAYWRIGHT_BROWSERS_PATH=/ms-playwright, because the restricted
network firewall does not reach Playwright's CDN by default — a container that
had to run playwright install itself would fail.
A default chromium.launch() works as-is for both the node user and the uid-0
root terminal user. The image build proves it by running
docker/verify-playwright.cjs once as each of those identities — Chromium
refuses to start as uid 0 without --no-sandbox, so the root path is a separate
claim and is not inferred from the node one. The same script stays in the image
at /usr/local/share/verify-playwright.cjs, so a container can answer "does
Playwright work here?" without reconstructing it:
NODE_PATH=/usr/local/share/npm-global/lib/node_modules \
node /usr/local/share/verify-playwright.cjs
Three constraints apply:
- Do not set
chromiumSandbox: true. Playwright defaults it tofalse, which is what makes this work: containers get neitherCAP_SYS_ADMINnor unprivileged user namespaces, so enabling Chromium's own sandbox fails with "Chromium sandboxing failed!". The container is the isolation boundary. - Containers are created with
--shm-size=1g(apps/backend/src/core/commands-containers.ts), because Chromium keeps renderer shared memory in/dev/shmand Docker's 64MB default is far below what a real page needs. That failure surfaces as a renderer crash part-way through a run ("Target page, context or browser has been closed"), not as a launch error, so it is invisible to a
Truncated - read the full file at https://github.com/orkestrator-ai/orkestrator-v2/blob/c75b09824671fc96430d952120cc6e494f0c0385/AGENTS.md.
