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