zooid 0.7.0 → 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.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>Zoon</title>
8
- <script type="module" crossorigin src="/assets/index-BT_v3DKu.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DJTghnF-.css">
8
+ <script type="module" crossorigin src="/assets/index-Cz5fEZBc.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-C2MAIew3.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zooid",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Open-source tool for defining and running AI agents alongside you and your team. Any model, any CLI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,12 +37,13 @@
37
37
  "marked": "^18.0.4",
38
38
  "sanitize-html": "^2.17.4",
39
39
  "yaml": "^2.5.0",
40
- "@zooid/runtime-docker": "^0.7.0",
41
- "@zooid/core": "^0.7.0",
42
- "@zooid/runtime-local": "^0.7.0",
43
- "@zooid/transport-matrix": "^0.7.0",
44
- "@zooid/context-mcp": "^0.7.0",
45
- "@zooid/transport-http": "^0.7.0"
40
+ "@zooid/acp-client": "^0.7.1",
41
+ "@zooid/context-mcp": "^0.7.1",
42
+ "@zooid/core": "^0.7.1",
43
+ "@zooid/runtime-docker": "^0.7.1",
44
+ "@zooid/runtime-local": "^0.7.1",
45
+ "@zooid/transport-http": "^0.7.1",
46
+ "@zooid/transport-matrix": "^0.7.1"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@agentclientprotocol/sdk": "^0.21.0",
package/src/bin.ts CHANGED
@@ -31,7 +31,10 @@ cli
31
31
  .option('--ui-port <n>', 'UI HTTP port', { default: 5173 })
32
32
  .option('--admin-user <name>', 'Admin username', { default: 'admin' })
33
33
  .option('--admin-password <pw>', 'Admin password', { default: 'admin' })
34
- .option('--watch-web', 'Run vite build --watch on @zoon/web (monorepo source only)')
34
+ .option(
35
+ '--watch-web [path]',
36
+ 'Run vite build --watch on @zoon/web. Path defaults to sibling ../zoon/packages/web.',
37
+ )
35
38
  .action(async (flags) => {
36
39
  await runDev({
37
40
  dataDir: flags.data,
@@ -39,7 +42,7 @@ cli
39
42
  uiPort: Number(flags.uiPort),
40
43
  adminUser: flags.adminUser,
41
44
  adminPassword: flags.adminPassword,
42
- watchWeb: Boolean(flags.watchWeb),
45
+ watchWeb: flags.watchWeb as string | boolean | undefined,
43
46
  })
44
47
  })
45
48
 
@@ -1,16 +1,21 @@
1
+ import { isAbsolute, resolve as pathResolve } from 'node:path'
1
2
  import { LocalAcpRuntime } from '@zooid/runtime-local'
2
3
  import { DockerAcpRuntime } from '@zooid/runtime-docker'
3
4
  import {
4
5
  AcpAgentRegistry,
6
+ type AcpMount,
5
7
  type AcpRuntime,
8
+ type AgentConfig,
6
9
  type ApprovalCorrelator,
7
10
  type ContextSpawnFactory,
11
+ type MountConfig,
8
12
  type TapEvent,
9
13
  type ZooidConfig,
10
14
  type TransportContextProvider,
11
15
  } from '@zooid/core'
12
16
  import { MatrixClient, MatrixContextProvider } from '@zooid/transport-matrix'
13
17
  import { SpawnRegistry, buildContextServerSpec } from '@zooid/context-mcp'
18
+ import { PRESETS, type PresetMount, type PresetName } from '@zooid/acp-client'
14
19
 
15
20
  export interface BuildAcpRegistryOptions {
16
21
  /** Override the runtime selection (tests). */
@@ -34,6 +39,159 @@ export interface BuildAcpRegistryOptions {
34
39
  contextSpawnRegistry?: SpawnRegistry
35
40
  /** Path to the daemon's context Unix socket. Passed to the MCP server bin. */
36
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
+ }
37
195
  }
38
196
 
39
197
  /**
@@ -43,9 +201,11 @@ export interface BuildAcpRegistryOptions {
43
201
  * - `runtime: docker` → `DockerAcpRuntime` (engine: docker)
44
202
  * - `runtime: podman` → `DockerAcpRuntime` (engine: podman)
45
203
  *
46
- * Per-agent `container.env` is passed through to each `AcpClient`'s spawn
47
- * spec verbatim (interpolation happens at parse time in `@zooid/core`).
48
- * The image is resolved as `agent.container?.image ?? cfg.container?.image`.
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.
49
209
  */
