* feat(orchestration): report a worker blocked on a human prompt (STA-4513, STA-3714) A lane parked on an approval, trust, or permission prompt looked exactly like a lane that was thinking or inside a long tool call. On origin/main, driving a real cursor-agent through Orca: surface running `sleep 60` awaiting approval worktree ps agents[].state working working terminal show / list no such field no such field terminal wait --for tui-idle satisfied: true satisfied: true worker-show no agent state no agent state The runtime already fuses hook state, OSC title, and matched prompt text into a `permission` verdict inside getTerminalAgentStatus — it was reachable only from the renderer, and it was blind to cursor-agent approvals. Two gaps, one boundary. Exposure: getTerminalInteractiveWait publishes that same fusion, minus the async foreground probe, as `agentWait` on `terminal show` and on `worker-show`'s observation. It carries the evidence that proved the wait (hook, prompt-text, or title) so a coordinator can weigh it. Null means no proof; a missing field means the host predates it — absence is never read as "not waiting". Detection: cursor-agent's hook set has no approval event and beforeShellExecution fires identically for auto-allowed commands, so its rendered menu is the only authority. Matched on the key-bound choices rather than the prose, requiring two, and self-clearing when the follow-up input line returns. Its live spinner title is exempted from the staleness rule that clears startup modals, because cursor keeps spinning while it waits. Falls out of routing it through the shared verdict: `dispatch --inject` into a cursor pane on an approval now refuses with agent_prompt_blocked instead of typing the preamble into the dialog. Fixtures are captured verbatim from cursor-agent 2026.08.11-e8db854 driven through Orca; the same case matrix was replayed live against a built runtime. terminal list stays untouched: its rows would each need a full tail scan, and STA-4694 owns the one-call-per-run aggregate. * fix(orchestration): only call a Cursor approval live while it owns the screen Independent review found the approval detector trusted one dismissal string, so any later output that did not contain cursor's follow-up line left the menu reading as a live wait. Reproduced: a tail of the real menu followed by two lines of ordinary output returned agent-approval-prompt, which fails tui-idle and refuses prompt injection on a healthy lane. Replaced with the structural property the string was standing in for: a live dialog owns the bottom of the screen, so the last choice may sit at most one line above the end of the retained tail. That tolerates a status footer or a partial line mid-redraw without admitting scrollback, and it drops the vendor prose. Being bottom-of-screen is also the dating this reason needed, so it no longer requires waitBlockedAt. A tail restored from terminal history carries none, and a lane parked on a prompt emits no bytes — so before this, an Orca restart made exactly the lane both issues are about go quiet for good. The startup modals keep the timestamp rule: their text lingers in scrollback with nothing to say whether it was answered. Also from review: - worker-show and federationShow reuse the verdict showTerminal already computed rather than rescanning the tail, so the two can no longer disagree. - The worker-show test now drives a real runtime, real PTY tail, and the real detector; it previously mocked getTerminalInteractiveWait, so it would have passed with detection permanently returning null. - The guard claim is now asserted against the guard: a blocked pane rejects both assertTerminalAgentSendable and sendTerminalAgentPrompt, and a working pane still passes. - Added a non-local (connectionId) pane case, since the verdict is derived from retained tail and title state on every host. * fix(agent-status): stop a hook wait from outliving its agent A third reviewer caught that the hook branch proved agent ownership from the pane title alone, while the shared verdict it claimed to reuse also probes the foreground process. A shell that takes a pane back usually sets something like `user@host: ~/repo`, which no title rule recognizes, and a hook row stays fresh for AGENT_STATUS_STALE_AFTER_MS — so a dead agent could be reported as waiting on a human for half an hour. Hook evidence now goes through getTerminalAgentStatus, which is the only thing that can answer whether an agent still owns this PTY. The two prompt branches skip it: a matched prompt is on the pane's screen now, so it proves itself. That makes the probe cost fall exactly where correctness needs it, and getTerminalInteractiveWait async, which only showTerminal had to absorb. Also trims the comments the same reviewer flagged as longer than the repo's rule. * test(agent-status): pin that a dead pane stops reporting a human wait A fourth reviewer noted the approval menu sits at the bottom of a dead pane's tail forever, and that no test covered process exit with no trailing output. The snapshot already refuses an exited pane, and worker-show gates agentWait on proven identity — this pins both so neither can drift into reporting a worker that needs intervention as one that needs an answer. * fix(orchestration): never report an unchecked worker as not waiting Automated review caught that the three worker paths which return before the wait is ever evaluated — unattached, missing, and identity_changed — then had their undefined coerced to null by the emitters. A worker whose process was replaced was reported as `agentWait: null`, which reads as "Orca looked and nobody is waiting" when Orca never looked. That is the false negative this field exists to remove. The field is now emitted only when it was evaluated, so a present null is a claim about the pane and an absent one means nobody looked — because the host predates the field, or the worker's identity could not be verified. The CLI and the worker-show note say that rather than blaming an old host. Covered on the context-only path, where the regression test fails against the previous behavior; the supervised and federated emitters take the identical one-line change. Also trims the two test-file headers to one statement of purpose. * fix(agent-status): tighten the Cursor menu match and stop guessing on unknowns Fourth review round, three findings, each reproduced before acting. Matching each choice marker with an independent lastIndexOf let text outside the menu carry the anchor. An agent narrating "next time I'll suggest Run Everything" after the menu was answered pulled the match down to the bottom of the screen and revived it. The match is now confined to the last lines of the tail, and a choice is a line that ends in the key that picks it — prose writes the same words but not the same shape. The one line of slack under the dialog went with it. It was a guess; every capture of a live dialog ends on its last choice, and one line is exactly enough room for that narration. A redraw caught mid-flight now reads as no wait until the next poll, which is the safe way to be wrong. The hook branch awaited a foreground probe that reaches a PTY controller which may be a remote host, so a wedged probe stalled every caller of showTerminal — a path that never probed before. It is bounded now, and a timeout leaves the wait unevaluated rather than claiming there is none. Which is the same distinction the previous commit only fixed one level up: getTerminalInteractiveWait itself turned an unreadable pane into `null`, so showTerminal published "looked, nobody waiting" for a pane it could not read. It returns undefined there, showTerminal omits the key, and worker-show's text output prints unknown rather than rendering it the same as none. * fix(agent-status): bound the wedged probe's cost and stop matching prose keys Fifth review round. No correctness defects in the shipped behaviour this time; two robustness holes and the documentation of the contract. The bounded probe abandoned the wait but not the request, so a coordinator watching a wedged remote host added one live probe on every poll. It is single-flighted per PTY now, the way the leaf-absence probe already is. The trailing-key rule that separates a menu row from the agent narrating a choice was written as a character class, and any lowercase run up to twelve characters satisfied it — "…suggest Run Everything (as before)" passed. Spelled out as key names instead, which also lets the glyph forms of those keys through. The contract wording said an absent agentWait meant an old host or an unverifiable identity. It also covers an unreadable pane and a probe that did not answer, and a reader diagnosing an old peer from that would be wrong. Corrected on the type, the worker-show note, and in docs/reference/remote-wire-compatibility.md, which had no entry for a field whose absent and null states mean different things. Also strengthens the worker-show agreement test, which compared the terminal and observation payloads without asserting either held the expected wait, so it passed when both were absent.
6.9 KiB
Remote wire compatibility
Orca's remote-server feature pairs a desktop client to a remote Orca runtime, and users update the two independently. Mixed versions are the normal state, not an edge case. This page is the contract for changing anything a paired client and host exchange: the runtime RPC envelope, the terminal binary stream, and the content either side publishes over them.
src/shared/protocol-version.ts says when to bump RUNTIME_PROTOCOL_VERSION. This
page covers the changes that do not bump it and are therefore easy to get wrong.
Rule 1 — a new optional JSON field on an existing frame is safe
Every JSON payload is parsed with a decoder that ignores unknown keys (zod .strip()
on RPC params, JSON.parse on stream frames). An older peer that has never heard of
the field simply does not read it.
Safe:
// host adds a field; older clients ignore it
encodeTerminalStreamJson({ kind, cols, rows, hiddenOutputReason })
The field is safe only for as long as every reader treats it as optional. The moment a newer client requires it, that client is broken against every host that predates the field — which is the same defect as removing a field, just discovered later. If new behavior depends on the field being present, that is Rule 2: negotiate it, or make the reader fall back.
Rule 2 — a new stream opcode is NOT safe; negotiate it
decodeTerminalStreamFrame returns null for an opcode it does not know, and
runtime-rpc.ts drops that frame without an error:
const frame = decodeTerminalStreamFrame(bytes)
if (!frame) {
return // silently dropped — the sender never learns
}
So a new opcode sent to an older peer does not fail loudly. It vanishes, and the feature behind it appears to hang. Input sent under a new opcode is swallowed.
A new opcode must be announced in the subscribe handshake and sent only after the
peer confirms it. The existing pattern is SetOutputPaused (opcode 16):
- the client advertises support in the
Subscribeframe'scapabilities; - the host echoes
capabilities: { outputPause: 1 }on thesubscribedevent; - the client sends opcode 16 only after that echo (
stream.supportsOutputPause); - the host only acts on opcode 16 when it negotiated it (
stream.supportsOutputPause).
Reuse an existing opcode with a new optional payload field (Rule 1) whenever that expresses the change; reach for a new opcode only when framing genuinely differs.
Opcode numbers are permanent. See the Ack = 13 and ClaimViewport = 14 comments
in src/shared/terminal-stream-protocol.ts for why a shipped number cannot be
reused even if the feature behind it is removed.
Rule 3 — changing what the host publishes breaks old clients with no wire change
The frame shape can be untouched and the skew still real, because clients react to frame content. PR #12641 is the worked example: the host stopped synthesizing a finished agent status, and clients running older code saw different content in an identical frame.
Treat these as wire changes even though nothing in the codec moves:
- a field the host stops populating (an old client reading it now sees
undefined); - a value whose meaning, units, or nullability changes;
- content the host stops synthesizing, trims, or starts deriving from a new source;
- a frame the host stops sending, or starts sending, on an existing path.
If old clients cannot interpret the new projection correctly, gate it behind a runtime capability the same way Rule 2 gates an opcode.
Enforcement
tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts runs the real
host RPC methods and the real renderer multiplexer from two builds against each
other — current working tree against the newest release tag, in both skew
directions — over one scripted terminal journey (subscribe, input, hide/reveal
snapshot, drop, reconnect).
Run it with:
pnpm exec vitest run --config config/vitest.config.ts tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
It fails when a frame is refused by the receiving build's decoder (Rule 2), when the observed frame sequence changes (Rule 3), or when published snapshot content or negotiated capabilities differ from the contract. Adding an optional field keeps it green (Rule 1); making a client depend on that field turns the new-client/old-host pairing red.
The harness covers the terminal stream only. It does not cover the session-tab sync channel, agent-session publications, file or Git RPCs, mobile/E2EE framing, or the relay transport. A change on those paths still needs its own reasoning against the three rules above.
Worked example: agentWait on terminal and worker reads
terminal.show, orchestration.workerShow and orchestration.federationShow carry an
optional agentWait naming a pane parked on a prompt only a human can answer. It is Rule 1 —
a new optional field — but it has a second state that Rule 1 alone does not describe, and
getting that wrong turns a skew into a false "nothing is blocked".
- present object — this pane is waiting, with the evidence that proved it.
- present
null— the host evaluated this pane and nothing proves a wait. - absent — the host never evaluated it: it predates the field, the worker identity was unverifiable, the pane was unreadable, or the agent probe did not answer in time.
A new client against an old host sees the field absent, which is why absence must read as
unknown and never as not waiting. Collapsing absent into null at any hop — including a
convenience ?? null in an RPC handler — makes an old or unreachable peer indistinguishable
from a healthy idle worker, which is the exact failure the field exists to remove.
An old client against a new host ignores the key, as Rule 1 allows. New members added to
RuntimeTerminalWaitBlockedReason are also Rule 1: no consumer switches exhaustively on it,
and both the CLI and worker-start interpolate it as an opaque string.
Known debt: JSON-RPC errors drop Node's string code
An error raised on an SSH host crosses the relay as JSON-RPC, and
ssh-channel-multiplexer rebuilds it with the TRANSPORT's numeric code. Node's
string code — 'ENOENT', 'EACCES' — does not survive, so a caller on this side
cannot ask what kind of failure it was.
isENOENT in src/main/ipc/filesystem-path-containment.ts pays for that by also
matching Node's canonical message text, which is what makes remote worktree creation
work. The cost is that a host can make an unrelated failure read as "absent" by
putting that sentence in a message.
The exit is Rule 1: carry the original string code in a new optional field on the
error payload and read that instead. An old host omits it and the message match still
covers them; once hosts that send it are the floor, the message match can be deleted
rather than lived with at its ~10 call sites. Narrowing isENOENT back to .code
without doing this reinstates the bug — the transport has already overwritten it.