@ours.network/fleet 1.1.0-nightly.9 → 1.1.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.
Files changed (200) hide show
  1. package/README.md +231 -46
  2. package/dist/application/capabilities.js +18 -6
  3. package/dist/application/fleet-query-service.js +7 -4
  4. package/dist/application/role-command-service.js +9 -0
  5. package/dist/application/role-creation-service.d.ts +4 -4
  6. package/dist/application/role-creation-service.js +8 -1
  7. package/dist/application/role-repository.d.ts +4 -2
  8. package/dist/application/role-repository.js +6 -3
  9. package/dist/application/session-control.d.ts +3 -1
  10. package/dist/application/session-control.js +4 -2
  11. package/dist/application/task-room-service.d.ts +92 -39
  12. package/dist/application/task-room-service.js +562 -289
  13. package/dist/briefing.js +79 -22
  14. package/dist/build-info.json +5 -5
  15. package/dist/canonical-json.d.ts +2 -0
  16. package/dist/canonical-json.js +10 -0
  17. package/dist/cli.js +129 -47
  18. package/dist/config.d.ts +38 -3
  19. package/dist/config.js +193 -28
  20. package/dist/creation.d.ts +1 -1
  21. package/dist/creation.js +7 -4
  22. package/dist/docs.d.ts +1 -1
  23. package/dist/docs.js +181 -41
  24. package/dist/doctor.d.ts +2 -0
  25. package/dist/doctor.js +40 -4
  26. package/dist/fleet-command-audit.d.ts +22 -1
  27. package/dist/fleet-command-audit.js +355 -50
  28. package/dist/fleet-proxy.d.ts +5 -2
  29. package/dist/harness/claude-code-session.js +2 -1
  30. package/dist/harness/codex-app-server-proxy.d.ts +40 -0
  31. package/dist/harness/codex-app-server-proxy.js +348 -14
  32. package/dist/harness/codex-session.d.ts +9 -2
  33. package/dist/harness/codex-session.js +18 -2
  34. package/dist/harness/codex.d.ts +5 -1
  35. package/dist/harness/codex.js +104 -10
  36. package/dist/harness/types.d.ts +1 -1
  37. package/dist/index.d.ts +4 -3
  38. package/dist/index.js +3 -2
  39. package/dist/init-guidance.d.ts +1 -1
  40. package/dist/init-guidance.js +1 -1
  41. package/dist/init-wizard.d.ts +98 -0
  42. package/dist/init-wizard.js +631 -0
  43. package/dist/lifecycle-summary.d.ts +134 -0
  44. package/dist/lifecycle-summary.js +179 -0
  45. package/dist/loops/config.d.ts +10 -1
  46. package/dist/loops/config.js +38 -5
  47. package/dist/model-recovery.d.ts +1 -1
  48. package/dist/ops.js +2 -17
  49. package/dist/owner-channel/channel.d.ts +19 -3
  50. package/dist/owner-channel/channel.js +301 -120
  51. package/dist/owner-channel/commands.d.ts +28 -6
  52. package/dist/owner-channel/commands.js +109 -27
  53. package/dist/owner-channel/lifecycle-outbox.d.ts +24 -0
  54. package/dist/owner-channel/lifecycle-outbox.js +87 -0
  55. package/dist/owner-channel/ours-client.d.ts +19 -0
  56. package/dist/owner-channel/ours-client.js +3 -0
  57. package/dist/permissions.js +1 -1
  58. package/dist/preset-migration.d.ts +42 -0
  59. package/dist/preset-migration.js +450 -0
  60. package/dist/reports/artifact.d.ts +10 -0
  61. package/dist/reports/artifact.js +32 -0
  62. package/dist/reports/index.d.ts +5 -0
  63. package/dist/reports/index.js +5 -0
  64. package/dist/reports/render.d.ts +6 -0
  65. package/dist/reports/render.js +240 -0
  66. package/dist/reports/service.d.ts +42 -0
  67. package/dist/reports/service.js +57 -0
  68. package/dist/reports/tasks.d.ts +52 -0
  69. package/dist/reports/tasks.js +177 -0
  70. package/dist/reports/types.d.ts +315 -0
  71. package/dist/reports/types.js +6 -0
  72. package/dist/resolved-plan.js +13 -1
  73. package/dist/rooms-tasks/cli.d.ts +2 -0
  74. package/dist/rooms-tasks/cli.js +361 -285
  75. package/dist/rooms-tasks/close.d.ts +9 -1
  76. package/dist/rooms-tasks/close.js +23 -7
  77. package/dist/rooms-tasks/config.js +16 -25
  78. package/dist/rooms-tasks/cowork-adapter.d.ts +5 -0
  79. package/dist/rooms-tasks/cowork-adapter.js +13 -0
  80. package/dist/rooms-tasks/deletion.d.ts +51 -0
  81. package/dist/rooms-tasks/deletion.js +217 -0
  82. package/dist/rooms-tasks/external-worker.d.ts +8 -0
  83. package/dist/rooms-tasks/external-worker.js +38 -0
  84. package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
  85. package/dist/rooms-tasks/launch-snapshot.js +181 -0
  86. package/dist/rooms-tasks/member-overrides.d.ts +30 -0
  87. package/dist/rooms-tasks/member-overrides.js +204 -0
  88. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  89. package/dist/rooms-tasks/member-startup.js +9 -2
  90. package/dist/rooms-tasks/provision.js +146 -83
  91. package/dist/rooms-tasks/room-state.d.ts +1 -0
  92. package/dist/rooms-tasks/room-state.js +12 -1
  93. package/dist/rooms-tasks/task-room-name.d.ts +12 -0
  94. package/dist/rooms-tasks/task-room-name.js +61 -0
  95. package/dist/rooms-tasks/task-state.d.ts +115 -3
  96. package/dist/rooms-tasks/task-state.js +361 -11
  97. package/dist/rooms-tasks/templates.d.ts +4 -1
  98. package/dist/rooms-tasks/templates.js +34 -3
  99. package/dist/rooms-tasks/terminal.d.ts +3 -0
  100. package/dist/rooms-tasks/terminal.js +10 -4
  101. package/dist/rooms-tasks/types.d.ts +92 -7
  102. package/dist/rooms-tasks/types.js +14 -1
  103. package/dist/runner.d.ts +3 -0
  104. package/dist/runner.js +91 -44
  105. package/dist/session/acp.d.ts +4 -1
  106. package/dist/session/acp.js +29 -13
  107. package/dist/session/arbiter.d.ts +2 -1
  108. package/dist/session/arbiter.js +2 -0
  109. package/dist/session/codex-app-server-transport.d.ts +53 -0
  110. package/dist/session/codex-app-server-transport.js +184 -0
  111. package/dist/session/codex-app-server.d.ts +122 -0
  112. package/dist/session/codex-app-server.js +1031 -0
  113. package/dist/session/control.d.ts +1 -1
  114. package/dist/session/control.js +17 -1
  115. package/dist/session/conversation-types.d.ts +4 -4
  116. package/dist/session/types.d.ts +24 -6
  117. package/dist/session/types.js +28 -0
  118. package/dist/spawn.d.ts +9 -0
  119. package/dist/spawn.js +77 -9
  120. package/dist/web/fleet-config-service.d.ts +1 -0
  121. package/dist/web/fleet-config-service.js +25 -7
  122. package/dist/web/runtime.js +11 -6
  123. package/dist/web/server.js +28 -1
  124. package/dist/web-app/assets/{index-BbTc5KtZ.js → index-C0h3ALvs.js} +8 -8
  125. package/dist/web-app/index.html +1 -1
  126. package/package.json +4 -4
  127. package/presets/brain-catalog.json +17 -0
  128. package/presets/fleet/{agents → agent_templates}/Critic.yaml +2 -0
  129. package/presets/fleet/{agents/Agent.yaml → agent_templates/Developer.yaml} +3 -1
  130. package/presets/fleet/agent_templates/LocalCoordinator.yaml +50 -0
  131. package/presets/fleet/agents/{Developer.yaml → FleetCoordinator.yaml} +1 -1
  132. package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
  133. package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
  134. package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
  135. package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
  136. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
  137. package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
  138. package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
  139. package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
  140. package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
  141. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
  142. package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
  143. package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
  144. package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
  145. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
  146. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
  147. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
  148. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
  149. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
  150. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
  151. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
  152. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
  153. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
  154. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
  155. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
  156. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
  157. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
  158. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
  159. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
  160. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
  161. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
  162. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
  163. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
  164. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
  165. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
  166. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
  167. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
  168. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
  169. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
  170. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
  171. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
  172. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
  173. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
  174. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
  175. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
  176. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
  177. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
  178. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
  179. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
  180. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +5 -0
  181. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +5 -0
  182. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +5 -0
  183. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +5 -0
  184. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +5 -0
  185. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +5 -0
  186. package/presets/fleet/roles/Coordinator.yaml +37 -0
  187. package/presets/fleet/roles/Critic.yaml +23 -1
  188. package/presets/fleet/roles/Developer.yaml +27 -5
  189. package/presets/fleet/roles/LocalCoordinator.yaml +35 -0
  190. package/presets/fleet/room_templates/pair.yaml +9 -7
  191. package/presets/fleet/room_templates/single.yaml +5 -4
  192. package/presets/fleet/room_templates/team.yaml +12 -8
  193. package/presets/manifest.json +2 -1
  194. package/presets/fleet/agents/Architect.yaml +0 -3
  195. package/presets/fleet/agents/Secretary.yaml +0 -3
  196. package/presets/fleet/agents/Tester.yaml +0 -3
  197. package/presets/fleet/roles/Agent.yaml +0 -6
  198. package/presets/fleet/roles/Architect.yaml +0 -6
  199. package/presets/fleet/roles/Secretary.yaml +0 -6
  200. package/presets/fleet/roles/Tester.yaml +0 -6
