zooid 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.
@@ -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.
@@ -1,4 +1,5 @@
1
1
  import { readFileSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
2
3
  import chalk from 'chalk'
3
4
  import { findConfigFile, findMatrixTransport, loadZooidConfig } from '@zooid/core'
4
5
  import { deriveHomeserverShape } from '../bootstrap/derive.js'
@@ -43,7 +44,9 @@ export async function collectStatus(opts: {
43
44
  agents: [],
44
45
  }
45
46
  }
46
- const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'))
47
+ const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
48
+ configDir: dirname(found.path),
49
+ })
47
50
  const matrixEntry = Object.entries(cfg.transports).find(
48
51
  ([, t]) => t.type === 'matrix',
49
52
  ) as [string, { port?: number }] | undefined
@@ -73,7 +76,9 @@ export async function runStatus(flags: StatusFlags): Promise<void> {
73
76
  if (port === undefined) {
74
77
  const found = findConfigFile(cwd)
75
78
  if (found) {
76
- const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'))
79
+ const cfg = loadZooidConfig(readFileSync(found.path, 'utf8'), {
80
+ configDir: dirname(found.path),
81
+ })
77
82
  const matrix = findMatrixTransport(cfg)
78
83
  if (matrix) {
79
84
  const userIds = Object.values(cfg.agents)
@@ -18,6 +18,7 @@ import { createApp } from '@zooid/transport-http'
18
18
  import {
19
19
  MatrixClient,
20
20
  createMatrixTransport,
21
+ ensureDefaultChannel,
21
22
  ensureWorkforceSpace,
22
23
  startWorkforcePublisher,
23
24
  type AgentBinding,
@@ -29,6 +30,7 @@ import {
29
30
  type DaemonSocketHandle,
30
31
  } from '@zooid/context-mcp'
31
32
  import { buildAcpRegistry } from '../build-registry.js'
33
+ import { prepullImages } from '../prepull-images.js'
32
34
 
33
35
  export interface StartDaemonOpts {
34
36
  configPath?: string
@@ -45,6 +47,17 @@ export interface StartDaemonOpts {
45
47
  * daemon restarts.
46
48
  */
47
49
  agentsDir?: string
50
+ /** Skip the startup image-prepull pass entirely. */
51
+ noPrepull?: boolean
52
+ /** Force a re-pull of every resolved image at startup (no inspect check). */
53
+ refreshImages?: boolean
54
+ /**
55
+ * Per-line progress hook for the image-prepull pass. Called once per
56
+ * top-level summary plus once per image (start + completion). Used by
57
+ * `zooid dev` to surface pull progress under the "Start daemon" listr
58
+ * task instead of leaving the user staring at an unmoving spinner.
59
+ */
60
+ prepullLog?: (line: string) => void
48
61
  }
49
62
 
50
63
  export interface DaemonHandle {
@@ -75,7 +88,8 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
75
88
  const cwd = opts.cwd ?? process.cwd()
76
89
  const found = opts.configPath ? { path: opts.configPath } : findConfigFile(cwd)
77
90
  if (!found) throw new Error('zooid.yaml is required')
78
- const base = loadZooidConfig(readFileSync(found.path, 'utf8'))
91
+ const configDir = dirname(found.path)
92
+ const base = loadZooidConfig(readFileSync(found.path, 'utf8'), { configDir })
79
93
  const config = mergeCliFlags(base, opts.cliFlags ?? {})
80
94
 
81
95
  const approvals = new ApprovalCorrelator()
@@ -95,15 +109,29 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
95
109
  console.warn('[context] daemon socket startup failed; zooid-context MCP disabled:', err)
96
110
  }
97
111
 
112
+ const dataDir = opts.agentsDir ? dirname(opts.agentsDir) : undefined
98
113
  const registry = buildAcpRegistry(config, {
99
114
  approvals,
100
115
  onTap: opts.onTap,
101
116
  agentsDir: opts.agentsDir,
102
117
  contextSpawnRegistry: contextSocket ? contextSpawnRegistry : undefined,
103
118
  daemonSockPath: contextSocket ? daemonSockPath : undefined,
119
+ configDir,
120
+ dataDir,
121
+ daemonHome: process.env.HOME,
104
122
  })
105
123
  const agentNames = Object.keys(config.agents)
106
124
 
125
+ if (config.runtime !== 'local') {
126
+ await prepullImages(registry, {
127
+ engine: config.runtime === 'podman' ? 'podman' : 'docker',
128
+ runtime: config.runtime,
129
+ skip: opts.noPrepull ?? process.env.ZOOID_NO_PREPULL === '1',
130
+ refresh: opts.refreshImages ?? process.env.ZOOID_REFRESH_IMAGES === '1',
131
+ log: opts.prepullLog,
132
+ })
133
+ }
134
+
107
135
  console.log(
108
136
  `[context] socket=${daemonSockPath} ` +
109
137
  `status=${contextSocket ? 'listening' : 'disabled'} ` +
@@ -147,7 +175,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
147
175
  hsToken: matrix.transport.hs_token,
148
176
  adminUserId: opts.adminUserId,
149
177
  })
150
- const requestedPort = matrix.transport.port ?? 8080
178
+ const requestedPort = matrix.transport.port ?? 9000
151
179
  // Bind 0.0.0.0 explicitly — @hono/node-server defaults to IPv6-only on
152
180
  // macOS, which Docker's NAT bridge can't reach when Tuwunel pushes AS
153
181
  // events back to host.docker.internal:<port>.
@@ -162,6 +190,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
162
190
  new URL(matrix.transport.homeserver).hostname
163
191
  const asUserId = `@${matrix.transport.sender_localpart}:${serverName}`
164
192
  const spaceLocalpart = matrix.transport.space ?? 'dev'
193
+ const adminUserIds = opts.adminUserId ? [opts.adminUserId] : []
165
194
  let spaceRoomId: string | undefined
166
195
  try {
167
196
  spaceRoomId = await ensureWorkforceSpace({
@@ -170,6 +199,7 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
170
199
  serverName,
171
200
  spaceLocalpart,
172
201
  preset: 'public_chat',
202
+ admins: adminUserIds,
173
203
  })
174
204
  console.log(`[matrix] ensured workforce space #${spaceLocalpart}:${serverName} → ${spaceRoomId}`)
175
205
  } catch (err) {
@@ -182,9 +212,21 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
182
212
  // those pushes don't hit a connection-refused. Bootstrap also runs
183
213
  // *after* the space exists so BotPool can attach each agent room as
184
214
  // m.space.child while joining it.
185
- await transport.bootstrap({ spaceRoomId, asUserId })
215
+ await transport.bootstrap({ spaceRoomId, asUserId, adminUserIds })
186
216
 
187
217
  if (spaceRoomId) {
218
+ try {
219
+ const generalRoomId = await ensureDefaultChannel({
220
+ client,
221
+ asUserId,
222
+ serverName,
223
+ spaceId: spaceRoomId,
224
+ admins: adminUserIds,
225
+ })
226
+ console.log(`[matrix] ensured default channel #general:${serverName} → ${generalRoomId}`)
227
+ } catch (err) {
228
+ console.warn('[matrix] default channel provisioning failed:', err)
229
+ }
188
230
  try {
189
231
  const publisher: PublisherHandle = await startWorkforcePublisher({
190
232
  client,
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { prepullImages } from './prepull-images.js'
3
+
4
+ type Call = { argv: string[] }
5
+
6
+ function mkExec(
7
+ plan: Record<
8
+ string,
9
+ { inspectExit: number; pullExit?: number; pullStderr?: string }
10
+ >,
11
+ ) {
12
+ const calls: Call[] = []
13
+ const exec = vi.fn(async (cmd: string, args: string[]) => {
14
+ calls.push({ argv: [cmd, ...args] })
15
+ const op = args[0] === 'image' ? 'inspect' : 'pull'
16
+ const image = op === 'inspect' ? args[2] : args[1]
17
+ const entry = plan[image!]
18
+ if (!entry) throw new Error(`unexpected image: ${image}`)
19
+ if (op === 'inspect') {
20
+ return { code: entry.inspectExit, stdout: '', stderr: '' }
21
+ }
22
+ return { code: entry.pullExit ?? 0, stdout: '', stderr: entry.pullStderr ?? '' }
23
+ })
24
+ return { exec, calls }
25
+ }
26
+
27
+ const mkRegistry = (imagesByAgent: Record<string, string | undefined>) => ({
28
+ resolveSpawnImage: (n: string) => imagesByAgent[n],
29
+ agentNames: () => Object.keys(imagesByAgent),
30
+ })
31
+
32
+ describe('prepullImages', () => {
33
+ it('skips locally cached images (inspect exit 0)', async () => {
34
+ const { exec, calls } = mkExec({
35
+ 'ghcr.io/zooid-ai/agent-claude:latest': { inspectExit: 0 },
36
+ })
37
+ const lines: string[] = []
38
+ await prepullImages(
39
+ mkRegistry({ alice: 'ghcr.io/zooid-ai/agent-claude:latest' }) as any,
40
+ {
41
+ engine: 'docker',
42
+ runtime: 'docker',
43
+ exec,
44
+ log: (l) => lines.push(l),
45
+ },
46
+ )
47
+ expect(calls.filter((c) => c.argv.includes('pull'))).toHaveLength(0)
48
+ expect(lines.some((l) => /1 unique images \(1 cached, 0 to pull\)/.test(l))).toBe(
49
+ true,
50
+ )
51
+ })
52
+
53
+ it('pulls only missing images (inspect exit non-zero → pull)', async () => {
54
+ const { exec, calls } = mkExec({
55
+ 'cached:1': { inspectExit: 0 },
56
+ 'missing:1': { inspectExit: 1, pullExit: 0 },
57
+ })
58
+ const lines: string[] = []
59
+ await prepullImages(
60
+ mkRegistry({ a: 'cached:1', b: 'missing:1' }) as any,
61
+ { engine: 'docker', runtime: 'docker', exec, log: (l) => lines.push(l) },
62
+ )
63
+ const pulls = calls.filter((c) => c.argv.includes('pull')).map((c) => c.argv[2])
64
+ expect(pulls).toEqual(['missing:1'])
65
+ expect(lines.some((l) => /1 cached, 1 to pull/.test(l))).toBe(true)
66
+ expect(lines.some((l) => /missing:1.*✓/.test(l))).toBe(true)
67
+ })
68
+
69
+ it('dedupes by image — five agents on the same image pull once', async () => {
70
+ const { exec, calls } = mkExec({
71
+ 'shared:1': { inspectExit: 1, pullExit: 0 },
72
+ })
73
+ await prepullImages(
74
+ mkRegistry({
75
+ a: 'shared:1',
76
+ b: 'shared:1',
77
+ c: 'shared:1',
78
+ d: 'shared:1',
79
+ e: 'shared:1',
80
+ }) as any,
81
+ { engine: 'docker', runtime: 'docker', exec, log: () => {} },
82
+ )
83
+ expect(calls.filter((c) => c.argv.includes('pull'))).toHaveLength(1)
84
+ })
85
+
86
+ it('refresh=true skips the inspect check and pulls every image', async () => {
87
+ const { exec, calls } = mkExec({
88
+ 'a:1': { inspectExit: 0, pullExit: 0 },
89
+ 'b:1': { inspectExit: 0, pullExit: 0 },
90
+ })
91
+ await prepullImages(mkRegistry({ x: 'a:1', y: 'b:1' }) as any, {
92
+ engine: 'docker',
93
+ runtime: 'docker',
94
+ exec,
95
+ refresh: true,
96
+ log: () => {},
97
+ })
98
+ expect(calls.filter((c) => c.argv.includes('inspect'))).toHaveLength(0)
99
+ expect(calls.filter((c) => c.argv.includes('pull'))).toHaveLength(2)
100
+ })
101
+
102
+ it('fail-fast: pull exit non-zero throws with engine stderr embedded', async () => {
103
+ const { exec } = mkExec({
104
+ 'broken:1': { inspectExit: 1, pullExit: 1, pullStderr: 'manifest unknown' },
105
+ })
106
+ await expect(
107
+ prepullImages(mkRegistry({ a: 'broken:1' }) as any, {
108
+ engine: 'docker',
109
+ runtime: 'docker',
110
+ exec,
111
+ log: () => {},
112
+ }),
113
+ ).rejects.toThrow(/broken:1[\s\S]*manifest unknown/)
114
+ })
115
+
116
+ it('skip=true is a no-op (returns immediately, no exec calls)', async () => {
117
+ const { exec, calls } = mkExec({})
118
+ await prepullImages(mkRegistry({ a: 'whatever:1' }) as any, {
119
+ engine: 'docker',
120
+ runtime: 'docker',
121
+ exec,
122
+ skip: true,
123
+ log: () => {},
124
+ })
125
+ expect(calls).toHaveLength(0)
126
+ })
127
+
128
+ it('runtime: local is a no-op regardless of opts', async () => {
129
+ const { exec, calls } = mkExec({})
130
+ await prepullImages(mkRegistry({ a: 'whatever:1' }) as any, {
131
+ engine: 'docker',
132
+ runtime: 'local',
133
+ exec,
134
+ log: () => {},
135
+ })
136
+ expect(calls).toHaveLength(0)
137
+ })
138
+
139
+ it('uses the configured engine (podman) in argv', async () => {
140
+ const { exec, calls } = mkExec({ 'x:1': { inspectExit: 1, pullExit: 0 } })
141
+ await prepullImages(mkRegistry({ a: 'x:1' }) as any, {
142
+ engine: 'podman',
143
+ runtime: 'podman',
144
+ exec,
145
+ log: () => {},
146
+ })
147
+ expect(calls.every((c) => c.argv[0] === 'podman')).toBe(true)
148
+ })
149
+ })
@@ -0,0 +1,100 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+
4
+ const pExecFile = promisify(execFile)
5
+
6
+ export interface PrepullExec {
7
+ (cmd: string, args: string[]): Promise<{ code: number; stdout: string; stderr: string }>
8
+ }
9
+
10
+ export interface PrepullOptions {
11
+ engine: 'docker' | 'podman'
12
+ runtime: 'local' | 'docker' | 'podman'
13
+ /** Injectable for tests. Defaults to a child_process.execFile-backed exec. */
14
+ exec?: PrepullExec
15
+ /** When true, skip the inspect check and pull every image. */
16
+ refresh?: boolean
17
+ /** When true, the function returns immediately without touching the engine. */
18
+ skip?: boolean
19
+ log?: (line: string) => void
20
+ }
21
+
22
+ export interface PrepullableRegistry {
23
+ agentNames(): string[]
24
+ resolveSpawnImage(name: string): string | undefined
25
+ }
26
+
27
+ /**
28
+ * Walk the registry's unique resolved-image set, `<engine> image inspect`
29
+ * each one, and `<engine> pull` the ones not cached locally. Parallel and
30
+ * fail-fast: any pull exit ≠ 0 throws with the engine's stderr embedded.
31
+ * No-op when `opts.skip` or `opts.runtime === 'local'`.
32
+ */
33
+ export async function prepullImages(
34
+ registry: PrepullableRegistry,
35
+ opts: PrepullOptions,
36
+ ): Promise<void> {
37
+ if (opts.skip || opts.runtime === 'local') return
38
+ const exec = opts.exec ?? defaultExec
39
+ const log = opts.log ?? ((l: string) => console.log(l))
40
+
41
+ const images = new Set<string>()
42
+ for (const name of registry.agentNames()) {
43
+ const img = registry.resolveSpawnImage(name)
44
+ if (img) images.add(img)
45
+ }
46
+
47
+ const toPull: string[] = []
48
+ let cached = 0
49
+ if (opts.refresh) {
50
+ toPull.push(...images)
51
+ } else {
52
+ await Promise.all(
53
+ [...images].map(async (img) => {
54
+ const r = await exec(opts.engine, ['image', 'inspect', img])
55
+ if (r.code === 0) cached++
56
+ else toPull.push(img)
57
+ }),
58
+ )
59
+ }
60
+
61
+ log(
62
+ `[zooid] image prepull: ${images.size} unique images (${cached} cached, ${toPull.length} to pull)`,
63
+ )
64
+ if (toPull.length === 0) return
65
+
66
+ await Promise.all(
67
+ toPull.map(async (img) => {
68
+ log(`[zooid] ${img} pulling…`)
69
+ const start = Date.now()
70
+ const r = await exec(opts.engine, ['pull', img])
71
+ const secs = ((Date.now() - start) / 1000).toFixed(1)
72
+ if (r.code !== 0) {
73
+ throw new Error(
74
+ `image prepull failed for ${img}:\n${r.stderr.trim() || '(no stderr)'}`,
75
+ )
76
+ }
77
+ log(`[zooid] ${img} ✓ ${secs}s`)
78
+ }),
79
+ )
80
+ }
81
+
82
+ const defaultExec: PrepullExec = async (cmd, args) => {
83
+ try {
84
+ const { stdout, stderr } = await pExecFile(cmd, args, {
85
+ maxBuffer: 16 * 1024 * 1024,
86
+ })
87
+ return { code: 0, stdout, stderr }
88
+ } catch (err) {
89
+ const e = err as NodeJS.ErrnoException & {
90
+ code?: number | string
91
+ stdout?: string
92
+ stderr?: string
93
+ }
94
+ return {
95
+ code: typeof e.code === 'number' ? e.code : 1,
96
+ stdout: e.stdout ?? '',
97
+ stderr: e.stderr ?? String(e),
98
+ }
99
+ }
100
+ }