@zooid/core 0.7.0

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/src/config.ts ADDED
@@ -0,0 +1,712 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { parse } from 'yaml'
4
+ import type { AcpAgentSpec } from './acp-types.js'
5
+ import { isPreset } from '@zooid/acp-client'
6
+ import { interpolateEnv, interpolateString } from './env-interpolation.js'
7
+ import type {
8
+ AgentConfig,
9
+ CliFlags,
10
+ ContainerConfig,
11
+ HttpBinding,
12
+ HttpTransportConfig,
13
+ MatrixBinding,
14
+ MatrixTransportConfig,
15
+ TransportConfig,
16
+ ZooidConfig,
17
+ ZooidContainerConfig,
18
+ } from './types.js'
19
+
20
+ const AGENT_NAME_RE = /^[a-z][a-z0-9-]{0,31}$/
21
+ const MATRIX_USER_ID_RE = /^@[A-Za-z0-9._\-=/+]+:[A-Za-z0-9.\-]+$/
22
+ const MATRIX_USER_LOCALPART_RE = /^@[a-z0-9._=/+\-]+$/
23
+ const MATRIX_ROOM_IDENT_RE = /^[#!]/
24
+
25
+ function deriveServerName(userNamespace: string): string {
26
+ // user_namespace is a regex like `@.*:localhost`. The part after the first
27
+ // `:` is the server_name (strip a trailing `)` left over from a wrapped
28
+ // group like `@(.*):localhost)`).
29
+ const tail = userNamespace.split(':').slice(1).join(':').replace(/\\?\)?$/, '')
30
+ if (!tail) throw new Error(`user_namespace missing server_name: ${userNamespace}`)
31
+ return tail
32
+ }
33
+
34
+ const TRANSPORT_KINDS = ['matrix', 'http'] as const
35
+ type TransportKind = (typeof TRANSPORT_KINDS)[number]
36
+
37
+ function parseAcpBlock(name: string, raw: unknown): AcpAgentSpec {
38
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
39
+ throw new Error(`agents.${name}.acp: must be a mapping with either preset or command`)
40
+ }
41
+ const a = raw as Record<string, unknown>
42
+ const hasPreset = a.preset !== undefined
43
+ const hasCommand = a.command !== undefined
44
+ if (hasPreset && hasCommand) {
45
+ throw new Error(
46
+ `agents.${name}.acp: specify either preset or command, not both`,
47
+ )
48
+ }
49
+ if (!hasPreset && !hasCommand) {
50
+ throw new Error(
51
+ `agents.${name}.acp: must specify either preset or command`,
52
+ )
53
+ }
54
+ if (hasPreset) {
55
+ if (typeof a.preset !== 'string' || a.preset.length === 0) {
56
+ throw new Error(`agents.${name}.acp.preset: must be a non-empty string`)
57
+ }
58
+ if (!isPreset(a.preset)) {
59
+ throw new Error(
60
+ `agents.${name}.acp.preset: unknown preset "${a.preset}"`,
61
+ )
62
+ }
63
+ const out: { preset: string; model?: string } = { preset: a.preset }
64
+ if (a.model !== undefined) {
65
+ if (typeof a.model !== 'string' || a.model.trim().length === 0) {
66
+ throw new Error(`agents.${name}.acp.model: must be a non-empty string`)
67
+ }
68
+ out.model = a.model.trim()
69
+ }
70
+ return out as AcpAgentSpec
71
+ }
72
+ if (typeof a.command !== 'string' || a.command.length === 0) {
73
+ throw new Error(`agents.${name}.acp.command: must be a non-empty string`)
74
+ }
75
+ const args: string[] = []
76
+ if (a.args !== undefined) {
77
+ if (!Array.isArray(a.args)) {
78
+ throw new Error(`agents.${name}.acp.args: must be an array of strings`)
79
+ }
80
+ for (const v of a.args) {
81
+ if (typeof v !== 'string') {
82
+ throw new Error(`agents.${name}.acp.args[]: must be a string`)
83
+ }
84
+ args.push(v)
85
+ }
86
+ }
87
+ return { command: a.command, args } as AcpAgentSpec
88
+ }
89
+
90
+ function parseApprovalTimeout(name: string, raw: unknown): number {
91
+ if (raw === undefined) return 0
92
+ if (raw === 0 || raw === '0') return 0
93
+ if (typeof raw !== 'string') {
94
+ throw new Error(
95
+ `agents.${name}.approval_timeout: must be a duration like "1h", "15m", "30s", or 0 to disable (got ${JSON.stringify(raw)})`,
96
+ )
97
+ }
98
+ const m = /^(\d+)(s|m|h)$/.exec(raw)
99
+ if (!m) {
100
+ throw new Error(
101
+ `agents.${name}.approval_timeout: "${raw}" is not a valid duration (use "<n>s", "<n>m", or "<n>h")`,
102
+ )
103
+ }
104
+ const n = Number(m[1])
105
+ switch (m[2]) {
106
+ case 's':
107
+ return n * 1000
108
+ case 'm':
109
+ return n * 60_000
110
+ case 'h':
111
+ return n * 60 * 60_000
112
+ }
113
+ throw new Error('unreachable')
114
+ }
115
+
116
+ function parseAgentContainer(
117
+ name: string,
118
+ raw: unknown,
119
+ processEnv: NodeJS.ProcessEnv,
120
+ ): ContainerConfig {
121
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
122
+ throw new Error(`agents.${name}.container must be a mapping`)
123
+ }
124
+ const r = raw as Record<string, unknown>
125
+ const out: ContainerConfig = {}
126
+ if (r.image !== undefined) {
127
+ if (typeof r.image !== 'string' || r.image.length === 0) {
128
+ throw new Error(`agents.${name}.container.image must be a non-empty string`)
129
+ }
130
+ out.image = r.image
131
+ }
132
+ if (r.env !== undefined && r.env !== null) {
133
+ if (typeof r.env !== 'object' || Array.isArray(r.env)) {
134
+ throw new Error(`agents.${name}.container.env must be a mapping`)
135
+ }
136
+ const rawEnv = r.env as Record<string, unknown>
137
+ const stringEnv: Record<string, string> = {}
138
+ for (const [k, v] of Object.entries(rawEnv)) {
139
+ if (typeof v !== 'string') {
140
+ throw new Error(
141
+ `agents.${name}.container.env.${k}: must be a string (got ${typeof v})`,
142
+ )
143
+ }
144
+ stringEnv[k] = v
145
+ }
146
+ out.env = interpolateEnv(stringEnv, processEnv, `agents.${name}.container.env`)
147
+ }
148
+ return out
149
+ }
150
+
151
+ function parseZooidContainer(raw: unknown): ZooidContainerConfig {
152
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
153
+ throw new Error('container must be a mapping')
154
+ }
155
+ const r = raw as Record<string, unknown>
156
+ const out: ZooidContainerConfig = {}
157
+ if (r.env !== undefined) {
158
+ throw new Error(
159
+ "Top-level 'container.env' is not supported (workforce-level env defaults are out of scope; see [ZOD043]). " +
160
+ 'Move env entries to per-agent container.env.',
161
+ )
162
+ }
163
+ if (r.image !== undefined) {
164
+ if (typeof r.image !== 'string' || r.image.length === 0) {
165
+ throw new Error('container.image must be a non-empty string')
166
+ }
167
+ out.image = r.image
168
+ }
169
+ return out
170
+ }
171
+
172
+ function parseTransports(
173
+ raw: unknown,
174
+ processEnv: NodeJS.ProcessEnv,
175
+ ): Record<string, TransportConfig> {
176
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
177
+ throw new Error('transports: must be a mapping with at least one entry')
178
+ }
179
+ const r = raw as Record<string, unknown>
180
+ const names = Object.keys(r)
181
+ if (names.length === 0) {
182
+ throw new Error('transports: at least one transport must be declared')
183
+ }
184
+ const out: Record<string, TransportConfig> = {}
185
+ for (const name of names) {
186
+ out[name] = parseTransport(name, r[name], processEnv)
187
+ }
188
+ const matrixEntries = Object.entries(out).filter(
189
+ (e): e is [string, MatrixTransportConfig] => e[1].type === 'matrix',
190
+ )
191
+ if (matrixEntries.length === 1) {
192
+ const [, mt] = matrixEntries[0]!
193
+ if (mt.as_token === '__INFER__') {
194
+ const v = interpolateString('${MATRIX_AS_TOKEN}', processEnv)
195
+ if (v.length === 0) {
196
+ throw new Error(
197
+ 'transports.matrix.as_token: env var MATRIX_AS_TOKEN is not set ' +
198
+ '(set it in your shell or .env, or declare as_token explicitly in zooid.yaml)',
199
+ )
200
+ }
201
+ mt.as_token = v
202
+ }
203
+ if (mt.hs_token === '__INFER__') {
204
+ const v = interpolateString('${MATRIX_HS_TOKEN}', processEnv)
205
+ if (v.length === 0) {
206
+ throw new Error(
207
+ 'transports.matrix.hs_token: env var MATRIX_HS_TOKEN is not set ' +
208
+ '(set it in your shell or .env, or declare hs_token explicitly in zooid.yaml)',
209
+ )
210
+ }
211
+ mt.hs_token = v
212
+ }
213
+ } else if (matrixEntries.length > 1) {
214
+ for (const [tname, mt] of matrixEntries) {
215
+ if (mt.as_token === '__INFER__' || mt.hs_token === '__INFER__') {
216
+ throw new Error(
217
+ `transports.${tname}: as_token / hs_token must be set explicitly when more than one matrix transport is declared ` +
218
+ `(no sensible default env var across multiple transports)`,
219
+ )
220
+ }
221
+ }
222
+ }
223
+ return out
224
+ }
225
+
226
+ function parseTransport(
227
+ name: string,
228
+ raw: unknown,
229
+ processEnv: NodeJS.ProcessEnv,
230
+ ): TransportConfig {
231
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
232
+ throw new Error(`transports.${name}: must be a mapping`)
233
+ }
234
+ const r = raw as Record<string, unknown>
235
+ const inferredType =
236
+ r.type ?? (name === 'matrix' || name === 'http' ? name : undefined)
237
+ if (inferredType !== 'matrix' && inferredType !== 'http') {
238
+ throw new Error(
239
+ `transports.${name}.type must be "matrix" or "http" (got ${JSON.stringify(r.type)})`,
240
+ )
241
+ }
242
+ if (inferredType === 'matrix') {
243
+ if (r.sender_localpart === undefined) r.sender_localpart = 'zooid'
244
+ if (r.user_namespace === undefined && typeof r.homeserver === 'string') {
245
+ try {
246
+ const host = new URL(
247
+ interpolateString(r.homeserver as string, processEnv),
248
+ ).hostname
249
+ if (host) r.user_namespace = `@.*:${host}`
250
+ } catch {
251
+ // Fall through: the required-fields loop will fire its "must be a
252
+ // non-empty string" error, which is the same message today's parser
253
+ // would emit for a bad homeserver URL.
254
+ }
255
+ }
256
+ if (r.as_token === undefined) r.as_token = '__INFER__'
257
+ if (r.hs_token === undefined) r.hs_token = '__INFER__'
258
+ const fields = [
259
+ 'homeserver',
260
+ 'as_token',
261
+ 'hs_token',
262
+ 'sender_localpart',
263
+ 'user_namespace',
264
+ ] as const
265
+ for (const f of fields) {
266
+ if (typeof r[f] !== 'string' || (r[f] as string).length === 0) {
267
+ throw new Error(`transports.${name}.${f} must be a non-empty string`)
268
+ }
269
+ }
270
+ const out: MatrixTransportConfig = {
271
+ type: 'matrix',
272
+ homeserver: interpolateString(r.homeserver as string, processEnv),
273
+ as_token: interpolateString(r.as_token as string, processEnv),
274
+ hs_token: interpolateString(r.hs_token as string, processEnv),
275
+ sender_localpart: r.sender_localpart as string,
276
+ user_namespace: r.user_namespace as string,
277
+ }
278
+ if (r.port !== undefined) {
279
+ if (!Number.isInteger(r.port)) {
280
+ throw new Error(
281
+ `transports.${name}.port must be an integer (got ${JSON.stringify(r.port)})`,
282
+ )
283
+ }
284
+ out.port = r.port as number
285
+ }
286
+ if (r.space !== undefined) {
287
+ if (typeof r.space !== 'string' || r.space.length === 0) {
288
+ throw new Error(
289
+ `transports.${name}.space must be a non-empty string (got ${JSON.stringify(r.space)})`,
290
+ )
291
+ }
292
+ out.space = r.space
293
+ }
294
+ return out
295
+ }
296
+ // type: 'http'
297
+ const port = (r.port ?? 8080) as number
298
+ if (!Number.isInteger(port)) {
299
+ throw new Error(`transports.${name}.port must be an integer (got ${JSON.stringify(port)})`)
300
+ }
301
+ return { type: 'http', port }
302
+ }
303
+
304
+ function parseTransportBinding(
305
+ name: string,
306
+ entry: Record<string, unknown>,
307
+ transports: Record<string, TransportConfig>,
308
+ ): { matrix?: MatrixBinding; http?: HttpBinding } {
309
+ const present = TRANSPORT_KINDS.filter(
310
+ (k) => entry[k] !== undefined && entry[k] !== null,
311
+ )
312
+ if (present.length === 0) {
313
+ throw new Error(
314
+ `agents.${name}: must declare exactly one transport-kind block ` +
315
+ `(e.g. 'matrix:' or 'http:'). Saw none.`,
316
+ )
317
+ }
318
+ if (present.length > 1) {
319
+ throw new Error(
320
+ `agents.${name}: must declare exactly one transport-kind block. ` +
321
+ `Saw: ${present.join(', ')}. To run "the same agent" on two transports, ` +
322
+ `declare two agents (e.g. ${name}-matrix and ${name}-http).`,
323
+ )
324
+ }
325
+ const kind = present[0] as TransportKind
326
+ const blockRaw = entry[kind]
327
+ if (typeof blockRaw !== 'object' || blockRaw === null || Array.isArray(blockRaw)) {
328
+ throw new Error(`agents.${name}.${kind}: must be a mapping`)
329
+ }
330
+ const block = blockRaw as Record<string, unknown>
331
+ let refName: string
332
+ if (typeof block.transport === 'string' && block.transport.length > 0) {
333
+ refName = block.transport
334
+ } else {
335
+ const matches = Object.entries(transports).filter(
336
+ ([, t]) => t.type === kind,
337
+ )
338
+ if (matches.length === 0) {
339
+ throw new Error(
340
+ `agents.${name}.${kind}: no transport of type ${kind} declared (add one under transports:)`,
341
+ )
342
+ }
343
+ if (matches.length > 1) {
344
+ throw new Error(
345
+ `agents.${name}.${kind}.transport is required when more than one ${kind} transport is declared ` +
346
+ `(saw: ${matches.map(([n]) => n).join(', ')})`,
347
+ )
348
+ }
349
+ refName = matches[0]![0]
350
+ }
351
+ const refTransport = transports[refName]
352
+ if (!refTransport) {
353
+ throw new Error(
354
+ `agents.${name}.${kind}.transport "${refName}" is not declared in transports`,
355
+ )
356
+ }
357
+ if (refTransport.type !== kind) {
358
+ throw new Error(
359
+ `agents.${name}.${kind} references transport "${refName}" of type: ${refTransport.type}. ` +
360
+ `Block name and referenced transport's type must match.`,
361
+ )
362
+ }
363
+
364
+ if (kind === 'matrix') {
365
+ if (refTransport.type !== 'matrix') {
366
+ throw new Error(`agents.${name}.matrix: transport must be matrix`)
367
+ }
368
+ const serverName = deriveServerName(refTransport.user_namespace)
369
+
370
+ const rawUserId =
371
+ typeof block.user_id === 'string' && block.user_id.length > 0
372
+ ? block.user_id
373
+ : `@${name}`
374
+ let userId = rawUserId
375
+ if (!userId.includes(':') && MATRIX_USER_LOCALPART_RE.test(userId)) {
376
+ userId = `${userId}:${serverName}`
377
+ }
378
+ if (!MATRIX_USER_ID_RE.test(userId)) {
379
+ throw new Error(
380
+ `agents.${name}.matrix.user_id must look like @localpart:server (got ${JSON.stringify(block.user_id)})`,
381
+ )
382
+ }
383
+
384
+ if (!Array.isArray(block.rooms) || block.rooms.length === 0) {
385
+ throw new Error(`agents.${name}.matrix.rooms is required and must be a non-empty array`)
386
+ }
387
+ const rooms: string[] = []
388
+ for (const r of block.rooms) {
389
+ if (typeof r !== 'string' || r.length === 0) {
390
+ throw new Error(`agents.${name}.matrix.rooms[] must be a non-empty string`)
391
+ }
392
+ if (!MATRIX_ROOM_IDENT_RE.test(r)) {
393
+ throw new Error(
394
+ `agents.${name}.matrix.rooms[] must start with '#' or '!' (got ${JSON.stringify(r)})`,
395
+ )
396
+ }
397
+ rooms.push(r.includes(':') ? r : `${r}:${serverName}`)
398
+ }
399
+
400
+ let displayName: string | undefined
401
+ if (block.display_name !== undefined) {
402
+ if (typeof block.display_name !== 'string') {
403
+ throw new Error(
404
+ `agents.${name}.matrix.display_name must be a string (got ${JSON.stringify(block.display_name)})`,
405
+ )
406
+ }
407
+ const trimmed = block.display_name.trim()
408
+ if (trimmed.length === 0) {
409
+ throw new Error(`agents.${name}.matrix.display_name must be non-empty after trim`)
410
+ }
411
+ if (trimmed.length > 256) {
412
+ throw new Error(`agents.${name}.matrix.display_name must be 256 characters or fewer`)
413
+ }
414
+ displayName = trimmed
415
+ }
416
+
417
+ const tr = block.trigger ?? 'mention'
418
+ if (tr !== 'mention' && tr !== 'any') {
419
+ throw new Error(
420
+ `agents.${name}.matrix.trigger must be "mention" or "any" (got ${JSON.stringify(tr)})`,
421
+ )
422
+ }
423
+ const matrix: MatrixBinding = {
424
+ transport: refName,
425
+ user_id: userId,
426
+ rooms,
427
+ trigger: tr,
428
+ }
429
+ if (displayName !== undefined) matrix.display_name = displayName
430
+ return { matrix }
431
+ }
432
+ // kind === 'http'
433
+ return { http: { transport: refName } }
434
+ }
435
+
436
+ function parseAgents(
437
+ raw: unknown,
438
+ runtime: 'local' | 'docker' | 'podman',
439
+ transports: Record<string, TransportConfig>,
440
+ daemonHooks: { pre_turn?: string; post_turn?: string },
441
+ processEnv: NodeJS.ProcessEnv,
442
+ ): Record<string, AgentConfig> {
443
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
444
+ throw new Error('agents: must be a mapping')
445
+ }
446
+ const entries = Object.entries(raw as Record<string, unknown>)
447
+ if (entries.length === 0) {
448
+ throw new Error('agents: must have at least one entry')
449
+ }
450
+ const result: Record<string, AgentConfig> = {}
451
+ for (const [name, val] of entries) {
452
+ if (!AGENT_NAME_RE.test(name)) {
453
+ throw new Error(`agents.${name}: name must match /^[a-z][a-z0-9-]{0,31}$/`)
454
+ }
455
+ if (!val || typeof val !== 'object' || Array.isArray(val)) {
456
+ throw new Error(`agents.${name} must be a mapping`)
457
+ }
458
+ const entry = val as Record<string, unknown>
459
+ let workdir: string
460
+ if (entry.workdir === undefined) {
461
+ workdir = `./agents/${name}`
462
+ } else if (typeof entry.workdir !== 'string' || entry.workdir.length === 0) {
463
+ throw new Error(`agents.${name}.workdir must be a non-empty string`)
464
+ } else {
465
+ workdir = entry.workdir
466
+ }
467
+
468
+ if (entry.adapter !== undefined) {
469
+ throw new Error(
470
+ `agents.${name}: "adapter" is no longer supported; use "acp" — see epics/003-ZOD025-acp-migration/SPEC.md`,
471
+ )
472
+ }
473
+
474
+ if (entry.acp === undefined) {
475
+ throw new Error(`agents.${name}: missing required "acp" block`)
476
+ }
477
+ const acp = parseAcpBlock(name, entry.acp)
478
+ const approval_timeout_ms = parseApprovalTimeout(name, entry.approval_timeout)
479
+
480
+ // Reject legacy fields up front with pointers to [ZOD043].
481
+ if (entry.docker !== undefined) {
482
+ throw new Error(
483
+ `agents.${name}.docker is no longer supported. ` +
484
+ `Move 'image' to agents.${name}.container.image, and 'forward_env' entries to ` +
485
+ `agents.${name}.container.env with \${VAR} interpolation. See [ZOD043].`,
486
+ )
487
+ }
488
+ if (typeof entry.transport === 'string') {
489
+ throw new Error(
490
+ `agents.${name}.transport (string) is no longer supported at the agent level. ` +
491
+ `Move it inside a transport-kind block, e.g.:\n` +
492
+ ` matrix:\n transport: <name>\n user_id: "@..."\n rooms: [...]\n` +
493
+ `See [ZOD043].`,
494
+ )
495
+ }
496
+ for (const k of ['matrix_user_id', 'rooms', 'trigger'] as const) {
497
+ if (entry[k] !== undefined) {
498
+ throw new Error(
499
+ `agents.${name}.${k} is no longer supported as a flat field. ` +
500
+ `Move it inside a 'matrix:' block on the agent. See [ZOD043].`,
501
+ )
502
+ }
503
+ }
504
+
505
+ const agentHooks: AgentConfig['hooks'] = {}
506
+ if (daemonHooks.pre_turn !== undefined) agentHooks.pre_turn = daemonHooks.pre_turn
507
+ if (daemonHooks.post_turn !== undefined) agentHooks.post_turn = daemonHooks.post_turn
508
+ if (entry.hooks !== undefined && entry.hooks !== null) {
509
+ if (typeof entry.hooks !== 'object' || Array.isArray(entry.hooks)) {
510
+ throw new Error(`agents.${name}.hooks must be a mapping`)
511
+ }
512
+ const h = entry.hooks as Record<string, unknown>
513
+ if (Object.prototype.hasOwnProperty.call(h, 'pre_turn')) {
514
+ if (typeof h.pre_turn === 'string') agentHooks.pre_turn = h.pre_turn
515
+ else delete agentHooks.pre_turn
516
+ }
517
+ if (Object.prototype.hasOwnProperty.call(h, 'post_turn')) {
518
+ if (typeof h.post_turn === 'string') agentHooks.post_turn = h.post_turn
519
+ else delete agentHooks.post_turn
520
+ }
521
+ }
522
+
523
+ let containerBlock: ContainerConfig | undefined
524
+ if (entry.container !== undefined && entry.container !== null) {
525
+ if (runtime === 'local') {
526
+ throw new Error(
527
+ `agents.${name}.container is only valid when runtime is 'docker' or 'podman'. ` +
528
+ `runtime: local spawns agents as host child processes — there is no container, ` +
529
+ `so 'image' is inert and 'env' would silently lie (the agent inherits the daemon's ` +
530
+ `full process.env regardless).`,
531
+ )
532
+ }
533
+ containerBlock = parseAgentContainer(name, entry.container, processEnv)
534
+ }
535
+
536
+ const binding = parseTransportBinding(name, entry, transports)
537
+
538
+ const agentCfg: AgentConfig = {
539
+ name,
540
+ workdir,
541
+ hooks: agentHooks,
542
+ acp,
543
+ approval_timeout_ms,
544
+ }
545
+ if (containerBlock) agentCfg.container = containerBlock
546
+ if (binding.matrix) agentCfg.matrix = binding.matrix
547
+ if (binding.http) agentCfg.http = binding.http
548
+ result[name] = agentCfg
549
+ }
550
+ return result
551
+ }
552
+
553
+ function parseRuntime(raw: unknown): 'local' | 'docker' | 'podman' {
554
+ const runtime = raw ?? 'docker'
555
+ if (runtime !== 'local' && runtime !== 'docker' && runtime !== 'podman') {
556
+ throw new Error(`runtime must be "local", "docker", or "podman" (got "${runtime}")`)
557
+ }
558
+ return runtime
559
+ }
560
+
561
+ function zooidHooks(raw: Record<string, unknown>): { pre_turn?: string; post_turn?: string } {
562
+ const out: { pre_turn?: string; post_turn?: string } = {}
563
+ if (raw.hooks && typeof raw.hooks === 'object') {
564
+ const h = raw.hooks as Record<string, unknown>
565
+ if (typeof h.pre_turn === 'string') out.pre_turn = h.pre_turn
566
+ if (typeof h.post_turn === 'string') out.post_turn = h.post_turn
567
+ }
568
+ return out
569
+ }
570
+
571
+ export function loadZooidConfig(yamlText: string): ZooidConfig {
572
+ const raw = parse(yamlText) ?? {}
573
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
574
+ throw new Error('zooid.yaml must be a YAML object')
575
+ }
576
+ const r = raw as Record<string, unknown>
577
+
578
+ if (r.transport !== undefined) {
579
+ throw new Error(
580
+ 'zooid.yaml: top-level "transport:" is no longer supported; declare entries under "transports:" instead',
581
+ )
582
+ }
583
+ if (r.matrix !== undefined) {
584
+ throw new Error(
585
+ 'zooid.yaml: top-level "matrix:" is no longer supported; move it under "transports.<name>: { type: matrix, ... }"',
586
+ )
587
+ }
588
+ if (r.workdir !== undefined) {
589
+ throw new Error(
590
+ 'top-level workdir is not supported; define agents: { <name>: { workdir: ... } } instead',
591
+ )
592
+ }
593
+ if (r.docker !== undefined) {
594
+ throw new Error(
595
+ "Top-level 'docker' block is no longer supported. " +
596
+ "Move 'image' to top-level 'container.image'. See [ZOD043].",
597
+ )
598
+ }
599
+ if (r.agents === undefined) {
600
+ throw new Error('agents: is required — zooid.yaml must define at least one agent')
601
+ }
602
+
603
+ const runtime = parseRuntime(r.runtime)
604
+ const processEnv = process.env
605
+ const transports = parseTransports(r.transports, processEnv)
606
+ const hooks = zooidHooks(r)
607
+ const agents = parseAgents(r.agents, runtime, transports, hooks, processEnv)
608
+
609
+ const cfg: ZooidConfig = {
610
+ runtime,
611
+ transports,
612
+ agents,
613
+ hooks,
614
+ }
615
+ if (r.container !== undefined && r.container !== null) {
616
+ if (runtime === 'local') {
617
+ throw new Error(
618
+ "container is only valid when runtime is 'docker' or 'podman'. " +
619
+ 'runtime: local does not run agents in containers; image is ignored. See [ZOD043].',
620
+ )
621
+ }
622
+ cfg.container = parseZooidContainer(r.container)
623
+ }
624
+ return cfg
625
+ }
626
+
627
+ export function findTransport(
628
+ cfg: ZooidConfig,
629
+ name: string,
630
+ ): TransportConfig | undefined {
631
+ return cfg.transports[name]
632
+ }
633
+
634
+ export function findMatrixTransport(
635
+ cfg: ZooidConfig,
636
+ ): { name: string; transport: MatrixTransportConfig } | null {
637
+ const matrices = Object.entries(cfg.transports).filter(
638
+ (e): e is [string, MatrixTransportConfig] => e[1].type === 'matrix',
639
+ )
640
+ if (matrices.length === 0) return null
641
+ if (matrices.length > 1) {
642
+ throw new Error(
643
+ `findMatrixTransport: multiple matrix transports declared (${matrices
644
+ .map((m) => m[0])
645
+ .join(', ')}). Per-agent matrix routing is not supported yet.`,
646
+ )
647
+ }
648
+ const [name, transport] = matrices[0]!
649
+ return { name, transport }
650
+ }
651
+
652
+ export function findHttpTransport(
653
+ cfg: ZooidConfig,
654
+ ): { name: string; transport: HttpTransportConfig } | null {
655
+ const https = Object.entries(cfg.transports).filter(
656
+ (e): e is [string, HttpTransportConfig] => e[1].type === 'http',
657
+ )
658
+ if (https.length === 0) return null
659
+ if (https.length > 1) {
660
+ throw new Error(
661
+ `findHttpTransport: multiple http transports declared (${https
662
+ .map((h) => h[0])
663
+ .join(', ')}). Per-agent http routing is not supported yet.`,
664
+ )
665
+ }
666
+ const [name, transport] = https[0]!
667
+ return { name, transport }
668
+ }
669
+
670
+ export interface FoundConfigFile {
671
+ path: string
672
+ }
673
+
674
+ export function findConfigFile(cwd: string): FoundConfigFile | null {
675
+ const z = join(cwd, 'zooid.yaml')
676
+ if (existsSync(z)) return { path: z }
677
+ const legacy = join(cwd, 'workforce.yaml')
678
+ if (existsSync(legacy)) {
679
+ throw new Error(
680
+ `workforce.yaml is no longer supported. Rename it to zooid.yaml. See [ZOD045].`,
681
+ )
682
+ }
683
+ return null
684
+ }
685
+
686
+ export function mergeCliFlags(base: ZooidConfig, flags: CliFlags): ZooidConfig {
687
+ const runtimeFlag = flags.runtime as 'local' | 'docker' | 'podman' | undefined
688
+ if (
689
+ runtimeFlag !== undefined &&
690
+ runtimeFlag !== 'local' &&
691
+ runtimeFlag !== 'docker' &&
692
+ runtimeFlag !== 'podman'
693
+ ) {
694
+ throw new Error(`runtime must be "local", "docker", or "podman" (got "${flags.runtime}")`)
695
+ }
696
+ const runtime = runtimeFlag ?? base.runtime
697
+ const merged: ZooidConfig = {
698
+ runtime,
699
+ transports: base.transports,
700
+ agents: base.agents,
701
+ hooks: { ...base.hooks },
702
+ }
703
+ if (runtime === 'docker' || runtime === 'podman') {
704
+ const image = flags.image ?? base.container?.image
705
+ if (image !== undefined) {
706
+ merged.container = { image }
707
+ } else if (base.container !== undefined) {
708
+ merged.container = { ...base.container }
709
+ }
710
+ }
711
+ return merged
712
+ }