Files
orca/src/main/daemon/daemon-protocol-version.ts
T
NeilandOrca 2dac0741b4 fix(terminal): stop answering mode-2031 toggles that the same chunk withdrew (#10817)
* fix(terminal): stop answering DECSET 2031 subscriptions fish already withdrew

fish enables and disables mode 2031 around every prompt (tty_handoff.rs), so a
single PTY chunk routinely carries `?2031h ... ?2031l`. All three responders
answered the sticky "an h appeared anywhere" flag, so each prompt cycle wrote
`?997;1n` into a shell that had already handed the tty to a child — it lands as
literal text, or as stdin for whatever is reading.

pty-connection.ts's hidden-pane responder already had the right shape
(`finalState !== 'subscribed'`); this brings the other three in line:

- shared tracker: gate the '2031-subscribe' fact on the chunk-final state
- parked-tab byte sidecar: same guard
- visible-pane xterm CSI handler: xterm dispatches mid-parse, so there is no
  chunk-final state to read. Defer the reply to a microtask and re-check the
  subscription, letting a same-chunk `?2031l` cancel it.

Refs #9993

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): decide 2031 replies per PTY chunk, not per xterm parse

The previous commit deferred the visible-pane reply to a microtask so a
same-chunk `?2031l` could cancel it. That cannot work: xterm's WriteBuffer
parses every queued `terminal.write()` synchronously in one batch before any
microtask runs, so the microtask sees the net state of N PTY chunks, not of the
one that carried the subscribe. A TUI that subscribes in chunk N gets no reply
when chunk N+1 happens to withdraw, and a fish prompt straddling two writes
still gets answered.

Move the decision to where chunk boundaries actually exist — pty-connection's
dataCallback, which receives one PTY chunk per call. It scans raw bytes with
`scanMode2031Sequences`, carrying a tail across chunks so a CSI split mid-
sequence still resolves, and replies only when that chunk *ends* subscribed.

Ownership stays single: gate-managed PTYs are answered by main's
'2031-subscribe' fact, so the chunk scanner returns early for them, and the
xterm CSI handler now observes only panes the scanner does not own. The tail is
dropped on PTY replacement — a partial prefix belongs to the stream that
produced it.

Removes the microtask responder and the seed-reply retry path it needed.

Mutation-tested: 6 mutations applied, 6 killed.

* fix(terminal): carry DECSET 2031 withdrawals as a side-effect fact

The previous commit moved 2031 reply decisions to the PTY chunk boundary and
gave gate-managed panes a single owner: main's '2031-subscribe' fact. But the
fact union is subscribe-only, and that left the withdrawal unobserved.

For a gate-managed pane, main drops renderer-bound bytes after model ingestion,
the chunk scanner early-returns, and xterm's CSI handler is disabled. So when a
TUI emits `?2031l` while hidden, nothing retires the subscription: paneMode2031
stays set, and the next theme flip has maybePushMode2031Flip push `CSI ?997;2n`
into the shell that replaced the TUI — #9993 again, through the theme-change
door. Before this branch, skipHiddenRendererOutput observed those withheld
bytes; consolidating ownership removed that observer without replacing it.

No renderer-side observer can close this: the bytes are gone before the
renderer sees them. The state protocol has to carry the withdrawal, so add a
'2031-unsubscribe' fact alongside the subscribe across the three fact unions
(shared, provider, daemon). It fires only on a real chunk-final withdrawal —
a chunk with no 2031 bytes scans to null and stays silent. The renderer handler
clears both maps and sends nothing: a withdrawal is not a query.

Also closes two gaps an adversarial review found by mutation, both previously
resting on comments rather than tests: the lifecycle parser-ownership predicate
(extracted as isPaneParserOwnedMode2031Observer so it is directly testable) and
the scan-before-reconciliation ordering that lets a chunk the snapshot drops as
a duplicate still answer its query.

Mutation-tested: 12 mutations applied, 12 killed (6 from the prior round
re-run, 6 new covering this fix and the two survivors).

* fix(daemon): refuse 2031 authority from a daemon that cannot retract it

Round-2 review found a wire-compatibility hole in the original #9993 fix.

Daemons survive app updates, so a new desktop can drive a daemon that was
started by the previous build. Pre-v29 daemons emit '2031-subscribe' but
have no '2031-unsubscribe' fact at all. For a gate-managed pane, main drops
the renderer-bound bytes before the renderer sees them, so main's transient
facts are the ONLY thing that can retire a subscription. Against such a
daemon a TUI exiting while its pane is hidden leaves the subscription
registered forever, and the next theme flip injects CSI 997 into whatever
shell replaced it -- #9993 all over again, reached through the upgrade path.

