Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/main/ipc/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
})
Expand Down
9 changes: 9 additions & 0 deletions src/main/runtime/__fixtures__/zcode-missing-tui.meta.json
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions src/main/runtime/__fixtures__/zcode-missing-tui.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Error: Cannot find package '@zcode/tui' imported from /Applications/ZCode.app/Contents/Resources/glm/zcode.cjs
31 changes: 31 additions & 0 deletions src/main/zcode/interactive-capability.live.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
84 changes: 84 additions & 0 deletions src/main/zcode/interactive-capability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { runProcess } from '../../shared/child-process/run-process'
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.
*
* 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 }

// 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<ZCodeInteractiveCapability> | undefined

export function _resetZCodeInteractiveCapabilityForTests(): void {
cached = undefined
inFlight = undefined
}

async function probe(command: string): Promise<ZCodeInteractiveCapability> {
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'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The probe always spawns bare zcode, so a user whose agentCmdOverrides.zcode points at a working binary is still judged by whatever zcode is on PATH, and an install resolvable only via detectCommandsInInstallDirs (as detectInstalledAgents handles) is missed. The command parameter also is not part of the cache key at line 71, so a non-default call would poison the run's cached verdict. Resolve the command the pane will actually launch and key the cache by it.

Suggested change
command = 'zcode'
export function readZCodeInteractiveCapability(
command = 'zcode'
): Promise<ZCodeInteractiveCapability> {

): Promise<ZCodeInteractiveCapability> {
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
}
3 changes: 3 additions & 0 deletions src/preload/api/preflight-api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ZCodeInteractiveCapability } from '../../shared/zcode-missing-tui'
import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime'
import type {
PathSource,
Expand Down Expand Up @@ -46,6 +47,8 @@ export type PreflightRuntimeContext = {
export type PreflightApi = {
check: (args?: PreflightRuntimeContext & { force?: boolean }) => Promise<PreflightStatus>
detectAgents: (args?: PreflightRuntimeContext) => Promise<string[]>
/** Whether the installed `zcode` can open a session; cached in main per run. */
zcodeInteractiveCapability: () => Promise<ZCodeInteractiveCapability>
refreshAgents: (args?: PreflightRuntimeContext) => Promise<RefreshAgentsResult>
detectRemoteAgents: (args: { connectionId: string }) => Promise<string[]>
detectRemoteWindowsTerminalCapabilities: (args: { connectionId: string }) => Promise<{
Expand Down
3 changes: 3 additions & 0 deletions src/preload/api/preflight-bridge.ts
Original file line number Diff line number Diff line change
@@ -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?: {
Expand Down Expand Up @@ -27,6 +28,8 @@ export const preflightApi = {
}> => ipcRenderer.invoke('preflight:check', args),
detectAgents: (args?: PreflightRuntimeContext): Promise<string[]> =>
ipcRenderer.invoke('preflight:detectAgents', args),
zcodeInteractiveCapability: (): Promise<ZCodeInteractiveCapability> =>
ipcRenderer.invoke('preflight:zcodeInteractiveCapability'),
refreshAgents: (args?: PreflightRuntimeContext): Promise<RefreshAgentsResult> =>
ipcRenderer.invoke('preflight:refreshAgents', args),
detectRemoteAgents: (args: { connectionId: string }): Promise<string[]> =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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
}
)
}
8 changes: 8 additions & 0 deletions src/renderer/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/src/store/terminals/terminal-tab-creation.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createTab already knows the pane's execution host — remoteConnectionId and isWslWorktree are computed at lines 99-101 — but warnIfZCodeCannotOpenSession() probes the machine running Orca's main process regardless. On an SSH or WSL worktree this shows a false notice when only the local binary lacks the TUI, and silently misses the real failure when only the remote one does. At minimum, skip the local probe for remote/WSL panes; the better fix is to ask the execution host (the same way detectRemoteAgents/detectWslCommandsOnPath do).

Suggested change
if (options?.launchAgent === 'zcode') {
if (options?.launchAgent === 'zcode' && !isRemoteWorktree && !isWslWorktree) {

// 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)
Expand Down
3 changes: 3 additions & 0 deletions src/renderer/src/web/preload-api/web-host-capability-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export function createPreflightApi(): NonNullable<Partial<PreloadApi>['preflight
}
return callRuntimeResult<PreflightStatus>('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 []
Expand Down
34 changes: 34 additions & 0 deletions src/shared/zcode-missing-tui.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading
Loading