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
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
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
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
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
Droid
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
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
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
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
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
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.
Droid
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
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
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
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)