Files
orca/src/main/zcode/interactive-capability.ts
T
Neil 69839c253e feat(zcode): explain a ZCode build that has no terminal UI (#22730)
* feat(zcode): explain a ZCode build that has no terminal UI

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

* refactor(zcode): ask the CLI if it can open a session instead of watching for the failure

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.

* 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.
2026-09-25 02:28:40 -07:00

85 lines
3.3 KiB
TypeScript

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'
): 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
}