From 5d59e82ab64362919ce31fc368c505e07cbba333 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 24 Sep 2026 15:25:37 -0700 Subject: [PATCH 1/3] feat(zcode): explain a ZCode build that has no terminal UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZCode ships one agent runtime behind two front ends. The desktop app bundles it without `@zcode/tui`, because it draws its own window in Electron. Put that bundle on PATH as `zcode` and it answers `--version`, runs `-p` headlessly, and passes `zcode doctor` — so Orca detects it, launches it, and installs hooks against it, all successfully. Only the interactive session fails, leaving a bare Node stack trace in the pane that reads as a broken Orca integration. Watch a freshly launched ZCode pane's first output and replace that with an explanation: Orca's hooks are fine, this `zcode` just cannot open a session, install one that ships the TUI. The rule keys on Node's own module-resolution error rather than on the healthy build's "TUI requires an interactive terminal." message, because ZCode localizes the latter (`TUI 需要交互式终端。` in zh-CN) and matching it would miss every non-English user. Node's error is not translated and names the package. Scoped so it costs a healthy pane nothing: it runs only for a pane Orca launched as `zcode`, and only over the first 8 KiB, because a module-resolution failure happens before the runtime renders anything. Evidence: `src/main/runtime/__fixtures__/zcode-missing-tui.txt`, a recorded PTY capture of the desktop bundle refusing to start, per docs/reference/agent-pty-transcript-capture.md. Reported-by: JWu527 --- .../__fixtures__/zcode-missing-tui.meta.json | 9 ++ .../__fixtures__/zcode-missing-tui.txt | 1 + .../pty-connection/fresh-spawn-start.ts | 8 ++ .../zcode-missing-tui-watcher.test.ts | 100 ++++++++++++++++++ .../zcode-missing-tui-watcher.ts | 74 +++++++++++++ src/renderer/src/i18n/locales/en.json | 8 ++ src/renderer/src/i18n/locales/es.json | 8 ++ src/renderer/src/i18n/locales/fr.json | 8 ++ src/renderer/src/i18n/locales/ja.json | 8 ++ src/renderer/src/i18n/locales/ko.json | 8 ++ src/renderer/src/i18n/locales/zh.json | 8 ++ src/shared/zcode-missing-tui.test.ts | 34 ++++++ src/shared/zcode-missing-tui.ts | 31 ++++++ 13 files changed, 305 insertions(+) create mode 100644 src/main/runtime/__fixtures__/zcode-missing-tui.meta.json create mode 100644 src/main/runtime/__fixtures__/zcode-missing-tui.txt create mode 100644 src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.test.ts create mode 100644 src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.ts create mode 100644 src/shared/zcode-missing-tui.test.ts create mode 100644 src/shared/zcode-missing-tui.ts diff --git a/src/main/runtime/__fixtures__/zcode-missing-tui.meta.json b/src/main/runtime/__fixtures__/zcode-missing-tui.meta.json new file mode 100644 index 000000000000..5298b50be5e0 --- /dev/null +++ b/src/main/runtime/__fixtures__/zcode-missing-tui.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-09-24T22:11:40.957Z", + "platform": "darwin", + "command": ["zcode"], + "cols": 120, + "rows": 32, + "note": "ZCode desktop-bundled runtime 0.16.5 (no @zcode/tui); interactive launch fails immediately", + "exitCode": 1 +} diff --git a/src/main/runtime/__fixtures__/zcode-missing-tui.txt b/src/main/runtime/__fixtures__/zcode-missing-tui.txt new file mode 100644 index 000000000000..a8a46b3ebbbc --- /dev/null +++ b/src/main/runtime/__fixtures__/zcode-missing-tui.txt @@ -0,0 +1 @@ +Error: Cannot find package '@zcode/tui' imported from /Applications/ZCode.app/Contents/Resources/glm/zcode.cjs diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index 29bc438a01cd..ad595a253383 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -3,6 +3,7 @@ import { hasPtySerializer } from '../pty-buffer-serializer' import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { settleSpawnThatLeftPaneUnbound } from './unbound-pane-spawn-recovery' +import { startZCodeMissingTuiWatcher } from '../zcode-missing-tui-watcher' import { STARTUP_CWD_FALLBACK_NOTICE } from './startup-cwd-fallback-notice' import { pendingSpawnByPaneKey, pendingSpawnGenerationByPaneKey } from './pty-connect-limits' import { shouldWritePtyOutputForeground } from './foreground-output-scan' @@ -139,6 +140,13 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { : typeof spawnedPtyId === 'string' ? spawnedPtyId : session.transport.getPtyId() + if (resolvedPtyId && session.paneStartup?.launchAgent === 'zcode') { + // Why here and not in the shared data path: a ZCode build with no terminal UI dies + // in its first chunk, so the scan is bounded to one freshly launched pane's startup + // instead of every chunk of every pane. See zcode-missing-tui-watcher. + session.disposeZCodeMissingTuiWatcher?.() + session.disposeZCodeMissingTuiWatcher = startZCodeMissingTuiWatcher(resolvedPtyId) + } if (resolvedPtyId && !session.claimCapturedDirectSshRetryPty(resolvedPtyId)) { releaseDeferredCwdFence() session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) diff --git a/src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.test.ts b/src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.test.ts new file mode 100644 index 000000000000..551ac3390e11 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.test.ts @@ -0,0 +1,100 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { toastErrorMock, subscribeMock, unsubscribeMock } = vi.hoisted(() => ({ + toastErrorMock: vi.fn<(title: string, options: { description: string }) => void>(), + // Typed here so the test can call the captured watcher back without an assertion. + subscribeMock: vi.fn<(ptyId: string, watcher: (data: string) => void) => void>(), + unsubscribeMock: vi.fn() +})) + +vi.mock('sonner', () => ({ toast: { error: toastErrorMock } })) +vi.mock('@/i18n/i18n', () => ({ translate: (_id: string, fallback: string) => fallback })) +vi.mock('./pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: (ptyId: string, watcher: (data: string) => void) => { + subscribeMock(ptyId, watcher) + return unsubscribeMock + } +})) + +import { startZCodeMissingTuiWatcher } from './zcode-missing-tui-watcher' + +const FIXTURE = readFileSync( + join( + __dirname, + '..', + '..', + '..', + '..', + 'main', + 'runtime', + '__fixtures__', + 'zcode-missing-tui.txt' + ), + 'utf8' +) + +function feed(data: string): void { + const watcher = subscribeMock.mock.calls.at(-1)?.[1] + if (!watcher) { + throw new Error('no watcher was registered') + } + watcher(data) +} + +beforeEach(() => { + toastErrorMock.mockReset() + subscribeMock.mockReset() + unsubscribeMock.mockReset() +}) +afterEach(() => vi.restoreAllMocks()) + +describe('startZCodeMissingTuiWatcher', () => { + it('explains the failure when the recorded desktop-bundle output arrives', () => { + startZCodeMissingTuiWatcher('pty-1') + feed(FIXTURE) + expect(toastErrorMock).toHaveBeenCalledTimes(1) + expect(toastErrorMock.mock.calls[0][0]).toContain('no terminal UI') + // Why: the whole point is that Orca's own setup succeeded, so the copy must not + // leave the user hunting through their hook config. + expect(toastErrorMock.mock.calls[0][1].description).toContain('hooks installed correctly') + }) + + it('stops listening once it has explained itself', () => { + startZCodeMissingTuiWatcher('pty-1') + feed(FIXTURE) + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + }) + + it('rejoins the error when it straddles two chunks', () => { + startZCodeMissingTuiWatcher('pty-1') + const split = FIXTURE.indexOf('@zcode') + 3 + feed(FIXTURE.slice(0, split)) + expect(toastErrorMock).not.toHaveBeenCalled() + feed(FIXTURE.slice(split)) + expect(toastErrorMock).toHaveBeenCalledTimes(1) + }) + + it('stays silent for a healthy pane and gives up after the startup budget', () => { + startZCodeMissingTuiWatcher('pty-1') + // A working ZCode paints its banner; none of it should trip the rule. + feed('Z'.repeat(9000)) + expect(toastErrorMock).not.toHaveBeenCalled() + // Why assert the unsubscribe: past the budget the watcher must cost the pane nothing. + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + }) + + it('stays silent when the build has a TUI but no TTY', () => { + startZCodeMissingTuiWatcher('pty-1') + feed('TUI requires an interactive terminal.\r\n') + expect(toastErrorMock).not.toHaveBeenCalled() + }) + + it('can be disposed before anything arrives', () => { + const dispose = startZCodeMissingTuiWatcher('pty-1') + dispose() + dispose() + expect(unsubscribeMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.ts b/src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.ts new file mode 100644 index 000000000000..98644874947d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.ts @@ -0,0 +1,74 @@ +import { toast } from 'sonner' +import { isZCodeMissingTuiOutput } from '../../../../shared/zcode-missing-tui' +import { translate } from '@/i18n/i18n' +import { subscribeToPtyData } from './pty-data-sidecar-subscriptions' + +/** + * Tell the user when their `zcode` is a build with no terminal UI. + * + * ZCode's desktop bundle answers `--version`, runs `-p`, and passes `zcode doctor`, so Orca + * detects it, launches it, and installs hooks against it — all successfully. Only the + * interactive session fails, leaving a bare Node stack trace in the pane that reads as a + * broken Orca integration. This turns that into an explanation. + */ + +// Why a byte budget and not a timer: the failure is a module-resolution error the runtime +// hits before it renders anything, so it is in the first chunk or it is not coming. Capping +// the scan keeps a healthy pane's output off this path entirely after startup. +const SCAN_BUDGET_BYTES = 8_192 + +/** Watch one freshly launched ZCode pane's first output for the missing-TUI failure. */ +export function startZCodeMissingTuiWatcher(ptyId: string): () => void { + let scanned = 0 + let carry = '' + let disposed = false + let unsubscribe: (() => void) | undefined + + const dispose = (): void => { + if (disposed) { + return + } + disposed = true + unsubscribe?.() + } + + unsubscribe = subscribeToPtyData(ptyId, (data) => { + if (disposed) { + return + } + scanned += data.length + // Why carry: the error can straddle a chunk boundary, and it is one line, so a small + // overlap is enough to rejoin it without retaining scrollback. + const window = carry + data + if (isZCodeMissingTuiOutput(window)) { + showZCodeMissingTuiNotice() + dispose() + return + } + if (scanned >= SCAN_BUDGET_BYTES) { + dispose() + return + } + carry = window.slice(-256) + }) + + return dispose +} + +export function showZCodeMissingTuiNotice(): void { + toast.error( + translate( + 'auto.components.terminal.pane.zcode.missing.tui.title', + 'This ZCode build has no terminal UI' + ), + { + // Why a stable id: two ZCode panes launched together would otherwise stack the same notice. + id: 'zcode-missing-tui', + description: translate( + 'auto.components.terminal.pane.zcode.missing.tui.description', + "Orca's hooks installed correctly — the `zcode` on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a `zcode` that ships the TUI, then run `zcode` outside Orca to confirm." + ), + duration: 20_000 + } + ) +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 72cf2a672176..055f62c90c85 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3264,6 +3264,14 @@ "e2fcf07c0d": "Orca could not save this terminal session because local storage is full or not writable. Open the disk space analyzer to find workspace storage you can clean up.", "678c780a2c": "Disk space is unavailable" }, + "zcode": { + "missing": { + "tui": { + "title": "This ZCode build has no terminal UI", + "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + } + } + }, "osc52": { "clipboard": { "blocked": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 871cd611af5c..1988b85014e9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2798,6 +2798,14 @@ "e2fcf07c0d": "Orca no pudo guardar esta sesión de terminal porque el almacenamiento local está lleno o no permite escritura. Abre el analizador de espacio en disco para encontrar almacenamiento de espacios de trabajo que puedas limpiar.", "678c780a2c": "El espacio en disco no está disponible" }, + "zcode": { + "missing": { + "tui": { + "title": "This ZCode build has no terminal UI", + "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + } + } + }, "osc52": { "clipboard": { "blocked": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index e6c60b41e593..ea3207be00a1 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3211,6 +3211,14 @@ "e2fcf07c0d": "Orca n'a pas pu enregistrer cette session de terminal car le stockage local est plein ou inaccessible en écriture. Ouvrez l'analyseur d'espace disque pour trouver du stockage de espace de travail à nettoyer.", "678c780a2c": "Espace disque indisponible" }, + "zcode": { + "missing": { + "tui": { + "title": "This ZCode build has no terminal UI", + "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + } + } + }, "osc52": { "clipboard": { "blocked": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index eadab74eb76b..3167a9e0ae52 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3089,6 +3089,14 @@ "e2fcf07c0d": "ローカルストレージがいっぱいか書き込みできないため、Orca はこのターミナルセッションを保存できませんでした。ディスク容量アナライザーを開いて、クリーンアップできるワークスペースストレージを見つけます。", "678c780a2c": "ディスク容量が利用できません" }, + "zcode": { + "missing": { + "tui": { + "title": "This ZCode build has no terminal UI", + "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + } + } + }, "osc52": { "clipboard": { "blocked": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index aafd2b76ebfc..488e10749e6c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3089,6 +3089,14 @@ "e2fcf07c0d": "로컬 저장소가 가득 찼거나 쓸 수 없기 때문에 Orca가 이 terminal 세션을 저장할 수 없습니다. 디스크 공간 분석기를 열어 정리할 수 있는 워크스페이스 저장소를 찾으세요.", "678c780a2c": "디스크 공간을 사용할 수 없습니다." }, + "zcode": { + "missing": { + "tui": { + "title": "This ZCode build has no terminal UI", + "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + } + } + }, "osc52": { "clipboard": { "blocked": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index ed926dadb279..4a546a3aca4b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3089,6 +3089,14 @@ "e2fcf07c0d": "Orca 无法保存此终端会话,因为本地存储已满或不可写。打开磁盘空间分析器以查找可以清理的工作区存储。", "678c780a2c": "磁盘空间不可用" }, + "zcode": { + "missing": { + "tui": { + "title": "This ZCode build has no terminal UI", + "description": "Orca's hooks installed correctly \u2014 the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + } + } + }, "osc52": { "clipboard": { "blocked": { diff --git a/src/shared/zcode-missing-tui.test.ts b/src/shared/zcode-missing-tui.test.ts new file mode 100644 index 000000000000..ad736c8a9b39 --- /dev/null +++ b/src/shared/zcode-missing-tui.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { isZCodeMissingTuiOutput } from './zcode-missing-tui' + +const FIXTURE = join(__dirname, '..', 'main', 'runtime', '__fixtures__', 'zcode-missing-tui.txt') + +describe('isZCodeMissingTuiOutput', () => { + it('matches the recorded desktop-bundle failure byte for byte', () => { + expect(isZCodeMissingTuiOutput(readFileSync(FIXTURE, 'utf8'))).toBe(true) + }) + + it('matches the CJS spelling of the same failure', () => { + expect(isZCodeMissingTuiOutput("Error: Cannot find module '@zcode/tui'")).toBe(true) + }) + + it('does not match a build that has the TUI but no TTY', () => { + // Why this case matters: it is the healthy build's stderr when stdin is not a terminal. + expect(isZCodeMissingTuiOutput('TUI requires an interactive terminal.')).toBe(false) + }) + + it('does not match the localized form of that same healthy message', () => { + // Why: ZCode translates it, which is exactly why this rule keys on Node's error instead. + expect(isZCodeMissingTuiOutput('TUI 需要交互式终端。')).toBe(false) + }) + + it('does not match an unrelated missing package', () => { + expect(isZCodeMissingTuiOutput("Cannot find package 'left-pad' imported from x.js")).toBe(false) + }) + + it('does not match prose that merely mentions the package', () => { + expect(isZCodeMissingTuiOutput('install @zcode/tui to get the terminal UI')).toBe(false) + }) +}) diff --git a/src/shared/zcode-missing-tui.ts b/src/shared/zcode-missing-tui.ts new file mode 100644 index 000000000000..cd87f1cdcec7 --- /dev/null +++ b/src/shared/zcode-missing-tui.ts @@ -0,0 +1,31 @@ +/** + * Recognizing a `zcode` build that cannot open a session. + * + * ZCode ships one agent runtime behind two front ends. The desktop app bundles the runtime + * without `@zcode/tui`, because it draws its own window in Electron and would never call a + * terminal renderer. Put that bundle on PATH as `zcode` and it answers `--version`, runs + * `-p` headlessly, and passes `zcode doctor` — but dies the moment Orca asks it for an + * interactive session. + * + * That combination is why this is worth detecting rather than documenting alone: Orca's own + * auto-setup reports success (the hooks really are installed correctly), so the only visible + * failure is a Node stack trace inside the pane, and it reads as a broken Orca integration. + * + * Evidence: `src/main/runtime/__fixtures__/zcode-missing-tui.txt`, a recorded PTY capture of + * the desktop bundle refusing to start. + */ + +// Why the module-resolution error and not the success path: a build that HAS the TUI but no +// TTY prints "TUI requires an interactive terminal.", which ZCode localizes (`TUI 需要交互式终端。` +// in zh-CN), so matching it would miss every non-English user. Node's own resolution failure +// is not localized and names the package directly. +// +// Both spellings are covered because the ESM loader reports `Cannot find package` while a CJS +// require path reports `Cannot find module`, and which one a given build hits depends on how +// it was bundled. +const ZCODE_MISSING_TUI_RE = /Cannot find (?:package|module) ['"]@zcode\/tui['"]/ + +/** True when this output is ZCode reporting that its terminal UI is not installed. */ +export function isZCodeMissingTuiOutput(output: string): boolean { + return ZCODE_MISSING_TUI_RE.test(output) +} From c6e8cea3486a82e16367a71b250768d8ecf71ac8 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 25 Sep 2026 00:05:54 -0700 Subject: [PATCH 2/3] refactor(zcode): ask the CLI if it can open a session instead of watching for the failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stream watcher this replaces never fired. Before/after screenshots were identical and instrumentation showed the hook never ran, so the sidecar was both misplaced and racing a failure that lands ~440ms after spawn. Replace it with a direct question, answered once per run and cached. Reading zai-org/ZCode shows why running it is the only way to ask, and why the answer is unambiguous. `--version` and `doctor` are byte-identical in shape between a build that has the terminal UI and one that does not, because the TUI is only ever touched on the `tui` command path. There, `runTuiCommand` calls `loadTuiRuntime()` before anything else, and `runTui` checks for a TTY only after that module is already loaded. So with stdin at EOF: - no TUI -> fails in the loader -> Node's module-resolution error - has TUI -> loads, then declines -> "TUI requires an interactive terminal." The module error is therefore present exactly when the terminal UI is absent. All three shipping shapes land correctly: an npm/node-bundle install resolves `@zcode/tui` as a real package (esbuild marks it external, so it is never inlined), a SEA build always carries it as embedded assets, and the desktop app's bundled runtime carries neither. Verified against both real builds on this machine rather than a mock: the desktop bundle answers `missing-tui`, a CLI built from source answers `interactive`, and a command that does not exist answers `unknown` — the probe fails open so an unrelated spawn failure never accuses a working CLI. --- .../zcode/interactive-capability.live.test.ts | 31 ++++++ src/main/zcode/interactive-capability.ts | 81 ++++++++++++++ .../pty-connection/fresh-spawn-start.ts | 8 -- .../zcode-missing-tui-watcher.test.ts | 100 ------------------ .../zcode-missing-tui-watcher.ts | 74 ------------- 5 files changed, 112 insertions(+), 182 deletions(-) create mode 100644 src/main/zcode/interactive-capability.live.test.ts create mode 100644 src/main/zcode/interactive-capability.ts delete mode 100644 src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.test.ts delete mode 100644 src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.ts diff --git a/src/main/zcode/interactive-capability.live.test.ts b/src/main/zcode/interactive-capability.live.test.ts new file mode 100644 index 000000000000..09ee238d5b80 --- /dev/null +++ b/src/main/zcode/interactive-capability.live.test.ts @@ -0,0 +1,31 @@ +import { existsSync } from 'node:fs' +import { describe, expect, it, beforeEach } from 'vitest' +import { + readZCodeInteractiveCapability, + _resetZCodeInteractiveCapabilityForTests +} from './interactive-capability' + +// Two real ZCode builds on this machine: the desktop app's bundled runtime (no TUI) and a +// CLI built from zai-org/ZCode (has TUI). Skipped anywhere they are absent. +const WITHOUT_TUI = '/tmp/zcode-bin-notui/zcode' +const WITH_TUI = '/tmp/zcode-bin-tui/zcode' +const bothPresent = existsSync(WITHOUT_TUI) && existsSync(WITH_TUI) +const describeLive = bothPresent ? describe : describe.skip + +beforeEach(() => _resetZCodeInteractiveCapabilityForTests()) + +describeLive('readZCodeInteractiveCapability against real ZCode builds', () => { + it("reports missing-tui for the desktop app's bundled runtime", async () => { + await expect(readZCodeInteractiveCapability(WITHOUT_TUI)).resolves.toBe('missing-tui') + }, 30_000) + + it('reports interactive for a CLI built from source', async () => { + await expect(readZCodeInteractiveCapability(WITH_TUI)).resolves.toBe('interactive') + }, 30_000) + + it('fails open when the command does not exist', async () => { + await expect(readZCodeInteractiveCapability('/tmp/definitely-not-zcode')).resolves.toBe( + 'unknown' + ) + }, 30_000) +}) diff --git a/src/main/zcode/interactive-capability.ts b/src/main/zcode/interactive-capability.ts new file mode 100644 index 000000000000..53cedeb00ec2 --- /dev/null +++ b/src/main/zcode/interactive-capability.ts @@ -0,0 +1,81 @@ +import { runProcess } from '../../shared/child-process/run-process' +import { isZCodeMissingTuiOutput } from '../../shared/zcode-missing-tui' + +/** + * Asking a `zcode` build whether it can open a session, before Orca opens a pane for it. + * + * Why a deliberate probe and not a rule over the pane's output: the failure lands within + * about half a second of spawn, so anything watching a live stream races it. Asking the + * question directly is race-free, answerable once, and can run before the user ever sees a + * terminal. + * + * Why running it is the only way to ask: ZCode's `--version` and `doctor` are identical + * between a build that has the TUI and one that does not (measured on both), because the + * TUI is only ever touched on the `tui` command path — `runTuiCommand` calls + * `loadTuiRuntime()` before anything else, and every other subcommand returns without it. + * + * That same ordering is what makes the answer unambiguous. With no TTY: + * - a build WITHOUT the TUI fails in `loadTuiRuntime` → Node's module-resolution error + * - a build WITH the TUI loads, then `runTui` rejects the missing TTY + * so the module error is present exactly when the terminal UI is absent. + * + * All three shipping shapes land correctly: an npm/node-bundle install resolves + * `@zcode/tui` as a real package (it is an esbuild external, never inlined); a SEA build + * always carries the TUI as embedded assets; and the desktop app's bundled runtime carries + * neither, which is the case worth catching. + */ +export type ZCodeInteractiveCapability = 'interactive' | 'missing-tui' | 'unknown' + +// Why bounded: the answer arrives in well under a second on both builds measured. A hang +// means something unexpected, and an unexpected build must not be called broken. +const PROBE_TIMEOUT_MS = 6_000 + +let cached: ZCodeInteractiveCapability | undefined +let inFlight: Promise | undefined + +export function _resetZCodeInteractiveCapabilityForTests(): void { + cached = undefined + inFlight = undefined +} + +async function probe(command: string): Promise { + try { + const result = await runProcess({ + program: command, + args: [], + // Why an explicit empty stdin: the child gets a pipe at EOF rather than a terminal, so + // a build that HAS the TUI declines instead of taking the probe interactive. + input: '', + timeoutMs: PROBE_TIMEOUT_MS + }) + if (result.timedOut) { + return 'unknown' + } + return isZCodeMissingTuiOutput(`${result.stderr}\n${result.stdout}`) + ? 'missing-tui' + : 'interactive' + } catch { + // Why fail open: a spawn that never ran says nothing about the build. Reporting + // 'missing-tui' here would accuse a perfectly good CLI on an unrelated failure. + return 'unknown' + } +} + +/** Whether this `zcode` can open a session. Answered once per Orca run. */ +export function readZCodeInteractiveCapability( + command = 'zcode' +): Promise { + if (cached !== undefined) { + return Promise.resolve(cached) + } + inFlight ??= probe(command).then((verdict) => { + // Why only remember a definitive answer: 'unknown' is usually transient (a timeout, a + // spawn refused under load), and caching it would suppress the notice for the session. + if (verdict !== 'unknown') { + cached = verdict + } + inFlight = undefined + return verdict + }) + return inFlight +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts index ad595a253383..29bc438a01cd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-start.ts @@ -3,7 +3,6 @@ import { hasPtySerializer } from '../pty-buffer-serializer' import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { settleSpawnThatLeftPaneUnbound } from './unbound-pane-spawn-recovery' -import { startZCodeMissingTuiWatcher } from '../zcode-missing-tui-watcher' import { STARTUP_CWD_FALLBACK_NOTICE } from './startup-cwd-fallback-notice' import { pendingSpawnByPaneKey, pendingSpawnGenerationByPaneKey } from './pty-connect-limits' import { shouldWritePtyOutputForeground } from './foreground-output-scan' @@ -140,13 +139,6 @@ export function bindStartFreshSpawn(session: ConnectPanePtySession): void { : typeof spawnedPtyId === 'string' ? spawnedPtyId : session.transport.getPtyId() - if (resolvedPtyId && session.paneStartup?.launchAgent === 'zcode') { - // Why here and not in the shared data path: a ZCode build with no terminal UI dies - // in its first chunk, so the scan is bounded to one freshly launched pane's startup - // instead of every chunk of every pane. See zcode-missing-tui-watcher. - session.disposeZCodeMissingTuiWatcher?.() - session.disposeZCodeMissingTuiWatcher = startZCodeMissingTuiWatcher(resolvedPtyId) - } if (resolvedPtyId && !session.claimCapturedDirectSshRetryPty(resolvedPtyId)) { releaseDeferredCwdFence() session.finishReattachLiveDataDeferral(false, outputCallbacks.generation) diff --git a/src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.test.ts b/src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.test.ts deleted file mode 100644 index 551ac3390e11..000000000000 --- a/src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { readFileSync } from 'node:fs' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const { toastErrorMock, subscribeMock, unsubscribeMock } = vi.hoisted(() => ({ - toastErrorMock: vi.fn<(title: string, options: { description: string }) => void>(), - // Typed here so the test can call the captured watcher back without an assertion. - subscribeMock: vi.fn<(ptyId: string, watcher: (data: string) => void) => void>(), - unsubscribeMock: vi.fn() -})) - -vi.mock('sonner', () => ({ toast: { error: toastErrorMock } })) -vi.mock('@/i18n/i18n', () => ({ translate: (_id: string, fallback: string) => fallback })) -vi.mock('./pty-data-sidecar-subscriptions', () => ({ - subscribeToPtyData: (ptyId: string, watcher: (data: string) => void) => { - subscribeMock(ptyId, watcher) - return unsubscribeMock - } -})) - -import { startZCodeMissingTuiWatcher } from './zcode-missing-tui-watcher' - -const FIXTURE = readFileSync( - join( - __dirname, - '..', - '..', - '..', - '..', - 'main', - 'runtime', - '__fixtures__', - 'zcode-missing-tui.txt' - ), - 'utf8' -) - -function feed(data: string): void { - const watcher = subscribeMock.mock.calls.at(-1)?.[1] - if (!watcher) { - throw new Error('no watcher was registered') - } - watcher(data) -} - -beforeEach(() => { - toastErrorMock.mockReset() - subscribeMock.mockReset() - unsubscribeMock.mockReset() -}) -afterEach(() => vi.restoreAllMocks()) - -describe('startZCodeMissingTuiWatcher', () => { - it('explains the failure when the recorded desktop-bundle output arrives', () => { - startZCodeMissingTuiWatcher('pty-1') - feed(FIXTURE) - expect(toastErrorMock).toHaveBeenCalledTimes(1) - expect(toastErrorMock.mock.calls[0][0]).toContain('no terminal UI') - // Why: the whole point is that Orca's own setup succeeded, so the copy must not - // leave the user hunting through their hook config. - expect(toastErrorMock.mock.calls[0][1].description).toContain('hooks installed correctly') - }) - - it('stops listening once it has explained itself', () => { - startZCodeMissingTuiWatcher('pty-1') - feed(FIXTURE) - expect(unsubscribeMock).toHaveBeenCalledTimes(1) - }) - - it('rejoins the error when it straddles two chunks', () => { - startZCodeMissingTuiWatcher('pty-1') - const split = FIXTURE.indexOf('@zcode') + 3 - feed(FIXTURE.slice(0, split)) - expect(toastErrorMock).not.toHaveBeenCalled() - feed(FIXTURE.slice(split)) - expect(toastErrorMock).toHaveBeenCalledTimes(1) - }) - - it('stays silent for a healthy pane and gives up after the startup budget', () => { - startZCodeMissingTuiWatcher('pty-1') - // A working ZCode paints its banner; none of it should trip the rule. - feed('Z'.repeat(9000)) - expect(toastErrorMock).not.toHaveBeenCalled() - // Why assert the unsubscribe: past the budget the watcher must cost the pane nothing. - expect(unsubscribeMock).toHaveBeenCalledTimes(1) - }) - - it('stays silent when the build has a TUI but no TTY', () => { - startZCodeMissingTuiWatcher('pty-1') - feed('TUI requires an interactive terminal.\r\n') - expect(toastErrorMock).not.toHaveBeenCalled() - }) - - it('can be disposed before anything arrives', () => { - const dispose = startZCodeMissingTuiWatcher('pty-1') - dispose() - dispose() - expect(unsubscribeMock).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.ts b/src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.ts deleted file mode 100644 index 98644874947d..000000000000 --- a/src/renderer/src/components/terminal-pane/zcode-missing-tui-watcher.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { toast } from 'sonner' -import { isZCodeMissingTuiOutput } from '../../../../shared/zcode-missing-tui' -import { translate } from '@/i18n/i18n' -import { subscribeToPtyData } from './pty-data-sidecar-subscriptions' - -/** - * Tell the user when their `zcode` is a build with no terminal UI. - * - * ZCode's desktop bundle answers `--version`, runs `-p`, and passes `zcode doctor`, so Orca - * detects it, launches it, and installs hooks against it — all successfully. Only the - * interactive session fails, leaving a bare Node stack trace in the pane that reads as a - * broken Orca integration. This turns that into an explanation. - */ - -// Why a byte budget and not a timer: the failure is a module-resolution error the runtime -// hits before it renders anything, so it is in the first chunk or it is not coming. Capping -// the scan keeps a healthy pane's output off this path entirely after startup. -const SCAN_BUDGET_BYTES = 8_192 - -/** Watch one freshly launched ZCode pane's first output for the missing-TUI failure. */ -export function startZCodeMissingTuiWatcher(ptyId: string): () => void { - let scanned = 0 - let carry = '' - let disposed = false - let unsubscribe: (() => void) | undefined - - const dispose = (): void => { - if (disposed) { - return - } - disposed = true - unsubscribe?.() - } - - unsubscribe = subscribeToPtyData(ptyId, (data) => { - if (disposed) { - return - } - scanned += data.length - // Why carry: the error can straddle a chunk boundary, and it is one line, so a small - // overlap is enough to rejoin it without retaining scrollback. - const window = carry + data - if (isZCodeMissingTuiOutput(window)) { - showZCodeMissingTuiNotice() - dispose() - return - } - if (scanned >= SCAN_BUDGET_BYTES) { - dispose() - return - } - carry = window.slice(-256) - }) - - return dispose -} - -export function showZCodeMissingTuiNotice(): void { - toast.error( - translate( - 'auto.components.terminal.pane.zcode.missing.tui.title', - 'This ZCode build has no terminal UI' - ), - { - // Why a stable id: two ZCode panes launched together would otherwise stack the same notice. - id: 'zcode-missing-tui', - description: translate( - 'auto.components.terminal.pane.zcode.missing.tui.description', - "Orca's hooks installed correctly — the `zcode` on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a `zcode` that ships the TUI, then run `zcode` outside Orca to confirm." - ), - duration: 20_000 - } - ) -} From 65d4fe0da56015a9da6bb6db8750aecb54858602 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 25 Sep 2026 01:58:20 -0700 Subject: [PATCH 3/3] feat(zcode): warn at launch when the installed zcode cannot open a session Wires the capability probe to the one place a ZCode launch is first known: terminal tab creation, which runs before the pane connects, so the explanation reaches the screen alongside the failure rather than after it. - main exposes the cached probe over `preflight:zcodeInteractiveCapability`, beside the other "what can the installed CLIs do" answers - the web preload stub answers `unknown`, because a paired client has no business deciding anything about the host's CLI install - the renderer notice is advisory: a probe that cannot run never blocks a launch Verified in the running app against the real desktop bundle: creating a ZCode workspace now shows "This ZCode build has no terminal UI" next to the stack trace, where before the trace stood alone. --- src/main/ipc/preflight.ts | 7 ++++ src/main/zcode/interactive-capability.ts | 7 ++-- src/preload/api/preflight-api.ts | 3 ++ src/preload/api/preflight-bridge.ts | 3 ++ .../terminal-pane/zcode-missing-tui-notice.ts | 33 +++++++++++++++++++ .../store/terminals/terminal-tab-creation.ts | 6 ++++ .../preload-api/web-host-capability-api.ts | 3 ++ src/shared/zcode-missing-tui.ts | 8 +++++ 8 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/zcode-missing-tui-notice.ts diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index e8a678b40cd1..581a7a86c66a 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -16,6 +16,7 @@ import type { // ../preflight/agent-detection so the runtime can call it without ipcMain. // Re-exported here so existing importers of `ipc/preflight` keep working. export * from '../preflight/agent-detection' +import { readZCodeInteractiveCapability } from '../zcode/interactive-capability' export function registerPreflightHandlers(): void { ipcMain.handle( @@ -32,6 +33,12 @@ export function registerPreflightHandlers(): void { detectInstalledAgentsWithShellPathHydration(args) ) + // Why here: this is the one place that already answers "what can the installed agent CLIs + // do", and the probe is cached, so a repeat launch costs nothing. + ipcMain.handle('preflight:zcodeInteractiveCapability', async () => + readZCodeInteractiveCapability() + ) + ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => { return refreshShellPathAndDetectAgents(args) }) diff --git a/src/main/zcode/interactive-capability.ts b/src/main/zcode/interactive-capability.ts index 53cedeb00ec2..d5b65851dae5 100644 --- a/src/main/zcode/interactive-capability.ts +++ b/src/main/zcode/interactive-capability.ts @@ -1,5 +1,8 @@ import { runProcess } from '../../shared/child-process/run-process' -import { isZCodeMissingTuiOutput } from '../../shared/zcode-missing-tui' +import { + isZCodeMissingTuiOutput, + type ZCodeInteractiveCapability +} from '../../shared/zcode-missing-tui' /** * Asking a `zcode` build whether it can open a session, before Orca opens a pane for it. @@ -24,7 +27,7 @@ import { isZCodeMissingTuiOutput } from '../../shared/zcode-missing-tui' * always carries the TUI as embedded assets; and the desktop app's bundled runtime carries * neither, which is the case worth catching. */ -export type ZCodeInteractiveCapability = 'interactive' | 'missing-tui' | 'unknown' +export type { ZCodeInteractiveCapability } // Why bounded: the answer arrives in well under a second on both builds measured. A hang // means something unexpected, and an unexpected build must not be called broken. diff --git a/src/preload/api/preflight-api.ts b/src/preload/api/preflight-api.ts index 8535bb35f8c8..d8582af13a74 100644 --- a/src/preload/api/preflight-api.ts +++ b/src/preload/api/preflight-api.ts @@ -1,3 +1,4 @@ +import type { ZCodeInteractiveCapability } from '../../shared/zcode-missing-tui' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { PathSource, @@ -46,6 +47,8 @@ export type PreflightRuntimeContext = { export type PreflightApi = { check: (args?: PreflightRuntimeContext & { force?: boolean }) => Promise detectAgents: (args?: PreflightRuntimeContext) => Promise + /** Whether the installed `zcode` can open a session; cached in main per run. */ + zcodeInteractiveCapability: () => Promise refreshAgents: (args?: PreflightRuntimeContext) => Promise detectRemoteAgents: (args: { connectionId: string }) => Promise detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{ diff --git a/src/preload/api/preflight-bridge.ts b/src/preload/api/preflight-bridge.ts index 64d317d139cf..843eec933314 100644 --- a/src/preload/api/preflight-bridge.ts +++ b/src/preload/api/preflight-bridge.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { PreflightRuntimeContext, PreloadApi, RefreshAgentsResult } from '../api-types' +import type { ZCodeInteractiveCapability } from '../../shared/zcode-missing-tui' export const preflightApi = { check: (args?: { @@ -27,6 +28,8 @@ export const preflightApi = { }> => ipcRenderer.invoke('preflight:check', args), detectAgents: (args?: PreflightRuntimeContext): Promise => ipcRenderer.invoke('preflight:detectAgents', args), + zcodeInteractiveCapability: (): Promise => + ipcRenderer.invoke('preflight:zcodeInteractiveCapability'), refreshAgents: (args?: PreflightRuntimeContext): Promise => ipcRenderer.invoke('preflight:refreshAgents', args), detectRemoteAgents: (args: { connectionId: string }): Promise => diff --git a/src/renderer/src/components/terminal-pane/zcode-missing-tui-notice.ts b/src/renderer/src/components/terminal-pane/zcode-missing-tui-notice.ts new file mode 100644 index 000000000000..5d758d39a8b9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/zcode-missing-tui-notice.ts @@ -0,0 +1,33 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' + +/** + * Tell the user their `zcode` cannot open a session, before they stare at a dead pane. + * + * ZCode's desktop bundle answers `--version`, runs `-p`, and passes `zcode doctor`, so Orca + * detects it, launches it, and installs hooks against it — all successfully. Only the + * interactive session fails, leaving a bare Node stack trace that reads as a broken Orca + * integration. The capability probe in main answers the question; this reports it. + */ +export async function warnIfZCodeCannotOpenSession(): Promise { + // Why swallow: this is advisory. A probe that could not run must never interrupt a launch. + const capability = await window.api.preflight.zcodeInteractiveCapability().catch(() => 'unknown') + if (capability !== 'missing-tui') { + return + } + toast.error( + translate( + 'auto.components.terminal.pane.zcode.missing.tui.title', + 'This ZCode build has no terminal UI' + ), + { + // Why a stable id: launching several ZCode panes must not stack the same notice. + id: 'zcode-missing-tui', + description: translate( + 'auto.components.terminal.pane.zcode.missing.tui.description', + "Orca's hooks installed correctly — the zcode on your PATH just cannot open a session. The ZCode desktop app bundles the agent runtime without its terminal UI. Install a zcode that ships the TUI, then run zcode outside Orca to confirm." + ), + duration: 20_000 + } + ) +} diff --git a/src/renderer/src/store/terminals/terminal-tab-creation.ts b/src/renderer/src/store/terminals/terminal-tab-creation.ts index c1e67a06ed6f..cbe42c62ee0f 100644 --- a/src/renderer/src/store/terminals/terminal-tab-creation.ts +++ b/src/renderer/src/store/terminals/terminal-tab-creation.ts @@ -1,3 +1,4 @@ +import { warnIfZCodeCannotOpenSession } from '@/components/terminal-pane/zcode-missing-tui-notice' import { clearWorktreeSleepIntent } from '@/lib/worktree-sleep-intent' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { isValidHostTerminalTabId } from '../../../../shared/terminal-tab-id' @@ -133,6 +134,11 @@ export function createTerminalTabCreationActions( // Why: mark click-caused (not work-caused) spawns so updateTabPtyId skips the activity/sortEpoch bump that would reorder Recent/Smart on click. ...(options?.pendingActivationSpawn ? { pendingActivationSpawn: true } : {}) } + if (options?.launchAgent === 'zcode') { + // Why here: this is where a ZCode launch is first known, and it runs before the + // pane connects, so the explanation can beat the stack trace to the screen. + void warnIfZCodeCannotOpenSession() + } const validTargetGroupId = targetGroupId && s.groupsByWorktree[worktreeId]?.some((group) => group.id === targetGroupId) diff --git a/src/renderer/src/web/preload-api/web-host-capability-api.ts b/src/renderer/src/web/preload-api/web-host-capability-api.ts index 3834d664d87a..8a736717c015 100644 --- a/src/renderer/src/web/preload-api/web-host-capability-api.ts +++ b/src/renderer/src/web/preload-api/web-host-capability-api.ts @@ -64,6 +64,9 @@ export function createPreflightApi(): NonNullable['preflight } return callRuntimeResult('preflight.check', args) }, + // Why 'unknown' rather than a runtime call: the probe spawns the host's own `zcode`, and + // a paired web client has no business deciding anything about the host's CLI install. + zcodeInteractiveCapability: async () => 'unknown' as const, detectAgents: async () => { if (!requireActiveEnvironmentOrNull()) { return [] diff --git a/src/shared/zcode-missing-tui.ts b/src/shared/zcode-missing-tui.ts index cd87f1cdcec7..29dc58418755 100644 --- a/src/shared/zcode-missing-tui.ts +++ b/src/shared/zcode-missing-tui.ts @@ -25,6 +25,14 @@ // it was bundled. const ZCODE_MISSING_TUI_RE = /Cannot find (?:package|module) ['"]@zcode\/tui['"]/ +/** + * What a `zcode` build can do when asked for a session. + * + * `unknown` is a real answer, not a failure: a probe that could not run says nothing about + * the build, and callers must not treat it as broken. + */ +export type ZCodeInteractiveCapability = 'interactive' | 'missing-tui' | 'unknown' + /** True when this output is ZCode reporting that its terminal UI is not installed. */ export function isZCodeMissingTuiOutput(output: string): boolean { return ZCODE_MISSING_TUI_RE.test(output)