From 7f2ba67db4ccc0b1fca80a9ff97a72d4f60b2fbf Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:46:56 -0700 Subject: [PATCH 01/11] fix: wait for OpenCode worker composer before first dispatch Reuse captured composer readiness on local and paired execution hosts and revoke launching-shell paste anchors. Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../serialize-grid-transcript-replay.test.ts | 5 + ...e-1-18-32-timed-boot-hidden-pane.meta.json | 9 + ...1-18-32-timed-boot-hidden-pane.timing.json | 1 + ...pencode-1-18-32-timed-boot-hidden-pane.txt | 1 + ...opencode-1-18-32-timed-boot-slow.meta.json | 9 + ...encode-1-18-32-timed-boot-slow.timing.json | 1 + .../opencode-1-18-32-timed-boot-slow.txt | 1 + ...ncode-1-18-32-timed-first-launch.meta.json | 9 + ...ode-1-18-32-timed-first-launch.timing.json | 1 + .../opencode-1-18-32-timed-first-launch.txt | 1 + ...de-2-0-18-timed-boot-hidden-pane.meta.json | 9 + ...-2-0-18-timed-boot-hidden-pane.timing.json | 1 + ...opencode-2-0-18-timed-boot-hidden-pane.txt | 1 + .../agent-transcript-replay-test-harness.ts | 35 ++++ ...draft-paste-ready-scanner-chunking.test.ts | 191 ++++++++++++++++++ .../launched-agent-composer-readiness.test.ts | 170 ++++++++++++++++ .../launched-agent-composer-readiness.ts | 37 ++++ ...pencode-worker-composer-transcript.test.ts | 101 +++++++++ .../orca-runtime-activate-managed-worktree.ts | 4 +- ...ederation-launched-agent-readiness.test.ts | 129 ++++++++++++ .../orchestration/federation/federation.ts | 6 +- .../worker/local-worker-start.ts | 17 +- .../worker/zcode-worker-readiness.test.ts | 44 ++-- src/shared/draft-paste-ready-scanner.test.ts | 37 +++- src/shared/draft-paste-ready-scanner.ts | 35 +++- src/shared/tui-agent-config-types.ts | 2 + src/shared/tui-agent-config.ts | 13 +- 27 files changed, 818 insertions(+), 52 deletions(-) create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json create mode 100644 src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt create mode 100644 src/main/runtime/draft-paste-ready-scanner-chunking.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness.test.ts create mode 100644 src/main/runtime/launched-agent-composer-readiness.ts create mode 100644 src/main/runtime/opencode-worker-composer-transcript.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 28f0eed4459e..32a32166417c 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -35,6 +35,11 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { less: 6, nano: 2, opencode: 5, + // Captured boots expose the existing background/cursor restore loss; serializer code is unchanged. + 'opencode-1-18-32-timed-boot-hidden-pane': 16, + 'opencode-1-18-32-timed-boot-slow': 16, + 'opencode-1-18-32-timed-first-launch': 16, + 'opencode-2-0-18-timed-boot-hidden-pane': 14, // Shrink leaves the cursor one column short; also present in the pre-Qoder serializer. 'qoder-no-account': 2, 'qoder-ready': 2, diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json new file mode 100644 index 000000000000..c7e2132fa288 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:40:33.073309Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: answered like a hidden Orca pane (silent on OSC colour queries), as a background worker pane is. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'headless' = Orca's hidden-pane replies, silent on OSC colour queries. Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 42.8 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json new file mode 100644 index 000000000000..0701a967c642 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":42761.8,"chunks":[[785.0,205],[786.9,71],[791.9,8],[793.5,57],[793.9,32],[794.0,16],[794.0,16],[794.1,24],[794.2,24],[794.2,24],[794.3,36],[794.4,24],[794.5,24],[795.2,1024],[795.8,1024],[796.3,1024],[796.7,1024],[796.9,1024],[797.2,961],[811.9,1024],[812.5,1024],[813.1,1024],[813.6,1024],[814.1,1024],[814.7,937],[2523.9,1024],[2524.9,1024],[2525.2,1024],[2525.5,1024],[2525.8,1024],[2526.1,1024],[2526.3,353],[2541.6,13],[2609.5,934],[2610.0,938],[2610.6,871],[2611.3,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt new file mode 100644 index 000000000000..903a8f0f6968 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-hidden-pane.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json new file mode 100644 index 000000000000..b05bbf658fe7 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:39:12.532885Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: the slowest recorded boot: 3.9 s of silence after bracketed paste is enabled, and the longest gap (161 ms) between the bare OpenCode title and the input box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'xterm' = xterm.js 6.0 replies incl. OSC 10/11/12/4 colours (a visible Orca pane). Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 76.9 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json new file mode 100644 index 000000000000..25aa23bb5bd3 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":76886.4,"chunks":[[2454.9,276],[2478.3,8],[2513.8,1024],[2518.9,1024],[2531.6,1024],[2539.1,1024],[2581.2,1024],[2583.9,1024],[2587.4,411],[2593.0,1024],[2602.3,1024],[2607.8,1024],[2621.8,1024],[2622.5,1024],[2623.3,937],[6479.2,1024],[6485.0,1024],[6487.5,1024],[6490.0,1024],[6499.8,1024],[6502.7,1024],[6509.0,353],[6538.4,13],[6685.0,934],[6686.4,938],[6697.9,871],[6699.1,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt new file mode 100644 index 000000000000..748053e1c3ee --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-boot-slow.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h]4;0;?]4;1;?]4;2;?]4;3;?]4;4;?]4;5;?]4;6;?]4;7;?]4;8;?]4;9;?]4;10;?]4;11;?]4;12;?]4;13;?]4;14;?]4;15;?]10;?]11;?]12;?]13;?]14;?]15;?]16;?]17;?]19;?[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json new file mode 100644 index 000000000000..e0ad565f9350 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:33:44.169427Z", + "platform": "darwin", + "command": ["opencode"], + "cols": 120, + "rows": 40, + "note": "1.18.32 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: first launch on an empty home: no first-run dialog appears before the input box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'xterm' = xterm.js 6.0 replies incl. OSC 10/11/12/4 colours (a visible Orca pane). Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 14.9 s; no bytes after the last chunk. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json new file mode 100644 index 000000000000..9c1d94e0a7a6 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":14932.3,"chunks":[[894.6,205],[897.3,71],[906.1,8],[909.0,57],[909.3,32],[909.4,16],[909.5,16],[909.6,24],[909.6,24],[909.8,24],[909.9,36],[910.0,24],[910.1,24],[911.8,134],[915.0,1024],[916.4,1024],[917.0,1024],[917.5,1024],[918.0,1024],[918.6,1024],[933.4,1024],[934.1,1024],[934.7,1024],[935.3,1024],[935.8,1024],[936.4,937],[2731.2,1024],[2731.8,1024],[2732.2,1024],[2732.7,1024],[2733.1,1024],[2734.0,1024],[2734.4,353],[2766.9,13],[2847.0,934],[2847.6,938],[2848.1,871],[2848.6,584]]} diff --git a/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt new file mode 100644 index 000000000000..2baf054700fc --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-1-18-32-timed-first-launch.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h]4;0;?_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h]4;0;?]4;1;?]4;2;?]4;3;?]4;4;?]4;5;?]4;6;?]4;7;?]4;8;?]4;9;?]4;10;?]4;11;?]4;12;?]4;13;?]4;14;?]4;15;?]10;?]11;?]12;?]13;?]14;?]15;?]16;?]17;?]19;?[?2026h[?25l                                        [?2026l[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "What is the tech stack of this project?" ┃ ┃ Build · Big Pickle OpenCode Zen ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀tab agentsctrl+p commands● Tip Run /connect to add an AI provider and start coding/Users/USER1/orca-qa/b9b-capture/work1.18.32]12;#eeeeee[1 q[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json new file mode 100644 index 000000000000..5399222bf7ac --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-30T08:50:47.405221Z", + "platform": "darwin", + "command": ["opencode", "--standalone"], + "cols": 120, + "rows": 40, + "note": "opencode v2.0.18 on macOS 26.6.2 arm64, 120x40, alternate screen. Why this run: OpenCode 2 answered like a hidden Orca pane; show-cursor repeats on every frame after the box. Recorded with a Python PTY driver rather than the repo recorder, because the driver stamps every read with its time and answers the terminal queries OpenCode sends; responder 'headless' = Orca's hidden-pane replies, silent on OSC colour queries. Isolated HOME and XDG dirs, OPENCODE_DISABLE_AUTOUPDATE=1, OPENCODE_CONFIG_CONTENT {\"autoupdate\":false}, no model configured, env -i (no ORCA_*); version and binary sha256 unchanged before and after. No input typed. Recording ran 41.5 s; no bytes after the last chunk. Run as `opencode --standalone` from a 2.0.18 binary; Orca's opencode2 row launches `opencode2 --standalone`. .timing.json holds each PTY read as [ms since spawn, UTF-16 length] and recordedUntilMs.", + "exitCode": null +} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json new file mode 100644 index 000000000000..2a95872c1809 --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.timing.json @@ -0,0 +1 @@ +{"recordedUntilMs":41502.8,"chunks":[[307.5,280],[316.3,1024],[316.7,1024],[317.0,1024],[317.4,1024],[317.7,1024],[318.0,1022],[318.3,164],[318.3,24],[318.3,24],[326.8,1024],[327.5,1024],[328.0,1024],[328.6,1024],[329.1,1024],[329.7,937],[1339.7,1024],[1340.4,1024],[1341.1,1024],[1341.8,1024],[1342.5,1024],[1343.1,1024],[1343.8,353],[1348.0,13],[1387.9,934],[1388.4,938],[1388.7,832],[1418.2,483],[1436.2,483],[1453.8,488],[1471.8,497],[1489.7,498],[1508.0,504],[1525.8,504],[1543.2,504],[1560.9,504]]} diff --git a/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt new file mode 100644 index 000000000000..b2b9cd01dfee --- /dev/null +++ b/src/main/runtime/__fixtures__/opencode-2-0-18-timed-boot-hidden-pane.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25lP+q4d73\[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\_Gi=31337,s=1,v=1,a=q,t=d,f=24;AAAA\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h[?2027h[?2004h[?2026h[?25l                                        [?2026l[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[>4;0m[>5u[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2027h[?2004h[?1004h[?2026h[?25l                                        [?2026l[?2026h[?25l                                        [?2026l]0;OpenCode[?2026h[?25l  ▄ █▀▀█ █▀▀█ █▀▀█ █▀▀▄█▀▀▀ █▀▀█ █▀▀█ █▀▀██ █ █ █ █▀▀▀ █ ██  █ █ █ █ █▀▀▀▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀┃ ┃ Ask anything… "Fix a TODO in the codebase" ┃ ┃ ╹▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀/Users/USER1/orca-qa/b9b-capture/workshift+tab agentsctrl+p commands2.0.18]12;#eeeeee[1 q[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l[?2026h[?25l┃┃┃┃Build·LongCat 2.5 Preview FreeOpenCode Zen╹[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/agent-transcript-replay-test-harness.ts b/src/main/runtime/agent-transcript-replay-test-harness.ts index 35e7726bea9d..7a52bd875aa1 100644 --- a/src/main/runtime/agent-transcript-replay-test-harness.ts +++ b/src/main/runtime/agent-transcript-replay-test-harness.ts @@ -22,6 +22,41 @@ export function readRuntimeFixture(name: string): string { return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') } +export type TimedRuntimeFixture = { + /** The recorded PTY reads, in order; they concatenate to the whole `.txt`. */ + chunks: string[] + /** ms since spawn at which each read arrived. */ + times: number[] + promptSentAtMs?: number + /** When recording stopped; no bytes arrived between the last read and this. */ + recordedUntilMs?: number +} + +/** `.timing.json` holds each read as [ms since spawn, UTF-16 length]. */ +export function readTimedRuntimeFixture(name: string): TimedRuntimeFixture { + const data = readRuntimeFixture(name) + const timing: { + chunks: [number, number][] + promptSentAtMs?: number + recordedUntilMs?: number + } = JSON.parse(readFileSync(join(__dirname, '__fixtures__', `${name}.timing.json`), 'utf8')) + const chunks: string[] = [] + let offset = 0 + for (const [, length] of timing.chunks) { + chunks.push(data.slice(offset, offset + length)) + offset += length + } + if (offset !== data.length) { + throw new Error(`${name}.timing.json covers ${offset} of ${data.length} chars`) + } + return { + chunks, + times: timing.chunks.map(([at]) => at), + ...(timing.promptSentAtMs !== undefined ? { promptSentAtMs: timing.promptSentAtMs } : {}), + ...(timing.recordedUntilMs !== undefined ? { recordedUntilMs: timing.recordedUntilMs } : {}) + } +} + /** Resizes the grid before chunk `atChunk`, as a PTY resize landing mid-paint would. */ export type TranscriptReplayResize = { atChunk: number; cols: number; rows: number } diff --git a/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts new file mode 100644 index 000000000000..8fbbf19d552d --- /dev/null +++ b/src/main/runtime/draft-paste-ready-scanner-chunking.test.ts @@ -0,0 +1,191 @@ +/** + * The draft-paste scanner must answer the same way however the PTY splits a stream into reads. + * Every committed capture whose signal revokes its anchor is replayed whole, one char at a time, + * in its recorded reads (where timed) and in seeded random chunkings, and each must turn ready on + * the read holding the offset a plain string walk finds. A regression guard for grok, DSH and + * ZCode (their markers are one char, so the seam fix cannot move them), and the proof for OpenCode. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import type { DraftPasteReadySignal } from '../../shared/tui-agent-config' +import { GROK_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { GROK_INLINE_STARTUP_PTY_TRACE } from '../../shared/__fixtures__/grok-inline-startup-pty-trace' +import type { GrokStartupTraceChunk } from '../../shared/__fixtures__/grok-startup-pty-trace' +import { readRuntimeFixture, readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' + +type Walk = { anchor: string; end: string; marker: string } + +const ALT_SCREEN: Walk = { anchor: '\x1b[?1049h', end: '\x1b[?1049l', marker: '' } +const WALKS: Partial> = { + 'render-cursor-after-bracketed-paste': { + anchor: '\x1b[?2004h', + end: '\x1b[?2004l', + marker: '\x1b[?25h' + }, + 'grok-composer-prompt': { ...ALT_SCREEN, marker: '❯' }, + 'dsh-composer-prompt': { ...ALT_SCREEN, marker: '❯' }, + 'zcode-composer-prompt': { ...ALT_SCREEN, marker: '╭' } +} + +// Recorded zsh shape: the prompt enables bracketed paste and accept-line disables it before exec. +// The launcher's cursor toggle after that is synthetic, standing in for any spinner. +const ZSH_LAUNCH_PROLOGUE = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' + +const OPENCODE_TIMED = [ + 'opencode-1-18-32-timed-boot-slow', + 'opencode-1-18-32-timed-boot-hidden-pane', + 'opencode-1-18-32-timed-first-launch', + 'opencode-2-0-18-timed-boot-hidden-pane' +] + +type Case = { name: string; signal: DraftPasteReadySignal; data: string; reads?: string[] } + +function readPtyTranscript(name: string): string { + return readFileSync( + join(__dirname, '..', 'daemon', '__fixtures__', 'pty-transcripts', name), + 'utf8' + ) +} + +function grokCase(name: string, trace: GrokStartupTraceChunk[]): Case { + const reads = trace.map((chunk) => chunk.data ?? 'x'.repeat(chunk.bytes ?? 0)) + return { name, signal: 'grok-composer-prompt', data: reads.join(''), reads } +} + +const CASES: Case[] = [ + grokCase('grok 1.0.0 alt screen', GROK_STARTUP_PTY_TRACE), + grokCase('grok 1.0.0 inline', GROK_INLINE_STARTUP_PTY_TRACE), + { + name: 'dsh-tui-ready-no-key', + signal: 'dsh-composer-prompt', + data: readRuntimeFixture('dsh-tui-ready-no-key') + }, + { + name: 'zcode-composer-ready', + signal: 'zcode-composer-prompt', + data: readRuntimeFixture('zcode-composer-ready') + }, + { + name: 'opencode (untimed pty transcript)', + signal: 'render-cursor-after-bracketed-paste', + data: readPtyTranscript('opencode.txt') + }, + ...OPENCODE_TIMED.flatMap((name): Case[] => { + const { chunks } = readTimedRuntimeFixture(name) + return [ + { name, signal: 'render-cursor-after-bracketed-paste', data: chunks.join(''), reads: chunks }, + { + name: `${name} behind a zsh launch`, + signal: 'render-cursor-after-bracketed-paste', + data: ZSH_LAUNCH_PROLOGUE + chunks.join(''), + reads: [ZSH_LAUNCH_PROLOGUE, ...chunks] + } + ] + }) +] + +/** End offset of the first marker seen while the anchor is held, walking the whole string. */ +function walkReadyEnd(data: string, { anchor, end, marker }: Walk): number | null { + let cursor = 0 + for (;;) { + const enter = data.indexOf(anchor, cursor) + if (enter === -1) { + return null + } + cursor = enter + anchor.length + const leave = data.indexOf(end, cursor) + const found = data.indexOf(marker, cursor) + if (found !== -1 && (leave === -1 || found + marker.length <= leave)) { + return found + marker.length + } + if (leave === -1) { + return null + } + cursor = leave + end.length + } +} + +/** [start, end) of the first read the scanner reports ready on, or null. */ +function scanReadyRead(signal: DraftPasteReadySignal, reads: string[]): [number, number] | null { + const scanner = createDraftPasteReadyScanner(signal) + let offset = 0 + for (const read of reads) { + if (scanner.observe(read).ready) { + return [offset, offset + read.length] + } + offset += read.length + } + return null +} + +function splitAt(data: string, sizes: () => number): string[] { + const reads: string[] = [] + for (let offset = 0; offset < data.length;) { + const size = sizes() + reads.push(data.slice(offset, offset + size)) + offset += size + } + return reads +} + +// mulberry32: a fixed seed keeps every chunking reproducible. +function seededSizes(seed: number, max: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b79f5) | 0 + let t = Math.imul(state ^ (state >>> 15), 1 | state) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return 1 + Math.floor((((t ^ (t >>> 14)) >>> 0) / 4294967296) * max) + } +} + +function chunkings(testCase: Case): Map { + const { data } = testCase + const result = new Map([ + ['whole', [data]], + ['one char per read', splitAt(data, () => 1)] + ]) + if (testCase.reads) { + result.set('recorded reads', testCase.reads) + } + for (let seed = 0; seed < 12; seed += 1) { + const max = seed % 2 === 0 ? 16 : 2048 + result.set(`random chunking ${seed}`, splitAt(data, seededSizes(seed + 1, max))) + } + return result +} + +describe('draft-paste readiness does not depend on how the stream is chunked', () => { + it.each(CASES.map((testCase) => [testCase.name, testCase] as const))('%s', (_, testCase) => { + const walk = WALKS[testCase.signal] + if (!walk) { + throw new Error(`no reference walk for ${testCase.signal}`) + } + const expected = walkReadyEnd(testCase.data, walk) + for (const [label, reads] of chunkings(testCase)) { + const readyRead = scanReadyRead(testCase.signal, reads) + if (expected === null) { + expect(readyRead, label).toBeNull() + continue + } + expect(readyRead, label).not.toBeNull() + const [start, end] = readyRead! + expect( + start < expected && expected <= end, + `${label}: [${start}, ${end}) vs ${expected}` + ).toBe(true) + } + }) + + it.each(OPENCODE_TIMED)('%s: a zsh launch in front moves nothing but the offset', (name) => { + const walk = WALKS['render-cursor-after-bracketed-paste']! + const data = readTimedRuntimeFixture(name).chunks.join('') + const alone = walkReadyEnd(data, walk) + expect(alone).not.toBeNull() + expect(walkReadyEnd(ZSH_LAUNCH_PROLOGUE + data, walk)).toBe(ZSH_LAUNCH_PROLOGUE.length + alone!) + }) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.test.ts b/src/main/runtime/launched-agent-composer-readiness.test.ts new file mode 100644 index 000000000000..7f8e7a18fa15 --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.test.ts @@ -0,0 +1,170 @@ +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { + readRuntimeFixture, + readTimedRuntimeFixture, + replayTranscript +} from './agent-transcript-replay-test-harness' +import { + getLaunchedAgentReadinessLane, + type LaunchedAgentReadinessLane +} from './launched-agent-composer-readiness' + +// Why a full Record: adding a TuiAgent fails to compile here until someone decides whether its +// worker start waits for a captured input-box marker, and a row that gains or loses evidence +// shows up in review. +const EXPECTED_LANES: Record = { + zcode: 'composer-marker', + opencode: 'composer-marker', + opencode2: 'composer-marker', + // mimo-code shares OpenCode's signal by parity only; no mimo boot has been recorded. + 'mimo-code': 'tui-idle', + // Grok's inline mode never fires its marker; its marker is not proven at the box for every mode. + grok: 'tui-idle', + dsh: 'tui-idle', + codex: 'tui-idle', + cursor: 'tui-idle', + pi: 'tui-idle', + omp: 'tui-idle', + droid: 'tui-idle', + hermes: 'tui-idle', + devin: 'tui-idle', + claude: 'tui-idle', + 'claude-agent-teams': 'tui-idle', + openclaude: 'tui-idle', + gemini: 'tui-idle', + antigravity: 'tui-idle', + aider: 'tui-idle', + openclaw: 'tui-idle', + copilot: 'tui-idle', + muse: 'tui-idle', + qoder: 'tui-idle', + codebuddy: 'tui-idle', + autohand: 'tui-idle', + ante: 'tui-idle', + trae: 'tui-idle', + 'prime-agent': 'tui-idle', + goose: 'tui-idle', + amp: 'tui-idle', + kilo: 'tui-idle', + kiro: 'tui-idle', + crush: 'tui-idle', + aug: 'tui-idle', + cline: 'tui-idle', + codebuff: 'tui-idle', + freebuff: 'tui-idle', + 'command-code': 'tui-idle', + continue: 'tui-idle', + kimi: 'tui-idle', + 'mistral-vibe': 'tui-idle', + 'qwen-code': 'tui-idle', + rovo: 'tui-idle' +} + +const FIXTURES = join(__dirname, '__fixtures__') +const OPENCODE_PLACEHOLDER = 'Ask anything' +const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + +const CITED_CAPTURES = Object.entries(TUI_AGENT_CONFIG).flatMap(([agent, row]) => + (row.composerReadyCaptures ?? []).map((capture) => [agent, capture] as const) +) + +function isTuiAgent(agent: string): agent is TuiAgent { + return agent in TUI_AGENT_CONFIG +} + +function readSignal(agent: string) { + if (!isTuiAgent(agent)) { + throw new Error(`${agent} is not a TuiAgent`) + } + const signal = TUI_AGENT_CONFIG[agent].draftPasteReadySignal + if (!signal) { + throw new Error(`${agent} cites composer-ready captures but has no draftPasteReadySignal`) + } + return signal +} + +/** Index of the first read the scanner reports ready on, or -1. */ +function firstReadyRead(agent: string, reads: readonly string[]): number { + const scanner = createDraftPasteReadyScanner(readSignal(agent)) + return reads.findIndex((read) => scanner.observe(read).ready) +} + +function readReads(capture: string): string[] { + if (existsSync(join(FIXTURES, `${capture}.timing.json`))) { + return readTimedRuntimeFixture(capture).chunks + } + const data = readRuntimeFixture(capture) + const reads: string[] = [] + for (let offset = 0; offset < data.length; offset += 4096) { + reads.push(data.slice(offset, offset + 4096)) + } + return reads +} + +describe('which lane a freshly launched worker waits on', () => { + it.each(Object.entries(EXPECTED_LANES))('%s: %s', (agent, lane) => { + if (!isTuiAgent(agent)) { + throw new Error(`${agent} is not a TuiAgent`) + } + expect(getLaunchedAgentReadinessLane(agent)).toBe(lane) + }) +}) + +describe('every capture a row cites proves its input-box marker', () => { + it('cites at least the rows worker start moved', () => { + expect(CITED_CAPTURES.map(([agent]) => agent)).toEqual( + expect.arrayContaining(['zcode', 'opencode', 'opencode2']) + ) + }) + + it.each(CITED_CAPTURES)('%s: %s is committed with its sidecar', (_agent, capture) => { + expect(existsSync(join(FIXTURES, `${capture}.txt`))).toBe(true) + const meta: { platform?: string; command?: string[]; note?: string } = JSON.parse( + readFileSync(join(FIXTURES, `${capture}.meta.json`), 'utf8') + ) + expect(meta.platform).toEqual(expect.any(String)) + expect(meta.command?.length).toBeGreaterThan(0) + expect(meta.note).toEqual(expect.any(String)) + }) + + it.each(CITED_CAPTURES)("%s: the row's current signal fires on %s", (agent, capture) => { + expect(firstReadyRead(agent, readReads(capture))).toBeGreaterThanOrEqual(0) + }) + + it.each( + CITED_CAPTURES.filter(([agent]) => readSignal(agent) === 'render-cursor-after-bracketed-paste') + )( + '%s: the first ready read in %s is the one that shows the input box', + async (agent, capture) => { + const meta: { cols: number; rows: number } = JSON.parse( + readFileSync(join(FIXTURES, `${capture}.meta.json`), 'utf8') + ) + const { chunks } = readTimedRuntimeFixture(capture) + const data = chunks.join('') + // OpenCode paints its box in one synchronized update, shown when that update ends. + const boxEnd = data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + let boxRead = -1 + for (let index = 0, end = 0; index < chunks.length && boxRead === -1; index += 1) { + end += chunks[index].length + boxRead = end >= boxEnd + SYNCHRONIZED_UPDATE_END.length ? index : -1 + } + let placeholderRead = -1 + let read = 0 + for await (const frame of replayTranscript(chunks, meta.cols, meta.rows)) { + if (frame.screenLines.some((line) => line.includes(OPENCODE_PLACEHOLDER))) { + placeholderRead = read + break + } + read += 1 + } + expect(placeholderRead).toBeGreaterThanOrEqual(0) + expect(boxRead).toBeGreaterThanOrEqual(placeholderRead) + expect(firstReadyRead(agent, chunks)).toBe(boxRead) + } + ) +}) diff --git a/src/main/runtime/launched-agent-composer-readiness.ts b/src/main/runtime/launched-agent-composer-readiness.ts new file mode 100644 index 000000000000..dedfeba8823e --- /dev/null +++ b/src/main/runtime/launched-agent-composer-readiness.ts @@ -0,0 +1,37 @@ +import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import type { OrcaRuntimeService } from './orca-runtime' + +export type LaunchedAgentReadinessLane = 'composer-marker' | 'tui-idle' + +export type LaunchedAgentReadinessRuntime = Pick< + OrcaRuntimeService, + 'waitForTerminal' | 'waitForFreshWorkerComposer' +> + +export function getLaunchedAgentReadinessLane(agent: TuiAgent): LaunchedAgentReadinessLane { + return TUI_AGENT_CONFIG[agent].composerReadyCaptures?.length ? 'composer-marker' : 'tui-idle' +} + +export function waitForLaunchedAgentComposer( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + agent: TuiAgent, + timeoutMs: number +): Promise { + return getLaunchedAgentReadinessLane(agent) === 'composer-marker' + ? runtime.waitForFreshWorkerComposer(handle, agent, timeoutMs) + : runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }) +} + +export function waitForWorkerAgentReady( + runtime: LaunchedAgentReadinessRuntime, + handle: string, + args: { agent: TuiAgent | undefined; reusesTerminal: boolean; timeoutMs: number } +): Promise { + // A caller-supplied terminal was not freshly launched, so its composer marker may be long gone. + return args.agent && !args.reusesTerminal + ? waitForLaunchedAgentComposer(runtime, handle, args.agent, args.timeoutMs) + : runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: args.timeoutMs }) +} diff --git a/src/main/runtime/opencode-worker-composer-transcript.test.ts b/src/main/runtime/opencode-worker-composer-transcript.test.ts new file mode 100644 index 000000000000..52aa2f4da86e --- /dev/null +++ b/src/main/runtime/opencode-worker-composer-transcript.test.ts @@ -0,0 +1,101 @@ +/** + * OpenCode worker start, replayed through the runtime at the recorded read times. The pane first + * shows a zsh launch that names it `opencode` (a shell auto-title, as oh-my-zsh's preexec writes), + * so a wait that trusts a bare-name title has its answer before OpenCode draws anything. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TuiAgent } from '../../shared/tui-agent' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { readTimedRuntimeFixture } from './agent-transcript-replay-test-harness' +import { waitForLaunchedAgentComposer } from './launched-agent-composer-readiness' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// Recorded zsh shape (prompt enables bracketed paste, accept-line disables it); the auto-title +// and the launcher's cursor toggle are synthetic. +const ZSH_LAUNCH = + '\x1b[?2004h% opencode\x1b[?2004l\r\n\x1b]2;opencode\x07\x1b[?25lresolving\x1b[?25h\r\n' +const OPENCODE_PLACEHOLDER = 'Ask anything' +const SYNCHRONIZED_UPDATE_END = '\x1b[?2026l' + +const RUNS: [string, TuiAgent][] = [ + ['opencode-1-18-32-timed-boot-slow', 'opencode'], + ['opencode-1-18-32-timed-boot-hidden-pane', 'opencode'], + ['opencode-1-18-32-timed-first-launch', 'opencode'], + ['opencode-2-0-18-timed-boot-hidden-pane', 'opencode2'] +] + +/** The read that ends the synchronized update drawing OpenCode's input box. */ +function boxRead(chunks: string[]): number { + const data = chunks.join('') + const boxEnd = + data.indexOf(SYNCHRONIZED_UPDATE_END, data.indexOf(OPENCODE_PLACEHOLDER)) + + SYNCHRONIZED_UPDATE_END.length + let end = 0 + return chunks.findIndex((chunk) => (end += chunk.length) >= boxEnd) +} + +async function replay(name: string, agent: TuiAgent) { + const { chunks, times } = readTimedRuntimeFixture(name) + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'opencode', + launchAgent: agent, + size: { cols: 120, rows: 40 }, + data: '' + }) + vi.useFakeTimers() + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, ZSH_LAUNCH, Date.now()) + const settledAt: { composer: number | null; tuiIdle: number | null } = { + composer: null, + tuiIdle: null + } + let read = -1 + const composer = waitForLaunchedAgentComposer(runtime, handle, agent, 60_000) + void composer.then(() => (settledAt.composer ??= read)) + const tuiIdle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 60_000 }) + void tuiIdle.then( + () => (settledAt.tuiIdle ??= read), + () => {} + ) + let now = 0 + await vi.advanceTimersByTimeAsync(0) + for (const [index, chunk] of chunks.entries()) { + await vi.advanceTimersByTimeAsync(Math.max(0, times[index] - now)) + now = times[index] + read = index + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, chunk, Date.now()) + await vi.advanceTimersByTimeAsync(0) + } + return { settledAt, composer, boxRead: boxRead(chunks) } +} + +describe('an OpenCode worker gets its task only once its input box exists', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it.each(RUNS)( + '%s: the worker-start lane settles on the read that shows the box', + async (name, agent) => { + const { settledAt, composer, boxRead } = await replay(name, agent) + await expect(composer).resolves.toMatchObject({ satisfied: true }) + expect(settledAt.composer).toBe(boxRead) + } + ) + + it.each(RUNS)( + '%s: the bare-name tui-idle wait main used would have settled before the box', + async (name, agent) => { + const { settledAt, boxRead } = await replay(name, agent) + expect(settledAt.tuiIdle).not.toBeNull() + expect(settledAt.tuiIdle!).toBeLessThan(boxRead) + } + ) +}) diff --git a/src/main/runtime/orca-runtime-activate-managed-worktree.ts b/src/main/runtime/orca-runtime-activate-managed-worktree.ts index 2f7e59a6d1f8..0b50aa9a5d77 100644 --- a/src/main/runtime/orca-runtime-activate-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-activate-managed-worktree.ts @@ -5,6 +5,7 @@ import { navigationTargetsClients, navigationTargetsHost } from '../../shared/ru import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeTerminalWait } from '../../shared/runtime-terminal-contracts' import type { WorktreeStartupLaunch } from '../../shared/worktree/launch-types' import type { WorktreeStartupDraftPaste, @@ -182,7 +183,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM handle: string, agent: TuiAgent, timeoutMs: number - ): Promise { + ): Promise { const initialPtyId = this.getLivePtyForHandle(handle)?.pty.ptyId ?? this.getLiveLeafForHandle(handle).leaf.ptyId const ptyId = await waitForWorktreeStartupDraft( @@ -198,6 +199,7 @@ export class OrcaRuntimeWithActivateManagedWorktree extends OrcaRuntimeWithListM if (!this.ptysById.get(ptyId)?.connected) { throw new Error('terminal_handle_stale') } + return { handle, condition: 'tui-idle', satisfied: true, status: 'running', exitCode: null } } protected sendStartupFollowupWhenReady(handle: string, followup: WorktreeStartupFollowup): void { diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts new file mode 100644 index 000000000000..4a6da545fbcd --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation-launched-agent-readiness.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveWorkerStartReadinessTimeoutMs } from '../../../../../../shared/orchestration-timing-budgets' +import { OrcaRuntimeService } from '../../../../orca-runtime' +import { OrchestrationDb } from '../../../../orchestration/db' +import { ORCHESTRATION_METHODS } from '../../orchestration' +import { configureFederationWorkerRuntime } from './federation-runtime.test-support' + +const HANDLE = 'term_windows_worker' +const WORKTREE = 'repo::windows-worktree' + +// Why: `worker-start --on ` launches the agent through this attach on the worker host, so +// it must wait for the same input box a local start waits for, or the task is typed too early. +describe('federated worker first dispatch readiness', () => { + const databases: OrchestrationDb[] = [] + + afterEach(() => { + for (const db of databases.splice(0)) { + db.close() + } + vi.restoreAllMocks() + }) + + function createWorkerHost(): OrcaRuntimeService { + const db = new OrchestrationDb(':memory:') + databases.push(db) + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + configureFederationWorkerRuntime(runtime) + vi.spyOn(runtime, 'waitForFreshWorkerComposer').mockResolvedValue({ + handle: HANDLE, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + return runtime + } + + async function attach( + runtime: OrcaRuntimeService, + target: { agent: string } | { terminal: string } + ): Promise { + const method = ORCHESTRATION_METHODS.find( + (candidate) => candidate.name === 'orchestration.federationAttachStart' + ) + if (!method) { + throw new Error('federationAttachStart method is not registered') + } + const placement = + 'agent' in target + ? { worktree: 'new-top-level', repo: 'windows-repo', name: 'remote-worker', ...target } + : { worktree: WORKTREE, ...target } + return await method.handler( + method.params!.parse({ + runId: 'run-home', + dispatchId: 'ctx_remote', + taskId: 'task_remote', + taskSpec: 'remote worker', + protocolVersion: 3, + ...placement + }), + { + runtime, + orchestrationMutation: { + callerFingerprint: 'home_peer', + requestId: 'request_remote', + method: 'orchestration.federationAttachStart', + payloadHash: 'remote_payload' + } + } + ) + } + + it.each(['opencode', 'opencode2', 'zcode'] as const)( + 'a %s terminal this attach created waits for its input box, not tui-idle', + async (agent) => { + const runtime = createWorkerHost() + + await expect(attach(runtime, { agent })).resolves.toMatchObject({ state: 'ready' }) + + expect(runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + HANDLE, + agent, + resolveWorkerStartReadinessTimeoutMs(undefined) + ) + expect(runtime.waitForTerminal).not.toHaveBeenCalled() + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + HANDLE, + expect.any(String), + expect.any(Object) + ) + } + ) + + it('a reused --terminal keeps the tui-idle wait', async () => { + const runtime = createWorkerHost() + vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the reuse path reads only the worktree id. + { id: WORKTREE } as never + ) + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + + await expect(attach(runtime, { terminal: HANDLE })).resolves.toMatchObject({ state: 'ready' }) + + expect(runtime.waitForFreshWorkerComposer).not.toHaveBeenCalled() + expect(runtime.waitForTerminal).toHaveBeenCalledWith( + HANDLE, + expect.objectContaining({ condition: 'tui-idle' }) + ) + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + HANDLE, + expect.any(String), + expect.any(Object) + ) + }) + + it('fails at agent_readiness without typing the task when the input box never appears', async () => { + const runtime = createWorkerHost() + vi.mocked(runtime.waitForFreshWorkerComposer).mockRejectedValue(new Error('timeout')) + + await expect(attach(runtime, { agent: 'opencode' })).resolves.toMatchObject({ + state: 'failed', + failedStage: 'agent_readiness', + lastError: 'timeout' + }) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts index ca63c1fb46d7..6bd678e2f6d9 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federation.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federation.ts @@ -1,3 +1,4 @@ +import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' import type { TuiAgent } from '../../../../../../shared/tui-agent' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import { @@ -217,8 +218,9 @@ export const ORCHESTRATION_FEDERATION_ATTACH_METHODS = [ } persistFederatedReadinessStage(setupStage) failedStage = 'agent_readiness' - const wait = await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', + const wait = await waitForWorkerAgentReady(runtime, terminalHandle, { + agent, + reusesTerminal: Boolean(params.terminal), timeoutMs: readinessTimeoutMs }) persistFederatedSetupWaitOutcome({ ...setupStage, wait }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 7e10274435b5..049f6503ac15 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -1,3 +1,4 @@ +import { waitForWorkerAgentReady } from '../../../../launched-agent-composer-readiness' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import type { OrchestrationDb } from '../../../../orchestration/db' @@ -188,17 +189,11 @@ export async function startLocalWorker(args: { effects, timeoutMs: params.timeoutMs ?? 60_000 }) - : // ZCode emits SessionStart only after input; its first dispatch must wait for the composer. - agent === 'zcode' && !params.terminal - ? await runtime.waitForFreshWorkerComposer( - terminalHandle, - agent, - params.timeoutMs ?? 60_000 - ) - : await runtime.waitForTerminal(terminalHandle, { - condition: 'tui-idle', - timeoutMs: params.timeoutMs ?? 60_000 - }) + : await waitForWorkerAgentReady(runtime, terminalHandle, { + agent, + reusesTerminal: Boolean(params.terminal), + timeoutMs: params.timeoutMs ?? 60_000 + }) if (wait) { persistWorkerSetupWaitOutcome({ ...setupStage, wait }) if (!wait.satisfied) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts index 30d32c2ede83..9421596669d6 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/zcode-worker-readiness.test.ts @@ -1,28 +1,38 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeTerminalWait } from '../../../../../../shared/runtime-terminal-contracts' import { createOrchestrationWorkerReleaseHarness } from './worker-release.test-support' describe('ZCode first dispatch readiness', () => { const h = createOrchestrationWorkerReleaseHarness() afterEach(() => h.cleanup()) - it('waits for the new composer before delivering exactly one dispatch', async () => { - h.setup() - const gate = h.deferred() - vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) - const pending = h.startWorker({ agent: 'zcode' }) - await vi.waitFor(() => - expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( - 'term_worker', - 'zcode', - 60_000 + it.each(['zcode', 'opencode', 'opencode2'] as const)( + '%s waits for the new composer before dispatch', + async (agent) => { + h.setup() + const gate = h.deferred() + vi.spyOn(h.runtime, 'waitForFreshWorkerComposer').mockReturnValue(gate.promise) + const pending = h.startWorker({ agent }) + await vi.waitFor(() => + expect(h.runtime.waitForFreshWorkerComposer).toHaveBeenCalledWith( + 'term_worker', + agent, + 60_000 + ) ) - ) - expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() - expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() - gate.resolve() - await pending - expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() - }) + expect(h.runtime.waitForTerminal).not.toHaveBeenCalled() + expect(h.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + gate.resolve({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + await pending + expect(h.runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + } + ) it('keeps reused terminals on the normal idle wait', async () => { h.setup() diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index a37111a2e22c..feaa40fa260a 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const DECRST_BRACKETED_PASTE = '\x1b[?2004l' const SHOW_CURSOR = '\x1b[?25h' const HIDE_CURSOR = '\x1b[?25l' const CODEX_PROMPT = '\x1b[1m›\x1b[0m Ask Codex to do anything' @@ -87,6 +88,27 @@ describe('createDraftPasteReadyScanner', () => { }) }) + it('never joins a show-cursor across a chunk seam from bytes it already scanned', () => { + // Why: the stream holds only a hide-cursor; re-reading carried chars used to assemble a show. + const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + expect(scanner.observe(DECSET_BRACKETED_PASTE).ready).toBe(false) + expect(scanner.observe('Search \x1b[?25').ready).toBe(false) + expect(scanner.observe('l more text').ready).toBe(false) + }) + + it('ignores a show-cursor after the shell turns bracketed paste back off to run a command', () => { + // zsh's prompt enables bracketed paste and disables it on accept-line, before the launcher + // runs; the launcher's cursor toggle stands in for any spinner (synthetic). + const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') + expect(scanner.observe(`${DECSET_BRACKETED_PASTE}% opencode`).ready).toBe(false) + expect(scanner.observe(`${DECRST_BRACKETED_PASTE}\r\n`).ready).toBe(false) + expect(scanner.observe(`${HIDE_CURSOR}resolving${SHOW_CURSOR}`).ready).toBe(false) + expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}`)).toEqual({ + ready: true, + armQuietTimer: false + }) + }) + it('ignores show-cursor that appears before bracketed paste is enabled', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') // A pre-handshake cursor toggle must not trip readiness. @@ -272,17 +294,16 @@ describe('createDraftPasteReadyScanner', () => { }) }) - describe('Windows ConPTY, which never forwards DECSET 2004', () => { - // Why: measured on a remote Windows host (terminal-agent-paste-bracketing.test.ts) — the - // agent's `\x1b[?2004h` is consumed by conhost and never reaches the client stream. Every - // signal below is anchored on it, so on Windows readiness cannot resolve and delivery always - // falls through to the caller's hard timeout and its blind process-ownership paste (#22479). - const WINDOWS_OPENCODE_FRAME = `${HIDE_CURSOR}\x1b[2J\x1b[H opencode ${SHOW_CURSOR}` + describe('a stream that never carries DECSET 2004', () => { + // Why: every signal below is anchored on `\x1b[?2004h`, so without it readiness cannot resolve + // and delivery falls through to the caller's hard timeout. A transport that loses the sequence + // lands here; terminal-agent-paste-bracketing.ts names remote replay and ConPTY as possible. + const ANCHORLESS_OPENCODE_FRAME = `${HIDE_CURSOR}\x1b[2J\x1b[H opencode ${SHOW_CURSOR}` it('never reports opencode ready from show-cursor frames alone', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') for (let frame = 0; frame < 5; frame += 1) { - expect(scanner.observe(WINDOWS_OPENCODE_FRAME)).toEqual({ + expect(scanner.observe(ANCHORLESS_OPENCODE_FRAME)).toEqual({ ready: false, armQuietTimer: false }) @@ -291,7 +312,7 @@ describe('createDraftPasteReadyScanner', () => { it('never arms the default quiet window either', () => { const scanner = createDraftPasteReadyScanner('render-quiet-after-bracketed-paste') - expect(scanner.observe(WINDOWS_OPENCODE_FRAME)).toEqual({ + expect(scanner.observe(ANCHORLESS_OPENCODE_FRAME)).toEqual({ ready: false, armQuietTimer: false }) diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index f7a9cb7c0c52..c079c724c601 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -4,6 +4,7 @@ import type { DraftPasteReadySignal } from './tui-agent-config' // actually mounted/focused. These markers let the scanner detect the real // "input is ready" moment per agent instead of guessing from output silence. const DECSET_BRACKETED_PASTE = '\x1b[?2004h' +const DECRST_BRACKETED_PASTE = '\x1b[?2004l' const CODEX_COMPOSER_PROMPT = '›' // Why: opencode emits the DECTCEM show-cursor only once the composer row is // mounted and the text cursor is placed in it — a "composer ready" signal, @@ -48,7 +49,10 @@ const DRAFT_PASTE_READY_SIGNALS: Record { + const scanRevocableAnchorSegments = ( + window: string, + carriedLength: number, + anchor: string, + end: string + ): boolean => { let cursor = 0 while (cursor < window.length) { if (!sawMarkerAnchor) { @@ -191,7 +201,11 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) continue } const leaveIndex = window.indexOf(end, cursor) - const segment = leaveIndex === -1 ? window.slice(cursor) : window.slice(cursor, leaveIndex) + // Why: carried chars only rejoin a split anchor or leave; postAnchorRecent already holds + // them, so re-reading them could join a marker across the duplicated seam. + const segmentStart = Math.max(cursor, carriedLength) + const segment = + leaveIndex === -1 ? window.slice(segmentStart) : window.slice(segmentStart, leaveIndex) if ((postAnchorRecent + segment).includes(signalMarker ?? '')) { return true } @@ -251,9 +265,10 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) if (markerAnchorEnd !== null) { // Why: carry only the bytes an anchor could straddle, so already-scanned // output is never re-walked into a second enter/leave transition. + const carriedLength = anchorCarry.length const window = anchorCarry + data anchorCarry = window.slice(-ANCHOR_CARRY_CHARS) - if (scanRevocableAnchorSegments(window, markerAnchor, markerAnchorEnd)) { + if (scanRevocableAnchorSegments(window, carriedLength, markerAnchor, markerAnchorEnd)) { return { ready: true, armQuietTimer: false } } } else if (!sawMarkerAnchor) { diff --git a/src/shared/tui-agent-config-types.ts b/src/shared/tui-agent-config-types.ts index aa393fd522b7..6b3fcb464a48 100644 --- a/src/shared/tui-agent-config-types.ts +++ b/src/shared/tui-agent-config-types.ts @@ -43,6 +43,8 @@ export type TuiAgentConfig = { draftPasteReadySignal?: DraftPasteReadySignal /** Hard deadline for the agent's composer readiness signal. */ draftPasteReadyTimeoutMs?: number + /** Captured boots proving the composer marker used for fresh worker dispatch. */ + composerReadyCaptures?: readonly string[] /** Delay before one extra blind submit Enter, for agents that render their composer before Enter is live (codex); a no-op if the first Enter landed. */ submitRetryDelayMs?: number /** Extra ms per logical prompt line before Enter, for TUIs that expand multiline paste slowly (antigravity). */ diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index a7be259df2cd..e2a96d978cec 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -98,7 +98,12 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { // composer is not ready until ~10s — so the 8s default expired first and the draft // was pasted blind, mid-startup (#22479). The signal itself fired every time in // those runs, so the budget was the problem, not a dropped escape. - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + composerReadyCaptures: [ + 'opencode-1-18-32-timed-boot-slow', + 'opencode-1-18-32-timed-boot-hidden-pane', + 'opencode-1-18-32-timed-first-launch' + ] }, // Why: opencode2 installs as a separate binary and uses the same prompt flags. // Its @opentui composer keeps the same cursor-gated paste signal. @@ -108,7 +113,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { launchCmd: 'opencode2 --standalone', promptInjectionMode: 'flag-prompt', draftPasteReadySignal: 'render-cursor-after-bracketed-paste', - draftPasteReadyTimeoutMs: 20_000 + draftPasteReadyTimeoutMs: 20_000, + composerReadyCaptures: ['opencode-2-0-18-timed-boot-hidden-pane'] }, 'mimo-code': { detectCmd: 'mimo', @@ -311,7 +317,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { promptInjectionMode: 'stdin-after-start', // Why: ZCode repaints an animated ASCII banner indefinitely, so the default quiet-render // window never settles; its composer box corner is the real "input is live" signal. - draftPasteReadySignal: 'zcode-composer-prompt' + draftPasteReadySignal: 'zcode-composer-prompt', + composerReadyCaptures: ['zcode-composer-ready'] }, devin: { detectCmd: 'devin', From 960bb84d74493b22d6c7e312073dca89831b0493 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:52:50 -0700 Subject: [PATCH 02/11] feat(opencode): probe execution-host CLI capabilities --- .../opencode/opencode-cli-version.test.ts | 51 ++++++++++++++ src/main/opencode/opencode-cli-version.ts | 68 +++++++++++++++++++ src/shared/opencode-cli-version.test.ts | 29 ++++++++ src/shared/opencode-cli-version.ts | 26 +++++++ 4 files changed, 174 insertions(+) create mode 100644 src/main/opencode/opencode-cli-version.test.ts create mode 100644 src/main/opencode/opencode-cli-version.ts create mode 100644 src/shared/opencode-cli-version.test.ts create mode 100644 src/shared/opencode-cli-version.ts diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts new file mode 100644 index 000000000000..3639f02d145f --- /dev/null +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -0,0 +1,51 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { runProcess } from '../../shared/child-process/run-process' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +vi.mock('../../shared/child-process/run-process', () => ({ runProcess: vi.fn() })) +beforeEach(() => vi.mocked(runProcess).mockReset()) + +it('shares concurrent probes and isolates executable, host and environment identities', async () => { + vi.mocked(runProcess).mockResolvedValue({ + code: 0, + signal: null, + stdout: '1.1.23', + stderr: '', + timedOut: false + }) + const options = { + executablePath: '/test/opencode', + env: { PATH: '/runtime', XDG_CONFIG_HOME: '/one' }, + hostIdentity: 'native' + } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].pluginApi).toBe('v1') + expect(runProcess).toHaveBeenCalledTimes(1) + await probeOpenCodeCliVersion({ ...options, executablePath: '/other/opencode' }) + await probeOpenCodeCliVersion({ ...options, hostIdentity: 'wsl:ubuntu' }) + await probeOpenCodeCliVersion({ ...options, env: { ...options.env, XDG_CONFIG_HOME: '/two' } }) + expect(runProcess).toHaveBeenCalledTimes(4) + expect(runProcess).toHaveBeenCalledWith( + expect.objectContaining({ args: ['--version'], timeoutMs: 5_000, maxOutputBytes: 4_096 }) + ) +}) + +it('degrades timeouts and missing executables to unknown', async () => { + vi.mocked(runProcess).mockResolvedValueOnce({ + code: null, + signal: 'SIGTERM', + stdout: '2.0.16', + stderr: '', + timedOut: true + }) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/timeout/opencode', env: {} })).pluginApi + ).toBe('unknown') + vi.mocked(runProcess).mockRejectedValueOnce(new Error('ENOENT')) + expect( + (await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version + ).toBeNull() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts new file mode 100644 index 000000000000..29e4775a0a02 --- /dev/null +++ b/src/main/opencode/opencode-cli-version.ts @@ -0,0 +1,68 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' +import { runProcess } from '../../shared/child-process/run-process' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' + +export type OpenCodeCliVersionProbe = { + executablePath: string + env: NodeJS.ProcessEnv + cwd?: string + hostIdentity?: string +} + +const probes = new Map }>() +const CACHE_TTL_MS = 60_000 +const MAX_CACHED_PROBES = 128 + +export function probeOpenCodeCliVersion( + options: OpenCodeCliVersionProbe +): Promise { + const identity = JSON.stringify([ + options.hostIdentity ?? process.platform, + options.executablePath, + options.cwd, + Object.entries(options.env).sort(([left], [right]) => left.localeCompare(right)) + ]) + const key = createHash('sha256').update(identity).digest('hex') + const cached = probes.get(key) + if (cached && cached.expiresAt > Date.now()) { + return cached.result + } + const result = runVersionProbe(options) + probes.delete(key) + probes.set(key, { expiresAt: Date.now() + CACHE_TTL_MS, result }) + if (probes.size > MAX_CACHED_PROBES) { + const oldest = probes.keys().next().value + if (oldest !== undefined) { + probes.delete(oldest) + } + } + return result +} + +async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise { + try { + const pathKey = process.platform === 'win32' && options.env.Path !== undefined ? 'Path' : 'PATH' + const executableDir = path.dirname(options.executablePath) + const inheritedPath = options.env[pathKey] + const result = await runProcess({ + program: options.executablePath, + args: ['--version'], + cwd: options.cwd, + env: { + ...options.env, + [pathKey]: inheritedPath + ? `${executableDir}${path.delimiter}${inheritedPath}` + : executableDir + }, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + return getOpenCodeCliCapabilities(result.code === 0 && !result.timedOut ? result.stdout : null) + } catch { + return getOpenCodeCliCapabilities(null) + } +} diff --git a/src/shared/opencode-cli-version.test.ts b/src/shared/opencode-cli-version.test.ts new file mode 100644 index 000000000000..9423f6e31754 --- /dev/null +++ b/src/shared/opencode-cli-version.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest' +import { getOpenCodeCliCapabilities, parseOpenCodeCliVersion } from './opencode-cli-version' + +it.each(['1.1.23', 'opencode v2.0.16', '2.0.0-beta.1+build'])('parses CLI output %s', (output) => { + expect(parseOpenCodeCliVersion(output)).toBe(output.replace(/^opencode v/, '')) +}) +it.each([null, '', 'Error 2.0.16', '1.1', '2.0.16\nwarning'])( + 'does not mistake other output for a version', + (output) => { + expect(parseOpenCodeCliVersion(output)).toBeNull() + } +) +it('keeps future versions unknown instead of assuming a plugin loader or prompt policy', () => { + expect(getOpenCodeCliCapabilities('3.0.0')).toEqual({ + version: '3.0.0', + pluginApi: 'unknown', + promptMode: 'unknown' + }) + expect(getOpenCodeCliCapabilities('1.1.23')).toEqual({ + version: '1.1.23', + pluginApi: 'v1', + promptMode: 'submit' + }) + expect(getOpenCodeCliCapabilities('opencode v2.0.16')).toEqual({ + version: '2.0.16', + pluginApi: 'v2', + promptMode: 'prefill' + }) +}) diff --git a/src/shared/opencode-cli-version.ts b/src/shared/opencode-cli-version.ts new file mode 100644 index 000000000000..874958e135b0 --- /dev/null +++ b/src/shared/opencode-cli-version.ts @@ -0,0 +1,26 @@ +import { isValidAppVersion } from './app-version' + +export type OpenCodeCliCapabilities = { + version: string | null + pluginApi: 'v1' | 'v2' | 'unknown' + promptMode: 'submit' | 'prefill' | 'unknown' +} + +export function parseOpenCodeCliVersion(output: string | null | undefined): string | null { + const version = output + ?.trim() + .match(/^(?:opencode\s+)?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/i)?.[1] + return version && isValidAppVersion(version) ? version : null +} + +export function getOpenCodeCliCapabilities( + output: string | null | undefined +): OpenCodeCliCapabilities { + const version = parseOpenCodeCliVersion(output) + const major = version?.split('.')[0] + return { + version, + pluginApi: major === '1' ? 'v1' : major === '2' ? 'v2' : 'unknown', + promptMode: major === '1' ? 'submit' : major === '2' ? 'prefill' : 'unknown' + } +} From 6b78423f6823897f36031d0518b0b54abe376e52 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:54:48 -0700 Subject: [PATCH 03/11] fix(opencode): select plugin default for execution host loader --- src/main/opencode/hook-plugin-module-contract.test.ts | 9 +++++++++ src/main/opencode/status-plugin-factory-source.ts | 7 ++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/opencode/hook-plugin-module-contract.test.ts b/src/main/opencode/hook-plugin-module-contract.test.ts index 668a879f76a0..f6d07361abe2 100644 --- a/src/main/opencode/hook-plugin-module-contract.test.ts +++ b/src/main/opencode/hook-plugin-module-contract.test.ts @@ -35,6 +35,7 @@ describe('OpenCode status plugin module contract', () => { // env. Pin every input here so the run does not depend on the developer's Orca session // (an inherited ORCA_AGENT_HOOK_ENDPOINT would otherwise redirect the post to a live app). const ENV_KEYS = [ + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_PANE_KEY', 'ORCA_AGENT_HOOK_ENDPOINT', 'ORCA_AGENT_HOOK_PORT', @@ -55,6 +56,7 @@ describe('OpenCode status plugin module contract', () => { // Why: the generated plugin self-disables when this names a different major, // so an inherited value from the developer's own Orca pane would leave // `hooks.event` undefined and fail the contract for the wrong reason. + delete process.env.ORCA_OPENCODE_PLUGIN_API delete process.env.ORCA_OPENCODE_AGENT delete process.env.ORCA_AGENT_HOOK_ENDPOINT process.env.ORCA_AGENT_HOOK_PORT = '59999' @@ -86,6 +88,13 @@ describe('OpenCode status plugin module contract', () => { return (await import(pathToFileURL(pluginPath).href)) as PluginModule } + it('selects the same factory for a v1 execution host', async () => { + process.env.ORCA_OPENCODE_PLUGIN_API = 'v1' + const module = await loadPluginModule() + expect(module.default).toBeTypeOf('function') + expect(module.default).toBe(module.OrcaOpenCodeStatusPlugin) + }) + it('exposes a default export carrying a string id and a callable server()', async () => { const module = await loadPluginModule() diff --git a/src/main/opencode/status-plugin-factory-source.ts b/src/main/opencode/status-plugin-factory-source.ts index 43f063dde812..fd6c39bf510d 100644 --- a/src/main/opencode/status-plugin-factory-source.ts +++ b/src/main/opencode/status-plugin-factory-source.ts @@ -289,11 +289,8 @@ export function getStatusPluginFactorySource(options: { '};', ...(options.emitNextEvents ? getOpenCode2ModuleSource(pluginID, expectedAgent) : []), '', - '// Why: OpenCode also resolves plugins through the module default export, and that', - '// loader rejects the module unless the default exposes `server()` ("must default', - '// export an object with server()"). `setup()` does not satisfy it. Keep the named', - '// export so the factory-based loader still finds the same instance.', - 'export default {', + '// OpenCode 1 requires a callable default; OpenCode 2 validates a plugin object.', + 'export default process.env.ORCA_OPENCODE_PLUGIN_API === "v1" ? OrcaOpenCodeStatusPlugin : {', ` id: "${pluginID}",`, ' server: OrcaOpenCodeStatusPlugin,', ...(options.emitNextEvents ? [' setup: setupOpenCode2Status,'] : []), From 1b5f4c54aa699772f5e5501eee52807197657325 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 00:58:07 -0700 Subject: [PATCH 04/11] fix(opencode): limit prompt prefill capability to verified release --- src/shared/opencode-cli-version.test.ts | 5 +++++ src/shared/opencode-cli-version.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/shared/opencode-cli-version.test.ts b/src/shared/opencode-cli-version.test.ts index 9423f6e31754..cc9c5987f2e9 100644 --- a/src/shared/opencode-cli-version.test.ts +++ b/src/shared/opencode-cli-version.test.ts @@ -27,3 +27,8 @@ it('keeps future versions unknown instead of assuming a plugin loader or prompt promptMode: 'prefill' }) }) + +it('does not opt unverified v2 builds into an extra prompt submission', () => { + expect(getOpenCodeCliCapabilities('2.0.17').promptMode).toBe('unknown') + expect(getOpenCodeCliCapabilities('2.0.16-beta').promptMode).toBe('unknown') +}) diff --git a/src/shared/opencode-cli-version.ts b/src/shared/opencode-cli-version.ts index 874958e135b0..2aa71b2c30f0 100644 --- a/src/shared/opencode-cli-version.ts +++ b/src/shared/opencode-cli-version.ts @@ -21,6 +21,6 @@ export function getOpenCodeCliCapabilities( return { version, pluginApi: major === '1' ? 'v1' : major === '2' ? 'v2' : 'unknown', - promptMode: major === '1' ? 'submit' : major === '2' ? 'prefill' : 'unknown' + promptMode: major === '1' ? 'submit' : version === '2.0.16' ? 'prefill' : 'unknown' } } From b8cc6fca163024e2c754497f3138eef55fdc2c48 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:36:12 -0700 Subject: [PATCH 05/11] feat(opencode): probe launch capabilities on the execution host --- .../opencode/opencode-cli-version.test.ts | 14 +++ src/main/opencode/opencode-cli-version.ts | 29 +++--- .../opencode-launch-capabilities.test.ts | 96 +++++++++++++++++++ .../opencode/opencode-launch-capabilities.ts | 82 ++++++++++++++++ 4 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 src/main/opencode/opencode-launch-capabilities.test.ts create mode 100644 src/main/opencode/opencode-launch-capabilities.ts diff --git a/src/main/opencode/opencode-cli-version.test.ts b/src/main/opencode/opencode-cli-version.test.ts index 3639f02d145f..b0886517da9d 100644 --- a/src/main/opencode/opencode-cli-version.test.ts +++ b/src/main/opencode/opencode-cli-version.test.ts @@ -49,3 +49,17 @@ it('degrades timeouts and missing executables to unknown', async () => { (await probeOpenCodeCliVersion({ executablePath: '/missing/opencode', env: {} })).version ).toBeNull() }) + +it('shares a bounded execution-host callback without probing the native machine', async () => { + const execute = vi + .fn() + .mockResolvedValue({ code: 0, timedOut: false, stdout: 'opencode v2.0.16' }) + const options = { executablePath: 'opencode', env: {}, hostIdentity: 'wsl:private', execute } + const results = await Promise.all([ + probeOpenCodeCliVersion(options), + probeOpenCodeCliVersion(options) + ]) + expect(results[0].promptMode).toBe('prefill') + expect(execute).toHaveBeenCalledTimes(1) + expect(runProcess).not.toHaveBeenCalled() +}) diff --git a/src/main/opencode/opencode-cli-version.ts b/src/main/opencode/opencode-cli-version.ts index 29e4775a0a02..00d5cefe6a3b 100644 --- a/src/main/opencode/opencode-cli-version.ts +++ b/src/main/opencode/opencode-cli-version.ts @@ -11,6 +11,7 @@ export type OpenCodeCliVersionProbe = { env: NodeJS.ProcessEnv cwd?: string hostIdentity?: string + execute?: () => Promise<{ code: number | null; timedOut: boolean; stdout: string }> } const probes = new Map }>() @@ -48,19 +49,21 @@ async function runVersionProbe(options: OpenCodeCliVersionProbe): Promise ({ resolve: vi.fn(), probe: vi.fn(), wsl: vi.fn() })) +vi.mock('../ipc/command-path-resolver', () => ({ resolveCommandOnLocalPath: mocks.resolve })) +vi.mock('./opencode-cli-version', () => ({ probeOpenCodeCliVersion: mocks.probe })) +vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl })) + +beforeEach(() => { + vi.clearAllMocks() + mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) +}) + +describe('OpenCode execution-host launch capability probe', () => { + it('recognizes quoted executables and preserves explicit run commands', () => { + expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') + expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( + 'custom-launcher' + ) + expect(getOpenCodeLaunchExecutable('claude')).toBeNull() + }) + + it('uses native resolution with the execution environment and cwd', async () => { + mocks.resolve.mockResolvedValue('/bin/opencode') + const env = { PATH: '/bin', OPENCODE_CONFIG_DIR: '/private/config' } + await probeOpenCodeLaunchCapabilities({ + command: 'opencode run task', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.resolve).toHaveBeenCalledWith('opencode', { env, cwd: '/repo' }) + expect(mocks.probe).toHaveBeenCalledWith({ + executablePath: '/bin/opencode', + env, + cwd: '/repo', + hostIdentity: 'native-test' + }) + expect(mocks.wsl).not.toHaveBeenCalled() + }) + + it('uses the relay resolver rather than client PATH resolution', async () => { + const resolveExecutable = vi.fn().mockResolvedValue('/host/opencode') + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + hostIdentity: 'relay:linux', + resolveExecutable + }) + expect(resolveExecutable).toHaveBeenCalledWith('opencode') + expect(mocks.resolve).not.toHaveBeenCalled() + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ executablePath: '/host/opencode', hostIdentity: 'relay:linux' }) + ) + }) + + it('returns unknown when the host cannot resolve the binary', async () => { + mocks.resolve.mockResolvedValue(null) + expect(await probeOpenCodeLaunchCapabilities({ command: 'opencode', env: {} })).toEqual( + getOpenCodeCliCapabilities(null) + ) + expect(mocks.probe).not.toHaveBeenCalled() + }) + + it('bounds and scopes WSL probes to the guest distro without native PATH or HOME', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode --standalone', + env: { HOME: '/native', PATH: '/native/bin', OPENCODE_CONFIG_DIR: '/guest/config' }, + wsl: { distro: 'Ubuntu' }, + hostIdentity: 'host-a' + }) + expect(mocks.resolve).not.toHaveBeenCalled() + const options = mocks.probe.mock.calls[0]?.[0] + expect(options).toEqual( + expect.objectContaining({ + executablePath: 'opencode', + hostIdentity: 'host-a:wsl:Ubuntu', + env: { OPENCODE_CONFIG_DIR: '/guest/config' } + }) + ) + await options.execute() + expect(mocks.wsl).toHaveBeenCalledWith({ + distro: 'Ubuntu', + loginPath: 'preferred', + program: 'opencode', + args: ['--version'], + env: { OPENCODE_CONFIG_DIR: '/guest/config' }, + timeoutMs: 5000, + maxOutputBytes: 4096 + }) + }) +}) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts new file mode 100644 index 000000000000..1d63cf0ed446 --- /dev/null +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -0,0 +1,82 @@ +import { + getFirstCommandToken, + getCommandTokenPathBasename +} from '../../shared/command-token-scanner' +import { + getOpenCodeCliCapabilities, + type OpenCodeCliCapabilities +} from '../../shared/opencode-cli-version' +import type { TuiAgent } from '../../shared/tui-agent' +import { resolveCommandOnLocalPath } from '../ipc/command-path-resolver' +import { runWslProcess } from '../wsl/wsl-runner' +import { probeOpenCodeCliVersion } from './opencode-cli-version' + +export function getOpenCodeLaunchExecutable( + command: string | undefined, + agent?: TuiAgent +): string | null { + const executable = getFirstCommandToken(command ?? '') + const name = getCommandTokenPathBasename(executable) + .toLowerCase() + .replace(/\.(?:exe|cmd|sh)$/, '') + return agent === 'opencode' || + agent === 'opencode2' || + (!agent && (name === 'opencode' || name === 'opencode2')) + ? executable || null + : null +} + +export async function probeOpenCodeLaunchCapabilities(options: { + command: string | undefined + agent?: TuiAgent + env: NodeJS.ProcessEnv + cwd?: string + wsl?: { distro?: string } + hostIdentity?: string + resolveExecutable?: (executable: string) => Promise +}): Promise { + const executable = getOpenCodeLaunchExecutable(options.command, options.agent) + if (!executable) { + return null + } + if (options.wsl) { + const guestEnv: Record = {} + for (const key of [ + 'OPENCODE_CONFIG_DIR', + 'ORCA_OPENCODE_CONFIG_DIR', + 'OPENCODE_DISABLE_AUTOUPDATE' + ]) { + const value = options.env[key] + if (value !== undefined) { + guestEnv[key] = value + } + } + const distro = options.wsl.distro + return probeOpenCodeCliVersion({ + executablePath: executable, + env: guestEnv, + hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, + execute: () => + runWslProcess({ + distro, + loginPath: 'preferred', + program: executable, + args: ['--version'], + env: guestEnv, + timeoutMs: 5_000, + maxOutputBytes: 4_096 + }) + }) + } + const executablePath = options.resolveExecutable + ? await options.resolveExecutable(executable) + : await resolveCommandOnLocalPath(executable, { env: options.env, cwd: options.cwd }) + return executablePath + ? probeOpenCodeCliVersion({ + executablePath, + env: options.env, + cwd: options.cwd, + hostIdentity: options.hostIdentity + }) + : getOpenCodeCliCapabilities(null) +} From 8c3d344f1b9201c76154875d113418fbd7768294 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 01:52:31 -0700 Subject: [PATCH 06/11] fix(opencode): select plugin loader for the launched host binary --- src/main/ipc/pty/ipc/spawn-options.ts | 12 ++++ src/main/ipc/pty/runtime/spawn-options.ts | 13 ++++ src/main/opencode/opencode-pty-launch.test.ts | 72 +++++++++++++++++++ src/main/opencode/opencode-pty-launch.ts | 33 +++++++++ src/main/providers/pty-spawn-result.ts | 3 + src/relay/pty-handler.ts | 29 +++++++- 6 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/main/opencode/opencode-pty-launch.test.ts create mode 100644 src/main/opencode/opencode-pty-launch.ts diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index d0a3ba8cef16..699cddda7582 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -27,6 +27,7 @@ import { withCodexTerminalServerIsolationEnv } from '../../../../shared/codex-te import { planCodexNoDaemonLaunch } from '../../../pty/codex-no-daemon-launch-command' import type { PtyIpcSpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Carries deletions to provider-owned environments, including persistent older daemons. */ export async function buildPtyIpcSpawnOptions( @@ -64,6 +65,17 @@ export async function buildPtyIpcSpawnOptions( ctx.combinedEnvToDelete = removeCodexHomeDeletionRequests(ctx.combinedEnvToDelete) } deleteRequestedEnvKeys(ctx.spawnEnv, ctx.combinedEnvToDelete) + ctx.spawnEnv = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.spawnEnv, + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) promoteAgentTeamsShimPath(ctx.spawnEnv, ctx.requestedAgentTeamsPath) ctx.spawnOptions = { cols: args.cols, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index 5f64779b364d..f5ddf87646fd 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -34,6 +34,7 @@ import { } from '../pane/spawn-reservation' import type { RuntimePtySpawnState } from './spawn-state' import { applyAgentWorkspaceTrustToSpawn } from '../../../agent-workspace-trust-spawn' +import { prepareOpenCodePtyLaunch } from '../../../opencode/opencode-pty-launch' /** Headless spawns need the same host-side environment isolation as desktop spawns. */ export async function buildRuntimePtySpawnOptions( @@ -102,6 +103,18 @@ export async function buildRuntimePtySpawnOptions( ctx.spawnOptions.envToDelete = removeCodexHomeDeletionRequests(ctx.spawnOptions.envToDelete) } deleteRequestedEnvKeys(ctx.env, ctx.spawnOptions.envToDelete) + ctx.env = await prepareOpenCodePtyLaunch({ + command: ctx.launchCommand, + agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, + env: ctx.env, + cwd: ctx.cwd, + connectionId: args.connectionId, + isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, + ...(ctx.codexSelectionTarget.runtime === 'wsl' + ? { wsl: { distro: ctx.expectedWslDistro ?? undefined } } + : {}) + }) + ctx.spawnOptions.env = ctx.env promoteAgentTeamsShimPath(ctx.env, ctx.requestedAgentTeamsPath) const noDaemonLaunch = planCodexNoDaemonLaunch({ command: ctx.launchCommand, diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts new file mode 100644 index 000000000000..2a1cfb4e7cd4 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' +import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' + +const probe = vi.hoisted(() => vi.fn()) +vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) + +beforeEach(() => probe.mockReset()) + +describe('execution-host OpenCode launch preparation', () => { + it.each(['1.1.23', '2.0.16'])( + 'selects the probed %s plugin for the execution host', + async (version) => { + const capabilities = getOpenCodeCliCapabilities(version) + probe.mockResolvedValue(capabilities) + const env = { + KEEP: '1', + ORCA_OPENCODE_PLUGIN_API: 'stale' + } + const result = await prepareOpenCodePtyLaunch({ + command: 'opencode --prompt test', + agent: 'opencode', + env, + cwd: '/repo', + isFreshLaunch: true + }) + expect(result).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi }) + expect(env).toEqual({ KEEP: '1', ORCA_OPENCODE_PLUGIN_API: 'stale' }) + expect(probe).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'opencode --prompt test', + cwd: '/repo', + env: expect.objectContaining({ KEEP: '1' }) + }) + ) + } + ) + + it('creates a launch environment for a known binary without caller env', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + expect( + await prepareOpenCodePtyLaunch({ command: 'opencode', env: undefined, isFreshLaunch: true }) + ).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' }) + }) + + it('forwards WSL plugin selection through WSLENV after a guest probe', async () => { + probe.mockResolvedValue(getOpenCodeCliCapabilities('1.1.23')) + const env = { KEEP: '1' } + const result = await prepareOpenCodePtyLaunch({ + command: 'opencode', + agent: 'opencode', + env, + isFreshLaunch: true, + wsl: { distro: 'Ubuntu' } + }) + expect(result).toMatchObject({ + ORCA_OPENCODE_PLUGIN_API: 'v1', + WSLENV: 'ORCA_OPENCODE_PLUGIN_API' + }) + expect(probe).toHaveBeenCalledWith(expect.objectContaining({ wsl: { distro: 'Ubuntu' } })) + }) + + it.each([{ connectionId: 'remote', isFreshLaunch: true }, { isFreshLaunch: false }])( + 'never probes the client for an attach or SSH launch', + async (route) => { + const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } + expect(await prepareOpenCodePtyLaunch({ command: 'opencode', env, ...route })).toEqual({}) + expect(probe).not.toHaveBeenCalled() + expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) + } + ) +}) diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts new file mode 100644 index 000000000000..cfd0f815f143 --- /dev/null +++ b/src/main/opencode/opencode-pty-launch.ts @@ -0,0 +1,33 @@ +import { addWslEnvKeys } from '../../shared/wsl-env' +import type { TuiAgent } from '../../shared/tui-agent' +import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' + +export async function prepareOpenCodePtyLaunch(options: { + command: string | undefined + agent?: TuiAgent + env: Record | undefined + cwd?: string + connectionId?: string | null + isFreshLaunch: boolean + wsl?: { distro?: string } +}): Promise | undefined> { + const env = options.env ? { ...options.env } : undefined + if (env) { + delete env.ORCA_OPENCODE_PLUGIN_API + } + if (options.connectionId || !options.isFreshLaunch) { + return env + } + const capabilities = await probeOpenCodeLaunchCapabilities({ + ...options, + env: { ...process.env, ...env } + }) + if (!capabilities || capabilities.pluginApi === 'unknown') { + return env + } + const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + if (options.wsl) { + addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) + } + return launchEnv +} diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 2886a00e8b66..4eb5dc45a60d 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -4,8 +4,11 @@ import type { AgentSessionClaimedSpawnResult } from '../../shared/agent-session- import type { PtyIncarnationId } from '../../shared/pty-incarnation' import type { PtySourceReceivingActivation } from '../../shared/pty-source-receiving-activation' import type { TerminalOwner } from '../../shared/terminal-owner' +import type { OpenCodeCliCapabilities } from '../../shared/opencode-cli-version' export type PtySpawnResult = { + /** Execution-host probe; absent on older hosts and reattachments. */ + openCodeCapabilities?: OpenCodeCliCapabilities agentSessionEnsure?: AgentSessionClaimedSpawnResult /** App-facing PTY id. Remote providers must return globally routable ids, * not relay-local handles, because renderer/runtime IPC routes by this key. */ diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index fbad4a212a0d..b175d4b6496b 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -3,6 +3,9 @@ import { resolveSynchronizedOutputSafeSplit } from '../shared/terminal-synchroni import { createTerminalTitleTracker } from '../shared/terminal-output-side-effects' import { getDecorativeTitleGateKey } from '../shared/agent-decorative-title-signature' import { FreebuffStatusProjection } from './freebuff-status-projection' +import { probeOpenCodeLaunchCapabilities } from '../main/opencode/opencode-launch-capabilities' +import type { OpenCodeCliCapabilities } from '../shared/opencode-cli-version' +import { resolveCommandPathForRelay } from './preflight-handler' import { applyRelayAgentWorkspaceTrust } from './agent-workspace-trust-spawn' import type { IPty } from 'node-pty' import { killWithDescendantSweep } from '../main/pty-descendant-termination' @@ -216,6 +219,7 @@ function parseSourceRecoveryRequest(value: unknown): PtySourceRecoveryRequest | } type ManagedPty = { + openCodeCapabilities?: OpenCodeCliCapabilities freebuffStatus?: FreebuffStatusProjection id: string incarnationId: string @@ -275,6 +279,7 @@ type ManagedPty = { } type RelayAgentSessionCreateResult = { + openCodeCapabilities?: OpenCodeCliCapabilities id: string incarnationId: string replay?: string @@ -1905,6 +1910,9 @@ export class PtyHandler { id: managed.id, incarnationId: managed.incarnationId, agentSessionEnsure: result, + ...(result.disposition === 'created' && managed.openCodeCapabilities + ? { openCodeCapabilities: managed.openCodeCapabilities } + : {}), ...(sourceActivation ? { sourceActivation } : {}), ...(adoptedReplay ? { replay: adoptedReplay } : {}), ...(managed.shellReadyArmed !== undefined @@ -1933,6 +1941,7 @@ export class PtyHandler { incarnationId: string sourceActivation?: PtySourceReceivingActivation shellReadyArmed?: boolean + openCodeCapabilities?: OpenCodeCliCapabilities }> { const pty = await this.loadPty() if (!pty) { @@ -1979,6 +1988,22 @@ export class PtyHandler { { id, paneKey, shell, command, launchAgent }, envToDelete ) + delete spawnEnv.ORCA_OPENCODE_PLUGIN_API + const openCodeCapabilities = await probeOpenCodeLaunchCapabilities({ + command, + agent: launchAgent, + env: spawnEnv, + cwd, + hostIdentity: `relay:${process.platform}`, + resolveExecutable: (executable) => resolveCommandPathForRelay(executable, { env: spawnEnv }), + ...(isRelayWslShell(shell) ? { wsl: { distro: terminalWindowsWslDistro ?? undefined } } : {}) + }) + if (openCodeCapabilities && openCodeCapabilities.pluginApi !== 'unknown') { + spawnEnv.ORCA_OPENCODE_PLUGIN_API = openCodeCapabilities.pluginApi + if (isRelayWslShell(shell)) { + addWslEnvKeys(spawnEnv, ['ORCA_OPENCODE_PLUGIN_API']) + } + } await applyRelayAgentWorkspaceTrust(params.agentWorkspaceTrust, launchAgent, spawnEnv, { wslShell: isRelayWslShell(shell) }) @@ -2079,6 +2104,7 @@ export class PtyHandler { const ownerClientInstanceId = context === undefined ? null : (this.consumerIdentityResolver?.(context.clientId) ?? null) const managed: ManagedPty = { + ...(openCodeCapabilities ? { openCodeCapabilities } : {}), ...(launchAgent === 'freebuff' ? { freebuffStatus: new FreebuffStatusProjection(cols, rows) } : {}), @@ -2157,7 +2183,8 @@ export class PtyHandler { id, incarnationId: managed.incarnationId, ...(sourceActivation ? { sourceActivation } : {}), - shellReadyArmed: rendererShellReadySupported + shellReadyArmed: rendererShellReadySupported, + ...(openCodeCapabilities ? { openCodeCapabilities } : {}) } } From 944c72c2db23abcc0a2c9d0313c4a5bc24485327 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:05:36 -0700 Subject: [PATCH 07/11] fix(opencode): match WSL probe cwd and declared guest environment --- .../opencode-launch-capabilities.test.ts | 55 +++++++++++++++++-- .../opencode/opencode-launch-capabilities.ts | 30 +++++++--- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts index 81eb123cbce4..6e4a09130631 100644 --- a/src/main/opencode/opencode-launch-capabilities.test.ts +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -66,31 +66,76 @@ describe('OpenCode execution-host launch capability probe', () => { expect(mocks.probe).not.toHaveBeenCalled() }) - it('bounds and scopes WSL probes to the guest distro without native PATH or HOME', async () => { + it('bounds WSL probes and matches guest cwd plus explicitly imported config roots', async () => { + const env = { + HOME: '/native', + PATH: '/native/bin', + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: 'OPENCODE_CONFIG_DIR:XDG_DATA_HOME' + } await probeOpenCodeLaunchCapabilities({ command: 'opencode --standalone', - env: { HOME: '/native', PATH: '/native/bin', OPENCODE_CONFIG_DIR: '/guest/config' }, - wsl: { distro: 'Ubuntu' }, + env, + cwd: '\\\\wsl.localhost\\Ubuntu\\home\\user\\repo', + wsl: { distro: 'Debian' }, hostIdentity: 'host-a' }) expect(mocks.resolve).not.toHaveBeenCalled() const options = mocks.probe.mock.calls[0]?.[0] + const guestEnv = { + OPENCODE_CONFIG_DIR: '/guest/config', + XDG_DATA_HOME: '/guest/data', + WSLENV: env.WSLENV + } expect(options).toEqual( expect.objectContaining({ executablePath: 'opencode', hostIdentity: 'host-a:wsl:Ubuntu', - env: { OPENCODE_CONFIG_DIR: '/guest/config' } + cwd: '/home/user/repo', + env: guestEnv }) ) await options.execute() expect(mocks.wsl).toHaveBeenCalledWith({ distro: 'Ubuntu', loginPath: 'preferred', + cwd: '/home/user/repo', program: 'opencode', args: ['--version'], - env: { OPENCODE_CONFIG_DIR: '/guest/config' }, + env: guestEnv, timeoutMs: 5000, maxOutputBytes: 4096 }) }) + + it('does not import native configuration the actual WSL pane would not receive', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { OPENCODE_CONFIG_DIR: 'C:\\native' }, + cwd: 'D:\\repo', + wsl: { distro: 'Ubuntu' } + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ cwd: '/mnt/d/repo', env: {} }) + ) + }) + + it('keeps WSL path translation flags and skips Windows-only values', async () => { + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: { + WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', + XDG_DATA_HOME: 'D:\\data', + WINDOWS_ONLY: 'private', + HOME: 'C:\\native' + }, + wsl: {} + }) + expect(mocks.probe).toHaveBeenCalledWith( + expect.objectContaining({ + env: { WSLENV: 'XDG_DATA_HOME/p:WINDOWS_ONLY/w:HOME', XDG_DATA_HOME: 'D:\\data' } + }) + ) + }) }) diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts index 1d63cf0ed446..86ade3f8af5e 100644 --- a/src/main/opencode/opencode-launch-capabilities.ts +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -1,3 +1,4 @@ +import { parseWslUncPath, toLinuxPath } from '../../shared/wsl-paths' import { getFirstCommandToken, getCommandTokenPathBasename @@ -41,25 +42,36 @@ export async function probeOpenCodeLaunchCapabilities(options: { } if (options.wsl) { const guestEnv: Record = {} - for (const key of [ - 'OPENCODE_CONFIG_DIR', - 'ORCA_OPENCODE_CONFIG_DIR', - 'OPENCODE_DISABLE_AUTOUPDATE' - ]) { - const value = options.env[key] - if (value !== undefined) { - guestEnv[key] = value + const wslEnv = options.env.WSLENV + if (wslEnv) { + guestEnv.WSLENV = wslEnv + for (const token of wslEnv.split(':')) { + const [key, flags = ''] = token.split('/') + if (!key || flags.includes('w') || ['PATH', 'HOME', 'TMP', 'TEMP'].includes(key)) { + continue + } + const value = options.env[key] + if (value !== undefined) { + guestEnv[key] = value + } } } - const distro = options.wsl.distro + const wslPath = options.cwd ? parseWslUncPath(options.cwd) : null + const cwd = options.cwd ? (wslPath?.linuxPath ?? toLinuxPath(options.cwd)) : undefined + if (cwd !== undefined && !cwd.startsWith('/')) { + return getOpenCodeCliCapabilities(null) + } + const distro = wslPath?.distro ?? options.wsl.distro return probeOpenCodeCliVersion({ executablePath: executable, env: guestEnv, + cwd, hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, execute: () => runWslProcess({ distro, loginPath: 'preferred', + cwd, program: executable, args: ['--version'], env: guestEnv, From 42788e7878161bca45d8be0008453a2b87b477e3 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 2 Oct 2026 02:39:15 -0700 Subject: [PATCH 08/11] fix(opencode): preserve launch environment deletion boundaries --- src/main/ipc/pty/ipc/spawn-options.ts | 1 + src/main/ipc/pty/runtime/spawn-options.ts | 1 + .../opencode-launch-capabilities.test.ts | 20 ++++ .../opencode/opencode-launch-capabilities.ts | 6 +- src/main/opencode/opencode-pty-launch.test.ts | 92 ++++++++++++++++++- src/main/opencode/opencode-pty-launch.ts | 16 +++- 6 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index 699cddda7582..cf2d1c53b6ae 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -69,6 +69,7 @@ export async function buildPtyIpcSpawnOptions( command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.spawnEnv, + envToDelete: (ctx.combinedEnvToDelete ??= []), cwd: ctx.cwd, connectionId: args.connectionId, isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index f5ddf87646fd..0f4921cdf7dc 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -107,6 +107,7 @@ export async function buildRuntimePtySpawnOptions( command: ctx.launchCommand, agent: isTuiAgent(args.launchAgent) ? args.launchAgent : undefined, env: ctx.env, + envToDelete: (ctx.spawnOptions.envToDelete ??= []), cwd: ctx.cwd, connectionId: args.connectionId, isFreshLaunch: !ctx.preAdoptedStablePane && ctx.launchCommand !== undefined, diff --git a/src/main/opencode/opencode-launch-capabilities.test.ts b/src/main/opencode/opencode-launch-capabilities.test.ts index 6e4a09130631..9d15cefe80f2 100644 --- a/src/main/opencode/opencode-launch-capabilities.test.ts +++ b/src/main/opencode/opencode-launch-capabilities.test.ts @@ -13,9 +13,29 @@ vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: mocks.wsl })) beforeEach(() => { vi.clearAllMocks() mocks.probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: true + }) }) describe('OpenCode execution-host launch capability probe', () => { + it('refuses a successful version response from an unresolved WSL execution environment', async () => { + mocks.wsl.mockResolvedValue({ + code: 0, + timedOut: false, + stdout: '1.18.30', + environmentResolved: false + }) + await probeOpenCodeLaunchCapabilities({ + command: 'opencode', + env: {}, + wsl: { distro: 'Ubuntu' } + }) + expect(await mocks.probe.mock.calls[0]?.[0].execute()).toMatchObject({ code: null }) + }) it('recognizes quoted executables and preserves explicit run commands', () => { expect(getOpenCodeLaunchExecutable('"/app dir/opencode" run task')).toBe('/app dir/opencode') expect(getOpenCodeLaunchExecutable('custom-launcher --standalone', 'opencode')).toBe( diff --git a/src/main/opencode/opencode-launch-capabilities.ts b/src/main/opencode/opencode-launch-capabilities.ts index 86ade3f8af5e..17a2a570872f 100644 --- a/src/main/opencode/opencode-launch-capabilities.ts +++ b/src/main/opencode/opencode-launch-capabilities.ts @@ -67,8 +67,8 @@ export async function probeOpenCodeLaunchCapabilities(options: { env: guestEnv, cwd, hostIdentity: `${options.hostIdentity ?? 'local'}:wsl:${distro ?? 'default'}`, - execute: () => - runWslProcess({ + execute: async () => { + const result = await runWslProcess({ distro, loginPath: 'preferred', cwd, @@ -78,6 +78,8 @@ export async function probeOpenCodeLaunchCapabilities(options: { timeoutMs: 5_000, maxOutputBytes: 4_096 }) + return result.environmentResolved ? result : { ...result, code: null } + } }) } const executablePath = options.resolveExecutable diff --git a/src/main/opencode/opencode-pty-launch.test.ts b/src/main/opencode/opencode-pty-launch.test.ts index 2a1cfb4e7cd4..f7813ddc969e 100644 --- a/src/main/opencode/opencode-pty-launch.test.ts +++ b/src/main/opencode/opencode-pty-launch.test.ts @@ -1,13 +1,90 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getOpenCodeCliCapabilities } from '../../shared/opencode-cli-version' import { prepareOpenCodePtyLaunch } from './opencode-pty-launch' +import { + buildLocalPtySpawnEnvironment, + enforceLocalPtySpawnEnvironmentOverrides +} from '../providers/local-pty-spawn-environment' +import type { LocalPtyLaunchPlan } from '../providers/local-pty-launch-plan' + +const plan: LocalPtyLaunchPlan = { + startupAgentRecognition: null, + defaultCwd: '', + cwd: '', + wslInfo: null, + worktreeWslContext: undefined, + preferredWslContext: undefined, + launchWslContext: undefined, + shellPath: process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', + shellArgs: [], + effectiveCwd: '', + validationCwd: '', + startupCommandDeliveredInShellArgs: false, + windowsFallbackAttempts: [], + shellReadyLaunch: null, + getFallbackShellReadyConfig: undefined, + primaryPreLaunchEnv: {}, + isWslShell: false, + launchWslDistro: null +} const probe = vi.hoisted(() => vi.fn()) vi.mock('./opencode-launch-capabilities', () => ({ probeOpenCodeLaunchCapabilities: probe })) beforeEach(() => probe.mockReset()) +afterEach(() => vi.unstubAllEnvs()) describe('execution-host OpenCode launch preparation', () => { + it('keeps deleted credentials and config absent from the probe and final provider environment', async () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'dummy-deleted-key') + vi.stubEnv('OPENCODE_CONFIG_DIR', '/dummy/deleted-config') + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities(null)) + const envToDelete = ['ANTHROPIC_API_KEY', 'OPENCODE_CONFIG_DIR'] + const env = await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const probeEnv = probe.mock.calls[0]?.[0].env + expect(probeEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(probeEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(probeEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv).not.toHaveProperty('ANTHROPIC_API_KEY') + expect(finalEnv).not.toHaveProperty('OPENCODE_CONFIG_DIR') + expect(finalEnv).not.toHaveProperty('ORCA_OPENCODE_PLUGIN_API') + }) + + it('retains a verified selection through the final provider deletion pass', async () => { + vi.stubEnv('ORCA_OPENCODE_PLUGIN_API', 'v1') + probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) + const envToDelete = ['KEEP_DELETED', 'ORCA_OPENCODE_PLUGIN_API'] + const env = await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: {}, + envToDelete, + isFreshLaunch: true + }) + const finalEnv = await buildLocalPtySpawnEnvironment({ + id: 'probe', + spawn: { cols: 80, rows: 24, env, envToDelete }, + getOptions: () => ({}), + plan + }) + finalEnv.KEEP_DELETED = 'dummy' + enforceLocalPtySpawnEnvironmentOverrides({ cols: 80, rows: 24, env, envToDelete }, finalEnv) + expect(finalEnv.ORCA_OPENCODE_PLUGIN_API).toBe('v2') + expect(finalEnv).not.toHaveProperty('KEEP_DELETED') + }) + it.each(['1.1.23', '2.0.16'])( 'selects the probed %s plugin for the execution host', async (version) => { @@ -21,6 +98,7 @@ describe('execution-host OpenCode launch preparation', () => { command: 'opencode --prompt test', agent: 'opencode', env, + envToDelete: [], cwd: '/repo', isFreshLaunch: true }) @@ -39,7 +117,12 @@ describe('execution-host OpenCode launch preparation', () => { it('creates a launch environment for a known binary without caller env', async () => { probe.mockResolvedValue(getOpenCodeCliCapabilities('2.0.16')) expect( - await prepareOpenCodePtyLaunch({ command: 'opencode', env: undefined, isFreshLaunch: true }) + await prepareOpenCodePtyLaunch({ + command: 'opencode', + env: undefined, + envToDelete: [], + isFreshLaunch: true + }) ).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v2' }) }) @@ -50,6 +133,7 @@ describe('execution-host OpenCode launch preparation', () => { command: 'opencode', agent: 'opencode', env, + envToDelete: [], isFreshLaunch: true, wsl: { distro: 'Ubuntu' } }) @@ -64,7 +148,9 @@ describe('execution-host OpenCode launch preparation', () => { 'never probes the client for an attach or SSH launch', async (route) => { const env = { ORCA_OPENCODE_PLUGIN_API: 'v1' } - expect(await prepareOpenCodePtyLaunch({ command: 'opencode', env, ...route })).toEqual({}) + expect( + await prepareOpenCodePtyLaunch({ command: 'opencode', env, envToDelete: [], ...route }) + ).toEqual({}) expect(probe).not.toHaveBeenCalled() expect(env).toEqual({ ORCA_OPENCODE_PLUGIN_API: 'v1' }) } diff --git a/src/main/opencode/opencode-pty-launch.ts b/src/main/opencode/opencode-pty-launch.ts index cfd0f815f143..5aa5adab0726 100644 --- a/src/main/opencode/opencode-pty-launch.ts +++ b/src/main/opencode/opencode-pty-launch.ts @@ -1,11 +1,13 @@ import { addWslEnvKeys } from '../../shared/wsl-env' import type { TuiAgent } from '../../shared/tui-agent' +import { deleteRequestedEnvKeys } from '../ipc/pty/host-env/path' import { probeOpenCodeLaunchCapabilities } from './opencode-launch-capabilities' export async function prepareOpenCodePtyLaunch(options: { command: string | undefined agent?: TuiAgent env: Record | undefined + envToDelete: string[] cwd?: string connectionId?: string | null isFreshLaunch: boolean @@ -15,17 +17,29 @@ export async function prepareOpenCodePtyLaunch(options: { if (env) { delete env.ORCA_OPENCODE_PLUGIN_API } + // Providers merge their own ambient environment after this preparation. + if (!options.envToDelete.includes('ORCA_OPENCODE_PLUGIN_API')) { + options.envToDelete.push('ORCA_OPENCODE_PLUGIN_API') + } if (options.connectionId || !options.isFreshLaunch) { return env } + const probeEnv: Record = {} + for (const [key, value] of Object.entries({ ...process.env, ...env })) { + if (value !== undefined) { + probeEnv[key] = value + } + } + deleteRequestedEnvKeys(probeEnv, options.envToDelete) const capabilities = await probeOpenCodeLaunchCapabilities({ ...options, - env: { ...process.env, ...env } + env: probeEnv }) if (!capabilities || capabilities.pluginApi === 'unknown') { return env } const launchEnv = { ...env, ORCA_OPENCODE_PLUGIN_API: capabilities.pluginApi } + options.envToDelete.splice(options.envToDelete.indexOf('ORCA_OPENCODE_PLUGIN_API'), 1) if (options.wsl) { addWslEnvKeys(launchEnv, ['ORCA_OPENCODE_PLUGIN_API']) } From 40d90386dd8b784f5f238416b98d0b7e61a8005c Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 05:16:45 -0700 Subject: [PATCH 09/11] STRICT launch CI contract correction --- src/main/runtime/zcode-readiness-transcript.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/zcode-readiness-transcript.test.ts b/src/main/runtime/zcode-readiness-transcript.test.ts index bd9909f89de7..558aa3f306e5 100644 --- a/src/main/runtime/zcode-readiness-transcript.test.ts +++ b/src/main/runtime/zcode-readiness-transcript.test.ts @@ -28,9 +28,13 @@ describe('ZCode readiness from captured terminal bytes', () => { launchAgent: 'zcode', data: '\x1b[?1049h╭' }) - await expect( - runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000) - ).resolves.toBeUndefined() + await expect(runtime.waitForFreshWorkerComposer(handle, 'zcode', 1_000)).resolves.toEqual({ + handle, + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) }) it('never emits an OSC title, so no title lane can settle its wait', () => { From 37f0be5e6e983bdaa624f7d4a2eab52847637892 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 3 Oct 2026 05:16:45 -0700 Subject: [PATCH 10/11] CAPS launch CI contract correction --- src/main/providers/provider-dispatch.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 7ac956b70fde..12b259f5b9f0 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -175,6 +175,7 @@ describe('PTY provider dispatch', () => { 'CLAUDE_CODE_CHILD_SESSION', 'CLAUDE_CODE_SESSION_ID', 'CLAUDE_CODE_BRIDGE_SESSION_ID', + 'ORCA_OPENCODE_PLUGIN_API', 'ORCA_PI_STATUS_OWNED', 'ORCA_PRIME_AGENT_STATUS_OWNED', 'ORCA_PI_TITLE_MARKER_OWNED', From 6a83da378864a4f64937112312834960b6f9700d Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:58:11 -0700 Subject: [PATCH 11/11] test: initialize Claude prompt state in output retention fixture --- .../claude/claude-child-work-evidence-retention.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/claude/claude-child-work-evidence-retention.test.ts b/src/main/claude/claude-child-work-evidence-retention.test.ts index 2ba58138c243..613c2694ac74 100644 --- a/src/main/claude/claude-child-work-evidence-retention.test.ts +++ b/src/main/claude/claude-child-work-evidence-retention.test.ts @@ -8,6 +8,7 @@ import { deriveToolInputPreview } from '../../shared/agent-hook-listener/tool-in import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { ClaudeChildWorkDecoder } from './claude-child-work-decoder' import { claudeChildOperation, drainClaudeChildWork } from './claude-child-work-evidence' +import { ClaudePromptRegistry } from './claude-prompt-registry' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { claudeToolResults, @@ -85,7 +86,11 @@ describe('Claude child operation output retention', () => { translator.handle({ type: 'message', sessionId: 'orca', message, observedAt: 1_000 }) const join = vi.spyOn(Array.prototype, 'join') const evidence = drainClaudeChildWork( - { childWork: new ClaudeChildWorkDecoder(), translator }, + { + childWork: new ClaudeChildWorkDecoder(), + prompts: new ClaudePromptRegistry(), + translator + }, message, 1_000 )