package/dist/config.js CHANGED
@@ -6,11 +6,13 @@ import { harnessRuntimeDir, resolveIsolation, validateIsolationConfig, } from '.
6
6
  import { getAdapter } from './harness/registry.js';
7
7
  import { resolveRoleModelEnv } from './model-env.js';
8
8
  import { resolveWatchdogs } from './watchdog/config.js';
9
- import { resolveLoops } from './loops/config.js';
9
+ import { resolveAgentLoops, resolveLoops } from './loops/config.js';
10
10
  import { validateRoomsConfig, validateTasksConfig, validateRoomTemplatesConfig, } from './rooms-tasks/config.js';
11
11
  import { CAPABILITIES, CAP_MONITOR_INTERRUPT_AFTER_TOOL } from './capabilities.js';
12
12
  import { runningLabel } from './provenance.js';
13
13
  import { parseDuration } from './duration.js';
14
+ import { createHash } from 'node:crypto';
15
+ import { canonicalJson } from './canonical-json.js';
14
16
  /** Conservative built-in policy; `worklog: false` is the explicit opt-out. */
15
17
  export const DEFAULT_WORKLOG_POLICY = Object.freeze({
16
18
  max_kb: 1024,
@@ -140,8 +142,13 @@ export const BRAIN_PRESET_KEYS = [
140
142
  ];
141
143
  export const AGENT_KEYS = [
142
144
  'role', 'brain', 'permissions', 'identity', 'cwd', 'coordinator', 'env', 'oversee',
143
- 'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy',
145
+ 'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy', 'loops',
144
146
  ];
147
+ const AGENT_INSTANCE_KEYS = ['template', 'overrides'];
148
+ const TEMPLATE_FORBIDDEN_KEYS = ['identity'];
149
+ const MERGED_MAP_FIELDS = new Set([
150
+ 'permissions', 'env', 'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy', 'loops',
151
+ ]);
145
152
  const V2_API_VERSION = 'ours.network/fleet/v2';
146
153
  const utf8Bytes = (value) => Buffer.byteLength(value, 'utf8');
147
154
  function schemaError(file, pointer, summary) {
@@ -179,7 +186,45 @@ export function validateBrainValue(value, file, pointer) {
179
186
  schemaError(file, `${pointer}/${key}`, 'must be a mapping');
180
187
  }
181
188
  }
189
+ /** Pure launch-boundary validation for a fully resolved temporary Agent definition. */
190
+ export function validateEffectiveAgentTemplate(definition, id = 'room-member') {
191
+ const value = structuredClone(definition);
192
+ const bad = Object.keys(value).filter(key => !AGENT_KEYS.includes(key));
193
+ if (bad.length)
194
+ throw new ConfigError(`member '${id}' has unknown Agent key(s) ${bad.join(', ')}`);
195
+ if (Object.hasOwn(value, 'identity'))
196
+ throw new ConfigError(`member '${id}' cannot override identity`);
197
+ if (!isPlainObject(value.role) || !isPlainObject(value.role.inline)
198
+ || Object.keys(value.role).some(key => key !== 'inline'))
199
+ throw new ConfigError(`member '${id}' role must be a resolved inline definition`);
200
+ if (!isPlainObject(value.brain) || !isPlainObject(value.brain.inline)
201
+ || Object.keys(value.brain).some(key => key !== 'inline'))
202
+ throw new ConfigError(`member '${id}' brain must be a resolved inline definition`);
203
+ const role = value.role.inline;
204
+ const brain = value.brain.inline;
205
+ assertBareKeys(role, ROLE_PRESET_KEYS, `member '${id}' Role`);
206
+ assertBareKeys(brain, BRAIN_PRESET_KEYS, `member '${id}' Brain`);
207
+ validateRoleValue(role, '(member override)', `/members/${id}/role`);
208
+ validateBrainValue(brain, '(member override)', `/members/${id}/brain`);
209
+ validateAgentScalars(Object.fromEntries(Object.entries(value)
210
+ .filter(([key]) => key !== 'role' && key !== 'brain')), '(member override)', id);
211
+ resolvePermissions(undefined, value.permissions, '(member override)', id);
212
+ if (value.isolation !== undefined) {
213
+ const problems = validateIsolationConfig(value.isolation);
214
+ if (problems.length)
215
+ throw new ConfigError(`member '${id}' ${problems.join('; ')}`);
216
+ }
217
+ const adapter = getAdapter(brain.harness);
218
+ adapter.agentSession.resolveBrain({ model: brain.model,
219
+ effort: brain.effort,
220
+ harnessOptions: brain.harness_options });
221
+ return value;
222
+ }
182
223
  function validateAgentScalars(value, file, id) {
224
+ if (value.loops !== undefined)
225
+ resolveAgentLoops(value.loops, {
226
+ name: id, harness: 'codex', session: 'acp', permissions: {},
227
+ }, file);
183
228
  for (const key of ['identity', 'cwd']) {
184
229
  const current = value[key];
185
230
  if (current !== undefined && (typeof current !== 'string' || !current.trim()))
@@ -334,7 +379,23 @@ function deepSubStrict(value, vars, file, pointer) {
334
379
  ]));
335
380
  return value;
336
381
  }
337
- function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files, additionalAgent) {
382
+ function mergeTemplateDefinition(template, overrides, file, id) {
383
+ const merge = (base, overlay, key) => {
384
+ if (overlay === null && key !== 'model')
385
+ schemaError(file, `/agents/${id}/overrides/${key}`, 'null deletion is not supported');
386
+ if (MERGED_MAP_FIELDS.has(key) && isPlainObject(base) && isPlainObject(overlay)) {
387
+ return Object.fromEntries([...new Set([...Object.keys(base), ...Object.keys(overlay)])]
388
+ .map(child => [child, Object.hasOwn(overlay, child)
389
+ ? merge(base[child], overlay[child], key) : structuredClone(base[child])]));
390
+ }
391
+ return structuredClone(overlay);
392
+ };
393
+ const result = structuredClone(template);
394
+ for (const [key, value] of Object.entries(overrides))
395
+ result[key] = merge(result[key], value, key);
396
+ return result;
397
+ }
398
+ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files, additionalAgent, templateSink = {}, roleSink = {}, brainSink = {}) {
338
399
  if (baseDoc.api_version !== V2_API_VERSION)
339
400
  throw new ConfigError(`${base}: legacy fleet configuration is unsupported; run 'ours-fleet doctor' and rewrite it as ${V2_API_VERSION}`);
340
401
  if ('roles' in baseDoc)
@@ -345,6 +406,7 @@ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files, addition
345
406
  assertTrustedPath(root, 'directory');
346
407
  const brains = readKindDirectory(join(root, 'brains'), 'brain', false, yamlMode, diagnostics, files);
347
408
  const roles = readKindDirectory(join(root, 'roles'), 'role', false, yamlMode, diagnostics, files);
409
+ const agentTemplates = readKindDirectory(join(root, 'agent_templates'), 'agent template', false, yamlMode, diagnostics, files);
348
410
  const agents = readKindDirectory(join(root, 'agents'), 'agent', true, yamlMode, diagnostics, files);
349
411
  if (additionalAgent) {
350
412
  if (agents.has(additionalAgent.id))
@@ -354,11 +416,67 @@ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files, addition
354
416
  });
