@ours.network/fleet 1.1.0-nightly.3 → 1.1.0-nightly.30

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 (208) hide show
  1. package/README.md +244 -40
  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 +4 -4
  8. package/dist/application/role-creation-service.js +19 -1
  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 +93 -40
  14. package/dist/application/task-room-service.js +579 -173
  15. package/dist/briefing.js +85 -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 +217 -51
  20. package/dist/config-yaml.d.ts +1 -1
  21. package/dist/config.d.ts +38 -3
  22. package/dist/config.js +240 -28
  23. package/dist/creation.d.ts +1 -1
  24. package/dist/creation.js +7 -4
  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 +213 -42
  29. package/dist/doctor.d.ts +2 -0
  30. package/dist/doctor.js +54 -4
  31. package/dist/fleet-command-audit.d.ts +181 -0
  32. package/dist/fleet-command-audit.js +738 -0
  33. package/dist/fleet-proxy.d.ts +7 -2
  34. package/dist/harness/agent-session.d.ts +5 -0
  35. package/dist/harness/claude-code-session.d.ts +2 -0
  36. package/dist/harness/claude-code-session.js +6 -1
  37. package/dist/harness/claude-code.js +2 -0
  38. package/dist/harness/codex-app-server-proxy.d.ts +40 -0
  39. package/dist/harness/codex-app-server-proxy.js +348 -14
  40. package/dist/harness/codex-session.d.ts +11 -2
  41. package/dist/harness/codex-session.js +22 -2
  42. package/dist/harness/codex.d.ts +5 -1
  43. package/dist/harness/codex.js +110 -10
  44. package/dist/harness/types.d.ts +1 -1
  45. package/dist/index.d.ts +4 -3
  46. package/dist/index.js +3 -2
  47. package/dist/init-guidance.d.ts +1 -1
  48. package/dist/init-guidance.js +4 -3
  49. package/dist/init-wizard.d.ts +98 -0
  50. package/dist/init-wizard.js +631 -0
  51. package/dist/lifecycle-summary.d.ts +134 -0
  52. package/dist/lifecycle-summary.js +179 -0
  53. package/dist/loops/config.d.ts +10 -1
  54. package/dist/loops/config.js +38 -5
  55. package/dist/model-recovery.d.ts +1 -1
  56. package/dist/ops.js +2 -17
  57. package/dist/owner-channel/channel.d.ts +75 -3
  58. package/dist/owner-channel/channel.js +531 -123
  59. package/dist/owner-channel/commands.d.ts +28 -6
  60. package/dist/owner-channel/commands.js +112 -36
  61. package/dist/owner-channel/lifecycle-outbox.d.ts +24 -0
  62. package/dist/owner-channel/lifecycle-outbox.js +87 -0
  63. package/dist/owner-channel/ours-client.d.ts +19 -0
  64. package/dist/owner-channel/ours-client.js +3 -0
  65. package/dist/owner-channel/state.d.ts +2 -0
  66. package/dist/owner-channel/state.js +8 -2
  67. package/dist/permissions.js +1 -1
  68. package/dist/preset-bootstrap.d.ts +10 -0
  69. package/dist/preset-bootstrap.js +85 -0
  70. package/dist/preset-migration.d.ts +42 -0
  71. package/dist/preset-migration.js +450 -0
  72. package/dist/reports/artifact.d.ts +10 -0
  73. package/dist/reports/artifact.js +32 -0
  74. package/dist/reports/index.d.ts +5 -0
  75. package/dist/reports/index.js +5 -0
  76. package/dist/reports/render.d.ts +6 -0
  77. package/dist/reports/render.js +240 -0
  78. package/dist/reports/service.d.ts +42 -0
  79. package/dist/reports/service.js +57 -0
  80. package/dist/reports/tasks.d.ts +52 -0
  81. package/dist/reports/tasks.js +177 -0
  82. package/dist/reports/types.d.ts +315 -0
  83. package/dist/reports/types.js +6 -0
  84. package/dist/resolved-plan.js +13 -1
  85. package/dist/rooms-tasks/cli.d.ts +2 -0
  86. package/dist/rooms-tasks/cli.js +519 -261
  87. package/dist/rooms-tasks/close.d.ts +9 -1
  88. package/dist/rooms-tasks/close.js +23 -7
  89. package/dist/rooms-tasks/config.js +16 -29
  90. package/dist/rooms-tasks/cowork-adapter.d.ts +5 -0
  91. package/dist/rooms-tasks/cowork-adapter.js +13 -0
  92. package/dist/rooms-tasks/deletion.d.ts +51 -0
  93. package/dist/rooms-tasks/deletion.js +217 -0
  94. package/dist/rooms-tasks/external-worker.d.ts +9 -0
  95. package/dist/rooms-tasks/external-worker.js +52 -9
  96. package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
  97. package/dist/rooms-tasks/launch-snapshot.js +181 -0
  98. package/dist/rooms-tasks/member-overrides.d.ts +30 -0
  99. package/dist/rooms-tasks/member-overrides.js +204 -0
  100. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  101. package/dist/rooms-tasks/member-startup.js +9 -2
  102. package/dist/rooms-tasks/provision.js +146 -56
  103. package/dist/rooms-tasks/room-state.d.ts +1 -0
  104. package/dist/rooms-tasks/room-state.js +12 -1
  105. package/dist/rooms-tasks/task-room-name.d.ts +12 -0
  106. package/dist/rooms-tasks/task-room-name.js +61 -0
  107. package/dist/rooms-tasks/task-state.d.ts +115 -3
  108. package/dist/rooms-tasks/task-state.js +361 -11
  109. package/dist/rooms-tasks/templates.d.ts +4 -2
  110. package/dist/rooms-tasks/templates.js +42 -78
  111. package/dist/rooms-tasks/terminal.d.ts +3 -0
  112. package/dist/rooms-tasks/terminal.js +10 -4
  113. package/dist/rooms-tasks/types.d.ts +94 -8
  114. package/dist/rooms-tasks/types.js +14 -1
  115. package/dist/runner.d.ts +12 -2
  116. package/dist/runner.js +167 -56
  117. package/dist/session/acp.d.ts +9 -1
  118. package/dist/session/acp.js +35 -9
  119. package/dist/session/arbiter.d.ts +2 -1
  120. package/dist/session/arbiter.js +2 -0
  121. package/dist/session/codex-app-server-transport.d.ts +53 -0
  122. package/dist/session/codex-app-server-transport.js +184 -0
  123. package/dist/session/codex-app-server.d.ts +122 -0
  124. package/dist/session/codex-app-server.js +1031 -0
  125. package/dist/session/control.d.ts +14 -1
  126. package/dist/session/control.js +51 -0
  127. package/dist/session/conversation-types.d.ts +4 -4
  128. package/dist/session/types.d.ts +24 -6
  129. package/dist/session/types.js +28 -0
  130. package/dist/spawn.d.ts +9 -0
  131. package/dist/spawn.js +77 -9
  132. package/dist/temp-lifecycle.d.ts +1 -1
  133. package/dist/web/fleet-config-service.d.ts +1 -0
  134. package/dist/web/fleet-config-service.js +27 -8
  135. package/dist/web/runtime.js +11 -6
  136. package/dist/web/server.js +28 -1
  137. package/dist/web-app/assets/{index-BbTc5KtZ.js → index-C0h3ALvs.js} +8 -8
  138. package/dist/web-app/index.html +1 -1
  139. package/package.json +5 -4
  140. package/presets/brain-catalog.json +17 -0
  141. package/presets/fleet/agent_templates/Critic.yaml +5 -0
  142. package/presets/fleet/agent_templates/Developer.yaml +5 -0
  143. package/presets/fleet/agent_templates/LocalCoordinator.yaml +50 -0
  144. package/presets/fleet/agents/FleetCoordinator.yaml +3 -0
  145. package/presets/fleet/brains/claude-default.yaml +5 -0
  146. package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
  147. package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
  148. package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
  149. package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
  150. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
  151. package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
  152. package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
  153. package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
  154. package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
  155. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
  156. package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
  157. package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
  158. package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
  159. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
  160. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
  161. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
  162. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
  163. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
  164. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
  165. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
  166. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
  167. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
  168. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
  169. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
  170. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
  171. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
  172. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
  173. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
  174. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
  175. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
  176. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
  177. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
  178. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
  179. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
  180. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
  181. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
  182. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
  183. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
  184. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
  185. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
  186. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
  187. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
  188. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
  189. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
  190. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
  191. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
  192. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
  193. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
  194. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +5 -0
  195. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +5 -0
  196. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +5 -0
  197. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +5 -0
  198. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +5 -0
  199. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +5 -0
  200. package/presets/fleet/roles/Coordinator.yaml +30 -0
  201. package/presets/fleet/roles/Critic.yaml +28 -0
  202. package/presets/fleet/roles/Developer.yaml +28 -0
  203. package/presets/fleet/roles/LocalCoordinator.yaml +35 -0
  204. package/presets/fleet/room_templates/pair.yaml +13 -0
  205. package/presets/fleet/room_templates/single.yaml +9 -0
  206. package/presets/fleet/room_templates/team.yaml +16 -0
  207. package/presets/fleet.yaml +7 -0
  208. package/presets/manifest.json +6 -0
