diff --git a/README.md b/README.md
index 04f5e309da38..8d4336244cde 100644
--- a/README.md
+++ b/README.md
@@ -198,6 +198,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
Droid
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
Qwen Code
diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json
index 43eac7cb024d..41fc652f7600 100644
--- a/config/tsconfig.cli.json
+++ b/config/tsconfig.cli.json
@@ -132,6 +132,8 @@
"../src/main/kimi/hook-service.ts",
"../src/main/kimi/kimi-hook-config-toml.ts",
"../src/main/openclaude/hook-service.ts",
+ "../src/main/zcode/hook-service.ts",
+ "../src/main/zcode/zcode-hook-config.ts",
"../src/main/rolling-file-backup.ts",
"../src/main/startup/hydrate-shell-path.ts",
"../src/main/startup/windows-shell-path-ownership.ts",
diff --git a/docs/readme/README.es.md b/docs/readme/README.es.md
index bb5f15f14e0f..92bddcee51df 100644
--- a/docs/readme/README.es.md
+++ b/docs/readme/README.es.md
@@ -196,6 +196,7 @@ Funciona con **cualquier agente CLI** — si corre en una terminal, corre en Orc
Droid
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
Qwen Code
diff --git a/docs/readme/README.fr.md b/docs/readme/README.fr.md
index 549f817b9fe2..bdb05c908135 100644
--- a/docs/readme/README.fr.md
+++ b/docs/readme/README.fr.md
@@ -202,6 +202,7 @@ Fonctionne avec **n'importe quel agent CLI** — s'il tourne dans un terminal, i
Droid
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
Qwen Code
diff --git a/docs/readme/README.ja.md b/docs/readme/README.ja.md
index 58330fe3f437..9e658dd5776c 100644
--- a/docs/readme/README.ja.md
+++ b/docs/readme/README.ja.md
@@ -196,6 +196,7 @@ PR、Issue、プロジェクトボードをアプリ内で閲覧 — 任意の
Droid
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
Qwen Code
diff --git a/docs/readme/README.ko.md b/docs/readme/README.ko.md
index 6e6b174e78fe..ba14d461a304 100644
--- a/docs/readme/README.ko.md
+++ b/docs/readme/README.ko.md
@@ -198,6 +198,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
Droid
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
Qwen Code
diff --git a/docs/readme/README.pt.md b/docs/readme/README.pt.md
index 8fbc63e1a039..7eb06f3ec77f 100644
--- a/docs/readme/README.pt.md
+++ b/docs/readme/README.pt.md
@@ -198,6 +198,7 @@ Funciona com **qualquer agente CLI** — se roda em um terminal, roda no Orca.
Droid
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
Qwen Code
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 565b76ff5d5d..8f7756a582b9 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -196,6 +196,7 @@ VS Code 的编辑器,处处自动保存 — 把文件或图片直接拖入智
Droid
Kilocode
Kimi
+
ZCode
Kiro
Mistral Vibe
Qwen Code
diff --git a/mobile/src/tasks/mobile-tui-agents.ts b/mobile/src/tasks/mobile-tui-agents.ts
index 46b7ebcb0d38..b13193c04242 100644
--- a/mobile/src/tasks/mobile-tui-agents.ts
+++ b/mobile/src/tasks/mobile-tui-agents.ts
@@ -44,7 +44,8 @@ export const MOBILE_TUI_AGENT_FAVICON_DOMAINS: Partial>
rovo: 'atlassian.com',
hermes: 'nousresearch.com',
devin: 'devin.ai',
- openclaw: 'openclaw.ai'
+ openclaw: 'openclaw.ai',
+ zcode: 'z.ai'
}
export const isMobileTuiAgent: (value: unknown) => value is TuiAgent = isTuiAgent
diff --git a/src/main/agent-hooks/managed-agent-hook-registry.ts b/src/main/agent-hooks/managed-agent-hook-registry.ts
index 49fdcadda421..008a3b29b6d6 100644
--- a/src/main/agent-hooks/managed-agent-hook-registry.ts
+++ b/src/main/agent-hooks/managed-agent-hook-registry.ts
@@ -14,6 +14,7 @@ import { grokHookService } from '../grok/hook-service'
import { hermesHookService } from '../hermes/hook-service'
import { kimiHookService } from '../kimi/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
+import { zcodeHookService } from '../zcode/hook-service'
// Why (#16441): Codex's installer awaits a codex app-server trust-grant session
// instead of blocking the main thread on spawnSync. Widening the tuple keeps the
@@ -50,7 +51,8 @@ export const MANAGED_AGENT_HOOK_INSTALLERS: readonly ManagedAgentHookInstaller[]
['copilot', () => copilotHookService.install()],
['hermes', () => hermesHookService.install()],
['devin', () => devinHookService.install()],
- ['kimi', () => kimiHookService.install()]
+ ['kimi', () => kimiHookService.install()],
+ ['zcode', () => zcodeHookService.install()]
]
// Why: covers the shared launcher/statusline scripts under ~/.orca/agent-hooks — the files a
@@ -71,7 +73,8 @@ export const MANAGED_AGENT_HOOK_SCRIPT_REFRESHERS: readonly ManagedAgentHookScri
['grok', () => grokHookService.refreshManagedScripts()],
['copilot', () => copilotHookService.refreshManagedScripts()],
['devin', () => devinHookService.refreshManagedScripts()],
- ['kimi', () => kimiHookService.refreshManagedScripts()]
+ ['kimi', () => kimiHookService.refreshManagedScripts()],
+ ['zcode', () => zcodeHookService.refreshManagedScripts()]
]
export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
@@ -88,7 +91,8 @@ export const MANAGED_AGENT_HOOK_REMOVERS: readonly ManagedAgentHookRemover[] = [
['copilot', () => copilotHookService.remove()],
['hermes', () => hermesHookService.remove()],
['devin', () => devinHookService.remove()],
- ['kimi', () => kimiHookService.remove()]
+ ['kimi', () => kimiHookService.remove()],
+ ['zcode', () => zcodeHookService.remove()]
]
export const MANAGED_AGENT_HOOK_ASYNC_REMOVERS: readonly ManagedAgentHookAsyncRemover[] = [
@@ -109,5 +113,6 @@ export const MANAGED_AGENT_HOOK_STATUS_READERS: readonly ManagedAgentHookStatusR
['copilot', () => copilotHookService.getStatus()],
['hermes', () => hermesHookService.getStatus()],
['devin', () => devinHookService.getStatus()],
- ['kimi', () => kimiHookService.getStatus()]
+ ['kimi', () => kimiHookService.getStatus()],
+ ['zcode', () => zcodeHookService.getStatus()]
]
diff --git a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts
index 3213ee0210e2..e3929de96df5 100644
--- a/src/main/agent-hooks/managed-hook-local-filesystem.test.ts
+++ b/src/main/agent-hooks/managed-hook-local-filesystem.test.ts
@@ -45,9 +45,9 @@ describe('managed-hook local filesystem', () => {
const cold = await installRemoteManagedAgentHooks(filesystem, home, options)
const warm = await installRemoteManagedAgentHooks(filesystem, home, options)
- expect(cold).toHaveLength(14)
+ expect(cold).toHaveLength(15)
expect(cold.filter((result) => result.state === 'error')).toEqual([])
- expect(warm).toHaveLength(14)
+ expect(warm).toHaveLength(15)
expect(warm.filter((result) => result.state === 'error')).toEqual([])
const files = await listFiles(home)
expect(files.filter((path) => path.endsWith('.tmp'))).toEqual([])
@@ -71,7 +71,7 @@ describe('managed-hook local filesystem', () => {
agents: REMOTE_MANAGED_HOOK_INSTALLER_AGENTS
})
- expect(results).toHaveLength(14)
+ expect(results).toHaveLength(15)
expect(results.find((result) => result.agent === 'claude')?.state).toBe('error')
expect(results.find((result) => result.agent === 'openclaude')?.state).toBe('installed')
expect(results.find((result) => result.agent === 'kimi')?.state).toBe('installed')
diff --git a/src/main/agent-hooks/remote-hook-service-installers.test.ts b/src/main/agent-hooks/remote-hook-service-installers.test.ts
index 2e4cbb06496f..ece763a0af84 100644
--- a/src/main/agent-hooks/remote-hook-service-installers.test.ts
+++ b/src/main/agent-hooks/remote-hook-service-installers.test.ts
@@ -22,6 +22,7 @@ import { CopilotHookService, copilotHookService } from '../copilot/hook-service'
import { HermesHookService, hermesHookService } from '../hermes/hook-service'
import { DevinHookService, devinHookService } from '../devin/hook-service'
import { KimiHookService, kimiHookService } from '../kimi/hook-service'
+import { ZcodeHookService, zcodeHookService } from '../zcode/hook-service'
import { openClaudeHookService } from '../openclaude/hook-service'
import { MANAGED_AGENT_HOOK_INSTALLERS } from './managed-agent-hook-controls'
import {
@@ -189,6 +190,10 @@ describe('remote hook service installers', () => {
{
path: '/home/dev/.orca/agent-hooks/droid-hook.sh',
install: (sftp: SFTPWrapper) => new DroidHookService().installRemote(sftp, '/home/dev')
+ },
+ {
+ path: '/home/dev/.orca/agent-hooks/zcode-hook.sh',
+ install: (sftp: SFTPWrapper) => new ZcodeHookService().installRemote(sftp, '/home/dev')
}
]
@@ -709,7 +714,8 @@ describe('remote hook service installers', () => {
['copilot', copilotHookService],
['hermes', hermesHookService],
['devin', devinHookService],
- ['kimi', kimiHookService]
+ ['kimi', kimiHookService],
+ ['zcode', zcodeHookService]
])
// Guard against a service silently missing from the map above as new agents land.
diff --git a/src/main/agent-hooks/remote-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)