diff --git a/README.md b/README.md index 04f5e309da38..8d4336244cde 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca. Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 43eac7cb024d..41fc652f7600 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -132,6 +132,8 @@ "../src/main/kimi/hook-service.ts", "../src/main/kimi/kimi-hook-config-toml.ts", "../src/main/openclaude/hook-service.ts", + "../src/main/zcode/hook-service.ts", + "../src/main/zcode/zcode-hook-config.ts", "../src/main/rolling-file-backup.ts", "../src/main/startup/hydrate-shell-path.ts", "../src/main/startup/windows-shell-path-ownership.ts", diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md index bb5f15f14e0f..92bddcee51df 100644 --- a/docs/readme/README.es.md +++ b/docs/readme/README.es.md @@ -196,6 +196,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md index 549f817b9fe2..bdb05c908135 100644 --- a/docs/readme/README.fr.md +++ b/docs/readme/README.fr.md @@ -202,6 +202,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i Logo Droid Droid   Logo Kilocode Kilocode   Logo Kimi Kimi   + Logo ZCode ZCode   Logo Kiro Kiro   Logo Mistral Vibe Mistral Vibe   Logo Qwen Code Qwen Code   diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md index 58330fe3f437..9e658dd5776c 100644 --- a/docs/readme/README.ja.md +++ b/docs/readme/README.ja.md @@ -196,6 +196,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md index 6e6b174e78fe..ba14d461a304 100644 --- a/docs/readme/README.ko.md +++ b/docs/readme/README.ko.md @@ -198,6 +198,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내 Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md index 8fbc63e1a039..7eb06f3ec77f 100644 --- a/docs/readme/README.pt.md +++ b/docs/readme/README.pt.md @@ -198,6 +198,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca. Logotipo do Droid Droid   Logotipo do Kilocode Kilocode   Logotipo do Kimi Kimi   + Logotipo do ZCode ZCode   Logotipo do Kiro Kiro   Logotipo do Mistral Vibe Mistral Vibe   Logotipo do Qwen Code Qwen Code   diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md index 565b76ff5d5d..8f7756a582b9 100644 --- a/docs/readme/README.zh-CN.md +++ b/docs/readme/README.zh-CN.md @@ -196,6 +196,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智 Droid logo Droid   Kilocode logo Kilocode   Kimi logo Kimi   + ZCode logo ZCode   Kiro logo Kiro   Mistral Vibe logo Mistral Vibe   Qwen Code logo Qwen Code   diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts index 46b7ebcb0d38..b13193c04242 100644 --- a/mobile/src/tasks/mobile-tui-agents.ts +++ b/mobile/src/tasks/mobile-tui-agents.ts @@ -44,7 +44,8 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial> rovo: 'atlassian.com', hermes: 'nousresearch.com', devin: 'devin.ai', - openclaw: 'openclaw.ai' + openclaw: 'openclaw.ai', + zcode: 'z.ai' } export const isMobileTuiAgent: (value: unknown) => value is TuiAgent = isTuiAgent diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts index 49fdcadda421..008a3b29b6d6 100644 --- a/src/main/agent-hooks/managed-agent-hook-registry.ts +++ b/src/main/agent-hooks/managed-agent-hook-registry.ts @@ -14,6 +14,7 @@ import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' +import { zcodeHookService } from '../zcode/hook-service' // Why (#16441): Codex's installer awaits a codex app-server trust-grant session // instead of blocking the main thread on spawnSync. Widening the tuple keeps the @@ -50,7 +51,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[] ['copilot', () => copilotHookService.install()], ['hermes', () => hermesHookService.install()], ['devin', () => devinHookService.install()], - ['kimi', () => kimiHookService.install()] + ['kimi', () => kimiHookService.install()], + ['zcode', () => zcodeHookService.install()] ] // Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a @@ -71,7 +73,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri ['grok', () => grokHookService.refreshManagedScripts()], ['copilot', () => copilotHookService.refreshManagedScripts()], ['devin', () => devinHookService.refreshManagedScripts()], - ['kimi', () => kimiHookService.refreshManagedScripts()] + ['kimi', () => kimiHookService.refreshManagedScripts()], + ['zcode', () => zcodeHookService.refreshManagedScripts()] ] export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ @@ -88,7 +91,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [ ['copilot', () => copilotHookService.remove()], ['hermes', () => hermesHookService.remove()], ['devin', () => devinHookService.remove()], - ['kimi', () => kimiHookService.remove()] + ['kimi', () => kimiHookService.remove()], + ['zcode', () => zcodeHookService.remove()] ] export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [ @@ -109,5 +113,6 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR ['copilot', () => copilotHookService.getStatus()], ['hermes', () => hermesHookService.getStatus()], ['devin', () => devinHookService.getStatus()], - ['kimi', () => kimiHookService.getStatus()] + ['kimi', () => kimiHookService.getStatus()], + ['zcode', () => zcodeHookService.getStatus()] ] diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts index 3213ee0210e2..e3929de96df5 100644 --- a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts +++ b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts @@ -45,9 +45,9 @@ describe('managed-hook local filesystem', () => { const cold = await installRemoteManagedAgentHooks(filesystem, home, options) const warm = await installRemoteManagedAgentHooks(filesystem, home, options) - expect(cold).toHaveLength(14) + expect(cold).toHaveLength(15) expect(cold.filter((result) => result.state === 'error')).toEqual([]) - expect(warm).toHaveLength(14) + expect(warm).toHaveLength(15) expect(warm.filter((result) => result.state === 'error')).toEqual([]) const files = await listFiles(home) expect(files.filter((path) => path.endsWith('.tmp'))).toEqual([]) @@ -71,7 +71,7 @@ describe('managed-hook local filesystem', () => { agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS }) - expect(results).toHaveLength(14) + expect(results).toHaveLength(15) expect(results.find((result) => result.agent === 'claude')?.state).toBe('error') expect(results.find((result) => result.agent === 'openclaude')?.state).toBe('installed') expect(results.find((result) => result.agent === 'kimi')?.state).toBe('installed') diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts index 2e4cbb06496f..ece763a0af84 100644 --- a/src/main/agent-hooks/remote-hook-service-installers.test.ts +++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts @@ -22,6 +22,7 @@ import { CopilotHookService, copilotHookService } from '../copilot/hook-service' import { HermesHookService, hermesHookService } from '../hermes/hook-service' import { DevinHookService, devinHookService } from '../devin/hook-service' import { KimiHookService, kimiHookService } from '../kimi/hook-service' +import { ZcodeHookService, zcodeHookService } from '../zcode/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls' import { @@ -189,6 +190,10 @@ describe('remote hook service installers', () => { { path: '/home/dev/.orca/agent-hooks/droid-hook.sh', install: (sftp: SFTPWrapper) => new DroidHookService().installRemote(sftp, '/home/dev') + }, + { + path: '/home/dev/.orca/agent-hooks/zcode-hook.sh', + install: (sftp: SFTPWrapper) => new ZcodeHookService().installRemote(sftp, '/home/dev') } ] @@ -709,7 +714,8 @@ describe('remote hook service installers', () => { ['copilot', copilotHookService], ['hermes', hermesHookService], ['devin', devinHookService], - ['kimi', kimiHookService] + ['kimi', kimiHookService], + ['zcode', zcodeHookService] ]) // Guard against a service silently missing from the map above as new agents land. diff --git a/src/main/agent-hooks/remote-hook-service-test-fixture.test.ts b/src/main/agent-hooks/remote-hook-service-test-fixture.test.ts new file mode 100644 index 000000000000..49e13e489bd5 --- /dev/null +++ b/src/main/agent-hooks/remote-hook-service-test-fixture.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { createFakeSftp } from './remote-hook-service-test-fixture' + +function callSftp( + invoke: (callback: (error: unknown, value?: unknown) => void) => void +): Promise { + return new Promise((resolve, reject) => { + invoke((error, value) => (error ? reject(error) : resolve(value))) + }) +} + +describe('remote hook service test fixture', () => { + it('derives the remote directory hierarchy from seeded files', async () => { + const { sftp } = createFakeSftp({ '/home/dev/.zcode/cli/config.json': '{}' }) + + await expect( + callSftp((callback) => sftp.readdir('/home/dev/.zcode/cli', callback as never)) + ).resolves.toEqual([{ filename: 'config.json' }]) + }) + + it('rejects writes, renames, and mkdir when the target parent is absent', async () => { + const { sftp } = createFakeSftp({ '/tmp/source': 'value' }) + + await expect( + callSftp((callback) => sftp.writeFile('/missing/file', 'x', 'utf8', callback)) + ).rejects.toMatchObject({ code: 2 }) + await expect( + callSftp((callback) => sftp.rename('/tmp/source', '/missing/file', callback)) + ).rejects.toMatchObject({ code: 2 }) + await expect( + callSftp((callback) => sftp.mkdir('/missing/child', callback)) + ).rejects.toMatchObject({ code: 2 }) + }) +}) diff --git a/src/main/agent-hooks/remote-hook-service-test-fixture.ts b/src/main/agent-hooks/remote-hook-service-test-fixture.ts new file mode 100644 index 000000000000..b42e7fdfa9ce --- /dev/null +++ b/src/main/agent-hooks/remote-hook-service-test-fixture.ts @@ -0,0 +1,143 @@ +import type { SFTPWrapper } from 'ssh2' +import { posix } from 'node:path' + +export type FakeRemoteHookFs = { + files: Map + dirs: Set + modes: Map + failRenameTo: Set +} + +export function createFakeSftp(initialFiles: Record = {}): { + sftp: SFTPWrapper + fs: FakeRemoteHookFs +} { + const dirs = new Set(['/']) + for (const path of Object.keys(initialFiles)) { + let parent = posix.dirname(path) + while (!dirs.has(parent)) { + dirs.add(parent) + const next = posix.dirname(parent) + if (next === parent) { + break + } + parent = next + } + } + const fs: FakeRemoteHookFs = { + files: new Map(Object.entries(initialFiles)), + dirs, + modes: new Map(), + failRenameTo: new Set() + } + const noEntryError = (path: string): { code: number; message: string } => ({ + code: 2, + message: `ENOENT ${path}` + }) + const fakeStats = (mode: number): { mode: number } => ({ mode }) + const requireParent = (path: string, cb: (err: unknown) => void): boolean => { + const parent = posix.dirname(path) + if (fs.dirs.has(parent)) { + return true + } + cb(noEntryError(parent)) + return false + } + + const sftp = { + readFile: (path: string, _enc: string, cb: (err: unknown, data?: string) => void): void => { + const value = fs.files.get(path) + if (value === undefined) { + cb(noEntryError(path)) + return + } + cb(null, value) + }, + writeFile: ( + path: string, + content: string, + options: string | { mode?: number }, + cb: (err: unknown) => void + ): void => { + if (!requireParent(path, cb)) { + return + } + fs.files.set(path, content) + if (typeof options !== 'string' && options.mode !== undefined) { + fs.modes.set(path, options.mode) + } + cb(null) + }, + rename: (src: string, dst: string, cb: (err: unknown) => void): void => { + if (fs.failRenameTo.has(dst)) { + cb({ code: 4, message: `rename failed ${dst}` }) + return + } + const value = fs.files.get(src) + if (value === undefined) { + cb(noEntryError(src)) + return + } + if (!requireParent(dst, cb)) { + return + } + fs.files.set(dst, value) + fs.files.delete(src) + const mode = fs.modes.get(src) + if (mode !== undefined) { + fs.modes.set(dst, mode) + fs.modes.delete(src) + } + cb(null) + }, + unlink: (path: string, cb: (err: unknown) => void): void => { + fs.files.delete(path) + fs.modes.delete(path) + cb(null) + }, + chmod: (path: string, mode: number, cb: (err: unknown) => void): void => { + fs.modes.set(path, mode) + cb(null) + }, + stat: (path: string, cb: (err: unknown, stats?: { mode: number }) => void): void => { + if (fs.dirs.has(path)) { + cb(null, fakeStats(0o040755)) + return + } + if (!fs.files.has(path)) { + cb(noEntryError(path)) + return + } + cb(null, fakeStats(fs.modes.get(path) ?? 0o100644)) + }, + readdir: (path: string, cb: (err: unknown, list?: { filename: string }[]) => void): void => { + if (fs.dirs.has(path)) { + const prefix = path === '/' ? '/' : `${path}/` + const children = new Set() + for (const candidate of [...fs.dirs, ...fs.files.keys()]) { + if (!candidate.startsWith(prefix) || candidate === path) { + continue + } + const remainder = candidate.slice(prefix.length) + if (remainder && !remainder.includes('/')) { + children.add(remainder) + } + } + cb( + null, + [...children].sort().map((filename) => ({ filename })) + ) + return + } + cb(noEntryError(path)) + }, + mkdir: (path: string, cb: (err: unknown) => void): void => { + if (!requireParent(path, cb)) { + return + } + fs.dirs.add(path) + cb(null) + } + } as unknown as SFTPWrapper + return { sftp, fs } +} diff --git a/src/main/agent-hooks/remote-managed-hook-installers.ts b/src/main/agent-hooks/remote-managed-hook-installers.ts index a335e8ccc773..39400df5b8a6 100644 --- a/src/main/agent-hooks/remote-managed-hook-installers.ts +++ b/src/main/agent-hooks/remote-managed-hook-installers.ts @@ -14,6 +14,7 @@ import { grokHookService } from '../grok/hook-service' import { hermesHookService } from '../hermes/hook-service' import { kimiHookService } from '../kimi/hook-service' import { openClaudeHookService } from '../openclaude/hook-service' +import { zcodeHookService } from '../zcode/hook-service' export type RemoteManagedHookInstallOptions = { /** Explicit CODEX_HOME dir for redirected runtimes (for example WSL's managed runtime home). */ @@ -72,7 +73,8 @@ const REMOTE_MANAGED_HOOK_INSTALLERS: readonly RemoteManagedHookInstaller[] = [ ['droid', (sftp, remoteHome) => droidHookService.installRemote(sftp, remoteHome)], ['hermes', (sftp, remoteHome) => hermesHookService.installRemote(sftp, remoteHome)], ['devin', (sftp, remoteHome) => devinHookService.installRemote(sftp, remoteHome)], - ['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)] + ['kimi', (sftp, remoteHome) => kimiHookService.installRemote(sftp, remoteHome)], + ['zcode', (sftp, remoteHome) => zcodeHookService.installRemote(sftp, remoteHome)] ] /** Agents wired into the remote (SSH) hook installer. Exported so an invariant diff --git a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts index 3466cb52af9d..a2a87f37a943 100644 --- a/src/main/agent-hooks/server-retired-pane-new-turn.test.ts +++ b/src/main/agent-hooks/server-retired-pane-new-turn.test.ts @@ -37,6 +37,7 @@ const NEW_TURN_EVENT: Record = { copilot: 'sessionStart', hermes: 'pre_llm_call', devin: 'UserPromptSubmit', + zcode: 'UserPromptSubmit', opencode: 'SessionStart', opencode2: 'SessionStart', 'mimo-code': null, diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts index 1103a8379e51..1bad8aca8499 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-client.ts @@ -4,6 +4,7 @@ import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-typ import type { OpenCodeSqliteCaptureValue, OpenCodeSqliteListValue, + OpenCodeSqliteTranscriptValue, OpenCodeSqliteWorkerRequest, OpenCodeSqliteWorkerResponse } from './session-scanner-opencode-sqlite-worker-protocol' @@ -23,6 +24,7 @@ export const PARSE_TIMEOUT_MS = 15_000 // Longer than a parse because it reads every part of the session rather than // the newest window, and shorter than nothing at all because the queue is FIFO. export const CAPTURE_TIMEOUT_MS = 30_000 +export const TRANSCRIPT_TIMEOUT_MS = 15_000 export const IDLE_TEARDOWN_MS = 30_000 // After this many consecutive worker deaths, fail the remaining queued calls to // scan issues instead of respawning so a DB that reliably kills the worker can't @@ -204,6 +206,27 @@ export class OpenCodeSqliteWorkerClient { } } + async transcript(args: { + dbPath: string + sessionId: string + offset?: number + endOffset?: number + beforeOffset?: number + limit: number + }): Promise { + try { + return (await this.dispatch( + (id) => ({ id, kind: 'transcript', ...args }), + TRANSCRIPT_TIMEOUT_MS + )) as OpenCodeSqliteTranscriptValue + } catch (err) { + if (err instanceof OpenCodeSqliteWorkerUnavailableError) { + throw new Error('ZCode SQLite transcript reader could not start.') + } + throw err instanceof Error ? err : new Error(String(err)) + } + } + private async dispatch( buildRequest: (id: number) => OpenCodeSqliteWorkerRequest, timeoutMs: number diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts index 7441bb1564aa..7b53c425c5c4 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts @@ -8,6 +8,7 @@ import { captureOpenCode2SqliteSession, parseOpenCode2SqliteSession } from './session-scanner-opencode2-sqlite' +import { readZcodeSqliteTranscript } from './zcode-sqlite-transcript' import type { OpenCodeSqliteWorkerRequest, OpenCodeSqliteWorkerResponse @@ -50,6 +51,9 @@ async function handleRequest( : await captureOpenCodeSqliteSession(request) return { id: request.id, ok: true, value: capture } } + if (request.kind === 'transcript') { + return { id: request.id, ok: true, value: readZcodeSqliteTranscript(request) } + } const session = request.agent === 'opencode2' ? await parseOpenCode2SqliteSession({ diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts index e18cad32c2c5..839015a4bd04 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-protocol.ts @@ -1,6 +1,7 @@ import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import type { SessionFileCandidate } from './session-scanner-types' import type { TranscriptMessage } from './session-transcript-consumers' +import type { NativeChatMessage } from '../../shared/native-chat-types' // Why: request/response shapes shared by the worker entry and the main-thread // client. Kept type-only (and electron-free) so importing it into the worker @@ -38,10 +39,22 @@ export type OpenCodeSqliteCaptureRequest = { agent?: 'opencode2' } +export type OpenCodeSqliteTranscriptRequest = { + id: number + kind: 'transcript' + dbPath: string + sessionId: string + offset?: number + endOffset?: number + beforeOffset?: number + limit: number +} + export type OpenCodeSqliteWorkerRequest = | OpenCodeSqliteListRequest | OpenCodeSqliteParseRequest | OpenCodeSqliteCaptureRequest + | OpenCodeSqliteTranscriptRequest // The list leg returns candidates plus the issues it accumulated; the worker // mutates a local array and hands it back so the caller can merge it into the @@ -59,6 +72,14 @@ export type OpenCodeSqliteCaptureValue = { messages: TranscriptMessage[] } +export type OpenCodeSqliteTranscriptValue = { + messages: NativeChatMessage[] + nextOffset: number + beforeOffset: number + limited: boolean + warnings: string[] +} + export type OpenCodeSqliteWorkerResponse = | { id: number; ok: true; value: unknown } | { id: number; ok: false; error: string } diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts index c1e89e9fc790..238a8e8079a6 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-worker-spawn.ts @@ -3,7 +3,10 @@ import { join } from 'node:path' import { Worker } from 'node:worker_threads' import type { AiVaultScanIssue, AiVaultSession } from '../../shared/ai-vault-types' import type { SessionFileCandidate } from './session-scanner-types' -import type { OpenCodeSqliteCaptureValue } from './session-scanner-opencode-sqlite-worker-protocol' +import type { + OpenCodeSqliteCaptureValue, + OpenCodeSqliteTranscriptValue +} from './session-scanner-opencode-sqlite-worker-protocol' import { OpenCodeSqliteWorkerClient } from './session-scanner-opencode-sqlite-worker-client' // Why: resolve the built worker entry + own the process-wide shared client so @@ -115,3 +118,14 @@ export function captureOpenCode2SqliteSessionViaWorker(args: { }): Promise { return getSharedClient().capture({ ...args, agent: 'opencode2' }) } + +export function readZcodeSqliteTranscriptViaWorker(args: { + dbPath: string + sessionId: string + offset?: number + endOffset?: number + beforeOffset?: number + limit: number +}): Promise { + return getSharedClient().transcript(args) +} diff --git a/src/main/ai-vault/zcode-sqlite-transcript.test.ts b/src/main/ai-vault/zcode-sqlite-transcript.test.ts new file mode 100644 index 000000000000..9db6f2da2bf6 --- /dev/null +++ b/src/main/ai-vault/zcode-sqlite-transcript.test.ts @@ -0,0 +1,146 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import SyncDatabase from '../sqlite/sync-database' +import { readZcodeSqliteTranscript, resolveZcodeSqliteDbPath } from './zcode-sqlite-transcript' + +describe('ZCode SQLite worker transcripts', () => { + let directory: string + let dbPath: string + let db: SyncDatabase + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-zcode-transcript-')) + dbPath = join(directory, 'db.sqlite') + db = new SyncDatabase(dbPath) + db.exec(` + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + data TEXT NOT NULL + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + time_created INTEGER NOT NULL, + data TEXT NOT NULL + ); + `) + }) + + afterEach(async () => { + db.close() + await rm(directory, { recursive: true, force: true }) + }) + + it('preserves explicit SQLite database paths', () => { + expect(resolveZcodeSqliteDbPath('/profiles/work/db.sqlite')).toBe('/profiles/work/db.sqlite') + expect(resolveZcodeSqliteDbPath('/profiles/work/zcode.db')).toBe('/profiles/work/zcode.db') + }) + + it('tails visible messages and continues with the SQLite row cursor', () => { + insert('m1', 'user', 'p1', { type: 'text', text: 'first' }, 1) + insert('m2', 'assistant', 'p2', { type: 'reasoning', text: 'considering' }, 2) + insert('m3', 'assistant', 'p3', { type: 'text', text: 'answer' }, 3) + + const initial = readZcodeSqliteTranscript({ + dbPath, + sessionId: 'session-1', + limit: 2 + }) + expect(initial).toMatchObject({ + messages: [ + { role: 'reasoning', blocks: [{ type: 'text', text: 'considering' }] }, + { role: 'assistant', blocks: [{ type: 'text', text: 'answer' }] } + ], + limited: true + }) + + insert( + 'm4', + 'assistant', + 'p4', + { + type: 'tool', + tool: 'Bash', + state: { status: 'completed', input: { command: 'go test ./...' }, output: 'ok' } + }, + 4 + ) + const continued = readZcodeSqliteTranscript({ + dbPath, + sessionId: 'session-1', + offset: initial.nextOffset, + limit: 2 + }) + expect(continued).toMatchObject({ + messages: [ + { + role: 'tool', + blocks: [ + { type: 'tool-call', name: 'Bash', input: { command: 'go test ./...' } }, + { type: 'tool-result', output: 'ok' } + ] + } + ], + limited: false + }) + }) + + it('excludes model-only ZCode messages', () => { + insert('hidden', 'user', 'hidden-part', { type: 'text', text: 'internal reminder' }, 1, true) + insert('visible', 'assistant', 'visible-part', { type: 'text', text: 'public result' }, 2) + + expect( + readZcodeSqliteTranscript({ dbPath, sessionId: 'session-1', limit: 10 }).messages + ).toEqual([ + expect.objectContaining({ + id: 'zcode:visible-part', + blocks: [{ type: 'text', text: 'public result' }] + }) + ]) + }) + + it('pages backward without repeating the boundary row', () => { + insert('m1', 'assistant', 'p1', { type: 'text', text: 'one' }, 1) + insert('m2', 'assistant', 'p2', { type: 'text', text: 'two' }, 2) + insert('m3', 'assistant', 'p3', { type: 'text', text: 'three' }, 3) + + const latest = readZcodeSqliteTranscript({ dbPath, sessionId: 'session-1', limit: 2 }) + const older = readZcodeSqliteTranscript({ + dbPath, + sessionId: 'session-1', + beforeOffset: latest.beforeOffset, + limit: 2 + }) + + expect(latest.messages.map((message) => message.id)).toEqual(['zcode:p2', 'zcode:p3']) + expect(older.messages.map((message) => message.id)).toEqual(['zcode:p1']) + }) + + function insert( + messageId: string, + role: string, + partId: string, + part: unknown, + timestamp: number, + hidden = false + ): void { + db.prepare('INSERT INTO message(id, session_id, time_created, data) VALUES (?, ?, ?, ?)').run( + messageId, + 'session-1', + timestamp, + JSON.stringify({ + role, + anchor: { turnId: `turn-${messageId}` }, + semantics: { transcriptVisibility: hidden ? 'hidden' : 'visible' } + }) + ) + db.prepare( + 'INSERT INTO part(id, message_id, session_id, time_created, data) VALUES (?, ?, ?, ?, ?)' + ).run(partId, messageId, 'session-1', timestamp, JSON.stringify(part)) + } +}) diff --git a/src/main/ai-vault/zcode-sqlite-transcript.ts b/src/main/ai-vault/zcode-sqlite-transcript.ts new file mode 100644 index 000000000000..94f0f4e2f633 --- /dev/null +++ b/src/main/ai-vault/zcode-sqlite-transcript.ts @@ -0,0 +1,165 @@ +import type { + NativeChatBlock, + NativeChatMessage, + NativeChatRole +} from '../../shared/native-chat-types' +import type { OpenCodeSqliteTranscriptValue } from './session-scanner-opencode-sqlite-worker-protocol' +import SyncDatabase from '../sqlite/sync-database' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const MAX_TEXT_CHARS = 256 * 1024 +const MAX_TOOL_VALUE_CHARS = 64 * 1024 + +type TranscriptRow = { + row_id: number + part_id: string + message_id: string + role: string | null + turn_id: string | null + part_type: string + text_value: string | null + tool_name: string | null + tool_status: string | null + tool_input: string | null + tool_output: string | null + time_created: number +} + +export function readZcodeSqliteTranscript(args: { + dbPath: string + sessionId: string + offset?: number + endOffset?: number + beforeOffset?: number + limit: number +}): OpenCodeSqliteTranscriptValue { + const db = new SyncDatabase(args.dbPath, { readonly: true, fileMustExist: true }) + db.pragma('query_only = ON') + try { + const sourceMax = Number( + ( + db + .prepare('SELECT COALESCE(MAX(rowid), 0) AS value FROM part WHERE session_id = ?') + .get(args.sessionId) as { value: number } + ).value + ) + if ( + (args.endOffset !== undefined && sourceMax < args.endOffset) || + (args.offset ?? 0) > sourceMax + ) { + throw new Error('ZCODE_TRANSCRIPT_SOURCE_CHANGED') + } + + const boundary = Math.min( + sourceMax, + args.endOffset ?? Number.MAX_SAFE_INTEGER, + args.beforeOffset === undefined ? Number.MAX_SAFE_INTEGER : args.beforeOffset - 1 + ) + const requestedLimit = Math.max(1, Math.trunc(args.limit)) + const rows = readRows(db, args.sessionId, args.offset, boundary, requestedLimit + 1) + const limited = rows.length > requestedLimit + const pageRows = + args.offset === undefined ? rows.slice(-requestedLimit) : rows.slice(0, requestedLimit) + const messages = pageRows + .map(decodeRow) + .filter((message): message is NativeChatMessage => message !== null) + const nextOffset = pageRows.at(-1)?.row_id ?? args.offset ?? boundary + const beforeOffset = pageRows[0]?.row_id ?? boundary + return { messages, nextOffset, beforeOffset, limited, warnings: [] } + } finally { + db.close() + } +} + +export function resolveZcodeSqliteDbPath(transcriptPath?: string): string { + return transcriptPath && /\.(?:db|sqlite)$/i.test(transcriptPath) + ? transcriptPath + : join(homedir(), '.zcode', 'cli', 'db', 'db.sqlite') +} + +function readRows( + db: SyncDatabase, + sessionId: string, + offset: number | undefined, + boundary: number, + limit: number +): TranscriptRow[] { + const select = `SELECT p.rowid AS row_id, + p.id AS part_id, + p.message_id, + json_extract(m.data, '$.role') AS role, + json_extract(m.data, '$.anchor.turnId') AS turn_id, + json_extract(p.data, '$.type') AS part_type, + substr(json_extract(p.data, '$.text'), 1, ${MAX_TEXT_CHARS}) AS text_value, + json_extract(p.data, '$.tool') AS tool_name, + json_extract(p.data, '$.state.status') AS tool_status, + substr(json_extract(p.data, '$.state.input'), 1, ${MAX_TOOL_VALUE_CHARS}) AS tool_input, + substr(json_extract(p.data, '$.state.output'), 1, ${MAX_TOOL_VALUE_CHARS}) AS tool_output, + p.time_created + FROM part p + JOIN message m ON m.id = p.message_id + WHERE p.session_id = ? + AND p.rowid <= ? + AND json_extract(p.data, '$.type') IN ('text', 'reasoning', 'tool') + AND COALESCE(json_extract(m.data, '$.semantics.transcriptVisibility'), 'visible') != 'hidden'` + if (offset !== undefined) { + return db + .prepare(`${select} AND p.rowid > ? ORDER BY p.rowid ASC LIMIT ?`) + .all(sessionId, boundary, offset, limit) as TranscriptRow[] + } + const rows = db + .prepare(`${select} ORDER BY p.rowid DESC LIMIT ?`) + .all(sessionId, boundary, limit) as TranscriptRow[] + return rows.toReversed() +} + +function decodeRow(row: TranscriptRow): NativeChatMessage | null { + const blocks: NativeChatBlock[] = [] + let role: NativeChatRole + if (row.part_type === 'tool') { + role = 'tool' + blocks.push({ + type: 'tool-call', + name: row.tool_name?.trim() || 'tool', + input: parseJsonValue(row.tool_input) + }) + if (row.tool_status === 'completed' || row.tool_status === 'error') { + blocks.push({ + type: 'tool-result', + output: row.tool_output ?? '', + ...(row.tool_status === 'error' ? { isError: true } : {}) + }) + } + } else { + const text = row.text_value ?? '' + if (!text) { + return null + } + role = row.part_type === 'reasoning' ? 'reasoning' : normalizeRole(row.role) + blocks.push({ type: 'text', text }) + } + return { + id: `zcode:${row.part_id}`, + role, + blocks, + timestamp: row.time_created, + source: 'transcript', + ...(row.turn_id ? { turnId: row.turn_id } : {}) + } +} + +function normalizeRole(role: string | null): NativeChatRole { + return role === 'user' || role === 'assistant' || role === 'system' ? role : 'assistant' +} + +function parseJsonValue(value: string | null): unknown { + if (value === null) { + return null + } + try { + return JSON.parse(value) as unknown + } catch { + return value + } +} 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/preflight-agent-detection.test.ts b/src/main/ipc/preflight-agent-detection.test.ts index 37a061e0541c..6a76fa327c80 100644 --- a/src/main/ipc/preflight-agent-detection.test.ts +++ b/src/main/ipc/preflight-agent-detection.test.ts @@ -167,6 +167,14 @@ describe('preflight', () => { await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'cursor']) }) + it('detects ZCode from its macOS Desktop bundle without a PATH shim', async () => { + isCommandOnLocalPathMock.mockImplementation( + async (command: string) => command === '/Applications/ZCode.app/Contents/MacOS/ZCode' + ) + + await expect(detectInstalledAgents()).resolves.toEqual(['zcode']) + }) + it('does not report Claude Agent Teams when only the Orca shim is present', async () => { execFileAsyncMock.mockImplementation(async (command, args) => { if (command !== 'which') { diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index ed509190038a..7e9599bc6e48 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -117,6 +117,12 @@ export async function prepareLegacyTranscriptImport(input: { if (!transcriptAgent) { return { ok: false, error: `Unsupported agent for journal import: ${input.agent}` } } + if (transcriptAgent === 'zcode') { + return { + ok: false, + error: 'ZCode SQLite transcripts are imported through the dedicated SQLite transcript path.' + } + } const filePath = options.filePath ?? (await resolveSessionFilePath(input.agent, input.sessionId, options)) if (!filePath) { diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 2d668a6ab2be..7f0a4c82ec09 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -211,6 +211,11 @@ async function resolveSessionFileById( signal ) } + // ZCode stores every session in one SQLite database; its readers bypass the + // JSONL file resolver and address the database plus session id directly. + if (transcriptAgent === 'zcode') { + return null + } // Why: a new transcript agent must pick its own resolver. Falling through to // OMP's scan would search the wrong root with a foreign session id, so fail // the build here instead of resolving silently wrong at runtime. diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index a4d3acb7465f..700703983320 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -15,6 +15,8 @@ import { decodeOmpTranscriptLine } from './transcript-line-decoders' import { decodeTranscriptStream } from './transcript-stream-lines' +import { readZcodeSqliteTranscriptViaWorker } from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn' +import { resolveZcodeSqliteDbPath } from '../ai-vault/zcode-sqlite-transcript' export type ReadTranscriptResult = | { @@ -42,6 +44,30 @@ export async function readNativeChatTranscript( sessionId: string, options: ReadTranscriptOptions = {} ): Promise { + if (agent === 'zcode') { + try { + const messages: NativeChatMessage[] = [] + let offset = 0 + while (true) { + const page = await readZcodeSqliteTranscriptViaWorker({ + dbPath: resolveZcodeSqliteDbPath(options.transcriptPath ?? options.filePath), + sessionId, + offset, + limit: 2_000 + }) + messages.push(...page.messages) + offset = page.nextOffset + if (!page.limited) { + return { messages } + } + } + } catch (err) { + const message = errorMessage(err) + return message.includes('does not exist') + ? { error: message, notFound: true } + : { error: message } + } + } let filePath: string | null try { filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options)) diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index b6fa9bafdb0a..375b96b1f1d6 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -28,6 +28,7 @@ import { wslGatedStat } from './wsl-transcript-fs-access' import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' +import { readZcodeTranscriptTail } from './zcode-transcript-tail' export const MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES = 2 * 1024 * 1024 @@ -230,6 +231,9 @@ export async function readNativeChatTranscriptTail( } | { error: string; notFound?: true } > { + if (args.agent === 'zcode') { + return readZcodeTranscriptTail(args, signal) + } const decode = nativeChatLineDecoderForAgent(args.agent) const decodeLifecycle = nativeChatTurnLifecycleDecoderForAgent(args.agent) if (!decode) { diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 1aeced2c3ffd..62e264ce6765 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -752,6 +752,17 @@ describe('subscribeNativeChatTranscript (resolve-poll for a not-yet-created file expect(getActiveNativeChatWatcherCount()).toBe(before) }) + it('does not start a ZCode SQLite poll for a blank session id', async () => { + const sub = await subscribeNativeChatTranscript({ + agent: 'zcode', + sessionId: ' ', + onAppend: () => {} + }) + + expect(sub.watching).toBe(false) + sub.unsubscribe() + }) + it('unsubscribing during the poll phase leaves no watcher or timer alive', async () => { const filePath = await pendingFilePath() const before = getActiveNativeChatWatcherCount() diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 97e7b75ec61a..c38e4ff54c21 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -14,6 +14,7 @@ import type { import { nativeChatLineDecoderForAgent } from './transcript-tail-reader' import { WslTranscriptFsError, wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' import { observeRunningWslDistros } from './wsl-transcript-running-observer' +import { subscribeZcodeSqliteTranscript } from './zcode-transcript-watch' export { readNativeChatTranscriptTail } from './transcript-tail-reader' export { getActiveNativeChatWatcherCount } from './transcript-watcher-count' @@ -264,18 +265,18 @@ export async function subscribeNativeChatTranscript( setupSignal?: AbortSignal ): Promise { setupSignal?.throwIfAborted() + if (!args.filePath && !args.sessionId.trim()) { + return { unsubscribe: () => {}, watching: false } + } + if (args.agent === 'zcode') { + return subscribeZcodeSqliteTranscript(args, setupSignal) + } const decode = nativeChatLineDecoderForAgent(args.agent) if (!decode) { // Nothing watchable — return a no-op teardown so callers can unconditionally // unsubscribe without null-checks. return { unsubscribe: () => {}, watching: false } } - // Why: a blank session id (and no explicit file) can never resolve — bail out - // instead of resolve-polling an unresolvable target forever. - if (!args.filePath && !args.sessionId.trim()) { - return { unsubscribe: () => {}, watching: false } - } - let installed: NativeChatTranscriptSubscription | null try { installed = await attemptInstall(args, decode, setupSignal) diff --git a/src/main/native-chat/zcode-transcript-tail.test.ts b/src/main/native-chat/zcode-transcript-tail.test.ts new file mode 100644 index 000000000000..33ed8d57b83c --- /dev/null +++ b/src/main/native-chat/zcode-transcript-tail.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { readZcodeSqliteTranscriptViaWorker } from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn' +import { readZcodeTranscriptTail } from './zcode-transcript-tail' + +vi.mock('../ai-vault/session-scanner-opencode-sqlite-worker-spawn', () => ({ + readZcodeSqliteTranscriptViaWorker: vi.fn() +})) + +describe('readZcodeTranscriptTail', () => { + beforeEach(() => { + vi.mocked(readZcodeSqliteTranscriptViaWorker).mockReset() + }) + + it('marks ENOENT failures as notFound even when the message differs', async () => { + vi.mocked(readZcodeSqliteTranscriptViaWorker).mockRejectedValue( + Object.assign(new Error('database unavailable'), { code: 'ENOENT' }) + ) + + await expect(readZcodeTranscriptTail({ sessionId: 'session-1', limit: 20 })).resolves.toEqual({ + error: 'database unavailable', + notFound: true + }) + }) +}) diff --git a/src/main/native-chat/zcode-transcript-tail.ts b/src/main/native-chat/zcode-transcript-tail.ts new file mode 100644 index 000000000000..4a240470fffc --- /dev/null +++ b/src/main/native-chat/zcode-transcript-tail.ts @@ -0,0 +1,39 @@ +import type { NativeChatMessage } from '../../shared/native-chat-types' +import { readZcodeSqliteTranscriptViaWorker } from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn' +import { resolveZcodeSqliteDbPath } from '../ai-vault/zcode-sqlite-transcript' + +export async function readZcodeTranscriptTail( + args: { + sessionId: string + transcriptPath?: string + filePath?: string + beforeOffset?: number + limit: number + }, + signal?: AbortSignal +): Promise< + | { messages: NativeChatMessage[]; hasMore: boolean; beforeOffset: number } + | { error: string; notFound?: true } +> { + try { + const result = await readZcodeSqliteTranscriptViaWorker({ + dbPath: resolveZcodeSqliteDbPath(args.transcriptPath ?? args.filePath), + sessionId: args.sessionId, + beforeOffset: args.beforeOffset, + limit: args.limit + }) + signal?.throwIfAborted() + return { + messages: result.messages, + hasMore: result.limited, + beforeOffset: result.beforeOffset + } + } catch (error) { + signal?.throwIfAborted() + const message = error instanceof Error ? error.message : String(error) + const code = (error as NodeJS.ErrnoException | null)?.code + return code === 'ENOENT' || message.includes('does not exist') + ? { error: message, notFound: true } + : { error: message } + } +} diff --git a/src/main/native-chat/zcode-transcript-watch.ts b/src/main/native-chat/zcode-transcript-watch.ts new file mode 100644 index 000000000000..8a242195f0ce --- /dev/null +++ b/src/main/native-chat/zcode-transcript-watch.ts @@ -0,0 +1,93 @@ +import { readZcodeSqliteTranscriptViaWorker } from '../ai-vault/session-scanner-opencode-sqlite-worker-spawn' +import { resolveZcodeSqliteDbPath } from '../ai-vault/zcode-sqlite-transcript' +import type { + NativeChatTranscriptSubscription, + SubscribeNativeChatTranscriptArgs +} from './transcript-watch-contract' + +const ZCODE_TRANSCRIPT_POLL_MS = 750 +const ZCODE_APPEND_PAGE_LIMIT = 500 +const ZCODE_MAX_PAGES_PER_POLL = 4 + +export function subscribeZcodeSqliteTranscript( + args: SubscribeNativeChatTranscriptArgs, + setupSignal?: AbortSignal +): NativeChatTranscriptSubscription { + const dbPath = resolveZcodeSqliteDbPath(args.transcriptPath ?? args.filePath) + let closed = false + let timer: ReturnType | null = null + let offset: number | undefined + let initialized = false + let initialErrorEmitted = false + + const schedule = (): void => { + if (closed) { + return + } + timer = setTimeout(() => void poll(), args.reconciliationIntervalMs ?? ZCODE_TRANSCRIPT_POLL_MS) + timer.unref?.() + } + + const poll = async (): Promise => { + if (closed || setupSignal?.aborted) { + return + } + try { + if (!initialized) { + const initial = await readZcodeSqliteTranscriptViaWorker({ + dbPath, + sessionId: args.sessionId, + limit: args.initialLimit ?? 40 + }) + if (closed || setupSignal?.aborted) { + return + } + offset = initial.nextOffset + initialized = true + args.onInitialSnapshot?.(initial.messages, initial.limited, initial.beforeOffset) + } else { + for (let pageIndex = 0; pageIndex < ZCODE_MAX_PAGES_PER_POLL; pageIndex += 1) { + const page = await readZcodeSqliteTranscriptViaWorker({ + dbPath, + sessionId: args.sessionId, + offset, + limit: ZCODE_APPEND_PAGE_LIMIT + }) + if (closed || setupSignal?.aborted) { + return + } + offset = page.nextOffset + if (page.messages.length > 0) { + args.onAppend(page.messages) + } + if (!page.limited) { + break + } + } + } + } catch (error) { + if (!initialized && !initialErrorEmitted && args.onInitialSnapshot) { + initialErrorEmitted = true + args.onInitialSnapshot( + [], + false, + 0, + error instanceof Error ? error.message : 'Transcript unavailable' + ) + } + } + schedule() + } + + void poll() + return { + watching: true, + unsubscribe: () => { + closed = true + if (timer) { + clearTimeout(timer) + } + timer = null + } + } +} diff --git a/src/main/providers/agent-foreground-process-zcode.test.ts b/src/main/providers/agent-foreground-process-zcode.test.ts new file mode 100644 index 000000000000..1eb546b82911 --- /dev/null +++ b/src/main/providers/agent-foreground-process-zcode.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })) + +vi.mock('child_process', () => ({ execFile: execFileMock })) + +import { resetProcessTableSnapshotForTests } from '../../shared/process-table-snapshot-reader' +import { resolveAgentForegroundProcess } from './agent-foreground-process' + +describe('ZCode foreground process recognition', () => { + let platform: PropertyDescriptor | undefined + + beforeEach(() => { + resetProcessTableSnapshotForTests() + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'darwin' }) + }) + + afterEach(() => { + execFileMock.mockReset() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + it('recognizes ZCode through its version-independent process tree', async () => { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: unknown, callback: unknown) => { + ;(callback as (error: unknown, result: { stdout: string; stderr: string }) => void)(null, { + stdout: [ + '101 100 S node /Users/dev/lib/node_modules/zcode-app-cli/bin/zcode.js --mode yolo', + '102 101 S+ zcode-cli' + ].join('\n'), + stderr: '' + }) + } + ) + + await expect(resolveAgentForegroundProcess(100, 'node')).resolves.toBe('zcode-cli') + }) +}) diff --git a/src/main/runtime/orca-runtime-zcode-orchestration.ts b/src/main/runtime/orca-runtime-zcode-orchestration.ts new file mode 100644 index 000000000000..3e4ea5019620 --- /dev/null +++ b/src/main/runtime/orca-runtime-zcode-orchestration.ts @@ -0,0 +1,251 @@ +// @ts-nocheck -- runtime mixin: the inherited surface is assembled across split OrcaRuntime classes. +import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter' +import { isShellProcess } from '../../shared/agent-detection' +import { isExpectedAgentProcess } from '../../shared/agent-process-recognition' +import type { AgentLaunchPreferences } from '../../shared/agent-session-host-authority' +import { repoIsRemote } from '../../shared/agent-launch-remote' +import type { TuiAgent } from '../../shared/tui-agent' +import { TUI_AGENT_CONFIG } from '../../shared/tui-agent-config' +import { + resolveTuiAgentLaunchArgs, + resolveTuiAgentLaunchEnv +} from '../../shared/tui-agent-launch-defaults' +import { requiresOrchestrationStartupPrompt } from '../../shared/tui-agent-orchestration' +import { buildAgentStartupPlan } from '../../shared/tui-agent-startup' +import { resolveLocalWindowsAgentStartupShell } from '../../shared/windows-terminal-shell' +import type { RuntimeTerminalCreate, RuntimeTerminalSend } from '../../shared/runtime-types' +import { + isInteractiveZcodeComposerOutput, + resolveZcodePromptDelivery +} from '../zcode/interactive-client' +import { + waitForWorktreeStartupDraft, + waitForWorktreeStartupFollowup +} from './runtime-worktree-startup-readiness' + +export class OrcaRuntimeWithZcodeOrchestration extends OrcaRuntimeWithResolveWaiter { + async waitForZcodeComposerReady(handle: string, timeoutMs = 30_000): Promise { + const host = this.getWorktreeStartupReadinessHost() + const deadline = Date.now() + Math.max(timeoutMs, 0) + do { + const ptyId = host.getPtyId(handle) + const recentOutput = ptyId ? (host.readRecentOutput(ptyId) ?? '') : '' + const terminal = await this.showTerminal(handle).catch(() => null) + // ZCode uses an alternate-screen renderer. Its normal PTY tail and terminal + // summary preview can therefore be empty even while the composer is fully + // visible. `terminal read --screen` uses this same rendered-screen path. + const screen = await this.readTerminal(handle, { screen: true }).catch(() => null) + const screenOutput = screen + ? [...screen.tail, ...(screen.draft ? [screen.draft] : [])].join('\n') + : '' + if ( + isInteractiveZcodeComposerOutput(recentOutput) || + isInteractiveZcodeComposerOutput(terminal?.preview ?? '') || + isInteractiveZcodeComposerOutput(screenOutput) + ) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } while (Date.now() < deadline) + return false + } + + async resolveOrchestrationPromptDelivery( + agent: TuiAgent, + worktreeId: string + ): Promise<'agent-input' | 'startup-command'> { + if (!requiresOrchestrationStartupPrompt(agent)) { + return 'agent-input' + } + if (agent !== 'zcode' || !this.store) { + return 'startup-command' + } + const worktree = await this.resolveWorktreeSelector(`id:${worktreeId}`) + const repo = this.store.getRepo(worktree.repoId) + if (!repo) { + return 'startup-command' + } + return resolveZcodePromptDelivery({ + isRemote: repoIsRemote(repo), + commandOverride: this.store.getSettings().agentCmdOverrides?.zcode?.trim() + }) + } + + async resolveOrchestrationInteractiveAgentCommand( + agent: TuiAgent, + worktreeId: string + ): Promise { + if (agent !== 'zcode' || !this.store) { + return undefined + } + const worktree = await this.resolveWorktreeSelector(`id:${worktreeId}`) + const repo = this.store.getRepo(worktree.repoId) + if (!repo) { + return undefined + } + const settings = this.store.getSettings() + const platform = this.getAgentLaunchPlatformForRepo(repo) + const isRemote = repoIsRemote(repo) + const shell = resolveLocalWindowsAgentStartupShell({ + platform, + isRemote, + terminalWindowsShell: settings.terminalWindowsShell + }) + return buildAgentStartupPlan({ + agent, + prompt: '', + cmdOverrides: settings.agentCmdOverrides ?? {}, + agentArgs: resolveTuiAgentLaunchArgs(agent, settings.agentDefaultArgs), + agentEnv: resolveTuiAgentLaunchEnv(agent, settings.agentDefaultEnv), + platform, + shell, + isRemote, + allowEmptyPromptLaunch: true + })?.launchCommand + } + + async createDeferredAgentTerminal( + worktreeSelector: string, + opts: { + agent: TuiAgent + launchPreferences?: AgentLaunchPreferences + bareShell?: boolean + title?: string + surfaceOwner?: false + } + ): Promise { + const worktree = await this.resolveWorktreeSelector(worktreeSelector) + // Kiro's shell integration otherwise replaces Orca's login shell with a + // nested `kiro-cli-term` PTY. The ZCode process then runs in that inner PTY, + // outside the terminal handle Orca supervises, so a healthy worker looks + // blank and fails readiness. This is the integration's own guard for an + // already-supervised shell; unlike Q_TERM it is not cleared by Q_NEW_SESSION. + const env = opts.agent === 'zcode' ? { PROCESS_LAUNCHED_BY_Q: '1' } : undefined + if (opts.bareShell) { + return this.createTerminal(`id:${worktree.id}`, { + title: opts.title, + ...(env ? { env } : {}), + ...(opts.surfaceOwner === false ? { surfaceOwner: false } : {}) + }) + } + return this.createTerminal(`id:${worktree.id}`, { + startupAgent: opts.agent, + ...(env ? { env } : {}), + ...(opts.launchPreferences ? { launchPreferences: opts.launchPreferences } : {}), + title: opts.title, + ...(opts.surfaceOwner === false ? { surfaceOwner: false } : {}) + }) + } + + async waitForTerminalAgentProcess( + handle: string, + agent: TuiAgent, + timeoutMs = 4_500 + ): Promise { + const host = this.getWorktreeStartupReadinessHost() + const deadline = Date.now() + Math.max(timeoutMs, 0) + const processReady = async (): Promise => { + do { + if ( + (await waitForWorktreeStartupFollowup( + host, + handle, + TUI_AGENT_CONFIG[agent].expectedProcess + )) !== null + ) { + return true + } + } while (Date.now() < deadline) + return false + } + if (agent !== 'zcode') { + return processReady() + } + + // A background worker's handle can exist before its PTY mapping is visible + // to the daemon. Poll all durable readiness signals within one budget instead + // of taking a one-shot PTY snapshot and then waiting only for the timeout. + do { + const ptyId = host.getPtyId(handle) + if (ptyId) { + try { + const foregroundProcess = await host.getForegroundProcess(ptyId) + if (isExpectedAgentProcess(foregroundProcess, TUI_AGENT_CONFIG[agent].expectedProcess)) { + return true + } + if ( + !isShellProcess(foregroundProcess ?? '') && + ((await host.hasChildProcesses?.(ptyId).catch(() => false)) ?? false) + ) { + return true + } + } catch { + // PTY inspection can race process replacement; keep polling. + } + if (isInteractiveZcodeComposerOutput(host.readRecentOutput(ptyId) ?? '')) { + return true + } + } + const terminal = await this.showTerminal(handle).catch(() => null) + if (isInteractiveZcodeComposerOutput(terminal?.preview ?? '')) { + return true + } + const screen = await this.readTerminal(handle, { screen: true }).catch(() => null) + if ( + screen && + isInteractiveZcodeComposerOutput( + [...screen.tail, ...(screen.draft ? [screen.draft] : [])].join('\n') + ) + ) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 250)) + } while (Date.now() < deadline) + return false + } + + async waitForTerminalAgentInputReady(handle: string, agent: TuiAgent): Promise { + if (agent === 'zcode') { + return this.waitForZcodeComposerReady(handle) + } + return ( + (await waitForWorktreeStartupDraft(this.getWorktreeStartupReadinessHost(), handle, agent)) !== + null + ) + } + + async waitForTerminalProviderSession( + handle: string, + agent: TuiAgent, + observedAfter: number, + timeoutMs = 10_000 + ): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() <= deadline) { + if (this.getExactWorkerProviderSession(handle, observedAfter)?.agent === agent) { + return true + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + return false + } + + async sendTerminalAgentStartupPrompt( + handle: string, + agent: TuiAgent, + prompt: string, + launchPreferences?: AgentLaunchPreferences + ): Promise { + const terminal = await this.showTerminal(handle) + const worktree = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`) + const repo = this.store?.getRepo(worktree.repoId) + if (!repo) { + throw new Error('Repository for the selected workspace is no longer available.') + } + const startup = this.buildStartupForAgent(repo, agent, prompt, launchPreferences) + if (startup.followup) { + throw new Error(`${agent} does not support startup-command prompt delivery.`) + } + return this.sendTerminalAgentPrompt(handle, startup.startup.command) + } +} diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f6a1ff5ac198..ebaa127e7abe 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1,8 +1,8 @@ import { installRuntimeLinearCommandSurface } from './runtime-linear-command-surface' -import { OrcaRuntimeWithResolveWaiter } from './orca-runtime-resolve-waiter' +import { OrcaRuntimeWithZcodeOrchestration } from './orca-runtime-zcode-orchestration' import type { RuntimeCommandSurfaceHost } from './orca-runtime-core' -class OrcaRuntimeService extends OrcaRuntimeWithResolveWaiter {} +class OrcaRuntimeService extends OrcaRuntimeWithZcodeOrchestration {} type OrcaRuntimeServiceExport = RuntimeCommandSurfaceHost const OrcaRuntimeServiceExport = OrcaRuntimeService as unknown as { new (...args: ConstructorParameters): OrcaRuntimeServiceExport diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index cc890a101ef8..ce9be13401ac 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -238,6 +238,16 @@ describe('buildDispatchPreamble', () => { expect(section).not.toMatch(/may exit/) }) + it('allows one-shot agents to exit after worker_done', () => { + const result = buildDispatchPreamble(baseParams({ workerKind: 'one-shot-agent' })) + const section = afterWorkerDoneSection(result) + + expect(section).toContain('This one-shot agent process may exit') + expect(section).toContain('launch a fresh worker') + expect(section).not.toContain('Do not exit the shell') + expect(section).not.toContain('Exit the shell after completion') + }) + it('uses === TASK === separator with the task spec appended', () => { const result = buildDispatchPreamble(baseParams({ taskSpec: 'refactor the auth module' })) expect(result).toContain('=== TASK ===') diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index 597e7bba89d0..1df706869081 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -30,7 +30,7 @@ export type PreambleParams = { } // Why: prompt-returning agents should idle after worker_done, while bare // shells have no agent prompt for Orca to reuse. - workerKind?: 'prompt-returning-agent' | 'bare-shell' + workerKind?: 'prompt-returning-agent' | 'one-shot-agent' | 'bare-shell' // Why gated: advertising a verb the depth cap will reject just burns a turn. canDispatchSubWorkers?: boolean } @@ -165,6 +165,20 @@ prompt for Orca to reuse; if the coordinator has more for you it will dispatch or prompt another worker with a fresh TASK block.` } + if (workerKind === 'one-shot-agent') { + return `=== AFTER YOU SEND worker_done === + +worker_done ends your turn for this task. Your dispatched work is complete: +stop and take no further actions — do NOT start new or unrelated work, +do NOT run a sleep/poll loop, and do NOT keep calling +\`${cli} orchestration check\`. The coordinator has already recorded your +completion and expects no further output. + +End the turn normally. This one-shot agent process may exit after completion; +if the coordinator has more work it will launch a fresh worker with a new +preamble + TASK block.` + } + return `=== AFTER YOU SEND worker_done === worker_done ends your turn for this task. Your dispatched work is complete: diff --git a/src/main/runtime/orchestration/worker-transcript-read-zcode.ts b/src/main/runtime/orchestration/worker-transcript-read-zcode.ts new file mode 100644 index 000000000000..6eaddf19c4ad --- /dev/null +++ b/src/main/runtime/orchestration/worker-transcript-read-zcode.ts @@ -0,0 +1,82 @@ +import { readZcodeSqliteTranscriptViaWorker } from '../../ai-vault/session-scanner-opencode-sqlite-worker-spawn' +import { resolveZcodeSqliteDbPath } from '../../ai-vault/zcode-sqlite-transcript' +import type { WorkerTranscriptReadResult } from './worker-transcript-read' +import { + boundWorkerTranscriptMessages, + clampWorkerTranscriptLimit +} from './worker-transcript-payload' +import { readLocalTranscriptSourceIdentity } from './worker-transcript-local-checkpoint' +import { createWorkerTranscriptBoundaryCheckpoint } from './worker-transcript-source-identity' + +export async function readZcodeWorkerTranscript(args: { + sessionId: string + transcriptPath?: string + offset?: number + limit?: number + expectedSourceFingerprint?: string + expectedBoundaryCheckpoint?: string +}): Promise { + const dbPath = resolveZcodeSqliteDbPath(args.transcriptPath) + try { + const before = await readLocalTranscriptSourceIdentity(dbPath) + if ( + !before || + (args.expectedSourceFingerprint && args.expectedSourceFingerprint !== before.fingerprint) + ) { + return { ok: false, reason: 'source_changed', warnings: [] } + } + const expectedCheckpoint = + args.offset === undefined ? undefined : checkpoint(args.sessionId, args.offset) + if ( + args.expectedBoundaryCheckpoint !== undefined && + args.expectedBoundaryCheckpoint !== expectedCheckpoint + ) { + return { ok: false, reason: 'source_changed', warnings: [] } + } + const page = await readZcodeSqliteTranscriptViaWorker({ + dbPath, + sessionId: args.sessionId, + offset: args.offset, + limit: clampWorkerTranscriptLimit(args.limit) + }) + const after = await readLocalTranscriptSourceIdentity(dbPath) + if (!after || after.fingerprint !== before.fingerprint) { + return { ok: false, reason: 'source_changed', warnings: [] } + } + const bounded = boundWorkerTranscriptMessages(page.messages, dbPath) + return { + ok: true, + filePath: dbPath, + sourceFingerprint: before.fingerprint, + boundaryCheckpoint: checkpoint(args.sessionId, page.nextOffset), + messages: bounded.messages, + nextOffset: page.nextOffset, + limited: page.limited || bounded.limited, + clipping: [ + ...(page.limited ? ['message_limit_or_scan_window'] : []), + ...(bounded.limited ? ['transcript_payload'] : []) + ], + warnings: [...page.warnings, ...bounded.warnings] + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (message.includes('ZCODE_TRANSCRIPT_SOURCE_CHANGED')) { + return { ok: false, reason: 'source_changed', warnings: [] } + } + const code = (error as NodeJS.ErrnoException | null)?.code + return { + ok: false, + reason: + code === 'ENOENT' || message.includes('does not exist') + ? 'transcript_missing' + : code === 'EACCES' || code === 'EPERM' + ? 'transcript_unreadable' + : 'transcript_parse_failed', + warnings: [] + } + } +} + +function checkpoint(sessionId: string, offset: number): string { + return createWorkerTranscriptBoundaryCheckpoint(Buffer.from(`${sessionId}\0${offset}`, 'utf8')) +} diff --git a/src/main/runtime/orchestration/worker-transcript-read.ts b/src/main/runtime/orchestration/worker-transcript-read.ts index cb6f0467c4aa..af2f5b822ee5 100644 --- a/src/main/runtime/orchestration/worker-transcript-read.ts +++ b/src/main/runtime/orchestration/worker-transcript-read.ts @@ -13,6 +13,7 @@ import { readInitialLocalWorkerTranscriptPage } from './worker-transcript-local-read' import { readRemoteWorkerTranscript } from './worker-transcript-remote-read' +import { readZcodeWorkerTranscript } from './worker-transcript-read-zcode' type WorkerTranscriptReadFailure = { ok: false @@ -49,6 +50,12 @@ export async function readWorkerTranscript(args: { /** Remote execution-host provider. When present no local filesystem lookup occurs. */ filesystemProvider?: IFilesystemProvider }): Promise { + if (args.agent === 'zcode') { + if (args.filesystemProvider) { + return { ok: false, reason: 'provider_unsupported', warnings: [] } + } + return readZcodeWorkerTranscript(args) + } const transcriptAgent = resolveNativeChatTranscriptAgent(args.agent) if (!transcriptAgent) { return { ok: false, reason: 'provider_unsupported', warnings: [] } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index 3da57f9c530e..5ee0274f1051 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -1,4 +1,6 @@ import type { RuntimeTerminalSend } from '../../../../../../shared/runtime-terminal-contracts' +import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority' +import type { TuiAgent } from '../../../../../../shared/tui-agent' import type { OrcaRuntimeService } from '../../../../orca-runtime' import { buildDispatchPreamble } from '../../../../orchestration/preamble' import { sendStructuredWorkerPreamble } from '../../orchestration-structured-worker-session' @@ -25,6 +27,9 @@ export async function deliverWorkerDispatchPreamble(args: { dispatchCapability: string devMode: boolean | undefined requestId: string + agent: TuiAgent + promptDelivery: 'agent-input' | 'startup-command' + launchPreferences?: AgentLaunchPreferences }): Promise { const { runtime, structuredSession, terminalHandle } = args const preamble = buildDispatchPreamble({ @@ -39,7 +44,8 @@ export async function deliverWorkerDispatchPreamble(args: { workerHandle: terminalHandle, dispatchCapability: args.dispatchCapability, devMode: args.devMode, - cliCommand: runtime.getTerminalOrchestrationCliCommand(terminalHandle) + cliCommand: runtime.getTerminalOrchestrationCliCommand(terminalHandle), + ...(args.promptDelivery === 'startup-command' ? { workerKind: 'one-shot-agent' } : {}) }) if (structuredSession) { await sendStructuredWorkerPreamble({ @@ -50,6 +56,16 @@ export async function deliverWorkerDispatchPreamble(args: { }) return undefined } + if (args.promptDelivery === 'startup-command') { + return ( + await runtime.sendTerminalAgentStartupPrompt( + terminalHandle, + args.agent, + preamble, + args.launchPreferences + ) + ).prompt + } return ( await runtime.sendTerminalAgentPrompt(terminalHandle, preamble, { acceptQueued: true, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index f8cd1033c97c..4e44c4acbaba 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -25,6 +25,7 @@ import { tearDownFailedWorkerStart } from './failed-worker-start-teardown' import { requireWorkerAuthority, type WorkerEffect } from './worker-topology' import { prepareLocalWorkerStart } from './worker-start-validation' import { deliverAndSettleWorkerStartReadiness } from './worker-start-readiness-settlement' +import { recordZcodeReadinessFailure, waitForZcodeReadiness } from './worker-zcode' type WorkerStartMutation = { callerFingerprint: string @@ -75,6 +76,16 @@ export async function startLocalWorker(args: { }) } let mode = await resolveWorkerStartModeOnHost(runtime, args.mode, resolvedWorktree?.id, agent) + const promptDeliveryWorktree = creationWorktree ?? resolvedWorktree + const promptDelivery = + agent && promptDeliveryWorktree + ? await runtime.resolveOrchestrationPromptDelivery(agent, promptDeliveryWorktree.id) + : 'agent-input' + const interactiveAgentCommand = + agent && promptDelivery === 'agent-input' && promptDeliveryWorktree + ? await runtime.resolveOrchestrationInteractiveAgentCommand(agent, promptDeliveryWorktree.id) + : undefined + const launchObservedAfter = Date.now() const startOptions = { worktree: requestedWorktree, @@ -137,6 +148,8 @@ export async function startLocalWorker(args: { mode, agent, launchPreferences: launch.preferences, + promptDelivery, + interactiveAgentCommand, effects, onStage: (stage) => { failedStage = stage @@ -165,6 +178,13 @@ export async function startLocalWorker(args: { persistWorkerReadinessStage(setupStage) failedStage = 'agent_readiness' + await waitForZcodeReadiness({ + runtime, + terminalHandle, + agent: agent!, + promptDelivery, + timeoutMs: params.timeoutMs ?? 60_000 + }) // A structured session is ready the moment its attach returns ok: there is no boot-to-idle // gap and no terminal title to read an idle edge from. Only the repo's wait-for-setup policy // still holds it back, and that gate has to be waited on explicitly here. @@ -218,7 +238,10 @@ export async function startLocalWorker(args: { dispatchCapability: capability, devMode: params.devMode, requestId: orchestrationMutation?.requestId ?? started.dispatch.id, - agent: agent ?? null, + agent: agent!, + promptDelivery, + launchObservedAfter, + ...(launch.preferences ? { launchPreferences: launch.preferences } : {}), setupReceipt, launchReceipt: launch.receipt, mode, @@ -230,6 +253,7 @@ export async function startLocalWorker(args: { } }) } catch (error) { + recordZcodeReadinessFailure({ agent, promptDelivery, failedStage }) await tearDownFailedWorkerStart({ runtime, structuredSession: placed?.structuredSession ?? null, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts index 8d87ea894c80..aa6355574187 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-agent-placement.ts @@ -59,6 +59,8 @@ type WorkerAgentPlacementArgs = { mode: WorkerStartModeReceipt agent: TuiAgent | undefined launchPreferences: AgentLaunchPreferences | undefined + promptDelivery: 'agent-input' | 'startup-command' + interactiveAgentCommand: string | undefined effects: WorkerEffect[] /** Attributes a throw to the step that was running, the way the caller's own stages do. */ onStage: (stage: string) => void @@ -118,12 +120,19 @@ async function placeInCreatedWorktree( coordinatorWorktree, params: args.params, agent: args.agent as TuiAgent, - withAgentTerminal: args.mode.mode !== 'structured', + withAgentTerminal: + args.mode.mode !== 'structured' && + args.promptDelivery === 'agent-input' && + !args.interactiveAgentCommand, ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), effects: args.effects }) const worktree = requireWorktree(created.worktree) - if (args.mode.mode !== 'structured') { + if ( + args.mode.mode !== 'structured' && + args.promptDelivery === 'agent-input' && + !args.interactiveAgentCommand + ) { return { mode: args.mode, worktree, @@ -170,6 +179,10 @@ async function createWorkerAgentSurface( worktreeId, agent: args.agent as TuiAgent, ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + promptDelivery: args.promptDelivery, + ...(args.interactiveAgentCommand + ? { interactiveAgentCommand: args.interactiveAgentCommand } + : {}), taskId: args.taskId, effects: args.effects }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts index a3c57a357d27..92ed63e697a9 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -1,4 +1,6 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' +import type { AgentLaunchPreferences } from '../../../../../../shared/agent-session-host-authority' +import type { TuiAgent } from '../../../../../../shared/tui-agent' import type { OrchestrationDb } from '../../../../orchestration/db' import type { RunRow, TaskRow } from '../../../../orchestration/types' import type { WorkerStartModeReceipt } from '../../orchestration-worker-start-mode' @@ -15,6 +17,7 @@ import { type WorkerEffect, type WorkerSetupReceipt } from './worker-topology' +import { mergeZcodeProviderWarning } from './worker-zcode' /** * Delivers the dispatch preamble and settles the worker's start state on the strongest @@ -34,7 +37,10 @@ export async function deliverAndSettleWorkerStartReadiness(args: { dispatchCapability: string devMode: boolean | undefined requestId: string - agent: string | null + agent: TuiAgent + promptDelivery: 'agent-input' | 'startup-command' + launchObservedAfter: number + launchPreferences?: AgentLaunchPreferences setupReceipt: WorkerSetupReceipt launchReceipt: OrchestrationWorkerLaunchReceipt mode: WorkerStartModeReceipt @@ -58,7 +64,10 @@ export async function deliverAndSettleWorkerStartReadiness(args: { coordinatorHandle: args.coordinatorHandle, dispatchCapability: args.dispatchCapability, devMode: args.devMode, - requestId: args.requestId + requestId: args.requestId, + agent: args.agent, + promptDelivery: args.promptDelivery, + ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}) }) effects.push({ kind: 'dispatch_input', @@ -77,6 +86,14 @@ export async function deliverAndSettleWorkerStartReadiness(args: { ? { verdict: 'observed' } : await observeWorkerTurnStart({ runtime, terminalHandle, prompt: promptDelivery }) const deliveredPrompt = turnStart.prompt ?? promptDelivery + const terminalRevealWarning = await mergeZcodeProviderWarning(args.terminalRevealWarning, { + runtime, + terminalHandle, + agent: args.agent, + promptDelivery: args.promptDelivery, + observedAfter: args.launchObservedAfter, + timeoutMs: args.timeoutMs + }) monitorWorkerSetup({ runtime, db, @@ -125,7 +142,7 @@ export async function deliverAndSettleWorkerStartReadiness(args: { `orca terminal read --terminal ${terminalHandle} --screen`, `orca orchestration worker-abandon --dispatch ${args.dispatchId} --json` ], - ...(args.terminalRevealWarning ? { warning: args.terminalRevealWarning } : {}) + ...(terminalRevealWarning ? { warning: terminalRevealWarning } : {}) } } const worker = alreadySettled @@ -151,6 +168,6 @@ export async function deliverAndSettleWorkerStartReadiness(args: { effects, ...(deliveredPrompt ? { prompt: deliveredPrompt } : {}), residualResources: [], - ...(args.terminalRevealWarning ? { warning: args.terminalRevealWarning } : {}) + ...(terminalRevealWarning ? { warning: terminalRevealWarning } : {}) } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology-zcode-timeout.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology-zcode-timeout.test.ts new file mode 100644 index 000000000000..5babbaf2e892 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology-zcode-timeout.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { getInteractiveAgentStartupTimeoutMs } from './worker-topology' + +describe('interactive worker startup timeout', () => { + it('allows ZCode to finish cold provider discovery', () => { + expect(getInteractiveAgentStartupTimeoutMs('zcode')).toBe(90_000) + }) + + it('keeps the existing startup budget for other agents', () => { + expect(getInteractiveAgentStartupTimeoutMs('codex')).toBe(30_000) + expect(getInteractiveAgentStartupTimeoutMs('claude')).toBe(30_000) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts index b18eb9ee6c25..c3baddb0bf36 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-topology.ts @@ -40,6 +40,12 @@ export type WorkerSetupReceipt = { | 'not_applicable' } +export function getInteractiveAgentStartupTimeoutMs(agent: TuiAgent): number { + // The npm-distributed ZCode TUI performs provider and workspace discovery before + // rendering its composer. On a cold launch that can exceed the generic 30s budget. + return agent === 'zcode' ? 90_000 : 30_000 +} + export function requireWorkerAuthority(runtime: OrcaRuntimeService, terminalHandle: string) { const authority = runtime.getOrchestrationDispatchAuthority(terminalHandle) const paneKey = authority?.paneKey ?? runtime.getTerminalPaneKey(terminalHandle) @@ -61,20 +67,31 @@ export async function createExistingWorktreeWorkerTerminal(args: { worktreeId: string agent: TuiAgent launchPreferences?: AgentLaunchPreferences + promptDelivery: 'agent-input' | 'startup-command' + interactiveAgentCommand?: string taskId: string effects: WorkerEffect[] }): Promise<{ handle: string; warning?: string }> { - const terminal = await args.runtime.createTerminal(`id:${args.worktreeId}`, { - // Why: the agent id is not a shell command — `cursor` resolves to the Cursor - // desktop app while its CLI is `cursor-agent`. Let the runtime build the - // configured launcher instead of executing the raw id. - startupAgent: args.agent, - ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), - title: `worker-${args.taskId}`, - // Why: dispatching a worker is background work; it must not pull the sidebar - // to the worker's workspace while the user is reading somewhere else. - surfaceOwner: false - }) + const terminal = + args.promptDelivery === 'startup-command' || args.interactiveAgentCommand + ? await args.runtime.createDeferredAgentTerminal(`id:${args.worktreeId}`, { + agent: args.agent, + bareShell: true, + ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + title: `worker-${args.taskId}`, + surfaceOwner: false + }) + : await args.runtime.createTerminal(`id:${args.worktreeId}`, { + // Why: the agent id is not a shell command — `cursor` resolves to the Cursor + // desktop app while its CLI is `cursor-agent`. Let the runtime build the + // configured launcher instead of executing the raw id. + startupAgent: args.agent, + ...(args.launchPreferences ? { launchPreferences: args.launchPreferences } : {}), + title: `worker-${args.taskId}`, + // Why: dispatching a worker is background work; it must not pull the sidebar + // to the worker's workspace while the user is reading somewhere else. + surfaceOwner: false + }) args.effects.push({ kind: 'terminal', role: 'agent', @@ -83,6 +100,26 @@ export async function createExistingWorktreeWorkerTerminal(args: { surface: terminal.surface, warning: terminal.warning }) + if (args.interactiveAgentCommand) { + // The daemon queues writes until the POSIX shell-ready gate opens. Waiting for + // `tui-idle` here is incorrect because this is deliberately a bare shell: it + // has no agent title or ready prompt and therefore cannot satisfy that agent + // readiness condition. Send the startup command now, then prove readiness from + // the launched process below. + await args.runtime.sendTerminal(terminal.handle, { + text: args.interactiveAgentCommand, + enter: true + }) + if ( + !(await args.runtime.waitForTerminalAgentProcess( + terminal.handle, + args.agent, + getInteractiveAgentStartupTimeoutMs(args.agent) + )) + ) { + throw new Error('interactive_agent_start_failed') + } + } return { handle: terminal.handle, warning: terminal.warning } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-zcode.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-zcode.ts new file mode 100644 index 000000000000..b5386dafc4e0 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-zcode.ts @@ -0,0 +1,74 @@ +import type { TuiAgent } from '../../../../../../shared/tui-agent' +import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { markInteractiveZcodeUnavailable } from '../../../../../zcode/interactive-client' + +export async function waitForZcodeReadiness(args: { + runtime: OrcaRuntimeService + terminalHandle: string + agent: TuiAgent + promptDelivery: 'agent-input' | 'startup-command' + timeoutMs: number +}): Promise { + if (args.agent !== 'zcode' || args.promptDelivery !== 'agent-input') { + return + } + if ( + !(await args.runtime.waitForTerminalAgentProcess( + args.terminalHandle, + args.agent, + args.timeoutMs + )) + ) { + throw new Error(`${args.agent} process did not become ready.`) + } + if (!(await args.runtime.waitForTerminalAgentInputReady(args.terminalHandle, args.agent))) { + throw new Error('zcode TUI did not report an input-ready composer.') + } +} + +export async function zcodeProviderSessionWarning(args: { + runtime: OrcaRuntimeService + terminalHandle: string + agent: TuiAgent + promptDelivery: 'agent-input' | 'startup-command' + observedAfter: number + timeoutMs: number +}): Promise { + if (args.agent !== 'zcode' || args.promptDelivery !== 'agent-input') { + return undefined + } + const reported = await args.runtime.waitForTerminalProviderSession( + args.terminalHandle, + args.agent, + args.observedAfter, + Math.min(args.timeoutMs, 10_000) + ) + return reported + ? undefined + : 'ZCode accepted the dispatch, but its provider session was not reported; output may temporarily fall back to the terminal.' +} + +export function recordZcodeReadinessFailure(args: { + agent: TuiAgent | null | undefined + promptDelivery: 'agent-input' | 'startup-command' + failedStage: string +}): void { + if ( + args.agent === 'zcode' && + args.promptDelivery === 'agent-input' && + args.failedStage === 'agent_readiness' + ) { + markInteractiveZcodeUnavailable() + } +} + +export async function mergeZcodeProviderWarning( + current: string | undefined, + args: Parameters[0] +): Promise { + const next = await zcodeProviderSessionWarning(args) + if (!next) { + return current + } + return current ? `${current} ${next}` : next +} diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts index 5164ac0b22f3..3c916c8a1117 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers-new-worktree.test.ts @@ -50,7 +50,14 @@ describe('orchestration new-worktree workers', () => { id: 'repo', kind: 'git' } as never) - vi.spyOn(runtime, 'createTerminal') + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_worker', + surface: 'background' + } as never) + vi.spyOn(runtime, 'createDeferredAgentTerminal').mockResolvedValue({ + handle: 'term_worker', + surface: 'background' + } as never) vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ terminals: [{ handle: 'term_worker', title: 'Codex' }], totalCount: 1, @@ -67,11 +74,28 @@ describe('orchestration new-worktree workers', () => { new Promise(() => undefined) ) vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'resolveOrchestrationPromptDelivery').mockResolvedValue('agent-input') + vi.spyOn(runtime, 'resolveOrchestrationInteractiveAgentCommand').mockImplementation( + async (agent) => (agent === 'zcode' ? 'command zcode --mode yolo' : undefined) + ) + vi.spyOn(runtime, 'sendTerminal').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'waitForTerminalAgentProcess').mockResolvedValue(true) + vi.spyOn(runtime, 'waitForTerminalAgentInputReady').mockResolvedValue(true) + vi.spyOn(runtime, 'waitForTerminalProviderSession').mockResolvedValue(true) vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ handle: 'term_worker', accepted: true, bytesWritten: 1 }) + vi.spyOn(runtime, 'sendTerminalAgentStartupPrompt').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) }) afterEach(() => { @@ -163,6 +187,7 @@ describe('orchestration new-worktree workers', () => { ]) ) expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(runtime.waitForTerminalAgentProcess).not.toHaveBeenCalled() }) it('passes launch preferences into agent-first worktree creation', async () => { @@ -188,6 +213,92 @@ describe('orchestration new-worktree workers', () => { }) }) + it('starts an interactive ZCode worktree before injecting the Dispatch preamble', async () => { + mockCreatedWorktree() + + const { result } = await startWorker({ agent: 'zcode' }) + + expect(result).toMatchObject({ state: 'ready' }) + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + createdWithAgent: 'zcode' + }) + ) + expect(vi.mocked(runtime.createManagedWorktree).mock.calls[0]?.[0]).not.toHaveProperty( + 'startupAgent' + ) + expect(runtime.createDeferredAgentTerminal).toHaveBeenCalledWith( + 'id:repo::created', + expect.objectContaining({ + agent: 'zcode', + bareShell: true, + title: expect.stringContaining('worker-') + }) + ) + expect(runtime.sendTerminal).toHaveBeenCalledWith('term_worker', { + text: 'command zcode --mode yolo', + enter: true + }) + expect(vi.mocked(runtime.sendTerminal).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(runtime.waitForTerminal).mock.invocationCallOrder[0]! + ) + expect(runtime.waitForTerminalAgentInputReady).toHaveBeenCalledWith('term_worker', 'zcode') + expect(runtime.waitForTerminalProviderSession).toHaveBeenCalledWith( + 'term_worker', + 'zcode', + expect.any(Number), + 10_000 + ) + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + 'term_worker', + expect.stringContaining('--dispatch-capability dcap_'), + expect.objectContaining({ acceptQueued: true }) + ) + expect(runtime.sendTerminalAgentStartupPrompt).not.toHaveBeenCalled() + }) + + it('keeps an accepted interactive ZCode dispatch ready when session reporting is delayed', async () => { + vi.mocked(runtime.waitForTerminalProviderSession).mockResolvedValueOnce(false) + mockCreatedWorktree() + + const { result, task } = await startWorker({ agent: 'zcode' }) + + expect(result).toMatchObject({ + state: 'ready', + warning: expect.stringContaining('provider session was not reported') + }) + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + 'term_worker', + expect.stringContaining('--dispatch-capability dcap_'), + expect.objectContaining({ acceptQueued: true }) + ) + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) + + it('defers ZCode startup when only the one-shot runtime is available', async () => { + vi.mocked(runtime.resolveOrchestrationPromptDelivery).mockResolvedValueOnce('startup-command') + mockCreatedWorktree() + + const { result } = await startWorker({ agent: 'zcode' }) + + expect(result).toMatchObject({ state: 'ready' }) + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + createdWithAgent: 'zcode' + }) + ) + expect(vi.mocked(runtime.createManagedWorktree).mock.calls[0]?.[0]).not.toHaveProperty( + 'startupAgent' + ) + expect(runtime.sendTerminalAgentStartupPrompt).toHaveBeenCalledWith( + 'term_worker', + 'zcode', + expect.stringContaining('--dispatch-capability dcap_'), + undefined + ) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + it('rejects a new worktree for a folder project before creating effects', async () => { vi.mocked(runtime.showRepo).mockResolvedValue({ id: 'repo', diff --git a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts index 67351e081023..50ef5286971b 100644 --- a/src/main/ssh/ssh-relay-session-managed-hooks.test.ts +++ b/src/main/ssh/ssh-relay-session-managed-hooks.test.ts @@ -159,4 +159,45 @@ describe('SshRelaySession managed hooks', () => { }) ) }) + + it('retries without ZCode when an older relay rejects the expanded allowlist', async () => { + let installAttempts = 0 + muxRequestMock.mockImplementation(async (method: string) => { + if (method === 'preflight.detectAgents') { + return { agents: ['codex', 'zcode'] } + } + if (method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD) { + installAttempts += 1 + if (installAttempts === 1) { + throw new Error('remote request failed: invalid_managed_hook_agents') + } + return { installers: 1, errors: 0 } + } + return { ok: true } + }) + const { mockStore, mockPortForward, getMainWindow } = createMockDeps() + const connection = { + sftp: vi.fn(), + getHostKeyFingerprint: vi.fn(() => 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') + } as unknown as SshConnection + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + + await session.establish(connection) + await vi.waitFor(() => expect(installAttempts).toBe(2)) + + const installCalls = muxRequestMock.mock.calls.filter( + ([method]) => method === AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD + ) + expect(installCalls.map(([, params]) => params)).toEqual([ + { + hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + agents: ['codex', 'zcode'] + }, + { + hostKeyFingerprint: 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + agents: ['codex'] + } + ]) + expect(registerSshPtyProvider).toHaveBeenCalled() + }) }) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index 19e102b08ede..4013e3eeec57 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -1399,8 +1399,25 @@ export class SshRelaySession { agents, ...(detected.claudeVersion ? { claudeVersion: detected.claudeVersion } : {}) } - const result = (await mux.request(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, params)) as { - errors?: unknown + let result: { errors?: unknown } + try { + result = (await mux.request(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, params)) as { + errors?: unknown + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const legacyAgents = agents.filter((agent) => agent !== 'zcode') + if ( + !message.includes('invalid_managed_hook_agents') || + legacyAgents.length === agents.length + ) { + throw error + } + // Older relays reject the entire allowlist when any target is unknown. + result = (await mux.request(AGENT_HOOK_INSTALL_MANAGED_HOOKS_METHOD, { + ...params, + agents: legacyAgents + })) as { errors?: unknown } } if (typeof result.errors === 'number' && result.errors > 0) { console.warn( diff --git a/src/main/zcode/hook-service.test.ts b/src/main/zcode/hook-service.test.ts new file mode 100644 index 000000000000..1c095887a187 --- /dev/null +++ b/src/main/zcode/hook-service.test.ts @@ -0,0 +1,144 @@ +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { ZcodeHookService } from './hook-service' +import { ZCODE_HOOK_EVENTS } from './zcode-hook-config' + +let home: string +let originalHome: string | undefined +let originalUserProfile: string | undefined + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'orca-zcode-hook-')) + originalHome = process.env.HOME + originalUserProfile = process.env.USERPROFILE + process.env.HOME = home + process.env.USERPROFILE = home +}) + +afterEach(() => { + if (originalHome === undefined) { + delete process.env.HOME + } else { + process.env.HOME = originalHome + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE + } else { + process.env.USERPROFILE = originalUserProfile + } + rmSync(home, { recursive: true, force: true }) +}) + +const configPath = (): string => join(home, '.zcode', 'cli', 'config.json') +const scriptPath = (): string => + join( + home, + '.orca', + 'agent-hooks', + process.platform === 'win32' ? 'zcode-hook.cmd' : 'zcode-hook.sh' + ) + +describe('ZcodeHookService', () => { + it('reports not_installed before install', () => { + expect(new ZcodeHookService().getStatus().state).toBe('not_installed') + }) + + it('installs all managed hooks and the payload-safe bridge script', () => { + const status = new ZcodeHookService().install() + expect(status.state).toBe('installed') + expect(status.managedHooksPresent).toBe(true) + + const config = JSON.parse(readFileSync(configPath(), 'utf-8')) as { + hooks?: { enabled?: boolean; events?: Record; [key: string]: unknown } + } + expect(config.hooks?.enabled).toBe(true) + expect(config.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + for (const event of ZCODE_HOOK_EVENTS) { + expect(config.hooks?.events?.[event]).toBeDefined() + } + + const script = readFileSync(scriptPath(), 'utf-8') + expect(script).toContain('/hook/zcode') + if (process.platform === 'win32') { + expect(script).toContain('curl.exe') + expect(script).toContain('--data-urlencode "payload@-"') + } else { + expect(script).toContain('printf \'%s\' "$payload" | curl') + expect(script).toContain('--data-urlencode "payload@-"') + expect(script).not.toContain('--data-urlencode "payload=${payload}"') + } + }) + + it('keeps user config across install, reinstall, and remove', () => { + const dir = join(home, '.zcode', 'cli') + mkdirSync(dir, { recursive: true }) + const userConfig = { + theme: 'dark', + hooks: { + enabled: false, + events: { + PreToolUse: [ + { + matcher: 'Write', + hooks: [{ type: 'command', command: 'echo user-hook', enabled: true }] + } + ] + } + } + } + writeFileSync(configPath(), `${JSON.stringify(userConfig, null, 2)}\n`) + + const service = new ZcodeHookService() + expect(service.install().state).toBe('installed') + service.install() + + type HookDef = { hooks?: { command?: string }[] } + type Parsed = { + theme?: string + hooks?: { enabled?: boolean; events?: Record } + } + const installed = JSON.parse(readFileSync(configPath(), 'utf-8')) as Parsed + expect(installed.theme).toBe('dark') + const preTool = installed.hooks?.events?.PreToolUse ?? [] + expect(preTool.some((definition) => definition.hooks?.[0]?.command === 'echo user-hook')).toBe( + true + ) + expect( + preTool + .flatMap((definition) => definition.hooks ?? []) + .filter((hook) => hook.command?.includes('zcode-hook')) + ).toHaveLength(1) + + expect(service.remove().state).toBe('not_installed') + const removed = JSON.parse(readFileSync(configPath(), 'utf-8')) as Parsed + expect(removed.theme).toBe('dark') + expect(removed.hooks?.enabled).toBe(false) + expect( + (removed.hooks?.events?.PreToolUse ?? []).some( + (definition) => definition.hooks?.[0]?.command === 'echo user-hook' + ) + ).toBe(true) + }) + + it('restores an absent hooks.enabled field on remove without writing private metadata', () => { + const dir = join(home, '.zcode', 'cli') + mkdirSync(dir, { recursive: true }) + writeFileSync(configPath(), `${JSON.stringify({ hooks: { events: {} } }, null, 2)}\n`) + + const service = new ZcodeHookService() + expect(service.install().state).toBe('installed') + const installed = JSON.parse(readFileSync(configPath(), 'utf-8')) as { + hooks?: Record + } + expect(installed.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + + expect(service.remove().state).toBe('not_installed') + const removed = JSON.parse(readFileSync(configPath(), 'utf-8')) as { + hooks?: Record + } + expect(removed.hooks).not.toHaveProperty('enabled') + expect(removed.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + }) +}) diff --git a/src/main/zcode/hook-service.ts b/src/main/zcode/hook-service.ts new file mode 100644 index 000000000000..2008f638a42d --- /dev/null +++ b/src/main/zcode/hook-service.ts @@ -0,0 +1,279 @@ +import { readFileSync, rmSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { SFTPWrapper } from 'ssh2' +import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types' +import { + buildWindowsAgentHookPostCommand, + getSharedManagedScriptPath, + readHooksJson, + wrapPosixHookCommand, + wrapWindowsHookCommand, + writeHooksJson, + writeManagedScript, + type HooksConfig +} from '../agent-hooks/installer-utils' +import { + readHooksJsonRemote, + writeHooksJsonRemote, + writeManagedScriptRemote +} from '../agent-hooks/installer-utils-remote' +import { + buildPosixHookPayloadCapture, + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' +import { refreshManagedScriptIfPresent } from '../agent-hooks/managed-hook-script-refresh' +import { + applyManagedZcodeHooks, + getPreInstallZcodeHooksEnabledState, + isZcodeHooksEnabled, + readManagedZcodeHookEvents, + removeManagedZcodeHooks, + ZCODE_HOOK_EVENTS, + type ZcodeConfig, + type ZcodeHooksEnabledState +} from './zcode-hook-config' + +type ZcodeHookState = { + schemaVersion: 1 + previousHooksEnabled: ZcodeHooksEnabledState +} + +function getConfigPath(): string { + return join(homedir(), '.zcode', 'cli', 'config.json') +} + +function getManagedScriptFileName(): string { + return process.platform === 'win32' ? 'zcode-hook.cmd' : 'zcode-hook.sh' +} + +function getManagedScriptPath(): string { + return getSharedManagedScriptPath(getManagedScriptFileName()) +} + +function getManagedStatePath(): string { + return getSharedManagedScriptPath('zcode-hook-state.json') +} + +function readManagedState(): ZcodeHookState | null { + try { + const parsed: unknown = JSON.parse(readFileSync(getManagedStatePath(), 'utf-8')) + if ( + typeof parsed !== 'object' || + parsed === null || + (parsed as ZcodeHookState).schemaVersion !== 1 || + !['missing', 'enabled', 'disabled'].includes((parsed as ZcodeHookState).previousHooksEnabled) + ) { + return null + } + return parsed as ZcodeHookState + } catch { + return null + } +} + +function writeManagedState(previousHooksEnabled: ZcodeHooksEnabledState): void { + writeHooksJson(getManagedStatePath(), { + schemaVersion: 1, + previousHooksEnabled + }) +} + +function removeManagedState(): void { + const statePath = getManagedStatePath() + rmSync(statePath, { force: true }) + rmSync(`${statePath}.bak`, { force: true }) +} + +function getManagedCommand(scriptPath: string): string { + return process.platform === 'win32' + ? wrapWindowsHookCommand(scriptPath) + : wrapPosixHookCommand(scriptPath) +} + +function getManagedScript(target: 'local' | 'posix' = 'local'): string { + if (target === 'local' && process.platform === 'win32') { + return [ + '@echo off', + 'setlocal', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + buildWindowsAgentHookPostCommand('zcode'), + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') + } + + return [ + '#!/bin/sh', + ...buildPosixHookPayloadCapture(), + 'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then', + ' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :', + 'fi', + 'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then', + ' exit 0', + 'fi', + 'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/zcode" \\', + ' --connect-timeout 0.5 --max-time 1.5 \\', + ' -H "Content-Type: application/x-www-form-urlencoded" \\', + ' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\', + ' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\', + ' --data-urlencode "tabId=${ORCA_TAB_ID}" \\', + ' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\', + ' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\', + ' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\', + ' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\', + ' --data-urlencode "payload@-" >/dev/null 2>&1 || true', + 'exit 0', + '' + ].join('\n') +} + +function asZcodeConfig(config: ReturnType): ZcodeConfig | null { + return config as ZcodeConfig | null +} + +function asHooksConfig(config: ZcodeConfig): HooksConfig { + return config as HooksConfig +} + +function buildStatus( + present: Set, + configPath: string, + hooksEnabled: boolean +): AgentHookInstallStatus { + const missing = ZCODE_HOOK_EVENTS.filter((event) => !present.has(event)) + let state: AgentHookInstallState + let detail: string | null + if (missing.length === 0 && hooksEnabled) { + state = 'installed' + detail = null + } else if (present.size === 0) { + state = 'not_installed' + detail = null + } else { + state = 'partial' + detail = hooksEnabled + ? `Managed hook missing for events: ${missing.join(', ')}` + : 'ZCode hooks are disabled (hooks.enabled is not true)' + } + return { agent: 'zcode', state, configPath, managedHooksPresent: present.size > 0, detail } +} + +export class ZcodeHookService { + async refreshManagedScripts(): Promise { + await refreshManagedScriptIfPresent(getManagedScriptPath(), getManagedScript()) + } + + getStatus(): AgentHookInstallStatus { + const configPath = getConfigPath() + const config = asZcodeConfig(readHooksJson(configPath)) + if (!config) { + return { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse ZCode cli/config.json' + } + } + return buildStatus( + readManagedZcodeHookEvents(config, getManagedCommand(getManagedScriptPath())), + configPath, + isZcodeHooksEnabled(config) + ) + } + + install(): AgentHookInstallStatus { + const configPath = getConfigPath() + const config = asZcodeConfig(readHooksJson(configPath)) + if (!config) { + return { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse ZCode cli/config.json' + } + } + const scriptPath = getManagedScriptPath() + if (!readManagedState()) { + writeManagedState(getPreInstallZcodeHooksEnabledState(config)) + } + const next = applyManagedZcodeHooks( + config, + getManagedCommand(scriptPath), + getManagedScriptFileName() + ) + writeManagedScript(scriptPath, getManagedScript()) + writeHooksJson(configPath, asHooksConfig(next)) + return this.getStatus() + } + + async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise { + const home = remoteHome.replace(/\/$/, '') + const configPath = `${home}/.zcode/cli/config.json` + const scriptPath = `${home}/.orca/agent-hooks/zcode-hook.sh` + try { + const config = asZcodeConfig(await readHooksJsonRemote(sftp, configPath)) + if (!config) { + return { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse remote ZCode cli/config.json' + } + } + const next = applyManagedZcodeHooks(config, wrapPosixHookCommand(scriptPath), 'zcode-hook.sh') + await writeManagedScriptRemote(sftp, scriptPath, getManagedScript('posix')) + await writeHooksJsonRemote(sftp, configPath, asHooksConfig(next)) + return { + agent: 'zcode', + state: 'installed', + configPath, + managedHooksPresent: true, + detail: null + } + } catch (error) { + return { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: error instanceof Error ? error.message : String(error) + } + } + } + + remove(): AgentHookInstallStatus { + const configPath = getConfigPath() + const config = asZcodeConfig(readHooksJson(configPath)) + if (!config) { + return { + agent: 'zcode', + state: 'error', + configPath, + managedHooksPresent: false, + detail: 'Could not parse ZCode cli/config.json' + } + } + const managedState = readManagedState() + writeHooksJson( + configPath, + asHooksConfig( + removeManagedZcodeHooks( + config, + getManagedScriptFileName(), + managedState?.previousHooksEnabled + ) + ) + ) + removeManagedState() + return this.getStatus() + } +} + +export const zcodeHookService = new ZcodeHookService() diff --git a/src/main/zcode/interactive-client.test.ts b/src/main/zcode/interactive-client.test.ts new file mode 100644 index 000000000000..08331c412c06 --- /dev/null +++ b/src/main/zcode/interactive-client.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + isInteractiveZcodeComposerOutput, + isInteractiveZcodeVersionOutput, + markInteractiveZcodeUnavailable, + resetInteractiveZcodeAvailabilityForTests, + resolveZcodePromptDelivery +} from './interactive-client' + +describe('ZCode interactive client detection', () => { + afterEach(() => resetInteractiveZcodeAvailabilityForTests()) + it('recognizes the third-party TUI distribution marker', () => { + expect(isInteractiveZcodeVersionOutput('zcode-app-cli 3.7.5-11\nzcode-runtime 0.16.1\n')).toBe( + true + ) + }) + + it('does not treat the official headless runtime as an interactive client', () => { + expect(isInteractiveZcodeVersionOutput('zcode 0.16.1\n')).toBe(false) + }) + + it('fails closed for empty or unrelated version output', () => { + expect(isInteractiveZcodeVersionOutput('')).toBe(false) + expect(isInteractiveZcodeVersionOutput('some-zcode-wrapper 1.0.0')).toBe(false) + }) + + it('recognizes the stable interactive composer text without relying on ANSI frames', () => { + expect( + isInteractiveZcodeComposerOutput( + '╭─ ◆ ZCODE v3.14.0-27 ─╮\n│ Ask a task about this workspace │' + ) + ).toBe(true) + expect(isInteractiveZcodeComposerOutput('ZCODE is starting')).toBe(false) + expect(isInteractiveZcodeComposerOutput('Ask a task about this workspace')).toBe(true) + }) + + it('uses the local interactive client when its capability probe succeeds', async () => { + const probe = vi.fn(async () => true) + await expect( + resolveZcodePromptDelivery({ + isRemote: false, + probeInteractiveClient: probe + }) + ).resolves.toBe('agent-input') + expect(probe).toHaveBeenCalledOnce() + + await expect( + resolveZcodePromptDelivery({ + isRemote: false, + probeInteractiveClient: async () => false + }) + ).resolves.toBe('startup-command') + }) + + it('temporarily falls back after a proven interactive readiness failure', async () => { + markInteractiveZcodeUnavailable(100) + const probe = vi.fn(async () => true) + const now = vi.spyOn(Date, 'now').mockReturnValue(101) + await expect( + resolveZcodePromptDelivery({ isRemote: false, probeInteractiveClient: probe }) + ).resolves.toBe('startup-command') + expect(probe).not.toHaveBeenCalled() + now.mockRestore() + }) + + it('keeps remote and command-override launches on the atomic one-shot path', async () => { + const probe = async (): Promise => { + throw new Error('probe should not run') + } + await expect( + resolveZcodePromptDelivery({ isRemote: true, probeInteractiveClient: probe }) + ).resolves.toBe('startup-command') + await expect( + resolveZcodePromptDelivery({ + isRemote: false, + commandOverride: 'custom-zcode', + probeInteractiveClient: probe + }) + ).resolves.toBe('startup-command') + }) + + it('ignores an empty command override when selecting the local interactive client', async () => { + await expect( + resolveZcodePromptDelivery({ + isRemote: false, + commandOverride: ' ', + probeInteractiveClient: async () => true + }) + ).resolves.toBe('agent-input') + }) +}) diff --git a/src/main/zcode/interactive-client.ts b/src/main/zcode/interactive-client.ts new file mode 100644 index 000000000000..2374cf7bf9a7 --- /dev/null +++ b/src/main/zcode/interactive-client.ts @@ -0,0 +1,45 @@ +import { execLocalPreflightCommandOrThrow } from '../ipc/preflight-command-exec' + +const INTERACTIVE_DISTRIBUTION_MARKER = /^zcode-app-cli\s+\S+/m +const INTERACTIVE_COMPOSER_PROMPT = 'Ask a task about this workspace' +const INTERACTIVE_FAILURE_COOLDOWN_MS = 10 * 60 * 1000 +let interactiveUnavailableUntil = 0 + +export function isInteractiveZcodeVersionOutput(output: string): boolean { + return INTERACTIVE_DISTRIBUTION_MARKER.test(output) +} + +export function isInteractiveZcodeComposerOutput(output: string): boolean { + return output.includes(INTERACTIVE_COMPOSER_PROMPT) +} + +export async function hasInteractiveZcodeClient(): Promise { + try { + const result = await execLocalPreflightCommandOrThrow('zcode', ['--version']) + return isInteractiveZcodeVersionOutput(`${result.stdout}\n${result.stderr}`) + } catch { + // Capability detection must fail closed so the signed Desktop runtime can + // still receive its prompt through the supported one-shot entry point. + return false + } +} + +export async function resolveZcodePromptDelivery(args: { + isRemote: boolean + commandOverride?: string | null + probeInteractiveClient?: () => Promise +}): Promise<'agent-input' | 'startup-command'> { + if (args.isRemote || args.commandOverride?.trim() || Date.now() < interactiveUnavailableUntil) { + return 'startup-command' + } + const probe = args.probeInteractiveClient ?? hasInteractiveZcodeClient + return (await probe()) ? 'agent-input' : 'startup-command' +} + +export function markInteractiveZcodeUnavailable(now = Date.now()): void { + interactiveUnavailableUntil = now + INTERACTIVE_FAILURE_COOLDOWN_MS +} + +export function resetInteractiveZcodeAvailabilityForTests(): void { + interactiveUnavailableUntil = 0 +} diff --git a/src/main/zcode/zcode-hook-config.test.ts b/src/main/zcode/zcode-hook-config.test.ts new file mode 100644 index 000000000000..dc3c86fd95c8 --- /dev/null +++ b/src/main/zcode/zcode-hook-config.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { + applyManagedZcodeHooks, + isZcodeHooksEnabled, + readManagedZcodeHookEvents, + removeManagedZcodeHooks, + ZCODE_HOOK_EVENTS +} from './zcode-hook-config' + +const COMMAND = + "if [ -f '/home/u/.orca/agent-hooks/zcode-hook.sh' ]; then /bin/sh '/home/u/.orca/agent-hooks/zcode-hook.sh'; else :; fi" +const SCRIPT = 'zcode-hook.sh' + +describe('zcode-hook-config', () => { + it('enables hooks and installs managed entries for every tracked event', () => { + const next = applyManagedZcodeHooks({}, COMMAND, SCRIPT) + expect(isZcodeHooksEnabled(next)).toBe(true) + expect([...readManagedZcodeHookEvents(next, COMMAND)].sort()).toEqual( + [...ZCODE_HOOK_EVENTS].sort() + ) + expect(next.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + }) + + it('preserves user hooks and strips only managed ones on remove', () => { + const withUser = applyManagedZcodeHooks( + { + hooks: { + enabled: true, + events: { + PreToolUse: [ + { + matcher: 'Write', + hooks: [{ type: 'command', command: 'echo keep-me', enabled: true }] + } + ] + } + } + }, + COMMAND, + SCRIPT + ) + const removed = removeManagedZcodeHooks(withUser, SCRIPT) + const pre = removed.hooks?.events?.PreToolUse ?? [] + expect(pre).toHaveLength(1) + expect(pre[0]?.hooks?.[0]?.command).toBe('echo keep-me') + expect(readManagedZcodeHookEvents(removed, COMMAND).size).toBe(0) + expect(removed.hooks?.enabled).toBe(true) + expect(removed.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + }) + + it('preserves user definitions without a hooks array', () => { + const config = { + hooks: { + enabled: true, + events: { PreToolUse: [{ matcher: 'Read', custom: 'keep-me' }] } + } + } + const removed = removeManagedZcodeHooks(applyManagedZcodeHooks(config, COMMAND, SCRIPT), SCRIPT) + expect(removed.hooks?.events?.PreToolUse).toEqual([{ matcher: 'Read', custom: 'keep-me' }]) + }) + + it('restores a disabled pre-install hooks.enabled value on remove', () => { + const installed = applyManagedZcodeHooks( + { hooks: { enabled: false, events: {} } }, + COMMAND, + SCRIPT + ) + expect(installed.hooks?.enabled).toBe(true) + expect(installed.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + + const reinstalled = applyManagedZcodeHooks(installed, COMMAND, SCRIPT) + expect(reinstalled.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + + const removed = removeManagedZcodeHooks(reinstalled, SCRIPT, 'disabled') + expect(removed.hooks?.enabled).toBe(false) + expect(removed.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + }) + + it('restores an absent pre-install hooks.enabled value on remove', () => { + const installed = applyManagedZcodeHooks({ hooks: { events: {} } }, COMMAND, SCRIPT) + expect(installed.hooks?.enabled).toBe(true) + + const removed = removeManagedZcodeHooks(installed, SCRIPT, 'missing') + expect(Object.hasOwn(removed.hooks ?? {}, 'enabled')).toBe(false) + }) + + it('removes legacy Orca metadata from the strict ZCode config schema', () => { + const installed = applyManagedZcodeHooks( + { hooks: { enabled: true, events: {}, orcaPreviousHooksEnabled: false } }, + COMMAND, + SCRIPT + ) + expect(installed.hooks).not.toHaveProperty('orcaPreviousHooksEnabled') + }) + + it('is idempotent across reinstall', () => { + const twice = applyManagedZcodeHooks( + applyManagedZcodeHooks({}, COMMAND, SCRIPT), + COMMAND, + SCRIPT + ) + for (const event of ZCODE_HOOK_EVENTS) { + const managed = (twice.hooks?.events?.[event] ?? []) + .flatMap((definition) => definition.hooks ?? []) + .filter((hook) => hook.command === COMMAND) + expect(managed).toHaveLength(1) + } + }) +}) diff --git a/src/main/zcode/zcode-hook-config.ts b/src/main/zcode/zcode-hook-config.ts new file mode 100644 index 000000000000..858a0ee34b18 --- /dev/null +++ b/src/main/zcode/zcode-hook-config.ts @@ -0,0 +1,202 @@ +import { + MANAGED_HOOK_TIMEOUT_MILLISECONDS, + createManagedCommandMatcher, + isPlainObject +} from '../agent-hooks/installer-utils' + +export const ZCODE_HOOK_EVENTS = [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop' +] as const + +const LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY = 'orcaPreviousHooksEnabled' + +export type ZcodeHooksEnabledState = 'missing' | 'enabled' | 'disabled' + +export type ZcodeHookCommand = { + type?: string + command?: string + enabled?: boolean + timeoutMs?: number + [key: string]: unknown +} + +export type ZcodeHookDefinition = { + matcher?: string + hooks?: ZcodeHookCommand[] + [key: string]: unknown +} + +export type ZcodeHooksRoot = { + enabled?: boolean + events?: Record + [key: string]: unknown +} + +export type ZcodeConfig = { + hooks?: ZcodeHooksRoot + [key: string]: unknown +} + +export function getZcodeHooksEnabledState(config: ZcodeConfig): ZcodeHooksEnabledState { + if (!isPlainObject(config.hooks) || !Object.hasOwn(config.hooks, 'enabled')) { + return 'missing' + } + return config.hooks.enabled === true ? 'enabled' : 'disabled' +} + +export function getPreInstallZcodeHooksEnabledState(config: ZcodeConfig): ZcodeHooksEnabledState { + if ( + isPlainObject(config.hooks) && + Object.hasOwn(config.hooks, LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY) + ) { + return config.hooks[LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY] === true ? 'enabled' : 'disabled' + } + return getZcodeHooksEnabledState(config) +} + +function asDefinitionArray(value: unknown): ZcodeHookDefinition[] { + if (!Array.isArray(value)) { + return [] + } + return value.filter((entry): entry is ZcodeHookDefinition => isPlainObject(entry)) +} + +function buildManagedHookCommand(command: string): ZcodeHookCommand { + return { + type: 'command', + command, + enabled: true, + timeoutMs: MANAGED_HOOK_TIMEOUT_MILLISECONDS + } +} + +function stripManagedCommands( + definitions: ZcodeHookDefinition[], + isManagedCommand: (command: string | undefined) => boolean +): ZcodeHookDefinition[] { + const next: ZcodeHookDefinition[] = [] + for (const definition of definitions) { + if (!Array.isArray(definition.hooks)) { + next.push(definition) + continue + } + const hooks = definition.hooks + const cleanedHooks = hooks.filter( + (hook) => !isManagedCommand(typeof hook.command === 'string' ? hook.command : undefined) + ) + if (hooks.length > 0 && cleanedHooks.length === 0) { + continue + } + next.push({ ...definition, hooks: cleanedHooks }) + } + return next +} + +export function applyManagedZcodeHooks( + config: ZcodeConfig, + command: string, + scriptFileName: string +): ZcodeConfig { + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const hooksRoot: ZcodeHooksRoot = isPlainObject(config.hooks) ? { ...config.hooks } : {} + const events: Record = isPlainObject(hooksRoot.events) + ? { ...hooksRoot.events } + : {} + const managedEvents = new Set(ZCODE_HOOK_EVENTS) + + for (const [eventName, definitions] of Object.entries(events)) { + if (managedEvents.has(eventName)) { + continue + } + const cleaned = stripManagedCommands(asDefinitionArray(definitions), isManagedCommand) + if (cleaned.length === 0) { + delete events[eventName] + } else { + events[eventName] = cleaned + } + } + + for (const eventName of ZCODE_HOOK_EVENTS) { + const current = stripManagedCommands(asDefinitionArray(events[eventName]), isManagedCommand) + events[eventName] = [...current, { matcher: '*', hooks: [buildManagedHookCommand(command)] }] + } + + // Why: ZCode validates config.json strictly. Orca state belongs in its own + // sidecar, and this also repairs configs written by the initial integration. + delete hooksRoot[LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY] + hooksRoot.enabled = true + hooksRoot.events = events + return { ...config, hooks: hooksRoot } +} + +export function removeManagedZcodeHooks( + config: ZcodeConfig, + scriptFileName: string, + previousHooksEnabled?: ZcodeHooksEnabledState +): ZcodeConfig { + if (!isPlainObject(config.hooks) || !isPlainObject(config.hooks.events)) { + return config + } + const isManagedCommand = createManagedCommandMatcher(scriptFileName) + const hooksRoot: ZcodeHooksRoot = { ...config.hooks } + const events: Record = { ...hooksRoot.events } + const legacyPreviousHooksEnabled = Object.hasOwn( + hooksRoot, + LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY + ) + ? hooksRoot[LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY] === true + ? 'enabled' + : 'disabled' + : undefined + + for (const [eventName, definitions] of Object.entries(events)) { + const cleaned = stripManagedCommands(asDefinitionArray(definitions), isManagedCommand) + if (cleaned.length === 0) { + delete events[eventName] + } else { + events[eventName] = cleaned + } + } + + hooksRoot.events = events + delete hooksRoot[LEGACY_ORCA_PREVIOUS_HOOKS_ENABLED_KEY] + const restoredState = previousHooksEnabled ?? legacyPreviousHooksEnabled + if (restoredState === 'missing') { + delete hooksRoot.enabled + } else if (restoredState !== undefined) { + hooksRoot.enabled = restoredState === 'enabled' + } + return { ...config, hooks: hooksRoot } +} + +export function readManagedZcodeHookEvents(config: ZcodeConfig, command: string): Set { + const present = new Set() + const events = + isPlainObject(config.hooks) && isPlainObject(config.hooks.events) ? config.hooks.events : null + if (!events) { + return present + } + for (const eventName of ZCODE_HOOK_EVENTS) { + const definitions = asDefinitionArray(events[eventName]) + if ( + definitions.some((definition) => + (Array.isArray(definition.hooks) ? definition.hooks : []).some( + (hook) => hook.command === command + ) + ) + ) { + present.add(eventName) + } + } + return present +} + +export function isZcodeHooksEnabled(config: ZcodeConfig): boolean { + return isPlainObject(config.hooks) && config.hooks.enabled === true +} diff --git a/src/renderer/src/components/terminal-pane/title-agent-identity.test.ts b/src/renderer/src/components/terminal-pane/title-agent-identity.test.ts index d0bf535eaf31..21097ac2b78b 100644 --- a/src/renderer/src/components/terminal-pane/title-agent-identity.test.ts +++ b/src/renderer/src/components/terminal-pane/title-agent-identity.test.ts @@ -11,4 +11,9 @@ describe('titleHasExplicitAgentIdentity', () => { expect(titleHasExplicitAgentIdentity('C:\\work\\devin.exe\\ready')).toBe(false) expect(titleHasExplicitAgentIdentity('devin-fixtures ready')).toBe(false) }) + + it('recognizes the ZCode runtime process title', () => { + expect(titleHasExplicitAgentIdentity('zcode-cli')).toBe(true) + expect(titleHasExplicitAgentIdentity('zcode-cli working')).toBe(true) + }) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 487c8f9bdbe2..81813e9f7277 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -573,6 +573,7 @@ "agent": { "catalog": { "5dff448636": "OpenClaw", + "a7c0de2012": "ZCode", "8a9ba743cc": "Hermes", "4e63c7b956": "Rovo Dev", "bee242fe3d": "Qwen Code", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cfccde752809..9c9d8aa703c7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -321,6 +321,7 @@ "agent": { "catalog": { "5dff448636": "OpenClaw", + "a7c0de2012": "ZCode", "8a9ba743cc": "Hermes", "4e63c7b956": "Rovo Dev", "bee242fe3d": "Qwen Code", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 0a3b2ee6c867..3f4fcdf6e820 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -321,6 +321,7 @@ "agent": { "catalog": { "5dff448636": "OpenClaw", + "a7c0de2012": "ZCode", "8a9ba743cc": "Hermes", "4e63c7b956": "Rovo Dev", "bee242fe3d": "Qwen Code", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 91aa9d8d74bb..866e262467ec 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -324,6 +324,7 @@ "agent": { "catalog": { "5dff448636": "OpenClaw", + "a7c0de2012": "ZCode", "8a9ba743cc": "Hermes", "4e63c7b956": "Rovo Dev", "bee242fe3d": "Qwen Code", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index f460f007b2cd..eee013893aa7 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -324,6 +324,7 @@ "agent": { "catalog": { "5dff448636": "OpenClaw", + "a7c0de2012": "ZCode", "8a9ba743cc": "Hermes", "4e63c7b956": "Rovo Dev", "bee242fe3d": "Qwen Code", diff --git a/src/renderer/src/lib/agent-catalog.tsx b/src/renderer/src/lib/agent-catalog.tsx index 8217517ad8be..5351920a613b 100644 --- a/src/renderer/src/lib/agent-catalog.tsx +++ b/src/renderer/src/lib/agent-catalog.tsx @@ -314,6 +314,13 @@ export const getAgentCatalog = createLocalizedCatalog((): AgentCatalogEntry[] => cmd: 'openclaw', faviconDomain: 'openclaw.ai', homepageUrl: 'https://github.com/openclaw/openclaw' + }, + { + id: 'zcode', + label: translate('auto.lib.agent.catalog.a7c0de2012', 'ZCode'), + cmd: 'zcode', + faviconDomain: 'z.ai', + homepageUrl: 'https://zcode.z.ai/en/docs/install' } ]) diff --git a/src/renderer/src/lib/agent-status.ts b/src/renderer/src/lib/agent-status.ts index 3af7b38def92..d6b00492f234 100644 --- a/src/renderer/src/lib/agent-status.ts +++ b/src/renderer/src/lib/agent-status.ts @@ -134,7 +134,8 @@ const ICONABLE_AGENT_TYPES: Record = { grok: true, devin: true, ante: true, - trae: true + trae: true, + zcode: true } // Why: return null (not a 'claude' fallback) for unknown so Codex panes don't flash the Claude icon before the hook fires. diff --git a/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts b/src/shared/agent-hook-listener-claude-compatible-vendors.test.ts index 601b8aea828f..175e18d96a74 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,79 @@ 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 started = normalizeHookPayload( + state, + 'zcode', + { + paneKey: PANE_KEY, + payload: { hook_event_name: 'SessionStart', session_id: 'zcode-session-1' } + }, + 'production' + ) + 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(started).toMatchObject({ + providerSessionOnly: true, + providerSession: { key: 'session_id', id: 'zcode-session-1' }, + payload: { agentType: 'zcode', state: 'done', prompt: '' } + }) + 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.ts b/src/shared/agent-hook-listener.ts index 7df46e74cbc0..3e794ce01575 100644 --- a/src/shared/agent-hook-listener.ts +++ b/src/shared/agent-hook-listener.ts @@ -119,8 +119,8 @@ export function normalizeHookPayload( extractedPrompt }) const providerSessionOnly = - (source === 'pi' || source === 'prime-agent') && - eventName === 'session_start' && + (((source === 'pi' || source === 'prime-agent') && eventName === 'session_start') || + (source === 'zcode' && eventName === 'SessionStart')) && providerSession !== null // A transcript session_start carries resume identity while idle; receivers discard the placeholder row. const transportPayload = diff --git a/src/shared/agent-hook-listener/provider-dispatch.ts b/src/shared/agent-hook-listener/provider-dispatch.ts index fb3647e26516..a1a95ea24163 100644 --- a/src/shared/agent-hook-listener/provider-dispatch.ts +++ b/src/shared/agent-hook-listener/provider-dispatch.ts @@ -22,6 +22,7 @@ import { normalizeCopilotEvent } from './providers/copilot-events' import { normalizeHermesEvent } from './providers/hermes-events' import { normalizeDevinEvent } from './providers/devin-events' import { normalizeKimiEvent } from './providers/kimi-events' +import { normalizeZcodeEvent } from './providers/zcode-events' export type ProviderDispatchResult = { payload: ParsedAgentStatusPayload | null @@ -149,6 +150,9 @@ export function normalizeProviderEvent(input: { case 'kimi': payload = normalizeKimiEvent(state, eventName, promptText, paneKey, hookPayload) break + case 'zcode': + payload = normalizeZcodeEvent(state, eventName, promptText, paneKey, hookPayload) + break } return { payload, resolvedPromptText, promptInteractionKey, hasTranscriptPromptEvidence } diff --git a/src/shared/agent-hook-listener/provider-event-routing.ts b/src/shared/agent-hook-listener/provider-event-routing.ts index 39fa150f34be..6c6a46aafb79 100644 --- a/src/shared/agent-hook-listener/provider-event-routing.ts +++ b/src/shared/agent-hook-listener/provider-event-routing.ts @@ -30,7 +30,8 @@ export function isNewTurnEvent(source: AgentHookSource, eventName: unknown): boo // tool/prompt caches left by the pane's previous session. return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' case 'kimi': - // Why: Kimi Code emits Claude-compatible hook events, so UserPromptSubmit is its new-turn boundary too. + case 'zcode': + // Why: Kimi Code and ZCode emit Claude-compatible hook events, so UserPromptSubmit is their new-turn boundary too. return eventName === 'UserPromptSubmit' case 'codex': return eventName === 'SessionStart' || eventName === 'UserPromptSubmit' @@ -128,9 +129,10 @@ export function extractToolFields( // Why: exhaustive switch so a new AgentHookSource fails typecheck here instead of silently routing through OpenCode's extractor. switch (source) { case 'claude': - // Why: Kimi Code uses Claude's tool_name/tool_input payload fields verbatim. + // Why: Kimi Code and ZCode use Claude's tool_name/tool_input payload fields verbatim. // falls through case 'kimi': + case 'zcode': return extractClaudeToolFields(eventName, hookPayload) case 'codex': return extractCodexToolFields(eventName, hookPayload) diff --git a/src/shared/agent-hook-listener/providers/kimi-events.ts b/src/shared/agent-hook-listener/providers/kimi-events.ts index c35bfef17d04..9a4f8d9eeb69 100644 --- a/src/shared/agent-hook-listener/providers/kimi-events.ts +++ b/src/shared/agent-hook-listener/providers/kimi-events.ts @@ -23,6 +23,24 @@ export function normalizeKimiEvent( promptText: string, paneKey: string, hookPayload: Record +): ParsedAgentStatusPayload | null { + return normalizeClaudeCompatibleAgentEvent( + state, + 'kimi', + eventName, + promptText, + paneKey, + hookPayload + ) +} + +export function normalizeClaudeCompatibleAgentEvent( + state: HookListenerState, + source: 'kimi' | 'zcode', + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record ): ParsedAgentStatusPayload | null { if (shouldIgnoreCompactContinuationUserPromptSubmit(eventName, promptText)) { return null @@ -52,8 +70,8 @@ export function normalizeKimiEvent( const snapshot = resolveToolState( state, paneKey, - extractToolFields('kimi', eventName, hookPayload), - { resetOnNewTurn: isNewTurnEvent('kimi', eventName) } + extractToolFields(source, eventName, hookPayload), + { resetOnNewTurn: isNewTurnEvent(source, eventName) } ) const interrupted = @@ -62,9 +80,9 @@ export function normalizeKimiEvent( return normalizeAgentStatusPayload({ state: stateName, prompt: resolvePrompt(state, paneKey, promptText, { - resetOnNewTurn: isNewTurnEvent('kimi', eventName) + resetOnNewTurn: isNewTurnEvent(source, eventName) }), - agentType: 'kimi', + agentType: source, toolName: snapshot.toolName, toolInput: snapshot.toolInput, lastAssistantMessage: snapshot.lastAssistantMessage, diff --git a/src/shared/agent-hook-listener/providers/zcode-events.ts b/src/shared/agent-hook-listener/providers/zcode-events.ts new file mode 100644 index 000000000000..ffe5ac875c04 --- /dev/null +++ b/src/shared/agent-hook-listener/providers/zcode-events.ts @@ -0,0 +1,21 @@ +import type { ParsedAgentStatusPayload } from '../../agent-status-types' +import type { HookListenerState } from '../listener-state' +import { normalizeClaudeCompatibleAgentEvent } from './kimi-events' + +// Why: ZCode emits Claude-compatible payloads/event names; normalize but attribute to ZCode so the sidebar shows ZCode's icon/label, not Claude's. +export function normalizeZcodeEvent( + state: HookListenerState, + eventName: unknown, + promptText: string, + paneKey: string, + hookPayload: Record +): ParsedAgentStatusPayload | null { + return normalizeClaudeCompatibleAgentEvent( + state, + 'zcode', + eventName, + promptText, + paneKey, + hookPayload + ) +} diff --git a/src/shared/agent-hook-listener/source-routing.ts b/src/shared/agent-hook-listener/source-routing.ts index b90da2462546..f4f9912f4a20 100644 --- a/src/shared/agent-hook-listener/source-routing.ts +++ b/src/shared/agent-hook-listener/source-routing.ts @@ -21,7 +21,8 @@ export const HOOK_SOURCE_BY_PATHNAME: Readonly> '/hook/copilot': 'copilot', '/hook/hermes': 'hermes', '/hook/devin': 'devin', - '/hook/kimi': 'kimi' + '/hook/kimi': 'kimi', + '/hook/zcode': 'zcode' }) export function resolveHookSource(pathname: string): AgentHookSource | null { diff --git a/src/shared/agent-hook-relay.ts b/src/shared/agent-hook-relay.ts index 6abdcb56aa05..fe031513c0f8 100644 --- a/src/shared/agent-hook-relay.ts +++ b/src/shared/agent-hook-relay.ts @@ -53,7 +53,8 @@ const AGENT_HOOK_SOURCES = [ 'copilot', 'hermes', 'devin', - 'kimi' + 'kimi', + 'zcode' ] as const export type AgentHookSource = (typeof AGENT_HOOK_SOURCES)[number] diff --git a/src/shared/agent-hook-types.ts b/src/shared/agent-hook-types.ts index 248638c5079f..0f9052c291f8 100644 --- a/src/shared/agent-hook-types.ts +++ b/src/shared/agent-hook-types.ts @@ -17,7 +17,8 @@ export const AGENT_HOOK_TARGETS = [ 'copilot', 'hermes', 'devin', - 'kimi' + 'kimi', + 'zcode' ] as const export type AgentHookTarget = (typeof AGENT_HOOK_TARGETS)[number] diff --git a/src/shared/agent-kind.ts b/src/shared/agent-kind.ts index 8ca50cbcea64..f28845a0573d 100644 --- a/src/shared/agent-kind.ts +++ b/src/shared/agent-kind.ts @@ -50,7 +50,8 @@ const TUI_AGENT_KIND_BY_AGENT = { grok: 'grok', devin: 'devin', ante: 'ante', - trae: 'trae' + trae: 'trae', + zcode: 'zcode' } satisfies Record // Why: `satisfies Record` makes the lookup exhaustive at compile diff --git a/src/shared/agent-name-token-match.ts b/src/shared/agent-name-token-match.ts index ea024babd324..4d089c32bb9a 100644 --- a/src/shared/agent-name-token-match.ts +++ b/src/shared/agent-name-token-match.ts @@ -27,7 +27,9 @@ export const AGENT_NAMES = [ 'openclaw', 'aider', 'grok', - 'devin' + 'devin', + 'zcode', + 'zcode-cli' ] // Why: Windows agent titles can surface launcher process names such as diff --git a/src/shared/agent-node-entrypoint-identities.ts b/src/shared/agent-node-entrypoint-identities.ts index e71118312d01..fd827dc5613b 100644 --- a/src/shared/agent-node-entrypoint-identities.ts +++ b/src/shared/agent-node-entrypoint-identities.ts @@ -26,5 +26,12 @@ export const EXACT_NODE_ENTRYPOINT_IDENTITIES: readonly { pattern: /(?:^|\/)node_modules\/prime-agent\/dist\/bundle\/cli\.js$/, agent: 'prime-agent', processName: 'prime-agent' + }, + // Why: zcode-app-cli's stable package entrypoint remains identifiable while + // its release version and native child process layout evolve independently. + { + pattern: /(?:^|\/)node_modules\/zcode-app-cli\/bin\/zcode\.js$/, + agent: 'zcode', + processName: 'zcode-cli' } ] diff --git a/src/shared/agent-process-recognition.test.ts b/src/shared/agent-process-recognition.test.ts index 7a9e961e292c..36206687c91a 100644 --- a/src/shared/agent-process-recognition.test.ts +++ b/src/shared/agent-process-recognition.test.ts @@ -284,6 +284,11 @@ describe('agent process recognition', () => { String.raw`node C:\Users\dev\AppData\Roaming\npm\node_modules\@google\gemini-cli\bundle\gemini.mjs` ) ).toEqual({ agent: 'gemini', processName: 'gemini' }) + expect( + recognizeAgentProcessFromCommandLine( + 'node /Users/dev/.nvm/versions/node/lib/node_modules/zcode-app-cli/bin/zcode.js --mode yolo' + ) + ).toEqual({ agent: 'zcode', processName: 'zcode-cli' }) }) it.each(['earendil-works', 'mariozechner'])('recognizes the @%s Pi npm entrypoint', (scope) => { diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 56aa3952e002..554ace41d828 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -196,7 +196,8 @@ export function extractAgentProviderSession( case 'droid': // Why: Kimi Code posts a Claude-shaped `session_id` (e.g. session_). // falls through - case 'kimi': { + case 'kimi': + case 'zcode': { const id = readSessionId(payload, ['session_id']) return id ? { key: 'session_id', id } : null } diff --git a/src/shared/agent-status-agent-type.ts b/src/shared/agent-status-agent-type.ts new file mode 100644 index 000000000000..ea8ccf5a99ad --- /dev/null +++ b/src/shared/agent-status-agent-type.ts @@ -0,0 +1,27 @@ +// Agent types are extensible; these names only improve narrowing for built-in integrations. +export type WellKnownAgentType = + | 'claude' + | 'openclaude' + | 'codex' + | 'gemini' + | 'antigravity' + | 'amp' + | 'opencode' + | 'mimo-code' + | 'cursor' + | 'copilot' + | 'aider' + | 'pi' + | 'omp' + | 'prime-agent' + | 'droid' + | 'command-code' + | 'grok' + | 'hermes' + | 'devin' + | 'ante' + | 'trae' + | 'zcode' + | 'unknown' + +export type AgentType = WellKnownAgentType | (string & {}) diff --git a/src/shared/agent-type-label.ts b/src/shared/agent-type-label.ts index 51f1695f5080..e97de62a4ee6 100644 --- a/src/shared/agent-type-label.ts +++ b/src/shared/agent-type-label.ts @@ -25,7 +25,8 @@ const WELL_KNOWN_LABELS: Record = { devin: 'Devin', ante: 'Ante', trae: 'Trae', - kimi: 'Kimi' + kimi: 'Kimi', + zcode: 'ZCode' } export function formatAgentTypeLabel(agentType: AgentType | null | undefined): string { diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index 5f6a3537fb03..468d008a7a5e 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -13,6 +13,40 @@ const GROK_ALT_SCREEN_LEAVE = '\x1b[?1049l\x1b[?25h' const GROK_COMPOSER_FRAME = '\x1b[38;2;80;80;88m│\x1b[38;2;200;200;200m❯ \x1b[0m' describe('createDraftPasteReadyScanner', () => { + describe('zcode-composer-prompt', () => { + it('ignores a shell cursor and waits for the focused ZCode composer', () => { + const scanner = createDraftPasteReadyScanner('zcode-composer-prompt') + expect(scanner.observe('\x1b[?2004h\x1b[?25h')).toEqual({ + ready: false, + armQuietTimer: false + }) + expect(scanner.observe('\x1b[?20')).toEqual({ ready: false, armQuietTimer: false }) + expect(scanner.observe('26h\x1b[?25l')).toEqual({ ready: false, armQuietTimer: false }) + expect(scanner.observe('\x1b[?2026l\x1b[?25h')).toEqual({ + ready: true, + armQuietTimer: false + }) + }) + + it('rejects cursor output until bracketed paste and a complete render frame finish', () => { + const withoutPaste = createDraftPasteReadyScanner('zcode-composer-prompt') + expect(withoutPaste.observe('\x1b[?2026h\x1b[?2026l\x1b[?25h')).toEqual({ + ready: false, + armQuietTimer: false + }) + + const insideFrame = createDraftPasteReadyScanner('zcode-composer-prompt') + expect(insideFrame.observe('\x1b[?2004h\x1b[?2026h\x1b[?25h')).toEqual({ + ready: false, + armQuietTimer: false + }) + expect(insideFrame.observe('\x1b[?2026l\x1b[?25h')).toEqual({ + ready: true, + armQuietTimer: false + }) + }) + }) + describe('render-cursor-after-bracketed-paste (opencode / mimo-code)', () => { it('is ready when show-cursor renders after bracketed paste in one chunk', () => { const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste') diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index 0b0e104e54a6..431cdef4361e 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -5,6 +5,10 @@ import type { DraftPasteReadySignal } from './tui-agent-config' // "input is ready" moment per agent instead of guessing from output silence. const DECSET_BRACKETED_PASTE = '\x1b[?2004h' const CODEX_COMPOSER_PROMPT = '›' +// ZCode's Ink renderer enters synchronized-output mode before painting its +// composer. Unlike banner text, this survives narrow, inactive, and first-frame +// terminal layouts. +const ZCODE_RENDER_FRAME = '\x1b[?2026h' // Why: opencode emits the DECTCEM show-cursor only once the composer row is // mounted and the text cursor is placed in it — a "composer ready" signal, // analogous to Codex's prompt glyph. It fires ~2s after bracketed paste is @@ -40,6 +44,12 @@ const DRAFT_PASTE_READY_SIGNALS: Record { + const markers = [DECSET_BRACKETED_PASTE, ZCODE_RENDER_FRAME, '\x1b[?2026l', DECTCEM_SHOW_CURSOR] + const window = zcodeCarry + data + let cursor = 0 + while (zcodeStage < markers.length) { + const markerIndex = window.indexOf(markers[zcodeStage]!, cursor) + if (markerIndex === -1) { + zcodeCarry = window.slice(Math.max(cursor, window.length - ANCHOR_CARRY_CHARS)) + return false + } + cursor = markerIndex + markers[zcodeStage]!.length + zcodeStage += 1 + } + return true + } + return { observe(data: string): DraftPasteReadyScanResult { + if (readySignal === 'zcode-composer-prompt') { + return { ready: scanZcodeComposerSequence(data), armQuietTimer: false } + } const combined = recent + data recent = combined.slice(-512) if (!sawQuietAnchor && quietAnchor !== null && combined.includes(quietAnchor)) { diff --git a/src/shared/local-agent-install-dir-detection.ts b/src/shared/local-agent-install-dir-detection.ts index 9b29a142f9ef..86edc44e8384 100644 --- a/src/shared/local-agent-install-dir-detection.ts +++ b/src/shared/local-agent-install-dir-detection.ts @@ -9,7 +9,15 @@ export function detectCommandsInInstallDirs(commands: readonly string[]): Set path.isAbsolute(resolvedCommands.get(command) ?? command)) + commands.filter((command) => { + // Absolute probes are checked directly by isCommandOnPath before this + // fallback runs. Treating the unresolved probe itself as a successful + // install-dir resolution would report every absolute alias as installed. + if (path.isAbsolute(command)) { + return false + } + return path.isAbsolute(resolvedCommands.get(command) ?? command) + }) ) } catch { return new Set() diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts index 7608f50c98cf..0db42f4a3954 100644 --- a/src/shared/native-chat-agent-support.test.ts +++ b/src/shared/native-chat-agent-support.test.ts @@ -12,10 +12,11 @@ describe('resolveNativeChatTranscriptAgent', () => { expect(resolveNativeChatTranscriptAgent('claude')).toBe('claude') }) - it('passes codex, grok and omp through and rejects everything else', () => { + it('passes supported transcript agents through and rejects everything else', () => { expect(resolveNativeChatTranscriptAgent('codex')).toBe('codex') expect(resolveNativeChatTranscriptAgent('grok')).toBe('grok') expect(resolveNativeChatTranscriptAgent('omp')).toBe('omp') + expect(resolveNativeChatTranscriptAgent('zcode')).toBe('zcode') expect(resolveNativeChatTranscriptAgent('cursor')).toBeNull() expect(resolveNativeChatTranscriptAgent(null)).toBeNull() expect(resolveNativeChatTranscriptAgent(undefined)).toBeNull() @@ -27,6 +28,7 @@ describe('isNativeChatSupportedAgent', () => { expect(isNativeChatSupportedAgent('claude')).toBe(true) expect(isNativeChatSupportedAgent('openclaude')).toBe(true) expect(isNativeChatSupportedAgent('omp')).toBe(true) + expect(isNativeChatSupportedAgent('zcode')).toBe(true) expect(isNativeChatSupportedAgent('cursor')).toBe(false) expect(isNativeChatSupportedAgent(null)).toBe(false) expect(isNativeChatSupportedAgent(undefined)).toBe(false) @@ -39,6 +41,7 @@ describe('nativeChatRequiresLocalTranscript', () => { // native chat has to find their file on a disk this process can read. expect(nativeChatRequiresLocalTranscript('grok')).toBe(true) expect(nativeChatRequiresLocalTranscript('omp')).toBe(true) + expect(nativeChatRequiresLocalTranscript('zcode')).toBe(true) expect(nativeChatRequiresLocalTranscript('claude')).toBe(false) expect(nativeChatRequiresLocalTranscript('openclaude')).toBe(false) expect(nativeChatRequiresLocalTranscript('codex')).toBe(false) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index 70fa92a92bf6..eb9ba07777a6 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -1,6 +1,6 @@ import type { TuiAgent } from './tui-agent' -export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp' +export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp' | 'zcode' /** Agents whose transcripts the native chat view can parse and render, in the * order the settings pane advertises them. */ @@ -9,7 +9,8 @@ export const NATIVE_CHAT_SUPPORTED_AGENT_LIST: readonly TuiAgent[] = [ 'openclaude', 'codex', 'grok', - 'omp' + 'omp', + 'zcode' ] export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set( @@ -24,7 +25,7 @@ export function isNativeChatSupportedAgent(agent: string | null | undefined): bo * does not establish owning-host reads, so OMP remains gated even with metadata. */ export function nativeChatRequiresLocalTranscript(agent: string | null | undefined): boolean { const transcriptAgent = resolveNativeChatTranscriptAgent(agent) - return transcriptAgent === 'grok' || transcriptAgent === 'omp' + return transcriptAgent === 'grok' || transcriptAgent === 'omp' || transcriptAgent === 'zcode' } /** True when the agent renders a digit-commit question selector that ignores @@ -45,7 +46,7 @@ export function resolveNativeChatTranscriptAgent( if (agent === 'claude' || agent === 'openclaude') { return 'claude' } - if (agent === 'codex' || agent === 'grok' || agent === 'omp') { + if (agent === 'codex' || agent === 'grok' || agent === 'omp' || agent === 'zcode') { return agent } return null diff --git a/src/shared/skills-cli-agent-keys.ts b/src/shared/skills-cli-agent-keys.ts index 7c49e0346cd0..763b082cfa6b 100644 --- a/src/shared/skills-cli-agent-keys.ts +++ b/src/shared/skills-cli-agent-keys.ts @@ -50,7 +50,9 @@ export const SKILLS_CLI_AGENT_KEY_BY_TUI_AGENT = { devin: 'devin', ante: null, // Why: Orca detects trae by `traecli`, an alias only TRAE CN ships. - trae: 'trae-cn' + trae: 'trae-cn', + // Why: the community skills CLI has no verified ZCode-specific target; ZCode reads the universal .agents root. + zcode: null } satisfies Record /** diff --git a/src/shared/telemetry-property-schemas.ts b/src/shared/telemetry-property-schemas.ts index 9d373903fdb6..2fd9e4d1767e 100644 --- a/src/shared/telemetry-property-schemas.ts +++ b/src/shared/telemetry-property-schemas.ts @@ -45,6 +45,7 @@ export const AGENT_KIND_VALUES = [ 'devin', 'ante', 'trae', + 'zcode', 'other' ] as const export const agentKindSchema = z.enum(AGENT_KIND_VALUES) diff --git a/src/shared/tui-agent-config-zcode.ts b/src/shared/tui-agent-config-zcode.ts new file mode 100644 index 000000000000..d1193e743348 --- /dev/null +++ b/src/shared/tui-agent-config-zcode.ts @@ -0,0 +1,28 @@ +import type { TuiAgentConfig } from './tui-agent-config' + +// The Desktop bundle exposes a prompt-only runtime. Keep it out of the +// interactive path unless the installed `zcode` identifies itself as the +// independently packaged TUI client. +const ZCODE_DARWIN_LAUNCH = [ + "/bin/sh -c 'prompt_only=0;", + 'for arg do [ "$arg" = "--prompt" ] && prompt_only=1; done;', + 'if command -v zcode >/dev/null 2>&1;', + 'then if zcode --version 2>&1 | grep -q "^zcode-app-cli "; then exec zcode "$@"; fi;', + 'if [ "$prompt_only" -eq 1 ]; then exec zcode "$@"; fi; fi;', + 'if [ "$prompt_only" -eq 1 ]', + '&& [ -x /Applications/ZCode.app/Contents/MacOS/ZCode ]', + '&& [ -f /Applications/ZCode.app/Contents/Resources/glm/zcode.cjs ];', + 'then ELECTRON_RUN_AS_NODE=1 exec /Applications/ZCode.app/Contents/MacOS/ZCode', + '/Applications/ZCode.app/Contents/Resources/glm/zcode.cjs "$@"; fi;', + 'printf "%s\\n" "ZCode Desktop supports prompted one-shot tasks only; install an interactive zcode-app-cli for an empty TUI session." >&2;', + "exit 64' --" +].join(' ') + +export const ZCODE_TUI_AGENT_CONFIG: TuiAgentConfig = { + detectCmd: 'zcode', + detectCmdAliases: ['/Applications/ZCode.app/Contents/MacOS/ZCode'], + launchCmd: 'zcode', + launchCmdByPlatform: { darwin: ZCODE_DARWIN_LAUNCH }, + expectedProcess: 'zcode-cli', + promptInjectionMode: 'flag-prompt' +} diff --git a/src/shared/tui-agent-config.ts b/src/shared/tui-agent-config.ts index 46d382e7075f..66e5c16f9ce1 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' @@ -12,6 +13,7 @@ export type AgentPromptInjectionMode = export type DraftPasteReadySignal = | 'render-quiet-after-bracketed-paste' | 'codex-composer-prompt' + | 'zcode-composer-prompt' | 'render-cursor-after-bracketed-paste' | 'grok-composer-prompt' @@ -298,7 +300,8 @@ const TUI_AGENT_CONFIG_SOURCE: Record = { detectCmd: 'devin', // Why: `devin -- ` auto-submits immediately (docs.devin.ai/cli), so start the REPL with no argv prompt. promptInjectionMode: 'stdin-after-start' - } + }, + zcode: ZCODE_TUI_AGENT_CONFIG } export const TUI_AGENT_CONFIG: Record = Object.fromEntries( diff --git a/src/shared/tui-agent-display-names.ts b/src/shared/tui-agent-display-names.ts index 4c2576bddc89..b3908069f437 100644 --- a/src/shared/tui-agent-display-names.ts +++ b/src/shared/tui-agent-display-names.ts @@ -42,7 +42,8 @@ export const TUI_AGENT_DISPLAY_NAMES: Record = { hermes: 'Hermes', openclaw: 'OpenClaw', copilot: 'GitHub Copilot', - grok: 'Grok' + grok: 'Grok', + zcode: 'ZCode' } /** Canonical agent id list derived from the exhaustive display-name record, diff --git a/src/shared/tui-agent-orchestration.ts b/src/shared/tui-agent-orchestration.ts new file mode 100644 index 000000000000..72cddae0be05 --- /dev/null +++ b/src/shared/tui-agent-orchestration.ts @@ -0,0 +1,5 @@ +import type { TuiAgent } from './tui-agent' + +export function requiresOrchestrationStartupPrompt(agent: TuiAgent): boolean { + return agent === 'zcode' +} diff --git a/src/shared/tui-agent-permissions.test.ts b/src/shared/tui-agent-permissions.test.ts index e1fa10e1993f..bdace5cb8de8 100644 --- a/src/shared/tui-agent-permissions.test.ts +++ b/src/shared/tui-agent-permissions.test.ts @@ -76,6 +76,19 @@ describe('tui agent permissions', () => { ).toBe('mixed') }) + it('resolves ZCode permission arguments through the shared profile', () => { + expect( + resolveTuiAgentPermissionMode({ + agent: 'zcode', + agentArgs: YOLO_TUI_AGENT_ARGS.zcode, + agentEnv: {} + }) + ).toBe('yolo') + expect(resolveTuiAgentPermissionMode({ agent: 'zcode', agentArgs: '', agentEnv: {} })).toBe( + 'manual' + ) + }) + it('resolves env-driven yolo launches', () => { expect( resolveTuiAgentPermissionMode({ diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index c0808b41a3d6..b55f490146cb 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -29,7 +29,8 @@ export const YOLO_TUI_AGENT_ARGS: Partial> = { devin: '--permission-mode bypass', ante: '--yolo', trae: '--yolo', - droid: '--auto high' + droid: '--auto high', + zcode: '--mode yolo' } export const YOLO_TUI_AGENT_ENV: Partial>> = { diff --git a/src/shared/tui-agent-selection.ts b/src/shared/tui-agent-selection.ts index 1de6301bd2fd..127fa69689f8 100644 --- a/src/shared/tui-agent-selection.ts +++ b/src/shared/tui-agent-selection.ts @@ -40,7 +40,8 @@ export const TUI_AGENT_AUTO_PICK_ORDER = [ 'rovo', 'hermes', 'devin', - 'openclaw' + 'openclaw', + 'zcode' ] as const satisfies readonly TuiAgent[] // Why: fresh installs should expose Claude Agent Teams in agent pickers; the diff --git a/src/shared/tui-agent-startup-zcode.test.ts b/src/shared/tui-agent-startup-zcode.test.ts new file mode 100644 index 000000000000..2bffdec92e62 --- /dev/null +++ b/src/shared/tui-agent-startup-zcode.test.ts @@ -0,0 +1,124 @@ +import { chmodSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFileSync, spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' +import { resolveTuiAgentLaunchArgs } from './tui-agent-launch-defaults' +import { buildAgentStartupPlan } from './tui-agent-startup' + +function writeExecutable(path: string, body: string): void { + writeFileSync(path, `#!/bin/sh\n${body}\n`) + chmodSync(path, 0o755) +} + +describe('ZCode startup plans', () => { + it('uses the supported headless prompt entry point', () => { + const plan = buildAgentStartupPlan({ + agent: 'zcode', + prompt: 'fix the tests', + cmdOverrides: {}, + agentArgs: resolveTuiAgentLaunchArgs('zcode', null), + platform: 'linux' + }) + expect(plan?.launchCommand).toBe("zcode '--mode' 'yolo' --prompt 'fix the tests'") + }) + + it('respects manual permission settings instead of forcing yolo mode', () => { + const plan = buildAgentStartupPlan({ + agent: 'zcode', + prompt: 'fix the tests', + cmdOverrides: {}, + agentArgs: '', + platform: 'linux' + }) + + expect(plan?.launchCommand).toBe("zcode --prompt 'fix the tests'") + }) + + it('preserves multiline prompts as one shell argument', () => { + const prompt = "Review this branch\nif (ready) { run it }\nDon't split this." + const plan = buildAgentStartupPlan({ + agent: 'zcode', + prompt, + cmdOverrides: {}, + agentArgs: resolveTuiAgentLaunchArgs('zcode', null), + platform: 'linux' + }) + + expect(plan?.launchCommand).toContain( + `--prompt 'Review this branch\nif (ready) { run it }\nDon'"'"'t split this.'` + ) + }) + + it('keeps the macOS Desktop fallback behind a prompt', () => { + const prompted = buildAgentStartupPlan({ + agent: 'zcode', + prompt: 'review this branch', + cmdOverrides: {}, + agentArgs: resolveTuiAgentLaunchArgs('zcode', null), + platform: 'darwin' + }) + const empty = buildAgentStartupPlan({ + agent: 'zcode', + prompt: '', + cmdOverrides: {}, + agentArgs: resolveTuiAgentLaunchArgs('zcode', null), + allowEmptyPromptLaunch: true, + platform: 'darwin' + }) + + expect(prompted?.launchCommand).toContain("--prompt 'review this branch'") + expect(prompted?.launchCommand).toContain('prompt_only=0') + expect(empty?.launchCommand).not.toContain(" --prompt '") + }) + + it('allows an interactive PATH client without a prompt', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-zcode-launch-')) + const bin = join(root, 'bin') + mkdirSync(bin) + const zcode = join(bin, 'zcode') + writeExecutable( + zcode, + 'if [ "$1" = "--version" ]; then echo "zcode-app-cli 3.7.6-12"; else printf "args:%s\\n" "$*"; fi' + ) + const plan = buildAgentStartupPlan({ + agent: 'zcode', + prompt: '', + cmdOverrides: {}, + agentArgs: resolveTuiAgentLaunchArgs('zcode', null), + allowEmptyPromptLaunch: true, + platform: 'darwin' + }) + + const output = execFileSync('/bin/sh', ['-c', plan?.launchCommand ?? ''], { + env: { ...process.env, PATH: `${bin}:/usr/bin:/bin` }, + encoding: 'utf8' + }) + expect(output).toContain('args:--mode yolo') + }) + + it('rejects an unprompted prompt-only PATH runtime', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-zcode-launch-')) + const bin = join(root, 'bin') + mkdirSync(bin) + writeExecutable( + join(bin, 'zcode'), + 'if [ "$1" = "--version" ]; then echo "zcode-runtime 0.16.3"; else exit 99; fi' + ) + const plan = buildAgentStartupPlan({ + agent: 'zcode', + prompt: '', + cmdOverrides: {}, + agentArgs: resolveTuiAgentLaunchArgs('zcode', null), + allowEmptyPromptLaunch: true, + platform: 'darwin' + }) + + const result = spawnSync('/bin/sh', ['-c', plan?.launchCommand ?? ''], { + env: { ...process.env, PATH: `${bin}:/usr/bin:/bin` }, + encoding: 'utf8' + }) + expect(result.status).toBe(64) + expect(result.stderr).toContain('prompted one-shot tasks only') + }) +}) diff --git a/src/shared/tui-agent.ts b/src/shared/tui-agent.ts index b6901aa8ef15..67044bc39aef 100644 --- a/src/shared/tui-agent.ts +++ b/src/shared/tui-agent.ts @@ -38,3 +38,4 @@ export type TuiAgent = | 'ante' // Ante (Antigma Labs) | 'trae' // Trae CLI | 'prime-agent' // Prime Agent (Prime Intellect) + | 'zcode' // ZCode (Z.ai / GLM)