package/dist/config.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type ConfigDiagnostic, type YamlMode } from './config-yaml.js';
2
2
  import type { IsolationConfig, WrapContext } from './isolation/types.js';
3
3
  import type { ResolvedWatchdog } from './watchdog/config.js';
4
- import type { ResolvedLoop, ResolvedRoleLoop } from './loops/config.js';
4
+ import type { AgentLoopsConfig, ResolvedLoop, ResolvedRoleLoop } from './loops/config.js';
5
5
  export interface OverseeEntry {
6
6
  agent: string;
7
7
  interval: string;
@@ -25,7 +25,8 @@ export declare const NOTIFY_EVENT_TYPES: readonly ["message_received", "file_rec
25
25
  export type NotifyEventType = (typeof NOTIFY_EVENT_TYPES)[number];
26
26
  export type InjectMode = 'notification' | 'full';
27
27
  export type MonitorMode = 'fleet' | 'native';
28
- export type SessionBackendId = 'acp';
28
+ /** Concrete transport selected for a role. Keep provider-native transports explicit. */
29
+ export type SessionBackendId = 'acp' | 'codex-app-server';
29
30
  /** Public, harness-neutral permission policy. */
30
31
  export type FleetPermissionMode = 'ask' | 'auto' | 'allow';
31
32
  /** `deny` is a deprecated, fail-closed compatibility alias retained for old fleet files. */
@@ -44,6 +45,10 @@ export interface SessionOptions {
44
45
  /** ACP agent command and arguments. Defaults are supplied by the harness adapter. */
45
46
  command?: string | string[];
46
47
  };
48
+ codex_app_server?: {
49
+ /** App-server command and arguments. Defaults to `codex app-server`. */
50
+ command?: string | string[];
51
+ };
47
52
  }
48
53
  /** Resolved per-role supervisor-monitor config. */
49
54
  export interface MonitorConfig {
@@ -157,6 +162,15 @@ export interface AgentDefinition {
157
162
  owner_channel?: OwnerChannelConfigInput;
158
163
  worklog?: WorklogPolicyInput;
159
164
  auth_proxy?: Partial<AuthProxyConfig>;
165
+ /** Scheduled turns scoped only to temporary launches; persistent Agents reject this field. */
166
+ loops?: AgentLoopsConfig;
167
+ }
168
+ /** Inert, reusable launch definition. It never owns an identity or runtime state. */
169
+ export type AgentTemplateDefinition = Omit<AgentDefinition, 'identity'>;
170
+ /** Explicit persistent instance reusing an inert template. */
171
+ export interface AgentTemplateInstance {
172
+ template: string;
173
+ overrides?: Partial<AgentDefinition>;
160
174
  }
161
175
  /** Internal first-boot payload for a Fleet-provisioned Cowork room member. */
162
176
  export interface RoomMemberStartup {
@@ -168,6 +182,7 @@ export interface RoomMemberStartup {
168
182
  role: string;
169
183
  task: string;
170
184
  owner_seat_cid: string | null;
185
+ anonymous?: boolean;
171
186
  }
172
187
  export interface ResolvedRole extends Omit<RoleConfig, 'model' | 'owner_channel' | 'worklog'> {
173
188
  name: string;
@@ -189,11 +204,20 @@ export interface ResolvedRole extends Omit<RoleConfig, 'model' | 'owner_channel'
189
204
  worklog?: WorklogPolicy;
190
205
  auth_proxy?: AuthProxyConfig;
191
206
  loops?: ResolvedRoleLoop[];
207
+ /** Agent Template-local loops, kept separate from manifest loops until a temp launch. */
208
+ temporaryLoops?: ResolvedRoleLoop[];
209
+ /** Durable source of the temporary loop policy; internal and never user-authored. */
210
+ temporaryLoopSource?: 'agent-template' | 'cli' | 'omitted';
192
211
  provenance?: Record<string, FieldProvenance>;
193
212
  /** Internal/transient: never accepted as a user-authored RoleConfig key. */
194
213
  roomMemberStartup?: RoomMemberStartup;
195
214
  /** Original unresolved selections only; denied operational fields never enter inheritance state. */
196
215
  agentSelections?: Pick<AgentDefinition, 'role' | 'brain'>;
216
+ agentTemplate?: {
217
+ id: string;
218
+ sourceFile: string;
219
+ contentHash: string;
220
+ };
197
221
  }
198
222
  export interface FieldProvenance {
199
223
  sourceFile: string;
@@ -215,12 +239,18 @@ export interface FleetConfig {
215
239
  roles: ResolvedRole[];
216
240
  /** Canonical, variable-resolved Agent authoring documents keyed by stable Agent ID. */
217
241
  agentDefinitions?: Record<string, AgentDefinition>;
242
+ /** Canonical inert templates keyed by stable template ID. */
243
+ agentTemplates?: Record<string, AgentTemplateDefinition>;
244
+ rolePresets?: Record<string, Record<string, unknown>>;
245
+ brainPresets?: Record<string, Record<string, unknown>>;
246
+ /** Required launch-boundary resolver for effective temporary Agent definitions. */
247
+ resolveAgentDefinition?: (id: string, definition: AgentDefinition) => ResolvedRole;
218
248
  vars: Record<string, string>;
219
249
  defaults: Record<string, unknown>;
220
250
  files: string[];
221
251
  configMode?: 'split-v2';
222
252
  sourceDocuments?: Array<{
223
- kind: 'Manifest' | 'Agent' | 'Role' | 'Brain';
253
+ kind: 'Manifest' | 'Agent' | 'AgentTemplate' | 'Role' | 'Brain' | 'RoomTemplate';
224
254
  id?: string;
225
255
  path: string;
226
256
  }>;
@@ -260,6 +290,8 @@ export declare const AGENT_KEYS: string[];
260
290
  type BarePreset = Record<string, unknown>;
261
291
  export declare function validateRoleValue(value: BarePreset, file: string, pointer: string): void;
262
292
  export declare function validateBrainValue(value: BarePreset, file: string, pointer: string): void;
293
+ /** Pure launch-boundary validation for a fully resolved temporary Agent definition. */
294
+ export declare function validateEffectiveAgentTemplate(definition: AgentTemplateDefinition, id?: string): AgentTemplateDefinition;
263
295
  export declare function assertBareKeys(value: unknown, allowed: string[], label: string): BarePreset;
264
296
  export declare function splitRootFor(base: string): string;
265
297
  /** Load a v2 manifest and bare Agent/Role/Brain documents from its stem directory. */
@@ -268,9 +300,12 @@ export declare function loadConfig(configPath?: string, options?: {
268
300
  additionalAgent?: {
269
301
  id: string;
270
302
  definition: AgentDefinition;
303
+ temporary?: boolean;
271
304
  };
272
305
  skipWatchdogs?: boolean;
273
306
  }): FleetConfig;
307
+ /** Full read-only Agent resolution used by launch dry-runs and room plan validation. */
308
+ export declare function resolveAgentDefinitionDryRun(configPath: string, id: string, definition: AgentDefinition): ResolvedRole;
274
309
  /**
275
310
  * Canonical form of a 64-hex container ID for authorization decisions. Hex
276
311
  * case is not identity: two casings of one CID are the same peer, so every
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()))
@@ -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, 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 = {}) {
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,6 +406,7 @@ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files, addition
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);
320
411
  if (additionalAgent) {
321
412
  if (agents.has(additionalAgent.id))
@@ -325,11 +416,67 @@ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files, addition
325
416
  });
326
417
  }
327
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
+ }
328
450
  const documents = [];
329
451
  for (const [id, agent] of agents) {
330
- 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
+ }
331
476
  if (!('role' in a) || !('brain' in a))
332
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');
333
480
  const role = selection(a.role, 'role', agent.file, roles, ROLE_PRESET_KEYS);
334
481
  const brain = selection(a.brain, 'brain', agent.file, brains, BRAIN_PRESET_KEYS);
335
482
  const operational = deepSubStrict(Object.fromEntries(Object.entries(a).filter(([key]) => key !== 'role' && key !== 'brain')), vars, agent.file, `/agents/${id}`);
@@ -404,10 +551,12 @@ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files, addition
404
551
  brain: deepSubStrict(a.brain, vars, agent.file, `/agents/${id}/brain`),
405
552
  ...operational,
406
553
  };
554
+ const { loops: _temporaryLoops, ...runtimeOperational } = operational;
407
555
  documents.push({ file: agent.file,
408
- doc: { roles: { [id]: { ...brainValue, ...roleValue, ...operational } } }, provenance,
556
+ doc: { roles: { [id]: { ...brainValue, ...roleValue, ...runtimeOperational } } }, provenance,
409
557
  agentSelections: structuredClone({ role: canonicalDefinition.role, brain: canonicalDefinition.brain }),
410
- agentDefinition: canonicalDefinition });
558
+ agentDefinition: canonicalDefinition,
559
+ ...(agentTemplate ? { agentTemplate } : {}) });
411
560
  }
412
561
  return documents;
413
562
  }
@@ -425,6 +574,9 @@ export function loadConfig(configPath, options = {}) {
425
574
  const base = configPath ?? defaultConfigPath();
426
575
  const files = [];
427
576
  const diagnostics = [];
577
+ const agentTemplates = {};
578
+ const rolePresets = {};
579
+ const brainPresets = {};
428
580
  let docs = [];
429
581
  let manifestDoc = {};
430
582
  if (existsSync(base)) {
@@ -443,7 +595,7 @@ export function loadConfig(configPath, options = {}) {
443
595
  const dd = fleetDDir();
444
596
  if (existsSync(dd) && readdirSync(dd).some(f => f.endsWith('.yaml') || f.endsWith('.yml')))
445
597
  throw new ConfigError(`${dd}: legacy fleet.d configuration is unsupported; run 'ours-fleet doctor' and move agents to ${splitRootFor(base)}/agents/`);
446
- 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)];
447
599
  const baseDoc = manifestDoc;
448
600
  const vars = (baseDoc.vars ?? {});
449
601
  const defaults = (baseDoc.defaults ?? {});
@@ -456,7 +608,7 @@ export function loadConfig(configPath, options = {}) {
456
608
  const seen = new Map();
457
609
  const roles = [];
458
610
  const agentDefinitions = {};
459
- for (const { file, doc, provenance, agentSelections, agentDefinition } of docs) {
611
+ for (const { file, doc, provenance, agentSelections, agentDefinition, agentTemplate } of docs) {
460
612
  for (const [name, raw] of Object.entries((doc.roles ?? {}))) {
461
613
  if (!ROLE_NAME_RE.test(name))
462
614
  throw new ConfigError(`${file}: invalid role name '${name}' (allowed: [A-Za-z0-9_-])`);
@@ -496,6 +648,8 @@ export function loadConfig(configPath, options = {}) {
496
648
  const worklog = resolveWorklogPolicy(defaults.worklog, r.worklog, file, name);
497
649
  const authProxy = resolveAuthProxy(defaults.auth_proxy, r.auth_proxy, file, name);
498
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`);
499
653
  const defaultHarness = defaults.harness ?? 'claude-code';
500
654
  const inheritsModelDefaults = harness === defaultHarness && r.model !== null;
501
655
  if (authProxy && harness !== 'claude-code')
@@ -530,7 +684,7 @@ export function loadConfig(configPath, options = {}) {
530
684
  defaulted('identity', r.identity !== undefined, r.identity === undefined);
531
685
  defaulted('permissions', defaults.permissions !== undefined, defaults.permissions === undefined);
532
686
  defaulted('monitor', defaults.monitor !== undefined, defaults.monitor === undefined);
533
- roles.push({
687
+ const resolvedRole = {
534
688
  ...r,
535
689
  name,
536
690
  sourceFile: file,
@@ -553,7 +707,11 @@ export function loadConfig(configPath, options = {}) {
553
707
  env: Object.keys(env).length ? env : undefined,
554
708
  loops: [],
555
709
  ...(agentSelections ? { agentSelections } : {}),
556
- });
710
+ ...(agentTemplate ? { agentTemplate } : {}),
711
+ };
712
+ if (agentDefinition?.loops !== undefined)
713
+ resolvedRole.temporaryLoops = resolveAgentLoops(agentDefinition.loops, resolvedRole, file);
714
+ roles.push(resolvedRole);
557
715
  // Forbidden-path enforcement: a mount that would breach the policy
558
716
  // is a configuration error, caught by `config` rather than at launch.
559
717
  if (isolation !== undefined) {
@@ -586,6 +744,9 @@ export function loadConfig(configPath, options = {}) {
586
744
  let ownerInvite;
587
745
  let roomTemplates;
588
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;
589
750
  for (const { file, doc } of docs) {
590
751
  if (doc.rooms !== undefined) {
591
752
  if (rooms)
@@ -598,7 +759,21 @@ export function loadConfig(configPath, options = {}) {
598
759
  }
599
760
  if (doc.room_templates !== undefined) {
600
761
  const validated = validateRoomTemplatesConfig(deepSub(doc.room_templates, vars), file);
601
- 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
+ }
602
777
  }
603
778
  if (doc.tasks !== undefined) {
604
779
  if (tasks)
@@ -607,7 +782,7 @@ export function loadConfig(configPath, options = {}) {
607
782
  }
608
783
  }
609
784
  const result = {
610
- roles, agentDefinitions, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
785
+ roles, agentDefinitions, agentTemplates, rolePresets, brainPresets, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
611
786
  loops: resolvedLoops.loops,
612
787
  rooms, roomTemplates, tasks, ownerInviteFingerprint,
613
788
  configMode: 'split-v2',
@@ -616,10 +791,18 @@ export function loadConfig(configPath, options = {}) {
616
791
  return { kind: 'Manifest', path };
617
792
  const parent = basename(dirname(path));
618
793
  const kind = parent === 'agents' ? 'Agent'
619
- : parent === 'roles' ? 'Role' : 'Brain';
794
+ : parent === 'agent_templates' ? 'AgentTemplate'
795
+ : parent === 'roles' ? 'Role'
796
+ : parent === 'room_templates' ? 'RoomTemplate' : 'Brain';
620
797
  return { kind, id: basename(path, extname(path)), path };
621
798
  }),
622
799
  };
800
+ Object.defineProperty(result, 'resolveAgentDefinition', {
801
+ value: (id, definition) => resolveAgentDefinitionDryRun(base, id, definition),
802
+ enumerable: false,
803
+ writable: false,
804
+ configurable: false,
805
+ });
623
806
  if (ownerInvite !== undefined) {
624
807
  Object.defineProperty(result, 'ownerInvite', {
625
808
  value: ownerInvite,
@@ -630,6 +813,12 @@ export function loadConfig(configPath, options = {}) {
630
813
  }
631
814
  return result;
632
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
+ }
633
822
  /**
634
823
  * Canonical form of a 64-hex container ID for authorization decisions. Hex
635
824
  * case is not identity: two casings of one CID are the same peer, so every
@@ -713,8 +902,8 @@ export function resolveOwnerChannelConfig(defaults, role, session, file = 'confi
713
902
  const maxRequestBytes = attachments.max_request_bytes ?? 20 * 1024 * 1024;
714
903
  if (maxRequestBytes < maxFileBytes)
715
904
  throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.max_request_bytes must be at least max_file_bytes`);
716
- if (session !== 'acp')
717
- 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`);
718
907
  return {
719
908
  identity: merged.identity.trim(),
720
909
  owners,
@@ -734,7 +923,13 @@ export function resolveOwnerChannelConfig(defaults, role, session, file = 'confi
734
923
  };
735
924
  }
736
925
  function validateOwnerChannelIdentities(roles) {
737
- 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
+ }
738
933
  const channels = new Map();
739
934
  for (const role of roles) {
740
935
  const identity = role.owner_channel?.identity;
@@ -840,8 +1035,8 @@ function resolveSession(raw, file, name) {
840
1035
  if (value === 'tmux')
841
1036
  throw new ConfigError(`${file}: role '${name}' session: tmux is no longer supported; use session: acp `
842
1037
  + 'with the Codex or Claude Code adapter');
843
- if (value !== 'acp')
844
- 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`);
845
1040
  return value;
846
1041
  }
847
1042
  function resolveSessionOptions(defaults, role, session, file, name) {
@@ -856,26 +1051,40 @@ function resolveSessionOptions(defaults, role, session, file, name) {
856
1051
  ...((defaults?.acp) ?? {}),
857
1052
  ...(role?.acp ?? {}),
858
1053
  },
1054
+ codex_app_server: {
1055
+ ...((defaults?.codex_app_server) ?? {}),
1056
+ ...(role?.codex_app_server ?? {}),
1057
+ },
859
1058
  };
860
1059
  if (defaults?.tmux !== undefined
861
1060
  || role?.tmux !== undefined)
862
1061
  throw new ConfigError(`${file}: role '${name}' session_options.tmux is no longer supported; `
863
1062
  + 'use session: acp with session_options.acp');
864
- const bad = Object.keys(merged).filter(k => k !== 'acp');
1063
+ const bad = Object.keys(merged).filter(k => k !== 'acp' && k !== 'codex_app_server');
865
1064
  if (bad.length)
866
1065
  throw new ConfigError(`${file}: role '${name}' session_options: unknown key(s) ${bad.join(', ')}`);
867
1066
  if (!isPlainObject(merged.acp))
868
- 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`);
869
1070
  const acpBad = Object.keys(merged.acp).filter(k => k !== 'command');
870
1071
  if (acpBad.length)
871
1072
  throw new ConfigError(`${file}: role '${name}' session_options.acp: unknown key(s) ${acpBad.join(', ')}`);
872
- const command = merged.acp.command;
873
- if (command !== undefined
874
- && !(typeof command === 'string' && command.trim())
875
- && !(Array.isArray(command) && command.length > 0
876
- && command.every(v => typeof v === 'string' && v.length > 0)))
877
- throw new ConfigError(`${file}: role '${name}' session_options.acp.command must be a non-empty string or string list`);
878
- 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;
879
1088
  }
880
1089
  export function resolvePermissions(defaults, role, file = 'config', name = 'role') {
881
1090
  if (defaults !== undefined && !isPlainObject(defaults))
@@ -960,7 +1169,10 @@ export function resolveMonitorConfig(defMonitor, roleMonitor, labels = {}) {
960
1169
  }
961
1170
  export function findRole(cfg, name) {
962
1171
  const r = cfg.roles.find(r => r.name === name);
963
- 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`);
964
1175
  throw new ConfigError(`no such role '${name}' in ${cfg.files.join(', ') || 'config'}`);
1176
+ }
965
1177
  return r;
966
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) {