diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index d59a8d798..fd4a4a15c 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -138,22 +138,6 @@ describe('HeadlessEmulator', () => { }) }) - describe('onData callback', () => { - it('fires onData for terminal query responses', async () => { - const responses: string[] = [] - emulator = new HeadlessEmulator({ - cols: 80, - rows: 24, - onData: (data) => responses.push(data) - }) - - // DA1 query — xterm.js will respond with a device attributes string - await emulator.write('\x1b[c') - - expect(responses.length).toBeGreaterThan(0) - }) - }) - describe('terminal modes', () => { it('tracks bracketed paste mode', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 101cb471d..6491e435d 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -7,7 +7,6 @@ export type HeadlessEmulatorOptions = { cols: number rows: number scrollback?: number - onData?: (data: string) => void } const DEFAULT_SCROLLBACK = 5000 @@ -57,9 +56,17 @@ export class HeadlessEmulator { this.serializer = new SerializeAddon() this.terminal.loadAddon(this.serializer) - if (opts.onData) { - this.terminal.onData(opts.onData) - } + // Why no onData wiring: this emulator exists purely for state tracking + // (snapshots, cwd, mode flags). It MUST NOT respond to terminal query + // sequences (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The emulator parses + // data in-process synchronously before `handleSubprocessData` forwards + // it to the renderer over IPC, so any reply it emits would land on the + // shell's stdin ahead of the renderer's xterm reply and win the race. + // The renderer is the authoritative responder (it has the real theme, + // cursor position, and paste mode); a daemon-side reply would be a + // double-reply with wrong values. OSC 11 was the visible casualty: + // Claude Code's /theme auto always saw the emulator's default-black + // background regardless of Orca's configured terminal theme. } write(data: string): Promise { diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 119c2cf13..c15d1107e 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -156,6 +156,27 @@ 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. + it.each([ + ['OSC 11 background-color', '\x1b]11;?\x07'], + ['DA1 device-attributes', '\x1b[c'], + ['DSR cursor-position', '\x1b[6n'] + ])('does not reply to %s query', async (_label, query) => { + createSession({ shellReadySupported: false }) + subprocess.simulateData(query) + // xterm.js fires terminal.write's completion callback via a microtask; + // two resolved-promise awaits flush any nested scheduling. + await Promise.resolve() + await Promise.resolve() + expect(subprocess.written).toEqual([]) + }) + }) + describe('shell readiness gating', () => { it('buffers writes during pending state', () => { createSession({ shellReadySupported: true }) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 12c9ca067..e01627ff3 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -54,16 +54,11 @@ export class Session { this.emulator = new HeadlessEmulator({ cols: size.cols, rows: size.rows, - scrollback: opts.scrollback, - // Why: xterm.js generates query responses (DA1, DSR) asynchronously. - // If the subprocess has already exited, writing to it would hit a dead - // NAPI handle. Check session state before forwarding. - onData: (data) => { - if (this._state === 'exited' || this._disposed) { - return - } - opts.subprocess.write(data) - } + scrollback: opts.scrollback + // No onData wiring: the daemon-side emulator must never reply to + // terminal query sequences. The renderer's xterm is the authoritative + // responder; any daemon reply races ahead via in-process parsing and + // clobbers the renderer's answer. See the comment in HeadlessEmulator. }) if (opts.shellReadySupported) {