@ours.network/fleet 1.1.0-nightly.2 → 1.1.0-nightly.4

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.
Files changed (43) hide show
  1. package/README.md +14 -22
  2. package/dist/application/role-creation-service.d.ts +2 -21
  3. package/dist/application/role-creation-service.js +15 -71
  4. package/dist/application/task-room-service.js +7 -2
  5. package/dist/briefing.js +3 -3
  6. package/dist/build-info.json +4 -4
  7. package/dist/cli.js +40 -33
  8. package/dist/config.d.ts +37 -0
  9. package/dist/config.js +40 -12
  10. package/dist/docs.d.ts +1 -1
  11. package/dist/docs.js +19 -30
  12. package/dist/doctor.js +3 -3
  13. package/dist/fleet-proxy.js +30 -8
  14. package/dist/harness/agent-session.d.ts +5 -0
  15. package/dist/harness/claude-code-session.d.ts +2 -0
  16. package/dist/harness/claude-code-session.js +4 -0
  17. package/dist/harness/claude-code.js +2 -0
  18. package/dist/harness/codex-session.d.ts +2 -0
  19. package/dist/harness/codex-session.js +4 -0
  20. package/dist/harness/codex.js +2 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/model-env.d.ts +2 -2
  24. package/dist/model-env.js +1 -1
  25. package/dist/owner-channel/commands.js +2 -1
  26. package/dist/rooms-tasks/cli.js +2 -1
  27. package/dist/rooms-tasks/config.js +24 -9
  28. package/dist/rooms-tasks/provision.js +71 -36
  29. package/dist/rooms-tasks/templates.js +6 -6
  30. package/dist/rooms-tasks/types.d.ts +8 -15
  31. package/dist/rooms-tasks/types.js +1 -4
  32. package/dist/session/acp.d.ts +5 -0
  33. package/dist/session/acp.js +27 -3
  34. package/dist/spawn.d.ts +6 -31
  35. package/dist/spawn.js +45 -233
  36. package/dist/watchdog/config.d.ts +10 -7
  37. package/dist/watchdog/config.js +29 -19
  38. package/dist/watchdog/run.js +4 -13
  39. package/dist/web/topology-promote.js +8 -1
  40. package/dist/web-app/assets/index-BbTc5KtZ.js +10 -0
  41. package/dist/web-app/index.html +1 -1
  42. package/package.json +1 -1
  43. package/dist/web-app/assets/index-BbE9EX6n.js +0 -10
package/dist/spawn.js CHANGED
@@ -4,8 +4,7 @@ import { join } from 'node:path';
4
4
  import { parse, stringify } from 'yaml';
5
5
  import { agentDir, defaultConfigPath } from './paths.js';
6
6
  import { validateIsolationConfig } from './isolation/policy.js';
7
- import { loadConfig, resolveAuthProxy, resolveModelChain, resolveMonitorConfig, resolveOwnerChannelConfig, resolvePermissions, resolveRoleModel, resolveWorklogPolicy, splitRootFor, validateMonitorConfig, } from './config.js';
8
- import { resolveRoleModelEnv } from './model-env.js';
7
+ import { loadConfig, findRole, splitRootFor, validateMonitorConfig, } from './config.js';
9
8
  import { applyRole, up } from './ops.js';
10
9
  import { START_STAGGER_FILE } from './runner.js';
11
10
  import { buildProvenance, daemonIdentityProvisioner, ensureIdentity, provenanceOf, withCreationTransaction, writeProvenance, writeRoleFile, } from './creation.js';
@@ -13,86 +12,46 @@ import { VERSION } from './version.js';
13
12
  import { recordGeneratedAgentSource } from './generated-agent-source.js';
14
13
  import './harness/claude-code.js';
15
14
  import './harness/codex.js';
16
- import { getAdapter } from './harness/registry.js';
17
15
  import { archiveTempState, makeTempSupervisorLauncher, prepareTempSupervisor, reclaimStaleTempState, } from './temp-lifecycle.js';
18
16
  /**
19
17
  * The provenance record written by the most recent spawn in this process, so
20
18
  * the CLI can print the same summary it persisted rather than rebuilding it.
21
19
  */
22
20
  export let lastProvenance;