355
417
  }
356
418
  const vars = (baseDoc.vars ?? {});
419
+ for (const [id, preset] of roles) {
420
+ const value = assertBareKeys(preset.value, ROLE_PRESET_KEYS, `${preset.file}: Role '${id}'`);
421
+ validateRoleValue(value, preset.file, `/roles/${id}`);
422
+ roleSink[id] = deepSubStrict(value, vars, preset.file, `/roles/${id}`);
423
+ }
424
+ for (const [id, preset] of brains) {
425
+ const value = assertBareKeys(preset.value, BRAIN_PRESET_KEYS, `${preset.file}: Brain '${id}'`);
426
+ validateBrainValue(value, preset.file, `/brains/${id}`);
427
+ brainSink[id] = deepSubStrict(value, vars, preset.file, `/brains/${id}`);
428
+ }
429
+ const canonicalTemplates = new Map();
430
+ for (const [id, template] of agentTemplates) {
431
+ const value = assertBareKeys(template.value, AGENT_KEYS, `${template.file}: agent template '${id}'`);
432
+ if (!('role' in value) || !('brain' in value))
433
+ throw new ConfigError(`${template.file}: agent template '${id}' requires role and brain`);
434
+ const forbidden = TEMPLATE_FORBIDDEN_KEYS.filter(key => Object.hasOwn(value, key));
435
+ if (forbidden.length)
436
+ schemaError(template.file, `/agent_templates/${id}/${forbidden[0]}`, 'templates cannot own instance identity');
437
+ const templateRole = selection(value.role, 'role', template.file, roles, ROLE_PRESET_KEYS);
438
+ const templateBrain = selection(value.brain, 'brain', template.file, brains, BRAIN_PRESET_KEYS);
439
+ validateRoleValue(templateRole.value, templateRole.file, templateRole.pointer);
440
+ validateBrainValue(templateBrain.value, templateBrain.file, templateBrain.pointer);
441
+ validateAgentScalars(Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'role' && key !== 'brain')), template.file, id);
442
+ canonicalTemplates.set(id, { file: template.file, value });
443
+ const operational = deepSubStrict(Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'role' && key !== 'brain')), vars, template.file, `/agent_templates/${id}`);
444
+ templateSink[id] = {
445
+ role: { inline: deepSubStrict(templateRole.value, vars, templateRole.file, templateRole.pointer) },
446
+ brain: { inline: deepSubStrict(templateBrain.value, vars, templateBrain.file, templateBrain.pointer) },
447
+ ...operational,
448
+ };
449
+ }
357
450
  const documents = [];
