@zooid/acp-client 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zooid/acp-client",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Zooid's ACP client: spawn agent subprocesses, drive them via Agent Client Protocol, route events and permission requests through callbacks.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
2
2
  import { EventEmitter } from 'node:events'
3
3
  import { Readable, Writable } from 'node:stream'
4
4
  import type { ChildProcess } from 'node:child_process'
5
+ import { RequestError } from '@agentclientprotocol/sdk'
5
6
  import { AcpClient } from './acp-client.js'
7
+ import type { TapEvent } from './turn-tracker.js'
6
8
 
7
9
  class FakeChild extends EventEmitter {
8
10
  stdout = new Readable({ read() {} })
@@ -34,3 +36,66 @@ describe('AcpClient onTap', () => {
34
36
  }).not.toThrow()
35
37
  })
36
38
  })
39
+
40
+ describe('AcpClient — error TapEvent emission', () => {
41
+ function makeClient(onTap: (e: TapEvent) => void) {
42
+ const child = new FakeChild() as unknown as ChildProcess
43
+ const rt = { spawn: vi.fn().mockReturnValue(child) }
44
+ const client = new AcpClient({
45
+ agent: { id: 'alice', command: 'foo', args: [] },
46
+ onEvent: () => {},
47
+ onApprovalRequest: async () => ({ decision: 'cancel' }),
48
+ runtime: rt,
49
+ onTap,
50
+ })
51
+ return client
52
+ }
53
+
54
+ it('emits {kind:"error",...} when prompt throws a RequestError', async () => {
55
+ const seen: TapEvent[] = []
56
+ const client = makeClient((e) => seen.push(e))
57
+
58
+ const fakeConn = {
59
+ newSession: vi.fn().mockResolvedValue({ sessionId: 'sess-1' }),
60
+ prompt: vi.fn().mockRejectedValue(
61
+ new RequestError(-32000, 'Authentication required', undefined),
62
+ ),
63
+ loadSession: vi.fn(),
64
+ }
65
+ ;(client as unknown as Record<string, unknown>).connection = fakeConn
66
+ ;(client as unknown as Record<string, unknown>).initialized = true
67
+
68
+ await expect(
69
+ client.prompt({ threadId: 'thread-1', content: [{ type: 'text', text: 'hi' }] }),
70
+ ).rejects.toThrow()
71
+
72
+ const errEvt = seen.find((e) => e.kind === 'error')
73
+ expect(errEvt).toBeDefined()
74
+ expect(errEvt!.code).toBe('auth_missing')
75
+ expect(errEvt!.acp_error).toMatchObject({ code: -32000, message: 'Authentication required' })
76
+ expect(errEvt!.sessionId).toBe('sess-1')
77
+ expect(errEvt!.turnId).not.toBeNull()
78
+ })
79
+
80
+ it('emits with sessionId=null when ensureSession throws (newSession failure)', async () => {
81
+ const seen: TapEvent[] = []
82
+ const client = makeClient((e) => seen.push(e))
83
+
84
+ const fakeConn = {
85
+ newSession: vi.fn().mockRejectedValue(new Error('ACP connection closed')),
86
+ prompt: vi.fn(),
87
+ loadSession: vi.fn(),
88
+ }
89
+ ;(client as unknown as Record<string, unknown>).connection = fakeConn
90
+ ;(client as unknown as Record<string, unknown>).initialized = true
91
+
92
+ await expect(
93
+ client.prompt({ threadId: 'thread-1', content: [{ type: 'text', text: 'hi' }] }),
94
+ ).rejects.toThrow()
95
+
96
+ const errEvt = seen.find((e) => e.kind === 'error')
97
+ expect(errEvt).toBeDefined()
98
+ expect(errEvt!.sessionId).toBeNull()
99
+ expect(errEvt!.code).toBe('container_exit')
100
+ })
101
+ })
package/src/acp-client.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  approvalDecisionToPermissionResponse,
17
17
  } from './event-mapping.js'
18
18
  import { TurnTracker, type TapEvent } from './turn-tracker.js'