Gate it: bump PROTOCOL_VERSION 28 -> 29, add
MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION with
supportsMode2031UnsubscribeFact(), and drop '2031-subscribe' from any
daemon below that floor.

Trade-off: a gate-managed pane on a preserved v28 daemon keeps
renderer-scanner authority instead of daemon-fact authority. That is exactly
the pre-fact behaviour -- correct for visible panes, no worse than today for
hidden ones -- and it resolves on the daemon's next restart. Non-2031
transient facts (bell, etc.) are unaffected at every version.

Tests: two adapter regression tests (v28 drops subscribe, v29 forwards it),
plus a version-pin test asserting the floor sits above every entry in
PREVIOUS_DAEMON_PROTOCOL_VERSIONS -- so adding a new preserved version
cannot silently re-open the hole.

Mutation-verified in both directions: `false &&` (under-block) and `true`
(over-block) each fail the new tests.

* fix(daemon): gate background delegation, not just the fact stream

A pre-v29 daemon can announce a 2031 subscribe but never retract it. Filtering
that fact is not enough: while a pane is visible main's own scanner registers
the subscription, and scan authority only moves to the daemon when the session
is backgrounded. So the gate belongs on setPtyBackgrounded — decline to hand a
non-retracting daemon authority at all, and main stays authoritative over the
whole stream.

Co-authored-by: Orca <help@stably.ai>

* fix(daemon): clear a preserved pre-v29 background hint at attach, not just at background

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): don't answer a 2031 subscribe whose withdrawal straddles a chunk

Review found the chunk-final-state fix left one hole open. When the kernel cuts
fish's toggle pair mid-withdrawal — chunk 1 ends "...?2031h prompt ESC[?20",
chunk 2 is "31l" — chunk 1 genuinely ends subscribed, so it answers, and the
reply lands as literal text at the prompt. Chunk 2 then recognizes the
withdrawal but cannot recall bytes already written. The same byte stream is
safe or corrupting purely by where the kernel split it.

The scanner already retains an incomplete private-mode tail; it just didn't
tell the caller whether that tail could still resolve to 2031. It now does, and
a subscribe is held one chunk while the answer is still in doubt. Only
subscribes defer — retiring a subscription writes nothing to the pty, so
withdrawals stay eager.

Deferral is narrow: a trailing "ESC[?25" (cursor hide) can never become 2031,
so a subscribe already seen in that chunk is still answered immediately.

This case predates the branch — the old sticky-flag policy replied here too —
so it is a residual this fix now closes rather than a regression it introduced.

Tests: three cases pinned (split withdrawal, non-2031 partial must not defer,
split re-subscribe answers once). Removing the deferral fails only the first.

* fix(terminal): preserve mode 2031 reply decisions

* fix(build): record daemon protocol v29 compatibility

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-27 17:17:47 -07:00

34 lines
1.9 KiB
TypeScript

// Why: daemons survive app updates, so wire behavior must be version-gated.
// v29 emits '2031-unsubscribe' transient facts; v20-28 emit only '2031-subscribe' (#9993).
export const PROTOCOL_VERSION = 29
export const COMPLETION_PROCESS_INSPECTION_PROTOCOL_VERSION = 27
export const GET_FOREGROUND_PROCESS_PROTOCOL_VERSION = 11
export const PTY_STARTUP_INGRESS_PROTOCOL_VERSION = 25
export const AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION = 26
export const AGENT_SESSION_CREATE_OPERATION_DAEMON_PROTOCOL_VERSION = 26
export const GIT_CREDENTIAL_GUARD_HOST_PROTOCOL_VERSION = 22
export const CLEAN_DISCONNECT_PROTOCOL_VERSION = 24
// Why (#9993): a gate-managed pane's bytes never reach the renderer, so main's
// transient facts are the only thing that can retire a 2031 subscription for it.
// Daemons before this version emit '2031-subscribe' but have no unsubscribe fact
// at all, so a TUI exiting while hidden would leave the subscription registered
// forever and the next theme flip would inject CSI 997 into whatever replaced it.
// Scan authority moves to the daemon only while a session is backgrounded, so the
// gate lives on backgrounding itself (setPtyBackgrounded): a pre-v29 daemon is
// never asked to thin, and main's scanner — which emits BOTH facts — stays
// authoritative over the whole stream. Filtering the subscribe fact alone would
// not help, because the visible-era subscription is registered by main.
export const MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION = 29
export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27,
28
] as const
export function supportsPtyStartupIngress(protocolVersion: number): boolean {
return protocolVersion >= PTY_STARTUP_INGRESS_PROTOCOL_VERSION
}
export function supportsMode2031UnsubscribeFact(protocolVersion: number): boolean {
return protocolVersion >= MODE_2031_UNSUBSCRIBE_FACT_PROTOCOL_VERSION
}