358
451
  for (const [id, agent] of agents) {
359
- const a = assertBareKeys(agent.value, AGENT_KEYS, `${agent.file}: agent '${id}'`);
452
+ let a;
453
+ let agentTemplate;
454
+ const instanceKeys = Object.keys(agent.value);
455
+ if (instanceKeys.includes('template')) {
456
+ assertBareKeys(agent.value, AGENT_INSTANCE_KEYS, `${agent.file}: agent '${id}'`);
457
+ if (typeof agent.value.template !== 'string' || !ROLE_NAME_RE.test(agent.value.template))
458
+ schemaError(agent.file, `/agents/${id}/template`, 'must be a valid Agent Template ID');
459
+ const template = canonicalTemplates.get(agent.value.template);
460
+ if (!template)
461
+ throw new ConfigError(`${agent.file}: E_REF_MISSING: agent template '${agent.value.template}' not found`);
462
+ if (agent.value.overrides !== undefined && !isPlainObject(agent.value.overrides))
463
+ schemaError(agent.file, `/agents/${id}/overrides`, 'must be a mapping');
464
+ const overrides = assertBareKeys((agent.value.overrides ?? {}), AGENT_KEYS, `${agent.file}: agent '${id}' overrides`);
465
+ a = mergeTemplateDefinition(template.value, overrides, agent.file, id);
466
+ const resolvedTemplate = templateSink[agent.value.template];
467
+ agentTemplate = {
468
+ id: agent.value.template,
469
+ sourceFile: template.file,
470
+ contentHash: createHash('sha256').update(canonicalJson(resolvedTemplate)).digest('hex'),
471
+ };
472
+ }
473
+ else {
474
+ a = assertBareKeys(agent.value, AGENT_KEYS, `${agent.file}: agent '${id}'`);
475
+ }
360
476
  if (!('role' in a) || !('brain' in a))
361
477
  throw new ConfigError(`${agent.file}: agent '${id}' requires role and brain`);
478
+ if (Object.hasOwn(a, 'loops') && !(additionalAgent?.temporary && additionalAgent.id === id))
479
+ schemaError(agent.file, `/agents/${id}/loops`, 'is supported only for temporary Agent launches and inert Agent Templates');
362
480
  const role = selection(a.role, 'role', agent.file, roles, ROLE_PRESET_KEYS);
363
481
  const brain = selection(a.brain, 'brain', agent.file, brains, BRAIN_PRESET_KEYS);
364
482
  const operational = deepSubStrict(Object.fromEntries(Object.entries(a).filter(([key]) => key !== 'role' && key !== 'brain')), vars, agent.file, `/agents/${id}`);
@@ -433,10 +551,12 @@ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files, addition
433
551
  brain: deepSubStrict(a.brain, vars, agent.file, `/agents/${id}/brain`),
434
552
  ...operational,
435
553
  };
