diff --git a/README.md b/README.md
index 8d0b9e796531..0bb122b65fc6 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
@@ -262,6 +263,7 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
## Signed Builds
+
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
## License
diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json
index 65116437cc1e..f05e50ffb55c 100644
--- a/config/tsconfig.cli.json
+++ b/config/tsconfig.cli.json
@@ -97,6 +97,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 86f04ea9fdf0..fb9a5121e92a 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 b015c26449a5..b4c43f9721ee 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 3b1992448fed..c68d8ab8c2df 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 6e53e5904636..c5c2734dedb3 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 9f86dc2cfa3b..ccb3f06c22b6 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 0c619ea260d8..31fc58bf01b0 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 598e762aa77d..617b35820184 100644
--- a/mobile/src/tasks/mobile-tui-agents.ts
+++ b/mobile/src/tasks/mobile-tui-agents.ts
@@ -39,7 +39,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 = {
@@ -78,7 +79,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> = {
@@ -112,7 +114,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/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts
index 4772a763b6c4..7ff920992d0c 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'
export type ManagedAgentHookInstaller = readonly [HookInstallAgent, () => AgentHookInstallStatus]
export type ManagedAgentHookScriptRefresher = readonly [HookInstallAgent, () => Promise]
@@ -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()]
]
// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a
@@ -55,7 +57,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[] = [
@@ -72,7 +75,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_STATUS_READERS: readonly ManagedAgentHookStatusReader[] = [
@@ -89,5 +93,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 b5ea0583ee16..83495beae484 100644
--- a/src/main/agent-hooks/remote-hook-service-installers.test.ts
+++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts
@@ -21,6 +21,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 {
@@ -177,6 +178,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')
}
]
@@ -703,7 +708,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 6471ec5cfb4e..95561dafd96e 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
@@ -68,7 +69,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 117fa50970bc..adf1081af3b3 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',
'mimo-code': null,
'command-code': null
diff --git a/src/main/codex-accounts/service-reset-credit-test-fixtures.ts b/src/main/codex-accounts/service-reset-credit-test-fixtures.ts
index 4578831985aa..65d27737ec96 100644
--- a/src/main/codex-accounts/service-reset-credit-test-fixtures.ts
+++ b/src/main/codex-accounts/service-reset-credit-test-fixtures.ts
@@ -35,6 +35,7 @@ export function createResetRateLimitState(
antigravity: null,
minimax: null,
grok: null,
+ zcode: null,
minimaxCookieConfigured: false,
grokAuthConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: 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/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 5615fa38208c..36031a87584b 100644
--- a/src/main/ipc/agent-hooks.ts
+++ b/src/main/ipc/agent-hooks.ts
@@ -22,6 +22,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 { getZcodeHookStatus } from './agent-hooks-zcode-status'
import { registerAgentPaneAuthorityIpcHandlers } from './agent-pane-authority-ipc'
import { registerAgentStatusRowTeardownIpcHandlers } from './agent-status-row-teardown-ipc'
import { createAgentPaneAuthorityOwnership } from './agent-pane-authority-ownership'
@@ -62,6 +63,7 @@ export function registerAgentHookHandlers(
ipcMain.removeHandler('agentHooks:hermesStatus')
ipcMain.removeHandler('agentHooks:devinStatus')
ipcMain.removeHandler('agentHooks:kimiStatus')
+ ipcMain.removeHandler('agentHooks:zcodeStatus')
ipcMain.removeHandler('agentStatus:getSnapshot')
ipcMain.removeHandler('agentStatus:inferInterrupt')
ipcMain.removeHandler('agentStatus:inferQuestionAnswered')
@@ -286,4 +288,5 @@ export function registerAgentHookHandlers(
}
}
})
+ ipcMain.handle('agentHooks:zcodeStatus', getZcodeHookStatus)
}
diff --git a/src/main/rate-limits/rate-limit-service-test-harness.ts b/src/main/rate-limits/rate-limit-service-test-harness.ts
index 00d3db160029..55d5de3cfe00 100644
--- a/src/main/rate-limits/rate-limit-service-test-harness.ts
+++ b/src/main/rate-limits/rate-limit-service-test-harness.ts
@@ -9,6 +9,7 @@ import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchGrokRateLimits } from './grok-fetcher'
import { readGrokAuthSession } from './grok-auth'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
+import { fetchZcodeRateLimits } from './zcode-usage-fetcher'
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
export type Deferred = {
@@ -89,6 +90,7 @@ export function mockFreshBackgroundProviderFetches(): void {
vi.mocked(fetchKimiRateLimits).mockImplementation(async () => okProvider('kimi', 0))
vi.mocked(fetchMiniMaxRateLimits).mockImplementation(async () => okProvider('minimax', 0))
vi.mocked(fetchGrokRateLimits).mockImplementation(async () => unavailableProvider('grok'))
+ vi.mocked(fetchZcodeRateLimits).mockImplementation(async () => unavailableProvider('zcode'))
}
/** Shared `beforeEach` body: healthy stubs for every provider the service polls. */
@@ -106,6 +108,7 @@ export function resetRateLimitProviderMocks(): void {
error: null,
status: 'unavailable'
})
+ vi.mocked(fetchZcodeRateLimits).mockResolvedValue(unavailableProvider('zcode'))
vi.mocked(hasMiniMaxSessionCookie).mockReturnValue(false)
vi.mocked(readGrokAuthSession).mockReturnValue({ status: 'missing' })
}
diff --git a/src/main/rate-limits/service-account-target-selection.test.ts b/src/main/rate-limits/service-account-target-selection.test.ts
index 86008a4bd846..2a5bcee4683a 100644
--- a/src/main/rate-limits/service-account-target-selection.test.ts
+++ b/src/main/rate-limits/service-account-target-selection.test.ts
@@ -32,6 +32,10 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
+vi.mock('./zcode-usage-fetcher', () => ({
+ fetchZcodeRateLimits: vi.fn()
+}))
+
vi.mock('./minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
diff --git a/src/main/rate-limits/service-inactive-account-previews.test.ts b/src/main/rate-limits/service-inactive-account-previews.test.ts
index e734b52172a4..89645380d462 100644
--- a/src/main/rate-limits/service-inactive-account-previews.test.ts
+++ b/src/main/rate-limits/service-inactive-account-previews.test.ts
@@ -39,6 +39,10 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
+vi.mock('./zcode-usage-fetcher', () => ({
+ fetchZcodeRateLimits: vi.fn()
+}))
+
vi.mock('./minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
diff --git a/src/main/rate-limits/service-live-claude-usage.test.ts b/src/main/rate-limits/service-live-claude-usage.test.ts
index 59c1300532da..99c996ccc7ee 100644
--- a/src/main/rate-limits/service-live-claude-usage.test.ts
+++ b/src/main/rate-limits/service-live-claude-usage.test.ts
@@ -36,6 +36,10 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
+vi.mock('./zcode-usage-fetcher', () => ({
+ fetchZcodeRateLimits: vi.fn()
+}))
+
vi.mock('./minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
diff --git a/src/main/rate-limits/service-minimax-usage.test.ts b/src/main/rate-limits/service-minimax-usage.test.ts
index 737ca0ff03d1..def6427600a4 100644
--- a/src/main/rate-limits/service-minimax-usage.test.ts
+++ b/src/main/rate-limits/service-minimax-usage.test.ts
@@ -33,6 +33,10 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
+vi.mock('./zcode-usage-fetcher', () => ({
+ fetchZcodeRateLimits: vi.fn()
+}))
+
vi.mock('./minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
diff --git a/src/main/rate-limits/service-refresh-orchestration.test.ts b/src/main/rate-limits/service-refresh-orchestration.test.ts
index 7ac7f22f1649..0fa64a9260ef 100644
--- a/src/main/rate-limits/service-refresh-orchestration.test.ts
+++ b/src/main/rate-limits/service-refresh-orchestration.test.ts
@@ -40,6 +40,10 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
+vi.mock('./zcode-usage-fetcher', () => ({
+ fetchZcodeRateLimits: vi.fn()
+}))
+
vi.mock('./minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
diff --git a/src/main/rate-limits/service-window-activation.test.ts b/src/main/rate-limits/service-window-activation.test.ts
index a4a1dc14f767..b85d2ff6fc27 100644
--- a/src/main/rate-limits/service-window-activation.test.ts
+++ b/src/main/rate-limits/service-window-activation.test.ts
@@ -41,6 +41,10 @@ vi.mock('./opencode-go-usage-fetcher', () => ({
fetchOpenCodeGoRateLimits: vi.fn()
}))
+vi.mock('./zcode-usage-fetcher', () => ({
+ fetchZcodeRateLimits: vi.fn()
+}))
+
vi.mock('./minimax-fetcher', () => ({
fetchMiniMaxRateLimits: vi.fn()
}))
diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts
index d6f291d6b38e..4002c5f4c0c5 100644
--- a/src/main/rate-limits/service.ts
+++ b/src/main/rate-limits/service.ts
@@ -28,6 +28,7 @@ import { readGrokAuthSession } from './grok-auth'
import { hasMiniMaxSessionCookie } from '../minimax/minimax-cookie-store'
import { fetchMiniMaxRateLimits } from './minimax-fetcher'
import { fetchOpenCodeGoRateLimits } from './opencode-go-usage-fetcher'
+import { fetchZcodeRateLimits } from './zcode-usage-fetcher'
import {
normalizeCodexAccountSelectionTarget,
type CodexAccountSelectionTarget,
@@ -110,6 +111,7 @@ type InternalRateLimitState = {
antigravity: ProviderRateLimits | null
minimax: ProviderRateLimits | null
grok: ProviderRateLimits | null
+ zcode: ProviderRateLimits | null
}
function normalizePollingInterval(ms: number): number {
@@ -176,7 +178,8 @@ export class RateLimitService {
kimi: null,
antigravity: null,
minimax: null,
- grok: null
+ grok: null,
+ zcode: null
}
private grokAuthConfigured = readGrokAuthSession().status === 'ok'
private pollInterval: number = DEFAULT_POLL_MS
@@ -191,7 +194,8 @@ export class RateLimitService {
kimi: 0,
minimax: 0,
grok: 0,
- antigravity: 0
+ antigravity: 0,
+ zcode: 0
}
// Why: consecutive failures drive exponential backoff of the fast activation-retry lane; reset on any success/unavailable result.
private activeFailureStreakByProvider: Record = {
@@ -202,7 +206,8 @@ export class RateLimitService {
kimi: 0,
minimax: 0,
grok: 0,
- antigravity: 0
+ antigravity: 0,
+ zcode: 0
}
private mainWindow: BrowserWindow | null = null
private detachWindowListeners: (() => void) | null = null
@@ -872,7 +877,8 @@ export class RateLimitService {
kimi: this.state.kimi,
minimax: this.state.minimax,
grok: this.state.grok,
- antigravity: this.state.antigravity
+ antigravity: this.state.antigravity,
+ zcode: this.state.zcode
}
return Object.entries(byProvider).map(([provider, limits]) => ({
provider: provider as ActiveRateLimitProvider,
@@ -1579,6 +1585,7 @@ export class RateLimitService {
| 'minimax'
| 'grok'
| 'antigravity'
+ | 'zcode'
): ProviderRateLimits {
if (!current) {
return {
@@ -1672,7 +1679,8 @@ export class RateLimitService {
minimax: miniMaxConfigChanged
? this.withFetchingStatus(null, 'minimax')
: this.withFetchingStatus(previousState.minimax, 'minimax'),
- grok: this.withFetchingStatus(previousState.grok, 'grok')
+ grok: this.withFetchingStatus(previousState.grok, 'grok'),
+ zcode: this.withFetchingStatus(previousState.zcode, 'zcode')
})
const missingWslCodexHome =
@@ -1689,40 +1697,48 @@ export class RateLimitService {
const claudeFetchGated =
!options?.force && this.shouldSkipAutomatedClaudeFetch(previousState.claude)
- const [claudeResult, codexResult, geminiResult, opencodeGoResult, kimiResult, miniMaxResult] =
- await Promise.allSettled([
- claudeFetchGated
- ? Promise.resolve(previousState.claude as ProviderRateLimits)
- : fetchClaudeRateLimits({
- authPreparation: claudeAuthPreparation,
- allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation),
- allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
- networkProxySettings: this.networkProxySettingsResolver?.(),
- signal
- }),
- codexFetchGated
- ? Promise.resolve(previousState.codex as ProviderRateLimits)
- : (missingWslCodexHome ??
- fetchCodexRateLimits({
- codexHomePath,
- allowPtyFallback: this.shouldAllowCodexPtyFallback(),
- signal
- })),
- fetchGeminiRateLimits(geminiCliOAuthEnabled),
- fetchOpenCodeGoRateLimits(
- cookie,
- workspaceIdOverride || undefined,
- this.networkProxySettingsResolver?.()
- ),
- this.fetchKimiWithResolvedHome(),
- miniMaxConfigResult.error
- ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error))
- : fetchMiniMaxRateLimits({
- cookie: miniMaxCookie,
- groupId: miniMaxGroupId,
- models: miniMaxModels
- })
- ])
+ const [
+ claudeResult,
+ codexResult,
+ geminiResult,
+ opencodeGoResult,
+ kimiResult,
+ miniMaxResult,
+ zcodeResult
+ ] = await Promise.allSettled([
+ claudeFetchGated
+ ? Promise.resolve(previousState.claude as ProviderRateLimits)
+ : fetchClaudeRateLimits({
+ authPreparation: claudeAuthPreparation,
+ allowPtyFallback: this.shouldAllowClaudePtyFallback(claudeAuthPreparation),
+ allowUsagePanelSupplement: this.shouldAllowClaudeUsagePanelSupplement(),
+ networkProxySettings: this.networkProxySettingsResolver?.(),
+ signal
+ }),
+ codexFetchGated
+ ? Promise.resolve(previousState.codex as ProviderRateLimits)
+ : (missingWslCodexHome ??
+ fetchCodexRateLimits({
+ codexHomePath,
+ allowPtyFallback: this.shouldAllowCodexPtyFallback(),
+ signal
+ })),
+ fetchGeminiRateLimits(geminiCliOAuthEnabled),
+ fetchOpenCodeGoRateLimits(
+ cookie,
+ workspaceIdOverride || undefined,
+ this.networkProxySettingsResolver?.()
+ ),
+ this.fetchKimiWithResolvedHome(),
+ miniMaxConfigResult.error
+ ? Promise.resolve(this.getMiniMaxCredentialError(miniMaxConfigResult.error))
+ : fetchMiniMaxRateLimits({
+ cookie: miniMaxCookie,
+ groupId: miniMaxGroupId,
+ models: miniMaxModels
+ }),
+ fetchZcodeRateLimits({ signal })
+ ])
if (signal.aborted) {
return
@@ -1813,6 +1829,20 @@ export class RateLimitService {
status: 'error'
} satisfies ProviderRateLimits)
+ const zcode =
+ zcodeResult.status === 'fulfilled'
+ ? zcodeResult.value
+ : ({
+ provider: 'zcode',
+ session: null,
+ weekly: null,
+ monthly: null,
+ updatedAt: Date.now(),
+ error:
+ zcodeResult.reason instanceof Error ? zcodeResult.reason.message : 'Unknown error',
+ status: 'error'
+ } satisfies ProviderRateLimits)
+
const latestCodexHome = this.resolveCodexHome(codexTarget)
const latestClaudeAuthPreparation = await this.claudeAuthPreparationResolver?.(claudeTarget)
if (signal.aborted) {
@@ -1852,6 +1882,7 @@ export class RateLimitService {
if (shouldApplyMiniMax) {
this.trackActiveFailureStreak('minimax', miniMax)
}
+ this.trackActiveFailureStreak('zcode', zcode)
// Why: apply a Codex result only when provenance and generation still match, else a raced in-flight fetch overwrites the new account.
this.updateState({
@@ -1876,7 +1907,8 @@ export class RateLimitService {
? miniMaxConfigChanged
? miniMax
: this.applyStalePolicy(miniMax, previousState.minimax)
- : this.state.minimax
+ : this.state.minimax,
+ zcode: this.applyStalePolicy(zcode, previousState.zcode)
})
const grokResult = await grokResultPromise
diff --git a/src/main/rate-limits/zcode-usage-fetcher.test.ts b/src/main/rate-limits/zcode-usage-fetcher.test.ts
new file mode 100644
index 000000000000..02e3c0027f29
--- /dev/null
+++ b/src/main/rate-limits/zcode-usage-fetcher.test.ts
@@ -0,0 +1,121 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { fetchZcodeRateLimits } from './zcode-usage-fetcher'
+
+let dir: string
+let configPath: string
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'orca-zcode-usage-'))
+ configPath = join(dir, 'config.json')
+ vi.stubGlobal('fetch', vi.fn())
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-08-12T06:00:00.000Z'))
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.useRealTimers()
+ rmSync(dir, { recursive: true, force: true })
+})
+
+function writeConfig(overrides: Record = {}): void {
+ mkdirSync(dir, { recursive: true })
+ writeFileSync(
+ configPath,
+ JSON.stringify({
+ model: { main: 'bigmodel-coding-plan/GLM-5.2' },
+ provider: {
+ other: { options: { apiKey: 'ignored', baseURL: 'https://example.com/v1' } },
+ 'bigmodel-coding-plan': {
+ options: {
+ apiKey: 'test-secret',
+ baseURL: 'https://open.bigmodel.cn/api/anthropic'
+ }
+ }
+ },
+ ...overrides
+ })
+ )
+}
+
+describe('fetchZcodeRateLimits', () => {
+ it('returns unavailable without a supported Coding Plan credential', async () => {
+ writeFileSync(configPath, JSON.stringify({ provider: {} }))
+
+ const result = await fetchZcodeRateLimits({ configPath })
+
+ expect(result).toMatchObject({ provider: 'zcode', status: 'unavailable' })
+ expect(fetch).not.toHaveBeenCalled()
+ })
+
+ it('queries the matching quota endpoint and maps rolling, weekly, and MCP limits', async () => {
+ writeConfig()
+ vi.mocked(fetch).mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ success: true,
+ data: {
+ level: 'max',
+ limits: [
+ { type: 'TIME_LIMIT', percentage: 3, nextResetTime: 1_787_000_000_000 },
+ { type: 'TOKENS_LIMIT', percentage: 44, nextResetTime: 1_786_600_000_000 },
+ { type: 'TOKENS_LIMIT', percentage: 12, nextResetTime: 1_786_500_000_000 }
+ ]
+ }
+ })
+ )
+ )
+
+ const result = await fetchZcodeRateLimits({ configPath })
+
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
+ expect.objectContaining({
+ method: 'GET',
+ headers: expect.objectContaining({ Authorization: 'test-secret' })
+ })
+ )
+ expect(result).toMatchObject({ provider: 'zcode', status: 'ok', planType: 'max' })
+ expect(result.session).toEqual({
+ usedPercent: 12,
+ windowMinutes: 300,
+ resetsAt: 1_786_500_000_000,
+ resetDescription: null
+ })
+ expect(result.weekly?.usedPercent).toBe(44)
+ expect(result.monthly?.usedPercent).toBe(3)
+ })
+
+ it('supports the Z.AI endpoint without exposing credentials in errors', async () => {
+ writeConfig({
+ model: { main: 'zai/GLM-5.2' },
+ provider: {
+ zai: { options: { apiKey: 'never-log-me', baseURL: 'https://api.z.ai/api/anthropic' } }
+ }
+ })
+ vi.mocked(fetch).mockResolvedValue(new Response('denied', { status: 401 }))
+
+ const result = await fetchZcodeRateLimits({ configPath })
+
+ expect(fetch).toHaveBeenCalledWith(
+ 'https://api.z.ai/api/monitor/usage/quota/limit',
+ expect.any(Object)
+ )
+ expect(result.status).toBe('error')
+ expect(result.error).toBe('ZCode quota request failed (401)')
+ expect(JSON.stringify(result)).not.toContain('never-log-me')
+ })
+
+ it('rejects malformed successful responses', async () => {
+ writeConfig()
+ vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ success: true, data: {} })))
+
+ const result = await fetchZcodeRateLimits({ configPath })
+
+ expect(result.status).toBe('error')
+ expect(result.usageMetadata?.failureKind).toBe('parse')
+ })
+})
diff --git a/src/main/rate-limits/zcode-usage-fetcher.ts b/src/main/rate-limits/zcode-usage-fetcher.ts
new file mode 100644
index 000000000000..6175f49c8ff0
--- /dev/null
+++ b/src/main/rate-limits/zcode-usage-fetcher.ts
@@ -0,0 +1,207 @@
+import { readFileSync } from 'node:fs'
+import { homedir } from 'node:os'
+import { join } from 'node:path'
+import type { ProviderRateLimits, RateLimitWindow } from '../../shared/rate-limit-types'
+
+const API_TIMEOUT_MS = 15_000
+const SUPPORTED_HOSTS = new Set(['api.z.ai', 'open.bigmodel.cn', 'dev.bigmodel.cn'])
+
+type ZcodeProviderOptions = {
+ apiKey?: unknown
+ baseURL?: unknown
+}
+
+type ZcodeConfig = {
+ model?: { main?: unknown }
+ provider?: Record
+}
+
+type QuotaLimit = {
+ type?: unknown
+ percentage?: unknown
+ nextResetTime?: unknown
+}
+
+type QuotaResponse = {
+ success?: unknown
+ msg?: unknown
+ data?: {
+ level?: unknown
+ limits?: unknown
+ }
+}
+
+type ZcodeUsageCredentials = {
+ apiKey: string
+ quotaUrl: string
+}
+
+function unavailable(error: string): ProviderRateLimits {
+ return {
+ provider: 'zcode',
+ session: null,
+ weekly: null,
+ monthly: null,
+ updatedAt: Date.now(),
+ error,
+ status: 'unavailable',
+ usageMetadata: { source: 'web', failureKind: 'missing-credentials' }
+ }
+}
+
+function failed(error: string, failureKind: 'network' | 'server' | 'parse'): ProviderRateLimits {
+ return {
+ provider: 'zcode',
+ session: null,
+ weekly: null,
+ monthly: null,
+ updatedAt: Date.now(),
+ error,
+ status: 'error',
+ usageMetadata: { source: 'web', failureKind }
+ }
+}
+
+function readCredentials(configPath: string): ZcodeUsageCredentials | null {
+ let config: ZcodeConfig
+ try {
+ config = JSON.parse(readFileSync(configPath, 'utf8')) as ZcodeConfig
+ } catch {
+ return null
+ }
+
+ const mainProvider =
+ typeof config.model?.main === 'string' ? config.model.main.split('/', 1)[0] : null
+ const candidates = Object.entries(config.provider ?? {}).sort(([left], [right]) => {
+ if (left === mainProvider) {
+ return -1
+ }
+ if (right === mainProvider) {
+ return 1
+ }
+ return 0
+ })
+
+ for (const [, provider] of candidates) {
+ const apiKey = provider.options?.apiKey
+ const baseURL = provider.options?.baseURL
+ if (typeof apiKey !== 'string' || !apiKey.trim() || typeof baseURL !== 'string') {
+ continue
+ }
+ try {
+ const parsed = new URL(baseURL)
+ if (parsed.protocol !== 'https:' || !SUPPORTED_HOSTS.has(parsed.hostname)) {
+ continue
+ }
+ return {
+ apiKey: apiKey.trim(),
+ quotaUrl: `${parsed.origin}/api/monitor/usage/quota/limit`
+ }
+ } catch {
+ continue
+ }
+ }
+ return null
+}
+
+function asPercentage(value: unknown): number | null {
+ return typeof value === 'number' && Number.isFinite(value)
+ ? Math.min(100, Math.max(0, value))
+ : null
+}
+
+function asResetTime(value: unknown): number | null {
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
+}
+
+function asWindow(limit: QuotaLimit | undefined, windowMinutes: number): RateLimitWindow | null {
+ if (!limit) {
+ return null
+ }
+ const usedPercent = asPercentage(limit.percentage)
+ if (usedPercent === null) {
+ return null
+ }
+ return {
+ usedPercent,
+ windowMinutes,
+ resetsAt: asResetTime(limit.nextResetTime),
+ resetDescription: null
+ }
+}
+
+export async function fetchZcodeRateLimits(
+ options: {
+ configPath?: string
+ signal?: AbortSignal
+ } = {}
+): Promise {
+ const configPath = options.configPath ?? join(homedir(), '.zcode', 'cli', 'config.json')
+ const credentials = readCredentials(configPath)
+ if (!credentials) {
+ return unavailable('ZCode Coding Plan credentials are not configured')
+ }
+
+ let response: Response
+ try {
+ const signal = options.signal
+ ? AbortSignal.any([options.signal, AbortSignal.timeout(API_TIMEOUT_MS)])
+ : AbortSignal.timeout(API_TIMEOUT_MS)
+ response = await fetch(credentials.quotaUrl, {
+ method: 'GET',
+ headers: {
+ Authorization: credentials.apiKey,
+ 'Accept-Language': 'en-US,en',
+ 'Content-Type': 'application/json'
+ },
+ signal
+ })
+ } catch (error) {
+ return failed(error instanceof Error ? error.message : 'ZCode quota request failed', 'network')
+ }
+
+ if (!response.ok) {
+ return failed(`ZCode quota request failed (${response.status})`, 'server')
+ }
+
+ let payload: QuotaResponse
+ try {
+ payload = (await response.json()) as QuotaResponse
+ } catch {
+ return failed('Could not parse ZCode quota response', 'parse')
+ }
+ if (payload.success !== true || !Array.isArray(payload.data?.limits)) {
+ const message = typeof payload.msg === 'string' ? payload.msg : 'Invalid ZCode quota response'
+ return failed(message, 'parse')
+ }
+
+ const limits = payload.data.limits.filter(
+ (value): value is QuotaLimit => typeof value === 'object' && value !== null
+ )
+ const tokenLimits = limits
+ .filter((limit) => limit.type === 'TOKENS_LIMIT')
+ .sort(
+ (left, right) =>
+ (asResetTime(left.nextResetTime) ?? Number.MAX_SAFE_INTEGER) -
+ (asResetTime(right.nextResetTime) ?? Number.MAX_SAFE_INTEGER)
+ )
+ const monthlyLimit = limits.find((limit) => limit.type === 'TIME_LIMIT')
+ const session = asWindow(tokenLimits[0], 300)
+ const weekly = asWindow(tokenLimits[1], 10080)
+ const monthly = asWindow(monthlyLimit, 43200)
+ if (!session && !weekly && !monthly) {
+ return failed('ZCode quota response contained no usable limits', 'parse')
+ }
+
+ return {
+ provider: 'zcode',
+ session,
+ weekly,
+ monthly,
+ planType: typeof payload.data.level === 'string' ? payload.data.level : null,
+ updatedAt: Date.now(),
+ error: null,
+ status: 'ok',
+ usageMetadata: { source: 'web', credentialSource: configPath }
+ }
+}
diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts
index a7940828d883..b6e373729c0d 100644
--- a/src/main/runtime/rpc/methods/client-ui-schemas.ts
+++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts
@@ -69,6 +69,7 @@ const StatusBarItem = z.enum([
'kimi',
'minimax',
'grok',
+ 'zcode',
'ssh',
'resource-usage',
'ports'
@@ -229,6 +230,7 @@ const UiUpdateFields = z
_minimaxStatusBarDefaultAdded: z.boolean().optional(),
_antigravityStatusBarDefaultAdded: z.boolean().optional(),
_grokStatusBarDefaultAdded: z.boolean().optional(),
+ _zcodeStatusBarDefaultAdded: z.boolean().optional(),
statusBarVisible: z.boolean().optional(),
usagePercentageDisplay: z.enum(['used', 'remaining']).optional(),
statusBarUsageMode: z.enum(['verbose', 'compact']).optional(),
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/preload/api/agent-hook-api.ts b/src/preload/api/agent-hook-api.ts
index c4116aa2b6d1..da613b324df8 100644
--- a/src/preload/api/agent-hook-api.ts
+++ b/src/preload/api/agent-hook-api.ts
@@ -49,4 +49,5 @@ export type AgentHooksApi = {
copilotStatus: () => Promise
hermesStatus: () => Promise
devinStatus: () => Promise
+ zcodeStatus: () => Promise
}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 4c30124a6da0..f13286a6b55b 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -2268,7 +2268,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/components/settings/appearance-status-bar-grok-toggle-search.ts b/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts
index f2709bbe8a7a..b0d6475edb1d 100644
--- a/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts
+++ b/src/renderer/src/components/settings/appearance-status-bar-grok-toggle-search.ts
@@ -2,36 +2,62 @@ import type { StatusBarItem } from '../../../../shared/ui-chrome-types'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
-export function getGrokStatusBarToggleSearchEntry(): {
+type StatusBarToggleSearchEntry = {
id: StatusBarItem
title: string
description: string
keywords: string[]
toggleDescription: string
-} {
- return {
- id: 'grok',
- title: translate('auto.components.settings.appearance.search.f8e2a1c4b6', 'Grok Usage'),
- description: translate(
- 'auto.components.settings.appearance.search.e7d1b0f3a5',
- 'Show Grok weekly credit usage from Grok CLI OAuth.'
- ),
- keywords: [
- ...translateSearchKeyword(
- 'auto.components.settings.appearance.search.896eb53fd4',
- 'status bar'
+}
+
+export function getExtendedUsageStatusBarToggleSearchEntries(): StatusBarToggleSearchEntry[] {
+ return [
+ {
+ id: 'grok',
+ title: translate('auto.components.settings.appearance.search.f8e2a1c4b6', 'Grok Usage'),
+ description: translate(
+ 'auto.components.settings.appearance.search.e7d1b0f3a5',
+ 'Show Grok weekly credit usage from Grok CLI OAuth.'
+ ),
+ keywords: [
+ ...translateSearchKeyword(
+ 'auto.components.settings.appearance.search.896eb53fd4',
+ 'status bar'
+ ),
+ ...translateSearchKeyword('auto.components.settings.appearance.search.d6c0a9e2f4', 'grok'),
+ ...translateSearchKeyword('auto.components.settings.appearance.search.c5b9f8d1e3', 'xai'),
+ ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'),
+ ...translateSearchKeyword(
+ 'auto.components.settings.appearance.search.de586def95',
+ 'subscription'
+ )
+ ],
+ toggleDescription: translate(
+ 'settings.appearance.statusBar.grokToggleDescription',
+ 'Show Grok subscription credit usage when signed in via Grok CLI.'
+ )
+ },
+ {
+ id: 'zcode',
+ title: translate('auto.components.settings.appearance.search.zcodeUsageTitle', 'ZCode Usage'),
+ description: translate(
+ 'auto.components.settings.appearance.search.zcodeUsageDescription',
+ 'Show ZCode Coding Plan quota usage in the status bar.'
),
- ...translateSearchKeyword('auto.components.settings.appearance.search.d6c0a9e2f4', 'grok'),
- ...translateSearchKeyword('auto.components.settings.appearance.search.c5b9f8d1e3', 'xai'),
- ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'),
- ...translateSearchKeyword(
- 'auto.components.settings.appearance.search.de586def95',
- 'subscription'
+ keywords: [
+ ...translateSearchKeyword(
+ 'auto.components.settings.appearance.search.896eb53fd4',
+ 'status bar'
+ ),
+ ...translateSearchKeyword('auto.components.settings.appearance.search.zcode', 'zcode'),
+ ...translateSearchKeyword('auto.components.settings.appearance.search.00a028f25f', 'usage'),
+ ...translateSearchKeyword('auto.components.settings.appearance.search.zai', 'zai'),
+ ...translateSearchKeyword('auto.components.settings.appearance.search.glm', 'glm')
+ ],
+ toggleDescription: translate(
+ 'settings.appearance.statusBar.zcodeToggleDescription',
+ 'Show ZCode Coding Plan quota usage.'
)
- ],
- toggleDescription: translate(
- 'settings.appearance.statusBar.grokToggleDescription',
- 'Show Grok subscription credit usage when signed in via Grok CLI.'
- )
- }
+ }
+ ]
}
diff --git a/src/renderer/src/components/settings/appearance-status-bar-search.ts b/src/renderer/src/components/settings/appearance-status-bar-search.ts
index 9be73da6a912..71242405558d 100644
--- a/src/renderer/src/components/settings/appearance-status-bar-search.ts
+++ b/src/renderer/src/components/settings/appearance-status-bar-search.ts
@@ -3,7 +3,7 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog'
import { translate } from '@/i18n/i18n'
import { translateSearchKeyword } from './settings-search-keywords'
import { getAntigravityStatusBarToggleSearchEntry } from './appearance-status-bar-antigravity-toggle-search'
-import { getGrokStatusBarToggleSearchEntry } from './appearance-status-bar-grok-toggle-search'
+import { getExtendedUsageStatusBarToggleSearchEntries } from './appearance-status-bar-grok-toggle-search'
export const getStatusBarToggles = createLocalizedCatalog(
(): readonly {
@@ -199,7 +199,7 @@ export const getStatusBarToggles = createLocalizedCatalog(
'Show MiniMax subscription usage for the active workspace.'
)
},
- getGrokStatusBarToggleSearchEntry(),
+ ...getExtendedUsageStatusBarToggleSearchEntries(),
{
id: 'ssh',
title: translate('auto.components.settings.appearance.search.57fb424c56', 'Remote Hosts'),
diff --git a/src/renderer/src/components/stats/GrokUsagePane.test.tsx b/src/renderer/src/components/stats/GrokUsagePane.test.tsx
index d1ef3700f834..1549290e5100 100644
--- a/src/renderer/src/components/stats/GrokUsagePane.test.tsx
+++ b/src/renderer/src/components/stats/GrokUsagePane.test.tsx
@@ -24,6 +24,7 @@ const mockStoreState = {
kimi: null,
antigravity: null,
minimax: null,
+ zcode: null,
grok: {
provider: 'grok',
session: null,
diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx
index 436da40e59c3..d69b375879e5 100644
--- a/src/renderer/src/components/status-bar/StatusBar.tsx
+++ b/src/renderer/src/components/status-bar/StatusBar.tsx
@@ -1160,6 +1160,8 @@ function getProviderLetter(provider: ProviderRateLimits['provider']): string {
return 'M'
case 'grok':
return 'R'
+ case 'zcode':
+ return 'Z'
case 'codex':
return 'X'
}
@@ -2106,7 +2108,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
return null
}
- const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok } = rateLimits
+ const { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, zcode } = rateLimits
// Why: a bar is earned by a live snapshot or durable Settings setup; detection-gating hides per-CLI bars when the agent isn't on PATH.
// Why: Antigravity has no persisted credential, so a checked status item + detected CLI is the durable "show its slot" signal.
@@ -2128,6 +2130,7 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
const visibleAntigravity = getVisibleUsageProvider('antigravity', antigravity, usageSettings)
const visibleMiniMax = getVisibleUsageProvider('minimax', minimax, usageSettings)
const visibleGrok = getVisibleUsageProvider('grok', grok, usageSettings)
+ const visibleZcode = getVisibleUsageProvider('zcode', zcode, usageSettings)
const showClaude =
visibleClaude !== null &&
statusBarItems.includes('claude') &&
@@ -2154,6 +2157,10 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
visibleGrok !== null &&
statusBarItems.includes('grok') &&
isStatusBarItemAvailable('grok', detectedAgentIds)
+ const showZcode =
+ visibleZcode !== null &&
+ statusBarItems.includes('zcode') &&
+ isStatusBarItemAvailable('zcode', detectedAgentIds)
// Why: OpenCode Go is web/cookie-auth, not a CLI on PATH, so detection-gating doesn't apply.
const visibleOpencodeGo = getVisibleUsageProvider('opencode-go', opencodeGo, usageSettings)
const showOpencodeGo = visibleOpencodeGo !== null && statusBarItems.includes('opencode-go')
@@ -2171,11 +2178,12 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
showKimi ||
showAntigravity ||
showMiniMax ||
- showGrok
+ showGrok ||
+ showZcode
const anyVisible = hasVisibleUsageMeters || showResourceUsage
// Why: include Settings so durable managed accounts count — a configured user isn't shown the empty state while snapshots hydrate.
const isEmptyUsageState = isUsageEmptyState(
- { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok },
+ { claude, codex, gemini, opencodeGo, kimi, antigravity, minimax, grok, zcode },
usageSettings
)
// Why: one-time nudge — once dismissed, stays hidden even if providers reconnect later.
@@ -2188,7 +2196,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
kimi?.status === 'fetching' ||
antigravity?.status === 'fetching' ||
minimax?.status === 'fetching' ||
- grok?.status === 'fetching'
+ grok?.status === 'fetching' ||
+ zcode?.status === 'fetching'
const compact = containerWidth < 900
const iconOnly = containerWidth < 500
@@ -2207,7 +2216,8 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
showOpencodeGo ? visibleOpencodeGo : null,
showKimi ? visibleKimi : null,
showMiniMax ? visibleMiniMax : null,
- showGrok ? visibleGrok : null
+ showGrok ? visibleGrok : null,
+ showZcode ? visibleZcode : null
].filter((p): p is ProviderRateLimits => p !== null)
const handleManageAccounts = (): void => {
@@ -2546,6 +2556,18 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
{translate('auto.components.status.bar.StatusBar.grokUsageMenu', 'Grok Usage')}
)}
+ {isStatusBarItemAvailable('zcode', detectedAgentIds) && (
+ {
+ recordFeatureInteraction('usage-tracking')
+ toggleStatusBarItem('zcode')
+ }}
+ >
+
+ {translate('auto.components.status.bar.StatusBar.zcodeUsageMenu', 'ZCode Usage')}
+
+ )}
{
diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts
index cd98315df505..4c7ada35a47d 100644
--- a/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts
+++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.test.ts
@@ -20,6 +20,7 @@ describe('isStatusBarItemAvailable', () => {
expect(isStatusBarItemAvailable('gemini', null)).toBe(true)
expect(isStatusBarItemAvailable('antigravity', null)).toBe(true)
expect(isStatusBarItemAvailable('grok', null)).toBe(true)
+ expect(isStatusBarItemAvailable('zcode', null)).toBe(true)
})
it('hides CLI items not detected on PATH', () => {
@@ -28,6 +29,7 @@ describe('isStatusBarItemAvailable', () => {
expect(isStatusBarItemAvailable('gemini', ['claude', 'codex'])).toBe(false)
expect(isStatusBarItemAvailable('antigravity', ['claude', 'codex'])).toBe(false)
expect(isStatusBarItemAvailable('grok', ['claude', 'kimi'])).toBe(false)
+ expect(isStatusBarItemAvailable('zcode', ['claude', 'kimi'])).toBe(false)
})
it('shows CLI items detected on PATH', () => {
@@ -36,5 +38,6 @@ describe('isStatusBarItemAvailable', () => {
expect(isStatusBarItemAvailable('gemini', ['gemini'])).toBe(true)
expect(isStatusBarItemAvailable('antigravity', ['antigravity'])).toBe(true)
expect(isStatusBarItemAvailable('grok', ['grok'])).toBe(true)
+ expect(isStatusBarItemAvailable('zcode', ['zcode'])).toBe(true)
})
})
diff --git a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts
index 145abef3a900..85f641b96d2d 100644
--- a/src/renderer/src/components/status-bar/status-bar-agent-gating.ts
+++ b/src/renderer/src/components/status-bar/status-bar-agent-gating.ts
@@ -13,7 +13,8 @@ const CLI_GATED_ITEMS: ReadonlySet = new Set([
'gemini',
'kimi',
'antigravity',
- 'grok'
+ 'grok',
+ 'zcode'
])
export function isStatusBarItemAvailable(
diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts
index e833bce7fa4c..f2d493a46240 100644
--- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts
+++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.test.ts
@@ -373,7 +373,8 @@ describe('isUsageEmptyState', () => {
kimi: null,
antigravity: null,
minimax: null,
- grok: null
+ grok: null,
+ zcode: null
},
usageSettings()
)
diff --git a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts
index f1afd97f5f4b..1309a1434a99 100644
--- a/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts
+++ b/src/renderer/src/components/status-bar/status-bar-provider-visibility.ts
@@ -28,6 +28,8 @@ type UsageProviderSnapshots = {
antigravity: ProviderRateLimits | null | undefined
minimax: ProviderRateLimits | null | undefined
grok: ProviderRateLimits | null | undefined
+ // Optional for renderer HMR against an older main process and persisted test fixtures.
+ zcode?: ProviderRateLimits | null
}
type UsageProviderId = ProviderRateLimits['provider']
@@ -165,7 +167,8 @@ export function isUsageEmptyState(
isProviderSnapshotPending(providers.kimi) ||
antigravitySnapshotPending ||
isProviderSnapshotPending(providers.minimax) ||
- isProviderSnapshotPending(providers.grok)
+ isProviderSnapshotPending(providers.grok) ||
+ (providers.zcode !== undefined && isProviderSnapshotPending(providers.zcode))
) {
return false
}
@@ -178,6 +181,7 @@ export function isUsageEmptyState(
!isProviderConfigured(providers.kimi) &&
!isProviderConfigured(providers.antigravity) &&
!isProviderConfigured(providers.minimax) &&
- !isProviderConfigured(providers.grok)
+ !isProviderConfigured(providers.grok) &&
+ !isProviderConfigured(providers.zcode)
)
}
diff --git a/src/renderer/src/components/status-bar/tooltip.tsx b/src/renderer/src/components/status-bar/tooltip.tsx
index db585ab64f7f..940c392ded5c 100644
--- a/src/renderer/src/components/status-bar/tooltip.tsx
+++ b/src/renderer/src/components/status-bar/tooltip.tsx
@@ -96,6 +96,9 @@ export function ProviderIcon({ provider }: { provider: string }): React.JSX.Elem
if (provider === 'grok') {
return
}
+ if (provider === 'zcode') {
+ return
+ }
return
}
diff --git a/src/renderer/src/components/status-bar/usage-error-copy.ts b/src/renderer/src/components/status-bar/usage-error-copy.ts
index 39032ab5e2c3..44805839cc27 100644
--- a/src/renderer/src/components/status-bar/usage-error-copy.ts
+++ b/src/renderer/src/components/status-bar/usage-error-copy.ts
@@ -26,6 +26,9 @@ export function getProviderDisplayName(provider: ProviderRateLimits['provider'])
if (provider === 'grok') {
return 'Grok'
}
+ if (provider === 'zcode') {
+ return 'ZCode'
+ }
return provider
}
diff --git a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts
index 59ea71042490..bef082ab1411 100644
--- a/src/renderer/src/components/status-bar/usage-provider-settings-target.ts
+++ b/src/renderer/src/components/status-bar/usage-provider-settings-target.ts
@@ -19,7 +19,9 @@ export function getUsageProviderAccountsSectionId(
case 'grok':
return 'accounts-grok'
case 'kimi':
+ case 'zcode':
// Why: Orca must not mutate Kimi's CLI-owned credential lifecycle.
+ // ZCode likewise owns its Coding Plan credential in ~/.zcode/cli/config.json.
return null
}
}
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 d320287b5a31..7350522d9433 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -60,7 +60,8 @@
"resourceUsageToggleDescription": "Show the Resource Manager. Click it for CPU, memory, sessions, daemon controls, and workspace disk scans.",
"portsToggleDescription": "Show live workspace ports. Click it for workspace-scoped ports and external listeners.",
"antigravityToggleDescription": "Show Antigravity subscription usage for the active workspace.",
- "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI."
+ "grokToggleDescription": "Show Grok subscription credit usage when signed in via Grok CLI.",
+ "zcodeToggleDescription": "Show ZCode Coding Plan quota usage."
},
"menuBarIcon": {
"title": "Show Menu Bar Icon",
@@ -422,6 +423,7 @@
"agent": {
"catalog": {
"5dff448636": "OpenClaw",
+ "a7c0de2012": "ZCode",
"8a9ba743cc": "Hermes",
"4e63c7b956": "Rovo Dev",
"bee242fe3d": "Qwen Code",
@@ -3513,7 +3515,8 @@
"grokUsageMenu": "Grok Usage",
"floatingTerminalNewActivity": "{{label}}, new activity",
"codexSignInSuccess": "Signed in to Codex",
- "codexSignInError": "Codex sign-in failed. Please try again."
+ "codexSignInError": "Codex sign-in failed. Please try again.",
+ "zcodeUsageMenu": "ZCode Usage"
},
"StatusBarUsageEmptyCta": {
"828c764a79": "Connect an account",
@@ -8749,7 +8752,9 @@
"d6c0a9e2f4": "grok",
"c5b9f8d1e3": "xai",
"usagePercentageDisplayTitle": "Usage percentages",
- "usagePercentageDisplayDescription": "Choose whether provider limits show the percentage used or remaining."
+ "usagePercentageDisplayDescription": "Choose whether provider limits show the percentage used or remaining.",
+ "zcodeUsageTitle": "ZCode Usage",
+ "zcodeUsageDescription": "Show ZCode Coding Plan quota usage in the status bar."
}
},
"auto": {
diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json
index 11cbc9194570..da5a82efd0d9 100644
--- a/src/renderer/src/i18n/locales/es.json
+++ b/src/renderer/src/i18n/locales/es.json
@@ -317,6 +317,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 4ee70910f299..c982feb843f5 100644
--- a/src/renderer/src/i18n/locales/ja.json
+++ b/src/renderer/src/i18n/locales/ja.json
@@ -317,6 +317,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 57319f410e41..4b940a4e713f 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -320,6 +320,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 aa041bc16b05..b43ddc2c59fc 100644
--- a/src/renderer/src/i18n/locales/zh.json
+++ b/src/renderer/src/i18n/locales/zh.json
@@ -320,6 +320,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 b60751839471..ee7ffb68da67 100644
--- a/src/renderer/src/lib/agent-catalog.tsx
+++ b/src/renderer/src/lib/agent-catalog.tsx
@@ -306,6 +306,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 94933a05bffa..40a09872c0f8 100644
--- a/src/renderer/src/lib/agent-status.ts
+++ b/src/renderer/src/lib/agent-status.ts
@@ -133,7 +133,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/store/slices/rate-limits.ts b/src/renderer/src/store/slices/rate-limits.ts
index 3df495cf32d6..8aa2e2978e6b 100644
--- a/src/renderer/src/store/slices/rate-limits.ts
+++ b/src/renderer/src/store/slices/rate-limits.ts
@@ -25,6 +25,7 @@ export const createRateLimitSlice: StateCreator {
'kimi',
'minimax',
'antigravity',
- 'grok'
+ 'grok',
+ 'zcode'
])
expect(setUI).toHaveBeenCalledWith({
statusBarItems: [
@@ -149,13 +150,15 @@ describe('createUISlice hydratePersistedUI', () => {
'kimi',
'minimax',
'antigravity',
- 'grok'
+ 'grok',
+ 'zcode'
],
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true,
_minimaxStatusBarDefaultAdded: true,
_antigravityStatusBarDefaultAdded: true,
- _grokStatusBarDefaultAdded: true
+ _grokStatusBarDefaultAdded: true,
+ _zcodeStatusBarDefaultAdded: true
})
})
@@ -171,7 +174,8 @@ describe('createUISlice hydratePersistedUI', () => {
_kimiStatusBarDefaultAdded: true,
_minimaxStatusBarDefaultAdded: true,
_antigravityStatusBarDefaultAdded: true,
- _grokStatusBarDefaultAdded: true
+ _grokStatusBarDefaultAdded: true,
+ _zcodeStatusBarDefaultAdded: true
})
)
diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts
index 7dc72db2ba70..bb69b6a4624d 100644
--- a/src/renderer/src/store/slices/ui.ts
+++ b/src/renderer/src/store/slices/ui.ts
@@ -287,6 +287,7 @@ const DEFAULT_ON_KIMI_STATUS_BAR_ITEM: StatusBarItem = 'kimi'
const DEFAULT_ON_MINIMAX_STATUS_BAR_ITEM: StatusBarItem = 'minimax'
const DEFAULT_ON_ANTIGRAVITY_STATUS_BAR_ITEM: StatusBarItem = 'antigravity'
const DEFAULT_ON_GROK_STATUS_BAR_ITEM: StatusBarItem = 'grok'
+const DEFAULT_ON_ZCODE_STATUS_BAR_ITEM: StatusBarItem = 'zcode'
function normalizeHydratedVisibleWorkspaceHostIds(ui: PersistedUIState): VisibleWorkspaceHostIds {
const visibleHostIds = normalizeVisibleExecutionHostIds(ui.visibleWorkspaceHostIds)
@@ -2471,22 +2472,28 @@ export const createUISlice: StateCreator = (set, get)
ui._grokStatusBarDefaultAdded || statusBarItemsWithAntigravity.includes('grok')
? statusBarItemsWithAntigravity
: [...statusBarItemsWithAntigravity, DEFAULT_ON_GROK_STATUS_BAR_ITEM]
+ const statusBarItemsWithZcode =
+ ui._zcodeStatusBarDefaultAdded || statusBarItemsWithGrok.includes('zcode')
+ ? statusBarItemsWithGrok
+ : [...statusBarItemsWithGrok, DEFAULT_ON_ZCODE_STATUS_BAR_ITEM]
if (
(!ui._portsStatusBarDefaultAdded ||
!ui._kimiStatusBarDefaultAdded ||
!ui._minimaxStatusBarDefaultAdded ||
!ui._antigravityStatusBarDefaultAdded ||
- !ui._grokStatusBarDefaultAdded) &&
+ !ui._grokStatusBarDefaultAdded ||
+ !ui._zcodeStatusBarDefaultAdded) &&
typeof window !== 'undefined'
) {
window.api.ui
.set({
- statusBarItems: statusBarItemsWithGrok,
+ statusBarItems: statusBarItemsWithZcode,
_portsStatusBarDefaultAdded: true,
_kimiStatusBarDefaultAdded: true,
_minimaxStatusBarDefaultAdded: true,
_antigravityStatusBarDefaultAdded: true,
- _grokStatusBarDefaultAdded: true
+ _grokStatusBarDefaultAdded: true,
+ _zcodeStatusBarDefaultAdded: true
})
.catch(console.error)
}
@@ -2557,7 +2564,7 @@ export const createUISlice: StateCreator = (set, get)
workspaceBoardOpacity: clampWorkspaceBoardOpacity(ui.workspaceBoardOpacity),
workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth(ui.workspaceBoardColumnWidth),
syncTaskStatusFromWorkspaceBoard: ui.syncTaskStatusFromWorkspaceBoard === true,
- statusBarItems: statusBarItemsWithGrok,
+ statusBarItems: statusBarItemsWithZcode,
statusBarVisible: ui.statusBarVisible ?? true,
usagePercentageDisplay: normalizeUsagePercentageDisplay(ui.usagePercentageDisplay),
statusBarUsageMode: normalizeStatusBarUsageMode(ui.statusBarUsageMode),
diff --git a/src/renderer/src/web/preload-api/web-agent-hooks-api.ts b/src/renderer/src/web/preload-api/web-agent-hooks-api.ts
index 41ed387d5595..14ecc8384b7c 100644
--- a/src/renderer/src/web/preload-api/web-agent-hooks-api.ts
+++ b/src/renderer/src/web/preload-api/web-agent-hooks-api.ts
@@ -16,6 +16,7 @@ export function createAgentHooksApi(): NonNullable['agentHoo
| 'copilot'
| 'hermes'
| 'devin'
+ | 'zcode'
) =>
Promise.resolve({
agent,
@@ -37,6 +38,7 @@ export function createAgentHooksApi(): NonNullable['agentHoo
grokStatus: () => status('grok'),
copilotStatus: () => status('copilot'),
hermesStatus: () => status('hermes'),
- devinStatus: () => status('devin')
+ devinStatus: () => status('devin'),
+ zcodeStatus: () => status('zcode')
}
}
diff --git a/src/renderer/src/web/preload-api/web-rate-limits-api.ts b/src/renderer/src/web/preload-api/web-rate-limits-api.ts
index 7587a8386606..53b4a6b4c6cf 100644
--- a/src/renderer/src/web/preload-api/web-rate-limits-api.ts
+++ b/src/renderer/src/web/preload-api/web-rate-limits-api.ts
@@ -12,6 +12,7 @@ export function createRateLimitsApi(): NonNullable['rateLimi
antigravity: null,
minimax: null,
grok: null,
+ zcode: null,
minimaxCookieConfigured: false,
grokAuthConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
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 473bb557d09e..3516eebf2bf5 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
@@ -148,6 +149,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 e74de1fdc446..8ac0ded36bd1 100644
--- a/src/shared/agent-hook-listener/provider-event-routing.ts
+++ b/src/shared/agent-hook-listener/provider-event-routing.ts
@@ -43,7 +43,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'
@@ -137,9 +138,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 c51a8be901a6..5900442d7907 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 470c1bf2410d..9e60a56a491f 100644
--- a/src/shared/agent-hook-listener/source-routing.ts
+++ b/src/shared/agent-hook-listener/source-routing.ts
@@ -20,7 +20,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 0ea809997667..682e5619ce0b 100644
--- a/src/shared/agent-hook-relay.ts
+++ b/src/shared/agent-hook-relay.ts
@@ -52,7 +52,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 5319dffceaf1..430783d7a35a 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 324a73f2cd89..00dd0d2e15bc 100644
--- a/src/shared/agent-kind.ts
+++ b/src/shared/agent-kind.ts
@@ -49,7 +49,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..831319828daf 100644
--- a/src/shared/agent-name-token-match.ts
+++ b/src/shared/agent-name-token-match.ts
@@ -26,7 +26,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 3e763fc20a6f..664699b39479 100644
--- a/src/shared/agent-session-resume.ts
+++ b/src/shared/agent-session-resume.ts
@@ -198,7 +198,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-status-types.ts b/src/shared/agent-status-types.ts
index de177dcd4b8b..304d273e7661 100644
--- a/src/shared/agent-status-types.ts
+++ b/src/shared/agent-status-types.ts
@@ -4,6 +4,7 @@
import type { AgentProviderSessionMetadata } from './agent-session-resume'
import type { AgentStatusRowFacets } from './agent-status-observation'
+import type { AgentType } from './agent-status-agent-type'
import {
normalizeInteractivePromptField,
normalizeOptionalField,
@@ -14,6 +15,7 @@ import {
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
export { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization'
+export type { AgentType, WellKnownAgentType } from './agent-status-agent-type'
export type {
AgentStatusClearIpcPayload,
AgentStatusIpcPayload,
@@ -23,32 +25,6 @@ export type {
export const AGENT_STATUS_STATES = ['working', 'blocked', 'waiting', 'done'] as const
export type AgentStatusState = (typeof AGENT_STATUS_STATES)[number]
export type AgentWorkingMode = 'monitoring'
-// Why: agent types aren't a fixed set (custom agents exist); any non-empty string is
-// accepted — these well-known names are just a convenience union for pattern-matching.
-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'
- | 'unknown'
-export type AgentType = WellKnownAgentType | (string & {})
/** A snapshot of a previous agent state, used to render activity blocks.
* Why: intentionally narrower than AgentStatusEntry — tool/assistant context is
diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts
index f13dc36b7bfa..1c17f83e59b4 100644
--- a/src/shared/agent-type-label.ts
+++ b/src/shared/agent-type-label.ts
@@ -24,7 +24,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/persisted-ui-state-types.ts b/src/shared/persisted-ui-state-types.ts
index 1db7f8e5dfc2..440d8b40dad7 100644
--- a/src/shared/persisted-ui-state-types.ts
+++ b/src/shared/persisted-ui-state-types.ts
@@ -99,6 +99,8 @@ export type PersistedUIState = {
_antigravityStatusBarDefaultAdded?: boolean
/** One-shot migration flag for adding the default-on Grok status item. */
_grokStatusBarDefaultAdded?: boolean
+ /** One-shot migration flag for adding the default-on ZCode status item. */
+ _zcodeStatusBarDefaultAdded?: boolean
statusBarItems: StatusBarItem[]
statusBarVisible: boolean
/** Why: this is client-side presentation, not a provider/account or execution-host setting. */
diff --git a/src/shared/rate-limit-types.test.ts b/src/shared/rate-limit-types.test.ts
index 6d35c2d22fc8..0686078926d9 100644
--- a/src/shared/rate-limit-types.test.ts
+++ b/src/shared/rate-limit-types.test.ts
@@ -16,6 +16,7 @@ describe('RateLimitState', () => {
antigravity: null,
minimax: null,
grok: null,
+ zcode: null,
minimaxCookieConfigured: false,
grokAuthConfigured: false,
claudeTarget: { runtime: 'host', wslDistro: null },
diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts
index 83210fba2ccb..8f57b2cef3d4 100644
--- a/src/shared/rate-limit-types.ts
+++ b/src/shared/rate-limit-types.ts
@@ -55,6 +55,7 @@ export type ProviderRateLimits = {
| 'minimax'
| 'grok'
| 'antigravity'
+ | 'zcode'
/** 5-hour session window, null if not available. */
session: RateLimitWindow | null
/** 7-day weekly window, null if not available. */
@@ -124,6 +125,7 @@ export type RateLimitState = {
antigravity: ProviderRateLimits | null
minimax: ProviderRateLimits | null
grok: ProviderRateLimits | null
+ zcode: ProviderRateLimits | null
/**
* True when a MiniMax session cookie is persisted on disk. The cookie lives
* outside GlobalSettings, so this flag is the durable signal that the
diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts
index 3126c0a81e5f..b1f989b4c57e 100644
--- a/src/shared/skills-cli-agent-keys.ts
+++ b/src/shared/skills-cli-agent-keys.ts
@@ -49,7 +49,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/status-bar-defaults.ts b/src/shared/status-bar-defaults.ts
index 73d72a70ab2a..e9bd4c620e6f 100644
--- a/src/shared/status-bar-defaults.ts
+++ b/src/shared/status-bar-defaults.ts
@@ -9,6 +9,7 @@ export const DEFAULT_STATUS_BAR_ITEMS: StatusBarItem[] = [
'kimi',
'minimax',
'grok',
+ 'zcode',
'ssh',
'resource-usage',
'ports'
diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts
index 0466ca79430c..e8b27ee418a1 100644
--- a/src/shared/telemetry-events.ts
+++ b/src/shared/telemetry-events.ts
@@ -100,6 +100,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 de3c79a68b11..8978b26503c1 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'
@@ -335,7 +336,8 @@ 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: ZCODE_TUI_AGENT_CONFIG
}
export function isTuiAgent(value: unknown): value is TuiAgent {
diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts
index 0b2078f0479c..cde651298cdb 100644
--- a/src/shared/tui-agent-display-names.ts
+++ b/src/shared/tui-agent-display-names.ts
@@ -41,7 +41,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 596ef7a1a602..b378bc6a68d1 100644
--- a/src/shared/tui-agent-permissions.ts
+++ b/src/shared/tui-agent-permissions.ts
@@ -28,7 +28,8 @@ export const YOLO_TUI_AGENT_ARGS: Partial> = {
grok: '--permission-mode bypassPermissions',
devin: '--permission-mode bypass',
ante: '--yolo',
- trae: '--yolo'
+ trae: '--yolo',
+ 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 86216e589b82..a26db76a5f6d 100644
--- a/src/shared/tui-agent-selection.ts
+++ b/src/shared/tui-agent-selection.ts
@@ -39,7 +39,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 a885e3dd24cb..c3e413eb7bdc 100644
--- a/src/shared/tui-agent.ts
+++ b/src/shared/tui-agent.ts
@@ -37,3 +37,4 @@ export type TuiAgent =
| 'ante' // Ante (Antigma Labs)
| 'trae' // Trae CLI
| 'prime-agent' // Prime Agent (Prime Intellect)
+ | 'zcode' // ZCode (Z.ai / GLM)
diff --git a/src/shared/ui-chrome-types.ts b/src/shared/ui-chrome-types.ts
index 90a12cf5b026..8c105c1b8d1f 100644
--- a/src/shared/ui-chrome-types.ts
+++ b/src/shared/ui-chrome-types.ts
@@ -58,6 +58,7 @@ export type StatusBarItem =
| 'kimi'
| 'minimax'
| 'grok'
+ | 'zcode'
| 'ssh'
| 'resource-usage'
| 'ports'