feat(agents): add first-class ZCode harness (#22464)

* feat(agents): add first-class ZCode harness

Add ZCode (Z.ai's `zcode` CLI) as a supervised Orca agent: managed lifecycle
hooks on local, SSH and Windows hosts; status, question and approval reporting;
synthetic status titles; session resume; orchestration worker launch options;
and desktop + mobile agent-picker registration.

Written against the newly open-sourced `zai-org/ZCode` (agent CLI 0.16.9), not
against a remembered screen:

- ZCode's hook runner writes a Claude-compatible stdin alias set, so it routes
  through the existing Claude-compatible vendor path while keeping its own
  identity in the sidebar.
- `PermissionRequest` fires only once the approval card is on screen and racing
  the user's answer, so it is proof the pane is blocked, not an auto-approval.
- ZCode's clarification tool is literally `AskUserQuestion` with Claude's
  questions/options shape, so Orca's question card renders it unchanged.
- ZCode's `hooks.enabled` defaults to false, which is why configured hooks were
  reported as never firing; the installer sets it.
- ZCode renames its own process to `zcode-cli`, so the expected foreground
  process cannot be the launch command or dispatch refuses the pane.
- ZCode emits no OSC title in any state and repaints its ASCII banner forever,
  so readiness comes from Orca's synthetic hook title and launch drafts wait on
  the composer box rather than on a quiet render window.

Three files crossed their max-lines limit, so each is split along a real seam:
command-line entrypoint parsing out of agent process recognition, skill
classification out of skill root discovery, and registry coverage out of the
remote hook installer tests.

Refs #10564

* fix(zcode): drop the session-option catalog and pin the orchestration contract

ZCode's CLI exposes no `--model` flag at all, and the session-option launch path
refuses to apply any option until a model id is chosen. A catalog therefore could
not deliver `--mode` per worker, and would have accepted `--model` only to drop
it silently. Take opencode's position instead: no catalog, so `worker-start
--model` is refused with a clear message and ZCode launches with the model from
its own config. `--mode` stays reachable through agent args, which is also how
the yolo default is applied.

Add a contract test covering the parts that make ZCode a usable worker:
dispatchable foreground process, stdin prompt delivery, the prompt staying out
of the launch command, and the composer-gated draft paste.

* refactor(zcode): reuse shared helpers and cut the harness down

No behaviour change; every ZCode test still passes.

- Use installer-utils' own `hookDefinitionHasManagedCommand` instead of
  re-walking a hook definition by hand, which also drops a local string reader.
- Share one `readZCodeEventMap` instead of keeping the same narrowing in both
  hook-settings and hook-config-json.
- Collapse five identical error returns into one `zcodeHookError` builder, and
  return early from the status branches instead of assigning through `let`.
- Split the event-to-status decision out of `normalizeZCodeEvent` into a pure
  `readZCodeTurn`, so the normalizer reads as decide-then-build and stops
  computing the tool name for events that never look at it.
- Take a script file name in `readManagedZCodeHookEvents` like its siblings,
  which removes a `Parameters<typeof …>` indirection at the call site.
- Drop the unused `ZCodeHookEvent` export and inline a single-use path helper.
- Correct a stale comment: ZCode's loader is a strict `JSON.parse`, so the
  in-place edit preserves key order and indentation, not comments.

* fix(zcode): address review — keep unmanaged event keys, correct comment, de-dupe README

- `removeZCodeManagedHooks` deleted any event key whose list ended up empty, so an
  unrelated `"Notification": []` the user wrote was removed as collateral whenever a
  managed hook elsewhere made the write happen. Only touch an event Orca actually
  owned something in; covered by a new regression test.
- The `isNewTurnEvent` comment claimed UserPromptSubmit was ZCode's only turn
  boundary while the expression below it also returned true for SessionStart. Say
  what the code does: SessionStart lands the idle boundary, UserPromptSubmit is the
  turn boundary (the Codex/Claude shape).
- ZCode appeared twice in the README's single agent-badge block; keep the
  local-icon entry the link checker validates and drop the favicon duplicate.

* docs(zcode): call out that the desktop bundle's CLI cannot open a session

From live testing on #22464: pointing `zcode` at the desktop app's bundled
`glm/zcode.cjs` installs Orca's hooks fine but then fails with
`Cannot find package '@zcode/tui'`, so the pane never opens a session. The
symptom reads as a broken harness when the CLI simply has no TUI. Say which
build to use and how to check before reporting a problem.

Reported-by: JWu527
This commit is contained in:
Neil
2026-09-25 02:17:51 -07:00
committed by GitHub
parent f5d2ce5de7
commit 90801e2deb
69 changed files with 1595 additions and 256 deletions
+1
View File
@@ -179,6 +179,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
+3
View File
@@ -134,6 +134,9 @@
"../src/main/muse/hook-config-json.ts",
"../src/main/muse/hook-service.ts",
"../src/main/muse/hook-settings.ts",
"../src/main/zcode/hook-config-json.ts",
"../src/main/zcode/hook-service.ts",
"../src/main/zcode/hook-settings.ts",
"../src/main/openclaude/hook-service.ts",
"../src/main/rolling-file-backup.ts",
"../src/main/startup/hydrate-shell-path.ts",
+1
View File
@@ -179,6 +179,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
+1
View File
@@ -183,6 +183,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logo Cursor" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logo GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Logo Muse" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="Logo ZCode" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logo OpenCode" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logo MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Logo Amp" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
+1
View File
@@ -179,6 +179,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
+1
View File
@@ -179,6 +179,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="MiMo Code logo" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
+1
View File
@@ -179,6 +179,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca.
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Logotipo do Cursor" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="Logotipo do GitHub Copilot" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Logotipo do Muse" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="Logotipo do ZCode" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="Logotipo do OpenCode" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://mimo.xiaomi.com/coder"><kbd><img src="https://www.google.com/s2/favicons?domain=mimo.xiaomi.com&sz=64" alt="Logotipo do MiMo Code" width="16" valign="middle" /> MiMo Code</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Logotipo do Amp" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
+1
View File
@@ -179,6 +179,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智
<a href="https://cursor.com/cli"><kbd><img src="https://www.google.com/s2/favicons?domain=cursor.com&sz=64" alt="Cursor logo" width="16" valign="middle" /> Cursor</kbd></a> &nbsp;
<a href="https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli"><kbd><img src="https://www.google.com/s2/favicons?domain=github.com&sz=64" alt="GitHub Copilot logo" width="16" valign="middle" /> GitHub Copilot</kbd></a> &nbsp;
<a href="https://dev.meta.ai/docs/muse-code"><kbd><img src="../../src/shared/agent-icons/muse.png" alt="Muse logo" width="16" valign="middle" /> Muse</kbd></a> &nbsp;
<a href="https://zcode.z.ai/en/docs"><kbd><img src="../../src/shared/agent-icons/zcode.png" alt="ZCode logo" width="16" valign="middle" /> ZCode</kbd></a> &nbsp;
<a href="https://opencode.ai/docs/cli/"><kbd><img src="https://www.google.com/s2/favicons?domain=opencode.ai&sz=64" alt="OpenCode logo" width="16" valign="middle" /> OpenCode</kbd></a> &nbsp;
<a href="https://ampcode.com/manual#install"><kbd><img src="https://www.google.com/s2/favicons?domain=ampcode.com&sz=64" alt="Amp logo" width="16" valign="middle" /> Amp</kbd></a> &nbsp;
<a href="https://openclaude.gitlawb.com/"><kbd><img src="../../resources/openclaude-logo.png" alt="OpenClaude logo" width="16" valign="middle" /> OpenClaude</kbd></a> &nbsp;
+22 -1
View File
@@ -16,7 +16,7 @@ Orca works with **any CLI agent** — the agent combobox just launches a process
## Permissions default
For new launches, Orca pre-fills each supported CLI's permission-bypass flag — `--dangerously-skip-permissions` for Claude, `--dangerously-bypass-approvals-and-sandbox` for Codex, `--yolo` for Gemini / Cursor / Crush / Kimi / Muse / Rovo Dev / Hermes / GitHub Copilot / Command Code, plus the equivalent flag for every other agent that exposes one. These flags allow an agent to act without confirming every shell command; review the trust boundary before using them.
For new launches, Orca pre-fills each supported CLI's permission-bypass flag — `--dangerously-skip-permissions` for Claude, `--dangerously-bypass-approvals-and-sandbox` for Codex, `--yolo` for Gemini / Cursor / Crush / Kimi / Muse / Rovo Dev / Hermes / GitHub Copilot / Command Code, `--mode yolo` for ZCode, plus the equivalent flag for every other agent that exposes one. These flags allow an agent to act without confirming every shell command; review the trust boundary before using them.
Use **Settings → Agents → Agent Permissions** when you want to switch all uncustomized agents between **Yolo** and **Manual** launches. If you already overrode a specific agent's launch arguments or environment, Orca leaves that agent alone so the global switch doesn't erase your custom command.
@@ -48,6 +48,7 @@ To restore prompts for one agent only, edit that agent's default arguments or en
| Codebuff | Auto-setup | [Codebuff](https://www.codebuff.com/docs/help/quick-start) |
| Command Code | Auto-setup, status | [Command Code](https://commandcode.ai/docs/quickstart) |
| Muse | macOS/Linux; trusts the workspace at launch | [Meta](https://dev.meta.ai/docs/muse-code) |
| ZCode | Deep integration; needs a `zcode` CLI that ships the TUI (see note below) | [Z.ai](https://zcode.z.ai/en/docs) |
| Continue | Auto-setup | [Continue](https://docs.continue.dev/guides/cli) |
| Cursor CLI | Deep integration | [Cursor](https://cursor.com/cli) |
| Devin | Auto-setup | [Devin](https://devin.ai/cli) |
@@ -60,3 +61,23 @@ To restore prompts for one agent only, edit that agent's default arguments or en
| Hermes | Auto-setup | [Nous](https://hermes-agent.nousresearch.com/docs/) |
| OpenClaw | Auto-setup | [OpenClaw](https://github.com/openclaw/openclaw) |
| Trae | Auto-setup via `traecli` (TRAE CN CLI) | [Trae](https://www.trae.ai/) |
## ZCode: pick a CLI that ships the TUI
Orca drives ZCode through the `zcode` terminal CLI, so `zcode` on your `PATH` has to be a build that
includes ZCode's TUI.
The ZCode **desktop app** bundles the agent runtime without it. Pointing `zcode` at
`/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs` starts, then fails to open a session with:
```
Cannot find package '@zcode/tui' imported from .../zcode.cjs
```
Orca's hooks still install correctly in that state, so the symptom looks like a broken integration when
it is really a CLI that cannot render a session. Use a `zcode` that carries the TUI — build the CLI from
[`zai-org/ZCode`](https://github.com/zai-org/ZCode) (`apps/zcode-cli`), or install a distribution that
packages the runtime together with the TUI.
Check what you have with `zcode --version`, and confirm `zcode` opens a session outside Orca before
reporting a harness problem.
@@ -40,5 +40,6 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropT
hermes: require('../../../src/shared/agent-icons/hermes.png'),
devin: require('../../../src/shared/agent-icons/devin.png'),
muse: require('../../../src/shared/agent-icons/muse.png'),
zcode: require('../../../src/shared/agent-icons/zcode.png'),
openclaw: require('../../../src/shared/agent-icons/openclaw.png')
}
+1
View File
@@ -22,6 +22,7 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial<Record<TuiAgent, string>>
ante: 'antigma.ai',
trae: 'www.trae.cn',
muse: 'dev.meta.ai',
zcode: 'zcode.z.ai',
omp: 'omp.sh',
'prime-agent': 'primeintellect.ai',
gemini: 'gemini.google.com',
+2 -2
View File
@@ -34,8 +34,8 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
notes: [
'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.',
'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.',
'--agent takes an Orca agent id enabled on the worker server, such as claude, codex, cursor, antigravity, muse, opencode, or opencode2.',
'--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. Neither can combine with --terminal. Other agents, including opencode, launch with the model from their own config.',
'--agent takes an Orca agent id enabled on the worker server, such as claude, codex, cursor, antigravity, muse, zcode, opencode, or opencode2.',
'--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. Neither can combine with --terminal. Other agents, including opencode and zcode, launch with the model from their own config.',
'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.',
'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.',
"How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.",
@@ -48,7 +48,8 @@ export async function readHooksJsonRemote(
export async function writeHooksJsonRemote(
sftp: SFTPWrapper,
remotePath: string,
config: HooksConfig,
// Why: mirrors the local writer — the config is only the fallback serialization source.
config: Record<string, unknown>,
// Why: mirrors the local writer — a JSONC config supplies text edited in place.
options?: { serialized?: string }
): Promise<void> {
+3 -1
View File
@@ -318,7 +318,9 @@ function writeScriptWithAclRetry(scriptPath: string, content: string): void {
export function writeHooksJson(
configPath: string,
config: HooksConfig,
// Why: only used for the fallback serialization, so any JSON-shaped config qualifies —
// ZCode nests its hook block under `hooks.events`, not Claude's `hooks.<Event>`.
config: Record<string, unknown>,
// Why: `serialized` lets a JSONC config (Devin) supply text edited in place, so the
// atomic write + rolling backup below stay shared instead of being reimplemented.
options?: { preserveMode?: boolean; serialized?: string }
@@ -15,6 +15,7 @@ import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { museHookService } from '../muse/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { zcodeHookService } from '../zcode/hook-service'
// Why (#16441): Codex's installer awaits a codex app-server trust-grant session
// instead of blocking the main thread on spawnSync. Widening the tuple keeps the
@@ -52,7 +53,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[]
['hermes', () => hermesHookService.install()],
['devin', () => devinHookService.install()],
['kimi', () => kimiHookService.install()],
['muse', () => museHookService.install()]
['muse', () => museHookService.install()],
['zcode', () => zcodeHookService.install()]
]
// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a
@@ -74,7 +76,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri
['copilot', () => copilotHookService.refreshManagedScripts()],
['devin', () => devinHookService.refreshManagedScripts()],
['kimi', () => kimiHookService.refreshManagedScripts()],
['muse', () => museHookService.refreshManagedScripts()]
['muse', () => museHookService.refreshManagedScripts()],
['zcode', () => zcodeHookService.refreshManagedScripts()]
]
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
@@ -92,7 +95,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
['hermes', () => hermesHookService.remove()],
['devin', () => devinHookService.remove()],
['kimi', () => kimiHookService.remove()],
['muse', () => museHookService.remove()]
['muse', () => museHookService.remove()],
['zcode', () => zcodeHookService.remove()]
]
export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [
@@ -114,5 +118,6 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR
['hermes', () => hermesHookService.getStatus()],
['devin', () => devinHookService.getStatus()],
['kimi', () => kimiHookService.getStatus()],
['muse', () => museHookService.getStatus()]
['muse', () => museHookService.getStatus()],
['zcode', () => zcodeHookService.getStatus()]
]
@@ -22,6 +22,7 @@ import {
import { getDevinManagedCommand, getDevinRemoteManagedCommand } from '../devin/hook-settings'
import { getGrokManagedCommand } from '../grok/grok-hook-script'
import { getMuseManagedCommand, getMuseRemoteManagedCommand } from '../muse/hook-settings'
import { getZCodeManagedCommand, getZCodeRemoteManagedCommand } from '../zcode/hook-settings'
import {
wrapPosixHookCommand,
wrapWindowsCmdHookCommand,
@@ -149,6 +150,13 @@ const buildersByAgent = new Map<string, CommandBuilders>([
local: (path) => [getMuseManagedCommand(path)],
remote: (path) => [getMuseRemoteManagedCommand(path)]
}
],
[
'zcode',
{
local: (path) => [getZCodeManagedCommand(path)],
remote: (path) => [getZCodeRemoteManagedCommand(path)]
}
]
])
@@ -8,23 +8,21 @@ vi.mock('electron', () => ({
}
}))
import { CodexHookService, codexHookService } from '../codex/hook-service'
import { DroidHookService, droidHookService } from '../droid/hook-service'
import { CursorHookService, cursorHookService } from '../cursor/hook-service'
import { CodexHookService } from '../codex/hook-service'
import { DroidHookService } from '../droid/hook-service'
import { CursorHookService } from '../cursor/hook-service'
import { CURSOR_EVENTS, type CursorEvent } from '../cursor/hook-events'
import { CommandCodeHookService, commandCodeHookService } from '../command-code/hook-service'
import { GeminiHookService, geminiHookService } from '../gemini/hook-service'
import { AntigravityHookService, antigravityHookService } from '../antigravity/hook-service'
import { AmpHookService, ampHookService } from '../amp/hook-service'
import { CommandCodeHookService } from '../command-code/hook-service'
import { GeminiHookService } from '../gemini/hook-service'
import { AntigravityHookService } from '../antigravity/hook-service'
import { AmpHookService } from '../amp/hook-service'
import { ClaudeHookService, claudeHookService } from '../claude/hook-service'
import { GrokHookService, grokHookService } from '../grok/hook-service'
import { CopilotHookService, copilotHookService } from '../copilot/hook-service'
import { HermesHookService, hermesHookService } from '../hermes/hook-service'
import { DevinHookService, devinHookService } from '../devin/hook-service'
import { KimiHookService, kimiHookService } from '../kimi/hook-service'
import { museHookService } from '../muse/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls'
import { GrokHookService } from '../grok/hook-service'
import { CopilotHookService } from '../copilot/hook-service'
import { HermesHookService } from '../hermes/hook-service'
import { DevinHookService } from '../devin/hook-service'
import { KimiHookService } from '../kimi/hook-service'
import {
installRemoteManagedAgentHooks,
REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
@@ -687,45 +685,6 @@ describe('remote hook service installers', () => {
expect(fs.modes.get('/home/dev/.orca/agent-hooks/copilot-hook.sh')).toBe(0o755)
})
// Why: Droid (and Copilot) each shipped a working installRemote but were never
// registered in REMOTE_MANAGED_HOOK_INSTALLERS, so their status silently never
// appeared over SSH (issue #7253). Guard the whole bug class, not one agent:
// every locally-managed hook service that implements installRemote MUST be
// wired into the remote installer.
it('registers every managed agent that implements installRemote in the remote installer (issue #7253)', () => {
const servicesByAgent = new Map<string, { installRemote?: unknown }>([
['claude', claudeHookService],
['openclaude', openClaudeHookService],
['codex', codexHookService],
['gemini', geminiHookService],
['antigravity', antigravityHookService],
['amp', ampHookService],
['cursor', cursorHookService],
['droid', droidHookService],
['command-code', commandCodeHookService],
['grok', grokHookService],
['copilot', copilotHookService],
['hermes', hermesHookService],
['devin', devinHookService],
['kimi', kimiHookService],
['muse', museHookService]
])
// Guard against a service silently missing from the map above as new agents land.
for (const [agent] of MANAGED_AGENT_HOOK_INSTALLERS) {
expect(servicesByAgent.has(agent)).toBe(true)
}
const registered = new Set<string>(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS)
const missing: string[] = []
for (const [agent, service] of servicesByAgent) {
if (typeof service.installRemote === 'function' && !registered.has(agent)) {
missing.push(agent)
}
}
expect(missing).toEqual([])
})
it('installs Droid and Copilot when running the aggregate remote installer (issue #7253)', async () => {
const { sftp } = createFakeSftp()
const results = await installRemoteManagedAgentHooks(sftp, '/home/dev', {
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: {
getPath: () => '/tmp/orca-user-data'
}
}))
import { ampHookService } from '../amp/hook-service'
import { antigravityHookService } from '../antigravity/hook-service'
import { claudeHookService } from '../claude/hook-service'
import { codexHookService } from '../codex/hook-service'
import { commandCodeHookService } from '../command-code/hook-service'
import { copilotHookService } from '../copilot/hook-service'
import { cursorHookService } from '../cursor/hook-service'
import { devinHookService } from '../devin/hook-service'
import { droidHookService } from '../droid/hook-service'
import { geminiHookService } from '../gemini/hook-service'
import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { museHookService } from '../muse/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { zcodeHookService } from '../zcode/hook-service'
import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls'
import { REMOTE_MANAGED_HOOK_INSTALLER_AGENTS } from './remote-managed-hook-installers'
// Split from remote-hook-service-installers.test.ts: that file covers what each installer
// WRITES over SFTP; this one covers which agents are wired into the registries at all.
describe('remote hook service registry coverage', () => {
// Why: Droid (and Copilot) each shipped a working installRemote but were never
// registered in REMOTE_MANAGED_HOOK_INSTALLERS, so their status silently never
// appeared over SSH (issue #7253). Guard the whole bug class, not one agent:
// every locally-managed hook service that implements installRemote MUST be
// wired into the remote installer.
it('registers every managed agent that implements installRemote in the remote installer (issue #7253)', () => {
const servicesByAgent = new Map<string, { installRemote?: unknown }>([
['claude', claudeHookService],
['openclaude', openClaudeHookService],
['codex', codexHookService],
['gemini', geminiHookService],
['antigravity', antigravityHookService],
['amp', ampHookService],
['cursor', cursorHookService],
['droid', droidHookService],
['command-code', commandCodeHookService],
['grok', grokHookService],
['copilot', copilotHookService],
['hermes', hermesHookService],
['devin', devinHookService],
['kimi', kimiHookService],
['muse', museHookService],
['zcode', zcodeHookService]
])
// Guard against a service silently missing from the map above as new agents land.
for (const [agent] of MANAGED_AGENT_HOOK_INSTALLERS) {
expect(servicesByAgent.has(agent)).toBe(true)
}
const registered = new Set<string>(REMOTE_MANAGED_HOOK_INSTALLER_AGENTS)
const missing: string[] = []
for (const [agent, service] of servicesByAgent) {
if (typeof service.installRemote === 'function' && !registered.has(agent)) {
missing.push(agent)
}
}
expect(missing).toEqual([])
})
})
@@ -14,6 +14,7 @@ import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { museHookService } from '../muse/hook-service'
import { zcodeHookService } from '../zcode/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
export type RemoteManagedHookInstallOptions = {
@@ -74,7 +75,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [
['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)],
['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)],
['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)],
['muse', (sftp, remoteHome) => museHookService.installRemote(sftp, remoteHome)]
['muse', (sftp, remoteHome) => museHookService.installRemote(sftp, remoteHome)],
['zcode', (sftp, remoteHome) => zcodeHookService.installRemote(sftp, remoteHome)]
]
/** Agents wired into the remote (SSH) hook installer. Exported so an invariant
@@ -41,7 +41,8 @@ const NEW_TURN_EVENT: Record<AgentHookSource, string | null> = {
opencode2: 'SessionStart',
'mimo-code': null,
'command-code': null,
muse: 'UserPromptSubmit'
muse: 'UserPromptSubmit',
zcode: 'SessionStart'
}
function reviveRetiredPane(source: unknown, hookEventName: string): boolean {
@@ -0,0 +1,10 @@
{
"capturedAt": "2026-09-23T07:41:58.671Z",
"platform": "darwin",
"command": ["zcode"],
"cols": 120,
"rows": 32,
"note": "ZCode 0.16.9 CLI TUI, empty git folder, no model configured. Tail truncated at a byte-exact escape boundary: the retained prefix covers startup, the composer mount, and ~7s of continued ASCII-banner repainting afterwards, which is the evidence that ZCode never reaches a quiet render window. ZCode emits no OSC title.",
"exitCode": 129,
"truncated": true
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,84 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { createDraftPasteReadyScanner } from '../../shared/draft-paste-ready-scanner'
import {
getSyntheticAgentTerminalTitle,
shouldDriveSyntheticAgentTitleFromHook
} from '../../shared/synthetic-agent-title'
import { createTranscriptPane } from './agent-transcript-pane-test-harness'
import { hasExplicitIdleTitle } from './tui-idle-evidence'
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') }
}))
function readTranscript(): string {
return readFileSync(join(__dirname, '__fixtures__', 'zcode-composer-ready.txt'), 'utf8')
}
describe('ZCode readiness from captured terminal bytes', () => {
it('never emits an OSC title, so no title lane can settle its wait', () => {
const data = readTranscript()
expect(data).toContain(String.fromCharCode(27))
// Why: this absence is the whole reason ZCode needs a body-evidence readiness lane.
expect(data).not.toMatch(new RegExp(`${String.fromCharCode(27)}\\][0-2];`))
})
it('keeps repainting long after the composer mounts, so a quiet window never settles', () => {
const data = readTranscript()
const composerIndex = data.indexOf('╭')
expect(composerIndex).toBeGreaterThan(-1)
// Why: ~175KB of banner animation after the composer is up. A quiet-render window
// measured in hundreds of ms cannot fire anywhere in that span.
expect(data.length - composerIndex).toBeGreaterThan(100_000)
})
it('leaves no durable readiness evidence in the wait-text tail', async () => {
const data = readTranscript()
const { runtime, handle } = await createTranscriptPane({
paneTitle: 'zcode-first-class-workspace',
foregroundProcess: 'zcode',
launchAgent: 'zcode',
data
})
// Why this asserts a NEGATIVE: Orca's wait text is a line-folded tail, and ZCode paints
// its composer once and then repaints only the banner — so the composer scrolls out and
// no screen rule can settle the wait. This is the evidence for driving ZCode readiness
// from its synthetic hook title instead (see synthetic-agent-title.ts).
await expect(
runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 })
).rejects.toThrow(/timeout/)
}, 15_000)
it('settles a tui-idle wait from the synthetic hook title Orca owns for ZCode', () => {
expect(getSyntheticAgentTerminalTitle('zcode', 'done')).toBe('ZCode ready')
expect(getSyntheticAgentTerminalTitle('zcode', 'waiting')).toBe('ZCode - action required')
expect(shouldDriveSyntheticAgentTitleFromHook('zcode', 'working')).toBe(true)
expect(
hasExplicitIdleTitle({ lastAgentStatus: 'idle', lastOutputAt: Date.now() }, 'ZCode ready')
).toBe(true)
})
it('fires the draft-paste signal at the composer mount, not at the hard timeout', () => {
const data = readTranscript()
const scanner = createDraftPasteReadyScanner('zcode-composer-prompt')
const composerIndex = data.indexOf('╭')
// Why: feeding the stream in PTY-sized chunks proves the marker survives chunk splits.
let readyAt: number | null = null
for (let offset = 0; offset < data.length; offset += 4096) {
const chunk = data.slice(offset, offset + 4096)
if (scanner.observe(chunk).ready) {
readyAt = offset + chunk.length
break
}
}
expect(readyAt).not.toBeNull()
// Ready lands on the chunk that carries the composer corner, not thousands of frames later.
expect(readyAt!).toBeGreaterThanOrEqual(composerIndex)
expect(readyAt!).toBeLessThan(composerIndex + 8192)
})
})
@@ -0,0 +1,58 @@
import { createHash } from 'node:crypto'
import type { DiscoveredSkill, SkillDiscoverySource, SkillSourceKind } from '../../shared/skills'
import type { SkillScanRoot } from './skill-discovery-sources'
/**
* Classifying and ordering the skills a scan found.
*
* Split out of `skill-discovery-sources.ts`, which is about WHERE to scan; this is about
* what a found file is and what order the results come back in.
*/
export function stablePathId(pathValue: string): string {
return createHash('sha1').update(pathValue).digest('hex').slice(0, 16)
}
// Skill classification and ordering are identical for native and WSL discovery;
// only the path arithmetic differs (node:path vs pathPosix), so both callers
// share these and pass the matching path adapter.
type SkillRelativePathApi = { relative: (from: string, to: string) => string; sep: string }
export function sourceKindForSkill(
root: SkillScanRoot,
skillFilePath: string,
pathApi: SkillRelativePathApi
): SkillSourceKind {
if (
root.sourceKind === 'home' &&
pathApi.relative(root.path, skillFilePath).split(pathApi.sep)[0] === '.system'
) {
return 'bundled'
}
return root.sourceKind
}
export function sourceLabelForSkill(root: SkillScanRoot, sourceKind: SkillSourceKind): string {
return sourceKind === 'bundled' ? `${root.label} bundled` : root.label
}
export function sortDiscoveredSkills(skills: DiscoveredSkill[]): DiscoveredSkill[] {
if (skills.length < 2) {
return skills
}
const compare = new Intl.Collator(undefined, { sensitivity: 'base' }).compare
return skills.sort(
(a, b) =>
compare(a.name, b.name) ||
compare(a.sourceLabel, b.sourceLabel) ||
a.skillFilePath.localeCompare(b.skillFilePath)
)
}
export function sortSkillDiscoverySources(sources: SkillDiscoverySource[]): SkillDiscoverySource[] {
if (sources.length < 2) {
return sources
}
const compare = new Intl.Collator(undefined, { sensitivity: 'base' }).compare
return sources.sort((a, b) => compare(a.label, b.label))
}
@@ -185,7 +185,7 @@ describe('bounded concurrent skill discovery', () => {
const line = String(info.mock.calls.at(0)?.at(0))
// `present` is the signal that separates "big tree" from "big root set", and
// is not derivable from the other counts.
expect(line).toContain('[skills] scan roots=25 present=3 walked=25 skills=3')
expect(line).toContain('[skills] scan roots=26 present=3 walked=26 skills=3')
expect(line).toContain('home-claude')
expect(line).not.toContain(home)
expect(line).not.toContain(tmpdir())
+21 -55
View File
@@ -1,16 +1,11 @@
import { createHash } from 'node:crypto'
import { homedir } from 'node:os'
import { basename, join, type posix } from 'node:path'
import type {
DiscoveredSkill,
SkillDiscoverySource,
SkillProvider,
SkillSourceKind
} from '../../shared/skills'
import type { SkillDiscoverySource, SkillProvider, SkillSourceKind } from '../../shared/skills'
import type { AgentType } from '../../shared/agent-status-types'
import type { Repo } from '../../shared/repo-types'
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { SkillProviderRootOverrides } from './skill-provider-destinations'
import { stablePathId } from './skill-discovery-classification'
import {
resolveDefaultHermesSkillsRoot,
resolveEnvironmentHermesSkillsRoot,
@@ -18,56 +13,17 @@ import {
} from './skill-provider-runtime-roots'
export type SkillScanRoot = Omit<SkillDiscoverySource, 'exists' | 'skippedReason'>
// Re-exported so existing importers keep one entry point for discovery helpers.
export {
sortDiscoveredSkills,
sortSkillDiscoverySources,
sourceKindForSkill,
sourceLabelForSkill,
stablePathId
} from './skill-discovery-classification'
type SkillDiscoveryPathApi = Pick<typeof posix, 'basename' | 'join'>
export function stablePathId(pathValue: string): string {
return createHash('sha1').update(pathValue).digest('hex').slice(0, 16)
}
// Skill classification and ordering are identical for native and WSL discovery;
// only the path arithmetic differs (node:path vs pathPosix), so both callers
// share these and pass the matching path adapter.
type SkillRelativePathApi = { relative: (from: string, to: string) => string; sep: string }
export function sourceKindForSkill(
root: SkillScanRoot,
skillFilePath: string,
pathApi: SkillRelativePathApi
): SkillSourceKind {
if (
root.sourceKind === 'home' &&
pathApi.relative(root.path, skillFilePath).split(pathApi.sep)[0] === '.system'
) {
return 'bundled'
}
return root.sourceKind
}
export function sourceLabelForSkill(root: SkillScanRoot, sourceKind: SkillSourceKind): string {
return sourceKind === 'bundled' ? `${root.label} bundled` : root.label
}
export function sortDiscoveredSkills(skills: DiscoveredSkill[]): DiscoveredSkill[] {
if (skills.length < 2) {
return skills
}
const compare = new Intl.Collator(undefined, { sensitivity: 'base' }).compare
return skills.sort(
(a, b) =>
compare(a.name, b.name) ||
compare(a.sourceLabel, b.sourceLabel) ||
a.skillFilePath.localeCompare(b.skillFilePath)
)
}
export function sortSkillDiscoverySources(sources: SkillDiscoverySource[]): SkillDiscoverySource[] {
if (sources.length < 2) {
return sources
}
const compare = new Intl.Collator(undefined, { sensitivity: 'base' }).compare
return sources.sort((a, b) => compare(a.label, b.label))
}
function source(
id: string,
label: string,
@@ -243,6 +199,16 @@ export function buildSkillDiscoverySources(
'home',
['agent-skills'],
'muse'
),
// Why: ZCode loads user skills from `~/.zcode/skills`; project skills are the canonical
// `.agents/skills` root already covered by home-agents/repo-agents.
source(
'home-zcode',
'ZCode home',
pathApi.join(home, '.zcode', 'skills'),
'home',
['agent-skills'],
'zcode'
)
]
+79
View File
@@ -0,0 +1,79 @@
import { readFileSync } from 'node:fs'
import { applyEdits, modify, parse as parseJsonc, type ParseError } from 'jsonc-parser'
import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence'
import { isPlainObject } from '../agent-hooks/installer-utils'
import { isZCodeHooksEnabled, readZCodeEventMap, type ZCodeConfig } from './hook-settings'
export type ZCodeConfigSource = {
text: string | null
config: ZCodeConfig
}
export function parseZCodeConfigText(text: string, diagnosticName: string): ZCodeConfig | null {
const errors: ParseError[] = []
const parsed = parseJsonc(text, errors)
if (errors.length > 0) {
console.warn(
`Could not parse ${diagnosticName}: ${errors.map((e) => `offset ${e.offset} length ${e.length}`).join(', ')}`
)
return null
}
if (parsed === undefined) {
return {}
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: isPlainObject just proved this is a plain object; ZCodeConfig only adds optional keys over its index signature.
return isPlainObject(parsed) ? (parsed as ZCodeConfig) : null
}
/** Original file text alongside its parsed form, so a write can edit the text in place. */
export function readZCodeConfigSource(configPath: string): ZCodeConfigSource | null {
let text: string
try {
text = readFileSync(configPath, 'utf-8')
} catch (error) {
// Why: only a definitive "no such file" is a fresh install; an EACCES/EIO must not
// be mistaken for one and overwrite the user's config with a stub.
return isDefinitiveAbsence(error) ? { text: null, config: {} } : null
}
const config = parseZCodeConfigText(text, 'ZCode config.json')
return config === null ? null : { text, config }
}
const JSON_EDIT_FORMATTING = { formattingOptions: { insertSpaces: true, tabSize: 2 } } as const
/** Set one path in the JSON text; `undefined` removes the key. */
function editJsonPath(text: string, path: readonly string[], value: unknown): string {
return applyEdits(text, modify(text, [...path], value, JSON_EDIT_FORMATTING))
}
/**
* Serialize by editing the original text one hook event at a time, so the user's key order
* and indentation survive. A parse -> JSON.stringify round trip would reformat the whole
* file. (ZCode's loader is a strict `JSON.parse`, so there are no comments to preserve.)
*/
export function serializeZCodeConfig(originalText: string | null, nextConfig: ZCodeConfig): string {
if (originalText === null) {
return `${JSON.stringify(nextConfig, null, 2)}\n`
}
const previous = parseZCodeConfigText(originalText, 'ZCode config.json') ?? {}
const previousEvents = readZCodeEventMap(previous)
const nextEvents = readZCodeEventMap(nextConfig)
let text = originalText
const nextEnabled = isZCodeHooksEnabled(nextConfig)
if (isZCodeHooksEnabled(previous) !== nextEnabled) {
text = editJsonPath(text, ['hooks', 'enabled'], nextEnabled)
}
// Why: touch only the events that actually changed, so the user's key order and
// indentation around their own untouched hook entries stay put.
for (const eventName of new Set([...Object.keys(previousEvents), ...Object.keys(nextEvents)])) {
const nextValue = nextEvents[eventName]
if (JSON.stringify(previousEvents[eventName]) === JSON.stringify(nextValue)) {
continue
}
// `undefined` removes the key, which is how remove() drops an emptied event.
text = editJsonPath(text, ['hooks', 'events', eventName], nextValue)
}
return text
}
+173
View File
@@ -0,0 +1,173 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeOs from 'node:os'
const hoisted = vi.hoisted(() => ({ home: '' }))
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof NodeOs>()
return { ...actual, homedir: () => hoisted.home }
})
vi.mock('electron', () => ({ app: { getPath: () => hoisted.home } }))
import { zcodeHookService } from './hook-service'
import { getZCodeConfigPath, ZCODE_HOOK_EVENTS } from './hook-settings'
type ManagedHookEntry = { type: string; command: string; timeout?: number }
type ZCodeConfigFile = {
hooks?: { enabled?: boolean; events?: Record<string, { hooks: ManagedHookEntry[] }[]> }
[key: string]: unknown
}
// Why no assertion: `JSON.parse` is already `any`, so the annotation narrows without a cast.
function readConfig(): ZCodeConfigFile {
return JSON.parse(readFileSync(getZCodeConfigPath(), 'utf-8'))
}
beforeEach(() => {
hoisted.home = mkdtempSync(join(tmpdir(), 'orca-zcode-'))
})
afterEach(() => {
rmSync(hoisted.home, { recursive: true, force: true })
vi.restoreAllMocks()
})
describe('ZCodeHookService', () => {
it('reports not_installed before any install', () => {
expect(zcodeHookService.getStatus()).toMatchObject({
agent: 'zcode',
state: 'not_installed',
managedHooksPresent: false
})
})
it('creates config.json with every lifecycle event and enables hooks', () => {
const status = zcodeHookService.install()
expect(status).toMatchObject({ agent: 'zcode', state: 'installed', managedHooksPresent: true })
const config = readConfig()
// Why: ZCode's DefaultRuntimeConfig ships `hooks.enabled: false`, so registering the
// events without this flag is exactly the "hooks never fire" report in zai-org/feedback#32.
expect(config.hooks?.enabled).toBe(true)
expect(Object.keys(config.hooks?.events ?? {}).sort()).toEqual([...ZCODE_HOOK_EVENTS].sort())
for (const event of ZCODE_HOOK_EVENTS) {
expect(config.hooks?.events?.[event]?.[0]?.hooks?.[0]?.command).toContain('zcode-hook')
}
})
it('is idempotent — a second install adds no duplicate entries', () => {
zcodeHookService.install()
const first = readFileSync(getZCodeConfigPath(), 'utf-8')
zcodeHookService.install()
expect(readFileSync(getZCodeConfigPath(), 'utf-8')).toBe(first)
const config = readConfig()
expect(config.hooks?.events?.Stop).toHaveLength(1)
})
it("preserves the user's own hooks, key order, and unrelated config", () => {
const configPath = getZCodeConfigPath()
mkdirSync(join(hoisted.home, '.zcode', 'cli'), { recursive: true })
// Why plain JSON and not JSONC: ZCode's loader is a strict `JSON.parse`
// (`packages/adapters/src/config/file-config.adapter.ts`), so a comment would make
// ZCode drop the whole file. The in-place edit still matters — it keeps the user's
// key order and indentation instead of reserializing their config.
writeFileSync(
configPath,
`{
"ui": { "theme": "dark" },
"telemetry": { "enabled": false },
"hooks": {
"enabled": true,
"events": {
"Stop": [{ "hooks": [{ "type": "command", "command": "my-own-hook.sh" }] }]
}
}
}
`
)
zcodeHookService.install()
const text = readFileSync(configPath, 'utf-8')
expect(text).toContain('my-own-hook.sh')
expect(text).toContain('"theme": "dark"')
// Key order is untouched: `ui` still precedes `telemetry`, which still precedes `hooks`.
expect(text.indexOf('"ui"')).toBeLessThan(text.indexOf('"telemetry"'))
expect(text.indexOf('"telemetry"')).toBeLessThan(text.indexOf('"hooks"'))
const config = readConfig()
expect(config.hooks?.events?.Stop).toHaveLength(2)
// The managed entry is appended, so the user's own hook still runs first.
expect(config.hooks?.events?.Stop?.[0]?.hooks?.[0]?.command).toBe('my-own-hook.sh')
})
it('leaves a config ZCode itself can parse (strict JSON, no comments)', () => {
zcodeHookService.install()
expect(() => JSON.parse(readFileSync(getZCodeConfigPath(), 'utf-8'))).not.toThrow()
})
it('removes only Orca-managed entries and leaves the user their hooks', () => {
const configPath = getZCodeConfigPath()
mkdirSync(join(hoisted.home, '.zcode', 'cli'), { recursive: true })
writeFileSync(
configPath,
JSON.stringify({
hooks: {
enabled: true,
events: { Stop: [{ hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] }
}
})
)
zcodeHookService.install()
zcodeHookService.remove()
const config = readConfig()
expect(config.hooks?.events?.Stop).toEqual([
{ hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }
])
// Why: the user may run their own hooks; remove() must not switch the runtime off.
expect(config.hooks?.enabled).toBe(true)
expect(zcodeHookService.getStatus().managedHooksPresent).toBe(false)
})
it('leaves an unrelated empty event key alone while removing its own', () => {
const configPath = getZCodeConfigPath()
mkdirSync(join(hoisted.home, '.zcode', 'cli'), { recursive: true })
// Why: `Notification` is not an event Orca manages, and an empty list is a legitimate
// thing for a user to have written. Removing Orca's hooks must not take it with them.
writeFileSync(
configPath,
JSON.stringify({ hooks: { enabled: true, events: { Notification: [] } } })
)
zcodeHookService.install()
zcodeHookService.remove()
const config = readConfig()
expect(config.hooks?.events?.Notification).toEqual([])
for (const event of ZCODE_HOOK_EVENTS) {
expect(config.hooks?.events?.[event]).toBeUndefined()
}
})
it('reports partial when the managed events are present but hooks are disabled', () => {
zcodeHookService.install()
const configPath = getZCodeConfigPath()
const config: ZCodeConfigFile = JSON.parse(readFileSync(configPath, 'utf-8'))
if (config.hooks) {
config.hooks.enabled = false
}
writeFileSync(configPath, JSON.stringify(config, null, 2))
expect(zcodeHookService.getStatus()).toMatchObject({
state: 'partial',
managedHooksPresent: true,
detail: expect.stringContaining('hooks.enabled')
})
})
it('writes an executable managed hook script that posts to the ZCode endpoint', () => {
zcodeHookService.install()
const scriptPath = join(
hoisted.home,
'.orca',
'agent-hooks',
process.platform === 'win32' ? 'zcode-hook.cmd' : 'zcode-hook.sh'
)
expect(existsSync(scriptPath)).toBe(true)
expect(readFileSync(scriptPath, 'utf-8')).toContain('/hook/zcode')
})
})
+191
View File
@@ -0,0 +1,191 @@
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
buildWindowsAgentHookCurlPostCommand,
writeHooksJson,
writeManagedScript
} from '../agent-hooks/installer-utils'
import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh'
import {
readTextFileRemote,
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
import {
buildPosixHookPayloadCapture,
buildPosixHookSpoolLines,
buildWindowsHookEnvironmentGuardLines,
buildWindowsHookStdinDrainEpilogue
} from '../agent-hooks/hook-stdin-contract'
import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command'
import {
applyZCodeManagedHooks,
getZCodeConfigPath,
getZCodeManagedCommand,
getZCodeManagedScriptFileName,
getZCodeManagedScriptPath,
getZCodePosixManagedScriptFileName,
getZCodeRemoteConfigPath,
getZCodeRemoteManagedCommand,
isZCodeHooksEnabled,
readManagedZCodeHookEvents,
removeZCodeManagedHooks,
ZCODE_HOOK_EVENTS,
type ZCodeConfig
} from './hook-settings'
import {
parseZCodeConfigText,
readZCodeConfigSource,
serializeZCodeConfig
} from './hook-config-json'
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
'@echo off',
'setlocal',
// Why: endpoint file holds the live port/token; a PTY that outlives an Orca restart carries stale env, so `call` it to refresh (else PTY env).
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
...buildWindowsHookEnvironmentGuardLines(),
buildWindowsAgentHookCurlPostCommand('zcode'),
'exit /b 0',
...buildWindowsHookStdinDrainEpilogue(),
''
].join('\r\n')
}
return [
'#!/bin/sh',
...buildPosixHookPayloadCapture(),
...buildPosixHookSpoolLines('zcode'),
// Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env).
// Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race can't leak shell parse errors into agent transcripts (fail-open).
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' spool_hook_event',
' exit 0',
'fi',
...buildPosixAgentHookPostCommand('zcode').map((line, index, lines) =>
index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line
),
'exit 0',
''
].join('\n')
}
function zcodeHookError(configPath: string, detail: string): AgentHookInstallStatus {
return { agent: 'zcode', state: 'error', configPath, managedHooksPresent: false, detail }
}
function buildStatus(
config: ZCodeConfig,
configPath: string,
scriptFileName: string
): AgentHookInstallStatus {
const base = { agent: 'zcode' as const, configPath }
const present = readManagedZCodeHookEvents(config, scriptFileName)
const missing = ZCODE_HOOK_EVENTS.filter((event) => !present.has(event))
// Why: ZCode ships `hooks.enabled: false` by default, so registered events alone prove
// nothing — an install that left the flag off would never deliver a single event.
const hooksEnabled = isZCodeHooksEnabled(config)
if (missing.length === 0 && hooksEnabled) {
return { ...base, state: 'installed', managedHooksPresent: true, detail: null }
}
if (present.size === 0) {
return { ...base, state: 'not_installed', managedHooksPresent: false, detail: null }
}
const reasons = [
missing.length > 0 ? `events: ${missing.join(', ')}` : '',
hooksEnabled ? '' : '`hooks.enabled` is false, so ZCode runs no hooks'
].filter(Boolean)
return { ...base, state: 'partial', managedHooksPresent: true, detail: reasons.join('; ') }
}
export class ZCodeHookService {
async refreshManagedScripts(): Promise<void> {
await refreshManagedScriptIfPresent(getZCodeManagedScriptPath(), getManagedScript())
}
getStatus(): AgentHookInstallStatus {
const configPath = getZCodeConfigPath()
const source = readZCodeConfigSource(configPath)
if (!source) {
return zcodeHookError(configPath, 'Could not read ZCode config.json')
}
return buildStatus(source.config, configPath, getZCodeManagedScriptFileName())
}
install(): AgentHookInstallStatus {
const configPath = getZCodeConfigPath()
const scriptPath = getZCodeManagedScriptPath()
const source = readZCodeConfigSource(configPath)
if (!source) {
return zcodeHookError(configPath, 'Could not read ZCode config.json')
}
const scriptFileName = getZCodeManagedScriptFileName()
const command = getZCodeManagedCommand(scriptPath)
const nextConfig = applyZCodeManagedHooks(source.config, command, scriptFileName)
// Why: write the script first so config.json never points at a missing file.
writeManagedScript(scriptPath, getManagedScript())
writeHooksJson(configPath, nextConfig, {
serialized: serializeZCodeConfig(source.text, nextConfig)
})
return this.getStatus()
}
// Install the ZCode hook on an SSH execution host, where the shell contract is POSIX.
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const remoteConfigPath = getZCodeRemoteConfigPath(remoteHome)
// Why: remote-Windows is out of scope; process.platform describes the local box, not the host.
const remoteScriptFileName = getZCodePosixManagedScriptFileName()
const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}`
try {
const body = await readTextFileRemote(sftp, remoteConfigPath)
const config = body === null ? {} : parseZCodeConfigText(body, 'remote ZCode config.json')
if (!config) {
return zcodeHookError(remoteConfigPath, 'Could not parse remote ZCode config.json')
}
const command = getZCodeRemoteManagedCommand(remoteScriptPath)
const nextConfig = applyZCodeManagedHooks(config, command, remoteScriptFileName)
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig, {
serialized: serializeZCodeConfig(body, nextConfig)
})
return {
agent: 'zcode',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: null
}
} catch (err) {
return zcodeHookError(remoteConfigPath, err instanceof Error ? err.message : String(err))
}
}
remove(): AgentHookInstallStatus {
const configPath = getZCodeConfigPath()
const source = readZCodeConfigSource(configPath)
if (!source) {
return zcodeHookError(configPath, 'Could not read ZCode config.json')
}
const { config: nextConfig, changed } = removeZCodeManagedHooks(
source.config,
getZCodeManagedScriptFileName()
)
if (changed) {
writeHooksJson(configPath, nextConfig, {
serialized: serializeZCodeConfig(source.text, nextConfig)
})
}
return this.getStatus()
}
}
export const zcodeHookService = new ZCodeHookService()
+195
View File
@@ -0,0 +1,195 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
buildManagedCommandHook,
createManagedCommandMatcher,
getSharedManagedScriptPath,
hookDefinitionHasManagedCommand,
isPlainObject,
removeManagedCommands,
wrapPosixHookCommand,
wrapWindowsCmdHookCommand,
type HookDefinition
} from '../agent-hooks/installer-utils'
const ZCODE_SCRIPT_BASE = 'zcode-hook'
/**
* Every lifecycle event ZCode's hook runner can fire (`HookEventName` in
* `packages/contracts/src/hooks/index.ts`). Matchers are omitted on purpose:
* ZCode's `matchesAnyHookMatcher` treats an absent matcher as "every tool",
* and Claude's `"*"` is not a valid ZCode matcher.
*/
export const ZCODE_HOOK_EVENTS = [
'SessionStart',
'UserPromptSubmit',
'PreToolUse',
'PermissionRequest',
'PostToolUse',
'PostToolUseFailure',
'Stop'
] as const
/**
* ZCode's hook block, nested one level deeper than Claude's (`hooks.events.<Event>`).
*
* `events` is deliberately `unknown`-valued: it comes straight off a user-editable JSON
* file, so each entry is narrowed by `readEventDefinitions` at the point of use rather
* than asserted to be well-formed here.
*/
export type ZCodeHooksRuntimeConfig = {
enabled?: boolean
events?: Record<string, unknown>
[key: string]: unknown
}
export type ZCodeConfig = {
hooks?: ZCodeHooksRuntimeConfig
[key: string]: unknown
}
export function getZCodeConfigPath(): string {
// Why: ZCode resolves `~/.zcode/cli` from `homedir()` on every platform
// (`packages/adapters/src/config/file-config.adapter.ts`) — no APPDATA/XDG branch.
return join(homedir(), '.zcode', 'cli', 'config.json')
}
export function getZCodeRemoteConfigPath(remoteHome: string): string {
return `${remoteHome.replace(/\/$/, '')}/.zcode/cli/config.json`
}
export function getZCodeManagedScriptFileName(): string {
return process.platform === 'win32' ? `${ZCODE_SCRIPT_BASE}.cmd` : `${ZCODE_SCRIPT_BASE}.sh`
}
export function getZCodePosixManagedScriptFileName(): string {
return `${ZCODE_SCRIPT_BASE}.sh`
}
export function getZCodeManagedScriptPath(): string {
return getSharedManagedScriptPath(getZCodeManagedScriptFileName())
}
export function getZCodeManagedCommand(scriptPath: string): string {
if (process.platform === 'win32') {
// Why: ZCode spawns a `type: "command"` hook through its own shell resolver, so keep the
// bare directly-spawnable .cmd on the safe path and fall back to the encoded form otherwise.
return wrapWindowsCmdHookCommand(scriptPath)
}
return wrapPosixHookCommand(scriptPath)
}
export function getZCodeRemoteManagedCommand(scriptPath: string): string {
return wrapPosixHookCommand(scriptPath)
}
function getZCodeManagedCommandMatcher(
scriptFileName = getZCodeManagedScriptFileName()
): (command: string | undefined) => boolean {
return createManagedCommandMatcher(scriptFileName)
}
/** The `hooks.events` block as a plain lookup, or empty when absent or malformed. */
export function readZCodeEventMap(config: ZCodeConfig): Record<string, unknown> {
const events = config.hooks?.events
return isPlainObject(events) ? events : {}
}
/** The definitions registered for one event, dropping anything not shaped like a list. */
function readEventDefinitions(
events: Record<string, unknown>,
eventName: string
): HookDefinition[] {
const definitions = events[eventName]
if (!Array.isArray(definitions)) {
return []
}
// Why: a hand-edited config can hold nulls or scalars here; keep only object entries so
// the callers below never have to re-check, and never throw on user content.
return definitions.filter((definition): definition is HookDefinition => isPlainObject(definition))
}
export function applyZCodeManagedHooks(
config: ZCodeConfig,
command: string,
scriptFileName = getZCodeManagedScriptFileName()
): ZCodeConfig {
const nextEvents = { ...readZCodeEventMap(config) }
const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName)
for (const eventName of ZCODE_HOOK_EVENTS) {
const current = readEventDefinitions(nextEvents, eventName)
const cleaned = removeManagedCommands(current, isManagedCommand)
nextEvents[eventName] = [...cleaned, { hooks: [buildManagedCommandHook(command)] }]
}
return {
...config,
hooks: {
...config.hooks,
// Why: ZCode's DefaultRuntimeConfig ships `hooks.enabled: false`, so a hook block alone
// fires nothing — this flag is what the "ZCode hooks never run" reports were missing.
enabled: true,
events: nextEvents
}
}
}
export function removeZCodeManagedHooks(
config: ZCodeConfig,
scriptFileName = getZCodeManagedScriptFileName()
): { config: ZCodeConfig; changed: boolean } {
const events = readZCodeEventMap(config)
const nextEvents = { ...events }
const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName)
let changed = false
for (const [eventName, value] of Object.entries(nextEvents)) {
// Why: leave a non-array value exactly as the user wrote it — emptying it below would
// delete a key Orca never owned.
if (!Array.isArray(value)) {
continue
}
const definitions = readEventDefinitions(nextEvents, eventName)
const cleaned = removeManagedCommands(definitions, isManagedCommand)
// Why: only touch an event Orca actually owned something in. Without this, an unrelated
// empty entry the user wrote (`"Notification": []`) was deleted as collateral whenever a
// managed hook elsewhere made the write happen.
if (JSON.stringify(cleaned) === JSON.stringify(definitions)) {
continue
}
changed = true
if (cleaned.length === 0) {
delete nextEvents[eventName]
} else {
nextEvents[eventName] = cleaned
}
}
if (!changed) {
return { config, changed: false }
}
// Why: leave `hooks.enabled` alone on remove — the user may run their own hooks, and
// flipping it back to false would silently disable those too.
return { config: { ...config, hooks: { ...config.hooks, events: nextEvents } }, changed: true }
}
/** Events whose managed command is currently registered in the user's config. */
export function readManagedZCodeHookEvents(
config: ZCodeConfig,
scriptFileName = getZCodeManagedScriptFileName()
): Set<string> {
const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName)
const events = readZCodeEventMap(config)
return new Set(
ZCODE_HOOK_EVENTS.filter((eventName) =>
readEventDefinitions(events, eventName).some((definition) =>
hookDefinitionHasManagedCommand(definition, isManagedCommand)
)
)
)
}
export function isZCodeHooksEnabled(config: ZCodeConfig): boolean {
return config.hooks?.enabled === true
}
+1
View File
@@ -608,6 +608,7 @@
"da41abbdd4": "Ante",
"060d152fb5": "Trae",
"muse_label": "Muse",
"zcode_label": "ZCode",
"d443a47995": "Prime Agent",
"mimo_code_label": "MiMo Code",
"opencode2_label": "OpenCode 2"
+2 -1
View File
@@ -383,7 +383,8 @@
"060d152fb5": "Trae",
"d443a47995": "Prime Agent",
"mimo_code_label": "MiMo Code",
"muse_label": "Muse"
"muse_label": "Muse",
"zcode_label": "ZCode"
},
"skill": {
"cli": {
+2 -1
View File
@@ -593,7 +593,8 @@
"d443a47995": "Prime Agent",
"mimo_code_label": "MiMo Code",
"muse_label": "Muse",
"opencode2_label": "OpenCode 2"
"opencode2_label": "OpenCode 2",
"zcode_label": "ZCode"
},
"skill": {
"cli": {
+2 -1
View File
@@ -494,7 +494,8 @@
"d443a47995": "Prime Agent",
"mimo_code_label": "MiMo Code",
"muse_label": "Muse",
"opencode2_label": "OpenCode 2"
"opencode2_label": "OpenCode 2",
"zcode_label": "ZCode"
},
"skill": {
"cli": {
+2 -1
View File
@@ -494,7 +494,8 @@
"d443a47995": "Prime Agent",
"mimo_code_label": "MiMo Code",
"muse_label": "Muse",
"opencode2_label": "OpenCode 2"
"opencode2_label": "OpenCode 2",
"zcode_label": "ZCode"
},
"skill": {
"cli": {
+2 -1
View File
@@ -494,7 +494,8 @@
"d443a47995": "Prime Agent",
"mimo_code_label": "MiMo Code",
"muse_label": "Muse",
"opencode2_label": "OpenCode 2"
"opencode2_label": "OpenCode 2",
"zcode_label": "ZCode"
},
"skill": {
"cli": {
+7
View File
@@ -127,6 +127,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] =>
faviconDomain: 'dev.meta.ai',
homepageUrl: 'https://dev.meta.ai/docs/muse-code'
},
{
id: 'zcode',
label: translate('auto.lib.agent.catalog.zcode_label', 'ZCode'),
cmd: 'zcode',
faviconDomain: 'zcode.z.ai',
homepageUrl: 'https://zcode.z.ai/en/docs'
},
{
id: 'pi',
label: translate('auto.lib.agent.catalog.302934c5d9', 'Pi'),
@@ -24,6 +24,7 @@ import rovoUrl from '../../../shared/agent-icons/rovo.png?url'
import hermesUrl from '../../../shared/agent-icons/hermes.png?url'
import devinUrl from '../../../shared/agent-icons/devin.png?url'
import museUrl from '../../../shared/agent-icons/muse.png?url'
import zcodeUrl from '../../../shared/agent-icons/zcode.png?url'
import openclawUrl from '../../../shared/agent-icons/openclaw.png?url'
// Why: these agents have no hand-authored SVG glyph, so previously their icons
@@ -59,5 +60,6 @@ export const AGENT_FAVICON_ASSETS: Partial<Record<TuiAgent, string>> = {
hermes: hermesUrl,
devin: devinUrl,
muse: museUrl,
zcode: zcodeUrl,
openclaw: openclawUrl
}
+2 -1
View File
@@ -135,7 +135,8 @@ const ICONABLE_AGENT_TYPES: Record<TuiAgent, true> = {
devin: true,
ante: true,
trae: true,
muse: true
muse: true,
zcode: true
}
// Why: return null (not a 'claude' fallback) for unknown so Codex panes don't flash the Claude icon before the hook fires.
@@ -65,6 +65,7 @@ describe('agentResumeHostAuthorityCapability', () => {
grok: undefined,
devin: undefined,
'prime-agent': undefined,
zcode: 'agent-session.zcode-resume.v1',
copilot: undefined,
muse: AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
omp: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
@@ -3,6 +3,7 @@ import type { TuiAgent } from '../../../shared/tui-agent'
import {
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
type RuntimeCapability
@@ -33,6 +34,7 @@ const RESUME_HOST_AUTHORITY_CAPABILITY_BY_AGENT = {
// Ungated to match how main shipped copilot resume; gating it is its own change.
copilot: undefined,
muse: AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
zcode: AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY,
omp: AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
kimi: AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY
} satisfies Record<ResumableTuiAgent, RuntimeCapability | undefined>
+132
View File
@@ -0,0 +1,132 @@
/**
* Finding the real entrypoint inside an interpreter command line.
*
* Split out of `agent-process-recognition.ts`: recognizing WHICH agent a process is is a
* separate concern from parsing a `node …/cli.js` / `python -m pkg` argv down to the token
* that names it. Only the latter lives here.
*/
const PROCESS_EXTENSION_RE = /\.(?:exe|cmd|bat|ps1)$/i
const STATIC_INTERPRETER_PROCESS_NAMES = new Set([
'node',
'python',
'python3',
'bash',
'zsh',
'sh',
'fish',
'pwsh',
'powershell'
])
export const PYTHON_PROCESS_RE = /^python(?:\d+(?:\.\d+)*)?$/
const INTERPRETER_OPTIONS_WITH_VALUE = new Set([
'-r',
'--require',
'--import',
'--loader',
'--experimental-loader'
])
const INTERPRETER_OPTIONS_WITH_INLINE_SOURCE = new Set(['-e', '--eval', '-p', '--print', '--check'])
export function isInterpreterProcessName(normalized: string): boolean {
return STATIC_INTERPRETER_PROCESS_NAMES.has(normalized) || PYTHON_PROCESS_RE.test(normalized)
}
export function tokenizeCommandLine(commandLine: string): string[] {
const tokens: string[] = []
let current = ''
let quote: '"' | "'" | null = null
let escaped = false
for (let index = 0; index < commandLine.length; index += 1) {
const char = commandLine[index]
if (escaped) {
current += char
escaped = false
continue
}
if (char === '\\' && quote !== "'") {
const next = commandLine[index + 1]
if (next && (/\s/.test(next) || next === '"' || next === "'" || next === '\\')) {
escaped = true
continue
}
}
if ((char === '"' || char === "'") && quote === null) {
quote = char
continue
}
if (quote === char) {
quote = null
continue
}
if (/\s/.test(char) && quote === null) {
if (current) {
tokens.push(current)
current = ''
}
continue
}
current += char
}
if (current) {
tokens.push(current)
}
return tokens
}
function tokenLooksExecutable(token: string, index: number, firstNormalized: string): boolean {
if (index === 0) {
return true
}
if (!isInterpreterProcessName(firstNormalized)) {
return false
}
// Why: only inspect interpreter script paths. Prompt text can mention other
// agents ("compare opencode vs orca"), and treating every argv token as an
// executable would reintroduce the substring-style false identity class that
// foreground-process detection is meant to avoid.
return token.includes('/') || token.includes('\\') || PROCESS_EXTENSION_RE.test(token)
}
export function findInterpreterEntrypointToken(
tokens: string[],
firstNormalized: string
): string | null {
if (!isInterpreterProcessName(firstNormalized)) {
return null
}
for (let index = 1; index < tokens.length; index += 1) {
const token = tokens[index]
if (token === '--') {
continue
}
if (PYTHON_PROCESS_RE.test(firstNormalized) && token === '-m') {
return tokens[index + 1] ?? null
}
if (token.startsWith('-')) {
const name = token.split('=', 1)[0] ?? ''
if (INTERPRETER_OPTIONS_WITH_INLINE_SOURCE.has(name)) {
return null
}
if (INTERPRETER_OPTIONS_WITH_VALUE.has(name) && name === token) {
index += 1
}
continue
}
if (tokenLooksExecutable(token, index, firstNormalized)) {
return token
}
}
return null
}
export function comparablePath(token: string): string {
return token
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\\/g, '/')
.toLowerCase()
}
+5 -2
View File
@@ -1,12 +1,14 @@
import { isAnteHeadlessOneShotCommand } from './ante-headless-command'
import { isMuseHeadlessOneShotCommand } from './muse-headless-command'
import { isZCodeHeadlessOneShotCommand } from './zcode-headless-command'
import { isPrimeAgentHeadlessOneShotCommand } from './prime-agent-headless-command'
import { isPrintModeHeadlessOneShotCommand } from './print-mode-headless-command'
import type { TuiAgent } from './tui-agent'
// Why: a table (not an if-chain) so adding an agent is one entry; Claude and Trae share
// the same `--print` one-shot contract, Ante's `--prompt` form, Prime Agent's
// `--mode` forms, and Muse's `exec` subcommand need their own matchers.
// `--mode` forms, Muse's `exec` subcommand, and ZCode's `--prompt`/`--target` forms need
// their own matchers.
const HEADLESS_ONE_SHOT_MATCHERS: Partial<
Record<TuiAgent, (tokens: readonly string[]) => boolean>
> = {
@@ -14,7 +16,8 @@ const HEADLESS_ONE_SHOT_MATCHERS: Partial<
trae: isPrintModeHeadlessOneShotCommand,
'prime-agent': isPrimeAgentHeadlessOneShotCommand,
ante: isAnteHeadlessOneShotCommand,
muse: isMuseHeadlessOneShotCommand
muse: isMuseHeadlessOneShotCommand,
zcode: isZCodeHeadlessOneShotCommand
}
export function isHeadlessOneShotAgentCommand(agent: TuiAgent, tokens: readonly string[]): boolean {
@@ -23,6 +23,7 @@ import { normalizeHermesEvent } from './providers/hermes-events'
import { normalizeDevinEvent } from './providers/devin-events'
import { normalizeKimiEvent } from './providers/kimi-events'
import { normalizeMuseEvent } from './providers/muse-events'
import { normalizeZCodeEvent } from './providers/zcode-events'
export type ProviderDispatchResult = {
payload: ParsedAgentStatusPayload | null
@@ -153,6 +154,9 @@ export function normalizeProviderEvent(input: {
case 'muse':
payload = normalizeMuseEvent(state, eventName, promptText, paneKey, hookPayload)
break
case 'zcode':
payload = normalizeZCodeEvent(state, eventName, promptText, paneKey, hookPayload)
break
}
return { payload, resolvedPromptText, promptInteractionKey, hasTranscriptPromptEvidence }
@@ -35,6 +35,10 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo
case 'muse':
// Muse uses Claude-compatible lifecycle events.
return eventName === 'UserPromptSubmit'
case 'zcode':
// Why: matches Codex/Claude — SessionStart lands an idle boundary row and drops stale
// tool/prompt caches, while UserPromptSubmit is the actual turn boundary.
return eventName === 'SessionStart' || eventName === 'UserPromptSubmit'
case 'codex':
return eventName === 'SessionStart' || eventName === 'UserPromptSubmit'
case 'gemini':
@@ -137,6 +141,9 @@ export function extractToolFields(
// Muse uses Claude-compatible tool fields.
// falls through
case 'muse':
// Why: ZCode's hook runner writes Claude's `tool_name`/`tool_input`/`tool_response` aliases.
// falls through
case 'zcode':
return extractClaudeToolFields(eventName, hookPayload)
case 'codex':
return extractCodexToolFields(eventName, hookPayload)
@@ -0,0 +1,178 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createHookListenerState, type HookListenerState } from '../listener-state'
import { normalizeAndAccept } from '../../agent-hook-listener-test-harness'
const SESSION_ID = 'sess_01a0caa30e777d41bad746283a45633d'
const TURN_ID = 'turn_e495d1a559aa478efba1bd75509afc'
/**
* ZCode's hook runner writes its own camelCase fields AND a Claude-compatible alias set
* onto the same stdin object (`createCompatibleHookStdin`), so every fixture below carries
* both — that is literally what lands on the wire.
*/
function zcodeEvent(
hookEventName: string,
extra: Record<string, unknown> = {}
): Record<string, unknown> {
return {
cwd: '/tmp/ws',
hookEventName,
hook_event_name: hookEventName,
mode: 'build',
permission_mode: 'build',
sessionId: SESSION_ID,
session_id: SESSION_ID,
timestamp: '2026-09-23T07:50:00.000Z',
traceId: 'tr_1',
turnId: TURN_ID,
...extra
}
}
function toolFields(toolName: string, toolInput: unknown): Record<string, unknown> {
return {
toolName,
tool_name: toolName,
toolInput,
tool_input: toolInput,
toolCallId: 'call_1',
tool_use_id: 'call_1'
}
}
let state: HookListenerState
beforeEach(() => {
state = createHookListenerState()
})
describe('normalizeZCodeEvent', () => {
it('lands a startup SessionStart as an idle session boundary, not a spinner', () => {
const event = normalizeAndAccept(
state,
'zcode',
zcodeEvent('SessionStart', { source: 'startup' })
)
expect(event?.payload).toMatchObject({
state: 'done',
agentType: 'zcode',
sessionBoundary: true
})
})
it('drops a compact SessionStart, which fires mid-turn', () => {
expect(
normalizeAndAccept(state, 'zcode', zcodeEvent('SessionStart', { source: 'compact' }))
).toBeNull()
})
it('reports working from UserPromptSubmit and carries the prompt', () => {
const event = normalizeAndAccept(
state,
'zcode',
zcodeEvent('UserPromptSubmit', { prompt: 'refactor the parser' })
)
expect(event?.payload).toMatchObject({
state: 'working',
agentType: 'zcode',
prompt: 'refactor the parser'
})
})
it('reports working for an ordinary PreToolUse and names the tool', () => {
const event = normalizeAndAccept(
state,
'zcode',
zcodeEvent('PreToolUse', toolFields('Bash', { command: 'pnpm test' }))
)
expect(event?.payload).toMatchObject({ state: 'working', agentType: 'zcode', toolName: 'Bash' })
})
it('reports waiting for PermissionRequest — ZCode only fires it with the card on screen', () => {
const event = normalizeAndAccept(
state,
'zcode',
zcodeEvent('PermissionRequest', {
...toolFields('Bash', { command: 'rm -rf build' }),
reason: 'Tool Bash requires approval',
riskLevel: 'high'
})
)
expect(event?.payload).toMatchObject({
state: 'waiting',
agentType: 'zcode',
toolName: 'Bash'
})
})
it('reports waiting for the AskUserQuestion tool and keeps its question card input', () => {
const questions = [
{
question: 'Which database should we use?',
header: 'Database',
options: [
{ label: 'Postgres', description: 'Relational' },
{ label: 'SQLite', description: 'Embedded' }
],
multiSelect: false
}
]
const event = normalizeAndAccept(
state,
'zcode',
zcodeEvent('PreToolUse', toolFields('AskUserQuestion', { questions }))
)
expect(event?.payload).toMatchObject({ state: 'waiting', agentType: 'zcode' })
// Why: the clients render this verbatim as a live question card.
expect(JSON.parse(event?.payload.interactivePrompt ?? '{}')).toMatchObject({ questions })
})
it('returns to working after PostToolUse and after a tool failure', () => {
for (const eventName of ['PostToolUse', 'PostToolUseFailure']) {
const event = normalizeAndAccept(
state,
'zcode',
zcodeEvent(eventName, toolFields('Bash', { command: 'pnpm test' }))
)
expect(event?.payload).toMatchObject({ state: 'working', agentType: 'zcode' })
}
})
it('reports done on Stop and surfaces the final assistant message', () => {
const event = normalizeAndAccept(
state,
'zcode',
zcodeEvent('Stop', {
last_assistant_message: 'All tests pass.',
stop_hook_active: false,
responsePreview: 'All tests pass.'
})
)
expect(event?.payload).toMatchObject({
state: 'done',
agentType: 'zcode',
lastAssistantMessage: 'All tests pass.'
})
})
it('marks an interrupted Stop so the row does not read as a clean finish', () => {
const event = normalizeAndAccept(
state,
'zcode',
zcodeEvent('Stop', { is_interrupt: true, last_assistant_message: '' })
)
expect(event?.payload).toMatchObject({ state: 'done', interrupted: true })
})
it('ignores lifecycle events it does not model', () => {
expect(normalizeAndAccept(state, 'zcode', zcodeEvent('SomethingElse'))).toBeNull()
})
it('attributes every event to zcode, never to claude, despite the compatible payload', () => {
const event = normalizeAndAccept(
state,
'zcode',
zcodeEvent('UserPromptSubmit', { prompt: 'hello' })
)
expect(event?.payload.agentType).toBe('zcode')
})
})
@@ -0,0 +1,102 @@
import { isAskUserQuestionTool } from '../../agent-question-answered-intent'
import {
normalizeAgentStatusPayload,
type ParsedAgentStatusPayload
} from '../../agent-status-types'
import type { HookListenerState } from '../listener-state'
import {
resolvePrompt,
resolveToolState,
shouldIgnoreCompactContinuationUserPromptSubmit
} from '../prompt-fields'
import { extractToolFields, isNewTurnEvent } from '../provider-event-routing'
import { readString } from '../tool-input-preview'
// Why: ZCode's own lifecycle events are camelCase, but its hook runner writes a
// Claude-compatible stdin alias set (`hook_event_name`, `tool_name`, `tool_input`,
// `transcript_path`, `last_assistant_message`) alongside them — see ZCode's
// `packages/core/src/hooks/configured-runner-input.ts`. Orca reads the aliases, so the
// Claude tool-field extractor applies verbatim; only the agent identity differs.
const ZCODE_IDLE_SESSION_START_SOURCES: ReadonlySet<string> = new Set([
'startup',
'resume',
'clear'
])
type ZCodeTurn = {
stateName: 'working' | 'waiting' | 'done'
/** Only SessionStart lands a boundary row, and only for an idle source. */
sessionBoundary?: true
}
/** What one ZCode lifecycle event says about the pane, or null when it says nothing. */
function readZCodeTurn(eventName: unknown, hookPayload: Record<string, unknown>): ZCodeTurn | null {
switch (eventName) {
case 'SessionStart': {
// Why: land a resumed/started session as an idle boundary row, not a phantom spinner;
// `compact` fires mid-turn, so anything outside the idle allowlist is dropped.
const source = hookPayload['source']
return typeof source === 'string' && ZCODE_IDLE_SESSION_START_SOURCES.has(source)
? { stateName: 'done', sessionBoundary: true }
: null
}
case 'UserPromptSubmit':
case 'PostToolUse':
case 'PostToolUseFailure':
return { stateName: 'working' }
case 'PreToolUse':
// Why: ZCode's clarification tool is literally `AskUserQuestion` with Claude's
// questions/options input shape, so Orca's question card renders it unchanged.
return {
stateName: isAskUserQuestionTool(readString(hookPayload, 'tool_name'))
? 'waiting'
: 'working'
}
// Why: ZCode fires this only once the approval card is already on screen and racing the
// user's answer (`packages/core/src/tool/executor/permission-flow.ts`), never for an
// auto-approved call — so it is proof the pane is blocked on a human.
case 'PermissionRequest':
return { stateName: 'waiting' }
case 'Stop':
return { stateName: 'done' }
default:
return null
}
}
export function normalizeZCodeEvent(
state: HookListenerState,
eventName: unknown,
promptText: string,
paneKey: string,
hookPayload: Record<string, unknown>
): ParsedAgentStatusPayload | null {
if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) {
return null
}
const turn = readZCodeTurn(eventName, hookPayload)
if (!turn) {
return null
}
const resetOnNewTurn = isNewTurnEvent('zcode', eventName)
const snapshot = resolveToolState(
state,
paneKey,
extractToolFields('zcode', eventName, hookPayload),
{ resetOnNewTurn }
)
return normalizeAgentStatusPayload({
state: turn.stateName,
prompt: resolvePrompt(state, paneKey, promptText, { resetOnNewTurn }),
agentType: 'zcode',
toolName: snapshot.toolName,
toolInput: snapshot.toolInput,
interactivePrompt: snapshot.interactivePrompt,
lastAssistantMessage: snapshot.lastAssistantMessage,
lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput,
sessionBoundary: turn.sessionBoundary,
interrupted: eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined
})
}
@@ -22,7 +22,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly<Record<string, AgentHookSource>>
'/hook/hermes': 'hermes',
'/hook/devin': 'devin',
'/hook/kimi': 'kimi',
'/hook/muse': 'muse'
'/hook/muse': 'muse',
'/hook/zcode': 'zcode'
})
export function resolveHookSource(pathname: string): AgentHookSource | null {
+2 -1
View File
@@ -54,7 +54,8 @@ const AGENT_HOOK_SOURCES = [
'hermes',
'devin',
'kimi',
'muse'
'muse',
'zcode'
] as const
export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number]
+2 -1
View File
@@ -18,7 +18,8 @@ export const AGENT_HOOK_TARGETS = [
'hermes',
'devin',
'kimi',
'muse'
'muse',
'zcode'
] as const
export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number]
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+2 -1
View File
@@ -51,7 +51,8 @@ const TUI_AGENT_KIND_BY_AGENT = {
devin: 'devin',
ante: 'ante',
trae: 'trae',
muse: 'muse'
muse: 'muse',
zcode: 'zcode'
} satisfies Record<TuiAgent, ConcreteAgentKind>
// Why: `satisfies Record<TuiAgent, …>` makes the lookup exhaustive at compile
+2 -1
View File
@@ -27,7 +27,8 @@ export const AGENT_NAMES = [
'openclaw',
'aider',
'grok',
'devin'
'devin',
'zcode'
]
// Why: Windows agent titles can surface launcher process names such as
+10 -119
View File
@@ -4,6 +4,12 @@ import type { AgentType } from './agent-status-types'
import type { TuiAgent } from './tui-agent'
import { filterHeadlessOneShotAgentCommand } from './agent-headless-command'
import { getFirstCommandToken } from './command-token-scanner'
import {
comparablePath,
findInterpreterEntrypointToken,
PYTHON_PROCESS_RE,
tokenizeCommandLine
} from './agent-command-line-entrypoint'
import { isFreshOmpLaunchCommand } from './omp-fresh-launch'
export type RecognizedAgentProcess = { agent: TuiAgent; processName: string }
@@ -28,31 +34,13 @@ function normalizeProcessName(
return withoutProcessExtension
}
const STATIC_INTERPRETER_PROCESS_NAMES = new Set([
'node',
'python',
'python3',
'bash',
'zsh',
'sh',
'fish',
'pwsh',
'powershell'
])
const FOREGROUND_AGENT_WRAPPER_PROCESS_NAMES = new Set(['node', 'python', 'python3'])
const PYTHON_PROCESS_RE = /^python(?:\d+(?:\.\d+)*)?$/
const INTERPRETER_OPTIONS_WITH_VALUE = new Set([
'-r',
'--require',
'--import',
'--loader',
'--experimental-loader'
])
const INTERPRETER_OPTIONS_WITH_INLINE_SOURCE = new Set(['-e', '--eval', '-p', '--print', '--check'])
const NODE_PACKAGE_SCRIPT_ENTRYPOINTS: Record<string, readonly string[]> = {
codex: ['node_modules/@openai/codex/'],
gemini: ['node_modules/@google/gemini-cli/']
gemini: ['node_modules/@google/gemini-cli/'],
// Why: ZCode's npm bin is `dist/zcode.cjs`, so a package install runs as `node …zcode.cjs`
// and never shows `zcode` as the foreground name (a SEA build still matches by name).
zcode: ['node_modules/@zcode/cli/']
}
const PYTHON_SCRIPT_ENTRYPOINT_DIRECTORIES = ['/bin/', '/scripts/', '/site-packages/']
@@ -108,103 +96,6 @@ function recognizedAgentForProcess(normalized: string): RecognizedAgentProcess |
return agent ? { agent, processName: normalized } : null
}
function tokenizeCommandLine(commandLine: string): string[] {
const tokens: string[] = []
let current = ''
let quote: '"' | "'" | null = null
let escaped = false
for (let index = 0; index < commandLine.length; index += 1) {
const char = commandLine[index]
if (escaped) {
current += char
escaped = false
continue
}
if (char === '\\' && quote !== "'") {
const next = commandLine[index + 1]
if (next && (/\s/.test(next) || next === '"' || next === "'" || next === '\\')) {
escaped = true
continue
}
}
if ((char === '"' || char === "'") && quote === null) {
quote = char
continue
}
if (quote === char) {
quote = null
continue
}
if (/\s/.test(char) && quote === null) {
if (current) {
tokens.push(current)
current = ''
}
continue
}
current += char
}
if (current) {
tokens.push(current)
}
return tokens
}
function tokenLooksExecutable(token: string, index: number, firstNormalized: string): boolean {
if (index === 0) {
return true
}
if (!isInterpreterProcessName(firstNormalized)) {
return false
}
// Why: only inspect interpreter script paths. Prompt text can mention other
// agents ("compare opencode vs orca"), and treating every argv token as an
// executable would reintroduce the substring-style false identity class that
// foreground-process detection is meant to avoid.
return token.includes('/') || token.includes('\\') || PROCESS_EXTENSION_RE.test(token)
}
function isInterpreterProcessName(normalized: string): boolean {
return STATIC_INTERPRETER_PROCESS_NAMES.has(normalized) || PYTHON_PROCESS_RE.test(normalized)
}
function findInterpreterEntrypointToken(tokens: string[], firstNormalized: string): string | null {
if (!isInterpreterProcessName(firstNormalized)) {
return null
}
for (let index = 1; index < tokens.length; index += 1) {
const token = tokens[index]
if (token === '--') {
continue
}
if (PYTHON_PROCESS_RE.test(firstNormalized) && token === '-m') {
return tokens[index + 1] ?? null
}
if (token.startsWith('-')) {
const name = token.split('=', 1)[0] ?? ''
if (INTERPRETER_OPTIONS_WITH_INLINE_SOURCE.has(name)) {
return null
}
if (INTERPRETER_OPTIONS_WITH_VALUE.has(name) && name === token) {
index += 1
}
continue
}
if (tokenLooksExecutable(token, index, firstNormalized)) {
return token
}
}
return null
}
function comparablePath(token: string): string {
return token
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\\/g, '/')
.toLowerCase()
}
function recognizeNodeScriptEntrypoint(token: string): RecognizedAgentProcess | null {
const path = comparablePath(token)
for (const identity of EXACT_NODE_ENTRYPOINT_IDENTITIES) {
+10 -1
View File
@@ -18,7 +18,8 @@ export const RESUMABLE_TUI_AGENTS = [
'prime-agent',
'copilot',
'kimi',
'muse'
'muse',
'zcode'
] as const satisfies readonly TuiAgent[]
export type ResumableTuiAgent = (typeof RESUMABLE_TUI_AGENTS)[number]
@@ -205,6 +206,12 @@ export function extractAgentProviderSession(
const id = readSessionId(payload, ['session_id'])
return id ? withTranscriptPath({ key: 'session_id', id }, payload) : null
}
// Why: ZCode's `transcript_path` is a per-invocation temp file it deletes when the hook
// returns (`createCompatibleHookStdin` mkdtemp + cleanup), so only the id is durable.
case 'zcode': {
const id = readSessionId(payload, ['session_id'])
return id ? { key: 'session_id', id } : null
}
case 'antigravity': {
const id = readSessionId(payload, ['conversationId'])
return id ? { key: 'conversation_id', id } : null
@@ -305,5 +312,7 @@ export function getAgentResumeArgv(
return providerSession.key === 'session_id' ? ['kimi', '--session', id] : null
case 'muse':
return providerSession.key === 'session_id' ? ['muse', 'resume', id] : null
case 'zcode':
return providerSession.key === 'session_id' ? ['zcode', '--resume', id] : null
}
}
+2 -1
View File
@@ -26,7 +26,8 @@ const WELL_KNOWN_LABELS: Record<string, string> = {
ante: 'Ante',
trae: 'Trae',
kimi: 'Kimi',
muse: 'Muse'
muse: 'Muse',
zcode: 'ZCode'
}
export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string {
+21
View File
@@ -19,6 +19,12 @@ const DECTCEM_SHOW_CURSOR = '\x1b[?25h'
// grok swaps it for `> ` on legacy Windows consoles, which is too generic to
// match; those fall back to the quiet window and the caller's hard timeout.
const GROK_COMPOSER_PROMPT = '❯'
// Why: ZCode's composer box top-left corner (U+256D), painted once the input box mounts.
// It is locale-independent — ZCode translates the placeholder and the mode label in the
// box title, but not the frame — and its modal dialogs draw SQUARE corners, so this glyph
// means the composer specifically. Anchored on the alternate-screen switch for the same
// reason as grok: a powerline shell prompt can also draw `╭`.
const ZCODE_COMPOSER_BOX_CORNER = '╭'
const DECSET_ALT_SCREEN = '\x1b[?1049h'
const DECRST_ALT_SCREEN = '\x1b[?1049l'
@@ -61,6 +67,15 @@ const DRAFT_PASTE_READY_SIGNALS: Record<DraftPasteReadySignal, DraftPasteReadySi
// the main-process caller drops the draft when readiness never resolves.
quietAnchor: DECSET_BRACKETED_PASTE
},
'zcode-composer-prompt': {
markerAnchor: DECSET_ALT_SCREEN,
markerAnchorEnd: DECRST_ALT_SCREEN,
marker: ZCODE_COMPOSER_BOX_CORNER,
// Why: ZCode animates its ASCII banner forever, so the quiet window never settles on
// its own — but keep it armed as the floor for a build that renders inline and never
// switches to the alternate screen, where the marker anchor would never arm.
quietAnchor: DECSET_BRACKETED_PASTE
},
'render-quiet-after-bracketed-paste': {
markerAnchor: null,
markerAnchorEnd: null,
@@ -111,6 +126,12 @@ export type DraftPasteReadyScanResult = {
* that keeps those launches on the pre-existing delivery path. The alt-screen
* anchor is revoked on `\x1b[?1049l`: leaving it hands the terminal back to
* the shell, so a glyph after that is the shell's prompt, not grok's composer.
* - `zcode-composer-prompt`: ready when ZCode's composer box corner (`╭`) renders
* after the alternate-screen switch. ZCode repaints its animated ASCII banner
* indefinitely — the captured transcript is still repainting 30s after the composer
* mounted — so the quiet window alone never settles and a launch draft would wait out
* the whole hard timeout, exactly as grok did. Same alt-screen anchoring and
* revocation as grok, because a powerline shell prompt can draw `╭` too.
* - `render-quiet-after-bracketed-paste` (default): no signal marker; arms the
* quiet window once DECSET 2004 is seen.
*
+3
View File
@@ -225,6 +225,8 @@ export const AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY = 'agent-session.kimi-
export const AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY =
'agent-session.opencode2-resume.v1' as const
export const AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY = 'agent-session.muse-resume.v1' as const
export const AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY =
'agent-session.zcode-resume.v1' as const
// Why: older runtimes strip mutation owner fields, so clients must fence writes before RPC.
export const FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY = 'files.mutation-ownership.v1' as const
export const FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE =
@@ -380,6 +382,7 @@ export const RUNTIME_CAPABILITIES = [
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_OPENCODE2_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_MUSE_RESUME_RUNTIME_CAPABILITY,
AGENT_SESSION_ZCODE_RESUME_RUNTIME_CAPABILITY,
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY,
GITLAB_READY_FOR_REVIEW_RUNTIME_CAPABILITY,
+9
View File
@@ -16,6 +16,7 @@ export type SkillInstallProviderId =
| 'grok'
| 'aug'
| 'muse'
| 'zcode'
export type SkillInstallProviderDefinition = {
id: SkillInstallProviderId
@@ -88,6 +89,14 @@ export const SKILL_INSTALL_PROVIDERS: readonly SkillInstallProviderDefinition[]
displayName: 'Muse',
globalSegments: null,
workspaceSegments: null
},
// Why: ZCode loads user skills from `~/.zcode/skills`; project skills come from the
// canonical `.agents/skills` root the shared repo source already covers.
{
id: 'zcode',
displayName: 'ZCode',
globalSegments: ['.zcode', 'skills'],
workspaceSegments: null
}
]
+2 -1
View File
@@ -51,7 +51,8 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = {
ante: null,
// Why: Orca detects trae by `traecli`, an alias only TRAE CN ships.
trae: 'trae-cn',
muse: null
muse: null,
zcode: 'zcode'
} satisfies Record<TuiAgent, string | null>
/**
+13 -1
View File
@@ -18,7 +18,8 @@ export const SYNTHETIC_AGENT_TITLE_AGENTS = [
'omp',
'droid',
'hermes',
'devin'
'devin',
'zcode'
] as const satisfies readonly TuiAgent[]
export const SYNTHETIC_AGENT_TITLE_PROFILES: Record<string, SyntheticAgentTitleProfile> = {
@@ -75,6 +76,17 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record<string, SyntheticAgentTitleP
workingLabel: 'Devin',
permissionLabel: 'Devin - action required',
idleLabel: 'Devin ready'
},
zcode: {
workingLabel: 'ZCode',
permissionLabel: 'ZCode - action required',
idleLabel: 'ZCode ready',
// Why every state synthesizes, unlike Codex/Pi: ZCode writes NO OSC title in any state
// — the captured transcript (`zcode-composer-ready.txt`) contains no OSC 0/1/2 at all —
// so there is no native title to fight with, and without this a `tui-idle` wait has no
// signal to settle on: ZCode also repaints its ASCII banner forever, so the quiescence
// lane never fires either.
synthesizeWorkingTitle: true
}
}
+1
View File
@@ -46,6 +46,7 @@ export const AGENT_KIND_VALUES = [
'ante',
'trae',
'muse',
'zcode',
'other'
] as const
export const agentKindSchema = z.enum(AGENT_KIND_VALUES)
+14
View File
@@ -14,6 +14,7 @@ export type DraftPasteReadySignal =
| 'codex-composer-prompt'
| 'render-cursor-after-bracketed-paste'
| 'grok-composer-prompt'
| 'zcode-composer-prompt'
export type TuiAgentDetectionRuntime = NodeJS.Platform | 'wsl'
@@ -321,6 +322,19 @@ const TUI_AGENT_CONFIG_SOURCE: Record<TuiAgent, TuiAgentConfigSource> = {
// Muse 1.3 treats subcommand-shaped prompts as commands even after `--`.
promptInjectionMode: 'stdin-after-start'
},
zcode: {
detectCmd: 'zcode',
// Why: ZCode's entrypoint sets `process.title = 'zcode-cli'` (its `process-name.ts`
// exports CLI_COMMAND_NAME 'zcode' / CLI_PROCESS_NAME 'zcode-cli'), so the foreground
// name never equals the launch command and dispatch would refuse with no_agent_detected.
expectedProcess: 'zcode-cli',
// Why: ZCode reads `positionals[0]` as a subcommand name (apps/zcode-cli/packages/cli/src/run.ts),
// so an argv prompt exits with "Unknown command"; `-p` is headless-only and quits after the turn.
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'
},
devin: {
detectCmd: 'devin',
// Why: `devin -- <prompt>` auto-submits immediately (docs.devin.ai/cli), so start the REPL with no argv prompt.
+1
View File
@@ -14,6 +14,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record<TuiAgent, string> = {
ante: 'Ante',
trae: 'Trae',
muse: 'Muse',
zcode: 'ZCode',
autohand: 'Autohand Code',
opencode: 'OpenCode',
opencode2: 'OpenCode 2',
+2
View File
@@ -21,6 +21,8 @@ export const YOLO_TUI_AGENT_ARGS: Partial<Record<TuiAgent, string>> = {
cursor: '--yolo',
kimi: '--yolo',
muse: '--yolo',
// Why: ZCode gates tools by collaboration mode; `yolo` is its bypass-everything mode.
zcode: '--mode yolo',
'mistral-vibe': '--agent auto-approve',
'qwen-code': '--approval-mode yolo',
rovo: '--yolo',
+1
View File
@@ -16,6 +16,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [
'ante',
'trae',
'muse',
'zcode',
'pi',
'omp',
'prime-agent',
+1
View File
@@ -38,4 +38,5 @@ export type TuiAgent =
| 'ante' // Ante (Antigma Labs)
| 'trae' // Trae CLI
| 'muse' // Muse (Meta `muse` CLI)
| 'zcode' // ZCode (Z.ai `zcode` CLI)
| 'prime-agent' // Prime Agent (Prime Intellect)
+20
View File
@@ -0,0 +1,20 @@
import { optionName } from './print-mode-headless-command'
// Why: ZCode dispatches headlessly on `typeof values.prompt === "string"` or an explicit
// `--target` (apps/zcode-cli/packages/cli/src/run.ts) — both run one prompt through
// `runPrompt` and exit, so the pane never hosts the interactive TUI. `--json` and
// `--output-format` are presentation flags on either path, so neither implies headless.
const ZCODE_HEADLESS_FLAGS: ReadonlySet<string> = new Set(['-p', '--prompt', '--target'])
export function isZCodeHeadlessOneShotCommand(tokens: readonly string[]): boolean {
for (let index = 1; index < tokens.length; index += 1) {
// Why: `--` ends option parsing, so a later `--prompt`-looking token is a value, not a flag.
if (tokens[index] === '--') {
return false
}
if (ZCODE_HEADLESS_FLAGS.has(optionName(tokens[index]))) {
return true
}
}
return false
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { getAgentSessionOptionCatalog } from './agent-session-option-catalog'
import { buildAgentStartupPlan, agentPromptRidesLaunchCommand } from './tui-agent-startup'
import { TUI_AGENT_CONFIG } from './tui-agent-config'
import { YOLO_TUI_AGENT_ARGS } from './tui-agent-permissions'
import { recognizeAgentProcess } from './agent-process-recognition'
describe('ZCode orchestration and send-to-agent contract', () => {
it('is dispatchable: its foreground process resolves back to the zcode agent', () => {
// Why this matters: ZCode sets `process.title = 'zcode-cli'`, so without this mapping
// `dispatch --inject` refuses the pane with no_agent_detected.
expect(TUI_AGENT_CONFIG.zcode.expectedProcess).toBe('zcode-cli')
expect(recognizeAgentProcess('zcode-cli')?.agent).toBe('zcode')
})
it('has no session-option catalog, so worker --model is refused rather than swallowed', () => {
// Why: ZCode's CLI exposes no `--model` flag at all — the model comes from its own
// config and its in-TUI picker. Same position as opencode: a catalog here would accept
// `--model` and then silently drop it, which is worse than a clear refusal.
expect(getAgentSessionOptionCatalog('zcode')).toBeNull()
})
it("bypasses permissions with ZCode's own yolo mode, not a --yolo flag", () => {
expect(YOLO_TUI_AGENT_ARGS.zcode).toBe('--mode yolo')
})
it('delivers prompts over stdin, never as argv', () => {
// Why: ZCode reads positionals[0] as a SUBCOMMAND, so an argv prompt exits with
// "Unknown command"; `-p` is headless-only and quits after one turn.
expect(TUI_AGENT_CONFIG.zcode.promptInjectionMode).toBe('stdin-after-start')
expect(agentPromptRidesLaunchCommand('zcode')).toBe(false)
})
it('keeps the prompt out of the launch command and hands it back as a follow-up', () => {
const plan = buildAgentStartupPlan({
agent: 'zcode',
prompt: 'refactor the parser',
cmdOverrides: {},
platform: 'darwin'
})
expect(plan?.launchCommand).toBe('zcode')
expect(plan?.launchCommand).not.toContain('refactor the parser')
expect(plan?.followupPrompt).toBe('refactor the parser')
})
it('carries a mode chosen through agent args onto the launch command', () => {
const plan = buildAgentStartupPlan({
agent: 'zcode',
prompt: '',
cmdOverrides: {},
platform: 'darwin',
allowEmptyPromptLaunch: true,
agentArgs: '--mode plan'
})
// Args are shell-quoted into the launch command, so assert the quoted form.
expect(plan?.launchCommand).toBe("zcode '--mode' 'plan'")
})
it('waits for the composer instead of a quiet window before pasting a draft', () => {
// Why: ZCode repaints its ASCII banner forever, so the default quiet-render window
// never settles — see zcode-readiness-transcript.test.ts for the captured evidence.
expect(TUI_AGENT_CONFIG.zcode.draftPasteReadySignal).toBe('zcode-composer-prompt')
})
})