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..6532e04fb58 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; needs a `zcode` CLI that ships the TUI (see note below) | [Z.ai](https://zcode.z.ai/en/docs) | | Continue | Auto-setup | [Continue](https://docs.continue.dev/guides/cli) | | Cursor CLI | Deep integration | [Cursor](https://cursor.com/cli) | | Devin | Auto-setup | [Devin](https://devin.ai/cli) | @@ -60,3 +61,23 @@ To restore prompts for one agent only, edit that agent's default arguments or en | Hermes | Auto-setup | [Nous](https://hermes-agent.nousresearch.com/docs/) | | OpenClaw | Auto-setup | [OpenClaw](https://github.com/openclaw/openclaw) | | Trae | Auto-setup via `traecli` (TRAE CN CLI) | [Trae](https://www.trae.ai/) | + +## ZCode: pick a CLI that ships the TUI + +Orca drives ZCode through the `zcode` terminal CLI, so `zcode` on your `PATH` has to be a build that +includes ZCode's TUI. + +The ZCode **desktop app** bundles the agent runtime without it. Pointing `zcode` at +`/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs` starts, then fails to open a session with: + +``` +Cannot find package '@zcode/tui' imported from .../zcode.cjs +``` + +Orca's hooks still install correctly in that state, so the symptom looks like a broken integration when +it is really a CLI that cannot render a session. Use a `zcode` that carries the TUI — build the CLI from +[`zai-org/ZCode`](https://github.com/zai-org/ZCode) (`apps/zcode-cli`), or install a distribution that +packages the runtime together with the TUI. + +Check what you have with `zcode --version`, and confirm `zcode` opens a session outside Orca before +reporting a harness problem. 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..1448667e54a 100644 --- a/src/cli/specs/orchestration-worker-specs.ts +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -34,8 +34,8 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ notes: [ 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', 'When reusing --terminal, pass --worktree for that terminal; current means the coordinator worktree.', - '--agent takes an Orca agent id enabled on the worker server, such as claude, codex, cursor, antigravity, muse, opencode, or opencode2.', - '--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. Neither can combine with --terminal. Other agents, including opencode, launch with the model from their own config.', + '--agent takes an Orca agent id enabled on the worker server, such as claude, codex, cursor, antigravity, muse, zcode, opencode, or opencode2.', + '--model supports Claude, Codex, Cursor, Antigravity, and Muse opaque provider model ids; --effort requires --model. Neither can combine with --terminal. Other agents, including opencode and zcode, launch with the model from their own config.', 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', "How the worker runs follows the user's own setting for new agent tabs; there is no flag for it and no caller needs to ask. A dispatch the setting cannot apply to still starts, so the placement, agent, and launch options passed here are always the ones honoured.", 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..49e1dfd0e2b --- /dev/null +++ b/src/main/zcode/hook-config-json.ts @@ -0,0 +1,79 @@ +import { readFileSync } from 'node:fs' +import { applyEdits, modify, parse as parseJsonc, type ParseError } from 'jsonc-parser' +import { isDefinitiveAbsence } from '../../shared/definitive-filesystem-absence' +import { isPlainObject } from '../agent-hooks/installer-utils' +import { isZCodeHooksEnabled, readZCodeEventMap, type ZCodeConfig } from './hook-settings' + +export type ZCodeConfigSource = { + text: string | null + config: ZCodeConfig +} + +export function parseZCodeConfigText(text: string, diagnosticName: string): ZCodeConfig | null { + const errors: ParseError[] = [] + const parsed = parseJsonc(text, errors) + if (errors.length > 0) { + console.warn( + `Could not parse ${diagnosticName}: ${errors.map((e) => `offset ${e.offset} length ${e.length}`).join(', ')}` + ) + return null + } + if (parsed === undefined) { + return {} + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: isPlainObject just proved this is a plain object; ZCodeConfig only adds optional keys over its index signature. + return isPlainObject(parsed) ? (parsed as ZCodeConfig) : null +} + +/** Original file text alongside its parsed form, so a write can edit the text in place. */ +export function readZCodeConfigSource(configPath: string): ZCodeConfigSource | null { + let text: string + try { + text = readFileSync(configPath, 'utf-8') + } catch (error) { + // Why: only a definitive "no such file" is a fresh install; an EACCES/EIO must not + // be mistaken for one and overwrite the user's config with a stub. + return isDefinitiveAbsence(error) ? { text: null, config: {} } : null + } + const config = parseZCodeConfigText(text, 'ZCode config.json') + return config === null ? null : { text, config } +} + +const JSON_EDIT_FORMATTING = { formattingOptions: { insertSpaces: true, tabSize: 2 } } as const + +/** Set one path in the JSON text; `undefined` removes the key. */ +function editJsonPath(text: string, path: readonly string[], value: unknown): string { + return applyEdits(text, modify(text, [...path], value, JSON_EDIT_FORMATTING)) +} + +/** + * Serialize by editing the original text one hook event at a time, so the user's key order + * and indentation survive. A parse -> JSON.stringify round trip would reformat the whole + * file. (ZCode's loader is a strict `JSON.parse`, so there are no comments to preserve.) + */ +export function serializeZCodeConfig(originalText: string | null, nextConfig: ZCodeConfig): string { + if (originalText === null) { + return `${JSON.stringify(nextConfig, null, 2)}\n` + } + + const previous = parseZCodeConfigText(originalText, 'ZCode config.json') ?? {} + const previousEvents = readZCodeEventMap(previous) + const nextEvents = readZCodeEventMap(nextConfig) + + let text = originalText + const nextEnabled = isZCodeHooksEnabled(nextConfig) + if (isZCodeHooksEnabled(previous) !== nextEnabled) { + text = editJsonPath(text, ['hooks', 'enabled'], nextEnabled) + } + // Why: touch only the events that actually changed, so the user's key order and + // indentation around their own untouched hook entries stay put. + for (const eventName of new Set([...Object.keys(previousEvents), ...Object.keys(nextEvents)])) { + const nextValue = nextEvents[eventName] + if (JSON.stringify(previousEvents[eventName]) === JSON.stringify(nextValue)) { + continue + } + // `undefined` removes the key, which is how remove() drops an emptied event. + text = editJsonPath(text, ['hooks', 'events', eventName], nextValue) + } + return text +} diff --git a/src/main/zcode/hook-service.test.ts b/src/main/zcode/hook-service.test.ts new file mode 100644 index 00000000000..0c7d18f4514 --- /dev/null +++ b/src/main/zcode/hook-service.test.ts @@ -0,0 +1,173 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeOs from 'node:os' + +const hoisted = vi.hoisted(() => ({ home: '' })) +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + 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('leaves an unrelated empty event key alone while removing its own', () => { + const configPath = getZCodeConfigPath() + mkdirSync(join(hoisted.home, '.zcode', 'cli'), { recursive: true }) + // Why: `Notification` is not an event Orca manages, and an empty list is a legitimate + // thing for a user to have written. Removing Orca's hooks must not take it with them. + writeFileSync( + configPath, + JSON.stringify({ hooks: { enabled: true, events: { Notification: [] } } }) + ) + zcodeHookService.install() + zcodeHookService.remove() + const config = readConfig() + expect(config.hooks?.events?.Notification).toEqual([]) + for (const event of ZCODE_HOOK_EVENTS) { + expect(config.hooks?.events?.[event]).toBeUndefined() + } + }) + + it('reports partial when the managed events are present but hooks are disabled', () => { + zcodeHookService.install() + const configPath = getZCodeConfigPath() + const config: ZCodeConfigFile = JSON.parse(readFileSync(configPath, 'utf-8')) + if (config.hooks) { + config.hooks.enabled = false + } + writeFileSync(configPath, JSON.stringify(config, null, 2)) + expect(zcodeHookService.getStatus()).toMatchObject({ + state: 'partial', + managedHooksPresent: true, + detail: expect.stringContaining('hooks.enabled') + }) + }) + + it('writes an executable managed hook script that posts to the ZCode endpoint', () => { + zcodeHookService.install() + const scriptPath = join( + hoisted.home, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'zcode-hook.cmd' : 'zcode-hook.sh' + ) + expect(existsSync(scriptPath)).toBe(true) + expect(readFileSync(scriptPath, 'utf-8')).toContain('/hook/zcode') + }) +}) diff --git a/src/main/zcode/hook-service.ts b/src/main/zcode/hook-service.ts new file mode 100644 index 00000000000..3f60e2d3df8 --- /dev/null +++ b/src/main/zcode/hook-service.ts @@ -0,0 +1,191 @@ +import type { SFTPWrapper } from 'ssh2' +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + buildWindowsAgentHookCurlPostCommand, + writeHooksJson, + writeManagedScript +} from '../agent-hooks/installer-utils' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' +import { + readTextFileRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildPosixHookSpoolLines, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' +import { buildPosixAgentHookPostCommand } from '../agent-hooks/hook-post-command' +import { + applyZCodeManagedHooks, + getZCodeConfigPath, + getZCodeManagedCommand, + getZCodeManagedScriptFileName, + getZCodeManagedScriptPath, + getZCodePosixManagedScriptFileName, + getZCodeRemoteConfigPath, + getZCodeRemoteManagedCommand, + isZCodeHooksEnabled, + readManagedZCodeHookEvents, + removeZCodeManagedHooks, + ZCODE_HOOK_EVENTS, + type ZCodeConfig +} from './hook-settings' +import { + parseZCodeConfigText, + readZCodeConfigSource, + serializeZCodeConfig +} from './hook-config-json' + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + // Why: endpoint file holds the live port/token; a PTY that outlives an Orca restart carries stale env, so `call` it to refresh (else PTY env). + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + buildWindowsAgentHookCurlPostCommand('zcode'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + ...buildPosixHookPayloadCapture(), + ...buildPosixHookSpoolLines('zcode'), + // Why: endpoint file holds the live port/token; PTYs that outlive an Orca restart carry stale env, so source it to reach the new server (else PTY env). + // Why: silence the `.` builtin (2>/dev/null + `|| :`) so a TOCTOU race can't leak shell parse errors into agent transcripts (fail-open). + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' spool_hook_event', + ' exit 0', + 'fi', + ...buildPosixAgentHookPostCommand('zcode').map((line, index, lines) => + index === lines.length - 1 ? `${line} >/dev/null 2>&1 || spool_hook_event` : line + ), + 'exit 0', + '' + ].join('\n') +} + +function zcodeHookError(configPath: string, detail: string): AgentHookInstallStatus { + return { agent: 'zcode', state: 'error', configPath, managedHooksPresent: false, detail } +} + +function buildStatus( + config: ZCodeConfig, + configPath: string, + scriptFileName: string +): AgentHookInstallStatus { + const base = { agent: 'zcode' as const, configPath } + const present = readManagedZCodeHookEvents(config, scriptFileName) + const missing = ZCODE_HOOK_EVENTS.filter((event) => !present.has(event)) + // Why: ZCode ships `hooks.enabled: false` by default, so registered events alone prove + // nothing — an install that left the flag off would never deliver a single event. + const hooksEnabled = isZCodeHooksEnabled(config) + + if (missing.length === 0 && hooksEnabled) { + return { ...base, state: 'installed', managedHooksPresent: true, detail: null } + } + if (present.size === 0) { + return { ...base, state: 'not_installed', managedHooksPresent: false, detail: null } + } + const reasons = [ + missing.length > 0 ? `events: ${missing.join(', ')}` : '', + hooksEnabled ? '' : '`hooks.enabled` is false, so ZCode runs no hooks' + ].filter(Boolean) + return { ...base, state: 'partial', managedHooksPresent: true, detail: reasons.join('; ') } +} + +export class ZCodeHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getZCodeManagedScriptPath(), getManagedScript()) + } + + getStatus(): AgentHookInstallStatus { + const configPath = getZCodeConfigPath() + const source = readZCodeConfigSource(configPath) + if (!source) { + return zcodeHookError(configPath, 'Could not read ZCode config.json') + } + return buildStatus(source.config, configPath, getZCodeManagedScriptFileName()) + } + + install(): AgentHookInstallStatus { + const configPath = getZCodeConfigPath() + const scriptPath = getZCodeManagedScriptPath() + const source = readZCodeConfigSource(configPath) + if (!source) { + return zcodeHookError(configPath, 'Could not read ZCode config.json') + } + + const scriptFileName = getZCodeManagedScriptFileName() + const command = getZCodeManagedCommand(scriptPath) + const nextConfig = applyZCodeManagedHooks(source.config, command, scriptFileName) + // Why: write the script first so config.json never points at a missing file. + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson(configPath, nextConfig, { + serialized: serializeZCodeConfig(source.text, nextConfig) + }) + return this.getStatus() + } + + // Install the ZCode hook on an SSH execution host, where the shell contract is POSIX. + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const remoteConfigPath = getZCodeRemoteConfigPath(remoteHome) + // Why: remote-Windows is out of scope; process.platform describes the local box, not the host. + const remoteScriptFileName = getZCodePosixManagedScriptFileName() + const remoteScriptPath = `${remoteHome.replace(/\/$/, '')}/.orca/agent-hooks/${remoteScriptFileName}` + try { + const body = await readTextFileRemote(sftp, remoteConfigPath) + const config = body === null ? {} : parseZCodeConfigText(body, 'remote ZCode config.json') + if (!config) { + return zcodeHookError(remoteConfigPath, 'Could not parse remote ZCode config.json') + } + + const command = getZCodeRemoteManagedCommand(remoteScriptPath) + const nextConfig = applyZCodeManagedHooks(config, command, remoteScriptFileName) + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, nextConfig, { + serialized: serializeZCodeConfig(body, nextConfig) + }) + + return { + agent: 'zcode', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return zcodeHookError(remoteConfigPath, err instanceof Error ? err.message : String(err)) + } + } + + remove(): AgentHookInstallStatus { + const configPath = getZCodeConfigPath() + const source = readZCodeConfigSource(configPath) + if (!source) { + return zcodeHookError(configPath, 'Could not read ZCode config.json') + } + const { config: nextConfig, changed } = removeZCodeManagedHooks( + source.config, + getZCodeManagedScriptFileName() + ) + if (changed) { + writeHooksJson(configPath, nextConfig, { + serialized: serializeZCodeConfig(source.text, nextConfig) + }) + } + return this.getStatus() + } +} + +export const zcodeHookService = new ZCodeHookService() diff --git a/src/main/zcode/hook-settings.ts b/src/main/zcode/hook-settings.ts new file mode 100644 index 00000000000..23d5819d114 --- /dev/null +++ b/src/main/zcode/hook-settings.ts @@ -0,0 +1,195 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import { + buildManagedCommandHook, + createManagedCommandMatcher, + getSharedManagedScriptPath, + hookDefinitionHasManagedCommand, + isPlainObject, + removeManagedCommands, + wrapPosixHookCommand, + wrapWindowsCmdHookCommand, + type HookDefinition +} from '../agent-hooks/installer-utils' + +const ZCODE_SCRIPT_BASE = 'zcode-hook' + +/** + * Every lifecycle event ZCode's hook runner can fire (`HookEventName` in + * `packages/contracts/src/hooks/index.ts`). Matchers are omitted on purpose: + * ZCode's `matchesAnyHookMatcher` treats an absent matcher as "every tool", + * and Claude's `"*"` is not a valid ZCode matcher. + */ +export const ZCODE_HOOK_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PermissionRequest', + 'PostToolUse', + 'PostToolUseFailure', + 'Stop' +] as const + +/** + * ZCode's hook block, nested one level deeper than Claude's (`hooks.events.`). + * + * `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 +} + +export function getZCodeConfigPath(): string { + // Why: ZCode resolves `~/.zcode/cli` from `homedir()` on every platform + // (`packages/adapters/src/config/file-config.adapter.ts`) — no APPDATA/XDG branch. + return join(homedir(), '.zcode', 'cli', 'config.json') +} + +export function getZCodeRemoteConfigPath(remoteHome: string): string { + return `${remoteHome.replace(/\/$/, '')}/.zcode/cli/config.json` +} + +export function getZCodeManagedScriptFileName(): string { + return process.platform === 'win32' ? `${ZCODE_SCRIPT_BASE}.cmd` : `${ZCODE_SCRIPT_BASE}.sh` +} + +export function getZCodePosixManagedScriptFileName(): string { + return `${ZCODE_SCRIPT_BASE}.sh` +} + +export function getZCodeManagedScriptPath(): string { + return getSharedManagedScriptPath(getZCodeManagedScriptFileName()) +} + +export function getZCodeManagedCommand(scriptPath: string): string { + if (process.platform === 'win32') { + // Why: ZCode spawns a `type: "command"` hook through its own shell resolver, so keep the + // bare directly-spawnable .cmd on the safe path and fall back to the encoded form otherwise. + return wrapWindowsCmdHookCommand(scriptPath) + } + return wrapPosixHookCommand(scriptPath) +} + +export function getZCodeRemoteManagedCommand(scriptPath: string): string { + return wrapPosixHookCommand(scriptPath) +} + +function getZCodeManagedCommandMatcher( + scriptFileName = getZCodeManagedScriptFileName() +): (command: string | undefined) => boolean { + return createManagedCommandMatcher(scriptFileName) +} + +/** The `hooks.events` block as a plain lookup, or empty when absent or malformed. */ +export function readZCodeEventMap(config: ZCodeConfig): Record { + 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 = { ...readZCodeEventMap(config) } + const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName) + + for (const eventName of ZCODE_HOOK_EVENTS) { + const current = readEventDefinitions(nextEvents, eventName) + const cleaned = removeManagedCommands(current, isManagedCommand) + nextEvents[eventName] = [...cleaned, { hooks: [buildManagedCommandHook(command)] }] + } + + return { + ...config, + hooks: { + ...config.hooks, + // Why: ZCode's DefaultRuntimeConfig ships `hooks.enabled: false`, so a hook block alone + // fires nothing — this flag is what the "ZCode hooks never run" reports were missing. + enabled: true, + events: nextEvents + } + } +} + +export function removeZCodeManagedHooks( + config: ZCodeConfig, + scriptFileName = getZCodeManagedScriptFileName() +): { config: ZCodeConfig; changed: boolean } { + const events = readZCodeEventMap(config) + const nextEvents = { ...events } + const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName) + let changed = false + + for (const [eventName, value] of Object.entries(nextEvents)) { + // Why: leave a non-array value exactly as the user wrote it — emptying it below would + // delete a key Orca never owned. + if (!Array.isArray(value)) { + continue + } + const definitions = readEventDefinitions(nextEvents, eventName) + const cleaned = removeManagedCommands(definitions, isManagedCommand) + // Why: only touch an event Orca actually owned something in. Without this, an unrelated + // empty entry the user wrote (`"Notification": []`) was deleted as collateral whenever a + // managed hook elsewhere made the write happen. + if (JSON.stringify(cleaned) === JSON.stringify(definitions)) { + continue + } + changed = true + if (cleaned.length === 0) { + delete nextEvents[eventName] + } else { + nextEvents[eventName] = cleaned + } + } + + if (!changed) { + return { config, changed: false } + } + // Why: leave `hooks.enabled` alone on remove — the user may run their own hooks, and + // flipping it back to false would silently disable those too. + return { config: { ...config, hooks: { ...config.hooks, events: nextEvents } }, changed: true } +} + +/** Events whose managed command is currently registered in the user's config. */ +export function readManagedZCodeHookEvents( + config: ZCodeConfig, + scriptFileName = getZCodeManagedScriptFileName() +): Set { + const isManagedCommand = getZCodeManagedCommandMatcher(scriptFileName) + const events = readZCodeEventMap(config) + return new Set( + ZCODE_HOOK_EVENTS.filter((eventName) => + readEventDefinitions(events, eventName).some((definition) => + hookDefinitionHasManagedCommand(definition, isManagedCommand) + ) + ) + ) +} + +export function isZCodeHooksEnabled(config: ZCodeConfig): boolean { + return config.hooks?.enabled === true +} 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..f9dc1e6d5d7 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: matches Codex/Claude — SessionStart lands an idle boundary row and drops stale + // tool/prompt caches, while UserPromptSubmit is the actual turn boundary. + return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' case 'codex': return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' case 'gemini': @@ -137,6 +141,9 @@ export function extractToolFields( // Muse uses Claude-compatible tool fields. // falls through case 'muse': + // Why: ZCode's hook runner writes Claude's `tool_name`/`tool_input`/`tool_response` aliases. + // falls through + case 'zcode': return extractClaudeToolFields(eventName, hookPayload) case 'codex': return extractCodexToolFields(eventName, hookPayload) 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..b80e71e7a22 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/zcode-events.ts @@ -0,0 +1,102 @@ +import { isAskUserQuestionTool } from '../../agent-question-answered-intent' +import { + normalizeAgentStatusPayload, + type ParsedAgentStatusPayload +} from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { + resolvePrompt, + resolveToolState, + shouldIgnoreCompactContinuationUserPromptSubmit +} from '../prompt-fields' +import { extractToolFields, isNewTurnEvent } from '../provider-event-routing' +import { readString } from '../tool-input-preview' + +// Why: ZCode's own lifecycle events are camelCase, but its hook runner writes a +// Claude-compatible stdin alias set (`hook_event_name`, `tool_name`, `tool_input`, +// `transcript_path`, `last_assistant_message`) alongside them — see ZCode's +// `packages/core/src/hooks/configured-runner-input.ts`. Orca reads the aliases, so the +// Claude tool-field extractor applies verbatim; only the agent identity differs. +const ZCODE_IDLE_SESSION_START_SOURCES: ReadonlySet = new Set([ + 'startup', + 'resume', + 'clear' +]) + +type ZCodeTurn = { + stateName: 'working' | 'waiting' | 'done' + /** Only SessionStart lands a boundary row, and only for an idle source. */ + sessionBoundary?: true +} + +/** What one ZCode lifecycle event says about the pane, or null when it says nothing. */ +function readZCodeTurn(eventName: unknown, hookPayload: Record): ZCodeTurn | null { + switch (eventName) { + case 'SessionStart': { + // Why: land a resumed/started session as an idle boundary row, not a phantom spinner; + // `compact` fires mid-turn, so anything outside the idle allowlist is dropped. + const source = hookPayload['source'] + return typeof source === 'string' && ZCODE_IDLE_SESSION_START_SOURCES.has(source) + ? { stateName: 'done', sessionBoundary: true } + : null + } + case 'UserPromptSubmit': + case 'PostToolUse': + case 'PostToolUseFailure': + return { stateName: 'working' } + case 'PreToolUse': + // Why: ZCode's clarification tool is literally `AskUserQuestion` with Claude's + // questions/options input shape, so Orca's question card renders it unchanged. + return { + stateName: isAskUserQuestionTool(readString(hookPayload, 'tool_name')) + ? 'waiting' + : 'working' + } + // Why: ZCode fires this only once the approval card is already on screen and racing the + // user's answer (`packages/core/src/tool/executor/permission-flow.ts`), never for an + // auto-approved call — so it is proof the pane is blocked on a human. + case 'PermissionRequest': + return { stateName: 'waiting' } + case 'Stop': + return { stateName: 'done' } + default: + return null + } +} + +export function normalizeZCodeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { + return null + } + const turn = readZCodeTurn(eventName, hookPayload) + if (!turn) { + return null + } + + const resetOnNewTurn = isNewTurnEvent('zcode', eventName) + const snapshot = resolveToolState( + state, + paneKey, + extractToolFields('zcode', eventName, hookPayload), + { resetOnNewTurn } + ) + + return normalizeAgentStatusPayload({ + state: turn.stateName, + prompt: resolvePrompt(state, paneKey, promptText, { resetOnNewTurn }), + agentType: 'zcode', + toolName: snapshot.toolName, + toolInput: snapshot.toolInput, + interactivePrompt: snapshot.interactivePrompt, + lastAssistantMessage: snapshot.lastAssistantMessage, + lastAssistantMessageIsToolOutput: snapshot.lastAssistantMessageIsToolOutput, + sessionBoundary: turn.sessionBoundary, + interrupted: eventName === 'Stop' && hookPayload['is_interrupt'] === true ? true : undefined + }) +} 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 00000000000..d49f58d53fd Binary files /dev/null and b/src/shared/agent-icons/zcode.png differ 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-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 +} 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') + }) +})