Align query authority contract and spawn-time ownership

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-06-10 23:32:14 -07:00
co-authored by Orca
parent ee540f32de
commit 0bcda38d63
21 changed files with 548 additions and 35 deletions
+14 -4
View File
@@ -38,9 +38,14 @@ depend on.
5. Snapshots and live bytes have ordering metadata. A view restore must not
duplicate bytes already included in the snapshot or drop bytes that arrived
after it.
6. Terminal query authority stays with the visible renderer when needed. The
headless model tracks state but must not answer DA, DSR, OSC 11, or other
shell/TUI queries that would inject replies into the PTY.
6. Terminal query authority is singular and structural: the party that
writes a chunk into a live terminal answers its queries. Visible renderer
and remote views keep xterm authority. Chunks dropped by the
hidden-delivery gate are answered exactly once by the main model
responder, from runtime-emulator state plus renderer-pushed view
attributes. Replayed, seeded, or snapshot bytes are answered by no one.
The daemon emulator never answers. (Amended by Phase 5 — see
[`terminal-query-authority.md`](./terminal-query-authority.md).)
7. The transcript contract stays separate from screen restore. `orca terminal
read` must preserve bounded previews, cursor pagination, partial-line rules,
truncation flags, and total counts even if view snapshots change shape.
@@ -116,7 +121,11 @@ Before moving more runtime behavior behind the model/view boundary, add or
extend tests that prove:
- headless snapshots rehydrate rich alternate-screen TUI state;
- headless tracking does not answer DA, DSR, OSC 11, or theme-sensitive queries;
- the daemon emulator never answers DA, DSR, OSC 11, or theme-sensitive
queries (the `session.test.ts` pins are permanent);
- the main runtime responder answers queries only from live chunks the
hidden-delivery gate dropped — never delivered, replayed, seeded, or
remote-subscribed chunks;
- hidden renderer overflow restores from model state without duplicate live
output;
- sleep/wake and worktree revisit restore from model-correct state;
@@ -135,6 +144,7 @@ Current coverage is spread across:
- `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts`
- `src/main/runtime/rpc/terminal-multiplex.test.ts`
- `src/main/runtime/orca-runtime.test.ts`
- `src/main/runtime/terminal-query-responder.test.ts`
- `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts`
- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts`
- `tests/e2e/terminal-sleep-wake-restore.spec.ts`
+40 -20
View File
@@ -150,8 +150,13 @@ actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes`
for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty
flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty
reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`)
stays authoritative. A re-seeded emulator that lost flags answers `?0u`;
protocol-conformant programs re-push.
stays authoritative. Slice 3 wires the re-seed consumer: the daemon
warm-reattach snapshot threads `modes.kittyKeyboardFlags` through the spawn
result into `seedHeadlessTerminal`, which applies them to the fresh runtime
emulator via its own `CSI = flags ; 1 u` parse (outside any forwarding
window), so hidden `CSI ? u` reports the flags the hidden app actually
pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer
`?0u`; protocol-conformant programs re-push.
### ConPTY DA1 variant
@@ -163,8 +168,8 @@ emulator parser (the main-side twin of
the forwarding predicate. The override is installed at emulator creation and
retrofitted when the spawn mark lands (daemon stream data can create the
emulator before the awaited spawn response marks the PTY). ConPTY blocking on
a missing DA1 is a spawn-time hazard; see the races section for the
hidden-at-spawn loss window that remains until Phase 6.
a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is
closed by the slice-3 `initiallyHidden` spawn flag (races section).
## Suppression: when main never replies
@@ -206,12 +211,17 @@ That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or
tolerate silence, as they did for every hidden pane before this phase). The
one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible
pane or an active codex startup window answers it from the renderer xterm.
But a PTY spawned hidden **without** the startup window has no answerer until
the renderer's hidden mark lands in main (one IPC hop after spawn): a DA1
arriving in that pre-mark window is lost. That loss window is the pre-Phase-4
hidden status quo and persists until Phase 6 marks hidden panes at spawn
(spawn-record flag, below) — spawn-time ownership is not deterministic before
then.
A PTY spawned hidden **without** the startup window previously had no
answerer until the renderer's hidden mark landed in main (one IPC hop after
spawn). Slice 3 closes that window with the `initiallyHidden` spawn-record
flag: the renderer declares hidden-at-spawn on `pty:spawn` (never while the
codex startup window would run, and never for remote-runtime transports),
and main marks the PTY hidden before the first byte — pre-spawn for
daemon-host sessions whose id is minted up front, immediately after
`provider.spawn` resolves otherwise — so the gate and responder own queries
from byte one. The pane's first visibility sync then re-marks or unmarks
through the existing Phase-4 machinery (unmark emits the restore marker for
any spawn-window drops).
## Invariants
@@ -283,16 +293,26 @@ otherwise untouched in this phase.
## What Phase 6 (delete skip grammar + startup window) requires from this design
- **Mark-before-first-byte**: panes spawned without a visible view must be
hidden-marked at spawn (spawn-record flag, not a renderer round trip) so
startup queries — including ConPTY's blocking DA1 and codex startup probes —
are main-owned from byte zero once the 10s window is gone.
- **Attributes before spawn**: the renderer must push view attributes at app
start, before any hidden spawn, or spawn-time view-attribute queries fall
into the silent-until-push rule.
- **Daemon shell-ready write gating** queues responder replies until the
ready marker; spawn-time replies on Windows daemon PTYs need explicit
validation before the window is removed.
Accepted and shipped in slice 3 (except where noted):
- **Mark-before-first-byte** (shipped): panes spawned without a visible view
are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn`
(spawn-record flag, not a renderer round trip) so startup queries —
including ConPTY's blocking DA1 — are main-owned from byte zero. Codex
startup probes stay renderer-answered while the 10s window exists: the
renderer never sets the flag for codex startups; once Phase 6 deletes the
window, dropping that exclusion makes codex spawns main-owned too.
- **Attributes before spawn** (shipped): the renderer pushes composed view
attributes once at app start (right after settings load, before terminal
reconnect/spawn), so spawn-time view-attribute queries no longer fall into
the silent-until-push rule. Per-pane appearance applies keep re-publishing
through the same deduped publisher.
- **Daemon shell-ready write gating** (verified): responder replies through
`ptyController.write` → daemon `Session.write` are QUEUED pre-ready, never
dropped, and the queue flushes at the shell-ready marker or the 15s
`SHELL_READY_TIMEOUT_MS` bound (`session.ts`). Spawn-time replies on
Windows daemon PTYs still need explicit e2e validation before the codex
window is removed.
- With the skip grammar deleted, every chunk is either written to a live
xterm or dropped — the delivered-but-skipped no-reply gap disappears and
the only remaining loss window is the mark IPC race.
@@ -34,7 +34,7 @@ Remote-runtime PTYs (`remote:`) never transit local main; the renderer
| OSC 133;D command-finished exit code | main | main | renderer |
| GitHub PR-link scan | main | main | renderer |
| Command Code output scrape | main (shipped: per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main (shipped) | renderer |
| DECSET 2031 color-scheme reply | renderer view/watcher — query authority stays with the view (contract invariant 6) | same | renderer |
| DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer |
| DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer |
## Main-Side Tracker
+8
View File
@@ -216,12 +216,20 @@ export class DaemonPtyAdapter implements IPtyProvider {
const isAltScreen = result.snapshot.modes.alternateScreen
const snapshotPayload = result.snapshot.rehydrateSequences + result.snapshot.snapshotAnsi
// Why kitty flags ride beside the payload, not inside it: the snapshot
// string reaches renderer xterms too, where POST_REPLAY_REATTACH_RESET's
// deliberate kitty reset must win. Only the runtime emulator re-seed
// consumes the flags (terminal-query-authority.md §kitty).
const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags
return {
id: sessionId,
pid,
snapshot: snapshotPayload,
snapshotCols: result.snapshot.cols,
snapshotRows: result.snapshot.rows,
...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0
? { snapshotKittyKeyboardFlags: kittyKeyboardFlags }
: {}),
isReattach: true,
isAlternateScreen: isAltScreen
}
+14
View File
@@ -157,6 +157,20 @@ export class HeadlessEmulator {
this.viewAttributeResponder?.clearColorOverrides()
}
/** Re-seed parity for snapshot `modes.kittyKeyboardFlags`
* (terminal-query-authority.md §kitty): replays the persisted flags
* through the same `CSI = flags ; 1 u` parse a live push uses, so hidden
* `CSI ? u` reports them instead of `?0u`. Routed as an UNFLAGGED write —
* outside any forwarding window, it can never answer anything — and never
* into renderer rehydrateSequences (POST_REPLAY_REATTACH_RESET's kitty
* reset stays authoritative). */
applyKittyKeyboardFlags(flags: number): Promise<void> {
if (!Number.isInteger(flags) || flags <= 0) {
return Promise.resolve()
}
return this.write(`\x1b[=${flags};1u`)
}
private emitQueryReply(reply: string): void {
if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) {
this.onQueryReply(reply)
+7 -4
View File
@@ -162,10 +162,13 @@ describe('Session', () => {
describe('emulator does not reply to terminal queries', () => {
// Why: daemon emulator parses in-process synchronously — before
// handleSubprocessData forwards bytes to the renderer over IPC — so any
// auto-reply it emits races ahead of the renderer's xterm and clobbers
// it with default-xterm values (no theme, stale cursor). The renderer is
// the authoritative responder; a daemon-side reply to any query is a bug.
// handleSubprocessData forwards bytes onward — so any auto-reply it
// emits races ahead of the live answerer and clobbers it with
// default-xterm values (no theme, stale cursor). Query authority is
// structural (terminal-query-authority.md): a delivered chunk is
// answered by the consuming view's xterm, a hidden-dropped chunk by
// MAIN's runtime model responder. The daemon emulator is neither — it
// stays write-only forever, and these pins are permanent.
it.each([
['OSC 10 foreground-color', '\x1b]10;?\x07'],
['OSC 11 background-color', '\x1b]11;?\x07'],
+4 -2
View File
@@ -35,8 +35,10 @@ export type TerminalModes = {
applicationCursor: boolean
alternateScreen: boolean
/** Kitty keyboard protocol flags (CSI = u pushes) for emulator re-seed
* parity ONLY. Produced but not yet consumed — the re-seed consumer is
* slice-3 work; do not mistake this field for live snapshot parity.
* parity ONLY. Consumed by the daemon warm-reattach path: the spawn
* result threads them into seedHeadlessTerminal, which re-applies them to
* the fresh runtime emulator (HeadlessEmulator.applyKittyKeyboardFlags)
* so hidden `CSI ? u` answers the real flags instead of ?0u.
* rehydrateSequences must never push these into a renderer xterm —
* POST_REPLAY_REATTACH_RESET's deliberate kitty reset stays authoritative
* (terminal-query-authority.md §kitty). */
+188 -2
View File
@@ -160,7 +160,11 @@ import {
rebindLocalProviderListeners,
unregisterSshPtyProvider
} from './pty'
import { _resetHiddenRendererPtyDeliveryGateForTest } from './pty-hidden-delivery-gate'
import {
_resetHiddenRendererPtyDeliveryGateForTest,
isHiddenRendererPty
} from './pty-hidden-delivery-gate'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate'
import {
encodePowerShellCommand,
@@ -430,11 +434,12 @@ describe('registerPtyHandlers', () => {
const spawn = vi.fn(async (options: { sessionId?: string }) => ({
id: options.sessionId ?? 'daemon-pty'
}))
const write = vi.fn()
let dataHandler: ((payload: { id: string; data: string }) => void) | null = null
let exitHandler: ((payload: { id: string; code: number }) => void) | null = null
setLocalPtyProvider({
spawn,
write: vi.fn(),
write,
resize: vi.fn(),
kill: vi.fn(),
shutdown: vi.fn(),
@@ -463,6 +468,7 @@ describe('registerPtyHandlers', () => {
} as never)
return {
spawn,
write,
emitData: (id: string, data: string) => dataHandler?.({ id, data }),
emitExit: (id: string, code = 0) => exitHandler?.({ id, code })
}
@@ -5425,6 +5431,186 @@ describe('registerPtyHandlers', () => {
})
})
describe('hidden-at-spawn mark (initiallyHidden)', () => {
// terminal-query-authority.md §races: the renderer declares hidden-at-
// spawn so main marks the PTY before its first byte — the spawn-time
// query window where neither side replied (the non-codex DA1 loss) is
// closed by the gate + responder owning queries from byte one.
function createRuntimeMock() {
return {
setPtyController: vi.fn(),
registerPty: vi.fn(),
noteTerminalSpawnCommand: vi.fn(),
onPtySpawned: vi.fn(),
onPtyExit: vi.fn(),
onPtyData: vi.fn(() => 42),
getPtyOutputSequence: vi.fn(() => 42),
createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'),
registerPreAllocatedHandleForPty: vi.fn()
}
}
it('marks a daemon PTY hidden before spawn resolves so byte zero is gated', async () => {
vi.useFakeTimers()
const runtime = createRuntimeMock()
const daemon = installObservableDaemonTestProvider()
const spawnGate = makeDeferred()
daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => {
await spawnGate.promise
return { id: options.sessionId ?? 'daemon-pty' }
})
try {
registerPtyHandlers(mainWindow as never, runtime as never)
const spawnPromise = handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'daemon-session',
initiallyHidden: true
}) as Promise<{ id: string }>
// Let the handler run up to the awaited provider.spawn.
await Promise.resolve()
mainWindow.webContents.send.mockClear()
// Daemon PTYs can emit prompt bytes before spawn() resolves — the
// pre-spawn mark must already gate them.
expect(isHiddenRendererPty('daemon-session')).toBe(true)
daemon.emitData('daemon-session', 'pre-spawn prompt\x1b[c')
vi.advanceTimersByTime(50)
expect(runtime.onPtyData).toHaveBeenCalledWith(
'daemon-session',
'pre-spawn prompt\x1b[c',
expect.any(Number)
)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: 'daemon-session',
reason: 'hidden-drop',
markerSeq: 42
})
spawnGate.resolve()
const result = await spawnPromise
expect(isHiddenRendererPty(result.id)).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('clears the pre-spawn hidden mark when the spawn fails', async () => {
const daemon = installObservableDaemonTestProvider()
daemon.spawn.mockRejectedValue(new Error('spawn exploded'))
registerPtyHandlers(mainWindow as never)
await expect(
handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'daemon-session',
initiallyHidden: true
})
).rejects.toThrow('spawn exploded')
// A later visible attach reusing this session id must not start gated.
expect(isHiddenRendererPty('daemon-session')).toBe(false)
})
it('marks local PTYs hidden after spawn, before their first data task', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp',
initiallyHidden: true
})) as { id: string }
mainWindow.webContents.send.mockClear()
expect(isHiddenRendererPty(spawnResult.id)).toBe(true)
mockProc.emitData('first chunk')
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', {
id: spawnResult.id,
reason: 'hidden-drop'
})
} finally {
vi.useRealTimers()
}
})
it('keeps spawns without the flag delivering to the renderer (visible unchanged)', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
spawnMock.mockReturnValue(mockProc.proc)
try {
registerPtyHandlers(mainWindow as never)
const spawnResult = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
cwd: '/tmp'
})) as { id: string }
mainWindow.webContents.send.mockClear()
expect(isHiddenRendererPty(spawnResult.id)).toBe(false)
mockProc.emitData('visible output')
vi.advanceTimersByTime(8)
expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', {
id: spawnResult.id,
data: 'visible output'
})
} finally {
vi.useRealTimers()
}
})
it('answers DA1 from the model on the first chunk of a hidden-at-spawn PTY', async () => {
// End-to-end through a REAL runtime: spawn-marked → first chunk dropped
// → runtime emulator parses the query → reply written to the provider
// input path (the renderer never saw the bytes; main is the answerer).
const daemon = installObservableDaemonTestProvider()
const runtime = new OrcaRuntimeService({
getRepo: () => undefined,
getRepos: () => [],
addRepo: () => {},
updateRepo: () => undefined as never,
getAllWorktreeMeta: () => ({}),
getWorktreeMeta: () => undefined,
setWorktreeMeta: () => undefined as never,
removeWorktreeMeta: () => {},
getGitHubCache: () => ({ pr: {}, issue: {} }) as never,
getSettings: () => ({
workspaceDir: '/tmp/workspaces',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
branchPrefix: 'none',
branchPrefixCustom: '',
terminalMainSideEffectAuthority: true,
terminalHiddenDeliveryGate: true,
terminalModelQueryAuthority: true
})
} as never)
registerPtyHandlers(mainWindow as never, runtime as never)
const result = (await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
sessionId: 'daemon-session',
initiallyHidden: true
})) as { id: string }
daemon.emitData(result.id, '\x1b[c')
// Settle the per-PTY emulator writeChain (and the reply it forwards).
await runtime.serializeMainTerminalBuffer(result.id)
expect(daemon.write).toHaveBeenCalledWith(result.id, '\x1b[?1;2c')
})
})
it('caps pending renderer delivery per PTY with oldest-drop and one restore marker', async () => {
vi.useFakeTimers()
const mockProc = createMockProc()
+49 -1
View File
@@ -2215,6 +2215,12 @@ export function registerPtyHandlers(
worktreeId?: string
sessionId?: string
shellOverride?: string
// Why: hidden-at-spawn declaration (terminal-query-authority.md
// §races) — the renderer knows at spawn time that no visible view
// will consume this PTY's bytes, so main marks it hidden BEFORE the
// first byte and the gate + model responder own spawn-time queries.
// The renderer never sets this while the codex startup window runs.
initiallyHidden?: boolean
// Why: closes the SIGKILL race documented in INVESTIGATION.md by
// letting main patch + sync-flush the (worktreeId, tabId, leafId →
// ptyId) binding before pty:spawn returns. Only the renderer's
@@ -2497,6 +2503,20 @@ export function registerPtyHandlers(
? (getSettings()?.terminalWindowsPowerShellImplementation ?? 'auto')
: undefined
}
const initiallyHidden = args.initiallyHidden === true
// Why pre-spawn for daemon-host sessions (id minted up front): daemon
// PTYs can emit prompt bytes before spawn() resolves, and the hidden
// mark must beat the first byte so the gate + model responder own
// spawn-time queries (terminal-query-authority.md §races). Other
// providers cannot emit until spawn resolves; the post-spawn mark
// below is byte-zero-safe for them.
const preSpawnHiddenMarkId =
initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined
? effectiveSessionAppId
: null
if (preSpawnHiddenMarkId !== null) {
markHiddenRendererPty(preSpawnHiddenMarkId)
}
let result: PtySpawnResult
try {
if (preAllocatedHandle) {
@@ -2504,6 +2524,11 @@ export function registerPtyHandlers(
}
result = await provider.spawn(spawnOptions)
} catch (err) {
// Why: a failed spawn must not leave a stale hidden mark on a session
// id a later visible attach may reuse.
if (preSpawnHiddenMarkId !== null) {
unmarkHiddenRendererPty(preSpawnHiddenMarkId)
}
const rawMessage = err instanceof Error ? err.message : String(err)
const spawnError = normalizeNodePtySpawnError(err)
if (effectiveSessionAppId !== undefined) {
@@ -2561,6 +2586,18 @@ export function registerPtyHandlers(
}
}
ptyOwnership.set(result.id, args.connectionId ?? null)
if (initiallyHidden) {
// Why marked synchronously before any await below: local/SSH provider
// data events dispatch on later tasks, so this is still ahead of the
// first byte's delivery decision. Idempotent for daemon hosts already
// marked pre-spawn; the renderer's first visibility sync re-marks or
// unmarks (emitting the restore marker) through the Phase-4 path.
markHiddenRendererPty(result.id)
if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) {
// Defense: never strand a mark on an id the provider renamed.
unmarkHiddenRendererPty(preSpawnHiddenMarkId)
}
}
// Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY
// determination from the spawn record before the headless seed below,
// so the runtime emulator's DA1 override exists from byte zero.
@@ -2664,7 +2701,18 @@ export function registerPtyHandlers(
? { cols: result.snapshotCols, rows: result.snapshotRows }
: undefined
if (typeof result.snapshot === 'string' && result.snapshot.length > 0) {
runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize)
// Why kitty flags ride seed metadata: the snapshot string omits
// them by design (renderer kitty reset stays authoritative), but
// the re-seeded emulator must answer hidden `CSI ? u` with the
// flags the still-running app pushed (terminal-query-authority.md).
runtime.seedHeadlessTerminal(
result.id,
result.snapshot,
seedSize,
typeof result.snapshotKittyKeyboardFlags === 'number'
? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags }
: {}
)
} else if (
result.coldRestore &&
typeof result.coldRestore.scrollback === 'string' &&
+5
View File
@@ -70,6 +70,11 @@ export type PtySpawnResult = {
* writing the snapshot so ANSI cursor positions land correctly. */
snapshotCols?: number
snapshotRows?: number
/** Kitty keyboard flags persisted in the daemon snapshot, threaded so the
* re-seeded runtime emulator answers hidden `CSI ? u` with the real flags
* (terminal-query-authority.md §kitty). Never replayed into a renderer
* xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */
snapshotKittyKeyboardFlags?: number
/** True when the spawn reattached to an existing daemon session. */
isReattach?: boolean
/** True when the reattached session uses the alternate screen buffer
+16
View File
@@ -772,6 +772,10 @@ type RuntimeHeadlessTerminal = {
type HeadlessSeedMetadata = {
cwd?: string | null
/** Persisted kitty flags from the daemon snapshot, re-applied to the fresh
* emulator so hidden `CSI ? u` answers the real flags instead of ?0u
* (terminal-query-authority.md §kitty). */
kittyKeyboardFlags?: number
}
type RuntimePtyController = {
@@ -4083,6 +4087,13 @@ export class OrcaRuntimeService {
// Why: seed writes never set forwardQueryReplies — the main-side
// replay guard. A snapshot containing old queries must answer no one.
await state.emulator.write(data)
// Why AFTER the seed write: the snapshot payload cannot carry kitty
// pushes (rehydrateSequences deliberately omits them), but ordering
// behind it keeps the parse deterministic. Unflagged like the seed —
// re-applying flags must answer no one.
if (typeof metadata.kittyKeyboardFlags === 'number') {
await state.emulator.applyKittyKeyboardFlags(metadata.kittyKeyboardFlags)
}
if (metadata.cwd !== undefined) {
state.emulator.setCwd(metadata.cwd)
}
@@ -4245,6 +4256,11 @@ export class OrcaRuntimeService {
// disposeHeadlessTerminal, and daemon respawns reuse session ids — a
// stale link's reply must never reach a successor PTY under this id.
if (state !== null && this.headlessTerminals.get(ptyId) === state) {
// Why this write is safe pre-shell-ready: daemon Session.write
// QUEUES (never drops) input while the POSIX shell-ready gate is
// pending and flushes at the ready marker or the 15s
// SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query
// reply is delayed at most that bound, not lost.
this.ptyController?.write(ptyId, reply)
}
}
@@ -295,6 +295,37 @@ describe('main-side replay guard', () => {
})
})
describe('kitty flag re-seed parity (terminal-query-authority.md §kitty)', () => {
it('answers ?u with the persisted snapshot flags after a re-seed, silently applied', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-kitty')
// Daemon warm-reattach threads modes.kittyKeyboardFlags through the
// spawn result into the seed; applying them is a seed-side write and
// must answer no one (main-side replay guard).
runtime.seedHeadlessTerminal('pty-kitty', 'restored prompt', undefined, {
kittyKeyboardFlags: 5
})
await settle(runtime, 'pty-kitty')
expect(replies).toEqual([])
runtime.onPtyData('pty-kitty', '\x1b[?u', Date.now())
await settle(runtime, 'pty-kitty')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?5u'])
})
it('answers ?0u when the snapshot carried no flags (fresh-shell paths)', async () => {
const { runtime, replies } = createResponderRuntime()
markHiddenRendererPty('pty-kitty0')
runtime.seedHeadlessTerminal('pty-kitty0', 'restored prompt')
runtime.onPtyData('pty-kitty0', '\x1b[?u', Date.now())
await settle(runtime, 'pty-kitty0')
expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?0u'])
})
})
describe('ingestion-time ownership capture', () => {
const DA1 = '\x1b[c'
+4
View File
@@ -908,6 +908,10 @@ export type PreloadApi = {
// Preserved from the deleted index.d.ts PtyApi duplicate during the
// single-source-of-truth collapse (see docs/preload-typecheck-hole.md §1).
shellOverride?: string
// Why: hidden-at-spawn declaration — main marks the PTY hidden before
// its first byte so the delivery gate + model responder own spawn-time
// queries (terminal-query-authority.md §races).
initiallyHidden?: boolean
// Why: closes the SIGKILL race documented in INVESTIGATION.md — main
// sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before
// pty:spawn returns. Only the renderer's daemon-host path threads these.
+4
View File
@@ -650,6 +650,10 @@ const api = {
worktreeId?: string
sessionId?: string
shellOverride?: string
// Why: hidden-at-spawn declaration — main marks the PTY hidden before
// its first byte so the delivery gate + model responder own spawn-time
// queries (terminal-query-authority.md §races).
initiallyHidden?: boolean
// Why: closes the SIGKILL race documented in INVESTIGATION.md by
// letting main patch + sync-flush the (worktreeId, tabId, leafId →
// ptyId) binding before pty:spawn returns. Only the renderer's
+9
View File
@@ -93,6 +93,7 @@ import {
import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility'
import { applyDocumentTheme } from './lib/document-theme'
import { getSystemPrefersDark } from './lib/terminal-theme'
import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-appearance'
import { isEditableTarget } from './lib/editable-target'
import { getSelectedTextForFileSearch } from './lib/file-search-selection'
import { useShortcutLabel } from './hooks/useShortcutLabel'
@@ -721,6 +722,14 @@ function App(): React.JSX.Element {
// Load settings first so a persisted remote runtime does not boot against
// the local filesystem and then hydrate stale local workspace state.
await actions.fetchSettings()
// Why here: hidden-at-launch PTYs (background terminal reconnects,
// agent sessions) can query OSC 10/11 before any terminal pane mounts
// and main's responder is silent-until-first-push. Publish composed
// view attributes as soon as settings exist, before any spawn below.
publishTerminalViewAttributesAtAppStart(
useAppStore.getState().settings,
getSystemPrefersDark()
)
await actions.fetchRepos()
await actions.fetchProjectGroups()
await actions.fetchAllWorktrees()
@@ -3338,6 +3338,37 @@ describe('connectPanePty', () => {
expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true)
})
it('declares hidden-at-spawn on connect for hidden non-codex panes', async () => {
enableMainAuthority()
const deps = createDeps({ isVisibleRef: { current: false } })
const { transport } = await connectHiddenPane(deps)
// Why: waiting for the first dataCallback sync left a spawn-time query
// window where neither side replied (the non-codex DA1 loss). The flag
// lets main mark the PTY hidden before its first byte.
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({ initiallyHidden: true })
)
})
it('keeps visible spawns undeclared (visible spawn unchanged)', async () => {
enableMainAuthority()
const deps = createDeps()
const { transport } = await connectHiddenPane(deps)
expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden')
})
it('never declares hidden-at-spawn while the codex startup window is active', async () => {
enableMainAuthority()
const deps = createDeps({
isVisibleRef: { current: false },
startup: { command: 'codex' }
})
const { transport } = await connectHiddenPane(deps)
// Codex startup probes need live renderer delivery for the 10s window;
// a spawn-time hidden mark would gate them (codex spawns keep delivery).
expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden')
})
it('does not gate or fact-reply when the hidden-delivery kill switch is off', async () => {
enableMainAuthority()
mockStoreState.settings = {
@@ -1993,6 +1993,7 @@ export function connectPanePty(
url: '',
cols,
rows,
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
@@ -2212,6 +2213,24 @@ export function connectPanePty(
)
}
// Why: hidden/parked panes used to mark hidden only at the first
// dataCallback sync, leaving a spawn-time window where neither side
// answered queries (the non-codex DA1 loss). Declaring hidden on the
// spawn IPC lets main mark the PTY before its first byte. Codex startups
// are excluded — their startup window needs live renderer delivery, and
// the window predicate is checked at connect time (same tick the flag is
// sent), so the two decisions cannot disagree. Remote-runtime PTYs are
// never gate-markable (no local main transit).
function shouldDeclareHiddenAtSpawn(): boolean {
return (
hiddenDeliveryGateActive &&
!runtimeEnvironmentId &&
!disposed &&
!shouldWritePtyOutputForeground(deps.isVisibleRef.current) &&
!isHiddenStartupRendererQueryWindowActive()
)
}
// ── Hidden-delivery gate sync (Phase 4) ─────────────────────────────
// Why: marks this pane's PTY hidden in main while no visible view needs
// its bytes; main then drops delivery after model ingestion and reveal
@@ -3428,6 +3447,7 @@ export function connectPanePty(
cols,
rows,
sessionId: pendingSessionId,
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
@@ -3565,6 +3585,7 @@ export function connectPanePty(
cols,
rows,
sessionId: deferredReattachSessionId,
...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}),
callbacks: {
onData: dataCallback,
onReplayData: replayDataCallback,
@@ -314,6 +314,12 @@ export type PtyTransport = {
/** Daemon session ID for reattach. When provided, the daemon reconnects
* to an existing session instead of creating a new one. */
sessionId?: string
/** Hidden-at-spawn declaration (terminal-query-authority.md): no visible
* view will consume this PTY's bytes, so main marks it hidden BEFORE the
* first byte and the gate + model responder own spawn-time queries.
* Never set while the codex startup window would run, and ignored by
* remote-runtime transports (their PTYs are not gate-markable). */
initiallyHidden?: boolean
callbacks: {
onConnect?: () => void
onDisconnect?: () => void
@@ -562,6 +562,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra
command,
...(connectionId ? { connectionId } : {}),
...(options.sessionId ? { sessionId: options.sessionId } : {}),
// Why: hidden-at-spawn mark must land in main before the PTY's
// first byte, so it rides the spawn IPC instead of the pane's
// first visibility sync (terminal-query-authority.md).
...(options.initiallyHidden ? { initiallyHidden: true } : {}),
worktreeId,
...(tabId ? { tabId } : {}),
...(leafId ? { leafId } : {}),
@@ -7,9 +7,12 @@ import {
hexToRgba,
installMode2031Handlers,
maybePushMode2031Flip,
mode2031SequenceFor
mode2031SequenceFor,
publishTerminalViewAttributesAtAppStart
} from './terminal-appearance'
import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard'
import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher'
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
function fakeTransport(overrides?: { connected?: boolean; sendOk?: boolean }): {
isConnected: () => boolean
@@ -437,6 +440,71 @@ describe('applyTerminalAppearance theme assignment', () => {
})
})
describe('publishTerminalViewAttributesAtAppStart', () => {
// Phase 6 prerequisite (terminal-query-authority.md): hidden-at-launch
// PTYs can query OSC 10/11 before any terminal pane mounts; the app-start
// publication must go out with no pane manager involved at all.
it('publishes composed attributes without any pane mount and dedupes repeats', () => {
_resetTerminalViewAttributesPublisherForTest()
const sent: TerminalViewAttributes[] = []
const send = (attributes: TerminalViewAttributes): boolean => {
sent.push(attributes)
return true
}
const settings = getDefaultSettings('/tmp')
expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(true)
expect(sent).toHaveLength(1)
expect(sent[0]!.ansi).toHaveLength(256)
expect(sent[0]!.cursorStyle).toBe(settings.terminalCursorStyle ?? 'block')
expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(false)
expect(sent).toHaveLength(1)
})
it('makes the later pane-mount applyTerminalAppearance a deduped no-op re-push', () => {
_resetTerminalViewAttributesPublisherForTest()
const publishMock = vi.fn()
;(globalThis as unknown as { window: unknown }).window = {
api: { pty: { publishTerminalViewAttributes: publishMock } }
}
try {
const settings = getDefaultSettings('/tmp')
publishTerminalViewAttributesAtAppStart(settings, true)
expect(publishMock).toHaveBeenCalledTimes(1)
// The first pane mount composes the identical app-global snapshot, so
// the publisher dedupe keeps it a single push.
const manager = {
getPanes: () => [],
setPaneLigaturesEnabled: vi.fn(),
setPaneStyleOptions: vi.fn()
} as unknown as PaneManager
applyTerminalAppearance(
manager,
settings,
true,
new Map(),
new Map(),
'false',
new Map(),
new Map()
)
expect(publishMock).toHaveBeenCalledTimes(1)
} finally {
delete (globalThis as { window?: unknown }).window
_resetTerminalViewAttributesPublisherForTest()
}
})
it('publishes nothing before settings are loaded', () => {
_resetTerminalViewAttributesPublisherForTest()
const send = vi.fn(() => true)
expect(publishTerminalViewAttributesAtAppStart(null, true, send)).toBe(false)
expect(send).not.toHaveBeenCalled()
})
})
describe('hexToRgba', () => {
it('converts 6-char hex to rgba', () => {
expect(hexToRgba('#1a1a1a', 0.72)).toBe('rgba(26, 26, 26, 0.72)')
@@ -16,6 +16,7 @@ import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides'
import type { PtyTransport } from './pty-transport'
import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt'
import { HEX_COLOR_RE } from '../../../../shared/color-validation'
import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes'
import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher'
export { mode2031SequenceFor }
@@ -196,6 +197,28 @@ export function composeActiveTerminalTheme(
return theme
}
/** App-start publication (terminal-query-authority.md §Phase 6
* prerequisites): hidden-at-launch PTYs can query OSC 10/11 before any
* terminal pane mounts, and main's responder is silent-until-first-push.
* Composes the same theme applyTerminalAppearance would and publishes it
* through the same deduped publisher, so the later pane-mount apply is a
* no-op re-push. Returns whether a publish actually went out. */
export function publishTerminalViewAttributesAtAppStart(
settings: GlobalSettings | null | undefined,
systemPrefersDark: boolean,
send?: (attributes: TerminalViewAttributes) => boolean
): boolean {
if (!settings) {
return false
}
const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark)
const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName)
const theme = composeActiveTerminalTheme(baseTheme, settings)
return send !== undefined
? publishTerminalViewAttributes(theme, appearance.mode, settings, send)
: publishTerminalViewAttributes(theme, appearance.mode, settings)
}
// Value equality over composed ITheme objects (flat string slots plus the
// extendedAnsi string array), used to gate the per-pane options.theme write.
function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean {