zooid 0.6.1 → 0.7.1

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.
Files changed (81) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +36 -444
  3. package/dist/bin.js +1891 -0
  4. package/dist/bin.js.map +1 -0
  5. package/dist/chunk-SUPTPSN3.js +31434 -0
  6. package/dist/chunk-SUPTPSN3.js.map +1 -0
  7. package/dist/index.d.ts +64 -0
  8. package/dist/index.js +4 -3790
  9. package/dist/index.js.map +1 -0
  10. package/dist/web/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  11. package/dist/web/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  12. package/dist/web/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  13. package/dist/web/assets/index-C2MAIew3.css +1 -0
  14. package/dist/web/assets/index-CqfWXqvO.js +118066 -0
  15. package/dist/web/assets/index-Cz5fEZBc.js +295 -0
  16. package/dist/web/assets/reaction-picker-emoji-cME0vOsX.js +716 -0
  17. package/dist/web/favicon.svg +1 -0
  18. package/dist/web/index.html +14 -0
  19. package/package.json +43 -22
  20. package/src/bin.ts +115 -0
  21. package/src/bootstrap/admin.test.ts +69 -0
  22. package/src/bootstrap/admin.ts +37 -0
  23. package/src/bootstrap/configs.test.ts +24 -0
  24. package/src/bootstrap/configs.ts +64 -0
  25. package/src/bootstrap/data-layout.test.ts +25 -0
  26. package/src/bootstrap/data-layout.ts +20 -0
  27. package/src/bootstrap/derive.test.ts +74 -0
  28. package/src/bootstrap/derive.ts +40 -0
  29. package/src/bootstrap/paths.test.ts +22 -0
  30. package/src/bootstrap/paths.ts +27 -0
  31. package/src/bootstrap/tokens.test.ts +44 -0
  32. package/src/bootstrap/tokens.ts +43 -0
  33. package/src/build-registry.context.test.ts +61 -0
  34. package/src/build-registry.test.ts +59 -0
  35. package/src/build-registry.ts +343 -0
  36. package/src/build-registry.zod043.test.ts +64 -0
  37. package/src/build-registry.zod044.test.ts +261 -0
  38. package/src/commands/dev-cascade.test.ts +38 -0
  39. package/src/commands/dev-cascade.ts +35 -0
  40. package/src/commands/dev-data-layout.test.ts +55 -0
  41. package/src/commands/dev.ts +316 -0
  42. package/src/commands/init/generators.test.ts +97 -0
  43. package/src/commands/init/generators.ts +124 -0
  44. package/src/commands/init/prompts.ts +123 -0
  45. package/src/commands/init/registry.test.ts +33 -0
  46. package/src/commands/init/registry.ts +75 -0
  47. package/src/commands/init/sniff.test.ts +41 -0
  48. package/src/commands/init/sniff.ts +30 -0
  49. package/src/commands/init.test.ts +131 -0
  50. package/src/commands/init.ts +158 -0
  51. package/src/commands/logs.test.ts +105 -0
  52. package/src/commands/logs.ts +108 -0
  53. package/src/commands/start.ts +28 -0
  54. package/src/commands/status.test.ts +73 -0
  55. package/src/commands/status.ts +105 -0
  56. package/src/daemon/start-daemon.test.ts +67 -0
  57. package/src/daemon/start-daemon.ts +270 -0
  58. package/src/index.ts +2 -0
  59. package/src/observability/capture-agent.ts +58 -0
  60. package/src/observability/capture-tuwunel.test.ts +57 -0
  61. package/src/observability/capture-tuwunel.ts +24 -0
  62. package/src/observability/file-sink.test.ts +68 -0
  63. package/src/observability/file-sink.ts +78 -0
  64. package/src/observability/observability.integration.test.ts +162 -0
  65. package/src/observability/paths.test.ts +104 -0
  66. package/src/observability/paths.ts +94 -0
  67. package/src/prepull-images.test.ts +149 -0
  68. package/src/prepull-images.ts +100 -0
  69. package/src/services/tuwunel.test.ts +64 -0
  70. package/src/services/tuwunel.ts +91 -0
  71. package/src/web/resolve.test.ts +50 -0
  72. package/src/web/resolve.ts +31 -0
  73. package/src/web/static.test.ts +51 -0
  74. package/src/web/static.ts +61 -0
  75. package/src/web/watch.ts +103 -0
  76. package/dist/chunk-67ZRMVHO.js +0 -174
  77. package/dist/chunk-AR456MHY.js +0 -29
  78. package/dist/chunk-VBGU2NST.js +0 -139
  79. package/dist/client-4VMFEFDX.js +0 -22
  80. package/dist/config-2KK5GX42.js +0 -27
  81. package/dist/template-T5IB4YWC.js +0 -92