19
+ import { classify } from './errors.js'
19
20
  import type {
20
21
  AgentConfig,
21
22
  AgentEvent,
@@ -37,6 +38,8 @@ export interface SpawnRuntime {
37
38
  cwd?: string
38
39
  /** Container image. Honoured by container runtimes; ignored by local spawners. */
39
40
  image?: string
41
+ /** Bind mounts. Honoured by container runtimes; ignored by local spawners. */
42
+ mounts?: Array<{ path: string; target: string; mode: 'ro' | 'rw' }>
40
43
  }): ChildProcess
41
44
  }
42
45
 
@@ -108,6 +111,7 @@ export class AcpClient {
108
111
  env: this.options.agent.env,
109
112
  cwd: this.options.agent.cwd,
110
113
  image: this.options.agent.image,
114
+ mounts: this.options.agent.mounts,
111
115
  })
112
116
  this.runtimeChild = child
113
117
  if (!child.stdout || !child.stdin) {
@@ -253,11 +257,13 @@ export class AcpClient {
253
257
  }
254
258
 
255
259
  async prompt(input: PromptInput): Promise<PromptResult> {
256
- const sessionId = await this.ensureSession(input.threadId, input.channelId)
257
- const promptText = stringifyPromptForLog(input.content)
258
- this.turns?.startTurn({ sessionId, promptText })
259
- debugLog(this.options.agent.id, 'prompt →', { sessionId, content: input.content })
260
+ let sessionId: string | null = null
261
+ let turnId: string | null = null
260
262
  try {
263
+ sessionId = await this.ensureSession(input.threadId, input.channelId)
264
+ const promptText = stringifyPromptForLog(input.content)
265
+ turnId = this.turns?.startTurn({ sessionId, promptText }) ?? null
266
+ debugLog(this.options.agent.id, 'prompt →', { sessionId, content: input.content })
261
267
  const result = await this.connection!.prompt({
262
268
  sessionId,
263
269
  prompt: input.content,
@@ -266,7 +272,19 @@ export class AcpClient {
266
272
  debugLog(this.options.agent.id, 'prompt ←', { sessionId, stopReason: result.stopReason })
267
273
  return { stopReason: result.stopReason }
268
274
  } catch (err) {
269
- this.turns?.endTurn({ sessionId, stopReason: 'error' })
275
+ const c = classify(err)
276
+ this.options.onTap?.({
277
+ kind: 'error',
278
+ agentId: this.options.agent.id,
279
+ sessionId,
280
+ turnId,
281
+ code: c.code,
282
+ message: err instanceof Error ? err.message : String(err),
283
+ detail: err instanceof Error && err.stack ? err.stack.slice(0, 2000) : undefined,
284
+ transient: c.transient,
285
+ acp_error: c.acp_error,
286
+ })
287
+ if (sessionId) this.turns?.endTurn({ sessionId, stopReason: 'error' })
270
288
  throw err
271
289
  }
272
290
  }
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { RequestError } from '@agentclientprotocol/sdk'
3
+ import { classify } from './errors.js'
4
+
5
+ describe('classify — RequestError', () => {
6
+ it('claude-agent-acp -32000 "Authentication required" → auth_missing', () => {
7
+ const err = new RequestError(-32000, 'Authentication required', undefined)
8
+ const r = classify(err)
9
+ expect(r.code).toBe('auth_missing')
10
+ expect(r.transient).toBe(false)
11
+ expect(r.acp_error).toEqual({
12
+ code: -32000,
13
+ message: 'Authentication required',
14
+ data: undefined,
15
+ })
16
+ })
17
+
18
+ it('-32002 (spec-compliant authRequired) → auth_missing', () => {
19
+ const err = RequestError.authRequired()
20
+ expect(classify(err).code).toBe('auth_missing')
21
+ })
22
+
23
+ it('invalid/expired/revoked credential → auth_invalid', () => {
24
+ expect(classify(new RequestError(-32000, 'API key invalid')).code).toBe('auth_invalid')
25
+ expect(classify(new RequestError(-32000, 'token expired')).code).toBe('auth_invalid')
26
+ expect(classify(new RequestError(-32000, 'credential revoked')).code).toBe('auth_invalid')
27
+ })
28
+
29
+ it('rate limit → model_rate_limit (transient)', () => {
30
+ const r = classify(new RequestError(-32000, 'rate limit exceeded'))
31
+ expect(r.code).toBe('model_rate_limit')
32
+ expect(r.transient).toBe(true)
33
+ })
34
+
35
+ it('5xx / overloaded / unknown model → model_unavailable (transient)', () => {
36
+ expect(classify(new RequestError(-32000, 'upstream 503')).code).toBe('model_unavailable')
37
+ expect(classify(new RequestError(-32000, 'service overloaded')).code).toBe('model_unavailable')
38
+ expect(classify(new RequestError(-32000, 'unknown model claude-5')).code).toBe('model_unavailable')
39
+ })
40
+
41
+ it('JSON-RPC standard codes → acp_protocol', () => {
42
+ for (const code of [-32700, -32600, -32601, -32602]) {
43
+ expect(classify(new RequestError(code, 'malformed')).code).toBe('acp_protocol')
44
+ }
45
+ })
46
+
47
+ it('-32603 (internal JSON-RPC error) → internal', () => {
48
+ expect(classify(new RequestError(-32603, 'internal')).code).toBe('internal')
49
+ })
50
+
51
+ it('preserves acp_error verbatim including data', () => {
52
+ const err = new RequestError(-32000, 'something', { trace: 'abc' })
53
+ expect(classify(err).acp_error).toEqual({
54
+ code: -32000,
55
+ message: 'something',
56
+ data: { trace: 'abc' },
57
+ })
58
+ })
59
+
60
+ it('unmatched -32000…-32099 message → internal (not auth-mis-fire)', () => {
61
+ expect(classify(new RequestError(-32000, 'gobbledygook')).code).toBe('internal')
62
+ })
63
+ })
64
+
65
+ describe('classify — out-of-band errors', () => {
66
+ it('mount-failed engine error → mount_failed (non-transient)', () => {
67
+ const err = new Error(
68
+ "docker: Error response from daemon: error while creating mount source path '/home/zooid/.codex': mkdir /home/zooid/.codex: permission denied",
69
+ )
70
+ const r = classify(err)
71
+ expect(r.code).toBe('mount_failed')
72
+ expect(r.transient).toBe(false)
73
+ expect(r.acp_error).toBeUndefined()
74
+ })
75
+
76
+ it('image pull failure → image_pull_failed (transient)', () => {
77
+ const err = new Error(
78
+ 'image prepull failed for ghcr.io/zooid-ai/agent-codex:latest:\nmanifest unknown',
79
+ )
80
+ expect(classify(err).code).toBe('image_pull_failed')
81
+ expect(classify(err).transient).toBe(true)
82
+ })
83
+
84
+ it('ACP stream closed → container_exit (transient)', () => {
85
+ const err = new Error('ACP connection closed')
86
+ expect(classify(err).code).toBe('container_exit')
87
+ expect(classify(err).transient).toBe(true)
88
+ })
89
+
90
+ it('unmatched error → internal with String(err) preserved', () => {
91
+ const r = classify(new Error('weird thing happened'))
92
+ expect(r.code).toBe('internal')
93
+ expect(r.transient).toBe(false)
94
+ })
95
+
96
+ it('non-Error thrown value → internal', () => {
97
+ expect(classify('a bare string').code).toBe('internal')
98
+ expect(classify(null).code).toBe('internal')
99
+ expect(classify(undefined).code).toBe('internal')
100
+ })
101
+ })
package/src/errors.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { RequestError } from '@agentclientprotocol/sdk'
2
+
3
+ export type ErrorCode =
4
+ | 'auth_missing'
5
+ | 'auth_invalid'
6
+ | 'model_rate_limit'
7
+ | 'model_unavailable'
8
+ | 'image_pull_failed'
9
+ | 'mount_failed'
10
+ | 'container_exit'
11
+ | 'acp_protocol'
12
+ | 'permission_denied'
13
+ | 'internal'
14
+
15
+ export interface Classified {
16
+ code: ErrorCode
17
+ transient: boolean
18
+ /** Verbatim ACP RequestError triple — forwarded into eco.zoon.error.acp_error. */
19
+ acp_error?: { code: number; message: string; data?: unknown }
20
+ }
21
+
22
+ const PROTOCOL_CODES = new Set([-32700, -32600, -32601, -32602])
23
+
24
+ export function classify(err: unknown): Classified {
25
+ if (err instanceof RequestError) {
26
+ const acp_error = { code: err.code, message: err.message, data: err.data }
27
+ if (PROTOCOL_CODES.has(err.code)) return { code: 'acp_protocol', transient: false, acp_error }
28
+ if (err.code === -32603) return { code: 'internal', transient: false, acp_error }
29
+ // -32002 (spec authRequired) → auth_missing
30
+ if (err.code === -32002) return { code: 'auth_missing', transient: false, acp_error }
31
+ // -32000…-32099 generic: classify by message
32
+ const m = err.message
33
+ if (/^auth(entication)? required$/i.test(m)) {
34
+ return { code: 'auth_missing', transient: false, acp_error }
35
+ }
36
+ if (/\b(token|api ?key|credential)\b/i.test(m) && /\b(invalid|expired|revoked)\b/i.test(m)) {
37
+ return { code: 'auth_invalid', transient: false, acp_error }
38
+ }
39
+ if (/\brate ?limit\b|\b429\b/i.test(m)) {
40
+ return { code: 'model_rate_limit', transient: true, acp_error }
41
+ }
42
+ if (/\b5\d\d\b|\boverloaded\b|\bunavailable\b|\bunknown model\b/i.test(m)) {
43
+ return { code: 'model_unavailable', transient: true, acp_error }
44
+ }
45
+ return { code: 'internal', transient: false, acp_error }
46
+ }
47
+
48
+ // Out-of-band errors (no RequestError → no acp_error).
49
+ if (err instanceof Error) {
50
+ const m = err.message
51
+ if (/error while creating mount source path|mkdir .* permission denied/i.test(m)) {
52
+ return { code: 'mount_failed', transient: false }
53
+ }
54
+ if (/^image prepull failed/i.test(m)) {
55
+ return { code: 'image_pull_failed', transient: true }
56
+ }
57
+ if (/ACP connection closed/i.test(m)) {
58
+ return { code: 'container_exit', transient: true }
59
+ }
60
+ }
61
+
62
+ return { code: 'internal', transient: false }
63
+ }
package/src/index.ts CHANGED
@@ -2,7 +2,12 @@ export { AcpClient } from './acp-client.js'
2
2
  export { AgentProcess } from './agent-process.js'
3
3
  export { SessionMap } from './session-map.js'
4
4
  export { PRESETS, resolvePreset, isPreset } from './presets.js'
5
- export type { PresetName, PresetSpec } from './presets.js'
5
+ export type {
6
+ PresetName,
7
+ PresetSpec,
8
+ PresetMount,
9
+ PresetMountContext,
10
+ } from './presets.js'
6
11
  export {
7
12
  acpUpdateToAgentEvent,
8
13
  approvalDecisionToPermissionResponse,
@@ -23,3 +28,5 @@ export type { SessionKey, SessionRecord } from './session-map.js'
23
28
  export type { AcpClientOptions } from './acp-client.js'
24
29
  export { TurnTracker } from './turn-tracker.js'
25
30
  export type { TapEvent, SessionUpdate, TurnTrackerOpts } from './turn-tracker.js'
31
+ export { classify } from './errors.js'
32
+ export type { ErrorCode, Classified } from './errors.js'
@@ -0,0 +1,82 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { PRESETS } from './presets.js'
3
+
4
+ const ctx = {
5
+ agentName: 'alice',
6
+ agentDataDir: '/data/agents/alice',
7
+ containerWorkdir: '/workspace',
8
+ daemonHome: '/home/zooid',
9
+ }
10
+
11
+ describe('preset mount declarations — home / data / config', () => {
12
+ it('claude declares a single `home` mount at ${daemonHome}/.claude', () => {
13
+ const mounts = PRESETS.claude.mounts!(ctx)
14
+ expect(mounts).toHaveLength(1)
15
+ expect(mounts[0]).toMatchObject({
16
+ id: 'home',
17
+ host: '/home/zooid/.claude',
18
+ target: '/root/.claude',
19
+ mode: 'rw',
20
+ create: false,
21
+ })
22
+ })
23
+
24
+ it('codex declares a single `home` mount at ${daemonHome}/.codex', () => {
25
+ const mounts = PRESETS.codex.mounts!(ctx)
26
+ expect(mounts).toHaveLength(1)
27
+ expect(mounts[0]).toMatchObject({
28
+ id: 'home',
29
+ host: '/home/zooid/.codex',
30
+ target: '/root/.codex',
31
+ mode: 'rw',
32
+ create: false,
33
+ })
34
+ })
35
+
36
+ it('opencode declares `data` + `config` from XDG dirs', () => {
37
+ const mounts = PRESETS.opencode.mounts!(ctx)
38
+ const byId = Object.fromEntries(mounts.map((m) => [m.id, m]))
39
+ expect(Object.keys(byId).sort()).toEqual(['config', 'data'])
40
+ expect(byId.data).toMatchObject({
41
+ host: '/home/zooid/.local/share/opencode',
42
+ target: '/root/.local/share/opencode',
43
+ mode: 'rw',
44
+ create: false,
45
+ })
46
+ expect(byId.config).toMatchObject({
47
+ host: '/home/zooid/.config/opencode',
48
+ target: '/root/.config/opencode',
49
+ mode: 'rw',
50
+ create: false,
51
+ })
52
+ })
53
+
54
+ it('preset mounts do not reference `ctx.agentDataDir` (per-agent isolation is opt-in via user mounts)', () => {
55
+ const claudeMounts = PRESETS.claude.mounts!(ctx)
56
+ const codexMounts = PRESETS.codex.mounts!(ctx)
57
+ const opencodeMounts = PRESETS.opencode.mounts!(ctx)
58
+ for (const m of [...claudeMounts, ...codexMounts, ...opencodeMounts]) {
59
+ expect(m.host).not.toContain('/data/agents/alice')
60
+ }
61
+ })
62
+
63
+ it('cline / kiro / gemini have no preset-declared mounts', () => {
64
+ expect(PRESETS.cline.mounts?.(ctx) ?? []).toEqual([])
65
+ expect(PRESETS.kiro.mounts?.(ctx) ?? []).toEqual([])
66
+ expect(PRESETS.gemini.mounts?.(ctx) ?? []).toEqual([])
67
+ })
68
+ })
69
+
70
+ describe('preset default images (unchanged from cycle 1)', () => {
71
+ it('claude, codex, opencode declare a default ghcr image', () => {
72
+ expect(PRESETS.claude.image).toBe('ghcr.io/zooid-ai/agent-claude-code:latest')
73
+ expect(PRESETS.codex.image).toBe('ghcr.io/zooid-ai/agent-codex:latest')
74
+ expect(PRESETS.opencode.image).toBe('ghcr.io/zooid-ai/agent-opencode:latest')
75
+ })
76
+
77
+ it('cline / kiro / gemini declare no default image', () => {
78
+ expect(PRESETS.cline.image).toBeUndefined()
79
+ expect(PRESETS.kiro.image).toBeUndefined()
80
+ expect(PRESETS.gemini.image).toBeUndefined()
81
+ })
82
+ })
@@ -3,20 +3,20 @@ import { PRESETS, resolvePreset, isPreset } from './presets.js'
3
3
 
4
4
  describe('PRESETS registry', () => {
5
5
  it('includes the ACP-native harnesses from SPEC.md', () => {
6
- expect(PRESETS.opencode).toEqual({ command: 'opencode', args: ['acp'] })
7
- expect(PRESETS.cline).toEqual({ command: 'cline', args: ['--acp'] })
8
- expect(PRESETS.kiro).toEqual({ command: 'kiro', args: ['--acp'] })
9
- expect(PRESETS.gemini).toEqual({ command: 'gemini', args: ['--acp'] })
6
+ expect(PRESETS.opencode).toMatchObject({ command: 'opencode', args: ['acp'] })
7
+ expect(PRESETS.cline).toMatchObject({ command: 'cline', args: ['--acp'] })
8
+ expect(PRESETS.kiro).toMatchObject({ command: 'kiro', args: ['--acp'] })
9
+ expect(PRESETS.gemini).toMatchObject({ command: 'gemini', args: ['--acp'] })
10
10
  })
11
11
 
12
12
  it('includes the vendored-shim harnesses', () => {
13
- expect(PRESETS.claude).toEqual({
13
+ expect(PRESETS.claude).toMatchObject({
14
14
  command: 'npx',
15
15
  args: ['-y', '@agentclientprotocol/claude-agent-acp'],
16
16
  })
17
- expect(PRESETS.codex).toEqual({
17
+ expect(PRESETS.codex).toMatchObject({
18
18
  command: 'npx',
19
- args: ['-y', '@zed-industries/codex-acp'],
19
+ args: ['-y', '@zed-industries/codex-acp', '-c', 'web_search="live"'],
20
20
  })
21
21
  })
22
22
  })
@@ -62,13 +62,15 @@ describe('resolvePreset', () => {
62
62
  ])
63
63
  })
64
64
 
65
- it('appends --model <id> to codex when opts.model is set', () => {
65
+ it('passes model to codex via `-c model="..."` (codex-acp uses TOML config overrides)', () => {
66
66
  const spec = resolvePreset('codex', { model: 'gpt-5.5' })
67
67
  expect(spec.args).toEqual([
68
68
  '-y',
69
69
  '@zed-industries/codex-acp',
70
- '--model',
71
- 'gpt-5.5',
70
+ '-c',
71
+ 'web_search="live"',
72
+ '-c',
73
+ 'model="gpt-5.5"',
72
74
  ])
73
75
  })
74
76
 
package/src/presets.ts CHANGED
@@ -7,28 +7,116 @@
7
7
  //
8
8
  // See epics/002-ZOD019-acp-runtime/SPEC.md §"Agent compatibility matrix".
9
9
 
10
+ export interface PresetMountContext {
11
+ agentName: string
12
+ /** Per-agent host state root (`<dataDir>/agents/<agentName>`). Kept on the
13
+ * context for forward compatibility — v1 default declarations don't use
14
+ * it, but presets that opt into per-agent isolation can. */
15
+ agentDataDir: string
16
+ /** Resolved container working directory, e.g. `/workspace`. */
17
+ containerWorkdir: string
18
+ /** Daemon user's `$HOME`. Source of `~/.<preset>` for the v1 `home` /
19
+ * `data` / `config` defaults. Threaded from `buildAcpRegistry` so tests
20
+ * can inject without mutating `process.env.HOME`. */
21
+ daemonHome: string
22
+ }
23
+
24
+ export interface PresetMount {
25
+ id: string
26
+ host: string
27
+ target: string
28
+ mode: 'ro' | 'rw'
29
+ create?: boolean
30
+ }
31
+
10
32
  export interface PresetSpec {
11
33
  command: string
12
34
  args: string[]
35
+ /**
36
+ * Default container image for this preset. Last fallback in the
37
+ * `buildAcpRegistry` image-resolution chain (agent > workforce > preset).
38
+ * Omit when no first-party image is published yet.
39
+ */
40
+ image?: string
41
+ /**
42
+ * Canonical-id mounts this preset wants set up. Called once per agent
43
+ * during registry construction with `{ agentName, agentDataDir, containerWorkdir }`.
44
+ */
45
+ mounts?: (ctx: PresetMountContext) => PresetMount[]
13
46
  }
14
47
 
48
+ type PresetInternal = PresetSpec
49
+
15
50
  const PRESETS_INTERNAL = {
16
- opencode: { command: 'opencode', args: ['acp'] },
51
+ opencode: {
52
+ command: 'opencode',
53
+ args: ['acp'],
54
+ image: 'ghcr.io/zooid-ai/agent-opencode:latest',
55
+ mounts: (ctx: PresetMountContext): PresetMount[] => [
56
+ {
57
+ id: 'data',
58
+ host: `${ctx.daemonHome}/.local/share/opencode`,
59
+ target: '/root/.local/share/opencode',
60
+ mode: 'rw',
61
+ create: false,
62
+ },
63
+ {
64
+ id: 'config',
65
+ host: `${ctx.daemonHome}/.config/opencode`,
66
+ target: '/root/.config/opencode',
67
+ mode: 'rw',
68
+ create: false,
69
+ },
70
+ ],
71
+ },
17
72
  cline: { command: 'cline', args: ['--acp'] },
18
73
  kiro: { command: 'kiro', args: ['--acp'] },
19
74
  gemini: { command: 'gemini', args: ['--acp'] },
20
- claude: { command: 'npx', args: ['-y', '@agentclientprotocol/claude-agent-acp'] },
21
- codex: { command: 'npx', args: ['-y', '@zed-industries/codex-acp'] },
22
- } as const satisfies Record<string, PresetSpec>
75
+ claude: {
76
+ command: 'npx',
77
+ args: ['-y', '@agentclientprotocol/claude-agent-acp'],
78
+ image: 'ghcr.io/zooid-ai/agent-claude-code:latest',
79
+ mounts: (ctx: PresetMountContext): PresetMount[] => [
80
+ {
81
+ id: 'home',
82
+ host: `${ctx.daemonHome}/.claude`,
83
+ target: '/root/.claude',
84
+ mode: 'rw',
85
+ create: false,
86
+ },
87
+ ],
88
+ },
89
+ codex: {
90
+ command: 'npx',
91
+ // web_search="live" forces live web fetches instead of codex's cached snippet
92
+ // index, which doesn't know about recently-launched sites (e.g. zooid.dev).
93
+ args: ['-y', '@zed-industries/codex-acp', '-c', 'web_search="live"'],
94
+ image: 'ghcr.io/zooid-ai/agent-codex:latest',
95
+ mounts: (ctx: PresetMountContext): PresetMount[] => [
96
+ {
97
+ id: 'home',
98
+ host: `${ctx.daemonHome}/.codex`,
99
+ target: '/root/.codex',
100
+ mode: 'rw',
101
+ create: false,
102
+ },
103
+ ],
104
+ },
105
+ } as const satisfies Record<string, PresetInternal>
23
106
 
24
107
  export type PresetName = keyof typeof PRESETS_INTERNAL
25
108
 
26
109
  export const PRESETS: Record<PresetName, PresetSpec> = Object.freeze(
27
110
  Object.fromEntries(
28
- (Object.keys(PRESETS_INTERNAL) as PresetName[]).map((k) => [
29
- k,
30
- { command: PRESETS_INTERNAL[k].command, args: [...PRESETS_INTERNAL[k].args] },
31
- ]),
111
+ (Object.keys(PRESETS_INTERNAL) as PresetName[]).map((k) => {
112
+ const e = PRESETS_INTERNAL[k]
113
+ const copy: PresetSpec = { command: e.command, args: [...e.args] }
114
+ if ('image' in e && e.image) copy.image = e.image
115
+ if ('mounts' in e && typeof e.mounts === 'function') {
116
+ copy.mounts = e.mounts as PresetSpec['mounts']
117
+ }
118
+ return [k, copy]
119
+ }),
32
120
  ),
33
121
  ) as Record<PresetName, PresetSpec>
34
122
 
@@ -43,9 +131,10 @@ export interface ResolvePresetOpts {
43
131
  }
44
132
 
45
133
  // null = preset has its own model channel (opencode reads opencode.json); undefined = not implemented yet.
46
- const MODEL_FLAG_PER_PRESET: Partial<Record<PresetName, string | null>> = {
47
- claude: '--model',
48
- codex: '--model',
134
+ // codex-acp doesn't accept --model; it takes config overrides via `-c key=value` (TOML).
135
+ const MODEL_ARGS_PER_PRESET: Partial<Record<PresetName, ((model: string) => string[]) | null>> = {
136
+ claude: (m) => ['--model', m],
137
+ codex: (m) => ['-c', `model="${m.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`],
49
138
  opencode: null,
50
139
  }
51
140
 
@@ -57,8 +146,8 @@ export function resolvePreset(name: string, opts: ResolvePresetOpts = {}): Prese
57
146
  const entry = PRESETS_INTERNAL[name]
58
147
  const args: string[] = [...entry.args]
59
148
  if (opts.model !== undefined) {
60
- const flag = MODEL_FLAG_PER_PRESET[name]
61
- if (flag) args.push(flag, opts.model)
149
+ const builder = MODEL_ARGS_PER_PRESET[name]
150
+ if (builder) args.push(...builder(opts.model))
62
151
  }
63
152
  return { command: entry.command, args }
64
153
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
  import { TurnTracker, type TapEvent } from './turn-tracker.js'
3
+ import type { ErrorCode } from './errors.js'
3
4
 
4
5
  describe('TurnTracker', () => {
5
6
  it('emits turn_started → session_update* → turn_completed in order', () => {
@@ -68,3 +69,33 @@ describe('TurnTracker', () => {
68
69
  expect(onTap).not.toHaveBeenCalled()
69
70
  })
70
71
  })
72
+
73
+ describe('TapEvent error variant (ZOD055)', () => {
74
+ it('is a discriminated union member with the expected fields', () => {
75
+ const e: TapEvent = {
76
+ kind: 'error',
77
+ agentId: 'alice',
78
+ sessionId: 'sess-1',
79
+ turnId: 'turn-1',
80
+ code: 'auth_missing' as ErrorCode,
81
+ message: 'Authentication required',
82
+ detail: 'classified from RequestError',
83
+ transient: false,
84
+ acp_error: { code: -32000, message: 'Authentication required', data: undefined },
85
+ }
86
+ expect(e.kind).toBe('error')
87
+ })
88
+
89
+ it('sessionId and turnId may be null (failure preceded session/new)', () => {
90
+ const e: TapEvent = {
91
+ kind: 'error',
92
+ agentId: 'alice',
93
+ sessionId: null,
94
+ turnId: null,
95
+ code: 'container_exit' as ErrorCode,
96
+ message: 'Agent container exited',
97
+ transient: true,
98
+ }
99
+ expect(e.sessionId).toBeNull()
100
+ })
101
+ })
@@ -1,4 +1,5 @@
1
1
  import type { SessionNotification } from '@agentclientprotocol/sdk'
2
+ import type { ErrorCode } from './errors.js'
2
3
 
3
4
  export type SessionUpdate = SessionNotification['update']
4
5
 
@@ -24,6 +25,17 @@ export type TapEvent =
24
25
  turnId: string
25
26
  stopReason: string
26
27
  }
28
+ | {
29
+ kind: 'error'
30
+ agentId: string
31
+ sessionId: string | null
32
+ turnId: string | null
33
+ code: ErrorCode
34
+ message: string
35
+ detail?: string
36
+ transient: boolean
37
+ acp_error?: { code: number; message: string; data?: unknown }
38
+ }
27
39
 
28
40
  export interface TurnTrackerOpts {
29
41
  agentId: string
package/src/types.ts CHANGED
@@ -8,6 +8,17 @@ import type {
8
8
  } from '@agentclientprotocol/sdk'
9
9
  import type { PresetName } from './presets.js'
10
10
 
11
+ /**
12
+ * Structural copy of `@zooid/core`'s `AcpMount` shape. Kept here to avoid a
13
+ * back-edge to core; both files describe the same `{ path, target, mode }`
14
+ * tuple the docker runtime consumes.
15
+ */
16
+ export interface AcpMountSpec {
17
+ path: string
18
+ target: string
19
+ mode: 'ro' | 'rw'
20
+ }
21
+
11
22
  export interface AgentConfig {
12
23
  id: string
13
24
  /** Short-hand for a known ACP harness. Resolves to command/args via the preset registry. */
@@ -22,6 +33,8 @@ export interface AgentConfig {
22
33
  cwd?: string
23
34
  /** Container image. Forwarded to the spawn runtime; ignored by host-spawn paths. */
24
35
  image?: string
36
+ /** Bind mounts. Forwarded to the spawn runtime; ignored by host-spawn paths. */
37
+ mounts?: AcpMountSpec[]
25
38
  }
26
39
 
27
40
  export interface PromptInput {