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.
- package/dist/bin.js +106 -12
- package/dist/bin.js.map +1 -1
- package/dist/{chunk-N5POSZX5.js → chunk-SUPTPSN3.js} +629 -195
- package/dist/chunk-SUPTPSN3.js.map +1 -0
- package/dist/index.d.ts +27 -3
- package/dist/index.js +1 -1
- package/dist/web/assets/index-C2MAIew3.css +1 -0
- package/dist/web/assets/{index-JZMMlqDP.js → index-CqfWXqvO.js} +1 -1
- package/dist/web/assets/{index-BT_v3DKu.js → index-Cz5fEZBc.js} +51 -51
- package/dist/web/assets/{reaction-picker-emoji-Kh7emgFa.js → reaction-picker-emoji-cME0vOsX.js} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +8 -7
- package/src/bin.ts +5 -2
- package/src/build-registry.ts +211 -10
- package/src/build-registry.zod044.test.ts +261 -0
- package/src/commands/dev.ts +15 -7
- package/src/commands/init/generators.ts +5 -0
- package/src/commands/status.ts +7 -2
- package/src/daemon/start-daemon.ts +28 -1
- package/src/prepull-images.test.ts +149 -0
- package/src/prepull-images.ts +100 -0
- package/dist/chunk-N5POSZX5.js.map +0 -1
- package/dist/web/assets/index-DJTghnF-.css +0 -1
package/src/commands/status.ts
CHANGED
|
@@ -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)
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
type DaemonSocketHandle,
|
|
30
30
|
} from '@zooid/context-mcp'
|
|
31
31
|
import { buildAcpRegistry } from '../build-registry.js'
|
|
32
|
+
import { prepullImages } from '../prepull-images.js'
|
|
32
33
|
|
|
33
34
|
export interface StartDaemonOpts {
|
|
34
35
|
configPath?: string
|
|
@@ -45,6 +46,17 @@ export interface StartDaemonOpts {
|
|
|
45
46
|
* daemon restarts.
|
|
46
47
|
*/
|
|
47
48
|
agentsDir?: string
|
|
49
|
+
/** Skip the startup image-prepull pass entirely. */
|
|
50
|
+
noPrepull?: boolean
|
|
51
|
+
/** Force a re-pull of every resolved image at startup (no inspect check). */
|
|
52
|
+
refreshImages?: boolean
|
|
53
|
+
/**
|
|
54
|
+
* Per-line progress hook for the image-prepull pass. Called once per
|
|
55
|
+
* top-level summary plus once per image (start + completion). Used by
|
|
56
|
+
* `zooid dev` to surface pull progress under the "Start daemon" listr
|
|
57
|
+
* task instead of leaving the user staring at an unmoving spinner.
|
|
58
|
+
*/
|
|
59
|
+
prepullLog?: (line: string) => void
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
export interface DaemonHandle {
|
|
@@ -75,7 +87,8 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
75
87
|
const cwd = opts.cwd ?? process.cwd()
|
|
76
88
|
const found = opts.configPath ? { path: opts.configPath } : findConfigFile(cwd)
|
|
77
89
|
if (!found) throw new Error('zooid.yaml is required')
|
|
78
|
-
const
|
|
90
|
+
const configDir = dirname(found.path)
|
|
91
|
+
const base = loadZooidConfig(readFileSync(found.path, 'utf8'), { configDir })
|
|
79
92
|
const config = mergeCliFlags(base, opts.cliFlags ?? {})
|
|
80
93
|
|
|
81
94
|
const approvals = new ApprovalCorrelator()
|
|
@@ -95,15 +108,29 @@ export async function startDaemon(opts: StartDaemonOpts = {}): Promise<DaemonHan
|
|
|
95
108
|
console.warn('[context] daemon socket startup failed; zooid-context MCP disabled:', err)
|
|
96
109
|
}
|
|
97
110
|
|
|
111
|
+
const dataDir = opts.agentsDir ? dirname(opts.agentsDir) : undefined
|
|
98
112
|
const registry = buildAcpRegistry(config, {
|
|
99
113
|
approvals,
|
|
100
114
|
onTap: opts.onTap,
|
|
101
115
|
agentsDir: opts.agentsDir,
|
|
102
116
|
contextSpawnRegistry: contextSocket ? contextSpawnRegistry : undefined,
|
|
103
117
|
daemonSockPath: contextSocket ? daemonSockPath : undefined,
|
|
118
|
+
configDir,
|
|
119
|
+
dataDir,
|
|
120
|
+
daemonHome: process.env.HOME,
|
|
104
121
|
})
|
|
105
122
|
const agentNames = Object.keys(config.agents)
|
|
106
123
|
|
|
124
|
+
if (config.runtime !== 'local') {
|
|
125
|
+
await prepullImages(registry, {
|
|
126
|
+
engine: config.runtime === 'podman' ? 'podman' : 'docker',
|
|
127
|
+
runtime: config.runtime,
|
|
128
|
+
skip: opts.noPrepull ?? process.env.ZOOID_NO_PREPULL === '1',
|
|
129
|
+
refresh: opts.refreshImages ?? process.env.ZOOID_REFRESH_IMAGES === '1',
|
|
130
|
+
log: opts.prepullLog,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
107
134
|
console.log(
|
|
108
135
|
`[context] socket=${daemonSockPath} ` +
|
|
109
136
|
`status=${contextSocket ? 'listening' : 'disabled'} ` +
|
|
@@ -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
|
+
}
|