554
+ const { loops: _temporaryLoops, ...runtimeOperational } = operational;
436
555
  documents.push({ file: agent.file,
437
- doc: { roles: { [id]: { ...brainValue, ...roleValue, ...operational } } }, provenance,
556
+ doc: { roles: { [id]: { ...brainValue, ...roleValue, ...runtimeOperational } } }, provenance,
438
557
  agentSelections: structuredClone({ role: canonicalDefinition.role, brain: canonicalDefinition.brain }),
439
- agentDefinition: canonicalDefinition });
558
+ agentDefinition: canonicalDefinition,
559
+ ...(agentTemplate ? { agentTemplate } : {}) });
440
560
  }
441
561
  return documents;
442
562
  }
@@ -454,6 +574,9 @@ export function loadConfig(configPath, options = {}) {
454
574
  const base = configPath ?? defaultConfigPath();
455
575
  const files = [];
456
576
  const diagnostics = [];
577
+ const agentTemplates = {};
578
+ const rolePresets = {};
579
+ const brainPresets = {};
457
580
  let docs = [];
458
581
  let manifestDoc = {};
459
582
  if (existsSync(base)) {
@@ -472,7 +595,7 @@ export function loadConfig(configPath, options = {}) {
472
595
  const dd = fleetDDir();
473
596
  if (existsSync(dd) && readdirSync(dd).some(f => f.endsWith('.yaml') || f.endsWith('.yml')))
474
597
  throw new ConfigError(`${dd}: legacy fleet.d configuration is unsupported; run 'ours-fleet doctor' and move agents to ${splitRootFor(base)}/agents/`);
475
- docs = [{ file: base, doc: manifestDoc }, ...composeSplitRoles(base, manifestDoc, options.yamlMode ?? 'compat', diagnostics, files, options.additionalAgent)];
598
+ docs = [{ file: base, doc: manifestDoc }, ...composeSplitRoles(base, manifestDoc, options.yamlMode ?? 'compat', diagnostics, files, options.additionalAgent, agentTemplates, rolePresets, brainPresets)];
476
599
  const baseDoc = manifestDoc;
477
600
  const vars = (baseDoc.vars ?? {});
478
601
  const defaults = (baseDoc.defaults ?? {});
@@ -485,7 +608,7 @@ export function loadConfig(configPath, options = {}) {
485
608
  const seen = new Map();
486
609
  const roles = [];
487
610
  const agentDefinitions = {};
488
- for (const { file, doc, provenance, agentSelections, agentDefinition } of docs) {
611
+ for (const { file, doc, provenance, agentSelections, agentDefinition, agentTemplate } of docs) {
489
612
  for (const [name, raw] of Object.entries((doc.roles ?? {}))) {
490
613
  if (!ROLE_NAME_RE.test(name))
491
614
  throw new ConfigError(`${file}: invalid role name '${name}' (allowed: [A-Za-z0-9_-])`);
@@ -525,6 +648,8 @@ export function loadConfig(configPath, options = {}) {
525
648
  const worklog = resolveWorklogPolicy(defaults.worklog, r.worklog, file, name);
526
649
  const authProxy = resolveAuthProxy(defaults.auth_proxy, r.auth_proxy, file, name);
527
650
  const harness = r.harness ?? defaults.harness ?? 'claude-code';
651
+ if (session === 'codex-app-server' && harness !== 'codex')
652
+ throw new ConfigError(`${file}: role '${name}' session: codex-app-server requires harness: codex`);
528
653
  const defaultHarness = defaults.harness ?? 'claude-code';
529
654
  const inheritsModelDefaults = harness === defaultHarness && r.model !== null;
530
655
  if (authProxy && harness !== 'claude-code')
@@ -559,7 +684,7 @@ export function loadConfig(configPath, options = {}) {
559
684
  defaulted('identity', r.identity !== undefined, r.identity === undefined);
560
685
  defaulted('permissions', defaults.permissions !== undefined, defaults.permissions === undefined);
561
686
  defaulted('monitor', defaults.monitor !== undefined, defaults.monitor === undefined);
562
- roles.push({
687
+ const resolvedRole = {
563
688
  ...r,
564
689
  name,
565
690
  sourceFile: file,
@@ -582,7 +707,11 @@ export function loadConfig(configPath, options = {}) {
582
707
  env: Object.keys(env).length ? env : undefined,
583
708
  loops: [],
584
709
  ...(agentSelections ? { agentSelections } : {}),
585
- });
710
+ ...(agentTemplate ? { agentTemplate } : {}),
711
+ };
712
+ if (agentDefinition?.loops !== undefined)
713
+ resolvedRole.temporaryLoops = resolveAgentLoops(agentDefinition.loops, resolvedRole, file);
714
+ roles.push(resolvedRole);
586
715
  // Forbidden-path enforcement: a mount that would breach the policy
587
716
  // is a configuration error, caught by `config` rather than at launch.
588
717
  if (isolation !== undefined) {
@@ -653,7 +782,7 @@ export function loadConfig(configPath, options = {}) {
653
782
  }
654
783
  }
655
784
  const result = {
656
- roles, agentDefinitions, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
785
+ roles, agentDefinitions, agentTemplates, rolePresets, brainPresets, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
657
786
  loops: resolvedLoops.loops,
658
787
  rooms, roomTemplates, tasks, ownerInviteFingerprint,
659
788
  configMode: 'split-v2',
@@ -662,11 +791,18 @@ export function loadConfig(configPath, options = {}) {
662
791
  return { kind: 'Manifest', path };
663
792
  const parent = basename(dirname(path));
664
793
  const kind = parent === 'agents' ? 'Agent'
665
- : parent === 'roles' ? 'Role'
666
- : parent === 'room_templates' ? 'RoomTemplate' : 'Brain';
794
+ : parent === 'agent_templates' ? 'AgentTemplate'
795
+ : parent === 'roles' ? 'Role'
796
+ : parent === 'room_templates' ? 'RoomTemplate' : 'Brain';
667
797
  return { kind, id: basename(path, extname(path)), path };
668
798
  }),
669
799
  };
800
+ Object.defineProperty(result, 'resolveAgentDefinition', {
801
+ value: (id, definition) => resolveAgentDefinitionDryRun(base, id, definition),
802
+ enumerable: false,
803
+ writable: false,
804
+ configurable: false,
805
+ });
670
806
  if (ownerInvite !== undefined) {
671
807
  Object.defineProperty(result, 'ownerInvite', {
672
808
  value: ownerInvite,
@@ -677,6 +813,12 @@ export function loadConfig(configPath, options = {}) {
677
813
  }
678
814
  return result;
679
815
  }
816
+ /** Full read-only Agent resolution used by launch dry-runs and room plan validation. */
817
+ export function resolveAgentDefinitionDryRun(configPath, id, definition) {
818
+ return findRole(loadConfig(configPath, {
819
+ additionalAgent: { id, definition, temporary: true }, skipWatchdogs: true,
820
+ }), id);
821
+ }
680
822
  /**
681
823
  * Canonical form of a 64-hex container ID for authorization decisions. Hex
682
824
  * case is not identity: two casings of one CID are the same peer, so every
@@ -760,8 +902,8 @@ export function resolveOwnerChannelConfig(defaults, role, session, file = 'confi
760
902
  const maxRequestBytes = attachments.max_request_bytes ?? 20 * 1024 * 1024;
761
903
  if (maxRequestBytes < maxFileBytes)
762
904
  throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.max_request_bytes must be at least max_file_bytes`);
763
- if (session !== 'acp')
764
- throw new ConfigError(`${file}: role '${name}' owner_channel requires session: acp for correlated final replies`);
905
+ if (session !== 'acp' && session !== 'codex-app-server')
906
+ throw new ConfigError(`${file}: role '${name}' owner_channel requires a managed session with correlated final replies`);
765
907
  return {
766
908
  identity: merged.identity.trim(),
767
909
  owners,
@@ -781,7 +923,13 @@ export function resolveOwnerChannelConfig(defaults, role, session, file = 'confi
781
923
  };
782
924
  }
783
925
  function validateOwnerChannelIdentities(roles) {
784
- const roleIdentities = new Map(roles.map(role => [role.identity, role.name]));
926
+ const roleIdentities = new Map();
927
+ for (const role of roles) {
928
+ const prior = roleIdentities.get(role.identity);
929
+ if (prior)
930
+ throw new ConfigError(`persistent Agents '${prior}' and '${role.name}' resolve to duplicate identity '${role.identity}'`);
931
+ roleIdentities.set(role.identity, role.name);
932
+ }
785
933
  const channels = new Map();
786
934
  for (const role of roles) {
787
935
  const identity = role.owner_channel?.identity;
@@ -887,8 +1035,8 @@ function resolveSession(raw, file, name) {
887
1035
  if (value === 'tmux')
888
1036
  throw new ConfigError(`${file}: role '${name}' session: tmux is no longer supported; use session: acp `
889
1037
  + 'with the Codex or Claude Code adapter');
890
- if (value !== 'acp')
891
- throw new ConfigError(`${file}: role '${name}' session: must be: acp`);
1038
+ if (value !== 'acp' && value !== 'codex-app-server')
1039
+ throw new ConfigError(`${file}: role '${name}' session: must be: acp, codex-app-server`);
892
1040
  return value;
893
1041
  }
894
1042
  function resolveSessionOptions(defaults, role, session, file, name) {
@@ -903,26 +1051,40 @@ function resolveSessionOptions(defaults, role, session, file, name) {
903
1051
  ...((defaults?.acp) ?? {}),
904
1052
  ...(role?.acp ?? {}),
905
1053
  },
1054
+ codex_app_server: {
1055
+ ...((defaults?.codex_app_server) ?? {}),
1056
+ ...(role?.codex_app_server ?? {}),
1057
+ },
906
1058
  };
907
1059
  if (defaults?.tmux !== undefined
908
1060
  || role?.tmux !== undefined)
909
1061
  throw new ConfigError(`${file}: role '${name}' session_options.tmux is no longer supported; `
910
1062
  + 'use session: acp with session_options.acp');
911
- const bad = Object.keys(merged).filter(k => k !== 'acp');
1063
+ const bad = Object.keys(merged).filter(k => k !== 'acp' && k !== 'codex_app_server');
912
1064
  if (bad.length)
913
1065
  throw new ConfigError(`${file}: role '${name}' session_options: unknown key(s) ${bad.join(', ')}`);
914
1066
  if (!isPlainObject(merged.acp))
915
- throw new ConfigError(`${file}: role '${name}' session_options.${session} must be a map`);
1067
+ throw new ConfigError(`${file}: role '${name}' session_options.acp must be a map`);
1068
+ if (!isPlainObject(merged.codex_app_server))
1069
+ throw new ConfigError(`${file}: role '${name}' session_options.codex_app_server must be a map`);
916
1070
  const acpBad = Object.keys(merged.acp).filter(k => k !== 'command');
917
1071
  if (acpBad.length)
918
1072
  throw new ConfigError(`${file}: role '${name}' session_options.acp: unknown key(s) ${acpBad.join(', ')}`);
919
- const command = merged.acp.command;
920
- if (command !== undefined
921
- && !(typeof command === 'string' && command.trim())
922
- && !(Array.isArray(command) && command.length > 0
923
- && command.every(v => typeof v === 'string' && v.length > 0)))
924
- throw new ConfigError(`${file}: role '${name}' session_options.acp.command must be a non-empty string or string list`);
925
- return Object.keys(merged.acp).length ? merged : undefined;
1073
+ const nativeBad = Object.keys(merged.codex_app_server).filter(k => k !== 'command');
1074
+ if (nativeBad.length)
1075
+ throw new ConfigError(`${file}: role '${name}' session_options.codex_app_server: unknown key(s) ${nativeBad.join(', ')}`);
1076
+ for (const [key, command] of [
1077
+ ['acp', merged.acp.command],
1078
+ ['codex_app_server', merged.codex_app_server.command],
1079
+ ]) {
1080
+ if (command !== undefined
1081
+ && !(typeof command === 'string' && command.trim())
1082
+ && !(Array.isArray(command) && command.length > 0
1083
+ && command.every(v => typeof v === 'string' && v.length > 0)))
1084
+ throw new ConfigError(`${file}: role '${name}' session_options.${key}.command must be a non-empty string or string list`);
1085
+ }
1086
+ return Object.keys(merged.acp).length || Object.keys(merged.codex_app_server).length
1087
+ ? merged : undefined;
926
1088
  }
927
1089
  export function resolvePermissions(defaults, role, file = 'config', name = 'role') {
928
1090
  if (defaults !== undefined && !isPlainObject(defaults))
@@ -1007,7 +1169,10 @@ export function resolveMonitorConfig(defMonitor, roleMonitor, labels = {}) {
1007
1169
  }
1008
1170
  export function findRole(cfg, name) {
1009
1171
  const r = cfg.roles.find(r => r.name === name);
1010
- if (!r)
1172
+ if (!r) {
1173
+ if (cfg.agentTemplates?.[name])
1174
+ throw new ConfigError(`'${name}' is an inert Agent Template, not a persistent Agent; lifecycle commands target only fleet/agents instances`);
1011
1175
  throw new ConfigError(`no such role '${name}' in ${cfg.files.join(', ') || 'config'}`);
1176
+ }
1012
1177
  return r;
1013
1178
  }
@@ -156,7 +156,7 @@ export declare function daemonIdentityInventoryProvisioner(env?: NodeJS.ProcessE
156
156
  /** Atomically write a bare Agent file, journalling it for rollback. */
157
157
  export declare function writeRoleFile(tx: CreationTransaction, file: string, contents: string): void;
158
158
  /** Where a setting's effective value came from. */
159
- export type ProvenanceSource = 'cli' | 'fleet-default' | 'caller-role' | 'built-in';
159
+ export type ProvenanceSource = 'cli' | 'agent-template' | 'fleet-default' | 'caller-role' | 'built-in';
160
160
  export interface ProvenanceEntry {
161
161
  value: unknown;
162
162
  source: ProvenanceSource;
package/dist/creation.js CHANGED
@@ -258,11 +258,13 @@ export function daemonIdentityProvisioner(env = process.env, attachClient = atta
258
258
  const before = await client.listIdentities();
259
259
  const existing = before.find(identity => identity.name === name);
260
260
  if (existing) {
261
+ if (!('kind' in existing))
262
+ throw new Error(`identity '${name}' is quarantined; refusing to replace it`);
261
263
  if (existing.temp)
262
264
  throw new Error(`identity '${name}' exists but is temporary; refusing to convert or adopt it`);
263
265
  return; // another reconciler won the create race
264
266
  }
265
- if (!before.some(identity => identity.kind === 'root'))
267
+ if (!before.some(identity => 'kind' in identity && identity.kind === 'root'))
266
268
  throw new Error(`cannot create role identity '${name}': this host has no Human identity; run ours onboarding first`);
267
269
  let createdHere = false;
268
270
  try {
@@ -279,7 +281,7 @@ export function daemonIdentityProvisioner(env = process.env, attachClient = atta
279
281
  // accept only the compatible permanent identity now visible.
280
282
  const after = await client.listIdentities();
281
283
  const raced = after.find(identity => identity.name === name);
282
- if (!raced || raced.temp)
284
+ if (!raced || !('kind' in raced) || raced.temp)
283
285
  throw error;
284
286
  }
285
287
  if (createdHere && profile.persona && client.setPersona)
@@ -374,11 +376,12 @@ export function creationBuildNote(p) {
374
376
  export function formatProvenance(p) {
375
377
  const mark = {
376
378
  cli: 'explicit', 'fleet-default': 'fleet default', 'caller-role': 'caller role',
377
- 'built-in': 'built-in',
379
+ 'agent-template': 'Agent Template', 'built-in': 'built-in',
378
380
  };
379
381
  return Object.entries(p.settings)
380
382
  .filter(([, e]) => e.value !== undefined)
381
- .map(([k, e]) => ` ${k.padEnd(12)} ${String(e.value)} (${mark[e.source]})`);
383
+ .map(([k, e]) => ` ${k.padEnd(12)} ${e.value && typeof e.value === 'object'
384
+ ? JSON.stringify(e.value) : String(e.value)} (${mark[e.source]})`);
382
385
  }
383
386
  /** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
384
387
  export function provenanceOf(cliValue, fleetDefault, builtIn) {