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

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 (202) hide show
  1. package/README.md +204 -55
  2. package/dist/agent-recovery-gate.d.ts +17 -0
  3. package/dist/agent-recovery-gate.js +135 -0
  4. package/dist/application/capabilities.js +18 -6
  5. package/dist/application/fleet-query-service.js +20 -5
  6. package/dist/application/role-command-service.js +9 -0
  7. package/dist/application/role-creation-service.d.ts +6 -25
  8. package/dist/application/role-creation-service.js +34 -72
  9. package/dist/application/role-repository.d.ts +4 -2
  10. package/dist/application/role-repository.js +6 -3
  11. package/dist/application/session-control.d.ts +3 -1
  12. package/dist/application/session-control.js +4 -2
  13. package/dist/application/task-room-service.d.ts +58 -6
  14. package/dist/application/task-room-service.js +435 -79
  15. package/dist/briefing.js +74 -24
  16. package/dist/build-info.json +4 -4
  17. package/dist/canonical-json.d.ts +2 -0
  18. package/dist/canonical-json.js +10 -0
  19. package/dist/cli.js +241 -76
  20. package/dist/config-yaml.d.ts +1 -1
  21. package/dist/config.d.ts +75 -3
  22. package/dist/config.js +274 -34
  23. package/dist/creation.d.ts +1 -1
  24. package/dist/creation.js +3 -2
  25. package/dist/daemon-recovery.d.ts +93 -0
  26. package/dist/daemon-recovery.js +328 -0
  27. package/dist/docs.d.ts +1 -1
  28. package/dist/docs.js +192 -66
  29. package/dist/doctor.d.ts +2 -0
  30. package/dist/doctor.js +54 -4
  31. package/dist/fleet-command-audit.d.ts +165 -0
  32. package/dist/fleet-command-audit.js +555 -0
  33. package/dist/fleet-proxy.d.ts +7 -2
  34. package/dist/fleet-proxy.js +30 -8
  35. package/dist/harness/agent-session.d.ts +5 -0
  36. package/dist/harness/claude-code-session.d.ts +2 -0
  37. package/dist/harness/claude-code-session.js +6 -1
  38. package/dist/harness/claude-code.js +2 -0
  39. package/dist/harness/codex-app-server-proxy.d.ts +40 -0
  40. package/dist/harness/codex-app-server-proxy.js +348 -14
  41. package/dist/harness/codex-session.d.ts +11 -2
  42. package/dist/harness/codex-session.js +22 -2
  43. package/dist/harness/codex.d.ts +5 -1
  44. package/dist/harness/codex.js +110 -10
  45. package/dist/harness/types.d.ts +1 -1
  46. package/dist/index.d.ts +5 -4
  47. package/dist/index.js +4 -3
  48. package/dist/init-guidance.d.ts +1 -1
  49. package/dist/init-guidance.js +4 -3
  50. package/dist/init-wizard.d.ts +98 -0
  51. package/dist/init-wizard.js +629 -0
  52. package/dist/lifecycle-summary.d.ts +134 -0
  53. package/dist/lifecycle-summary.js +179 -0
  54. package/dist/loops/config.d.ts +10 -1
  55. package/dist/loops/config.js +38 -5
  56. package/dist/model-env.d.ts +2 -2
  57. package/dist/model-env.js +1 -1
  58. package/dist/model-recovery.d.ts +1 -1
  59. package/dist/ops.js +2 -17
  60. package/dist/owner-channel/channel.d.ts +58 -0
  61. package/dist/owner-channel/channel.js +321 -42
  62. package/dist/owner-channel/commands.d.ts +6 -1
  63. package/dist/owner-channel/commands.js +34 -19
  64. package/dist/owner-channel/state.d.ts +2 -0
  65. package/dist/owner-channel/state.js +8 -2
  66. package/dist/permissions.js +1 -1
  67. package/dist/preset-bootstrap.d.ts +10 -0
  68. package/dist/preset-bootstrap.js +85 -0
  69. package/dist/preset-migration.d.ts +42 -0
  70. package/dist/preset-migration.js +414 -0
  71. package/dist/reports/artifact.d.ts +10 -0
  72. package/dist/reports/artifact.js +32 -0
  73. package/dist/reports/index.d.ts +5 -0
  74. package/dist/reports/index.js +5 -0
  75. package/dist/reports/render.d.ts +6 -0
  76. package/dist/reports/render.js +240 -0
  77. package/dist/reports/service.d.ts +42 -0
  78. package/dist/reports/service.js +57 -0
  79. package/dist/reports/tasks.d.ts +52 -0
  80. package/dist/reports/tasks.js +177 -0
  81. package/dist/reports/types.d.ts +315 -0
  82. package/dist/reports/types.js +6 -0
  83. package/dist/resolved-plan.js +13 -1
  84. package/dist/rooms-tasks/cli.d.ts +2 -0
  85. package/dist/rooms-tasks/cli.js +514 -75
  86. package/dist/rooms-tasks/close.d.ts +9 -1
  87. package/dist/rooms-tasks/close.js +20 -4
  88. package/dist/rooms-tasks/config.js +16 -14
  89. package/dist/rooms-tasks/cowork-adapter.d.ts +1 -0
  90. package/dist/rooms-tasks/cowork-adapter.js +6 -0
  91. package/dist/rooms-tasks/deletion.d.ts +51 -0
  92. package/dist/rooms-tasks/deletion.js +217 -0
  93. package/dist/rooms-tasks/external-worker.d.ts +1 -0
  94. package/dist/rooms-tasks/external-worker.js +14 -9
  95. package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
  96. package/dist/rooms-tasks/launch-snapshot.js +181 -0
  97. package/dist/rooms-tasks/member-overrides.d.ts +30 -0
  98. package/dist/rooms-tasks/member-overrides.js +204 -0
  99. package/dist/rooms-tasks/provision.js +184 -49
  100. package/dist/rooms-tasks/room-state.d.ts +1 -0
  101. package/dist/rooms-tasks/room-state.js +12 -1
  102. package/dist/rooms-tasks/task-state.d.ts +115 -3
  103. package/dist/rooms-tasks/task-state.js +361 -11
  104. package/dist/rooms-tasks/templates.d.ts +4 -2
  105. package/dist/rooms-tasks/templates.js +42 -78
  106. package/dist/rooms-tasks/terminal.d.ts +3 -0
  107. package/dist/rooms-tasks/terminal.js +7 -3
  108. package/dist/rooms-tasks/types.d.ts +96 -17
  109. package/dist/rooms-tasks/types.js +14 -4
  110. package/dist/runner.d.ts +12 -2
  111. package/dist/runner.js +124 -43
  112. package/dist/session/acp.d.ts +9 -1
  113. package/dist/session/acp.js +35 -9
  114. package/dist/session/arbiter.d.ts +2 -1
  115. package/dist/session/arbiter.js +2 -0
  116. package/dist/session/codex-app-server-transport.d.ts +53 -0
  117. package/dist/session/codex-app-server-transport.js +184 -0
  118. package/dist/session/codex-app-server.d.ts +122 -0
  119. package/dist/session/codex-app-server.js +1031 -0
  120. package/dist/session/control.d.ts +14 -1
  121. package/dist/session/control.js +38 -0
  122. package/dist/session/conversation-types.d.ts +4 -4
  123. package/dist/session/types.d.ts +24 -6
  124. package/dist/session/types.js +28 -0
  125. package/dist/spawn.d.ts +15 -31
  126. package/dist/spawn.js +117 -237
  127. package/dist/temp-lifecycle.d.ts +1 -1
  128. package/dist/watchdog/config.d.ts +10 -7
  129. package/dist/watchdog/config.js +29 -19
  130. package/dist/watchdog/run.js +4 -13
  131. package/dist/web/fleet-config-service.d.ts +1 -0
  132. package/dist/web/fleet-config-service.js +27 -8
  133. package/dist/web/runtime.js +11 -6
  134. package/dist/web/server.js +28 -1
  135. package/dist/web/topology-promote.js +8 -1
  136. package/dist/web-app/assets/index-C0h3ALvs.js +10 -0
  137. package/dist/web-app/index.html +1 -1
  138. package/package.json +3 -2
  139. package/presets/brain-catalog.json +16 -0
  140. package/presets/fleet/agent_templates/Critic.yaml +4 -0
  141. package/presets/fleet/agent_templates/Developer.yaml +4 -0
  142. package/presets/fleet/agent_templates/LocalCoordinator.yaml +4 -0
  143. package/presets/fleet/agents/FleetCoordinator.yaml +3 -0
  144. package/presets/fleet/brains/claude-default.yaml +5 -0
  145. package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
  146. package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
  147. package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
  148. package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
  149. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
  150. package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
  151. package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
  152. package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
  153. package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
  154. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
  155. package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
  156. package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
  157. package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
  158. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
  159. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
  160. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
  161. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
  162. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
  163. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
  164. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
  165. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
  166. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
  167. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
  168. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
  169. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
  170. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
  171. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
  172. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
  173. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
  174. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
  175. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
  176. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
  177. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
  178. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
  179. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
  180. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
  181. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
  182. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
  183. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
  184. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
  185. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
  186. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
  187. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
  188. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
  189. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
  190. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
  191. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
  192. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
  193. package/presets/fleet/roles/Coordinator.yaml +30 -0
  194. package/presets/fleet/roles/Critic.yaml +19 -0
  195. package/presets/fleet/roles/Developer.yaml +19 -0
  196. package/presets/fleet/roles/LocalCoordinator.yaml +25 -0
  197. package/presets/fleet/room_templates/pair.yaml +10 -0
  198. package/presets/fleet/room_templates/single.yaml +7 -0
  199. package/presets/fleet/room_templates/team.yaml +12 -0
  200. package/presets/fleet.yaml +7 -0
  201. package/presets/manifest.json +6 -0
  202. package/dist/web-app/assets/index-BbE9EX6n.js +0 -10
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,
@@ -133,21 +135,26 @@ const ROLE_KEYS = [
133
135
  'briefing_file', 'model', 'effort', 'model_chain', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
134
136
  'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy',
135
137
  ];
