diff --git a/README.md b/README.md index b0ef0741e4b3..71dc2de14c33 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index 0a1ca5390ac5..29a96ab40019 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -196,6 +196,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index ddbf633b3211..4263cd3444f4 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -202,6 +202,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i Logo Droid Droid   Logo Kilocode Kilocode   Logo Kimi Kimi   + ZCode logo ZCode   Logo Kiro Kiro   Logo Mistral Vibe Mistral Vibe   Logo Qwen Code Qwen Code   diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index b5783322291f..93a6c92f3f46 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -196,6 +196,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 4ee6beaeedc9..b302f2734e79 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -196,6 +196,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index d4017883d3ed..002f160b7722 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -198,6 +198,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca. Logotipo do Droid Droid   Logotipo do Kilocode Kilocode   Logotipo do Kimi Kimi   + ZCode logo ZCode   Logotipo do Kiro Kiro   Logotipo do Mistral Vibe Mistral Vibe   Logotipo do Qwen Code Qwen Code   diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 0a68d205665c..fbb7c40d006b 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -196,6 +196,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智 Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts index 3870ed244fed..4653dfe4f104 100644 --- a/mobile/src/tasks/mobile-tui-agents.ts +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -38,7 +38,8 @@ export const MOBILE_TUI_AGENT_AUTO_PICK_ORDER = [ 'rovo', 'hermes', 'devin', - 'openclaw' + 'openclaw', + 'zcode' ] as const satisfies readonly TuiAgent[] export const MOBILE_TUI_AGENT_LABELS: Record = { @@ -76,7 +77,8 @@ export const MOBILE_TUI_AGENT_LABELS: Record = { rovo: 'Rovo Dev', hermes: 'Hermes', devin: 'Devin', - openclaw: 'OpenClaw' + openclaw: 'OpenClaw', + zcode: 'ZCode' } export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> = { @@ -109,7 +111,8 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> rovo: 'atlassian.com', hermes: 'nousresearch.com', devin: 'devin.ai', - openclaw: 'openclaw.ai' + openclaw: 'openclaw.ai', + zcode: 'z.ai' } export function isMobileTuiAgent(value: unknown): value is TuiAgent { diff --git a/src/main/agent-hooks/agent-hook-memory-sftp.test-fixture.ts b/src/main/agent-hooks/agent-hook-memory-sftp.test-fixture.ts index 1dfaabf6c68a..056c1774ee84 100644 --- a/src/main/agent-hooks/agent-hook-memory-sftp.test-fixture.ts +++ b/src/main/agent-hooks/agent-hook-memory-sftp.test-fixture.ts @@ -4,6 +4,8 @@ export type AgentHookMemoryFileSystem = { files: Map dirs: Set modes: Map + /** Why: tests inject rename failures (e.g. Codex trust write) without a real SFTP. */ + failRenameTo: Set } export function createAgentHookMemorySftp(initialFiles: Record = {}): { @@ -13,7 +15,8 @@ export function createAgentHookMemorySftp(initialFiles: Record = const fs: AgentHookMemoryFileSystem = { files: new Map(Object.entries(initialFiles)), dirs: new Set(['/']), - modes: new Map() + modes: new Map(), + failRenameTo: new Set() } const missing = (path: string): { code: number; message: string } => ({ code: 2, @@ -41,6 +44,10 @@ export function createAgentHookMemorySftp(initialFiles: Record = done(null) }, rename: (source: string, target: string, done: (error: unknown) => void) => { + if (fs.failRenameTo.has(target)) { + done({ code: 4, message: `rename failed ${target}` }) + return + } const content = fs.files.get(source) if (content === undefined) { done(missing(source)) diff --git a/src/main/agent-hooks/managed-agent-hook-controls.ts b/src/main/agent-hooks/managed-agent-hook-controls.ts index e3070afed3e2..943a4600fceb 100644 --- a/src/main/agent-hooks/managed-agent-hook-controls.ts +++ b/src/main/agent-hooks/managed-agent-hook-controls.ts @@ -15,6 +15,7 @@ import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' +import { zcodeHookService } from '../zcode/hook-service' export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => void] type ManagedHookRemover = readonly [HookInstallAgent, () => AgentHookInstallStatus] @@ -34,7 +35,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['copilot', () => copilotHookService.install()], ['hermes', () => hermesHookService.install()], ['devin', () => devinHookService.install()], - ['kimi', () => kimiHookService.install()] + ['kimi', () => kimiHookService.install()], + ['zcode', () => zcodeHookService.install()] ] const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ @@ -51,7 +53,8 @@ const LOCAL_MANAGED_HOOK_REMOVERS: readonly ManagedHookRemover[] = [ ['copilot', () => copilotHookService.remove()], ['hermes', () => hermesHookService.remove()], ['devin', () => devinHookService.remove()], - ['kimi', () => kimiHookService.remove()] + ['kimi', () => kimiHookService.remove()], + ['zcode', () => zcodeHookService.remove()] ] const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [ @@ -68,7 +71,8 @@ const LOCAL_MANAGED_HOOK_STATUS_READERS: readonly ManagedHookStatusReader[] = [ ['copilot', () => copilotHookService.getStatus()], ['hermes', () => hermesHookService.getStatus()], ['devin', () => devinHookService.getStatus()], - ['kimi', () => kimiHookService.getStatus()] + ['kimi', () => kimiHookService.getStatus()], + ['zcode', () => zcodeHookService.getStatus()] ] export function isAgentStatusHooksEnabled( 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 89124fe86a88..a3d75bbcd63d 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -20,106 +20,17 @@ 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 { ZcodeHookService, zcodeHookService } from '../zcode/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls' import { installRemoteManagedAgentHooks, REMOTE_MANAGED_HOOK_INSTALLER_AGENTS } from './remote-managed-hook-installers' +import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture' -type FakeFs = { - files: Map - dirs: Set - modes: Map - failRenameTo: Set -} - -function createFakeSftp(initialFiles: Record = {}): { - sftp: SFTPWrapper - fs: FakeFs -} { - const fs: FakeFs = { - files: new Map(Object.entries(initialFiles)), - dirs: new Set(['/']), - modes: new Map(), - failRenameTo: new Set() - } - const noEntryError = (path: string): { code: number; message: string } => ({ - code: 2, - message: `ENOENT ${path}` - }) - const fakeStats = (mode: number): { mode: number } => ({ mode }) - - const sftp = { - readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => { - const v = fs.files.get(path) - if (v === undefined) { - cb(noEntryError(path)) - return - } - cb(null, v) - }, - writeFile: ( - path: string, - content: string, - options: string | { mode?: number }, - cb: (err: unknown) => void - ): void => { - fs.files.set(path, content) - if (typeof options !== 'string' && options.mode !== undefined) { - fs.modes.set(path, options.mode) - } - cb(null) - }, - rename: (src: string, dst: string, cb: (err: unknown) => void): void => { - if (fs.failRenameTo.has(dst)) { - cb({ code: 4, message: `rename failed ${dst}` }) - return - } - const v = fs.files.get(src) - if (v === undefined) { - cb(noEntryError(src)) - return - } - fs.files.set(dst, v) - fs.files.delete(src) - const mode = fs.modes.get(src) - if (mode !== undefined) { - fs.modes.set(dst, mode) - fs.modes.delete(src) - } - cb(null) - }, - unlink: (path: string, cb: (err: unknown) => void): void => { - fs.files.delete(path) - fs.modes.delete(path) - cb(null) - }, - chmod: (path: string, mode: number, cb: (err: unknown) => void): void => { - fs.modes.set(path, mode) - cb(null) - }, - stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => { - if (!fs.files.has(path)) { - cb(noEntryError(path)) - return - } - cb(null, fakeStats(fs.modes.get(path) ?? 0o100644)) - }, - readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => { - if (fs.dirs.has(path)) { - cb(null, []) - return - } - cb(noEntryError(path)) - }, - mkdir: (path: string, cb: (err: unknown) => void): void => { - fs.dirs.add(path) - cb(null) - } - } as unknown as SFTPWrapper - return { sftp, fs } -} +// Why: local alias keeps existing call sites short; fixture is shared with other SFTP hook tests. +const createFakeSftp = createAgentHookMemorySftp describe('remote hook service installers', () => { it('always writes POSIX scripts for SSH remotes even from a Windows host', async () => { @@ -499,6 +410,26 @@ describe('remote hook service installers', () => { expect(fs.files.get('/home/dev/.orca/agent-hooks/kimi-hook.sh')).toContain('/hook/kimi') }) + it('installs remote ZCode hooks into ~/.zcode/cli/config.json with hooks.enabled', async () => { + const { sftp, fs } = createFakeSftp({ + '/home/dev/.zcode/cli/config.json': '{"theme":"dark","hooks":{"enabled":false}}' + }) + const status = await new ZcodeHookService().installRemote(sftp, '/home/dev') + expect(status.state).toBe('installed') + const config = JSON.parse(fs.files.get('/home/dev/.zcode/cli/config.json')!) as { + theme?: string + hooks?: { enabled?: boolean; events?: Record } + } + expect(config.theme).toBe('dark') + expect(config.hooks?.enabled).toBe(true) + expect(config.hooks?.events?.UserPromptSubmit).toBeDefined() + expect(config.hooks?.events?.Stop).toBeDefined() + expect(fs.files.get('/home/dev/.zcode/cli/config.json')).toContain( + '/home/dev/.orca/agent-hooks/zcode-hook.sh' + ) + expect(fs.files.get('/home/dev/.orca/agent-hooks/zcode-hook.sh')).toContain('/hook/zcode') + }) + it('does not overwrite malformed remote Devin JSONC', async () => { const original = '{"hooks": }' const { sftp, fs } = createFakeSftp({ @@ -687,7 +618,8 @@ describe('remote hook service installers', () => { ['copilot', copilotHookService], ['hermes', hermesHookService], ['devin', devinHookService], - ['kimi', kimiHookService] + ['kimi', kimiHookService], + ['zcode', zcodeHookService] ]) // Guard against a service silently missing from the map above as new agents land. diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index 7f9e97f2f9f3..93ac03c0469c 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 { openClaudeHookService } from '../openclaude/hook-service' +import { zcodeHookService } from '../zcode/hook-service' export type RemoteManagedHookInstallOptions = { /** Explicit CODEX_HOME dir for redirected runtimes (WSL managed runtime @@ -65,7 +66,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['droid', (sftp, remoteHome) => droidHookService.installRemote(sftp, remoteHome)], ['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)], ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)], - ['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)] + ['kimi', (sftp, remoteHome) => kimiHookService.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/ipc/agent-hooks.test.ts b/src/main/ipc/agent-hooks.test.ts index 48440713cd01..56d32740166e 100644 --- a/src/main/ipc/agent-hooks.test.ts +++ b/src/main/ipc/agent-hooks.test.ts @@ -102,6 +102,9 @@ vi.mock('../devin/hook-service', () => ({ vi.mock('../kimi/hook-service', () => ({ kimiHookService: { getStatus: vi.fn(() => ({ agent: 'kimi', state: 'absent' })) } })) +vi.mock('../zcode/hook-service', () => ({ + zcodeHookService: { getStatus: vi.fn(() => ({ agent: 'zcode', state: 'absent' })) } +})) beforeEach(() => { dropStatusEntry.mockReset() @@ -274,6 +277,17 @@ describe('agentHooks:kimiStatus IPC', () => { }) }) +describe('agentHooks:zcodeStatus IPC', () => { + it('returns ZCode hook installation status', async () => { + const { registerAgentHookHandlers } = await import('./agent-hooks') + registerAgentHookHandlers() + + const handler = handleHandlers.get('agentHooks:zcodeStatus') + expect(handler).toBeDefined() + expect(handler!({})).toEqual({ agent: 'zcode', state: 'absent' }) + }) +}) + describe('agentStatus:inferInterrupt IPC', () => { it('forwards valid inference requests to the hook server', async () => { inferInterrupt.mockReturnValue(true) diff --git a/src/main/ipc/agent-hooks.ts b/src/main/ipc/agent-hooks.ts index 5a8e3f69ae49..763ae82837da 100644 --- a/src/main/ipc/agent-hooks.ts +++ b/src/main/ipc/agent-hooks.ts @@ -26,6 +26,7 @@ import { hermesHookService } from '../hermes/hook-service' import { devinHookService } from '../devin/hook-service' import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' +import { zcodeHookService } from '../zcode/hook-service' import { registerAgentPaneAuthorityIpcHandlers } from './agent-pane-authority-ipc' import { createAgentPaneAuthorityOwnership } from './agent-pane-authority-ownership' import { @@ -38,6 +39,99 @@ type AgentHookHandlerDependencies = { getPtyIdForPaneKey?: (paneKey: string) => string | undefined } +// Why: channel name differs from agent id for camelCase IPC (openClaude/commandCode). +const AGENT_HOOK_STATUS_HANDLERS: readonly { + channel: string + agent: AgentHookInstallStatus['agent'] + getStatus: () => AgentHookInstallStatus +}[] = [ + { + channel: 'agentHooks:claudeStatus', + agent: 'claude', + getStatus: () => claudeHookService.getStatus() + }, + { + channel: 'agentHooks:openClaudeStatus', + agent: 'openclaude', + getStatus: () => openClaudeHookService.getStatus() + }, + { + channel: 'agentHooks:codexStatus', + agent: 'codex', + getStatus: () => codexHookService.getStatus() + }, + { + channel: 'agentHooks:geminiStatus', + agent: 'gemini', + getStatus: () => geminiHookService.getStatus() + }, + { + channel: 'agentHooks:antigravityStatus', + agent: 'antigravity', + getStatus: () => antigravityHookService.getStatus() + }, + { channel: 'agentHooks:ampStatus', agent: 'amp', getStatus: () => ampHookService.getStatus() }, + { + channel: 'agentHooks:cursorStatus', + agent: 'cursor', + getStatus: () => cursorHookService.getStatus() + }, + { + channel: 'agentHooks:droidStatus', + agent: 'droid', + getStatus: () => droidHookService.getStatus() + }, + { + channel: 'agentHooks:commandCodeStatus', + agent: 'command-code', + getStatus: () => commandCodeHookService.getStatus() + }, + { channel: 'agentHooks:grokStatus', agent: 'grok', getStatus: () => grokHookService.getStatus() }, + { + channel: 'agentHooks:copilotStatus', + agent: 'copilot', + getStatus: () => copilotHookService.getStatus() + }, + { + channel: 'agentHooks:hermesStatus', + agent: 'hermes', + getStatus: () => hermesHookService.getStatus() + }, + { + channel: 'agentHooks:devinStatus', + agent: 'devin', + getStatus: () => devinHookService.getStatus() + }, + { channel: 'agentHooks:kimiStatus', agent: 'kimi', getStatus: () => kimiHookService.getStatus() }, + { + channel: 'agentHooks:zcodeStatus', + agent: 'zcode', + getStatus: () => zcodeHookService.getStatus() + } +] + +function registerAgentHookStatusHandler( + channel: string, + agent: AgentHookInstallStatus['agent'], + getStatus: () => AgentHookInstallStatus +): void { + // Why: errors from getStatus() must be reported as state:'error' so the sidebar + // can render a coherent per-agent error row instead of an unhandled rejection. + ipcMain.handle(channel, (): AgentHookInstallStatus => { + try { + return getStatus() + } catch (err) { + return { + agent, + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + }) +} + // Why: install/remove are intentionally not exposed to the renderer. Orca // auto-installs managed hooks at app startup (see src/main/index.ts), so a // renderer-triggered remove would be silently reverted on the next launch @@ -52,20 +146,9 @@ export function registerAgentHookHandlers( // recreates the main window). Today the module-level `registered` guard in // register-core-handlers.ts prevents re-entry, but decoupling from that guard // future-proofs this file. - ipcMain.removeHandler('agentHooks:claudeStatus') - ipcMain.removeHandler('agentHooks:openClaudeStatus') - ipcMain.removeHandler('agentHooks:codexStatus') - ipcMain.removeHandler('agentHooks:geminiStatus') - ipcMain.removeHandler('agentHooks:antigravityStatus') - ipcMain.removeHandler('agentHooks:ampStatus') - ipcMain.removeHandler('agentHooks:cursorStatus') - ipcMain.removeHandler('agentHooks:droidStatus') - ipcMain.removeHandler('agentHooks:commandCodeStatus') - ipcMain.removeHandler('agentHooks:grokStatus') - ipcMain.removeHandler('agentHooks:copilotStatus') - ipcMain.removeHandler('agentHooks:hermesStatus') - ipcMain.removeHandler('agentHooks:devinStatus') - ipcMain.removeHandler('agentHooks:kimiStatus') + for (const { channel } of AGENT_HOOK_STATUS_HANDLERS) { + ipcMain.removeHandler(channel) + } ipcMain.removeHandler('agentStatus:getSnapshot') ipcMain.removeHandler('agentStatus:inferInterrupt') ipcMain.removeHandler('agentStatus:inferQuestionAnswered') @@ -134,191 +217,7 @@ export function registerAgentHookHandlers( (): MigrationUnsupportedPtyEntry[] => getMigrationUnsupportedPtySnapshot() ) - // Why: errors from getStatus() (fs permission denied, homedir resolution - // failure, etc.) must be reported inline via state:'error' so the sidebar can - // render a coherent per-agent error row. Letting the exception propagate out - // of the IPC handler surfaces as an unhandled renderer-side rejection, which - // defeats the AgentHookInstallStatus contract the UI relies on. - ipcMain.handle('agentHooks:claudeStatus', (): AgentHookInstallStatus => { - try { - return claudeHookService.getStatus() - } catch (err) { - return { - agent: 'claude', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:openClaudeStatus', (): AgentHookInstallStatus => { - try { - return openClaudeHookService.getStatus() - } catch (err) { - return { - agent: 'openclaude', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:codexStatus', (): AgentHookInstallStatus => { - try { - return codexHookService.getStatus() - } catch (err) { - return { - agent: 'codex', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:geminiStatus', (): AgentHookInstallStatus => { - try { - return geminiHookService.getStatus() - } catch (err) { - return { - agent: 'gemini', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:antigravityStatus', (): AgentHookInstallStatus => { - try { - return antigravityHookService.getStatus() - } catch (err) { - return { - agent: 'antigravity', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:ampStatus', (): AgentHookInstallStatus => { - try { - return ampHookService.getStatus() - } catch (err) { - return { - agent: 'amp', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:cursorStatus', (): AgentHookInstallStatus => { - try { - return cursorHookService.getStatus() - } catch (err) { - return { - agent: 'cursor', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:droidStatus', (): AgentHookInstallStatus => { - try { - return droidHookService.getStatus() - } catch (err) { - return { - agent: 'droid', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:commandCodeStatus', (): AgentHookInstallStatus => { - try { - return commandCodeHookService.getStatus() - } catch (err) { - return { - agent: 'command-code', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:grokStatus', (): AgentHookInstallStatus => { - try { - return grokHookService.getStatus() - } catch (err) { - return { - agent: 'grok', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:copilotStatus', (): AgentHookInstallStatus => { - try { - return copilotHookService.getStatus() - } catch (err) { - return { - agent: 'copilot', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:hermesStatus', (): AgentHookInstallStatus => { - try { - return hermesHookService.getStatus() - } catch (err) { - return { - agent: 'hermes', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:devinStatus', (): AgentHookInstallStatus => { - try { - return devinHookService.getStatus() - } catch (err) { - return { - agent: 'devin', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) - ipcMain.handle('agentHooks:kimiStatus', (): AgentHookInstallStatus => { - try { - return kimiHookService.getStatus() - } catch (err) { - return { - agent: 'kimi', - state: 'error', - configPath: '', - managedHooksPresent: false, - detail: err instanceof Error ? err.message : String(err) - } - } - }) + for (const { channel, agent, getStatus } of AGENT_HOOK_STATUS_HANDLERS) { + registerAgentHookStatusHandler(channel, agent, getStatus) + } } diff --git a/src/main/zcode/hook-service.test.ts b/src/main/zcode/hook-service.test.ts new file mode 100644 index 000000000000..5b080b70143b --- /dev/null +++ b/src/main/zcode/hook-service.test.ts @@ -0,0 +1,119 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ZcodeHookService } from './hook-service' +import { ZCODE_HOOK_EVENTS } from './zcode-hook-config' + +// Why: getSharedManagedScriptPath() writes under homedir()/.orca and ZCode +// config is ~/.zcode/cli/config.json. Point $HOME at a temp dir so install/remove +// never touch the real user profile. +let home: string +let originalHome: string | undefined + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-zcode-hook-')) + originalHome = process.env.HOME + process.env.HOME = home +}) + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + rmSync(home, { recursive: true, force: true }) +}) + +const configPath = (): string => join(home, '.zcode', 'cli', 'config.json') +const scriptPath = (): string => join(home, '.orca', 'agent-hooks', 'zcode-hook.sh') + +describe('ZcodeHookService', () => { + it('reports not_installed before install', () => { + expect(new ZcodeHookService().getStatus().state).toBe('not_installed') + }) + + it('installs managed hooks into ~/.zcode/cli/config.json and the managed script', () => { + const status = new ZcodeHookService().install() + expect(status.state).toBe('installed') + expect(status.managedHooksPresent).toBe(true) + + const config = JSON.parse(readFileSync(configPath(), 'utf-8')) as { + hooks?: { enabled?: boolean; events?: Record } + } + expect(config.hooks?.enabled).toBe(true) + for (const event of ZCODE_HOOK_EVENTS) { + expect(config.hooks?.events?.[event]).toBeDefined() + expect(Array.isArray(config.hooks?.events?.[event])).toBe(true) + } + + const script = readFileSync(scriptPath(), 'utf-8') + expect(script).toContain('/hook/zcode') + expect(script).toContain('printf \'%s\' "$payload" | curl') + expect(script).toContain('--data-urlencode "payload@-"') + expect(script).not.toContain('--data-urlencode "payload=${payload}"') + + const serialized = readFileSync(configPath(), 'utf-8') + expect(serialized).toContain('agent-hooks/zcode-hook.sh') + }) + + it('keeps user config when installing, then restores it on remove', () => { + const dir = join(home, '.zcode', 'cli') + mkdirSync(dir, { recursive: true }) + const userConfig = { + theme: 'dark', + hooks: { + enabled: false, + events: { + PreToolUse: [ + { + matcher: 'Write', + hooks: [{ type: 'command', command: 'echo user-hook', enabled: true }] + } + ] + } + } + } + writeFileSync(configPath(), `${JSON.stringify(userConfig, null, 2)}\n`) + + const service = new ZcodeHookService() + expect(service.install().state).toBe('installed') + + type HookDef = { hooks?: { command?: string }[] } + type Parsed = { + theme?: string + hooks?: { enabled?: boolean; events?: Record } + } + const installed = JSON.parse(readFileSync(configPath(), 'utf-8')) as Parsed + expect(installed.theme).toBe('dark') + expect(installed.hooks?.enabled).toBe(true) + const preTool = installed.hooks?.events?.PreToolUse ?? [] + expect(preTool.some((def) => def.hooks?.some((h) => h.command === 'echo user-hook'))).toBe(true) + expect(preTool.some((def) => def.hooks?.some((h) => h.command?.includes('zcode-hook')))).toBe( + true + ) + + // Reinstall must not duplicate managed entries. + service.install() + const reinstalled = JSON.parse(readFileSync(configPath(), 'utf-8')) as Parsed + const managedCount = (reinstalled.hooks?.events?.PreToolUse ?? []) + .flatMap((def) => def.hooks ?? []) + .filter((hook) => hook.command?.includes('zcode-hook')).length + expect(managedCount).toBe(1) + + const removed = service.remove() + expect(removed.state).toBe('not_installed') + const afterRemove = JSON.parse(readFileSync(configPath(), 'utf-8')) as Parsed + expect(afterRemove.theme).toBe('dark') + // Why: install forced enabled=true; remove restores the pre-install value (false). + expect(afterRemove.hooks?.enabled).toBe(false) + const remainingPre = afterRemove.hooks?.events?.PreToolUse ?? [] + expect(remainingPre.some((def) => def.hooks?.some((h) => h.command === 'echo user-hook'))).toBe( + true + ) + expect( + remainingPre.some((def) => def.hooks?.some((h) => h.command?.includes('zcode-hook'))) + ).toBe(false) + }) +}) diff --git a/src/main/zcode/hook-service.ts b/src/main/zcode/hook-service.ts new file mode 100644 index 000000000000..1c8643121425 --- /dev/null +++ b/src/main/zcode/hook-service.ts @@ -0,0 +1,249 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { SFTPWrapper } from 'ssh2' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + buildWindowsAgentHookPostCommand, + getSharedManagedScriptPath, + readHooksJson, + wrapPosixHookCommand, + wrapWindowsHookCommand, + writeHooksJson, + writeManagedScript, + type HooksConfig +} from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' +import { + applyManagedZcodeHooks, + isZcodeHooksEnabled, + readManagedZcodeHookEvents, + removeManagedZcodeHooks, + ZCODE_HOOK_EVENTS, + type ZcodeConfig +} from './zcode-hook-config' + +function getConfigPath(): string { + // Why: ZCode user-scope hooks live in ~/.zcode/cli/config.json (docs). + return join(homedir(), '.zcode', 'cli', 'config.json') +} + +function getManagedScriptFileName(): string { + return process.platform === 'win32' ? 'zcode-hook.cmd' : 'zcode-hook.sh' +} + +function getManagedScriptPath(): string { + return getSharedManagedScriptPath(getManagedScriptFileName()) +} + +function getManagedCommand(scriptPath: string): string { + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) +} + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + buildWindowsAgentHookPostCommand('zcode'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + ...buildPosixHookPayloadCapture(), + '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', + ' exit 0', + 'fi', + // Why: pipe payload to curl stdin so tool output never lands on the argv (EDR false positives). + 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/zcode" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} + +function asZcodeConfig(config: ReturnType): ZcodeConfig | null { + if (!config) { + return null + } + return config as ZcodeConfig +} + +// Why: ZCode nests hooks under hooks.events; HooksConfig's hooks map type is Claude-shaped. Cast at the write boundary only. +function asHooksConfig(config: ZcodeConfig): HooksConfig { + return config as HooksConfig +} + +function buildStatus( + present: Set, + configPath: string, + hooksEnabled: boolean +): AgentHookInstallStatus { + const missing = ZCODE_HOOK_EVENTS.filter((event) => !present.has(event)) + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0) { + if (!hooksEnabled) { + state = 'partial' + detail = 'ZCode hooks are disabled (hooks.enabled is not true)' + } else { + state = 'installed' + detail = null + } + } else if (present.size === 0) { + if (!hooksEnabled) { + state = 'not_installed' + detail = null + } else { + state = 'not_installed' + detail = null + } + } else { + state = 'partial' + detail = !hooksEnabled + ? `ZCode hooks are disabled; managed hook missing for events: ${missing.join(', ')}` + : `Managed hook missing for events: ${missing.join(', ')}` + } + return { + agent: 'zcode', + state, + configPath, + managedHooksPresent: present.size > 0, + detail + } +} + +export class ZcodeHookService { + getStatus(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = asZcodeConfig(readHooksJson(configPath)) + if (!config) { + return { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse ZCode cli/config.json' + } + } + const command = getManagedCommand(scriptPath) + return buildStatus( + readManagedZcodeHookEvents(config, command), + configPath, + isZcodeHooksEnabled(config) + ) + } + + install(): AgentHookInstallStatus { + const configPath = getConfigPath() + const scriptPath = getManagedScriptPath() + const config = asZcodeConfig(readHooksJson(configPath)) + if (!config) { + return { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse ZCode cli/config.json' + } + } + + const scriptFileName = getManagedScriptFileName() + const next = applyManagedZcodeHooks(config, getManagedCommand(scriptPath), scriptFileName) + // Why: script first so config never points at a missing managed script. + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson(configPath, asHooksConfig(next)) + return this.getStatus() + } + + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const home = remoteHome.replace(/\/$/, '') + const remoteConfigPath = `${home}/.zcode/cli/config.json` + const remoteScriptPath = `${home}/.orca/agent-hooks/zcode-hook.sh` + try { + const config = asZcodeConfig(await readHooksJsonRemote(sftp, remoteConfigPath)) + if (!config) { + return { + agent: 'zcode', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: 'Could not parse remote ZCode cli/config.json' + } + } + + const next = applyManagedZcodeHooks( + config, + wrapPosixHookCommand(remoteScriptPath), + 'zcode-hook.sh' + ) + await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, remoteConfigPath, asHooksConfig(next)) + + return { + agent: 'zcode', + state: 'installed', + configPath: remoteConfigPath, + managedHooksPresent: true, + detail: null + } + } catch (err) { + return { + agent: 'zcode', + state: 'error', + configPath: remoteConfigPath, + managedHooksPresent: false, + detail: err instanceof Error ? err.message : String(err) + } + } + } + + remove(): AgentHookInstallStatus { + const configPath = getConfigPath() + const config = asZcodeConfig(readHooksJson(configPath)) + if (!config) { + return { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse ZCode cli/config.json' + } + } + + const next = removeManagedZcodeHooks(config, getManagedScriptFileName()) + writeHooksJson(configPath, asHooksConfig(next)) + return this.getStatus() + } +} + +export const zcodeHookService = new ZcodeHookService() diff --git a/src/main/zcode/zcode-hook-config.test.ts b/src/main/zcode/zcode-hook-config.test.ts new file mode 100644 index 000000000000..64fec6a180c4 --- /dev/null +++ b/src/main/zcode/zcode-hook-config.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { + applyManagedZcodeHooks, + isZcodeHooksEnabled, + ORCA_PREVIOUS_HOOKS_ENABLED_KEY, + readManagedZcodeHookEvents, + removeManagedZcodeHooks, + ZCODE_HOOK_EVENTS +} from './zcode-hook-config' + +const COMMAND = + "if [ -f '/home/u/.orca/agent-hooks/zcode-hook.sh' ]; then /bin/sh '/home/u/.orca/agent-hooks/zcode-hook.sh'; else :; fi" +const SCRIPT = 'zcode-hook.sh' + +describe('zcode-hook-config', () => { + it('enables hooks and installs managed entries for every tracked event', () => { + const next = applyManagedZcodeHooks({}, COMMAND, SCRIPT) + expect(isZcodeHooksEnabled(next)).toBe(true) + const present = readManagedZcodeHookEvents(next, COMMAND) + expect([...present].sort()).toEqual([...ZCODE_HOOK_EVENTS].sort()) + }) + + it('preserves user hooks and strips only managed ones on remove', () => { + const withUser = applyManagedZcodeHooks( + { + hooks: { + enabled: true, + events: { + PreToolUse: [ + { + matcher: 'Write', + hooks: [{ type: 'command', command: 'echo keep-me', enabled: true }] + } + ] + } + } + }, + COMMAND, + SCRIPT + ) + const removed = removeManagedZcodeHooks(withUser, SCRIPT) + const pre = removed.hooks?.events?.PreToolUse ?? [] + expect(pre).toHaveLength(1) + expect(pre[0]?.hooks?.[0]?.command).toBe('echo keep-me') + expect(readManagedZcodeHookEvents(removed, COMMAND).size).toBe(0) + expect(removed.hooks?.enabled).toBe(true) + expect(removed.hooks?.[ORCA_PREVIOUS_HOOKS_ENABLED_KEY]).toBeUndefined() + }) + + it('restores pre-install hooks.enabled on remove', () => { + const installed = applyManagedZcodeHooks( + { hooks: { enabled: false, events: {} } }, + COMMAND, + SCRIPT + ) + expect(installed.hooks?.enabled).toBe(true) + expect(installed.hooks?.[ORCA_PREVIOUS_HOOKS_ENABLED_KEY]).toBe(false) + + // Why: reinstall must not overwrite the stashed original enabled value. + const reinstalled = applyManagedZcodeHooks(installed, COMMAND, SCRIPT) + expect(reinstalled.hooks?.[ORCA_PREVIOUS_HOOKS_ENABLED_KEY]).toBe(false) + + const removed = removeManagedZcodeHooks(reinstalled, SCRIPT) + expect(removed.hooks?.enabled).toBe(false) + expect(removed.hooks?.[ORCA_PREVIOUS_HOOKS_ENABLED_KEY]).toBeUndefined() + }) + + it('is idempotent across reinstall', () => { + const once = applyManagedZcodeHooks({}, COMMAND, SCRIPT) + const twice = applyManagedZcodeHooks(once, COMMAND, SCRIPT) + for (const event of ZCODE_HOOK_EVENTS) { + const defs = twice.hooks?.events?.[event] ?? [] + const managed = defs.flatMap((d) => d.hooks ?? []).filter((h) => h.command === COMMAND) + expect(managed).toHaveLength(1) + } + }) + + it('tracks SessionStart among managed events', () => { + expect(ZCODE_HOOK_EVENTS).toContain('SessionStart') + const next = applyManagedZcodeHooks({}, COMMAND, SCRIPT) + expect(readManagedZcodeHookEvents(next, COMMAND).has('SessionStart')).toBe(true) + }) +}) diff --git a/src/main/zcode/zcode-hook-config.ts b/src/main/zcode/zcode-hook-config.ts new file mode 100644 index 000000000000..98a4147c9c0c --- /dev/null +++ b/src/main/zcode/zcode-hook-config.ts @@ -0,0 +1,194 @@ +import { + MANAGED_HOOK_TIMEOUT_MILLISECONDS, + createManagedCommandMatcher, + isPlainObject +} from '../agent-hooks/installer-utils' + +// Why: mirror Claude-compatible lifecycle events ZCode documents +// (https://zcode.z.ai/en/docs/hooks) so Orca status tracks working/waiting/done. +export const ZCODE_HOOK_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop' +] as const + +export type ZcodeHookEventName = (typeof ZCODE_HOOK_EVENTS)[number] + +// Why: apply() forces hooks.enabled=true; stash prior value so remove() can restore it. +export const ORCA_PREVIOUS_HOOKS_ENABLED_KEY = 'orcaPreviousHooksEnabled' + +export type ZcodeHookCommand = { + type?: string + command?: string + args?: string[] + enabled?: boolean + timeoutMs?: number + [key: string]: unknown +} + +export type ZcodeHookDefinition = { + matcher?: string + hooks?: ZcodeHookCommand[] + [key: string]: unknown +} + +export type ZcodeHooksRoot = { + enabled?: boolean + timeoutMs?: number + maxOutputBytes?: number + events?: Record + [key: string]: unknown +} + +export type ZcodeConfig = { + hooks?: ZcodeHooksRoot + [key: string]: unknown +} + +function asDefinitionArray(value: unknown): ZcodeHookDefinition[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((entry): entry is ZcodeHookDefinition => isPlainObject(entry)) +} + +function buildManagedHookCommand(command: string): ZcodeHookCommand { + return { + type: 'command', + command, + enabled: true, + // Why: ZCode uses timeoutMs (ms); Claude-style `timeout` is seconds and is secondary. + timeoutMs: MANAGED_HOOK_TIMEOUT_MILLISECONDS + } +} + +function definitionHasManagedCommand( + definition: ZcodeHookDefinition, + isManagedCommand: (command: string | undefined) => boolean +): boolean { + const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] + return hooks.some((hook) => + isManagedCommand(typeof hook.command === 'string' ? hook.command : undefined) + ) +} + +function stripManagedCommands( + definitions: ZcodeHookDefinition[], + isManagedCommand: (command: string | undefined) => boolean +): ZcodeHookDefinition[] { + const next: ZcodeHookDefinition[] = [] + for (const definition of definitions) { + const hooks = Array.isArray(definition.hooks) ? definition.hooks : [] + const cleanedHooks = hooks.filter( + (hook) => !isManagedCommand(typeof hook.command === 'string' ? hook.command : undefined) + ) + if (cleanedHooks.length === 0) { + // Why: drop empty matchers Orca owned; leave user matchers that still have hooks. + if (hooks.length > 0 && hooks.every((hook) => isManagedCommand(hook.command))) { + continue + } + if (hooks.length === 0 && !definition.command) { + continue + } + } + next.push({ ...definition, hooks: cleanedHooks }) + } + return next +} + +export function applyManagedZcodeHooks( + config: ZcodeConfig, + command: string, + scriptFileName: string +): ZcodeConfig { + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const hooksRoot: ZcodeHooksRoot = isPlainObject(config.hooks) ? { ...config.hooks } : {} + const events: Record = isPlainObject(hooksRoot.events) + ? { ...hooksRoot.events } + : {} + const managedEvents = new Set(ZCODE_HOOK_EVENTS) + + // Why: sweep managed entries from retired events so reinstall converges. + for (const [eventName, definitions] of Object.entries(events)) { + if (managedEvents.has(eventName)) { + continue + } + const cleaned = stripManagedCommands(asDefinitionArray(definitions), isManagedCommand) + if (cleaned.length === 0) { + delete events[eventName] + } else { + events[eventName] = cleaned + } + } + + for (const eventName of ZCODE_HOOK_EVENTS) { + const current = stripManagedCommands(asDefinitionArray(events[eventName]), isManagedCommand) + const managedDefinition: ZcodeHookDefinition = { + matcher: '*', + hooks: [buildManagedHookCommand(command)] + } + events[eventName] = [...current, managedDefinition] + } + + // Why: ZCode only executes hooks when hooks.enabled is true at the config root. + // Capture pre-install value once so reinstall does not overwrite the original. + if (!(ORCA_PREVIOUS_HOOKS_ENABLED_KEY in hooksRoot)) { + hooksRoot[ORCA_PREVIOUS_HOOKS_ENABLED_KEY] = hooksRoot.enabled === true + } + hooksRoot.enabled = true + hooksRoot.events = events + return { ...config, hooks: hooksRoot } +} + +export function removeManagedZcodeHooks(config: ZcodeConfig, scriptFileName: string): ZcodeConfig { + if (!isPlainObject(config.hooks) || !isPlainObject(config.hooks.events)) { + return config + } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const hooksRoot: ZcodeHooksRoot = { ...config.hooks } + const events: Record = { ...hooksRoot.events } + + for (const [eventName, definitions] of Object.entries(events)) { + const cleaned = stripManagedCommands(asDefinitionArray(definitions), isManagedCommand) + if (cleaned.length === 0) { + delete events[eventName] + } else { + events[eventName] = cleaned + } + } + + hooksRoot.events = events + // Why: restore pre-install hooks.enabled when Orca forced it true for managed hooks. + if (ORCA_PREVIOUS_HOOKS_ENABLED_KEY in hooksRoot) { + hooksRoot.enabled = hooksRoot[ORCA_PREVIOUS_HOOKS_ENABLED_KEY] === true + delete hooksRoot[ORCA_PREVIOUS_HOOKS_ENABLED_KEY] + } + return { ...config, hooks: hooksRoot } +} + +export function readManagedZcodeHookEvents(config: ZcodeConfig, command: string): Set { + const present = new Set() + const events = + isPlainObject(config.hooks) && isPlainObject(config.hooks.events) ? config.hooks.events : null + if (!events) { + return present + } + for (const eventName of ZCODE_HOOK_EVENTS) { + const definitions = asDefinitionArray(events[eventName]) + const hasCommand = definitions.some((definition) => + definitionHasManagedCommand(definition, (candidate) => candidate === command) + ) + if (hasCommand) { + present.add(eventName) + } + } + return present +} + +export function isZcodeHooksEnabled(config: ZcodeConfig): boolean { + return isPlainObject(config.hooks) && config.hooks.enabled === true +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index b5b053c1ea21..38f14a52b426 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2407,6 +2407,8 @@ export type PreloadApi = { copilotStatus: () => Promise hermesStatus: () => Promise devinStatus: () => Promise + kimiStatus: () => Promise + zcodeStatus: () => Promise } agentTrust: { markTrusted: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 9141e9c43bc8..4a7a0f73074c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2081,7 +2081,8 @@ const api = { ipcRenderer.invoke('agentHooks:copilotStatus'), hermesStatus: (): Promise => ipcRenderer.invoke('agentHooks:hermesStatus'), - kimiStatus: (): Promise => ipcRenderer.invoke('agentHooks:kimiStatus') + kimiStatus: (): Promise => ipcRenderer.invoke('agentHooks:kimiStatus'), + zcodeStatus: (): Promise => ipcRenderer.invoke('agentHooks:zcodeStatus') }, agentTrust: { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 72937a37dc90..420705c2650f 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -324,6 +324,7 @@ "agent": { "catalog": { "5dff448636": "OpenClaw", + "a7c0de2012": "ZCode", "8a9ba743cc": "Hermes", "4e63c7b956": "Rovo Dev", "bee242fe3d": "Qwen Code", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d97f38a33da3..05fb094c6ed9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -301,6 +301,7 @@ "agent": { "catalog": { "5dff448636": "OpenClaw", + "a7c0de2012": "ZCode", "8a9ba743cc": "Hermes", "4e63c7b956": "Rovo Dev", "bee242fe3d": "Qwen Code", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 61619a7fcf59..2a88a3ba68fd 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -301,6 +301,7 @@ "agent": { "catalog": { "5dff448636": "OpenClaw", + "a7c0de2012": "ZCode", "8a9ba743cc": "Hermes", "4e63c7b956": "Rovo Dev", "bee242fe3d": "Qwen Code", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 92dcecb1d998..53e0126ea32a 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -301,6 +301,7 @@ "agent": { "catalog": { "5dff448636": "OpenClaw", + "a7c0de2012": "ZCode", "8a9ba743cc": "Hermes", "4e63c7b956": "Rovo Dev", "bee242fe3d": "Qwen Code", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f73a876317a6..6576d472c1a1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -301,6 +301,7 @@ "agent": { "catalog": { "5dff448636": "OpenClaw", + "a7c0de2012": "ZCode", "8a9ba743cc": "Hermes", "4e63c7b956": "Rovo Dev", "bee242fe3d": "Qwen Code", diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 2106c0eaa485..0c64fcfbaae2 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -299,6 +299,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => cmd: 'openclaw', faviconDomain: 'openclaw.ai', homepageUrl: 'https://github.com/openclaw/openclaw' + }, + { + id: 'zcode', + label: translate('auto.lib.agent.catalog.a7c0de2012', 'ZCode'), + cmd: 'zcode', + faviconDomain: 'z.ai', + homepageUrl: 'https://zcode.z.ai/en/docs/install' } ]) diff --git a/src/renderer/src/lib/agent-status.test.ts b/src/renderer/src/lib/agent-status.test.ts index ce546f541b8a..8b894cd8eef1 100644 --- a/src/renderer/src/lib/agent-status.test.ts +++ b/src/renderer/src/lib/agent-status.test.ts @@ -864,6 +864,10 @@ describe('formatAgentTypeLabel', () => { expect(formatAgentTypeLabel('trae')).toBe('Trae') }) + it("maps 'zcode' to 'ZCode'", () => { + expect(formatAgentTypeLabel('zcode')).toBe('ZCode') + }) + it('passes through arbitrary custom agent names as-is', () => { expect(formatAgentTypeLabel('weirdo')).toBe('weirdo') }) @@ -889,6 +893,7 @@ describe('agentTypeToIconAgent', () => { expect(agentTypeToIconAgent('command-code')).toBe('command-code') expect(agentTypeToIconAgent('ante')).toBe('ante') expect(agentTypeToIconAgent('trae')).toBe('trae') + expect(agentTypeToIconAgent('zcode')).toBe('zcode') }) it('returns null for arbitrary non-iconable strings', () => { diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 1bfa9fc49da0..f116df104130 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -130,7 +130,8 @@ const ICONABLE_AGENT_TYPES: Record = { grok: true, devin: true, ante: true, - trae: true + trae: 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/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 4c30a9835553..8f55c8126ddf 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2733,6 +2733,8 @@ function createAgentHooksApi(): NonNullable['agentHooks']> { | 'copilot' | 'hermes' | 'devin' + | 'kimi' + | 'zcode' ) => Promise.resolve({ agent, @@ -2754,7 +2756,9 @@ function createAgentHooksApi(): NonNullable['agentHooks']> { grokStatus: () => status('grok'), copilotStatus: () => status('copilot'), hermesStatus: () => status('hermes'), - devinStatus: () => status('devin') + devinStatus: () => status('devin'), + kimiStatus: () => status('kimi'), + zcodeStatus: () => status('zcode') } } diff --git a/src/shared/agent-hook-listener.test.ts b/src/shared/agent-hook-listener.test.ts index f6bcc0c383f8..1941e3478093 100644 --- a/src/shared/agent-hook-listener.test.ts +++ b/src/shared/agent-hook-listener.test.ts @@ -1452,6 +1452,66 @@ describe('shared agent-hook-listener', () => { expect(stopped?.providerSession).toMatchObject({ key: 'session_id', id: 'session_abc' }) }) + it('normalizes ZCode Claude-compatible lifecycle events as zcode status', () => { + const submitted = normalizeHookPayload( + state, + 'zcode', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'UserPromptSubmit', + session_id: 'zcode-session-1', + cwd: '/repo', + prompt: 'implement the feature' + } + }, + 'production' + ) + const tool = normalizeHookPayload( + state, + 'zcode', + { + paneKey: PANE_KEY, + payload: { + hook_event_name: 'PreToolUse', + session_id: 'zcode-session-1', + tool_name: 'Write', + tool_input: { file_path: 'src/a.ts', content: 'x' } + } + }, + 'production' + ) + const waiting = normalizeHookPayload( + state, + 'zcode', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'PermissionRequest', session_id: 'zcode-session-1' } + }, + 'production' + ) + const stopped = normalizeHookPayload( + state, + 'zcode', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'Stop', session_id: 'zcode-session-1' } + }, + 'production' + ) + + expect(submitted?.payload).toMatchObject({ + agentType: 'zcode', + state: 'working', + prompt: 'implement the feature' + }) + expect(tool?.payload).toMatchObject({ agentType: 'zcode', state: 'working', toolName: 'Write' }) + expect(waiting?.payload).toMatchObject({ agentType: 'zcode', state: 'waiting' }) + expect(stopped?.payload).toMatchObject({ agentType: 'zcode', state: 'done' }) + expect(stopped?.providerSession).toMatchObject({ key: 'session_id', id: 'zcode-session-1' }) + expect(resolveHookSource('/hook/zcode')).toBe('zcode') + }) + it('normalizes MiMo Code OpenCode-compatible lifecycle events as mimo-code status', () => { const message = normalizeHookPayload( state, diff --git a/src/shared/agent-hook-listener.ts b/src/shared/agent-hook-listener.ts index ee3536bb932e..4c0eb9db9cdf 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -2280,6 +2280,8 @@ function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boolean { // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. // falls through case 'kimi': + // Why: ZCode lifecycle events mirror Claude's; UserPromptSubmit is the new-turn boundary. + case 'zcode': return eventName === 'UserPromptSubmit' case 'codex': return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' @@ -2374,6 +2376,8 @@ function extractToolFields( // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. // falls through case 'kimi': + // Why: ZCode stdin contract carries Claude-compatible tool_name/tool_input. + case 'zcode': return extractClaudeToolFields(eventName, hookPayload) case 'codex': return extractCodexToolFields(eventName, hookPayload) @@ -2764,16 +2768,21 @@ function isKimiUserInputTool(toolName: string | undefined): boolean { return toolName?.replaceAll(/[^a-z0-9]/gi, '').toLowerCase() === 'askuserquestion' } -// Why: Kimi Code emits Claude-compatible payloads/event names; normalize but attribute to Kimi so the sidebar shows Kimi's icon/label, not Claude's. -function normalizeKimiEvent( +// Why: Claude-compatible agents (Kimi, ZCode) share PreToolUse→waiting for AskUserQuestion. +function isClaudeCompatibleUserInputTool(toolName: string | undefined): boolean { + return isKimiUserInputTool(toolName) +} + +function normalizeClaudeCompatibleAgentEvent( state: HookListenerState, + source: 'kimi' | 'zcode', eventName: unknown, promptText: string, paneKey: string, hookPayload: Record ): ParsedAgentStatusPayload | null { const toolName = readString(hookPayload, 'tool_name') - const isUserInputTool = isKimiUserInputTool(toolName) + const isUserInputTool = isClaudeCompatibleUserInputTool(toolName) let stateName: 'working' | 'waiting' | 'done' | null = null if ( @@ -2796,8 +2805,8 @@ function normalizeKimiEvent( const snapshot = resolveToolState( state, paneKey, - extractToolFields('kimi', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('kimi', eventName) } + extractToolFields(source, eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent(source, eventName) } ) const interrupted = @@ -2806,9 +2815,9 @@ function normalizeKimiEvent( return normalizeAgentStatusPayload({ state: stateName, prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('kimi', eventName) + resetOnNewTurn: isNewTurnEvent(source, eventName) }), - agentType: 'kimi', + agentType: source, toolName: snapshot.toolName, toolInput: snapshot.toolInput, lastAssistantMessage: snapshot.lastAssistantMessage, @@ -2816,6 +2825,42 @@ function normalizeKimiEvent( }) } +// Why: Kimi Code emits Claude-compatible payloads; attribute status to Kimi. +function normalizeKimiEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + return normalizeClaudeCompatibleAgentEvent( + state, + 'kimi', + eventName, + promptText, + paneKey, + hookPayload + ) +} + +// Why: ZCode lifecycle hooks mirror Claude's event names/payloads; attribute to zcode. +function normalizeZcodeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + return normalizeClaudeCompatibleAgentEvent( + state, + 'zcode', + eventName, + promptText, + paneKey, + hookPayload + ) +} + function normalizeGeminiEvent( state: HookListenerState, eventName: unknown, @@ -4014,6 +4059,9 @@ export function normalizeHookPayload( case 'kimi': payload = normalizeKimiEvent(state, eventName, promptText, paneKey, hookPayloadRecord) break + case 'zcode': + payload = normalizeZcodeEvent(state, eventName, promptText, paneKey, hookPayloadRecord) + break } // Why: connectionId is null here; ingestRemote stamps it from mux identity on receive. See docs/design/agent-status-over-ssh.md §5. @@ -4081,7 +4129,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/copilot': 'copilot', '/hook/hermes': 'hermes', '/hook/devin': 'devin', - '/hook/kimi': 'kimi' + '/hook/kimi': 'kimi', + '/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 9cbd6c18faaa..5cef4817da5a 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -49,6 +49,7 @@ export type AgentHookSource = | 'hermes' | 'devin' | 'kimi' + | 'zcode' /** Env marker used by the remote relay. It is a transport/location marker, not * a dev-vs-prod build tag, so main-process env mismatch diagnostics ignore it. */ diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 707a89ffda3d..cca033e890d3 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -17,7 +17,8 @@ export const AGENT_HOOK_TARGETS = [ 'copilot', 'hermes', 'devin', - 'kimi' + 'kimi', + 'zcode' ] as const export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number] diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index 95a25a3c0776..4fcd0b7ee69a 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -48,7 +48,8 @@ const TUI_AGENT_KIND_BY_AGENT = { grok: 'grok', devin: 'devin', ante: 'ante', - trae: 'trae' + trae: 'trae', + 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 e2084052d72d..552dcd5e17a0 100644 --- a/src/shared/agent-name-token-match.ts +++ b/src/shared/agent-name-token-match.ts @@ -26,7 +26,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-session-resume.ts b/src/shared/agent-session-resume.ts index 50581c5c919e..3476428fd991 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -187,7 +187,10 @@ export function extractAgentProviderSession( case 'droid': // Why: Kimi Code posts a Claude-shaped `session_id` (e.g. session_). // falls through - case 'kimi': { + case 'kimi': + // Why: ZCode posts Claude-compatible session_id on lifecycle hooks. + // falls through + case 'zcode': { const id = readSessionId(payload, ['session_id']) return id ? { key: 'session_id', id } : null } diff --git a/src/shared/agent-status-types.ts b/src/shared/agent-status-types.ts index be22ce6e68d2..cbc98e53d357 100644 --- a/src/shared/agent-status-types.ts +++ b/src/shared/agent-status-types.ts @@ -38,6 +38,8 @@ export type WellKnownAgentType = | 'devin' | 'ante' | 'trae' + | 'kimi' + | 'zcode' | 'unknown' export type AgentType = WellKnownAgentType | (string & {}) diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index 05d70d238681..9c2438e57af0 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -23,7 +23,8 @@ const WELL_KNOWN_LABELS: Record = { devin: 'Devin', ante: 'Ante', trae: 'Trae', - kimi: 'Kimi' + kimi: 'Kimi', + zcode: 'ZCode' } export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 4428bdc95410..4d51a63b5795 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -95,6 +95,7 @@ export const AGENT_KIND_VALUES = [ 'devin', 'ante', 'trae', + 'zcode', 'other' ] as const export const agentKindSchema = z.enum(AGENT_KIND_VALUES) diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 49e07d41839c..8e8263d0b355 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -304,6 +304,14 @@ export const TUI_AGENT_CONFIG: Record = { expectedProcess: 'devin', // Why: `devin -- ` auto-submits immediately (docs.devin.ai/cli), so start the REPL with no argv prompt. promptInjectionMode: 'stdin-after-start' + }, + zcode: { + detectCmd: 'zcode', + launchCmd: 'zcode', + expectedProcess: 'zcode', + // Why: ZCode's documented surface is the desktop app / REPL; no stable + // headless prompt flag, so launch bare and inject after startup. + promptInjectionMode: 'stdin-after-start' } } diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index f680e16f967d..70c870880984 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -40,7 +40,8 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { hermes: 'Hermes', openclaw: 'OpenClaw', copilot: 'GitHub Copilot', - grok: 'Grok' + grok: 'Grok', + zcode: 'ZCode' } /** Canonical agent id list derived from the exhaustive display-name record, diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 48e10305aa51..08a6cf07faa1 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -38,7 +38,8 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'rovo', 'hermes', 'devin', - 'openclaw' + 'openclaw', + 'zcode' ] as const satisfies readonly TuiAgent[] // Why: fresh installs should expose Claude Agent Teams in agent pickers; the diff --git a/src/shared/types.ts b/src/shared/types.ts index b98f3d1ea1bf..c2974c1042f1 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2538,6 +2538,7 @@ export type TuiAgent = | 'devin' // Devin CLI | 'ante' // Ante (Antigma Labs) | 'trae' // Trae CLI + | 'zcode' // ZCode (Z.ai / GLM) export type TaskViewPresetId = 'all' | 'issues' | 'review' | 'my-issues' | 'my-prs' | 'prs'