@@ -0,0 +1,44 @@
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { ensureTokens, readTokens } from './tokens.js'
6
+
7
+ let dir: string
8
+ beforeEach(() => {
9
+ dir = join(tmpdir(), `zooid-tokens-${Math.random().toString(36).slice(2)}`)
10
+ mkdirSync(dir, { recursive: true })
11
+ })
12
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
13
+
14
+ describe('ensureTokens', () => {
15
+ it('generates two distinct hex tokens on first call and persists them', () => {
16
+ const envPath = join(dir, '.env')
17
+ const t = ensureTokens(envPath)
18
+ expect(t.asToken).toMatch(/^as-[0-9a-f]{32,}$/)
19
+ expect(t.hsToken).toMatch(/^hs-[0-9a-f]{32,}$/)
20
+ expect(t.asToken).not.toBe(t.hsToken)
21
+ const env = readFileSync(envPath, 'utf8')
22
+ expect(env).toContain(`MATRIX_AS_TOKEN=${t.asToken}`)
23
+ expect(env).toContain(`MATRIX_HS_TOKEN=${t.hsToken}`)
24
+ })
25
+
26
+ it('reuses tokens on subsequent calls (idempotent)', () => {
27
+ const envPath = join(dir, '.env')
28
+ const a = ensureTokens(envPath)
29
+ const b = ensureTokens(envPath)
30
+ expect(a).toEqual(b)
31
+ })
32
+
33
+ it('errors with a clear message if .env exists but is missing tokens', () => {
34
+ const envPath = join(dir, '.env')
35
+ writeFileSync(envPath, 'MATRIX_AS_TOKEN=as-abc\n', 'utf8')
36
+ expect(() => ensureTokens(envPath)).toThrow(
37
+ /missing MATRIX_AS_TOKEN or MATRIX_HS_TOKEN/i,
38
+ )
39
+ })
40
+
41
+ it('readTokens returns null when file does not exist', () => {
42
+ expect(readTokens(join(dir, 'nope.env'))).toBeNull()
43
+ })
44
+ })
@@ -0,0 +1,43 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+
5
+ export interface Tokens {
6
+ asToken: string
7
+ hsToken: string
8
+ }
9
+
10
+ const AS_RE = /^MATRIX_AS_TOKEN=(.+)$/m
11
+ const HS_RE = /^MATRIX_HS_TOKEN=(.+)$/m
12
+
13
+ function genToken(prefix: string): string {
14
+ return `${prefix}-${randomBytes(24).toString('hex')}`
15
+ }
16
+
17
+ export function readTokens(envPath: string): Tokens | null {
18
+ if (!existsSync(envPath)) return null
19
+ const text = readFileSync(envPath, 'utf8')
20
+ const as = text.match(AS_RE)?.[1]
21
+ const hs = text.match(HS_RE)?.[1]
22
+ if (!as && !hs) return null
23
+ if (!as || !hs) {
24
+ throw new Error(
25
+ `${envPath}: missing MATRIX_AS_TOKEN or MATRIX_HS_TOKEN. Delete the file to regenerate, or restore the missing value.`,
26
+ )
27
+ }
28
+ return { asToken: as, hsToken: hs }
29
+ }
30
+
31
+ export function ensureTokens(envPath: string): Tokens {
32
+ const existing = readTokens(envPath)
33
+ if (existing) return existing
34
+ const tokens: Tokens = { asToken: genToken('as'), hsToken: genToken('hs') }
35
+ mkdirSync(dirname(envPath), { recursive: true })
36
+ const previous = existsSync(envPath) ? readFileSync(envPath, 'utf8') : ''
37
+ const next =
38
+ previous.trimEnd() +
39
+ (previous ? '\n' : '') +
40
+ `MATRIX_AS_TOKEN=${tokens.asToken}\nMATRIX_HS_TOKEN=${tokens.hsToken}\n`
41
+ writeFileSync(envPath, next, { mode: 0o600 })
42
+ return tokens
43
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { buildAcpRegistry } from './build-registry.js'
3
+ import type { ZooidConfig } from '@zooid/core'
4
+
5
+ const matrixCfg: ZooidConfig = {
6
+ runtime: 'local',
7
+ transports: {
8
+ mx: {
9
+ type: 'matrix',
10
+ homeserver: 'https://hs',
11
+ as_token: 'as',
12
+ hs_token: 'hs',
13
+ user_namespace: '@.*:hs',
14
+ sender_localpart: '_zooid',
15
+ space: 'dev',
16
+ port: 0,
17
+ },
18
+ },
19
+ agents: {
20
+ architect: {
21
+ name: 'architect',
22
+ workdir: '/tmp',
23
+ hooks: {},
24
+ acp: { command: 'fake', args: [] },
25
+ approval_timeout_ms: 0,
26
+ matrix: {
27
+ transport: 'mx',
28
+ user_id: '@architect:hs',
29
+ rooms: ['!r:hs'],
30
+ trigger: 'mention',
31
+ },
32
+ },
33
+ },
34
+ hooks: {},
35
+ } as unknown as ZooidConfig
36
+
37
+ describe('buildAcpRegistry — context provider wiring', () => {
38
+ it('attaches a Matrix-backed context provider to agents bound to a matrix transport', () => {
39
+ const registry = buildAcpRegistry(matrixCfg, {
40
+ approvals: { register: vi.fn(), on: vi.fn() } as never,
41
+ contextSpawnRegistry: {} as never,
42
+ daemonSockPath: '/tmp/zooid-test.sock',
43
+ })
44
+ expect(registry.hasContextSpawn('architect')).toBe(true)
45
+ })
46
+
47
+ it('does not attach a context provider to http-bound agents', () => {
48
+ const httpCfg = structuredClone(matrixCfg)
49
+ httpCfg.transports = {
50
+ h: { type: 'http', port: 0 } as never,
51
+ }
52
+ httpCfg.agents.architect.matrix = undefined as never
53
+ ;(httpCfg.agents.architect as { http?: unknown }).http = { transport: 'h' }
54
+ const registry = buildAcpRegistry(httpCfg, {
55
+ approvals: { register: vi.fn(), on: vi.fn() } as never,
56
+ contextSpawnRegistry: {} as never,
57
+ daemonSockPath: '/tmp/zooid-test.sock',
58
+ })
59
+ expect(registry.hasContextSpawn('architect')).toBe(false)
60
+ })
61
+ })
@@ -0,0 +1,59 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { LocalAcpRuntime } from '@zooid/runtime-local'
3
+ import { DockerAcpRuntime } from '@zooid/runtime-docker'
4
+ import type { ZooidConfig } from '@zooid/core'
5
+ import { buildAcpRegistry } from './build-registry.js'
6
+
7
+ function cfg(overrides: Partial<ZooidConfig> & Pick<ZooidConfig, 'runtime'>): ZooidConfig {
8
+ return {
9
+ runtime: overrides.runtime,
10
+ transports: overrides.transports ?? { 'http-local': { type: 'http', port: 8080 } },
11
+ agents: overrides.agents ?? {
12
+ a: {
13
+ name: 'a',
14
+ workdir: '.',
15
+ hooks: {},
16
+ acp: { preset: 'claude' },
17
+ approval_timeout_ms: 0,
18
+ http: { transport: 'http-local' },
19
+ },
20
+ },
21
+ hooks: {},
22
+ container: overrides.container,
23
+ } as ZooidConfig
24
+ }
25
+
26
+ describe('buildAcpRegistry', () => {
27
+ it('constructs with LocalAcpRuntime for runtime: local', () => {
28
+ const reg = buildAcpRegistry(cfg({ runtime: 'local' }))
29
+ expect((reg as unknown as { opts: { runtime: unknown } }).opts.runtime).toBeInstanceOf(LocalAcpRuntime)
30
+ })
31
+
32
+ it('constructs with DockerAcpRuntime (docker engine) for runtime: docker', () => {
33
+ const reg = buildAcpRegistry(
34
+ cfg({ runtime: 'docker', container: { image: 'img:latest' } }),
35
+ )
36
+ const rt = (reg as unknown as { opts: { runtime: DockerAcpRuntime } }).opts.runtime
37
+ expect(rt).toBeInstanceOf(DockerAcpRuntime)
38
+ })
39
+
40
+ it('constructs with DockerAcpRuntime (podman engine) for runtime: podman', () => {
41
+ const reg = buildAcpRegistry(
42
+ cfg({ runtime: 'podman', container: { image: 'img:latest' } }),
43
+ )
44
+ const rt = (reg as unknown as { opts: { runtime: DockerAcpRuntime } }).opts.runtime
45
+ expect(rt).toBeInstanceOf(DockerAcpRuntime)
46
+ })
47
+
48
+ it('throws if any agent has no acp block (defense in depth)', () => {
49
+ const c: ZooidConfig = {
50
+ runtime: 'local',
51
+ transports: { 'http-local': { type: 'http', port: 8080 } },
52
+ agents: {
53
+ a: { name: 'a', workdir: '.', hooks: {}, http: { transport: 'http-local' } } as never,
54
+ },
55
+ hooks: {},
56
+ }
57
+ expect(() => buildAcpRegistry(c)).toThrow(/agents\.a.*acp/i)
58
+ })
59
+ })
@@ -0,0 +1,343 @@
1
+ import { isAbsolute, resolve as pathResolve } from 'node:path'
2
+ import { LocalAcpRuntime } from '@zooid/runtime-local'
3
+ import { DockerAcpRuntime } from '@zooid/runtime-docker'
4
+ import {
5
+ AcpAgentRegistry,
6
+ type AcpMount,
7
+ type AcpRuntime,
8
+ type AgentConfig,
9
+ type ApprovalCorrelator,
10
+ type ContextSpawnFactory,
11
+ type MountConfig,
12
+ type TapEvent,
13
+ type ZooidConfig,
14
+ type TransportContextProvider,
15
+ } from '@zooid/core'
16
+ import { MatrixClient, MatrixContextProvider } from '@zooid/transport-matrix'
17
+ import { SpawnRegistry, buildContextServerSpec } from '@zooid/context-mcp'
18
+ import { PRESETS, type PresetMount, type PresetName } from '@zooid/acp-client'
19
+
20
+ export interface BuildAcpRegistryOptions {
21
+ /** Override the runtime selection (tests). */
22
+ runtime?: AcpRuntime
23
+ /** When set, the registry's approval handler routes through this correlator. */
24
+ approvals?: ApprovalCorrelator
25
+ /** Observability tap forwarded to each AcpClient. */
26
+ onTap?: (agentName: string, event: TapEvent) => void
27
+ /**
28
+ * Per-agent state root (`<dataRoot>/agents/`). When set, each AcpClient
29
+ * persists its `(threadId → sessionId)` map under
30
+ * `<agentsDir>/<agentName>/sessions.json` so threads survive daemon restarts.
31
+ */
32
+ agentsDir?: string
33
+ /**
34
+ * Per-spawn binding store for the zooid-context MCP server. When set
35
+ * together with `daemonSockPath`, agents bound to a transport that owns
36
+ * conversation context get a `contextSpawn` factory threaded into their
37
+ * AcpClient so `session/new mcpServers` includes the zooid-context entry.
38
+ */
39
+ contextSpawnRegistry?: SpawnRegistry
40
+ /** Path to the daemon's context Unix socket. Passed to the MCP server bin. */
41
+ daemonSockPath?: string
42
+ /**
43
+ * Directory containing zooid.yaml. Required when any agent uses the
44
+ * workspace auto-mount (the default under `runtime: docker | podman`):
45
+ * relative `agent.workdir` paths are resolved against this dir.
46
+ */
47
+ configDir?: string
48
+ /**
49
+ * Daemon data root. Each agent's preset mounts target
50
+ * `<dataDir>/agents/<agentName>` on the host.
51
+ */
52
+ dataDir?: string
53
+ /**
54
+ * Daemon user's `$HOME`. Sourced by the v1 preset `home` / `data` /
55
+ * `config` mounts. Tests inject a stub; production threads
56
+ * `process.env.HOME` from `start-daemon`.
57
+ */
58
+ daemonHome?: string
59
+ /**
60
+ * Sink for the per-agent "resolved image + mounts" startup log lines.
61
+ * Defaults to `console.log`. Tests pass a capture array.
62
+ */
63
+ log?: (line: string) => void
64
+ }
65
+
66
+ type ImageProvenance = 'agent' | 'workforce' | `preset:${PresetName}` | 'unresolved'
67
+
68
+ interface ResolvedImage {
69
+ image: string | undefined
70
+ source: ImageProvenance
71
+ }
72
+
73
+ const CONTAINER_WORKDIR = '/workspace'
74
+
75
+ function presetOf(agent: AgentConfig): PresetName | undefined {
76
+ const spec = agent.acp as { preset?: string } | undefined
77
+ if (spec?.preset && spec.preset in PRESETS) return spec.preset as PresetName
78
+ return undefined
79
+ }
80
+
81
+ function resolveAgentImage(
82
+ agent: AgentConfig,
83
+ cfg: ZooidConfig,
84
+ ): ResolvedImage {
85
+ if (agent.container?.image) return { image: agent.container.image, source: 'agent' }
86
+ if (cfg.container?.image) return { image: cfg.container.image, source: 'workforce' }
87
+ const preset = presetOf(agent)
88
+ const presetImage = preset ? PRESETS[preset]?.image : undefined
89
+ if (presetImage && preset) return { image: presetImage, source: `preset:${preset}` }
90
+ return { image: undefined, source: 'unresolved' }
91
+ }
92
+
93
+ interface ComposedMounts {
94
+ mounts: PresetMount[]
95
+ mkdirs: string[]
96
+ cwd: string
97
+ }
98
+
99
+ function composeAgentMounts(
100
+ name: string,
101
+ agent: AgentConfig,
102
+ cfg: ZooidConfig,
103
+ opts: BuildAcpRegistryOptions,
104
+ ): ComposedMounts {
105
+ if (cfg.runtime === 'local') {
106
+ return { mounts: [], mkdirs: [], cwd: agent.workdir }
107
+ }
108
+
109
+ const disabled = new Set(agent.container?.disable_mounts ?? [])
110
+ const knownIds = new Set<string>(['workspace'])
111
+ const composed: PresetMount[] = []
112
+ let workspaceActive = false
113
+
114
+ // Layer 1: workspace auto-mount. Skip when explicitly disabled, or when
115
+ // configDir is missing and the workdir is relative — the daemon path always
116
+ // passes configDir; tests that exercise other concerns (image resolution,
117
+ // env) can leave it off without the workspace mount intruding.
118
+ if (!disabled.has('workspace')) {
119
+ let host = agent.workdir
120
+ if (isAbsolute(host)) {
121
+ composed.push({
122
+ id: 'workspace',
123
+ host,
124
+ target: CONTAINER_WORKDIR,
125
+ mode: 'rw',
126
+ create: true,
127
+ })
128
+ workspaceActive = true
129
+ } else if (opts.configDir) {
130
+ host = pathResolve(opts.configDir, host)
131
+ composed.push({
132
+ id: 'workspace',
133
+ host,
134
+ target: CONTAINER_WORKDIR,
135
+ mode: 'rw',
136
+ create: true,
137
+ })
138
+ workspaceActive = true
139
+ }
140
+ }
141
+
142
+ // Layer 2: preset mounts.
143
+ const preset = presetOf(agent)
144
+ if (preset && PRESETS[preset]?.mounts) {
145
+ const dataDir = opts.dataDir ?? process.cwd()
146
+ const ctx = {
147
+ agentName: name,
148
+ agentDataDir: pathResolve(dataDir, 'agents', name),
149
+ containerWorkdir: CONTAINER_WORKDIR,
150
+ daemonHome: opts.daemonHome ?? process.env.HOME ?? '',
151
+ }
152
+ const presetMounts = PRESETS[preset].mounts!(ctx)
153
+ for (const m of presetMounts) {
154
+ knownIds.add(m.id)
155
+ if (!disabled.has(m.id)) composed.push(m)
156
+ }
157
+ }
158
+
159
+ // Validate disable_mounts ids — every entry must be either 'workspace' or a
160
+ // preset-declared id. Unknown ids are typos that would silently no-op.
161
+ for (const id of disabled) {
162
+ if (!knownIds.has(id)) {
163
+ throw new Error(
164
+ `agents.${name}.container.disable_mounts: unknown id "${id}". ` +
165
+ `Known ids for this agent: ${[...knownIds].sort().join(', ')}`,
166
+ )
167
+ }
168
+ }
169
+
170
+ // Layer 3: user mounts. Auto-id any without one.
171
+ const userMounts: MountConfig[] = agent.container?.mounts ?? []
172
+ let userIdx = 0
173
+ for (const m of userMounts) {
174
+ const id = m.id ?? `user-${userIdx}`
175
+ userIdx++
176
+ const out: PresetMount = {
177
+ id,
178
+ host: m.host,
179
+ target: m.target,
180
+ mode: m.mode,
181
+ }
182
+ if (m.create !== undefined) out.create = m.create
183
+ composed.push(out)
184
+ }
185
+
186
+ const mkdirs: string[] = composed
187
+ .filter((m) => m.create)
188
+ .map((m) => m.host)
189
+
190
+ return {
191
+ mounts: composed,
192
+ mkdirs,
193
+ cwd: workspaceActive ? CONTAINER_WORKDIR : agent.workdir,
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Build an `AcpAgentRegistry` from a parsed workforce config.
199
+ *
200
+ * - `runtime: local` → `LocalAcpRuntime`
201
+ * - `runtime: docker` → `DockerAcpRuntime` (engine: docker)
202
+ * - `runtime: podman` → `DockerAcpRuntime` (engine: podman)
203
+ *
204
+ * Compose layers (docker/podman only): workspace auto-mount → preset-declared
205
+ * canonical-id mounts (filtered by `container.disable_mounts`) → user mounts.
206
+ * Image resolution: agent.container.image > workforce container.image >
207
+ * preset.image. Under `runtime: docker | podman`, throws at startup if any
208
+ * agent has no resolvable image.
209
+ */
210
+ export function buildAcpRegistry(
211
+ cfg: ZooidConfig,
212
+ opts: BuildAcpRegistryOptions = {},
213
+ ): AcpAgentRegistry {
214
+ for (const [name, agent] of Object.entries(cfg.agents)) {
215
+ if (!agent.acp) {
216
+ throw new Error(`agents.${name}: missing acp block (parser should have caught this)`)
217
+ }
218
+ }
219
+
220
+ // Startup validation: every docker/podman agent must resolve to an image.
221
+ if (cfg.runtime !== 'local') {
222
+ const missing: Array<{ name: string; preset?: string }> = []
223
+ for (const [name, agent] of Object.entries(cfg.agents)) {
224
+ const { image } = resolveAgentImage(agent, cfg)
225
+ if (!image) {
226
+ missing.push({ name, preset: presetOf(agent) })
227
+ }
228
+ }
229
+ if (missing.length > 0) {
230
+ const lines = missing.map(({ name, preset }) =>
231
+ ` - ${name}${preset ? ` (preset: ${preset})` : ''} — no preset-default image`,
232
+ )
233
+ throw new Error(
234
+ `runtime: ${cfg.runtime} requires a container image for each agent. Unresolved:\n` +
235
+ lines.join('\n') +
236
+ `\nSet agents.<name>.container.image, top-level container.image, or use a ` +
237
+ `preset that ships a default image (claude, codex, opencode).`,
238
+ )
239
+ }
240
+ }
241
+
242
+ const runtime = opts.runtime ?? defaultRuntimeFor(cfg)
243
+ const env: Record<string, Record<string, string>> = {}
244
+ const image: Record<string, string | undefined> = {}
245
+ const mountsByAgent: Record<string, AcpMount[]> = {}
246
+ const mkdirByAgent: Record<string, string[]> = {}
247
+ const cwdByAgent: Record<string, string> = {}
248
+ const log = opts.log ?? ((line: string) => console.log(line))
249
+
250
+ for (const [name, agent] of Object.entries(cfg.agents)) {
251
+ env[name] = agent.container?.env ?? {}
252
+ const { image: resolvedImage, source } = resolveAgentImage(agent, cfg)
253
+ image[name] = resolvedImage
254
+ const composed = composeAgentMounts(name, agent, cfg, opts)
255
+ mountsByAgent[name] = composed.mounts.map((m) => ({
256
+ path: m.host,
257
+ target: m.target,
258
+ mode: m.mode,
259
+ }))
260
+ mkdirByAgent[name] = composed.mkdirs
261
+ cwdByAgent[name] = composed.cwd
262
+ if (resolvedImage && cfg.runtime !== 'local') {
263
+ const mountSummary = composed.mounts.length === 0
264
+ ? 'mounts=[]'
265
+ : `mounts=[${composed.mounts.map((m) => m.id ?? m.target).join(',')}]`
266
+ log(
267
+ `[zooid] agent ${name.padEnd(12)} image=${resolvedImage} source=${source} ${mountSummary}`,
268
+ )
269
+ }
270
+ }
271
+
272
+ const contextSpawns = buildContextSpawns(cfg, opts)
273
+
274
+ return new AcpAgentRegistry({
275
+ runtime,
276
+ agents: cfg.agents,
277
+ env,
278
+ image,
279
+ mounts: mountsByAgent,
280
+ mkdirOnSpawn: mkdirByAgent,
281
+ cwd: cwdByAgent,
282
+ approvals: opts.approvals,
283
+ onTap: opts.onTap,
284
+ agentsDir: opts.agentsDir,
285
+ contextSpawns,
286
+ })
287
+ }
288
+
289
+ function buildContextSpawns(
290
+ cfg: ZooidConfig,
291
+ opts: BuildAcpRegistryOptions,
292
+ ): Record<string, ContextSpawnFactory | undefined> | undefined {
293
+ if (!opts.contextSpawnRegistry || !opts.daemonSockPath) return undefined
294
+ const registry = opts.contextSpawnRegistry
295
+ const sockPath = opts.daemonSockPath
296
+
297
+ const matrixClients = new Map<string, MatrixClient>()
298
+ const agentBots = new Map<string, string>()
299
+ for (const [name, agent] of Object.entries(cfg.agents)) {
300
+ if (agent.matrix?.user_id) agentBots.set(agent.matrix.user_id, name)
301
+ }
302
+ for (const [tname, tcfg] of Object.entries(cfg.transports)) {
303
+ if (tcfg.type !== 'matrix') continue
304
+ matrixClients.set(
305
+ tname,
306
+ new MatrixClient({ homeserver: tcfg.homeserver, asToken: tcfg.as_token }),
307
+ )
308
+ }
309
+
310
+ const result: Record<string, ContextSpawnFactory | undefined> = {}
311
+ for (const [name, agent] of Object.entries(cfg.agents)) {
312
+ if (agent.matrix && matrixClients.has(agent.matrix.transport)) {
313
+ const client = matrixClients.get(agent.matrix.transport)!
314
+ const provider: TransportContextProvider = new MatrixContextProvider({
315
+ client,
316
+ asUserId: agent.matrix.user_id,
317
+ agentBots,
318
+ })
319
+ result[name] = async (threadId: string, channelId?: string) => {
320
+ const spawnId = registry.register({
321
+ agentName: name,
322
+ threadRef: { channelId: channelId ?? threadId, threadId },
323
+ provider,
324
+ })
325
+ return buildContextServerSpec({ spawnId, sockPath })
326
+ }
327
+ } else {
328
+ result[name] = undefined
329
+ }
330
+ }
331
+ return result
332
+ }
333
+
334
+ function defaultRuntimeFor(cfg: ZooidConfig): AcpRuntime {
335
+ if (cfg.runtime === 'local') return new LocalAcpRuntime()
336
+ if (cfg.runtime === 'docker') {
337
+ return new DockerAcpRuntime({ defaultImage: cfg.container?.image, engine: 'docker' })
338
+ }
339
+ if (cfg.runtime === 'podman') {
340
+ return new DockerAcpRuntime({ defaultImage: cfg.container?.image, engine: 'podman' })
341
+ }
342
+ throw new Error(`unsupported runtime: ${cfg.runtime}`)
343
+ }
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildAcpRegistry } from './build-registry.js'
3
+ import type { ZooidConfig } from '@zooid/core'
4
+
5
+ const baseTransports = {
6
+ m1: {
7
+ type: 'matrix' as const,
8
+ homeserver: 'http://localhost:8448',
9
+ as_token: 't',
10
+ hs_token: 'h',
11
+ sender_localpart: 'z',
12
+ user_namespace: '@.*:localhost',
13
+ },
14
+ }
15
+
16
+ function makeCfg(over: Partial<ZooidConfig> = {}): ZooidConfig {
17
+ return {
18
+ runtime: 'docker',
19
+ container: { image: 'workforce-default:1' },
20
+ transports: baseTransports,
21
+ agents: {
22
+ alice: {
23
+ name: 'alice',
24
+ workdir: './alice',
25
+ hooks: {},
26
+ acp: { preset: 'claude' },
27
+ approval_timeout_ms: 0,
28
+ matrix: {
29
+ transport: 'm1',
30
+ user_id: '@alice:localhost',
31
+ rooms: ['!r:localhost'],
32
+ trigger: 'mention',
33
+ },
34
+ container: { env: { LOG_LEVEL: 'info' } },
35
+ },
36
+ },
37
+ hooks: {},
38
+ ...over,
39
+ }
40
+ }
41
+
42
+ describe('buildAcpRegistry — ZOD043', () => {
43
+ it('passes agent.container.env through as the spawn env (no forward_env layer)', () => {
44
+ const cfg = makeCfg()
45
+ const reg = buildAcpRegistry(cfg)
46
+ expect(reg.resolveSpawnEnv('alice')).toEqual({ LOG_LEVEL: 'info' })
47
+ })
48
+
49
+ it('resolves image with per-agent override beating workforce default', () => {
50
+ const cfg = makeCfg()
51
+ cfg.agents.alice!.container = {
52
+ ...cfg.agents.alice!.container,
53
+ image: 'alice:2',
54
+ }
55
+ const reg = buildAcpRegistry(cfg)
56
+ expect(reg.resolveSpawnImage('alice')).toBe('alice:2')
57
+ })
58
+
59
+ it('falls back to workforce container.image when no per-agent image', () => {
60
+ const cfg = makeCfg()
61
+ const reg = buildAcpRegistry(cfg)
62
+ expect(reg.resolveSpawnImage('alice')).toBe('workforce-default:1')
63
+ })
64
+ })