136
- const ROLE_PRESET_KEYS = ['mission', 'persona', 'bio', 'briefing_file'];
137
- const BRAIN_PRESET_KEYS = [
138
+ export const ROLE_PRESET_KEYS = ['mission', 'persona', 'bio', 'briefing_file'];
139
+ export const BRAIN_PRESET_KEYS = [
138
140
  'harness', 'session', 'session_options', 'model', 'model_chain', 'max_tokens',
139
141
  'autocompact_pct', 'harness_options', 'effort',
140
142
  ];
141
- const AGENT_KEYS = [
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) {
148
155
  throw new ConfigError(`${file}: E_SCHEMA ${pointer}: ${summary}`);
149
156
  }
150
- function validateRoleValue(value, file, pointer) {
157
+ export function validateRoleValue(value, file, pointer) {
151
158
  const limits = {
152
159
  mission: { bytes: 16 * 1024, nonblank: true },
153
160
  persona: { bytes: 16 * 1024 },
@@ -168,7 +175,7 @@ function validateRoleValue(value, file, pointer) {
168
175
  && (typeof value.briefing_file !== 'string' || !value.briefing_file.trim()))
169
176
  schemaError(file, `${pointer}/briefing_file`, 'must be a non-blank relative path');
170
177
  }
171
- function validateBrainValue(value, file, pointer) {
178
+ export function validateBrainValue(value, file, pointer) {
172
179
  if (typeof value.harness !== 'string' || !value.harness.trim())
173
180
  schemaError(file, `${pointer}/harness`, 'is required and must be a registered non-blank harness ID');
174
181
  if (value.model !== undefined && value.model !== null
@@ -179,7 +186,45 @@ 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()))
@@ -228,7 +273,7 @@ function assertTrustedPath(path, expected) {
228
273
  if ((st.mode & 0o022) !== 0)
229
274
  throw new ConfigError(`E_FILE_TRUST: ${path} is group/world writable (mode ${(st.mode & 0o777).toString(8)})`);
230
275
  }
231
- function assertBareKeys(value, allowed, label) {
276
+ export function assertBareKeys(value, allowed, label) {
232
277
  if (!isPlainObject(value))
233
278
  throw new ConfigError(`${label}: E_SCHEMA: must be a mapping`);
234
279
  const bad = Object.keys(value).filter(key => !allowed.includes(key));
@@ -265,6 +310,35 @@ function readKindDirectory(root, kind, required, yamlMode, diagnostics, files) {
265
310
  }
266
311
  return result;
267
312
  }
313
+ function readRoomTemplateDirectory(root, yamlMode, diagnostics, files) {
314
+ if (!existsSync(root))
315
+ return {};
316
+ assertTrustedPath(root, 'directory');
317
+ const result = {};
318
+ const sources = new Map();
319
+ const names = readdirSync(root)
320
+ .filter(name => ['.yaml', '.yml'].includes(extname(name).toLowerCase())).sort();
321
+ for (const filename of names) {
322
+ const file = join(root, filename);
323
+ assertTrustedPath(file, 'file');
324
+ const id = basename(filename, extname(filename));
325
+ const previous = sources.get(id);
326
+ if (previous)
327
+ throw new ConfigError(`E_DUPLICATE_ID: room template '${id}' defined by both ${previous} and ${file}`);
328
+ const parsed = parseFleetDocument(file, readFileSync(file, 'utf8'), yamlMode);
329
+ diagnostics.push(...parsed.diagnostics);
330
+ if (parsed.value.override_builtin === true)
331
+ diagnostics.push({
332
+ severity: 'warning', kind: 'deprecated-field', file, line: 1, column: 1,
333
+ message: `${file}: override_builtin is deprecated and ignored for a file-backed Room template`,
334
+ });
335
+ files.push(file);
336
+ const validated = validateRoomTemplatesConfig({ [id]: parsed.value }, file)[id];
337
+ result[id] = { ...validated, sourceFile: file };
338
+ sources.set(id, file);
339
+ }
340
+ return result;
341
+ }
268
342
  function selection(raw, kind, agentFile, presets, allowed) {
269
343
  if (!isPlainObject(raw))
270
344
  schemaError(agentFile, `/${kind}`, 'must be { ref } or { inline }');
@@ -305,7 +379,23 @@ function deepSubStrict(value, vars, file, pointer) {
305
379
  ]));
306
380
  return value;
307
381
  }
308
- function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files) {
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 = {}) {
309
399
  if (baseDoc.api_version !== V2_API_VERSION)
310
400
  throw new ConfigError(`${base}: legacy fleet configuration is unsupported; run 'ours-fleet doctor' and rewrite it as ${V2_API_VERSION}`);
311
401
  if ('roles' in baseDoc)
@@ -316,13 +406,77 @@ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files) {
316
406
  assertTrustedPath(root, 'directory');
317
407
  const brains = readKindDirectory(join(root, 'brains'), 'brain', false, yamlMode, diagnostics, files);
318
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);
319
410
  const agents = readKindDirectory(join(root, 'agents'), 'agent', true, yamlMode, diagnostics, files);
411
+ if (additionalAgent) {
412
+ if (agents.has(additionalAgent.id))
413
+ throw new ConfigError(`agent '${additionalAgent.id}' already exists`);
414
+ agents.set(additionalAgent.id, {
415
+ file: '(spawn request)', value: structuredClone(additionalAgent.definition),
416
+ });
417
+ }
320
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
+ }
321
450
  const documents = [];
322
451
  for (const [id, agent] of agents) {
323
- 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
+ }
324
476
  if (!('role' in a) || !('brain' in a))
325
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');
326
480
  const role = selection(a.role, 'role', agent.file, roles, ROLE_PRESET_KEYS);
327
481
  const brain = selection(a.brain, 'brain', agent.file, brains, BRAIN_PRESET_KEYS);
328
482
  const operational = deepSubStrict(Object.fromEntries(Object.entries(a).filter(([key]) => key !== 'role' && key !== 'brain')), vars, agent.file, `/agents/${id}`);
@@ -392,8 +546,17 @@ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files) {
392
546
  : undefined);
