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 logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo 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 logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index 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 Logo Droid Droid   Logo Kilocode Kilocode   Logo Kimi Kimi   + Logo ZCode ZCode   Logo Kiro Kiro   Logo Mistral Vibe Mistral Vibe   Logo Qwen Code Qwen Code   diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 3b1992448fed..c68d8ab8c2df 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -196,6 +196,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 6e53e5904636..c5c2734dedb3 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -198,6 +198,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 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. Logotipo do Droid Droid   Logotipo do Kilocode Kilocode   Logotipo do Kimi Kimi   + Logotipo do ZCode ZCode   Logotipo do Kiro Kiro   Logotipo do Mistral Vibe Mistral Vibe   Logotipo do Qwen Code Qwen Code   diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 0c619ea260d8..31fc58bf01b0 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -196,6 +196,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智 Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts index 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'