From 908104ec3851a20c4c61abcd857d021f152df0e3 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 23 Sep 2026 02:08:52 -0700 Subject: [PATCH 1/5] 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 --- README.md | 1 + config/tsconfig.cli.json | 3 + docs/readme/README.es.md | 1 + docs/readme/README.fr.md | 1 + docs/readme/README.ja.md | 1 + docs/readme/README.ko.md | 1 + docs/readme/README.pt.md | 1 + docs/readme/README.zh-CN.md | 1 + docs/site/content/docs/agents/supported.mdx | 3 +- .../components/mobile-agent-icon-assets.ts | 1 + mobile/src/tasks/mobile-tui-agents.ts | 1 + src/cli/specs/orchestration-worker-specs.ts | 2 +- .../agent-hooks/installer-utils-remote.ts | 3 +- src/main/agent-hooks/installer-utils.ts | 4 +- .../managed-agent-hook-registry.ts | 13 +- .../managed-hook-command-contract.test.ts | 8 + .../remote-hook-service-installers.test.ts | 65 +---- ...ote-hook-service-registry-coverage.test.ts | 70 ++++++ .../remote-managed-hook-installers.ts | 4 +- .../server-retired-pane-new-turn.test.ts | 3 +- .../zcode-composer-ready.meta.json | 10 + .../__fixtures__/zcode-composer-ready.txt | 1 + .../zcode-readiness-transcript.test.ts | 84 +++++++ .../skills/skill-discovery-classification.ts | 58 +++++ .../skill-discovery-concurrency.test.ts | 2 +- src/main/skills/skill-discovery-sources.ts | 76 ++---- src/main/zcode/hook-config-json.ts | 87 +++++++ src/main/zcode/hook-service.test.ts | 155 ++++++++++++ src/main/zcode/hook-service.ts | 225 ++++++++++++++++++ src/main/zcode/hook-settings.ts | 205 ++++++++++++++++ src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/renderer/src/lib/agent-catalog.tsx | 7 + src/renderer/src/lib/agent-favicon-assets.ts | 2 + src/renderer/src/lib/agent-status.ts | 3 +- ...t-resume-host-authority-capability.test.ts | 1 + .../agent-resume-host-authority-capability.ts | 2 + src/shared/agent-command-line-entrypoint.ts | 132 ++++++++++ src/shared/agent-headless-command.ts | 7 +- .../agent-hook-listener/provider-dispatch.ts | 4 + .../provider-event-routing.ts | 7 + .../providers/zcode-events.test.ts | 178 ++++++++++++++ .../providers/zcode-events.ts | 101 ++++++++ .../agent-hook-listener/source-routing.ts | 3 +- src/shared/agent-hook-relay.ts | 3 +- src/shared/agent-hook-types.ts | 3 +- src/shared/agent-icons/zcode.png | Bin 0 -> 2598 bytes src/shared/agent-kind.ts | 3 +- src/shared/agent-name-token-match.ts | 3 +- src/shared/agent-process-recognition.ts | 129 +--------- .../agent-session-option-catalog-zcode.ts | 40 ++++ src/shared/agent-session-option-catalog.ts | 4 +- src/shared/agent-session-resume.ts | 11 +- src/shared/agent-type-label.ts | 3 +- src/shared/draft-paste-ready-scanner.ts | 21 ++ src/shared/protocol-version.ts | 3 + src/shared/skill-install-providers.ts | 9 + src/shared/skills-cli-agent-keys.ts | 3 +- src/shared/synthetic-agent-title.ts | 14 +- src/shared/telemetry-property-schemas.ts | 1 + src/shared/tui-agent-config.ts | 14 ++ src/shared/tui-agent-display-names.ts | 1 + src/shared/tui-agent-permissions.ts | 2 + src/shared/tui-agent-selection.ts | 1 + src/shared/tui-agent.ts | 1 + src/shared/zcode-headless-command.ts | 20 ++ 70 files changed, 1586 insertions(+), 256 deletions(-) create mode 100644 src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts create mode 100644 src/main/runtime/__fixtures__/zcode-composer-ready.meta.json create mode 100644 src/main/runtime/__fixtures__/zcode-composer-ready.txt create mode 100644 src/main/runtime/zcode-readiness-transcript.test.ts create mode 100644 src/main/skills/skill-discovery-classification.ts create mode 100644 src/main/zcode/hook-config-json.ts create mode 100644 src/main/zcode/hook-service.test.ts create mode 100644 src/main/zcode/hook-service.ts create mode 100644 src/main/zcode/hook-settings.ts create mode 100644 src/shared/agent-command-line-entrypoint.ts create mode 100644 src/shared/agent-hook-listener/providers/zcode-events.test.ts create mode 100644 src/shared/agent-hook-listener/providers/zcode-events.ts create mode 100644 src/shared/agent-icons/zcode.png create mode 100644 src/shared/agent-session-option-catalog-zcode.ts create mode 100644 src/shared/zcode-headless-command.ts diff --git a/README.md b/README.md index f0b3f1df6a3..98efc301c2f 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + ZCode logo ZCode   OpenCode logo OpenCode   MiMo Code logo MiMo Code   Amp logo Amp   diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 3954303811e..cfb75813483 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -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", diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 34793ca60f0..fdd067ef763 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -179,6 +179,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + ZCode logo ZCode   OpenCode logo OpenCode   Amp logo Amp   OpenClaude logo OpenClaude   diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index c6255ae6ad1..56cfc334fd7 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -183,6 +183,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i Logo Cursor Cursor   Logo GitHub Copilot GitHub Copilot   Logo Muse Muse   + Logo ZCode ZCode   Logo OpenCode OpenCode   Logo MiMo Code MiMo Code   Logo Amp Amp   diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index fe3c1e492e6..e5eee5781b0 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -179,6 +179,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + ZCode logo ZCode   OpenCode logo OpenCode   Amp logo Amp   OpenClaude logo OpenClaude   diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index e6f197a7cc9..17528c25ee0 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -179,6 +179,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + ZCode logo ZCode   OpenCode logo OpenCode   MiMo Code logo MiMo Code   Amp logo Amp   diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 1fdf556a593..568a6d0de0d 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -179,6 +179,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca. Logotipo do Cursor Cursor   Logotipo do GitHub Copilot GitHub Copilot   Logotipo do Muse Muse   + Logotipo do ZCode ZCode   Logotipo do OpenCode OpenCode   Logotipo do MiMo Code MiMo Code   Logotipo do Amp Amp   diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 65beb7cc81a..7882935dfa4 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -179,6 +179,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智 Cursor logo Cursor   GitHub Copilot logo GitHub Copilot   Muse logo Muse   + ZCode logo ZCode   OpenCode logo OpenCode   Amp logo Amp   OpenClaude logo OpenClaude   diff --git a/docs/site/content/docs/agents/supported.mdx b/docs/site/content/docs/agents/supported.mdx index 8f93ec6bb4e..052eea085f3 100644 --- a/docs/site/content/docs/agents/supported.mdx +++ b/docs/site/content/docs/agents/supported.mdx @@ -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 | [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) | diff --git a/mobile/src/components/mobile-agent-icon-assets.ts b/mobile/src/components/mobile-agent-icon-assets.ts index 791364bf5e2..473b557096e 100644 --- a/mobile/src/components/mobile-agent-icon-assets.ts +++ b/mobile/src/components/mobile-agent-icon-assets.ts @@ -40,5 +40,6 @@ export const MOBILE_AGENT_ICON_ASSETS: Partial> 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', diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 8c8e117f63b..1eaf54504cb 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -34,7 +34,7 @@ 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.', + '--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, 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.', diff --git a/src/main/agent-hooks/installer-utils-remote.ts b/src/main/agent-hooks/installer-utils-remote.ts index 7c321be5515..32ec4c42201 100644 --- a/src/main/agent-hooks/installer-utils-remote.ts +++ b/src/main/agent-hooks/installer-utils-remote.ts @@ -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, // Why: mirrors the local writer — a JSONC config supplies text edited in place. options?: { serialized?: string } ): Promise { diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index ac4abee9469..a1cd7877638 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -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.`. + config: Record, // 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 } diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index a642ef4b527..560233e8363 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -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()] ] diff --git a/src/main/agent-hooks/managed-hook-command-contract.test.ts b/src/main/agent-hooks/managed-hook-command-contract.test.ts index 38ee5eda19d..774e9396c25 100644 --- a/src/main/agent-hooks/managed-hook-command-contract.test.ts +++ b/src/main/agent-hooks/managed-hook-command-contract.test.ts @@ -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([ local: (path) => [getMuseManagedCommand(path)], remote: (path) => [getMuseRemoteManagedCommand(path)] } + ], + [ + 'zcode', + { + local: (path) => [getZCodeManagedCommand(path)], + remote: (path) => [getZCodeRemoteManagedCommand(path)] + } ] ]) diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 62d6c0b9857..148d5f719d7 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -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([ - ['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(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', { diff --git a/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts new file mode 100644 index 00000000000..f145ebc9be8 --- /dev/null +++ b/src/main/agent-hooks/remote-hook-service-registry-coverage.test.ts @@ -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([ + ['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(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([]) + }) +}) diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index c33d303baef..1f42bdd68a6 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -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 diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index 4e3c4d997b2..d38f6c836c7 100644 --- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts @@ -41,7 +41,8 @@ const NEW_TURN_EVENT: Record = { opencode2: 'SessionStart', 'mimo-code': null, 'command-code': null, - muse: 'UserPromptSubmit' + muse: 'UserPromptSubmit', + zcode: 'SessionStart' } function reviveRetiredPane(source: unknown, hookEventName: string): boolean { diff --git a/src/main/runtime/__fixtures__/zcode-composer-ready.meta.json b/src/main/runtime/__fixtures__/zcode-composer-ready.meta.json new file mode 100644 index 00000000000..4c941ff5727 --- /dev/null +++ b/src/main/runtime/__fixtures__/zcode-composer-ready.meta.json @@ -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 +} diff --git a/src/main/runtime/__fixtures__/zcode-composer-ready.txt b/src/main/runtime/__fixtures__/zcode-composer-ready.txt new file mode 100644 index 00000000000..236dadbbf8e --- /dev/null +++ b/src/main/runtime/__fixtures__/zcode-composer-ready.txt @@ -0,0 +1 @@ +[?2031h]10;?]11;?[>0q[?25l[?1016$p[?2027$p[?2031$p[?1004$p[?2004$p[?2026$p[?u]99;i=opentui-notifications:p=?;\]1337;Capabilities\]66;w=1; \]66;s=2; \[?1049h[>4;1m[?2027h[?2004h[?1000h[?1002h[?1003h[?1006h[?2026h[?25l             ███████╗ ██████╗ ██████╗ ██████╗ ███████╗   ███╔╝██╔════╝██╔═══██╗██╔══██╗██╔════╝   ███╔╝ ██║ ██║ ██║██║ ██║█████╗    ███╔╝ ██║ ██║ ██║██║ ██║██╔══╝   ███████╗╚██████╗╚██████╔╝██████╔╝███████╗  ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝             /private/tmp/zcode-capture  Starting ZCode... Ctrl+C to exit  [?2026l[?2026h[?25l             ███████╗ ██████╗ ██████╗ ██████╗ ███████╗   ███╔╝██╔════╝██╔═══██╗██╔══██╗██╔════╝   ███╔╝ ██║ ██║ ██║██║ ██║█████╗    ███╔╝ ██║ ██║ ██║██║ ██║██╔══╝   ███████╗╚██████╗╚██████╔╝██████╔╝███████╗  ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝             /private/tmp/zcode-capture  Starting ZCode... Ctrl+C to exit  [?2026l[?2026h[?25l▲   ███████╗ ██████╗ ██████╗ ██████╗ ███████╗  ███╔╝██╔════╝██╔═══██╗██╔══██╗██╔════╝  ███╔╝ ██║ ██║ ██║██║ ██║█████╗   ███╔╝ ██║ ██║ ██║██║ ██║██╔══╝  ███████╗╚██████╗╚██████╔╝██████╔╝███████╗█╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝██ █ █ █ █ ▼ ┌─model setup required───────────────────────────────────────────────────────────────────────────────────────────────┐│ No available models. Configure a provider or sign in with /login. ││ Use /model to view models, or /login to connect a Coding Plan account. ││ │└────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘╭─ Build ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│ Type a prompt ││ ││ ││ - - | - │╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ ]12;#ffffff[1 q[?25h[?2026l[?2026h[?25l      ████╗ █████╗ ████╗ ███╗ █████╗  █╔╝╔════╝╔═══╗╔══╗╔════╝  █╔╝███╗  ╔╝ █║ █║ █║║ █║╔══╝  ███████╗╚██████╗╚██████╔╝██████╔╝███████╗ ╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝      [?25h[?2026l[?2026h[?25l █╗ █╗ █╗ █╗ █╗  █╔╝█╔═╝█╔═█╗█╔═█╗█╔═╝  █╔╝ █║ █║ █║█║ █║█╗   █╔╝ █║ █║ █║█║ █║█╔═╝  █╗╚█╗╚█╔╝█╔╝█╗ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝[?25h[?2026l]4;0;?[?2026h[?25l███     █ █████╚══[?25h[?2026l[?2026h[?25l██████   ███  ███╔ ███╔╝██████╚═════[?25h[?2026l[?2026h[?25l███████╗    ███╔╝█  ███╔╝ █ ███╔╝  ████████╗╚╚══════╝ [?25h[?2026l[?2026h[?25l███████╗ ███ ███╔╝██╔═ ███╔╝ ██║  ███╔╝  ██║ ███████╗╚███╚══════╝ ╚══[?25h[?2026l[?2026h[?25l████╗████████╔╝█╔═══██╔╝ █║   █╔╝  █║   ████╗█████════╝╚════[?25h[?2026l[?2026h[?25l██╗ ██████╗ █╔╝██╔════╝█╔╝ ██║     █╝ ██║     ███╗╚██████╗╚══╝ ╚═════╝ [?25h[?2026l[?2026h[?25l ██████╗ █████╔════╝██╔═██║     ██║ ██║     ██║ ╚██████╗╚███ ╚═════╝ ╚══[?25h[?2026l[?2026h[?25l████╗ ██████════╝██╔═══█   ██║   █   ██║   █████╗╚██████════╝ ╚═════[?25h[?2026l[?2026h[?25l█╗ ██████╗ █═╝██╔═══██╗█ ██║   ██║█ ██║   ██║██╗╚██████╔╝█═╝ ╚═════╝ ╚[?25h[?2026l[?2026h[?25l█████╗ ████╔═══█╗██╔█║   █║██║█║   █║██║█████╔╝███╚════╝ ╚══[?25h[?2026l[?2026h[?25l████╗ ██████═══██╗██╔══█ ██║██║  █ ██║██║  █████╔╝██████════╝ ╚═════[?25h[?2026l[?2026h[?25l█╗ ██████╗ ███╗██╔══██╗███║██║  ██║███║██║  ██║██╔╝██████╔╝█═╝ ╚═════╝ ╚[?25h[?2026l[?2026h[?25l██████╗ ██████╔══██╗██╔═██║  ██║██████║  ██║██╔═██████╔╝████╚═════╝ ╚═══[?25h[?2026l[?2026h[?25l███╗ █████══██╗█╔═══ ██║████╗ ██║█╔══╝███╔╝█████═══╝ ═════[?25h[?2026l[?2026h[?25l█╗ ███████╗██╗██╔════╝██║█████╗  ██║██╔══╝  █╔╝███████╗═╝ ╚══════╝[?25h[?2026l[?2026h[?25l███████╗██╔════╝█████╗  ██╔══╝  ███████╗╚══════╝[?25h[?2026l[?2026h[?25l████╗════╝██╗  ══╝  ████╗════╝[?25h[?2026l[?2026h[?25l█╗═╝  █╗═╝[?25h[?2026l[?2026h[?25l███     █ █████╚══[?25h[?2026l[?2026h[?25l██████   ███  ███╔ ███╔╝██████╚═════[?25h[?2026l[?2026h[?25l███████╗    ███╔╝█  ███╔╝ █ ███╔╝  ████████╗╚╚══════╝ [?25h[?2026l[?2026h[?25l██████╗ ██  ██╔╝██╔ ███╝ ██║ ███╔  ██║██████╗╚██╚═════╝ ╚═[?25h[?2026l[?2026h[?25l█████╗ █████ ███╔╝██╔═══███╔╝ ██║   ██╔╝  ██║   █████╗╚█████═════╝ ╚════[?25h[?2026l[?2026h[?25l██╗ ██████╗ █╔╝██╔════╝█╔╝ ██║     █╝ ██║     ███╗╚██████╗╚══╝ ╚═════╝ [?25h[?2026l[?2026h[?25l ██████╗ █████╔════╝██╔═██║     ██║ ██║     ██║ ╚██████╗╚███ ╚═════╝ ╚══[?25h[?2026l[?2026h[?25l████╗ ██████════╝██╔═══█   ██║   █   ██║   █████╗╚██████════╝ ╚═════[?25h[?2026l[?2026h[?25l█╗ █████╗ ═╝██╔══██╗ ██║  ██║ ██║  ██║█╗╚█████╔╝═╝ ╚════╝ [?25h[?2026l[?2026h[?25l ██████╗ █████╔═══██╗██╔██║   ██║██║██║   ██║██║╚██████╔╝███ ╚═════╝ ╚══[?25h[?2026l[?2026h[?25l████╗ ██████═══██╗██╔══█ ██║██║  █ ██║██║  █████╔╝██████════╝ ╚═════[?25h[?2026l[?2026h[?25l█╗ ██████╗ ███╗██╔══██╗███║██║  ██║███║██║  ██║██╔╝██████╔╝█═╝ ╚═════╝ ╚[?25h[?2026l[?2026h[?25l█████╗ █████╔══█╗██╔██║  █║█████║  █║██╔█████╔╝███╚════╝ ╚══[?25h[?2026l[?2026h[?25l████╗ ██████╔══██╗██╔═══║ ██║█████╗║ ██║██╔══╝████╔╝██████════╝ ╚═════[?25h[?2026l[?2026h[?25l█╗ ███████╗██╗██╔════╝██║█████╗  ██║██╔══╝  █╔╝███████╗═╝ ╚══════╝[?25h[?2026l[?2026h[?25l███████╗██╔════╝█████╗  ██╔══╝  ███████╗╚══════╝[?25h[?2026l[?2026h[?25l████╗════╝██╗  ══╝  ████╗════╝[?25h[?2026l[?2026h[?25l█╗═╝  █╗═╝[?25h[?2026l[?2026h[?25l███     █ █████╚══[?25h[?2026l[?2026h[?25l██████   ███  ███╔ ███╔╝██████╚═════[?25h[?2026l[?2026h[?25l██████╗  ███╔╝  ██╔╝  ██╔╝  ██████╗╚═════╝[?25h[?2026l[?2026h[?25l███████╗ ██  ███╔╝██╔ ███╔╝ ██║ ███╔╝  ██║███████╗╚██╚══════╝ ╚═[?25h[?2026l[?2026h[?25l█████╗ █████ ███╔╝██╔═══███╔╝ ██║   ██╔╝  ██║   █████╗╚█████═════╝ ╚════[?25h[?2026l[?2026h[?25l██╗ ██████╗ █╔╝██╔════╝█╔╝ ██║     █╝ ██║     ███╗╚██████╗╚══╝ ╚═════╝ [?25h[?2026l[?2026h[?25l ██████╗ █████╔════╝██╔═██║     ██║ ██║     ██║ ╚██████╗╚███ ╚═════╝ ╚══[?25h[?2026l[?2026h[?25l████╗█████════╝█╔═══    █║       █║   ████╗█████════╝╚════[?25h[?2026l[?2026h[?25l██╗ ██████╗ ══╝██╔═══██╗ ██║   ██║ ██║   ██║██╗╚██████╔╝══╝ ╚═════╝ [?25h[?2026l[?2026h[?25l ██████╗ █████╔═══██╗██╔██║   ██║██║██║   ██║██║╚██████╔╝███ ╚═════╝ ╚══[?25h[?2026l[?2026h[?25l████╗ ██████═══██╗██╔══█ ██║██║  █ ██║██║  █████╔╝██████════╝ ╚═════[?25h[?2026l[?2026h[?25l█╗ █████╗ ██╗██══██╗██║██  ██║██║██  ██║█╔╝█████╔╝═╝ ╚════╝ [?25h[?2026l[?2026h[?25l ██████╗ ███╗██╔══██╗██╔║██║  ██║███║██║  ██║██╔╝██████╔╝███ ╚═════╝ ╚══[?25h[?2026l[?2026h[?25l████╗ ██████╔══██╗██╔═══║ ██║█████╗║ ██║██╔══╝████╔╝██████════╝ ╚═════[?25h[?2026l[?2026h[?25l█╗ ███████╗██╗██╔════╝██║█████╗  ██║██╔══╝  █╔╝███████╗═╝ ╚══════╝[?25h[?2026l[?2026h[?25l███████╗██╔════╝█████╗  ██╔══╝  ███████╗╚══════╝[?25h[?2026l[?2026h[?25l████╗════╝██╗  ══╝  ████╗════╝[?25h[?2026l[?2026h[?25l██╗══╝╗ ╝ ██╗══╝[?25h[?2026l[?2026h[?25l███     █ █████╚══[?25h[?2026l[?2026h[?25l██████   ███  ███╔ ███╔╝██████╚═════[?25h[?2026l[?2026h[?25l██████╗  ███╔╝  ██╔╝  ██╔╝  ██████╗╚═════╝[?25h[?2026l[?2026h[?25l███████╗ ██  ███╔╝██╔ ███╔╝ ██║ ███╔╝  ██║███████╗╚██╚══════╝ ╚═[?25h[?2026l[?2026h[?25l█████╗ █████ ███╔╝██╔═══███╔╝ ██║   ██╔╝  ██║   █████╗╚█████═════╝ ╚════[?25h[?2026l[?2026h[?25l██╗ ██████╗ █╔╝██╔════╝█╔╝ ██║     █╝ ██║     ███╗╚██████╗╚══╝ ╚═════╝ [?25h[?2026l[?2026h[?25l █████╗ ████╔═══╝██╔██║    ██║██║    ██║╚█████╗╚██ ╚════╝ ╚═[?25h[?2026l[?2026h[?25l█████╗ █████╔════╝██╔═══║    ██║   ║    ██║   █████╗╚█████═════╝ ╚════[?25h[?2026l[?2026h[?25l██╗ ██████╗ ══╝██╔═══██╗ ██║   ██║ ██║   ██║██╗╚██████╔╝══╝ ╚═════╝ [?25h[?2026l[?2026h[?25l ██████╗ █████╔═══██╗██╔██║   ██║██║██║   ██║██║╚██████╔╝███ ╚═════╝ ╚══[?25h[?2026l[?2026h[?25l████╗█████═══████╔══  ████║    ████║  ████╔█████════╝╚════[?25h[?2026l[?2026h[?25l██╗ ██████╗ ═██╗██╔══██╗ ██║██║  ██║ ██║██║  ██║██╔╝██████╔╝══╝ ╚═════╝ [?25h[?2026l[?2026h[?25l ██████╗ ███╗██╔══██╗██╔║██║  ██║███║██║  ██║██╔╝██████╔╝███ ╚═════╝ ╚══[?25h[?2026l[?2026h[?25l████╗ ██████╔══██╗██╔═══║ ██║█████╗║ ██║██╔══╝████╔╝██████════╝ ╚═════[?25h[?2026l[?2026h[?25l█╗ ███████╗██╗██╔════╝██║█████╗  ██║██╔══╝  █╔╝███████╗═╝ ╚══════╝[?25h[?2026l[?2026h[?25l██████╗██╔═══╝█████  ██╔══  ██████╗╚═════╝[?25h[?2026l[?2026h[?25l█████╗╔════╝███╗  ╔══╝  █████╗═════╝[?25h[?2026l[?2026h[?25l██╗══╝╗ ╝ ██╗══╝[?25h[?2026l[?2026h[?25l███     █ █████╚══[?25h[?2026l[?2026h[?25l██ \ No newline at end of file diff --git a/src/main/runtime/zcode-readiness-transcript.test.ts b/src/main/runtime/zcode-readiness-transcript.test.ts new file mode 100644 index 00000000000..2319915ab68 --- /dev/null +++ b/src/main/runtime/zcode-readiness-transcript.test.ts @@ -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) + }) +}) diff --git a/src/main/skills/skill-discovery-classification.ts b/src/main/skills/skill-discovery-classification.ts new file mode 100644 index 00000000000..a02464b8ad1 --- /dev/null +++ b/src/main/skills/skill-discovery-classification.ts @@ -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)) +} diff --git a/src/main/skills/skill-discovery-concurrency.test.ts b/src/main/skills/skill-discovery-concurrency.test.ts index 782d1692dac..7cca77d3fd4 100644 --- a/src/main/skills/skill-discovery-concurrency.test.ts +++ b/src/main/skills/skill-discovery-concurrency.test.ts @@ -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()) diff --git a/src/main/skills/skill-discovery-sources.ts b/src/main/skills/skill-discovery-sources.ts index e9a1db8a4fd..2bf9a4c5da5 100644 --- a/src/main/skills/skill-discovery-sources.ts +++ b/src/main/skills/skill-discovery-sources.ts @@ -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,55 +13,16 @@ import { } from './skill-provider-runtime-roots' export type SkillScanRoot = Omit -type SkillDiscoveryPathApi = Pick - -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)) -} +// 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 function source( id: 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' ) ] diff --git a/src/main/zcode/hook-config-json.ts b/src/main/zcode/hook-config-json.ts new file mode 100644 index 00000000000..8e811b4ad48 --- /dev/null +++ b/src/main/zcode/hook-config-json.ts @@ -0,0 +1,87 @@ +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, 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 } +} + +/** The `hooks.events` block as a plain lookup, or empty when absent or malformed. */ +function readEventMap(config: ZCodeConfig): Record { + const events = config.hooks?.events + return isPlainObject(events) ? events : {} +} + +/** + * Serialize by editing the original text one hook event at a time, so the user's comments, + * key order, and formatting survive. A parse -> JSON.stringify round trip would drop them. + */ +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 = readEventMap(previous) + const nextEvents = readEventMap(nextConfig) + + let text = originalText + const nextEnabled = isZCodeHooksEnabled(nextConfig) + if (isZCodeHooksEnabled(previous) !== nextEnabled) { + text = applyEdits( + text, + modify(text, ['hooks', 'enabled'], nextEnabled, { + formattingOptions: { insertSpaces: true, tabSize: 2 } + }) + ) + } + // 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 + } + text = applyEdits( + text, + // Why: `undefined` removes the key, which is how remove() drops an emptied event. + modify(text, ['hooks', 'events', eventName], nextValue, { + formattingOptions: { insertSpaces: true, tabSize: 2 } + }) + ) + } + return text +} diff --git a/src/main/zcode/hook-service.test.ts b/src/main/zcode/hook-service.test.ts new file mode 100644 index 00000000000..ad53e53a808 --- /dev/null +++ b/src/main/zcode/hook-service.test.ts @@ -0,0 +1,155 @@ +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() + 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 } + [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('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') + }) +}) diff --git a/src/main/zcode/hook-service.ts b/src/main/zcode/hook-service.ts new file mode 100644 index 00000000000..8dfb3ceefc2 --- /dev/null +++ b/src/main/zcode/hook-service.ts @@ -0,0 +1,225 @@ +import type { SFTPWrapper } from 'ssh2' +import type { AgentHookInstallState, 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, + getZCodeManagedCommandMatcher, + getZCodeManagedScriptFileName, + getZCodeManagedScriptPath, + getZCodePosixManagedScriptFileName, + getZCodeRemoteConfigPath, + getZCodeRemoteManagedCommand, + isZCodeHooksEnabled, + readManagedZCodeHookEvents, + removeZCodeManagedHooks, + ZCODE_HOOK_EVENTS +} 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 buildStatus( + config: Parameters[0], + configPath: string, + scriptFileName: string +): AgentHookInstallStatus { + const base = { agent: 'zcode' as const, configPath } + const present = readManagedZCodeHookEvents(config, getZCodeManagedCommandMatcher(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) + + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0 && hooksEnabled) { + state = 'installed' + detail = null + } else if (present.size === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = + [ + missing.length > 0 ? `events: ${missing.join(', ')}` : null, + hooksEnabled ? null : '`hooks.enabled` is false, so ZCode runs no hooks' + ] + .filter(Boolean) + .join('; ') || null + } + return { ...base, state, managedHooksPresent: present.size > 0, detail } +} + +export class ZCodeHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getZCodeManagedScriptPath(), getManagedScript()) + } + + getStatus(): AgentHookInstallStatus { + const configPath = getZCodeConfigPath() + const source = readZCodeConfigSource(configPath) + if (!source) { + return { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: '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 { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: '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 { + 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 { + agent: 'zcode', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: '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 { + agent: 'zcode', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + + remove(): AgentHookInstallStatus { + const configPath = getZCodeConfigPath() + const source = readZCodeConfigSource(configPath) + if (!source) { + return { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: '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() diff --git a/src/main/zcode/hook-settings.ts b/src/main/zcode/hook-settings.ts new file mode 100644 index 00000000000..df23fbf89a6 --- /dev/null +++ b/src/main/zcode/hook-settings.ts @@ -0,0 +1,205 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + buildManagedCommandHook, + createManagedCommandMatcher, + getSharedManagedScriptPath, + 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 + +export type ZCodeHookEvent = (typeof ZCODE_HOOK_EVENTS)[number] + +/** + * ZCode's hook block, nested one level deeper than Claude's (`hooks.events.`). + * + * `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 + [key: string]: unknown +} + +export type ZCodeConfig = { + hooks?: ZCodeHooksRuntimeConfig + [key: string]: unknown +} + +function getZCodeConfigDir(home: string): 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(home, '.zcode', 'cli') +} + +export function getZCodeConfigPath(): string { + return join(getZCodeConfigDir(homedir()), '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) +} + +export function getZCodeManagedCommandMatcher( + scriptFileName = getZCodeManagedScriptFileName() +): (command: string | undefined) => boolean { + return createManagedCommandMatcher(scriptFileName) +} + +function readEvents(config: ZCodeConfig): Record { + 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, + 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 = { ...readEvents(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 = readEvents(config) + const nextEvents = { ...events } + const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName) + let changed = false + + for (const eventName of Object.keys(nextEvents)) { + if (!Array.isArray(nextEvents[eventName])) { + continue + } + const definitions = readEventDefinitions(nextEvents, eventName) + const cleaned = removeManagedCommands(definitions, isManagedCommand) + if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) { + 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, + isManagedCommand: (command: string | undefined) => boolean +): Set { + const present = new Set() + const events = readEvents(config) + for (const eventName of ZCODE_HOOK_EVENTS) { + const hasManaged = readEventDefinitions(events, eventName).some((definition) => { + const hooks = definition.hooks + return ( + Array.isArray(hooks) && + hooks.some( + (hook) => isPlainObject(hook) && isManagedCommand(readCommandString(hook.command)) + ) + ) + }) + if (hasManaged) { + present.add(eventName) + } + } + return present +} + +function readCommandString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +export function isZCodeHooksEnabled(config: ZCodeConfig): boolean { + return config.hooks?.enabled === true +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 683d82c3578..72cf2a67217 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -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" diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3cfa6967d4f..871cd611af5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 0147111e3de..e6c60b41e59 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index dae80d521e4..eadab74eb76 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 0386126ae39..aafd2b76ebf 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -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": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index b88637923cd..ed926dadb27 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -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": { diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 6ea1de86787..42c9baa7f6e 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -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'), diff --git a/src/renderer/src/lib/agent-favicon-assets.ts b/src/renderer/src/lib/agent-favicon-assets.ts index caeea46bc43..6c7221efe80 100644 --- a/src/renderer/src/lib/agent-favicon-assets.ts +++ b/src/renderer/src/lib/agent-favicon-assets.ts @@ -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> = { hermes: hermesUrl, devin: devinUrl, muse: museUrl, + zcode: zcodeUrl, openclaw: openclawUrl } diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index b88018b4294..acf7d7cbd53 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -135,7 +135,8 @@ const ICONABLE_AGENT_TYPES: Record = { 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. diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts index 3fca694baa3..f25aa9868a9 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.test.ts @@ -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, diff --git a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts index 12c1b96efd6..ddc59b5ae98 100644 --- a/src/renderer/src/runtime/agent-resume-host-authority-capability.ts +++ b/src/renderer/src/runtime/agent-resume-host-authority-capability.ts @@ -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 diff --git a/src/shared/agent-command-line-entrypoint.ts b/src/shared/agent-command-line-entrypoint.ts new file mode 100644 index 00000000000..fe30447fe0b --- /dev/null +++ b/src/shared/agent-command-line-entrypoint.ts @@ -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() +} diff --git a/src/shared/agent-headless-command.ts b/src/shared/agent-headless-command.ts index 74896e029b5..3ce26e949c3 100644 --- a/src/shared/agent-headless-command.ts +++ b/src/shared/agent-headless-command.ts @@ -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 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 { diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index 9fb7255220d..d3abd287e89 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -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 } diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index 3b4132b1a6a..c6e6af7be4d 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -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: ZCode's SessionStart lands an idle boundary row, and its own `compact` source is + // filtered upstream, so UserPromptSubmit is the only real new-turn boundary left. + 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) diff --git a/src/shared/agent-hook-listener/providers/zcode-events.test.ts b/src/shared/agent-hook-listener/providers/zcode-events.test.ts new file mode 100644 index 00000000000..31a1e764ba0 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/zcode-events.test.ts @@ -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 = {} +): Record { + 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 { + 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') + }) +}) diff --git a/src/shared/agent-hook-listener/providers/zcode-events.ts b/src/shared/agent-hook-listener/providers/zcode-events.ts new file mode 100644 index 00000000000..dc0df184838 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/zcode-events.ts @@ -0,0 +1,101 @@ +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 = new Set([ + 'startup', + 'resume', + 'clear' +]) + +export function normalizeZCodeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + + const toolName = readString(hookPayload, 'tool_name') + // Why: ZCode's clarification tool is literally `AskUserQuestion` with Claude's + // questions/options input shape, so Orca's question card renders it unchanged. + const isUserInputTool = isAskUserQuestionTool(toolName) + + let stateName: 'working' | 'waiting' | 'done' | null = null + let sessionBoundary = false + 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'] + if (typeof source !== 'string' || !ZCODE_IDLE_SESSION_START_SOURCES.has(source)) { + return null + } + stateName = 'done' + sessionBoundary = true + break + } + case 'UserPromptSubmit': + case 'PostToolUse': + case 'PostToolUseFailure': + stateName = 'working' + break + case 'PreToolUse': + stateName = isUserInputTool ? 'waiting' : 'working' + break + case 'PermissionRequest': + // 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. + stateName = 'waiting' + break + case 'Stop': + stateName = 'done' + break + default: + return null + } + + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('zcode', eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent('zcode', eventName) } + ) + + const interrupted = + eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + + return normalizeAgentStatusPayload({ + state: stateName, + prompt: resolvePrompt(state, paneKey, promptText, { + resetOnNewTurn: isNewTurnEvent('zcode', eventName) + }), + agentType: 'zcode', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + ...(sessionBoundary ? { sessionBoundary: true } : {}), + interrupted + }) +} diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts index 818f4d9da8a..e84313b0c26 100644 --- a/src/shared/agent-hook-listener/source-routing.ts +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -22,7 +22,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/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 { diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 33bee532ad6..cc5d21ba8f7 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -54,7 +54,8 @@ const AGENT_HOOK_SOURCES = [ 'hermes', 'devin', 'kimi', - 'muse' + 'muse', + 'zcode' ] as const export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number] diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index d08223ed053..a006c2cc37d 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -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] diff --git a/src/shared/agent-icons/zcode.png b/src/shared/agent-icons/zcode.png new file mode 100644 index 0000000000000000000000000000000000000000..d49f58d53fd64de471da7ca604e362ee2cbd4fc9 GIT binary patch literal 2598 zcmV+>3fc9EP)4Tx0C=2rkiAR8P!z>at5VQ9hz=bbGKoXf(h7EQXe$&&FjNJrQ<{DWZG0pt zQgIVkDfk~)!C7#yh*WTKa1cZX5#5|RDY$5O-j`I`BHqX4{WzR+xm>^-P#G)s0x0R0 zkxay-wbZ)gdxM9bQ>tdNsG=+i{{6e_^U?L*Pl#DfyLJ%SPh6MIE|+$m0#kqeUDcn- zni~Dz)Ip6I7T}SIm2Ha&-X$I}Xer{V;JnMng3~UaJD!zfocNYl(h6#ZxJfLhJM?@9 zmx^VrwS(B+pVe2F#T@EU%wZEI7>ZC)fdmENfBe&qKaMSOS71;sj{+>pL`e}7vc&Vy zpY?`L za=`luFqi^{?9*UQ?e0AH9zV>> z&dxqF&rEBy+|9G|%=2>Zx##@PIsbd^C<;vj9-wR4vVN`gT{u~X;u=8l|8BxDlzLa= z_kQ~6r-v$KR;82W%a<>8!gk=>N+}~Eh)B5)1xUnsbt~Od<*^suqvF0)X-8`Hr4&jj zM8pAmjpCtCKKW!6&<^ip>R^Iu)&nC=GQzIi2 z8jv6#?oe8Y;SNAl zSJ$$BWq7M-o=ZvVb*-V4(&AWez4mvTd2}`)d_B}gp}p33i5Wv`eJ^#Uyq3EyLZ1L4 zq8e8~v(HGAeZ)SmikJITXZKQ%h*}IB1tKV=7AsvxS7eZzf-2uf>=))f*hoPm{y=qp80MTBH3 zNx~)yIYF@?H^kvt1QLk^=gv(rm!BhQ8W_CLoO7hpX@0qF8&5y|G*|cZ@bcc5Xzyr8ye~krj^;&R zW+uls@3@1rXU>pJr4TQR5NIU=l%*@)J387sSiXGu1;x9gN00Kt3oj4^0o$MX6+Jyy zH}(FHA9;iij(J_Ot-T}iu2PCH3|YByCEeZK zktU34MjOV)Mmh2E$F#P#^5;JtqPMpf?|sn`Yg?`~$#30xC$GM`kIv3c!Z55mUTq7A zV2ok-l{0Fj2mve5$_9r)CgqNlD0O9 zQfL&Dp$6A4lM}6(o13Gvql2NLAy%$fQFiJ$=!9X&P3zZl`0!z}*)~EK)?AA$$^#O^ zQW|A83dKxrhF|aA&8n3v8-h*=_m4gH6W)F29Xh(Y2=igh@fM}gCE2WSQLT`<6E?x= ziE%dGzlohY1_;9tYi%sVmNIW@j5LC3U2?G#0Btn6=^R&GbrtXY z^=(>PTVs8vgzt%o3HsKqV|aL&R4Rq{zPYg%t)7}ZlmhV{V>G*V4U)}f3nVPY@tt!h zrFiIvKjNc*{gX^4jrXqU@vU5>{*5u5nLN$Uo_K$mhcg z18_@gt6aG7ma}Ki(BI$Bfj8bLn6$x=`O!xw=v&)IE|=30r{9h7B9ovu96JA#-SGh_ChcV~m|&^)$H7FRrN; zUQTB+(p<1o!kTgN{>{E&1e(=b7-A4VIESS2-h zk9Uq;gM%zx+8xU>OWN03^XTTy96xr9OiRl=$G^BJUYm_Z@d;}U=ccB3_~D1S<(6BT z3Yq)%?c=$fJ85ZcC7;hXUZIKefmA9jvG^;-`&@3CYp?whZ@>K)f*^>^wxy^vGBU!K zue*-1(J>N<#07`UDF3ul&E{$?Zd^z@o#y%9JYP=vjf70CH5>20pMQ^xuq2x$48uZw zC`M}vg%=-9sbxVJjVX?)LUPJYPqTgdcD}e~O+!(q6fy?}2H5}l>m-7Jsk3LBQ?%9u zK~T3KrGjKq>RHKJOKy6a+its!{rmTqowku|`~LgybN%(#Gc`5E<(IE!>C)oTG=?XY zieaOpql}yyL7O^@n;=L^kc>Ejq@+@5v5ACyZrL(9IXo=hdvVT5z2?1_Fbrj8W=8t@ z3T4-O?zu(5I)y_|ZwQM3G z(M2X9(~=xqm{(Rkl zp{%u1D-wI}V&i=jevPq+6eMmd%@P$i6`^SNIMPx6q;O92~0qj_8(S>2B0000007*qo IM6N<$f+bV!jQ{`u literal 0 HcmV?d00001 diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index 13e123fbc88..06fa51ab76c 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -51,7 +51,8 @@ const TUI_AGENT_KIND_BY_AGENT = { devin: 'devin', ante: 'ante', trae: 'trae', - muse: 'muse' + muse: 'muse', + zcode: 'zcode' } satisfies Record // Why: `satisfies Record` makes the lookup exhaustive at compile diff --git a/src/shared/agent-name-token-match.ts b/src/shared/agent-name-token-match.ts index ea024babd32..083489bb77a 100644 --- a/src/shared/agent-name-token-match.ts +++ b/src/shared/agent-name-token-match.ts @@ -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 diff --git a/src/shared/agent-process-recognition.ts b/src/shared/agent-process-recognition.ts index baf15a63759..a0f0faa68e7 100644 --- a/src/shared/agent-process-recognition.ts +++ b/src/shared/agent-process-recognition.ts @@ -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 = { 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) { diff --git a/src/shared/agent-session-option-catalog-zcode.ts b/src/shared/agent-session-option-catalog-zcode.ts new file mode 100644 index 00000000000..5077c93a27a --- /dev/null +++ b/src/shared/agent-session-option-catalog-zcode.ts @@ -0,0 +1,40 @@ +import { hasFlag } from './agent-cli-flag-detection' +import { removeAgentArgOption } from './agent-session-option-agent-args' +import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' + +/** + * ZCode's collaboration mode is the one launch-time knob its CLI exposes. + * `normalizePromptMode` (apps/zcode-cli/packages/cli/src/run.ts) accepts exactly these + * four values and throws on anything else — `auto` exists in the runtime type but is not + * a valid `--mode` argument, so it is deliberately absent here. + */ +const ZCODE_MODE: CatalogOption = { + id: 'mode', + label: 'Collaboration mode', + category: 'mode', + kind: { + type: 'select', + choices: [ + { value: 'plan', label: 'Plan' }, + { value: 'edit', label: 'Edit' }, + { value: 'build', label: 'Build' }, + { value: 'yolo', label: 'Yolo' } + ], + // Why: DefaultRuntimeConfig sets `mode: "build"` for the interactive TUI. + defaultValue: 'build' + }, + apply: { + launchArgs: (value) => ['--mode', String(value)], + agentArgsOverride: (tokens) => hasFlag(tokens, ['--mode']), + removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--mode']) + } +} + +export const ZCODE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { + supportsWorkerLaunchPreferences: true, + // Why: ZCode's CLI has no `--model` flag at all (see `parseGlobalArgs`) — the model comes + // from account config and the in-TUI picker — so there is nothing to seed or apply. + models: [], + modelApply: {}, + unknownModelOptions: [ZCODE_MODE] +} diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index a8e26f5a0ed..9fbd413a4b2 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -12,6 +12,7 @@ import { import { GROK_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-grok' import { MUSE_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-muse' import { OMP_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-omp' +import { ZCODE_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-zcode' import type { AgentSessionOptionCatalog, AgentSessionOptionCatalogMap, @@ -39,7 +40,8 @@ const CATALOGS: AgentSessionOptionCatalogMap = { cursor: CURSOR_SESSION_OPTION_CATALOG, grok: GROK_SESSION_OPTION_CATALOG, muse: MUSE_SESSION_OPTION_CATALOG, - omp: OMP_SESSION_OPTION_CATALOG + omp: OMP_SESSION_OPTION_CATALOG, + zcode: ZCODE_SESSION_OPTION_CATALOG } export function getAgentSessionOptionCatalog(agent: AgentType): AgentSessionOptionCatalog | null { diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 0a0ab7d443a..89bef799a22 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -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 } } diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index 662a0434bf5..24febeaafa7 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -26,7 +26,8 @@ const WELL_KNOWN_LABELS: Record = { ante: 'Ante', trae: 'Trae', kimi: 'Kimi', - muse: 'Muse' + muse: 'Muse', + zcode: 'ZCode' } export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index 0b0e104e54a..1e586031a4e 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -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 /** diff --git a/src/shared/synthetic-agent-title.ts b/src/shared/synthetic-agent-title.ts index 1e862718215..ef09984593e 100644 --- a/src/shared/synthetic-agent-title.ts +++ b/src/shared/synthetic-agent-title.ts @@ -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 = { @@ -75,6 +76,17 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record = { // 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 -- ` auto-submits immediately (docs.devin.ai/cli), so start the REPL with no argv prompt. diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index b886cfb1490..f85a87834b2 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -14,6 +14,7 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { ante: 'Ante', trae: 'Trae', muse: 'Muse', + zcode: 'ZCode', autohand: 'Autohand Code', opencode: 'OpenCode', opencode2: 'OpenCode 2', diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index e45adeea0d7..6d6a6bec856 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -21,6 +21,8 @@ export const YOLO_TUI_AGENT_ARGS: Partial> = { 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', diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 85863618228..f3eb7322fb1 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -16,6 +16,7 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'ante', 'trae', 'muse', + 'zcode', 'pi', 'omp', 'prime-agent', diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index 226060d1d23..d19fcb0b1ce 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -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) diff --git a/src/shared/zcode-headless-command.ts b/src/shared/zcode-headless-command.ts new file mode 100644 index 00000000000..5ed729a2c1b --- /dev/null +++ b/src/shared/zcode-headless-command.ts @@ -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 = 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 +} From 73c36b3af69f9f0e56114d4f5f25d2424b20aea3 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 23 Sep 2026 02:26:10 -0700 Subject: [PATCH 2/5] 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. --- src/cli/specs/orchestration-worker-specs.ts | 2 +- .../agent-session-option-catalog-zcode.ts | 40 ------------ src/shared/agent-session-option-catalog.ts | 4 +- .../zcode-orchestration-contract.test.ts | 64 +++++++++++++++++++ 4 files changed, 66 insertions(+), 44 deletions(-) delete mode 100644 src/shared/agent-session-option-catalog-zcode.ts create mode 100644 src/shared/zcode-orchestration-contract.test.ts diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts index 1eaf54504cb..1448667e54a 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -35,7 +35,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ '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, 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, launch with the model from their own config.', + '--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.", diff --git a/src/shared/agent-session-option-catalog-zcode.ts b/src/shared/agent-session-option-catalog-zcode.ts deleted file mode 100644 index 5077c93a27a..00000000000 --- a/src/shared/agent-session-option-catalog-zcode.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { hasFlag } from './agent-cli-flag-detection' -import { removeAgentArgOption } from './agent-session-option-agent-args' -import type { AgentSessionOptionCatalog, CatalogOption } from './agent-session-option-catalog-types' - -/** - * ZCode's collaboration mode is the one launch-time knob its CLI exposes. - * `normalizePromptMode` (apps/zcode-cli/packages/cli/src/run.ts) accepts exactly these - * four values and throws on anything else — `auto` exists in the runtime type but is not - * a valid `--mode` argument, so it is deliberately absent here. - */ -const ZCODE_MODE: CatalogOption = { - id: 'mode', - label: 'Collaboration mode', - category: 'mode', - kind: { - type: 'select', - choices: [ - { value: 'plan', label: 'Plan' }, - { value: 'edit', label: 'Edit' }, - { value: 'build', label: 'Build' }, - { value: 'yolo', label: 'Yolo' } - ], - // Why: DefaultRuntimeConfig sets `mode: "build"` for the interactive TUI. - defaultValue: 'build' - }, - apply: { - launchArgs: (value) => ['--mode', String(value)], - agentArgsOverride: (tokens) => hasFlag(tokens, ['--mode']), - removeAgentArgs: (tokens) => removeAgentArgOption(tokens, ['--mode']) - } -} - -export const ZCODE_SESSION_OPTION_CATALOG: AgentSessionOptionCatalog = { - supportsWorkerLaunchPreferences: true, - // Why: ZCode's CLI has no `--model` flag at all (see `parseGlobalArgs`) — the model comes - // from account config and the in-TUI picker — so there is nothing to seed or apply. - models: [], - modelApply: {}, - unknownModelOptions: [ZCODE_MODE] -} diff --git a/src/shared/agent-session-option-catalog.ts b/src/shared/agent-session-option-catalog.ts index 9fbd413a4b2..a8e26f5a0ed 100644 --- a/src/shared/agent-session-option-catalog.ts +++ b/src/shared/agent-session-option-catalog.ts @@ -12,7 +12,6 @@ import { import { GROK_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-grok' import { MUSE_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-muse' import { OMP_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-omp' -import { ZCODE_SESSION_OPTION_CATALOG } from './agent-session-option-catalog-zcode' import type { AgentSessionOptionCatalog, AgentSessionOptionCatalogMap, @@ -40,8 +39,7 @@ const CATALOGS: AgentSessionOptionCatalogMap = { cursor: CURSOR_SESSION_OPTION_CATALOG, grok: GROK_SESSION_OPTION_CATALOG, muse: MUSE_SESSION_OPTION_CATALOG, - omp: OMP_SESSION_OPTION_CATALOG, - zcode: ZCODE_SESSION_OPTION_CATALOG + omp: OMP_SESSION_OPTION_CATALOG } export function getAgentSessionOptionCatalog(agent: AgentType): AgentSessionOptionCatalog | null { diff --git a/src/shared/zcode-orchestration-contract.test.ts b/src/shared/zcode-orchestration-contract.test.ts new file mode 100644 index 00000000000..ba25cbceaef --- /dev/null +++ b/src/shared/zcode-orchestration-contract.test.ts @@ -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') + }) +}) From ea455e313af018d9bfdcd654febade6b03bff7cf Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 23 Sep 2026 13:21:44 -0700 Subject: [PATCH 3/5] refactor(zcode): reuse shared helpers and cut the harness down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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. --- src/main/zcode/hook-config-json.ts | 36 +++----- src/main/zcode/hook-service.ts | 80 +++++------------ src/main/zcode/hook-settings.ts | 56 +++++------- .../providers/zcode-events.ts | 89 ++++++++++--------- 4 files changed, 103 insertions(+), 158 deletions(-) diff --git a/src/main/zcode/hook-config-json.ts b/src/main/zcode/hook-config-json.ts index 8e811b4ad48..49e1dfd0e2b 100644 --- a/src/main/zcode/hook-config-json.ts +++ b/src/main/zcode/hook-config-json.ts @@ -2,7 +2,7 @@ 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, type ZCodeConfig } from './hook-settings' +import { isZCodeHooksEnabled, readZCodeEventMap, type ZCodeConfig } from './hook-settings' export type ZCodeConfigSource = { text: string | null @@ -39,15 +39,17 @@ export function readZCodeConfigSource(configPath: string): ZCodeConfigSource | n return config === null ? null : { text, config } } -/** The `hooks.events` block as a plain lookup, or empty when absent or malformed. */ -function readEventMap(config: ZCodeConfig): Record { - const events = config.hooks?.events - return isPlainObject(events) ? events : {} +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 comments, - * key order, and formatting survive. A parse -> JSON.stringify round trip would drop them. + * 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) { @@ -55,18 +57,13 @@ export function serializeZCodeConfig(originalText: string | null, nextConfig: ZC } const previous = parseZCodeConfigText(originalText, 'ZCode config.json') ?? {} - const previousEvents = readEventMap(previous) - const nextEvents = readEventMap(nextConfig) + const previousEvents = readZCodeEventMap(previous) + const nextEvents = readZCodeEventMap(nextConfig) let text = originalText const nextEnabled = isZCodeHooksEnabled(nextConfig) if (isZCodeHooksEnabled(previous) !== nextEnabled) { - text = applyEdits( - text, - modify(text, ['hooks', 'enabled'], nextEnabled, { - formattingOptions: { insertSpaces: true, tabSize: 2 } - }) - ) + 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. @@ -75,13 +72,8 @@ export function serializeZCodeConfig(originalText: string | null, nextConfig: ZC if (JSON.stringify(previousEvents[eventName]) === JSON.stringify(nextValue)) { continue } - text = applyEdits( - text, - // Why: `undefined` removes the key, which is how remove() drops an emptied event. - modify(text, ['hooks', 'events', eventName], nextValue, { - formattingOptions: { insertSpaces: true, tabSize: 2 } - }) - ) + // `undefined` removes the key, which is how remove() drops an emptied event. + text = editJsonPath(text, ['hooks', 'events', eventName], nextValue) } return text } diff --git a/src/main/zcode/hook-service.ts b/src/main/zcode/hook-service.ts index 8dfb3ceefc2..3f60e2d3df8 100644 --- a/src/main/zcode/hook-service.ts +++ b/src/main/zcode/hook-service.ts @@ -1,5 +1,5 @@ import type { SFTPWrapper } from 'ssh2' -import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' import { buildWindowsAgentHookCurlPostCommand, writeHooksJson, @@ -22,7 +22,6 @@ import { applyZCodeManagedHooks, getZCodeConfigPath, getZCodeManagedCommand, - getZCodeManagedCommandMatcher, getZCodeManagedScriptFileName, getZCodeManagedScriptPath, getZCodePosixManagedScriptFileName, @@ -31,7 +30,8 @@ import { isZCodeHooksEnabled, readManagedZCodeHookEvents, removeZCodeManagedHooks, - ZCODE_HOOK_EVENTS + ZCODE_HOOK_EVENTS, + type ZCodeConfig } from './hook-settings' import { parseZCodeConfigText, @@ -75,37 +75,33 @@ function getManagedScript(target: 'local' | 'posix' = 'local'): string { ].join('\n') } +function zcodeHookError(configPath: string, detail: string): AgentHookInstallStatus { + return { agent: 'zcode', state: 'error', configPath, managedHooksPresent: false, detail } +} + function buildStatus( - config: Parameters[0], + config: ZCodeConfig, configPath: string, scriptFileName: string ): AgentHookInstallStatus { const base = { agent: 'zcode' as const, configPath } - const present = readManagedZCodeHookEvents(config, getZCodeManagedCommandMatcher(scriptFileName)) + 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) - let state: AgentHookInstallState - let detail: string | null if (missing.length === 0 && hooksEnabled) { - state = 'installed' - detail = null - } else if (present.size === 0) { - state = 'not_installed' - detail = null - } else { - state = 'partial' - detail = - [ - missing.length > 0 ? `events: ${missing.join(', ')}` : null, - hooksEnabled ? null : '`hooks.enabled` is false, so ZCode runs no hooks' - ] - .filter(Boolean) - .join('; ') || null + return { ...base, state: 'installed', managedHooksPresent: true, detail: null } } - return { ...base, state, managedHooksPresent: present.size > 0, detail } + 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 { @@ -117,13 +113,7 @@ export class ZCodeHookService { const configPath = getZCodeConfigPath() const source = readZCodeConfigSource(configPath) if (!source) { - return { - agent: 'zcode', - state: 'error', - configPath, - managedHooksPresent: false, - detail: 'Could not read ZCode config.json' - } + return zcodeHookError(configPath, 'Could not read ZCode config.json') } return buildStatus(source.config, configPath, getZCodeManagedScriptFileName()) } @@ -133,13 +123,7 @@ export class ZCodeHookService { const scriptPath = getZCodeManagedScriptPath() const source = readZCodeConfigSource(configPath) if (!source) { - return { - agent: 'zcode', - state: 'error', - configPath, - managedHooksPresent: false, - detail: 'Could not read ZCode config.json' - } + return zcodeHookError(configPath, 'Could not read ZCode config.json') } const scriptFileName = getZCodeManagedScriptFileName() @@ -163,13 +147,7 @@ export class ZCodeHookService { const body = await readTextFileRemote(sftp, remoteConfigPath) const config = body === null ? {} : parseZCodeConfigText(body, 'remote ZCode config.json') if (!config) { - return { - agent: 'zcode', - state: 'error', - configPath: remoteConfigPath, - managedHooksPresent: false, - detail: 'Could not parse remote ZCode config.json' - } + return zcodeHookError(remoteConfigPath, 'Could not parse remote ZCode config.json') } const command = getZCodeRemoteManagedCommand(remoteScriptPath) @@ -187,13 +165,7 @@ export class ZCodeHookService { detail: null } } catch (err) { - return { - agent: 'zcode', - state: 'error', - configPath: remoteConfigPath, - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } + return zcodeHookError(remoteConfigPath, err instanceof Error ? err.message : String(err)) } } @@ -201,13 +173,7 @@ export class ZCodeHookService { const configPath = getZCodeConfigPath() const source = readZCodeConfigSource(configPath) if (!source) { - return { - agent: 'zcode', - state: 'error', - configPath, - managedHooksPresent: false, - detail: 'Could not read ZCode config.json' - } + return zcodeHookError(configPath, 'Could not read ZCode config.json') } const { config: nextConfig, changed } = removeZCodeManagedHooks( source.config, diff --git a/src/main/zcode/hook-settings.ts b/src/main/zcode/hook-settings.ts index df23fbf89a6..673e0df159c 100644 --- a/src/main/zcode/hook-settings.ts +++ b/src/main/zcode/hook-settings.ts @@ -4,6 +4,7 @@ import { buildManagedCommandHook, createManagedCommandMatcher, getSharedManagedScriptPath, + hookDefinitionHasManagedCommand, isPlainObject, removeManagedCommands, wrapPosixHookCommand, @@ -29,8 +30,6 @@ export const ZCODE_HOOK_EVENTS = [ 'Stop' ] as const -export type ZCodeHookEvent = (typeof ZCODE_HOOK_EVENTS)[number] - /** * ZCode's hook block, nested one level deeper than Claude's (`hooks.events.`). * @@ -49,14 +48,10 @@ export type ZCodeConfig = { [key: string]: unknown } -function getZCodeConfigDir(home: string): string { +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(home, '.zcode', 'cli') -} - -export function getZCodeConfigPath(): string { - return join(getZCodeConfigDir(homedir()), 'config.json') + return join(homedir(), '.zcode', 'cli', 'config.json') } export function getZCodeRemoteConfigPath(remoteHome: string): string { @@ -88,13 +83,14 @@ export function getZCodeRemoteManagedCommand(scriptPath: string): string { return wrapPosixHookCommand(scriptPath) } -export function getZCodeManagedCommandMatcher( +function getZCodeManagedCommandMatcher( scriptFileName = getZCodeManagedScriptFileName() ): (command: string | undefined) => boolean { return createManagedCommandMatcher(scriptFileName) } -function readEvents(config: ZCodeConfig): Record { +/** The `hooks.events` block as a plain lookup, or empty when absent or malformed. */ +export function readZCodeEventMap(config: ZCodeConfig): Record { const events = config.hooks?.events return isPlainObject(events) ? events : {} } @@ -118,7 +114,7 @@ export function applyZCodeManagedHooks( command: string, scriptFileName = getZCodeManagedScriptFileName() ): ZCodeConfig { - const nextEvents = { ...readEvents(config) } + const nextEvents = { ...readZCodeEventMap(config) } const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName) for (const eventName of ZCODE_HOOK_EVENTS) { @@ -143,13 +139,15 @@ export function removeZCodeManagedHooks( config: ZCodeConfig, scriptFileName = getZCodeManagedScriptFileName() ): { config: ZCodeConfig; changed: boolean } { - const events = readEvents(config) + const events = readZCodeEventMap(config) const nextEvents = { ...events } const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName) let changed = false - for (const eventName of Object.keys(nextEvents)) { - if (!Array.isArray(nextEvents[eventName])) { + 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) @@ -175,29 +173,17 @@ export function removeZCodeManagedHooks( /** Events whose managed command is currently registered in the user's config. */ export function readManagedZCodeHookEvents( config: ZCodeConfig, - isManagedCommand: (command: string | undefined) => boolean + scriptFileName = getZCodeManagedScriptFileName() ): Set { - const present = new Set() - const events = readEvents(config) - for (const eventName of ZCODE_HOOK_EVENTS) { - const hasManaged = readEventDefinitions(events, eventName).some((definition) => { - const hooks = definition.hooks - return ( - Array.isArray(hooks) && - hooks.some( - (hook) => isPlainObject(hook) && isManagedCommand(readCommandString(hook.command)) - ) + const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName) + const events = readZCodeEventMap(config) + return new Set( + ZCODE_HOOK_EVENTS.filter((eventName) => + readEventDefinitions(events, eventName).some((definition) => + hookDefinitionHasManagedCommand(definition, isManagedCommand) ) - }) - if (hasManaged) { - present.add(eventName) - } - } - return present -} - -function readCommandString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined + ) + ) } export function isZCodeHooksEnabled(config: ZCodeConfig): boolean { diff --git a/src/shared/agent-hook-listener/providers/zcode-events.ts b/src/shared/agent-hook-listener/providers/zcode-events.ts index dc0df184838..b80e71e7a22 100644 --- a/src/shared/agent-hook-listener/providers/zcode-events.ts +++ b/src/shared/agent-hook-listener/providers/zcode-events.ts @@ -23,79 +23,80 @@ const ZCODE_IDLE_SESSION_START_SOURCES: ReadonlySet = new Set([ 'clear' ]) -export function normalizeZCodeEvent( - state: HookListenerState, - eventName: unknown, - promptText: string, - paneKey: string, - hookPayload: Record -): ParsedAgentStatusPayload | null { - if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { - return null - } - - const toolName = readString(hookPayload, 'tool_name') - // Why: ZCode's clarification tool is literally `AskUserQuestion` with Claude's - // questions/options input shape, so Orca's question card renders it unchanged. - const isUserInputTool = isAskUserQuestionTool(toolName) +type ZCodeTurn = { + stateName: 'working' | 'waiting' | 'done' + /** Only SessionStart lands a boundary row, and only for an idle source. */ + sessionBoundary?: true +} - let stateName: 'working' | 'waiting' | 'done' | null = null - let sessionBoundary = false +/** What one ZCode lifecycle event says about the pane, or null when it says nothing. */ +function readZCodeTurn(eventName: unknown, hookPayload: Record): 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'] - if (typeof source !== 'string' || !ZCODE_IDLE_SESSION_START_SOURCES.has(source)) { - return null - } - stateName = 'done' - sessionBoundary = true - break + return typeof source === 'string' && ZCODE_IDLE_SESSION_START_SOURCES.has(source) + ? { stateName: 'done', sessionBoundary: true } + : null } case 'UserPromptSubmit': case 'PostToolUse': case 'PostToolUseFailure': - stateName = 'working' - break + return { stateName: 'working' } case 'PreToolUse': - stateName = isUserInputTool ? 'waiting' : 'working' - break + // 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': - // 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. - stateName = 'waiting' - break + return { stateName: 'waiting' } case 'Stop': - stateName = 'done' - break + return { stateName: 'done' } default: return null } +} +export function normalizeZCodeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): 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: isNewTurnEvent('zcode', eventName) } + { resetOnNewTurn } ) - const interrupted = - eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined - return normalizeAgentStatusPayload({ - state: stateName, - prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('zcode', eventName) - }), + 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 ? { sessionBoundary: true } : {}), - interrupted + sessionBoundary: turn.sessionBoundary, + interrupted: eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined }) } From be38772fb2b14beaf1f648e75b341d2733718267 Mon Sep 17 00:00:00 2001 From: Neil Date: Wed, 23 Sep 2026 16:06:29 -0700 Subject: [PATCH 4/5] =?UTF-8?q?fix(zcode):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20keep=20unmanaged=20event=20keys,=20correct=20comment,=20de-d?= =?UTF-8?q?upe=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `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. --- src/main/zcode/hook-service.test.ts | 18 ++++++++++++++++++ src/main/zcode/hook-settings.ts | 8 ++++++-- .../provider-event-routing.ts | 4 ++-- 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/main/zcode/hook-service.test.ts b/src/main/zcode/hook-service.test.ts index ad53e53a808..0c7d18f4514 100644 --- a/src/main/zcode/hook-service.test.ts +++ b/src/main/zcode/hook-service.test.ts @@ -126,6 +126,24 @@ describe('ZCodeHookService', () => { 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() diff --git a/src/main/zcode/hook-settings.ts b/src/main/zcode/hook-settings.ts index 673e0df159c..23d5819d114 100644 --- a/src/main/zcode/hook-settings.ts +++ b/src/main/zcode/hook-settings.ts @@ -152,9 +152,13 @@ export function removeZCodeManagedHooks( } const definitions = readEventDefinitions(nextEvents, eventName) const cleaned = removeManagedCommands(definitions, isManagedCommand) - if (JSON.stringify(cleaned) !== JSON.stringify(definitions)) { - changed = true + // 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 { diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index c6e6af7be4d..f9dc1e6d5d7 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -36,8 +36,8 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo // Muse uses Claude-compatible lifecycle events. return eventName === 'UserPromptSubmit' case 'zcode': - // Why: ZCode's SessionStart lands an idle boundary row, and its own `compact` source is - // filtered upstream, so UserPromptSubmit is the only real new-turn boundary left. + // 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' From 67ec117381518d4e645c732ecb7ee4dd6e511af3 Mon Sep 17 00:00:00 2001 From: Neil Date: Thu, 24 Sep 2026 13:28:53 -0700 Subject: [PATCH 5/5] 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 --- docs/site/content/docs/agents/supported.mdx | 22 ++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/site/content/docs/agents/supported.mdx b/docs/site/content/docs/agents/supported.mdx index 052eea085f3..6532e04fb58 100644 --- a/docs/site/content/docs/agents/supported.mdx +++ b/docs/site/content/docs/agents/supported.mdx @@ -48,7 +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 | [Z.ai](https://zcode.z.ai/en/docs) | +| 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) | @@ -61,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.