393
547
  delete provenance.role;
394
548
  delete provenance.brain;
549
+ const canonicalDefinition = {
550
+ role: deepSubStrict(a.role, vars, agent.file, `/agents/${id}/role`),
551
+ brain: deepSubStrict(a.brain, vars, agent.file, `/agents/${id}/brain`),
552
+ ...operational,
553
+ };
554
+ const { loops: _temporaryLoops, ...runtimeOperational } = operational;
395
555
  documents.push({ file: agent.file,
396
- doc: { roles: { [id]: { ...brainValue, ...roleValue, ...operational } } }, provenance });
556
+ doc: { roles: { [id]: { ...brainValue, ...roleValue, ...runtimeOperational } } }, provenance,
557
+ agentSelections: structuredClone({ role: canonicalDefinition.role, brain: canonicalDefinition.brain }),
558
+ agentDefinition: canonicalDefinition,
559
+ ...(agentTemplate ? { agentTemplate } : {}) });
397
560
  }
398
561
  return documents;
399
562
  }
@@ -411,6 +574,9 @@ export function loadConfig(configPath, options = {}) {
411
574
  const base = configPath ?? defaultConfigPath();
412
575
  const files = [];
413
576
  const diagnostics = [];
577
+ const agentTemplates = {};
578
+ const rolePresets = {};
579
+ const brainPresets = {};
414
580
  let docs = [];
415
581
  let manifestDoc = {};
416
582
  if (existsSync(base)) {
@@ -429,14 +595,20 @@ export function loadConfig(configPath, options = {}) {
429
595
  const dd = fleetDDir();
430
596
  if (existsSync(dd) && readdirSync(dd).some(f => f.endsWith('.yaml') || f.endsWith('.yml')))
431
597
  throw new ConfigError(`${dd}: legacy fleet.d configuration is unsupported; run 'ours-fleet doctor' and move agents to ${splitRootFor(base)}/agents/`);
432
- docs = [{ file: base, doc: manifestDoc }, ...composeSplitRoles(base, manifestDoc, options.yamlMode ?? 'compat', diagnostics, files)];
598
+ docs = [{ file: base, doc: manifestDoc }, ...composeSplitRoles(base, manifestDoc, options.yamlMode ?? 'compat', diagnostics, files, options.additionalAgent, agentTemplates, rolePresets, brainPresets)];
433
599
  const baseDoc = manifestDoc;
434
600
  const vars = (baseDoc.vars ?? {});
435
601
  const defaults = (baseDoc.defaults ?? {});
602
+ const legacyRuntimeDefaults = ['harness', 'model', 'model_chain', 'effort', 'harness_options',
603
+ 'session', 'session_options', 'max_tokens', 'autocompact_pct']
604
+ .filter(key => Object.hasOwn(defaults, key));
605
+ if (legacyRuntimeDefaults.length)
606
+ throw new ConfigError(`${base}: E_LEGACY /defaults: Brain-owned field(s) ${legacyRuntimeDefaults.join(', ')} are unsupported; move them to a Brain definition`);
436
607
  const startStaggerMs = resolveStartStaggerMs(baseDoc.start_stagger_ms, base);
437
608
  const seen = new Map();
438
609
  const roles = [];
439
- for (const { file, doc, provenance } of docs) {
610
+ const agentDefinitions = {};
611
+ for (const { file, doc, provenance, agentSelections, agentDefinition, agentTemplate } of docs) {
440
612
  for (const [name, raw] of Object.entries((doc.roles ?? {}))) {
441
613
  if (!ROLE_NAME_RE.test(name))
442
614
  throw new ConfigError(`${file}: invalid role name '${name}' (allowed: [A-Za-z0-9_-])`);
@@ -444,6 +616,8 @@ export function loadConfig(configPath, options = {}) {
444
616
  if (prev)
445
617
  throw new ConfigError(`role '${name}' defined in both ${prev} and ${file}`);
446
618
  seen.set(name, file);
619
+ if (agentDefinition)
620
+ agentDefinitions[name] = structuredClone(agentDefinition);
447
621
  const r = deepSub(raw ?? {}, vars);
448
622
  const bad = Object.keys(r).filter(k => !ROLE_KEYS.includes(k));
449
623
  if (bad.length)
@@ -474,6 +648,8 @@ export function loadConfig(configPath, options = {}) {
474
648
  const worklog = resolveWorklogPolicy(defaults.worklog, r.worklog, file, name);
475
649
  const authProxy = resolveAuthProxy(defaults.auth_proxy, r.auth_proxy, file, name);
476
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`);
477
653
  const defaultHarness = defaults.harness ?? 'claude-code';
478
654
  const inheritsModelDefaults = harness === defaultHarness && r.model !== null;
479
655
  if (authProxy && harness !== 'claude-code')
@@ -508,7 +684,7 @@ export function loadConfig(configPath, options = {}) {
508
684
  defaulted('identity', r.identity !== undefined, r.identity === undefined);
509
685
  defaulted('permissions', defaults.permissions !== undefined, defaults.permissions === undefined);
510
686
  defaulted('monitor', defaults.monitor !== undefined, defaults.monitor === undefined);
511
- roles.push({
687
+ const resolvedRole = {
512
688
  ...r,
513
689
  name,
514
690
  sourceFile: file,
@@ -530,7 +706,12 @@ export function loadConfig(configPath, options = {}) {
530
706
  auth_proxy: authProxy,
531
707
  env: Object.keys(env).length ? env : undefined,
532
708
  loops: [],
533
- });
709
+ ...(agentSelections ? { agentSelections } : {}),
710
+ ...(agentTemplate ? { agentTemplate } : {}),
711
+ };
712
+ if (agentDefinition?.loops !== undefined)
713
+ resolvedRole.temporaryLoops = resolveAgentLoops(agentDefinition.loops, resolvedRole, file);
714
+ roles.push(resolvedRole);
534
715
  // Forbidden-path enforcement: a mount that would breach the policy
535
716
  // is a configuration error, caught by `config` rather than at launch.
536
717
  if (isolation !== undefined) {
@@ -545,7 +726,12 @@ export function loadConfig(configPath, options = {}) {
545
726
  }
546
727
  }
547
728
  validateOwnerChannelIdentities(roles);
548
- const watchdogs = resolveWatchdogs(baseDoc, base, roles, vars, defaults);
729
+ const watchdogs = options.skipWatchdogs ? [] : resolveWatchdogs(baseDoc, base, roles, vars, agentDefinitions, (name, definition) => {
730
+ const id = `WatchdogAgent${name}`.slice(0, 64);
731
+ return findRole(loadConfig(base, {
732
+ yamlMode: options.yamlMode, additionalAgent: { id, definition }, skipWatchdogs: true,
733
+ }), id);
734
+ });
549
735
  const resolvedLoops = resolveLoops(baseDoc.loops, base, roles, vars);
550
736
  for (const role of roles)
551
737
  role.loops = resolvedLoops.byRole.get(role.name) ?? [];
@@ -558,6 +744,9 @@ export function loadConfig(configPath, options = {}) {
558
744
  let ownerInvite;
559
745
  let roomTemplates;
560
746
  let tasks;
747
+ const fileTemplates = readRoomTemplateDirectory(join(splitRootFor(base), 'room_templates'), options.yamlMode ?? 'compat', diagnostics, files);
748
+ if (Object.keys(fileTemplates).length)
749
+ roomTemplates = fileTemplates;
561
750
  for (const { file, doc } of docs) {
562
751
  if (doc.rooms !== undefined) {
563
752
  if (rooms)
@@ -570,7 +759,21 @@ export function loadConfig(configPath, options = {}) {
570
759
  }
571
760
  if (doc.room_templates !== undefined) {
572
761
  const validated = validateRoomTemplatesConfig(deepSub(doc.room_templates, vars), file);
573
- roomTemplates = { ...(roomTemplates ?? {}), ...validated };
762
+ const raw = doc.room_templates;
763
+ for (const [name, template] of Object.entries(validated)) {
764
+ const shadowed = roomTemplates?.[name];
765
+ const marker = raw[name]?.override_builtin;
766
+ if (shadowed && (marker !== true || template.version <= shadowed.version))
767
+ throw new ConfigError(`${file}: room_templates.${name} shadows file preset ${shadowed.sourceFile}; `
768
+ + 'set override_builtin: true and use a higher version');
769
+ if (marker === true)
770
+ diagnostics.push({
771
+ severity: 'warning', kind: 'deprecated-field', file, line: 1, column: 1,
772
+ message: `${file}: room_templates.${name}.override_builtin is deprecated; `
773
+ + 'it is retained only as an explicit manifest-over-file migration marker',
774
+ });
775
+ roomTemplates = { ...(roomTemplates ?? {}), [name]: { ...template, sourceFile: file } };
776
+ }
574
777
  }
575
778
  if (doc.tasks !== undefined) {
576
779
  if (tasks)
@@ -579,7 +782,7 @@ export function loadConfig(configPath, options = {}) {
579
782
  }
580
783
  }
581
784
  const result = {
582
- roles, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
785
+ roles, agentDefinitions, agentTemplates, rolePresets, brainPresets, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
583
786
  loops: resolvedLoops.loops,
584
787
  rooms, roomTemplates, tasks, ownerInviteFingerprint,
585
788
  configMode: 'split-v2',
@@ -588,10 +791,18 @@ export function loadConfig(configPath, options = {}) {
588
791
  return { kind: 'Manifest', path };
589
792
  const parent = basename(dirname(path));
590
793
  const kind = parent === 'agents' ? 'Agent'
591
- : parent === 'roles' ? 'Role' : 'Brain';
794
+ : parent === 'agent_templates' ? 'AgentTemplate'
795
+ : parent === 'roles' ? 'Role'
796
+ : parent === 'room_templates' ? 'RoomTemplate' : 'Brain';
592
797
  return { kind, id: basename(path, extname(path)), path };
593
798
  }),
594
799
  };
800
+ Object.defineProperty(result, 'resolveAgentDefinition', {
801
+ value: (id, definition) => resolveAgentDefinitionDryRun(base, id, definition),
802
+ enumerable: false,
803
+ writable: false,
804
+ configurable: false,
805
+ });
595
806
  if (ownerInvite !== undefined) {
596
807
  Object.defineProperty(result, 'ownerInvite', {
597
808
  value: ownerInvite,
@@ -602,6 +813,12 @@ export function loadConfig(configPath, options = {}) {
602
813
  }
603
814
  return result;
604
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
+ }
605
822
  /**
606
823
  * Canonical form of a 64-hex container ID for authorization decisions. Hex
607
824
  * case is not identity: two casings of one CID are the same peer, so every
@@ -685,8 +902,8 @@ export function resolveOwnerChannelConfig(defaults, role, session, file = 'confi
685
902
  const maxRequestBytes = attachments.max_request_bytes ?? 20 * 1024 * 1024;
686
903
  if (maxRequestBytes < maxFileBytes)
687
904
  throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.max_request_bytes must be at least max_file_bytes`);
688
- if (session !== 'acp')
689
- 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`);
690
907
  return {
691
908
  identity: merged.identity.trim(),
692
909
  owners,
@@ -706,7 +923,13 @@ export function resolveOwnerChannelConfig(defaults, role, session, file = 'confi
706
923
  };
707
924
  }
708
925
  function validateOwnerChannelIdentities(roles) {
709
- 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
+ }
710
933
  const channels = new Map();
711
934
  for (const role of roles) {
712
935
  const identity = role.owner_channel?.identity;
@@ -812,8 +1035,8 @@ function resolveSession(raw, file, name) {
812
1035
  if (value === 'tmux')
813
1036
  throw new ConfigError(`${file}: role '${name}' session: tmux is no longer supported; use session: acp `
814
1037
  + 'with the Codex or Claude Code adapter');
815
- if (value !== 'acp')
816
- 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`);
817
1040
  return value;
818
1041
  }
819
1042
  function resolveSessionOptions(defaults, role, session, file, name) {
@@ -828,26 +1051,40 @@ function resolveSessionOptions(defaults, role, session, file, name) {
828
1051
  ...((defaults?.acp) ?? {}),
829
1052
  ...(role?.acp ?? {}),
830
1053
  },
1054
+ codex_app_server: {
1055
+ ...((defaults?.codex_app_server) ?? {}),
1056
+ ...(role?.codex_app_server ?? {}),
1057
+ },
831
1058
  };
832
1059
  if (defaults?.tmux !== undefined
833
1060
  || role?.tmux !== undefined)
834
1061
  throw new ConfigError(`${file}: role '${name}' session_options.tmux is no longer supported; `
835
1062
  + 'use session: acp with session_options.acp');
836
- const bad = Object.keys(merged).filter(k => k !== 'acp');
1063
+ const bad = Object.keys(merged).filter(k => k !== 'acp' && k !== 'codex_app_server');
837
1064
  if (bad.length)
838
1065
  throw new ConfigError(`${file}: role '${name}' session_options: unknown key(s) ${bad.join(', ')}`);
839
1066
  if (!isPlainObject(merged.acp))
840
- 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`);
841
1070
  const acpBad = Object.keys(merged.acp).filter(k => k !== 'command');
842
1071
  if (acpBad.length)
843
1072
  throw new ConfigError(`${file}: role '${name}' session_options.acp: unknown key(s) ${acpBad.join(', ')}`);
844
- const command = merged.acp.command;
845
- if (command !== undefined
846
- && !(typeof command === 'string' && command.trim())
847
- && !(Array.isArray(command) && command.length > 0
848
- && command.every(v => typeof v === 'string' && v.length > 0)))
849
- throw new ConfigError(`${file}: role '${name}' session_options.acp.command must be a non-empty string or string list`);
850
- 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;
851
1088
  }
852
1089
  export function resolvePermissions(defaults, role, file = 'config', name = 'role') {
853
1090
  if (defaults !== undefined && !isPlainObject(defaults))
@@ -932,7 +1169,10 @@ export function resolveMonitorConfig(defMonitor, roleMonitor, labels = {}) {
932
1169
  }
933
1170
  export function findRole(cfg, name) {
934
1171
  const r = cfg.roles.find(r => r.name === name);
935
- 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`);
936
1175
  throw new ConfigError(`no such role '${name}' in ${cfg.files.join(', ') || 'config'}`);
1176
+ }
937
1177
  return r;
938
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
@@ -374,11 +374,12 @@ export function creationBuildNote(p) {
374
374
  export function formatProvenance(p) {
375
375
  const mark = {
376
376
  cli: 'explicit', 'fleet-default': 'fleet default', 'caller-role': 'caller role',
377
- 'built-in': 'built-in',
377
+ 'agent-template': 'Agent Template', 'built-in': 'built-in',
378
378
  };
379
379
  return Object.entries(p.settings)
380
380
  .filter(([, e]) => e.value !== undefined)
381
- .map(([k, e]) => ` ${k.padEnd(12)} ${String(e.value)} (${mark[e.source]})`);
381
+ .map(([k, e]) => ` ${k.padEnd(12)} ${e.value && typeof e.value === 'object'
382
+ ? JSON.stringify(e.value) : String(e.value)} (${mark[e.source]})`);
382
383
  }
383
384
  /** Classify one setting: an explicit CLI value, a fleet default, or built-in. */
384
385
  export function provenanceOf(cliValue, fleetDefault, builtIn) {
@@ -0,0 +1,93 @@
1
+ import { type FetchLike } from './monitor.js';
2
+ export declare const DAEMON_RECOVERY_MAX_ATTEMPTS = 6;
3
+ export declare const DAEMON_RECOVERY_INITIAL_BACKOFF_MS = 1000;
4
+ export declare const DAEMON_RECOVERY_MAX_BACKOFF_MS = 5000;
5
+ export declare const DAEMON_RECOVERY_DEADLINE_MS = 60000;
6
+ export interface DaemonGeneration {
7
+ bootId: string;
8
+ pid: number;
9
+ startedAt: number;
10
+ stateDir: string;
11
+ }
12
+ export type DaemonGenerationProbe = {
13
+ state: 'ready';
14
+ generation: DaemonGeneration;
15
+ } | {
16
+ state: 'unavailable';
17
+ reason: string;
18
+ };
19
+ export type DaemonGenerationObservation = {
20
+ kind: 'baseline' | 'stable' | 'available';
21
+ generation: DaemonGeneration;
22
+ } | {
23
+ kind: 'changed';
24
+ previous: DaemonGeneration;
25
+ generation: DaemonGeneration;
26
+ } | {
27
+ kind: 'lost' | 'unavailable';
28
+ previous?: DaemonGeneration;
29
+ reason: string;
30
+ };
31
+ interface GenerationProbeDeps {
32
+ readText?(path: string): string;
33
+ canonicalize?(path: string): string;
34
+ }
35
+ /**
36
+ * Corroborate the loopback daemon's unauthenticated `/info`, its credentialed
37
+ * identity-index readiness route, and its local boot-generation record. The
38
+ * index enforces auth when the daemon visibility requires it; open visibility
39
+ * deliberately does not. No source is sufficient
40
+ * alone: `/info` has no boot id, the identity index has no generation, and a
41
+ * stale `ready` file can outlive the process that wrote it.
42
+ */
43
+ export declare function probeDaemonGeneration(fetch: FetchLike, env: NodeJS.ProcessEnv, deps?: GenerationProbeDeps): Promise<DaemonGenerationProbe>;
44
+ export declare class DaemonGenerationObserver {
45
+ private current?;
46
+ private unavailable;
47
+ observe(probe: DaemonGenerationProbe): DaemonGenerationObservation;
48
+ }
49
+ export declare function daemonRecoveryBackoff(attempt: number): number;
50
+ export type RecoveryPath = 'agent' | 'owner';
51
+ export type RecoveryPathResult = {
52
+ ok: true;
53
+ } | {
54
+ ok: false;
55
+ reason: string;
56
+ };
57
+ export interface RoleRecoveryControllerOptions {
58
+ role: string;
59
+ identity: string;
60
+ stateDir: string;
61
+ now(): number;
62
+ sleep(ms: number): Promise<void>;
63
+ recoverAgent(epoch: string): Promise<RecoveryPathResult>;
64
+ recoverOwner(epoch: string): Promise<RecoveryPathResult>;
65
+ log(line: string): void;
66
+ }
67
+ export interface RecoveryStatus {
68
+ version: 1;
69
+ identity: string;
70
+ epoch: string;
71
+ state: 'recovering' | 'recovered' | 'degraded' | 'cancelled';
72
+ paths: Record<RecoveryPath, {
73
+ state: 'pending' | 'recovered' | 'degraded';
74
+ attempts: number;
75
+ reason?: string;
76
+ }>;
77
+ updatedAt: string;
78
+ }
79
+ export declare function readDaemonRecoveryStatus(dir: string): RecoveryStatus | undefined;
80
+ /** Per-role, per-generation bounded recovery with path-level fault isolation. */
81
+ export declare class RoleRecoveryController {
82
+ private readonly options;
83
+ private token;
84
+ private activeEpoch?;
85
+ private active?;
86
+ private status?;
87
+ constructor(options: RoleRecoveryControllerOptions);
88
+ recover(generation: DaemonGeneration): Promise<RecoveryStatus>;
89
+ cancel(): void;
90
+ noteLoss(reason: string): void;
91
+ private write;
92
+ }
93
+ export {};