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/dist/web/index.html
CHANGED
|
@@ -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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
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/
|
|
41
|
-
"@zooid/
|
|
42
|
-
"@zooid/
|
|
43
|
-
"@zooid/
|
|
44
|
-
"@zooid/
|
|
45
|
-
"@zooid/transport-http": "^0.7.
|
|
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(
|
|
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:
|
|
45
|
+
watchWeb: flags.watchWeb as string | boolean | undefined,
|
|
43
46
|
})
|
|
44
47
|
})
|
|
45
48
|
|
package/src/build-registry.ts
CHANGED
|
@@ -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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
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
|
|
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
|
+
})
|
package/src/commands/dev.ts
CHANGED
|
@@ -66,9 +66,9 @@ export interface DevFlags {
|
|
|
66
66
|
adminPassword: string
|
|
67
67
|
installSignalHandlers?: boolean
|
|
68
68
|
foreground?: boolean
|
|
69
|
-
//
|
|
70
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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.
|