50
210
  export function buildAcpRegistry(
51
211
  cfg: ZooidConfig,
@@ -57,12 +217,56 @@ export function buildAcpRegistry(
57
217
  }
58
218
  }
59
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
+
60
242
  const runtime = opts.runtime ?? defaultRuntimeFor(cfg)
61
243
  const env: Record<string, Record<string, string>> = {}
62
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
+
63
250
  for (const [name, agent] of Object.entries(cfg.agents)) {
64
251
  env[name] = agent.container?.env ?? {}
65
- image[name] = agent.container?.image ?? cfg.container?.image
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
+ }
66
270
  }
67
271
 
68
272
  const contextSpawns = buildContextSpawns(cfg, opts)
@@ -72,6 +276,9 @@ export function buildAcpRegistry(
72
276
  agents: cfg.agents,
73
277
  env,
74
278
  image,
279
+ mounts: mountsByAgent,
280
+ mkdirOnSpawn: mkdirByAgent,
281
+ cwd: cwdByAgent,
75
282
  approvals: opts.approvals,
76
283
  onTap: opts.onTap,
77
284
  agentsDir: opts.agentsDir,
@@ -87,12 +294,6 @@ function buildContextSpawns(
87
294
  const registry = opts.contextSpawnRegistry
88
295
  const sockPath = opts.daemonSockPath
89
296
 
90
- // Share one MatrixClient per matrix transport (only state is homeserver +
91
- // asToken). Per-agent providers wrap that client with the agent's own
92
- // user_id as asUserId — when the AS impersonates the agent (via ?user_id=)
93
- // it sees the rooms that bot is a member of. The AS sender_localpart
94
- // typically isn't in those rooms, so impersonating it returns empty
95
- // chunks.
96
297
  const matrixClients = new Map<string, MatrixClient>()
97
298
  const agentBots = new Map<string, string>()
98
299
  for (const [name, agent] of Object.entries(cfg.agents)) {
@@ -0,0 +1,261 @@
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 mkCfg(over: Partial<ZooidConfig['agents']['alice']> = {}): ZooidConfig {
17
+ return {
18
+ runtime: 'docker',
19
+ transports: baseTransports,
20
+ agents: {
21
+ alice: {
22
+ name: 'alice',
23
+ workdir: './agents/alice',
24
+ hooks: {},
25
+ acp: { preset: 'claude' },
26
+ approval_timeout_ms: 0,
27
+ matrix: {
28
+ transport: 'm1',
29
+ user_id: '@alice:localhost',
30
+ rooms: ['!r:localhost'],
31
+ trigger: 'mention',
32
+ },
33
+ ...over,
34
+ },
35
+ },
36
+ hooks: {},
37
+ } as ZooidConfig
38
+ }
39
+
40
+ describe('buildAcpRegistry — mounts (ZOD044)', () => {
41
+ it('auto-injects the workspace mount with id "workspace"', () => {
42
+ const cfg = mkCfg()
43
+ const reg = buildAcpRegistry(cfg, {
44
+ configDir: '/example',
45
+ dataDir: '/data',
46
+ daemonHome: '/home/zooid',
47
+ })
48
+ const mounts = reg.resolveSpawnMounts('alice')
49
+ const ws = mounts.find((m) => m.target === '/workspace')
50
+ expect(ws).toMatchObject({
51
+ path: '/example/agents/alice',
52
+ target: '/workspace',
53
+ mode: 'rw',
54
+ })
55
+ })
56
+
57
+ it('overrides cwd to /workspace when workspace mount is active', () => {
58
+ const cfg = mkCfg()
59
+ const reg = buildAcpRegistry(cfg, {
60
+ configDir: '/example',
61
+ dataDir: '/data',
62
+ daemonHome: '/home/zooid',
63
+ })
64
+ expect(reg.resolveSpawnCwd('alice')).toBe('/workspace')
65
+ })
66
+
67
+ it('disable_mounts: [workspace] skips auto-mount and passes workdir through to cwd', () => {
68
+ const cfg = mkCfg({
69
+ workdir: '/baked/workspace',
70
+ container: { disable_mounts: ['workspace'] },
71
+ })
72
+ const reg = buildAcpRegistry(cfg, {
73
+ configDir: '/example',
74
+ dataDir: '/data',
75
+ daemonHome: '/home/zooid',
76
+ })
77
+ const mounts = reg.resolveSpawnMounts('alice')
78
+ expect(mounts.find((m) => m.target === '/workspace')).toBeUndefined()
79
+ expect(reg.resolveSpawnCwd('alice')).toBe('/baked/workspace')
80
+ })
81
+
82
+ it('layers preset mounts after workspace, filtered by disable_mounts', () => {
83
+ const cfg = mkCfg({ container: { disable_mounts: ['home'] } })
84
+ const reg = buildAcpRegistry(cfg, {
85
+ configDir: '/example',
86
+ dataDir: '/data',
87
+ daemonHome: '/home/zooid',
88
+ })
89
+ const mounts = reg.resolveSpawnMounts('alice')
90
+ // claude declares one `home` mount; with home disabled, only workspace remains.
91
+ const targets = mounts.map((m) => m.target)
92
+ expect(targets).toContain('/workspace')
93
+ expect(targets).not.toContain('/root/.claude')
94
+ })
95
+
96
+ it('preset `home` mount points at the daemon user host home, not <dataDir>/agents/<name>', () => {
97
+ const cfg = mkCfg()
98
+ const reg = buildAcpRegistry(cfg, {
99
+ configDir: '/example',
100
+ dataDir: '/data',
101
+ daemonHome: '/home/zooid',
102
+ })
103
+ const mounts = reg.resolveSpawnMounts('alice')
104
+ const home = mounts.find((m) => m.target === '/root/.claude')
105
+ expect(home).toBeDefined()
106
+ expect(home!.path).toBe('/home/zooid/.claude')
107
+ expect(home!.path).not.toContain('/data/agents/alice')
108
+ })
109
+
110
+ it('user-declared mounts append after preset mounts', () => {
111
+ const cfg = mkCfg({
112
+ container: {
113
+ mounts: [{ host: '/srv/shared', target: '/shared', mode: 'rw' }],
114
+ },
115
+ })
116
+ const reg = buildAcpRegistry(cfg, {
117
+ configDir: '/example',
118
+ dataDir: '/data',
119
+ daemonHome: '/home/zooid',
120
+ })
121
+ const mounts = reg.resolveSpawnMounts('alice')
122
+ expect(mounts[mounts.length - 1]).toMatchObject({
123
+ path: '/srv/shared',
124
+ target: '/shared',
125
+ })
126
+ })
127
+
128
+ it('throws on unknown disable_mounts id', () => {
129
+ const cfg = mkCfg({ container: { disable_mounts: ['nope'] } })
130
+ expect(() =>
131
+ buildAcpRegistry(cfg, {
132
+ configDir: '/example',
133
+ dataDir: '/data',
134
+ daemonHome: '/home/zooid',
135
+ }),
136
+ ).toThrow(/disable_mounts.*unknown id.*"nope"/i)
137
+ })
138
+
139
+ it('runtime: local — no mounts emitted, cwd is workdir verbatim', () => {
140
+ const cfg = mkCfg()
141
+ cfg.runtime = 'local'
142
+ const reg = buildAcpRegistry(cfg, {
143
+ configDir: '/example',
144
+ dataDir: '/data',
145
+ daemonHome: '/home/zooid',
146
+ })
147
+ expect(reg.resolveSpawnMounts('alice')).toEqual([])
148
+ expect(reg.resolveSpawnCwd('alice')).toBe('./agents/alice')
149
+ })
150
+ })
151
+
152
+ describe('buildAcpRegistry — image resolution (ZOD044)', () => {
153
+ it('uses agent.container.image when set (highest precedence)', () => {
154
+ const cfg = mkCfg({ container: { image: 'agent-img:v1' } })
155
+ cfg.container = { image: 'workforce-img:v1' }
156
+ const reg = buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' })
157
+ expect(reg.resolveSpawnImage('alice')).toBe('agent-img:v1')
158
+ })
159
+
160
+ it('falls back to workforce-level container.image', () => {
161
+ const cfg = mkCfg()
162
+ cfg.container = { image: 'workforce-img:v1' }
163
+ const reg = buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' })
164
+ expect(reg.resolveSpawnImage('alice')).toBe('workforce-img:v1')
165
+ })
166
+
167
+ it('falls back to preset.image (claude → ghcr default)', () => {
168
+ const cfg = mkCfg() // preset: claude, no container.image anywhere
169
+ const reg = buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' })
170
+ expect(reg.resolveSpawnImage('alice')).toBe('ghcr.io/zooid-ai/agent-claude-code:latest')
171
+ })
172
+
173
+ it('runtime: docker — throws when an agent has no resolvable image (preset has none)', () => {
174
+ const cfg = mkCfg({ acp: { preset: 'cline' } })
175
+ expect(() =>
176
+ buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' }),
177
+ ).toThrow(/runtime: docker requires a container image[\s\S]*alice[\s\S]*cline/i)
178
+ })
179
+
180
+ it('runtime: podman — same startup error', () => {
181
+ const cfg = mkCfg({ acp: { preset: 'gemini' } })
182
+ cfg.runtime = 'podman'
183
+ expect(() =>
184
+ buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' }),
185
+ ).toThrow(/runtime: podman requires a container image/i)
186
+ })
187
+
188
+ it('runtime: local — never errors on missing image (image is unused)', () => {
189
+ const cfg = mkCfg({ acp: { preset: 'cline' } })
190
+ cfg.runtime = 'local'
191
+ expect(() =>
192
+ buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' }),
193
+ ).not.toThrow()
194
+ })
195
+
196
+ it('lists every unresolved agent in a single error', () => {
197
+ const base = mkCfg()
198
+ const cfg: ZooidConfig = {
199
+ runtime: 'podman',
200
+ transports: baseTransports,
201
+ agents: {
202
+ alice: { ...base.agents.alice!, acp: { preset: 'cline' } },
203
+ bob: {
204
+ ...base.agents.alice!,
205
+ name: 'bob',
206
+ acp: { preset: 'kiro' },
207
+ matrix: {
208
+ transport: 'm1',
209
+ user_id: '@bob:localhost',
210
+ rooms: ['!r:localhost'],
211
+ trigger: 'mention',
212
+ },
213
+ },
214
+ },
215
+ hooks: {},
216
+ } as ZooidConfig
217
+ expect(() =>
218
+ buildAcpRegistry(cfg, { configDir: '/example', dataDir: '/data' }),
219
+ ).toThrow(/alice[\s\S]*cline[\s\S]*bob[\s\S]*kiro/)
220
+ })
221
+ })
222
+
223
+ describe('buildAcpRegistry — startup discoverability (ZOD044)', () => {
224
+ it('logs one resolved-image line per agent with provenance', () => {
225
+ const lines: string[] = []
226
+ const cfg = mkCfg()
227
+ buildAcpRegistry(cfg, {
228
+ configDir: '/example',
229
+ dataDir: '/data',
230
+ log: (line) => lines.push(line),
231
+ })
232
+ expect(
233
+ lines.some((l) =>
234
+ /agent alice.*image=ghcr\.io\/zooid-ai\/agent-claude-code.*source=preset:claude/.test(l),
235
+ ),
236
+ ).toBe(true)
237
+ })
238
+
239
+ it('reports source=agent when the agent overrides the preset default', () => {
240
+ const lines: string[] = []
241
+ const cfg = mkCfg({ container: { image: 'custom:v3' } })
242
+ buildAcpRegistry(cfg, {
243
+ configDir: '/example',
244
+ dataDir: '/data',
245
+ log: (line) => lines.push(line),
246
+ })
247
+ expect(lines.some((l) => /agent alice.*image=custom:v3.*source=agent/.test(l))).toBe(true)
248
+ })
249
+
250
+ it('reports source=workforce when only workforce container.image is set', () => {
251
+ const lines: string[] = []
252
+ const cfg = mkCfg()
253
+ cfg.container = { image: 'wf:v1' }
254
+ buildAcpRegistry(cfg, {
255
+ configDir: '/example',
256
+ dataDir: '/data',
257
+ log: (line) => lines.push(line),
258
+ })
259
+ expect(lines.some((l) => /agent alice.*image=wf:v1.*source=workforce/.test(l))).toBe(true)
260
+ })
261
+ })
@@ -66,9 +66,9 @@ export interface DevFlags {
66
66
  adminPassword: string
67
67
  installSignalHandlers?: boolean
68
68
  foreground?: boolean
69
- // When true, run `vite build --watch` against the in-tree @zoon/web package
70
- // and serve its dist directly. Requires running from the monorepo source.
71
- watchWeb?: boolean
69
+ // Run `vite build --watch` against a @zoon/web package and serve its dist.
70
+ // true = auto-detect (sibling ../zoon/packages/web or in-monorepo); string = explicit path.
71
+ watchWeb?: string | boolean
72
72
  }
73
73
 
74
74
  export interface DevHandle {
@@ -111,7 +111,7 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
111
111
  process.env.MATRIX_HS_TOKEN = tokens.hsToken
112
112
 
113
113
  const rawYaml = readFileSync(found.path, 'utf8')
114
- const preview = loadZooidConfig(rawYaml)
114
+ const preview = loadZooidConfig(rawYaml, { configDir: dirname(found.path) })
115
115
  const matrix = findMatrixTransport(preview)
116
116
  if (!matrix) {
117
117
  throw new Error('zooid.yaml: zooid dev requires at least one matrix transport')
@@ -179,7 +179,7 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
179
179
  },
180
180
  {
181
181
  title: 'Start daemon',
182
- task: async () => {
182
+ task: async (_ctx, t) => {
183
183
  const captures: Record<string, AgentCapture> = {}
184
184
  for (const name of Object.keys(preview.agents)) {
185
185
  captures[name] = wireAgentCapture({
@@ -199,6 +199,9 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
199
199
  adminUserId: `@${flags.adminUser}:${shape.serverName}`,
200
200
  agentsDir: layout.agentsDir,
201
201
  onTap: (agentName, event) => captures[agentName]?.onTap(event),
202
+ prepullLog: (line) => {
203
+ t.output = line.replace(/^\[zooid\]\s+/, '').trim()
204
+ },
202
205
  })
203
206
  },
204
207
  },
@@ -207,10 +210,15 @@ export async function runDev(flags: DevFlags): Promise<DevHandle> {
207
210
  {
208
211
  title: 'Start @zoon/web watcher (vite build --watch)',
209
212
  task: async (): Promise<void> => {
210
- const pkgDir = webSourcePackage(CLI_ROOT)
213
+ const pkgDir =
214
+ typeof flags.watchWeb === 'string'
215
+ ? resolve(flags.watchWeb)
216
+ : webSourcePackage(CLI_ROOT)
211
217
  if (!pkgDir) {
218
+ const defaultPath = dirname(dirname(dirname(CLI_ROOT))) + '/zoon/packages/web'
212
219
  throw new Error(
213
- '--watch-web requires running from the monorepo source (no zoon/packages/web found).',
220
+ `--watch-web: @zoon/web not found at ${defaultPath}.\n` +
221
+ `Pass an explicit path: --watch-web=/path/to/zoon/packages/web`,
214
222
  )
215
223
  }
216
224
  ctx.webWatch = await startWebWatch({ webPackageDir: pkgDir })
@@ -35,6 +35,11 @@ You are the zooid setup assistant. Your job is to help the operator get a workfo
35
35
  - Zooid documentation: https://zooid.dev/docs/
36
36
  - Use your web fetch tool to read documentation pages when answering questions.
37
37
 
38
+ ## Stack facts
39
+
40
+ - **Tuwunel** is a lightweight Rust Matrix homeserver (Conduit fork) backed by RocksDB. Single binary, low resource floor — not "heavy" for typical workforces.
41
+ - **Logs land on disk** in \`./data/\` inside this zooid home dir: tuwunel logs, the daemon, and each agent's ACP stream. Use \`zooid logs <source>\` to tail them (sources: \`tuwunel\`, \`daemon\`, \`dev\`, \`agent-<name>\`, \`agent-<name>.acp\`). Debugging is mostly "read the log file" — don't tell the operator it's opaque.
42
+
38
43
  ## Behavior
39
44
 
40
45
  - Be terse. Give the answer; link to the doc page; stop.