diff --git a/README.md b/README.md index 04f5e309da38..8d4336244cde 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/config/tsconfig.cli.json b/config/tsconfig.cli.json index 43eac7cb024d..41fc652f7600 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -132,6 +132,8 @@ "../src/main/kimi/hook-service.ts", "../src/main/kimi/kimi-hook-config-toml.ts", "../src/main/openclaude/hook-service.ts", + "../src/main/zcode/hook-service.ts", + "../src/main/zcode/zcode-hook-config.ts", "../src/main/rolling-file-backup.ts", "../src/main/startup/hydrate-shell-path.ts", "../src/main/startup/windows-shell-path-ownership.ts", diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index bb5f15f14e0f..92bddcee51df 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 549f817b9fe2..bdb05c908135 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   + Logo ZCode 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 58330fe3f437..9e658dd5776c 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 6e6b174e78fe..ba14d461a304 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -198,6 +198,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 8fbc63e1a039..7eb06f3ec77f 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   + Logotipo do ZCode 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 565b76ff5d5d..8f7756a582b9 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 46b7ebcb0d38..b13193c04242 100644 --- a/mobile/src/tasks/mobile-tui-agents.ts +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -44,7 +44,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 const isMobileTuiAgent: (value: unknown) => value is TuiAgent = isTuiAgent diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 49fdcadda421..008a3b29b6d6 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.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' // 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 @@ -50,7 +51,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()] ] // Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a @@ -71,7 +73,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri ['grok', () => grokHookService.refreshManagedScripts()], ['copilot', () => copilotHookService.refreshManagedScripts()], ['devin', () => devinHookService.refreshManagedScripts()], - ['kimi', () => kimiHookService.refreshManagedScripts()] + ['kimi', () => kimiHookService.refreshManagedScripts()], + ['zcode', () => zcodeHookService.refreshManagedScripts()] ] export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ @@ -88,7 +91,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['copilot', () => copilotHookService.remove()], ['hermes', () => hermesHookService.remove()], ['devin', () => devinHookService.remove()], - ['kimi', () => kimiHookService.remove()] + ['kimi', () => kimiHookService.remove()], + ['zcode', () => zcodeHookService.remove()] ] export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [ @@ -109,5 +113,6 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR ['copilot', () => copilotHookService.getStatus()], ['hermes', () => hermesHookService.getStatus()], ['devin', () => devinHookService.getStatus()], - ['kimi', () => kimiHookService.getStatus()] + ['kimi', () => kimiHookService.getStatus()], + ['zcode', () => zcodeHookService.getStatus()] ] diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts index 3213ee0210e2..e3929de96df5 100644 --- a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts +++ b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts @@ -45,9 +45,9 @@ describe('managed-hook local filesystem', () => { const cold = await installRemoteManagedAgentHooks(filesystem, home, options) const warm = await installRemoteManagedAgentHooks(filesystem, home, options) - expect(cold).toHaveLength(14) + expect(cold).toHaveLength(15) expect(cold.filter((result) => result.state === 'error')).toEqual([]) - expect(warm).toHaveLength(14) + expect(warm).toHaveLength(15) expect(warm.filter((result) => result.state === 'error')).toEqual([]) const files = await listFiles(home) expect(files.filter((path) => path.endsWith('.tmp'))).toEqual([]) @@ -71,7 +71,7 @@ describe('managed-hook local filesystem', () => { agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS }) - expect(results).toHaveLength(14) + expect(results).toHaveLength(15) expect(results.find((result) => result.agent === 'claude')?.state).toBe('error') expect(results.find((result) => result.agent === 'openclaude')?.state).toBe('installed') expect(results.find((result) => result.agent === 'kimi')?.state).toBe('installed') 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 2e4cbb06496f..ece763a0af84 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -22,6 +22,7 @@ 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 { @@ -189,6 +190,10 @@ describe('remote hook service installers', () => { { path: '/home/dev/.orca/agent-hooks/droid-hook.sh', install: (sftp: SFTPWrapper) => new DroidHookService().installRemote(sftp, '/home/dev') + }, + { + path: '/home/dev/.orca/agent-hooks/zcode-hook.sh', + install: (sftp: SFTPWrapper) => new ZcodeHookService().installRemote(sftp, '/home/dev') } ] @@ -709,7 +714,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 a335e8ccc773..39400df5b8a6 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 (for example WSL's managed runtime home). */ @@ -72,7 +73,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/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index 3466cb52af9d..a2a87f37a943 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 @@ -37,6 +37,7 @@ const NEW_TURN_EVENT: Record = { copilot: 'sessionStart', hermes: 'pre_llm_call', devin: 'UserPromptSubmit', + zcode: 'UserPromptSubmit', opencode: 'SessionStart', opencode2: 'SessionStart', 'mimo-code': null, diff --git a/src/main/ipc/agent-hooks-zcode-status.ts b/src/main/ipc/agent-hooks-zcode-status.ts new file mode 100644 index 000000000000..8988f0cb0d9f --- /dev/null +++ b/src/main/ipc/agent-hooks-zcode-status.ts @@ -0,0 +1,16 @@ +import type { AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { zcodeHookService } from '../zcode/hook-service' + +export function getZcodeHookStatus(): AgentHookInstallStatus { + try { + return zcodeHookService.getStatus() + } catch (error) { + return { + agent: 'zcode', + state: 'error', + configPath: '', + managedHooksPresent: false, + detail: error instanceof Error ? error.message : String(error) + } + } +} diff --git a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts index 67351e081023..50ef5286971b 100644 --- a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts +++ b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts @@ -159,4 +159,45 @@ describe('SshRelaySession managed hooks', () => { }) ) }) + + it('retries without ZCode when an older relay rejects the expanded allowlist', async () => { + let installAttempts = 0 + muxRequestMock.mockImplementation(async (method: string) => { + if (method === 'preflight.detectAgents') { + return { agents: ['codex', 'zcode'] } + } + if (method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD) { + installAttempts += 1 + if (installAttempts === 1) { + throw new Error('remote request failed: invalid_managed_hook_agents') + } + return { installers: 1, errors: 0 } + } + return { ok: true } + }) + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + const connection = { + sftp: vi.fn(), + getHostKeyFingerprint: vi.fn(() => 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') + } as unknown as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(connection) + await vi.waitFor(() => expect(installAttempts).toBe(2)) + + const installCalls = muxRequestMock.mock.calls.filter( + ([method]) => method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD + ) + expect(installCalls.map(([, params]) => params)).toEqual([ + { + hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + agents: ['codex', 'zcode'] + }, + { + hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + agents: ['codex'] + } + ]) + expect(registerSshPtyProvider).toHaveBeenCalled() + }) }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 19e102b08ede..4013e3eeec57 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1399,8 +1399,25 @@ export class SshRelaySession { agents, ...(detected.claudeVersion ? { claudeVersion: detected.claudeVersion } : {}) } - const result = (await mux.request(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, params)) as { - errors?: unknown + let result: { errors?: unknown } + try { + result = (await mux.request(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, params)) as { + errors?: unknown + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const legacyAgents = agents.filter((agent) => agent !== 'zcode') + if ( + !message.includes('invalid_managed_hook_agents') || + legacyAgents.length === agents.length + ) { + throw error + } + // Older relays reject the entire allowlist when any target is unknown. + result = (await mux.request(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, { + ...params, + agents: legacyAgents + })) as { errors?: unknown } } if (typeof result.errors === 'number' && result.errors > 0) { console.warn( diff --git a/src/main/zcode/hook-service.test.ts b/src/main/zcode/hook-service.test.ts new file mode 100644 index 000000000000..1c095887a187 --- /dev/null +++ b/src/main/zcode/hook-service.test.ts @@ -0,0 +1,144 @@ +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' + +let home: string +let originalHome: string | undefined +let originalUserProfile: string | undefined + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-zcode-hook-')) + originalHome = process.env.HOME + originalUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home +}) + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + rmSync(home, { recursive: true, force: true }) +}) + +const configPath = (): string => join(home, '.zcode', 'cli', 'config.json') +const scriptPath = (): string => + join( + home, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'zcode-hook.cmd' : 'zcode-hook.sh' + ) + +describe('ZcodeHookService', () => { + it('reports not_installed before install', () => { + expect(new ZcodeHookService().getStatus().state).toBe('not_installed') + }) + + it('installs all managed hooks and the payload-safe bridge 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; [key: string]: unknown } + } + expect(config.hooks?.enabled).toBe(true) + expect(config.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + for (const event of ZCODE_HOOK_EVENTS) { + expect(config.hooks?.events?.[event]).toBeDefined() + } + + const script = readFileSync(scriptPath(), 'utf-8') + expect(script).toContain('/hook/zcode') + if (process.platform === 'win32') { + expect(script).toContain('curl.exe') + expect(script).toContain('--data-urlencode "payload@-"') + } else { + expect(script).toContain('printf \'%s\' "$payload" | curl') + expect(script).toContain('--data-urlencode "payload@-"') + expect(script).not.toContain('--data-urlencode "payload=${payload}"') + } + }) + + it('keeps user config across install, reinstall, and 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') + service.install() + + 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') + const preTool = installed.hooks?.events?.PreToolUse ?? [] + expect(preTool.some((definition) => definition.hooks?.[0]?.command === 'echo user-hook')).toBe( + true + ) + expect( + preTool + .flatMap((definition) => definition.hooks ?? []) + .filter((hook) => hook.command?.includes('zcode-hook')) + ).toHaveLength(1) + + expect(service.remove().state).toBe('not_installed') + const removed = JSON.parse(readFileSync(configPath(), 'utf-8')) as Parsed + expect(removed.theme).toBe('dark') + expect(removed.hooks?.enabled).toBe(false) + expect( + (removed.hooks?.events?.PreToolUse ?? []).some( + (definition) => definition.hooks?.[0]?.command === 'echo user-hook' + ) + ).toBe(true) + }) + + it('restores an absent hooks.enabled field on remove without writing private metadata', () => { + const dir = join(home, '.zcode', 'cli') + mkdirSync(dir, { recursive: true }) + writeFileSync(configPath(), `${JSON.stringify({ hooks: { events: {} } }, null, 2)}\n`) + + const service = new ZcodeHookService() + expect(service.install().state).toBe('installed') + const installed = JSON.parse(readFileSync(configPath(), 'utf-8')) as { + hooks?: Record + } + expect(installed.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + + expect(service.remove().state).toBe('not_installed') + const removed = JSON.parse(readFileSync(configPath(), 'utf-8')) as { + hooks?: Record + } + expect(removed.hooks).not.toHaveProperty('enabled') + expect(removed.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + }) +}) diff --git a/src/main/zcode/hook-service.ts b/src/main/zcode/hook-service.ts new file mode 100644 index 000000000000..2008f638a42d --- /dev/null +++ b/src/main/zcode/hook-service.ts @@ -0,0 +1,279 @@ +import { readFileSync, rmSync } from 'node:fs' +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 { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' +import { + applyManagedZcodeHooks, + getPreInstallZcodeHooksEnabledState, + isZcodeHooksEnabled, + readManagedZcodeHookEvents, + removeManagedZcodeHooks, + ZCODE_HOOK_EVENTS, + type ZcodeConfig, + type ZcodeHooksEnabledState +} from './zcode-hook-config' + +type ZcodeHookState = { + schemaVersion: 1 + previousHooksEnabled: ZcodeHooksEnabledState +} + +function getConfigPath(): string { + 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 getManagedStatePath(): string { + return getSharedManagedScriptPath('zcode-hook-state.json') +} + +function readManagedState(): ZcodeHookState | null { + try { + const parsed: unknown = JSON.parse(readFileSync(getManagedStatePath(), 'utf-8')) + if ( + typeof parsed !== 'object' || + parsed === null || + (parsed as ZcodeHookState).schemaVersion !== 1 || + !['missing', 'enabled', 'disabled'].includes((parsed as ZcodeHookState).previousHooksEnabled) + ) { + return null + } + return parsed as ZcodeHookState + } catch { + return null + } +} + +function writeManagedState(previousHooksEnabled: ZcodeHooksEnabledState): void { + writeHooksJson(getManagedStatePath(), { + schemaVersion: 1, + previousHooksEnabled + }) +} + +function removeManagedState(): void { + const statePath = getManagedStatePath() + rmSync(statePath, { force: true }) + rmSync(`${statePath}.bak`, { force: true }) +} + +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', + '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 { + return config as ZcodeConfig | null +} + +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 && hooksEnabled) { + state = 'installed' + detail = null + } else if (present.size === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = hooksEnabled + ? `Managed hook missing for events: ${missing.join(', ')}` + : 'ZCode hooks are disabled (hooks.enabled is not true)' + } + return { agent: 'zcode', state, configPath, managedHooksPresent: present.size > 0, detail } +} + +export class ZcodeHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + + getStatus(): 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' + } + } + return buildStatus( + readManagedZcodeHookEvents(config, getManagedCommand(getManagedScriptPath())), + configPath, + isZcodeHooksEnabled(config) + ) + } + + install(): 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 scriptPath = getManagedScriptPath() + if (!readManagedState()) { + writeManagedState(getPreInstallZcodeHooksEnabledState(config)) + } + const next = applyManagedZcodeHooks( + config, + getManagedCommand(scriptPath), + getManagedScriptFileName() + ) + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson(configPath, asHooksConfig(next)) + return this.getStatus() + } + + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const home = remoteHome.replace(/\/$/, '') + const configPath = `${home}/.zcode/cli/config.json` + const scriptPath = `${home}/.orca/agent-hooks/zcode-hook.sh` + try { + const config = asZcodeConfig(await readHooksJsonRemote(sftp, configPath)) + if (!config) { + return { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse remote ZCode cli/config.json' + } + } + const next = applyManagedZcodeHooks(config, wrapPosixHookCommand(scriptPath), 'zcode-hook.sh') + await writeManagedScriptRemote(sftp, scriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, configPath, asHooksConfig(next)) + return { + agent: 'zcode', + state: 'installed', + configPath, + managedHooksPresent: true, + detail: null + } + } catch (error) { + return { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: error instanceof Error ? error.message : String(error) + } + } + } + + 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 managedState = readManagedState() + writeHooksJson( + configPath, + asHooksConfig( + removeManagedZcodeHooks( + config, + getManagedScriptFileName(), + managedState?.previousHooksEnabled + ) + ) + ) + removeManagedState() + 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..dc3c86fd95c8 --- /dev/null +++ b/src/main/zcode/zcode-hook-config.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { + applyManagedZcodeHooks, + isZcodeHooksEnabled, + 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) + expect([...readManagedZcodeHookEvents(next, COMMAND)].sort()).toEqual( + [...ZCODE_HOOK_EVENTS].sort() + ) + expect(next.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + }) + + 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).not.toHaveProperty('orcaPreviousHooksEnabled') + }) + + it('preserves user definitions without a hooks array', () => { + const config = { + hooks: { + enabled: true, + events: { PreToolUse: [{ matcher: 'Read', custom: 'keep-me' }] } + } + } + const removed = removeManagedZcodeHooks(applyManagedZcodeHooks(config, COMMAND, SCRIPT), SCRIPT) + expect(removed.hooks?.events?.PreToolUse).toEqual([{ matcher: 'Read', custom: 'keep-me' }]) + }) + + it('restores a disabled pre-install hooks.enabled value on remove', () => { + const installed = applyManagedZcodeHooks( + { hooks: { enabled: false, events: {} } }, + COMMAND, + SCRIPT + ) + expect(installed.hooks?.enabled).toBe(true) + expect(installed.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + + const reinstalled = applyManagedZcodeHooks(installed, COMMAND, SCRIPT) + expect(reinstalled.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + + const removed = removeManagedZcodeHooks(reinstalled, SCRIPT, 'disabled') + expect(removed.hooks?.enabled).toBe(false) + expect(removed.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + }) + + it('restores an absent pre-install hooks.enabled value on remove', () => { + const installed = applyManagedZcodeHooks({ hooks: { events: {} } }, COMMAND, SCRIPT) + expect(installed.hooks?.enabled).toBe(true) + + const removed = removeManagedZcodeHooks(installed, SCRIPT, 'missing') + expect(Object.hasOwn(removed.hooks ?? {}, 'enabled')).toBe(false) + }) + + it('removes legacy Orca metadata from the strict ZCode config schema', () => { + const installed = applyManagedZcodeHooks( + { hooks: { enabled: true, events: {}, orcaPreviousHooksEnabled: false } }, + COMMAND, + SCRIPT + ) + expect(installed.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + }) + + it('is idempotent across reinstall', () => { + const twice = applyManagedZcodeHooks( + applyManagedZcodeHooks({}, COMMAND, SCRIPT), + COMMAND, + SCRIPT + ) + for (const event of ZCODE_HOOK_EVENTS) { + const managed = (twice.hooks?.events?.[event] ?? []) + .flatMap((definition) => definition.hooks ?? []) + .filter((hook) => hook.command === COMMAND) + expect(managed).toHaveLength(1) + } + }) +}) diff --git a/src/main/zcode/zcode-hook-config.ts b/src/main/zcode/zcode-hook-config.ts new file mode 100644 index 000000000000..858a0ee34b18 --- /dev/null +++ b/src/main/zcode/zcode-hook-config.ts @@ -0,0 +1,202 @@ +import { + MANAGED_HOOK_TIMEOUT_MILLISECONDS, + createManagedCommandMatcher, + isPlainObject +} from '../agent-hooks/installer-utils' + +export const ZCODE_HOOK_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop' +] as const + +const LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY = 'orcaPreviousHooksEnabled' + +export type ZcodeHooksEnabledState = 'missing' | 'enabled' | 'disabled' + +export type ZcodeHookCommand = { + type?: string + command?: string + enabled?: boolean + timeoutMs?: number + [key: string]: unknown +} + +export type ZcodeHookDefinition = { + matcher?: string + hooks?: ZcodeHookCommand[] + [key: string]: unknown +} + +export type ZcodeHooksRoot = { + enabled?: boolean + events?: Record + [key: string]: unknown +} + +export type ZcodeConfig = { + hooks?: ZcodeHooksRoot + [key: string]: unknown +} + +export function getZcodeHooksEnabledState(config: ZcodeConfig): ZcodeHooksEnabledState { + if (!isPlainObject(config.hooks) || !Object.hasOwn(config.hooks, 'enabled')) { + return 'missing' + } + return config.hooks.enabled === true ? 'enabled' : 'disabled' +} + +export function getPreInstallZcodeHooksEnabledState(config: ZcodeConfig): ZcodeHooksEnabledState { + if ( + isPlainObject(config.hooks) && + Object.hasOwn(config.hooks, LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY) + ) { + return config.hooks[LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY] === true ? 'enabled' : 'disabled' + } + return getZcodeHooksEnabledState(config) +} + +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, + timeoutMs: MANAGED_HOOK_TIMEOUT_MILLISECONDS + } +} + +function stripManagedCommands( + definitions: ZcodeHookDefinition[], + isManagedCommand: (command: string | undefined) => boolean +): ZcodeHookDefinition[] { + const next: ZcodeHookDefinition[] = [] + for (const definition of definitions) { + if (!Array.isArray(definition.hooks)) { + next.push(definition) + continue + } + const hooks = definition.hooks + const cleanedHooks = hooks.filter( + (hook) => !isManagedCommand(typeof hook.command === 'string' ? hook.command : undefined) + ) + if (hooks.length > 0 && cleanedHooks.length === 0) { + 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) + + 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) + events[eventName] = [...current, { matcher: '*', hooks: [buildManagedHookCommand(command)] }] + } + + // Why: ZCode validates config.json strictly. Orca state belongs in its own + // sidecar, and this also repairs configs written by the initial integration. + delete hooksRoot[LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY] + hooksRoot.enabled = true + hooksRoot.events = events + return { ...config, hooks: hooksRoot } +} + +export function removeManagedZcodeHooks( + config: ZcodeConfig, + scriptFileName: string, + previousHooksEnabled?: ZcodeHooksEnabledState +): 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 } + const legacyPreviousHooksEnabled = Object.hasOwn( + hooksRoot, + LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY + ) + ? hooksRoot[LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY] === true + ? 'enabled' + : 'disabled' + : undefined + + 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 + delete hooksRoot[LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY] + const restoredState = previousHooksEnabled ?? legacyPreviousHooksEnabled + if (restoredState === 'missing') { + delete hooksRoot.enabled + } else if (restoredState !== undefined) { + hooksRoot.enabled = restoredState === 'enabled' + } + 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]) + if ( + definitions.some((definition) => + (Array.isArray(definition.hooks) ? definition.hooks : []).some( + (hook) => hook.command === command + ) + ) + ) { + present.add(eventName) + } + } + return present +} + +export function isZcodeHooksEnabled(config: ZcodeConfig): boolean { + return isPlainObject(config.hooks) && config.hooks.enabled === true +} diff --git a/src/renderer/src/components/terminal-pane/title-agent-identity.test.ts b/src/renderer/src/components/terminal-pane/title-agent-identity.test.ts index d0bf535eaf31..21097ac2b78b 100644 --- a/src/renderer/src/components/terminal-pane/title-agent-identity.test.ts +++ b/src/renderer/src/components/terminal-pane/title-agent-identity.test.ts @@ -11,4 +11,9 @@ describe('titleHasExplicitAgentIdentity', () => { expect(titleHasExplicitAgentIdentity('C:\\work\\devin.exe\\ready')).toBe(false) expect(titleHasExplicitAgentIdentity('devin-fixtures ready')).toBe(false) }) + + it('recognizes the ZCode runtime process title', () => { + expect(titleHasExplicitAgentIdentity('zcode-cli')).toBe(true) + expect(titleHasExplicitAgentIdentity('zcode-cli working')).toBe(true) + }) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 487c8f9bdbe2..81813e9f7277 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -573,6 +573,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 cfccde752809..9c9d8aa703c7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -321,6 +321,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 0a3b2ee6c867..3f4fcdf6e820 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -321,6 +321,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 91aa9d8d74bb..866e262467ec 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.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/zh.json b/src/renderer/src/i18n/locales/zh.json index f460f007b2cd..eee013893aa7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.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/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 8217517ad8be..5351920a613b 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -314,6 +314,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.ts b/src/renderer/src/lib/agent-status.ts index 3af7b38def92..d6b00492f234 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -134,7 +134,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/shared/agent-hook-listener-claude-compatible-vendors.test.ts b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts index 601b8aea828f..81eb1f373a01 100644 --- a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts +++ b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts @@ -116,6 +116,65 @@ 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' }) + }) + // Why: Kimi shares Claude-compatible compact/harness hooks; cover the same sticky-working // guards so a Kimi-only regression cannot slip past the Claude-only tests (issue #11352). it('ignores harness-injected UserPromptSubmit for Kimi', () => { diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index fb3647e26516..a1a95ea24163 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -22,6 +22,7 @@ import { normalizeCopilotEvent } from './providers/copilot-events' import { normalizeHermesEvent } from './providers/hermes-events' import { normalizeDevinEvent } from './providers/devin-events' import { normalizeKimiEvent } from './providers/kimi-events' +import { normalizeZcodeEvent } from './providers/zcode-events' export type ProviderDispatchResult = { payload: ParsedAgentStatusPayload | null @@ -149,6 +150,9 @@ export function normalizeProviderEvent(input: { case 'kimi': payload = normalizeKimiEvent(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 39fa150f34be..6c6a46aafb79 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -30,7 +30,8 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo // tool/prompt caches left by the pane's previous session. return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' case 'kimi': - // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. + case 'zcode': + // Why: Kimi Code and ZCode emit Claude-compatible hook events, so UserPromptSubmit is their new-turn boundary too. return eventName === 'UserPromptSubmit' case 'codex': return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' @@ -128,9 +129,10 @@ export function extractToolFields( // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently routing through OpenCode's extractor. switch (source) { case 'claude': - // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. + // Why: Kimi Code and ZCode use Claude's tool_name/tool_input payload fields verbatim. // falls through case 'kimi': + case 'zcode': return extractClaudeToolFields(eventName, hookPayload) case 'codex': return extractCodexToolFields(eventName, hookPayload) diff --git a/src/shared/agent-hook-listener/providers/kimi-events.ts b/src/shared/agent-hook-listener/providers/kimi-events.ts index c35bfef17d04..9a4f8d9eeb69 100644 --- a/src/shared/agent-hook-listener/providers/kimi-events.ts +++ b/src/shared/agent-hook-listener/providers/kimi-events.ts @@ -23,6 +23,24 @@ export function normalizeKimiEvent( promptText: string, paneKey: string, hookPayload: Record +): ParsedAgentStatusPayload | null { + return normalizeClaudeCompatibleAgentEvent( + state, + 'kimi', + eventName, + promptText, + paneKey, + hookPayload + ) +} + +export function normalizeClaudeCompatibleAgentEvent( + state: HookListenerState, + source: 'kimi' | 'zcode', + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record ): ParsedAgentStatusPayload | null { if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { return null @@ -52,8 +70,8 @@ export 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 = @@ -62,9 +80,9 @@ export 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, 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 000000000000..ffe5ac875c04 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/zcode-events.ts @@ -0,0 +1,21 @@ +import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { normalizeClaudeCompatibleAgentEvent } from './kimi-events' + +// Why: ZCode emits Claude-compatible payloads/event names; normalize but attribute to ZCode so the sidebar shows ZCode's icon/label, not Claude's. +export function normalizeZcodeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + return normalizeClaudeCompatibleAgentEvent( + state, + 'zcode', + eventName, + promptText, + paneKey, + hookPayload + ) +} diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts index b90da2462546..f4f9912f4a20 100644 --- a/src/shared/agent-hook-listener/source-routing.ts +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -21,7 +21,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 6abdcb56aa05..fe031513c0f8 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -53,7 +53,8 @@ const AGENT_HOOK_SOURCES = [ 'copilot', 'hermes', 'devin', - 'kimi' + 'kimi', + '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 248638c5079f..0f9052c291f8 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 8ca50cbcea64..f28845a0573d 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -50,7 +50,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 ea024babd324..4d089c32bb9a 100644 --- a/src/shared/agent-name-token-match.ts +++ b/src/shared/agent-name-token-match.ts @@ -27,7 +27,9 @@ export const AGENT_NAMES = [ 'openclaw', 'aider', 'grok', - 'devin' + 'devin', + 'zcode', + 'zcode-cli' ] // 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 56aa3952e002..554ace41d828 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -196,7 +196,8 @@ export function extractAgentProviderSession( case 'droid': // Why: Kimi Code posts a Claude-shaped `session_id` (e.g. session_). // falls through - case 'kimi': { + case 'kimi': + case 'zcode': { const id = readSessionId(payload, ['session_id']) return id ? { key: 'session_id', id } : null } diff --git a/src/shared/agent-status-agent-type.ts b/src/shared/agent-status-agent-type.ts new file mode 100644 index 000000000000..ea8ccf5a99ad --- /dev/null +++ b/src/shared/agent-status-agent-type.ts @@ -0,0 +1,27 @@ +// Agent types are extensible; these names only improve narrowing for built-in integrations. +export type WellKnownAgentType = + | 'claude' + | 'openclaude' + | 'codex' + | 'gemini' + | 'antigravity' + | 'amp' + | 'opencode' + | 'mimo-code' + | 'cursor' + | 'copilot' + | 'aider' + | 'pi' + | 'omp' + | 'prime-agent' + | 'droid' + | 'command-code' + | 'grok' + | 'hermes' + | 'devin' + | 'ante' + | 'trae' + | 'zcode' + | 'unknown' + +export type AgentType = WellKnownAgentType | (string & {}) diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index 51f1695f5080..e97de62a4ee6 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -25,7 +25,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/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts index 7c49e0346cd0..763b082cfa6b 100644 --- a/src/shared/skills-cli-agent-keys.ts +++ b/src/shared/skills-cli-agent-keys.ts @@ -50,7 +50,9 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { devin: 'devin', ante: null, // Why: Orca detects trae by `traecli`, an alias only TRAE CN ships. - trae: 'trae-cn' + trae: 'trae-cn', + // Why: the community skills CLI has no verified ZCode-specific target; ZCode reads the universal .agents root. + zcode: null } satisfies Record /** diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index 9d373903fdb6..2fd9e4d1767e 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -45,6 +45,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-zcode.ts b/src/shared/tui-agent-config-zcode.ts new file mode 100644 index 000000000000..990b39ba9568 --- /dev/null +++ b/src/shared/tui-agent-config-zcode.ts @@ -0,0 +1,8 @@ +import type { TuiAgentConfig } from './tui-agent-config' + +export const ZCODE_TUI_AGENT_CONFIG: TuiAgentConfig = { + detectCmd: 'zcode', + launchCmd: 'zcode', + expectedProcess: 'zcode-cli', + promptInjectionMode: 'stdin-after-start' +} diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 46d382e7075f..990686b6efb4 100644 --- a/src/shared/tui-agent-config.ts +++ b/src/shared/tui-agent-config.ts @@ -1,5 +1,6 @@ import type { TuiAgent } from './tui-agent' import { getOrcaCliCommandNameForPlatform } from './orca-cli-command-name' +import { ZCODE_TUI_AGENT_CONFIG } from './tui-agent-config-zcode' export type AgentPromptInjectionMode = | 'argv' @@ -298,7 +299,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { detectCmd: 'devin', // Why: `devin -- ` auto-submits immediately (docs.devin.ai/cli), so start the REPL with no argv prompt. promptInjectionMode: 'stdin-after-start' - } + }, + zcode: ZCODE_TUI_AGENT_CONFIG } export const TUI_AGENT_CONFIG: Record = Object.fromEntries( diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index 4c2576bddc89..b3908069f437 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -42,7 +42,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-permissions.test.ts b/src/shared/tui-agent-permissions.test.ts index e1fa10e1993f..bdace5cb8de8 100644 --- a/src/shared/tui-agent-permissions.test.ts +++ b/src/shared/tui-agent-permissions.test.ts @@ -76,6 +76,19 @@ describe('tui agent permissions', () => { ).toBe('mixed') }) + it('resolves ZCode permission arguments through the shared profile', () => { + expect( + resolveTuiAgentPermissionMode({ + agent: 'zcode', + agentArgs: YOLO_TUI_AGENT_ARGS.zcode, + agentEnv: {} + }) + ).toBe('yolo') + expect(resolveTuiAgentPermissionMode({ agent: 'zcode', agentArgs: '', agentEnv: {} })).toBe( + 'manual' + ) + }) + it('resolves env-driven yolo launches', () => { expect( resolveTuiAgentPermissionMode({ diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index c0808b41a3d6..b55f490146cb 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -29,7 +29,8 @@ export const YOLO_TUI_AGENT_ARGS: Partial> = { devin: '--permission-mode bypass', ante: '--yolo', trae: '--yolo', - droid: '--auto high' + droid: '--auto high', + zcode: '--mode yolo' } export const YOLO_TUI_AGENT_ENV: Partial>> = { diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 1de6301bd2fd..127fa69689f8 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -40,7 +40,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/tui-agent-startup-zcode.test.ts b/src/shared/tui-agent-startup-zcode.test.ts new file mode 100644 index 000000000000..2e608d09fbfe --- /dev/null +++ b/src/shared/tui-agent-startup-zcode.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { buildAgentStartupPlan } from './tui-agent-startup' + +describe('ZCode startup plans', () => { + it('uses stdin-after-start prompt delivery for the interactive client', () => { + const plan = buildAgentStartupPlan({ + agent: 'zcode', + prompt: 'fix the tests', + cmdOverrides: {}, + platform: 'linux' + }) + + expect(plan).toEqual({ + agent: 'zcode', + launchCommand: 'zcode', + expectedProcess: 'zcode-cli', + followupPrompt: 'fix the tests', + launchConfig: { agentCommand: 'zcode', agentArgs: '', agentEnv: {} } + }) + }) +}) diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index b6901aa8ef15..67044bc39aef 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -38,3 +38,4 @@ export type TuiAgent = | 'ante' // Ante (Antigma Labs) | 'trae' // Trae CLI | 'prime-agent' // Prime Agent (Prime Intellect) + | 'zcode' // ZCode (Z.ai / GLM)