23
- export function profileValues(o) {
24
- if (o.bio !== undefined && o.bioFile)
25
- throw new Error('bio and bioFile are mutually exclusive');
26
- if (o.persona !== undefined && o.personaFile)
27
- throw new Error('persona and personaFile are mutually exclusive');
21
+ export function agentDefinitionFromSpawn(o) {
22
+ if (o.agentDefinition) {
23
+ if (o.brain !== undefined || o.role !== undefined || o.cwd !== undefined
24
+ || o.coordinator !== undefined || o.approval !== undefined || o.filesystem !== undefined
25
+ || o.unattended !== undefined || o.isolationFile !== undefined || o.monitorConfig !== undefined)
26
+ throw new Error('canonical agentDefinition conflicts with separate Agent fields');
27
+ const definition = structuredClone(o.agentDefinition);
28
+ if (o.identity)
29
+ definition.identity = o.identity;
30
+ return definition;
31
+ }
32
+ if (!o.role)
33
+ throw new Error('--role is required (declared ID or inline mapping)');
34
+ if (!o.brain)
35
+ throw new Error('--brain is required (declared ID or inline mapping)');
36
+ const permissions = o.approval || o.filesystem || o.unattended ? {
37
+ ...(o.approval ? { approval: o.approval } : {}),
38
+ ...(o.filesystem ? { filesystem: o.filesystem } : {}),
39
+ ...(o.unattended ? { unattended: o.unattended } : {}),
40
+ } : undefined;
28
41
  return {
29
- bio: o.bio !== undefined ? o.bio.trim()
30
- : o.bioFile ? readFileSync(o.bioFile, 'utf8').trim() : undefined,
31
- persona: o.persona !== undefined ? o.persona.trim()
32
- : o.personaFile ? readFileSync(o.personaFile, 'utf8').trim() : undefined,
42
+ role: structuredClone(o.role), brain: structuredClone(o.brain),
43
+ ...(o.identity ? { identity: o.identity } : {}),
44
+ ...(o.cwd ? { cwd: o.cwd } : {}),
45
+ ...(o.coordinator ? { coordinator: o.coordinator } : {}),
46
+ ...(permissions ? { permissions } : {}),
47
+ ...(o.isolationFile ? { isolation: readIsolationFile(o.isolationFile) } : {}),
48
+ ...(o.monitorConfig ? { monitor: structuredClone(o.monitorConfig) } : {}),
33
49
  };
34
50
  }
35
- /** Pure option-to-role mapping shared by CLI and application services. */
36
- export function buildRoleConfig(o, defaultHarness) {
37
- const r = {};
38
- const harness = o.harness ?? defaultHarness;
39
- if (harness)
40
- r.harness = harness;
41
- if (o.session)
42
- r.session = o.session;
43
- if (o.identity)
44
- r.identity = o.identity;
45
- if (o.cwd)
46
- r.cwd = o.cwd;
47
- if (o.coordinator)
48
- r.coordinator = o.coordinator;
49
- if (o.missionFile)
50
- r.mission = readMissionFile(o.missionFile);
51
- else if (o.mission !== undefined)
52
- r.mission = o.mission;
53
- if (o.model === null)
54
- r.model = null;
55
- else if (o.model?.trim())
56
- r.model = o.model.trim();
57
- if (o.reasoningEffort?.trim())
58
- r.effort = o.reasoningEffort.trim();
59
- const harnessOptions = {};
60
- if (o.permissionMode)
61
- harnessOptions[harness === 'claude-code' ? 'permission_mode' : 'approval'] = o.permissionMode;
62
- if (o.sandbox)
63
- harnessOptions.sandbox = o.sandbox;
64
- if (o.profile)
65
- harnessOptions.profile = o.profile;
66
- if (o.launcher)
67
- harnessOptions.launcher = o.launcher;
68
- if (o.search === true)
69
- harnessOptions.search = true;
70
- const codexConfig = { ...(o.codexConfig ?? {}) };
71
- if (Object.keys(codexConfig).length)
72
- harnessOptions.config = codexConfig;
73
- if (o.addDirs?.length)
74
- harnessOptions.add_dirs = o.addDirs;
75
- if (o.monitor === true)
76
- harnessOptions.monitor = true;
77
- if (Object.keys(harnessOptions).length)
78
- r.harness_options = harnessOptions;
79
- if (o.approval || o.filesystem || o.unattended) {
80
- r.permissions = {
81
- ...(o.approval ? { approval: o.approval } : {}),
82
- ...(o.filesystem ? { filesystem: o.filesystem } : {}),
83
- ...(o.unattended ? { unattended: o.unattended } : {}),
84
- };
85
- }
86
- const profile = profileValues(o);
87
- if (profile.bio)
88
- r.bio = profile.bio;
89
- if (profile.persona)
90
- r.persona = profile.persona;
91
- if (o.isolationFile)
92
- r.isolation = readIsolationFile(o.isolationFile);
93
- if (o.monitorConfig)
94
- r.monitor = { ...o.monitorConfig };
95
- return r;
51
+ function resolvedSpawn(o) {
52
+ const definition = agentDefinitionFromSpawn(o);
53
+ const cfg = loadConfig(o.configPath, { additionalAgent: { id: o.name, definition } });
54
+ return { definition, role: findRole(cfg, o.name) };
96
55
  }
97
56
  /**
98
57
  * Read and validate an `--isolation-file`. The file is the existing
@@ -120,12 +79,6 @@ export function readIsolationFile(path) {
120
79
  return cfg;
121
80
  }
122
81
  export function validateSpawnOpts(o) {
123
- if (o.mission !== undefined && o.missionFile)
124
- throw new Error('--mission and --mission-file are mutually exclusive');
125
- if (o.session === 'tmux')
126
- throw new Error("invalid --session 'tmux'; tmux is no longer supported; use --session acp");
127
- if (o.session && o.session !== 'acp')
128
- throw new Error(`invalid --session '${o.session}'; allowed: acp`);
129
82
  if (o.approval && !['ask', 'auto', 'allow', 'deny'].includes(o.approval))
130
83
  throw new Error(`invalid --approval '${o.approval}'; allowed: ask, auto, allow (deprecated alias: deny)`);
131
84
  if (o.filesystem && !['read-only', 'workspace', 'unrestricted'].includes(o.filesystem))
@@ -136,23 +89,12 @@ export function validateSpawnOpts(o) {
136
89
  throw new Error(`invalid role name '${o.name}'`);
137
90
  if (o.identity && !/^[A-Za-z0-9_-]+$/.test(o.identity))
138
91
  throw new Error(`invalid identity name '${o.identity}'`);
139
- if (o.model && (o.model.length > 128 || /[\0-\x1f\x7f]/.test(o.model)))
140
- throw new Error('model must be printable and at most 128 characters');
141
92
  if (o.monitorConfig) {
142
93
  const problems = validateMonitorConfig(o.monitorConfig);
143
94
  if (problems.length)
144
95
  throw new Error(problems.join('; '));
145
96
  }
146
97
  }
147
- /** Read mission text without trimming or newline rewriting. */
148
- export function readMissionFile(path) {
149
- try {
150
- return readFileSync(path, 'utf8');
151
- }
152
- catch (e) {
153
- throw new Error(`--mission-file ${path}: ${e.message}`);
154
- }
155
- }
156
98
  /**
157
99
  * Reject names that are already USED. This is a precondition, not a claim: it
158
100
  * runs INSIDE the creation transaction, after both names are reserved, so the
@@ -173,23 +115,6 @@ function assertNameFree(o) {
173
115
  }
174
116
  /** The ours identity a spawn will bind: explicit, else the role name. */
175
117
  export const effectiveIdentity = (o) => o.identity ?? o.name;
176
- /** Bare Agent document written by v2 spawn: inline Role × inline Brain + operations. */
177
- export function buildAgentDocument(raw) {
178
- const role = Object.fromEntries(['mission', 'persona', 'bio', 'briefing_file']
179
- .filter(key => raw[key] !== undefined)
180
- .map(key => [key, raw[key]]));
181
- const brain = Object.fromEntries([
182
- 'harness', 'session', 'session_options', 'model', 'model_chain', 'max_tokens',
183
- 'autocompact_pct', 'harness_options', 'effort',
184
- ].filter(key => raw[key] !== undefined)
185
- .map(key => [key, raw[key]]));
186
- const operational = Object.fromEntries([
187
- 'permissions', 'identity', 'cwd', 'coordinator', 'env', 'oversee', 'isolation',
188
- 'monitor', 'owner_channel', 'worklog', 'auth_proxy',
189
- ].filter(key => raw[key] !== undefined)
190
- .map(key => [key, raw[key]]));
191
- return { role: { inline: role }, brain: { inline: brain }, ...operational };
192
- }
193
118
  /**
194
119
  * Validate and resolve a spawn without reserving names, contacting the daemon,
195
120
  * or writing state. Collision checks are necessarily a point-in-time snapshot.
@@ -198,68 +123,13 @@ export function spawnDryRun(o) {
198
123
  validateSpawnOpts(o);
199
124
  if (o.isolationFile)
200
125
  readIsolationFile(o.isolationFile);
201
- if (o.missionFile)
202
- readMissionFile(o.missionFile);
203
126
  assertNameFree(o);
204
- const cfg = loadConfig(o.configPath);
205
- const raw = buildRoleConfig(o, cfg.defaults.harness ?? 'claude-code');
206
- const harnessOptions = {
207
- ...(cfg.defaults.harness_options ?? {}),
208
- ...(raw.harness_options ?? {}),
209
- };
210
- const harness = raw.harness ?? cfg.defaults.harness ?? 'claude-code';
211
- const defaultHarness = cfg.defaults.harness ?? 'claude-code';
212
- const inheritsModelDefaults = harness === defaultHarness && raw.model !== null;
213
- const authProxy = resolveAuthProxy(cfg.defaults.auth_proxy, raw.auth_proxy);
214
- // One resolution for the environment and the model it pins (src/model-env.ts).
215
- const modelEnv = resolveRoleModelEnv({
216
- harness,
217
- model: resolveRoleModel(raw.model, raw.harness, cfg.defaults),
218
- modelWasExplicit: raw.model !== undefined,
219
- defaultsEnv: (cfg.defaults.env ?? {}),
220
- roleEnv: raw.env,
221
- ...(authProxy ? { authProxyBaseUrl: authProxy.base_url } : {}),
222
- });
223
- const adapter = getAdapter(harness);
224
- const brain = adapter.agentSession.resolveBrain({
225
- model: modelEnv.model,
226
- effort: raw.effort,
227
- harnessOptions: Object.keys(harnessOptions).length ? harnessOptions : undefined,
228
- });
229
- const model = brain.model ?? undefined;
230
- const session = raw.session ?? cfg.defaults.session ?? 'acp';
231
- const resolvedRole = {
232
- ...raw,
233
- name: o.name,
234
- sourceFile: o.temp ? '(temp dry-run)' : join(splitRootFor(o.configPath ?? defaultConfigPath()), 'agents', `${o.name}.yaml`),
235
- harness,
236
- session,
237
- session_options: raw.session_options,
238
- permissions: resolvePermissions(cfg.defaults.permissions, raw.permissions),
239
- permissionsDeclared: raw.permissions !== undefined || cfg.defaults.permissions !== undefined,
240
- identity: effectiveIdentity(o),
241
- effort: raw.effort,
242
- model,
243
- model_chain: resolveModelChain(model, raw.model_chain ?? (inheritsModelDefaults
244
- ? cfg.defaults.model_chain
245
- : undefined)),
246
- harness_options: brain.harnessOptions,
247
- isolation: raw.isolation ?? cfg.defaults.isolation,
248
- monitor: resolveMonitorConfig(cfg.defaults.monitor, raw.monitor),
249
- owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, raw.owner_channel, session),
250
- worklog: resolveWorklogPolicy(cfg.defaults.worklog, raw.worklog),
251
- auth_proxy: authProxy,
252
- };
253
- resolvedRole.env = modelEnv.env;
254
- if (resolvedRole.auth_proxy && resolvedRole.harness !== 'claude-code')
255
- throw new Error('auth_proxy is supported only by claude-code');
256
- const optionProblems = adapter.validateOptions(resolvedRole.harness_options, resolvedRole);
257
- if (optionProblems.length)
258
- throw new Error(optionProblems.map(problem => `${problem.path}: ${problem.message}`).join('; '));
127
+ const resolved = resolvedSpawn(o);
128
+ const resolvedRole = resolved.role;
259
129
  return {
260
130
  schemaVersion: 1,
261
131
  warning: 'collision checks are a snapshot; a real spawn reserves names atomically',
262
- roleDocument: buildAgentDocument(raw),
132
+ roleDocument: structuredClone(resolved.definition),
263
133
  resolvedRole,
264
134
  };
265
135
  }
@@ -275,22 +145,15 @@ function provenanceSettings(o, defaults) {
275
145
  const perms = (defaults.permissions ?? {});
276
146
  const callerDefaults = new Set(o.inheritedFromCaller ?? []);
277
147
  const tagged = (key, entry) => callerDefaults.has(key) ? { ...entry, source: 'caller-role' } : entry;
278
- const explicitModel = typeof o.model === 'string' ? o.model.trim() : undefined;
279
- const inheritedModel = resolveRoleModel(undefined, o.harness, defaults);
148
+ const selection = (value) => value && 'ref' in value ? `ref:${value.ref}` : value ? 'inline' : undefined;
280
149
  return {
281
- harness: tagged('harness', provenanceOf(o.harness, defaults.harness, 'claude-code')),
282
- session: tagged('session', provenanceOf(o.session, defaults.session, 'acp')),
150
+ brain: tagged('brain', provenanceOf(selection(o.brain), undefined, undefined)),
151
+ role: tagged('role', provenanceOf(selection(o.role), undefined, undefined)),
283
152
  identity: o.identity
284
153
  ? { value: o.identity, source: 'cli' }
285
154
  : { value: o.name, source: 'built-in' }, // defaults to the role name
286
155
  cwd: tagged('cwd', provenanceOf(o.cwd, undefined, undefined)),
287
- model: tagged('model', o.model === null
288
- ? { value: undefined, source: 'cli' }
289
- : explicitModel
290
- ? { value: explicitModel, source: 'cli' }
291
- : { value: inheritedModel, source: inheritedModel ? 'fleet-default' : 'built-in' }),
292
156
  coordinator: tagged('coordinator', provenanceOf(o.coordinator, undefined, undefined)),
293
- permission_mode: provenanceOf(o.permissionMode, undefined, undefined),
294
157
  approval: tagged('approval', provenanceOf(o.approval, perms.approval, 'ask')),
295
158
  filesystem: tagged('filesystem', provenanceOf(o.filesystem, perms.filesystem, 'workspace')),
296
159
  unattended: tagged('unattended', provenanceOf(o.unattended, perms.unattended, 'deny')),
@@ -305,8 +168,7 @@ export async function spawnPermanent(o, deps, creation = {}) {
305
168
  validateSpawnOpts(o);
306
169
  if (o.isolationFile)
307
170
  readIsolationFile(o.isolationFile); // fail before reserving
308
- if (o.missionFile)
309
- readMissionFile(o.missionFile); // fail before reserving
171
+ const prepared = resolvedSpawn(o); // canonical validation before mutation
310
172
  // Reserve name and identity together before anything is written or started.
311
173
  // A loser of the race creates no config, state, or service.
312
174
  creation.onStage?.('reserving');
@@ -316,7 +178,7 @@ export async function spawnPermanent(o, deps, creation = {}) {
316
178
  // Establish the identity BEFORE the service is enabled, and record
317
179
  // what was actually guaranteed so the briefing can say something true.
318
180
  creation.onStage?.('checking_identity');
319
- const guarantee = await ensureIdentity(effectiveIdentity(o), profileValues(o), creation.identityProvisioner ?? deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
181
+ const guarantee = await ensureIdentity(effectiveIdentity(o), { bio: prepared.role.bio, persona: prepared.role.persona }, creation.identityProvisioner ?? deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
320
182
  creation.onStage?.('checking_identity', {
321
183
  result: guarantee.evidence, guarantee: guarantee.state,
322
184
  });
@@ -333,7 +195,7 @@ export async function spawnPermanent(o, deps, creation = {}) {
333
195
  mkdirSync(agentRoot, { recursive: true });
334
196
  creation.onStage?.('writing_role');
335
197
  const file = join(agentRoot, `${o.name}.yaml`);
336
- writeRoleFile(tx, file, stringify(buildAgentDocument(buildRoleConfig(o, cfg.defaults.harness ?? 'claude-code'))));
198
+ writeRoleFile(tx, file, stringify(agentDefinitionFromSpawn(o)));
337
199
  // `up` materialises the state dir and registers the service. Journal the
338
200
  // dir before it exists so a failure leaves the name genuinely reusable
339
201
  // rather than blocked by a half-built directory.
@@ -402,8 +264,7 @@ export async function spawnTemp(o, binPath, launch = independentSupervisor, crea
402
264
  validateSpawnOpts(o);
403
265
  if (o.isolationFile)
404
266
  readIsolationFile(o.isolationFile); // fail before reserving
405
- if (o.missionFile)
406
- readMissionFile(o.missionFile); // fail before reserving
267
+ const prepared = resolvedSpawn(o); // canonical validation before mutation
407
268
  // Retire only supervisors whose recorded owner is definitively stopped. This
408
269
  // bounded pass keeps the active roster clean without deleting old evidence.
409
270
  await reclaimStaleTempState();
@@ -416,63 +277,14 @@ export async function spawnTemp(o, binPath, launch = independentSupervisor, crea
416
277
  creation.onStage?.('checking_identity', {
417
278
  result: 'unknown', guarantee: 'unverified',
418
279
  });
419
- return spawnTempInner(o, binPath, launch, tx, creation.onStage);
280
+ return spawnTempInner(o, prepared.role, binPath, launch, tx, creation.onStage);
420
281
  }, creation);
421
282
  }
422
- async function spawnTempInner(o, binPath, launch, tx, onStage) {
283
+ async function spawnTempInner(o, preparedRole, binPath, launch, tx, onStage) {
423
284
  const cfg = loadConfig(o.configPath);
424
- const defaultHarness = cfg.defaults.harness;
425
- const fromOpts = buildRoleConfig(o, defaultHarness);
426
- const mergedHarnessOptions = {
427
- ...(cfg.defaults.harness_options ?? {}),
428
- ...(fromOpts.harness_options ?? {}),
429
- };
430
- const harness = o.harness ?? defaultHarness ?? 'claude-code';
431
- const inheritsModelDefaults = harness === (defaultHarness ?? 'claude-code') && o.model !== null;
432
- const tempAuthProxy = resolveAuthProxy(cfg.defaults.auth_proxy, fromOpts.auth_proxy);
433
- // An explicitly requested --model must reach the child, not just the banner
434
- // (src/model-env.ts).
435
- const modelEnv = resolveRoleModelEnv({
436
- harness,
437
- model: resolveRoleModel(o.model, o.harness, cfg.defaults),
438
- modelWasExplicit: o.model !== undefined,
439
- defaultsEnv: (cfg.defaults.env ?? {}),
440
- roleEnv: fromOpts.env,
441
- ...(tempAuthProxy ? { authProxyBaseUrl: tempAuthProxy.base_url } : {}),
442
- });
443
- const adapter = getAdapter(harness);
444
- const brain = adapter.agentSession.resolveBrain({
445
- model: modelEnv.model,
446
- effort: fromOpts.effort,
447
- harnessOptions: Object.keys(mergedHarnessOptions).length ? mergedHarnessOptions : undefined,
448
- });
449
- const model = brain.model ?? undefined;
450
- const session = o.session ?? cfg.defaults.session ?? 'acp';
451
285
  const role = {
452
- ...fromOpts, // includes `isolation` when --isolation-file was given
453
- name: o.name,
454
- harness,
455
- session,
456
- identity: o.identity ?? o.name,
457
- effort: fromOpts.effort,
458
- model,
459
- model_chain: resolveModelChain(model, fromOpts.model_chain ?? (inheritsModelDefaults
460
- ? cfg.defaults.model_chain
461
- : undefined)),
462
- harness_options: brain.harnessOptions,
463
- permissions: resolvePermissions(cfg.defaults.permissions, fromOpts.permissions),
464
- permissionsDeclared: fromOpts.permissions !== undefined || cfg.defaults.permissions !== undefined,
465
- // Temp agents inherit the fleet-wide monitor defaults via the snapshot.
466
- monitor: resolveMonitorConfig(cfg.defaults.monitor, fromOpts.monitor),
467
- owner_channel: resolveOwnerChannelConfig(cfg.defaults.owner_channel, fromOpts.owner_channel, session),
468
- worklog: resolveWorklogPolicy(cfg.defaults.worklog, fromOpts.worklog),
469
- auth_proxy: tempAuthProxy,
470
- sourceFile: '(temp)',
471
- roomMemberStartup: o.roomMemberStartup,
286
+ ...preparedRole, sourceFile: '(temp)', loops: undefined, roomMemberStartup: o.roomMemberStartup,
472
287
  };
473
- role.env = modelEnv.env;
474
- if (role.auth_proxy && role.harness !== 'claude-code')
475
- throw new Error('auth_proxy is supported only by claude-code');
476
288
  onStage?.('writing_role');
477
289
  const dir = applyRole(role, { temp: true, identityGuarantee: 'unverified' });
478
290
  const provenance = buildProvenance({
@@ -1,19 +1,19 @@
1
- import type { FleetConfig, ResolvedRole, SessionBackendId } from '../config.js';
1
+ import type { AgentDefinition, FleetConfig, ResolvedRole } from '../config.js';
2
+ import type { SessionBackendId } from '../config.js';
2
3
  import type { IsolationConfig } from '../isolation/types.js';
3
4
  export interface WatchdogConfig {
4
5
  coordinator?: string;
5
6
  enabled?: boolean;
6
7
  interval?: string;
7
8
  watch?: string[];
8
- harness?: string;
9
- model?: string | null;
10
- session?: string;
9
+ agent?: {
10
+ ref: string;
11
+ } | AgentDefinition;
11
12
  identity?: string;
12
13
  timeout?: string;
13
14
  keep_reports?: number;
14
15
  alert_cooldown?: string;
15
16
  prompt_file?: string;
16
- isolation?: IsolationConfig;
17
17
  }
18
18
  export interface ResolvedWatchdog {
19
19
  name: string;
@@ -22,15 +22,18 @@ export interface ResolvedWatchdog {
22
22
  intervalMs: number;
23
23
  watch: string[];
24
24
  watchExplicit: boolean;
25
+ agentDefinition: AgentDefinition;
26
+ resolvedAgent: ResolvedRole;
27
+ /** Resolved/read-only runtime facts; never independent authoring fields. */
25
28
  harness: string;
26
29
  session: SessionBackendId;
27
30
  model?: string;
31
+ isolation?: IsolationConfig;
28
32
  identity: string;
29
33
  timeoutMs: number;
30
34
  keepReports: number;
31
35
  alertCooldownMs: number;
32
36
  promptFile?: string;
33
- isolation?: IsolationConfig;
34
37
  sourceFile: string;
35
38
  }
36
39
  export declare const WATCHDOG_DEFAULT_INTERVAL_MS = 600000;
@@ -38,7 +41,7 @@ export declare const WATCHDOG_MIN_INTERVAL_MS = 60000;
38
41
  export declare const WATCHDOG_DEFAULT_TIMEOUT_MS = 300000;
39
42
  export declare const WATCHDOG_DEFAULT_KEEP_REPORTS = 50;
40
43
  export declare const WATCHDOG_DEFAULT_COOLDOWN_MS = 3600000;
41
- export declare function resolveWatchdogs(baseDoc: Record<string, unknown>, baseFile: string, roles: ResolvedRole[], vars: Record<string, string>, defaults: Record<string, unknown>): ResolvedWatchdog[];
44
+ export declare function resolveWatchdogs(baseDoc: Record<string, unknown>, baseFile: string, roles: ResolvedRole[], vars: Record<string, string>, agentDefinitions: Record<string, AgentDefinition>, resolveInline: (name: string, definition: AgentDefinition) => ResolvedRole): ResolvedWatchdog[];
42
45
  /**
43
46
  * Split `restart`'s argument names into watchdogs (a release, handled
44
47
  * directly) and roles (handed to restartRoles). Watchdog names can't collide
@@ -1,11 +1,10 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { isAbsolute } from 'node:path';
3
3
  import { parseDuration } from '../duration.js';
4
- import { ConfigError, ROLE_NAME_RE, resolveRoleModel } from '../config.js';
5
- import { validateIsolationConfig } from '../isolation/policy.js';
4
+ import { ConfigError, ROLE_NAME_RE } from '../config.js';
6
5
  const WATCHDOG_KEYS = [
7
- 'coordinator', 'enabled', 'interval', 'watch', 'harness', 'model', 'session',
8
- 'identity', 'timeout', 'keep_reports', 'alert_cooldown', 'prompt_file', 'isolation',
6
+ 'coordinator', 'enabled', 'interval', 'watch', 'agent',
7
+ 'identity', 'timeout', 'keep_reports', 'alert_cooldown', 'prompt_file',
9
8
  ];
10
9
  export const WATCHDOG_DEFAULT_INTERVAL_MS = 600_000;
11
10
  export const WATCHDOG_MIN_INTERVAL_MS = 60_000;
@@ -21,7 +20,7 @@ function deepSub(v, vars) {
21
20
  return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, deepSub(x, vars)]));
22
21
  return v;
23
22
  }
24
- export function resolveWatchdogs(baseDoc, baseFile, roles, vars, defaults) {
23
+ export function resolveWatchdogs(baseDoc, baseFile, roles, vars, agentDefinitions, resolveInline) {
25
24
  const block = baseDoc.watchdogs;
26
25
  if (block === undefined || block === null)
27
26
  return [];
@@ -42,6 +41,9 @@ export function resolveWatchdogs(baseDoc, baseFile, roles, vars, defaults) {
42
41
  if (roleNames.has(name))
43
42
  throw new ConfigError(`${baseFile}: watchdog '${name}' collides with a role name`);
44
43
  const w = deepSub(rawEntry ?? {}, vars);
44
+ const legacy = ['harness', 'model', 'session', 'isolation'].filter(key => Object.hasOwn(w, key));
45
+ if (legacy.length)
46
+ throw new ConfigError(`${where}: E_LEGACY Brain/Agent-owned field(s) ${legacy.join(', ')} are unsupported; select a canonical Agent with agent`);
45
47
  const bad = Object.keys(w).filter(k => !WATCHDOG_KEYS.includes(k));
46
48
  if (bad.length)
47
49
  throw new ConfigError(`${where} has unknown key(s) ${bad.join(', ')}; allowed: ${WATCHDOG_KEYS.join(', ')}`);
@@ -60,23 +62,29 @@ export function resolveWatchdogs(baseDoc, baseFile, roles, vars, defaults) {
60
62
  if (collidingWatchdog !== undefined)
61
63
  throw new ConfigError(`${where}: identity '${identity}' collides with watchdog '${collidingWatchdog}'`);
62
64
  watchdogIdentities.set(identity, name);
63
- const harness = w.harness ?? defaults.harness ?? 'claude-code';
64
- const sessionRaw = w.session ?? defaults.session ?? 'acp';
65
- if (sessionRaw === 'tmux')
66
- throw new ConfigError(`${where}: session 'tmux' is no longer supported; use session: acp`);
67
- if (sessionRaw !== 'acp')
68
- throw new ConfigError(`${where}: session must be 'acp'`);
65
+ if (!w.agent || typeof w.agent !== 'object' || Array.isArray(w.agent))
66
+ throw new ConfigError(`${where}: agent is required (Agent ref or canonical Agent definition)`);
67
+ let agentDefinition;
68
+ let resolvedAgent;
69
+ if ('ref' in w.agent) {
70
+ const ref = w.agent.ref;
71
+ if (typeof ref !== 'string' || !ref)
72
+ throw new ConfigError(`${where}: agent.ref must be a declared Agent ID`);
73
+ agentDefinition = agentDefinitions[ref];
74
+ resolvedAgent = roles.find(role => role.name === ref);
75
+ if (!agentDefinition || !resolvedAgent)
76
+ throw new ConfigError(`${where}: Agent ref '${ref}' was not found`);
77
+ }
78
+ else {
79
+ agentDefinition = structuredClone(w.agent);
80
+ resolvedAgent = resolveInline(name, agentDefinition);
81
+ }
69
82
  if (w.prompt_file !== undefined) {
70
83
  if (typeof w.prompt_file !== 'string' || !isAbsolute(w.prompt_file))
71
84
  throw new ConfigError(`${where}: prompt_file must be an absolute path`);
72
85
  if (!existsSync(w.prompt_file))
73
86
  throw new ConfigError(`${where}: prompt_file not found: ${w.prompt_file}`);
74
87
  }
75
- if (w.isolation !== undefined) {
76
- const problems = validateIsolationConfig(w.isolation);
77
- if (problems.length)
78
- throw new ConfigError(`${where} ${problems.join('; ')}`);
79
- }
80
88
  const dur = (v, key, fallback, minMs) => {
81
89
  if (v === undefined)
82
90
  return fallback;
@@ -96,13 +104,15 @@ export function resolveWatchdogs(baseDoc, baseFile, roles, vars, defaults) {
96
104
  name, coordinator: w.coordinator.trim(),
97
105
  enabled: w.enabled ?? true,
98
106
  intervalMs: dur(w.interval, 'interval', WATCHDOG_DEFAULT_INTERVAL_MS, WATCHDOG_MIN_INTERVAL_MS),
99
- watch, watchExplicit: w.watch !== undefined, harness, session: sessionRaw,
100
- model: resolveRoleModel(w.model, w.harness, defaults),
107
+ watch, watchExplicit: w.watch !== undefined,
108
+ agentDefinition: structuredClone(agentDefinition), resolvedAgent: structuredClone(resolvedAgent),
109
+ harness: resolvedAgent.harness, session: resolvedAgent.session,
110
+ model: resolvedAgent.model, isolation: resolvedAgent.isolation,
101
111
  identity,
102
112
  timeoutMs: dur(w.timeout, 'timeout', WATCHDOG_DEFAULT_TIMEOUT_MS),
103
113
  keepReports,
104
114
  alertCooldownMs: dur(w.alert_cooldown, 'alert_cooldown', WATCHDOG_DEFAULT_COOLDOWN_MS),
105
- promptFile: w.prompt_file, isolation: w.isolation, sourceFile: baseFile,
115
+ promptFile: w.prompt_file, sourceFile: baseFile,
106
116
  });
107
117
  }
108
118
  return out;
@@ -10,7 +10,7 @@ import { applyRole } from '../ops.js';
10
10
  import { daemonIdentityInventoryProvisioner, ensureIdentity, } from '../creation.js';
11
11
  import { runOnce, START_STAGGER_FILE } from '../runner.js';
12
12
  import { agentDir, tmpRoot } from '../paths.js';
13
- import { loadConfig, resolveMonitorConfig, resolveWorklogPolicy, ROLE_NAME_RE, } from '../config.js';
13
+ import { loadConfig, ROLE_NAME_RE, } from '../config.js';
14
14
  import { getAdapter } from '../harness/registry.js';
15
15
  import { redactLogLine } from '../application/log-service.js';
16
16
  import { controlRequest, controlSocketPath } from '../session/control.js';
@@ -84,19 +84,10 @@ function readTail(runDir) {
84
84
  }
85
85
  }
86
86
  /** The temp role every watchdog-family run launches under. Isolation is opt-in. */
87
- function buildWatchdogRole(wd, cfg) {
87
+ function buildWatchdogRole(wd, _cfg) {
88
88
  return {
89
- name: wd.identity, sourceFile: '(watchdog)',
90
- harness: wd.harness, session: wd.session,
91
- identity: wd.identity, model: wd.model,
92
- // Watchdogs are observe-only by contract, but their sanctioned status commands
93
- // must reach host control sockets. Keep approvals/unattended escalation denied
94
- // while disabling the harness's native filesystem/network sandbox.
95
- permissions: { approval: 'deny', filesystem: 'unrestricted', unattended: 'deny' },
96
- permissionsDeclared: true,
97
- monitor: resolveMonitorConfig(cfg.defaults.monitor, undefined),
98
- worklog: resolveWorklogPolicy(cfg.defaults.worklog, undefined),
99
- isolation: wd.isolation,
89
+ ...structuredClone(wd.resolvedAgent),
90
+ name: wd.identity, sourceFile: '(watchdog)', identity: wd.identity,
100
91
  };
101
92
  }
102
93
  /** Discover temporary sessions that are live now, not merely stale dirs on disk. */
@@ -126,7 +126,14 @@ function agentEntry(node) {
126
126
  */
127
127
  function watchdogEntry(node, merged) {
128
128
  const scoped = linked(merged, node.id, 'watches');
129
- return { ...pick(node, WATCHDOG_FIELDS), ...(scoped.length ? { watch: scoped } : {}) };
129
+ return {
130
+ ...pick(node, WATCHDOG_FIELDS),
131
+ agent: {
132
+ role: { inline: {} },
133
+ brain: { inline: { harness: 'claude-code', ...pick(node, BRAIN_FIELDS) } },
134
+ },
135
+ ...(scoped.length ? { watch: scoped } : {}),
136
+ };
130
137
  }
131
138
  function loopEntry(node, merged) {
132
139
  const roles = linked(merged, node.id, 'targets');