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

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 (199) hide show
  1. package/README.md +169 -54
  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 +426 -78
  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 +227 -73
  20. package/dist/config-yaml.d.ts +1 -1
  21. package/dist/config.d.ts +67 -2
  22. package/dist/config.js +260 -30
  23. package/dist/daemon-recovery.d.ts +93 -0
  24. package/dist/daemon-recovery.js +328 -0
  25. package/dist/docs.d.ts +1 -1
  26. package/dist/docs.js +171 -66
  27. package/dist/doctor.d.ts +2 -0
  28. package/dist/doctor.js +54 -4
  29. package/dist/fleet-command-audit.d.ts +165 -0
  30. package/dist/fleet-command-audit.js +543 -0
  31. package/dist/fleet-proxy.d.ts +7 -2
  32. package/dist/fleet-proxy.js +30 -8
  33. package/dist/harness/agent-session.d.ts +5 -0
  34. package/dist/harness/claude-code-session.d.ts +2 -0
  35. package/dist/harness/claude-code-session.js +6 -1
  36. package/dist/harness/claude-code.js +2 -0
  37. package/dist/harness/codex-app-server-proxy.d.ts +40 -0
  38. package/dist/harness/codex-app-server-proxy.js +348 -14
  39. package/dist/harness/codex-session.d.ts +11 -2
  40. package/dist/harness/codex-session.js +22 -2
  41. package/dist/harness/codex.d.ts +5 -1
  42. package/dist/harness/codex.js +110 -10
  43. package/dist/harness/types.d.ts +1 -1
  44. package/dist/index.d.ts +5 -4
  45. package/dist/index.js +4 -3
  46. package/dist/init-guidance.d.ts +1 -1
  47. package/dist/init-guidance.js +4 -3
  48. package/dist/init-wizard.d.ts +98 -0
  49. package/dist/init-wizard.js +629 -0
  50. package/dist/lifecycle-summary.d.ts +118 -0
  51. package/dist/lifecycle-summary.js +161 -0
  52. package/dist/loops/config.js +3 -2
  53. package/dist/model-env.d.ts +2 -2
  54. package/dist/model-env.js +1 -1
  55. package/dist/model-recovery.d.ts +1 -1
  56. package/dist/ops.js +2 -17
  57. package/dist/owner-channel/channel.d.ts +58 -0
  58. package/dist/owner-channel/channel.js +321 -42
  59. package/dist/owner-channel/commands.d.ts +6 -1
  60. package/dist/owner-channel/commands.js +34 -19
  61. package/dist/owner-channel/state.d.ts +2 -0
  62. package/dist/owner-channel/state.js +8 -2
  63. package/dist/permissions.js +1 -1
  64. package/dist/preset-bootstrap.d.ts +10 -0
  65. package/dist/preset-bootstrap.js +85 -0
  66. package/dist/preset-migration.d.ts +42 -0
  67. package/dist/preset-migration.js +414 -0
  68. package/dist/reports/artifact.d.ts +10 -0
  69. package/dist/reports/artifact.js +32 -0
  70. package/dist/reports/index.d.ts +5 -0
  71. package/dist/reports/index.js +5 -0
  72. package/dist/reports/render.d.ts +6 -0
  73. package/dist/reports/render.js +240 -0
  74. package/dist/reports/service.d.ts +42 -0
  75. package/dist/reports/service.js +57 -0
  76. package/dist/reports/tasks.d.ts +52 -0
  77. package/dist/reports/tasks.js +177 -0
  78. package/dist/reports/types.d.ts +315 -0
  79. package/dist/reports/types.js +6 -0
  80. package/dist/resolved-plan.js +4 -0
  81. package/dist/rooms-tasks/cli.d.ts +2 -0
  82. package/dist/rooms-tasks/cli.js +503 -75
  83. package/dist/rooms-tasks/close.d.ts +9 -1
  84. package/dist/rooms-tasks/close.js +20 -4
  85. package/dist/rooms-tasks/config.js +16 -14
  86. package/dist/rooms-tasks/cowork-adapter.d.ts +1 -0
  87. package/dist/rooms-tasks/cowork-adapter.js +6 -0
  88. package/dist/rooms-tasks/deletion.d.ts +51 -0
  89. package/dist/rooms-tasks/deletion.js +217 -0
  90. package/dist/rooms-tasks/external-worker.d.ts +1 -0
  91. package/dist/rooms-tasks/external-worker.js +14 -9
  92. package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
  93. package/dist/rooms-tasks/launch-snapshot.js +176 -0
  94. package/dist/rooms-tasks/member-overrides.d.ts +27 -0
  95. package/dist/rooms-tasks/member-overrides.js +170 -0
  96. package/dist/rooms-tasks/provision.js +179 -49
  97. package/dist/rooms-tasks/room-state.d.ts +1 -0
  98. package/dist/rooms-tasks/room-state.js +12 -1
  99. package/dist/rooms-tasks/task-state.d.ts +115 -3
  100. package/dist/rooms-tasks/task-state.js +361 -11
  101. package/dist/rooms-tasks/templates.d.ts +4 -2
  102. package/dist/rooms-tasks/templates.js +42 -78
  103. package/dist/rooms-tasks/terminal.d.ts +3 -0
  104. package/dist/rooms-tasks/terminal.js +7 -3
  105. package/dist/rooms-tasks/types.d.ts +94 -17
  106. package/dist/rooms-tasks/types.js +14 -4
  107. package/dist/runner.d.ts +9 -2
  108. package/dist/runner.js +107 -40
  109. package/dist/session/acp.d.ts +9 -1
  110. package/dist/session/acp.js +35 -9
  111. package/dist/session/arbiter.d.ts +2 -1
  112. package/dist/session/arbiter.js +2 -0
  113. package/dist/session/codex-app-server-transport.d.ts +53 -0
  114. package/dist/session/codex-app-server-transport.js +184 -0
  115. package/dist/session/codex-app-server.d.ts +122 -0
  116. package/dist/session/codex-app-server.js +1031 -0
  117. package/dist/session/control.d.ts +14 -1
  118. package/dist/session/control.js +38 -0
  119. package/dist/session/conversation-types.d.ts +4 -4
  120. package/dist/session/types.d.ts +24 -6
  121. package/dist/session/types.js +28 -0
  122. package/dist/spawn.d.ts +6 -31
  123. package/dist/spawn.js +57 -234
  124. package/dist/temp-lifecycle.d.ts +1 -1
  125. package/dist/watchdog/config.d.ts +10 -7
  126. package/dist/watchdog/config.js +29 -19
  127. package/dist/watchdog/run.js +4 -13
  128. package/dist/web/fleet-config-service.d.ts +1 -0
  129. package/dist/web/fleet-config-service.js +27 -8
  130. package/dist/web/runtime.js +11 -6
  131. package/dist/web/server.js +28 -1
  132. package/dist/web/topology-promote.js +8 -1
  133. package/dist/web-app/assets/index-C0h3ALvs.js +10 -0
  134. package/dist/web-app/index.html +1 -1
  135. package/package.json +3 -2
  136. package/presets/brain-catalog.json +16 -0
  137. package/presets/fleet/agent_templates/Critic.yaml +4 -0
  138. package/presets/fleet/agent_templates/Developer.yaml +4 -0
  139. package/presets/fleet/agent_templates/LocalCoordinator.yaml +4 -0
  140. package/presets/fleet/agents/FleetCoordinator.yaml +3 -0
  141. package/presets/fleet/brains/claude-default.yaml +5 -0
  142. package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
  143. package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
  144. package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
  145. package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
  146. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
  147. package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
  148. package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
  149. package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
  150. package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
  151. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
  152. package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
  153. package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
  154. package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
  155. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
  156. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
  157. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
  158. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
  159. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
  160. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
  161. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
  162. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
  163. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
  164. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
  165. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
  166. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
  167. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
  168. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
  169. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
  170. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
  171. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
  172. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
  173. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
  174. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
  175. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
  176. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
  177. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
  178. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
  179. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
  180. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
  181. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
  182. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
  183. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
  184. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
  185. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
  186. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
  187. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
  188. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
  189. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
  190. package/presets/fleet/roles/Coordinator.yaml +30 -0
  191. package/presets/fleet/roles/Critic.yaml +19 -0
  192. package/presets/fleet/roles/Developer.yaml +19 -0
  193. package/presets/fleet/roles/LocalCoordinator.yaml +25 -0
  194. package/presets/fleet/room_templates/pair.yaml +10 -0
  195. package/presets/fleet/room_templates/single.yaml +7 -0
  196. package/presets/fleet/room_templates/team.yaml +12 -0
  197. package/presets/fleet.yaml +7 -0
  198. package/presets/manifest.json +6 -0
  199. package/dist/web-app/assets/index-BbE9EX6n.js +0 -10
package/dist/config.d.ts CHANGED
@@ -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 {
@@ -137,6 +142,34 @@ export interface RoleConfig {
137
142
  worklog?: WorklogPolicyInput;
138
143
  auth_proxy?: Partial<AuthProxyConfig>;
139
144
  }
145
+ export type AgentSelection<T extends Record<string, unknown> = Record<string, unknown>> = {
146
+ ref: string;
147
+ } | {
148
+ inline: T;
149
+ };
150
+ /** Canonical authoring contract shared by configured, spawned, and room agents. */
151
+ export interface AgentDefinition {
152
+ role: AgentSelection;
153
+ brain: AgentSelection;
154
+ permissions?: Partial<CommonPermissions>;
155
+ identity?: string;
156
+ cwd?: string;
157
+ coordinator?: string;
158
+ env?: Record<string, string>;
159
+ oversee?: OverseeEntry[];
160
+ isolation?: IsolationConfig;
161
+ monitor?: Partial<MonitorConfig>;
162
+ owner_channel?: OwnerChannelConfigInput;
163
+ worklog?: WorklogPolicyInput;
164
+ auth_proxy?: Partial<AuthProxyConfig>;
165
+ }
166
+ /** Inert, reusable launch definition. It never owns an identity or runtime state. */
167
+ export type AgentTemplateDefinition = Omit<AgentDefinition, 'identity'>;
168
+ /** Explicit persistent instance reusing an inert template. */
169
+ export interface AgentTemplateInstance {
170
+ template: string;
171
+ overrides?: Partial<AgentDefinition>;
172
+ }
140
173
  /** Internal first-boot payload for a Fleet-provisioned Cowork room member. */
141
174
  export interface RoomMemberStartup {
142
175
  room_id: string;
@@ -147,6 +180,7 @@ export interface RoomMemberStartup {
147
180
  role: string;
148
181
  task: string;
149
182
  owner_seat_cid: string | null;
183
+ anonymous?: boolean;
150
184
  }
151
185
  export interface ResolvedRole extends Omit<RoleConfig, 'model' | 'owner_channel' | 'worklog'> {
152
186
  name: string;
@@ -171,6 +205,13 @@ export interface ResolvedRole extends Omit<RoleConfig, 'model' | 'owner_channel'
171
205
  provenance?: Record<string, FieldProvenance>;
172
206
  /** Internal/transient: never accepted as a user-authored RoleConfig key. */
173
207
  roomMemberStartup?: RoomMemberStartup;
208
+ /** Original unresolved selections only; denied operational fields never enter inheritance state. */
209
+ agentSelections?: Pick<AgentDefinition, 'role' | 'brain'>;
210
+ agentTemplate?: {
211
+ id: string;
212
+ sourceFile: string;
213
+ contentHash: string;
214
+ };
174
215
  }
175
216
  export interface FieldProvenance {
176
217
  sourceFile: string;
@@ -190,12 +231,20 @@ export interface FieldProvenance {
190
231
  }
191
232
  export interface FleetConfig {
192
233
  roles: ResolvedRole[];
234
+ /** Canonical, variable-resolved Agent authoring documents keyed by stable Agent ID. */
235
+ agentDefinitions?: Record<string, AgentDefinition>;
236
+ /** Canonical inert templates keyed by stable template ID. */
237
+ agentTemplates?: Record<string, AgentTemplateDefinition>;
238
+ rolePresets?: Record<string, Record<string, unknown>>;
239
+ brainPresets?: Record<string, Record<string, unknown>>;
240
+ /** Required launch-boundary resolver for effective temporary Agent definitions. */
241
+ resolveAgentDefinition?: (id: string, definition: AgentDefinition) => ResolvedRole;
193
242
  vars: Record<string, string>;
194
243
  defaults: Record<string, unknown>;
195
244
  files: string[];
196
245
  configMode?: 'split-v2';
197
246
  sourceDocuments?: Array<{
198
- kind: 'Manifest' | 'Agent' | 'Role' | 'Brain';
247
+ kind: 'Manifest' | 'Agent' | 'AgentTemplate' | 'Role' | 'Brain' | 'RoomTemplate';
199
248
  id?: string;
200
249
  path: string;
201
250
  }>;
@@ -229,11 +278,27 @@ export declare function resolveRoleModel(model: string | null | undefined, harne
229
278
  */
230
279
  export declare function isolationContextFor(role: ResolvedRole): WrapContext;
231
280
  export declare const ROLE_NAME_RE: RegExp;
281
+ export declare const ROLE_PRESET_KEYS: string[];
282
+ export declare const BRAIN_PRESET_KEYS: string[];
283
+ export declare const AGENT_KEYS: string[];
284
+ type BarePreset = Record<string, unknown>;
285
+ export declare function validateRoleValue(value: BarePreset, file: string, pointer: string): void;
286
+ export declare function validateBrainValue(value: BarePreset, file: string, pointer: string): void;
287
+ /** Pure launch-boundary validation for a fully resolved temporary Agent definition. */
288
+ export declare function validateEffectiveAgentTemplate(definition: AgentTemplateDefinition, id?: string): AgentTemplateDefinition;
289
+ export declare function assertBareKeys(value: unknown, allowed: string[], label: string): BarePreset;
232
290
  export declare function splitRootFor(base: string): string;
233
291
  /** Load a v2 manifest and bare Agent/Role/Brain documents from its stem directory. */
234
292
  export declare function loadConfig(configPath?: string, options?: {
235
293
  yamlMode?: YamlMode;
294
+ additionalAgent?: {
295
+ id: string;
296
+ definition: AgentDefinition;
297
+ };
298
+ skipWatchdogs?: boolean;
236
299
  }): FleetConfig;
300
+ /** Full read-only Agent resolution used by launch dry-runs and room plan validation. */
301
+ export declare function resolveAgentDefinitionDryRun(configPath: string, id: string, definition: AgentDefinition): ResolvedRole;
237
302
  /**
238
303
  * Canonical form of a 64-hex container ID for authorization decisions. Hex
239
304
  * case is not identity: two casings of one CID are the same peer, so every
package/dist/config.js CHANGED
@@ -11,6 +11,8 @@ import { validateRoomsConfig, validateTasksConfig, validateRoomTemplatesConfig,
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
145
  'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy',
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',
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,6 +186,40 @@ 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) {
183
224
  for (const key of ['identity', 'cwd']) {
184
225
  const current = value[key];
@@ -228,7 +269,7 @@ function assertTrustedPath(path, expected) {
228
269
  if ((st.mode & 0o022) !== 0)
229
270
  throw new ConfigError(`E_FILE_TRUST: ${path} is group/world writable (mode ${(st.mode & 0o777).toString(8)})`);
230
271
  }
231
- function assertBareKeys(value, allowed, label) {
272
+ export function assertBareKeys(value, allowed, label) {
232
273
  if (!isPlainObject(value))
233
274
  throw new ConfigError(`${label}: E_SCHEMA: must be a mapping`);
234
275
  const bad = Object.keys(value).filter(key => !allowed.includes(key));
@@ -265,6 +306,35 @@ function readKindDirectory(root, kind, required, yamlMode, diagnostics, files) {
265
306
  }
266
307
  return result;
267
308
  }
309
+ function readRoomTemplateDirectory(root, yamlMode, diagnostics, files) {
310
+ if (!existsSync(root))
311
+ return {};
312
+ assertTrustedPath(root, 'directory');
313
+ const result = {};
314
+ const sources = new Map();
315
+ const names = readdirSync(root)
316
+ .filter(name => ['.yaml', '.yml'].includes(extname(name).toLowerCase())).sort();
317
+ for (const filename of names) {
318
+ const file = join(root, filename);
319
+ assertTrustedPath(file, 'file');
320
+ const id = basename(filename, extname(filename));
321
+ const previous = sources.get(id);
322
+ if (previous)
323
+ throw new ConfigError(`E_DUPLICATE_ID: room template '${id}' defined by both ${previous} and ${file}`);
324
+ const parsed = parseFleetDocument(file, readFileSync(file, 'utf8'), yamlMode);
325
+ diagnostics.push(...parsed.diagnostics);
326
+ if (parsed.value.override_builtin === true)
327
+ diagnostics.push({
328
+ severity: 'warning', kind: 'deprecated-field', file, line: 1, column: 1,
329
+ message: `${file}: override_builtin is deprecated and ignored for a file-backed Room template`,
330
+ });
331
+ files.push(file);
332
+ const validated = validateRoomTemplatesConfig({ [id]: parsed.value }, file)[id];
333
+ result[id] = { ...validated, sourceFile: file };
334
+ sources.set(id, file);
335
+ }
336
+ return result;
337
+ }
268
338
  function selection(raw, kind, agentFile, presets, allowed) {
269
339
  if (!isPlainObject(raw))
270
340
  schemaError(agentFile, `/${kind}`, 'must be { ref } or { inline }');
@@ -305,7 +375,23 @@ function deepSubStrict(value, vars, file, pointer) {
305
375
  ]));
306
376
  return value;
307
377
  }
308
- function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files) {
378
+ function mergeTemplateDefinition(template, overrides, file, id) {
379
+ const merge = (base, overlay, key) => {
380
+ if (overlay === null && key !== 'model')
381
+ schemaError(file, `/agents/${id}/overrides/${key}`, 'null deletion is not supported');
382
+ if (MERGED_MAP_FIELDS.has(key) && isPlainObject(base) && isPlainObject(overlay)) {
383
+ return Object.fromEntries([...new Set([...Object.keys(base), ...Object.keys(overlay)])]
384
+ .map(child => [child, Object.hasOwn(overlay, child)
385
+ ? merge(base[child], overlay[child], key) : structuredClone(base[child])]));
386
+ }
387
+ return structuredClone(overlay);
388
+ };
389
+ const result = structuredClone(template);
390
+ for (const [key, value] of Object.entries(overrides))
391
+ result[key] = merge(result[key], value, key);
392
+ return result;
393
+ }
394
+ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files, additionalAgent, templateSink = {}, roleSink = {}, brainSink = {}) {
309
395
  if (baseDoc.api_version !== V2_API_VERSION)
310
396
  throw new ConfigError(`${base}: legacy fleet configuration is unsupported; run 'ours-fleet doctor' and rewrite it as ${V2_API_VERSION}`);
311
397
  if ('roles' in baseDoc)
@@ -316,11 +402,73 @@ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files) {
316
402
  assertTrustedPath(root, 'directory');
317
403
  const brains = readKindDirectory(join(root, 'brains'), 'brain', false, yamlMode, diagnostics, files);
318
404
  const roles = readKindDirectory(join(root, 'roles'), 'role', false, yamlMode, diagnostics, files);
405
+ const agentTemplates = readKindDirectory(join(root, 'agent_templates'), 'agent template', false, yamlMode, diagnostics, files);
319
406
  const agents = readKindDirectory(join(root, 'agents'), 'agent', true, yamlMode, diagnostics, files);
407
+ if (additionalAgent) {
408
+ if (agents.has(additionalAgent.id))
409
+ throw new ConfigError(`agent '${additionalAgent.id}' already exists`);
410
+ agents.set(additionalAgent.id, {
411
+ file: '(spawn request)', value: structuredClone(additionalAgent.definition),
412
+ });
413
+ }
320
414
  const vars = (baseDoc.vars ?? {});
415
+ for (const [id, preset] of roles) {
416
+ const value = assertBareKeys(preset.value, ROLE_PRESET_KEYS, `${preset.file}: Role '${id}'`);
417
+ validateRoleValue(value, preset.file, `/roles/${id}`);
418
+ roleSink[id] = deepSubStrict(value, vars, preset.file, `/roles/${id}`);
419
+ }
420
+ for (const [id, preset] of brains) {
421
+ const value = assertBareKeys(preset.value, BRAIN_PRESET_KEYS, `${preset.file}: Brain '${id}'`);
422
+ validateBrainValue(value, preset.file, `/brains/${id}`);
423
+ brainSink[id] = deepSubStrict(value, vars, preset.file, `/brains/${id}`);
424
+ }
425
+ const canonicalTemplates = new Map();
426
+ for (const [id, template] of agentTemplates) {
427
+ const value = assertBareKeys(template.value, AGENT_KEYS, `${template.file}: agent template '${id}'`);
428
+ if (!('role' in value) || !('brain' in value))
429
+ throw new ConfigError(`${template.file}: agent template '${id}' requires role and brain`);
430
+ const forbidden = TEMPLATE_FORBIDDEN_KEYS.filter(key => Object.hasOwn(value, key));
431
+ if (forbidden.length)
432
+ schemaError(template.file, `/agent_templates/${id}/${forbidden[0]}`, 'templates cannot own instance identity');
433
+ const templateRole = selection(value.role, 'role', template.file, roles, ROLE_PRESET_KEYS);
434
+ const templateBrain = selection(value.brain, 'brain', template.file, brains, BRAIN_PRESET_KEYS);
435
+ validateRoleValue(templateRole.value, templateRole.file, templateRole.pointer);
436
+ validateBrainValue(templateBrain.value, templateBrain.file, templateBrain.pointer);
437
+ validateAgentScalars(Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'role' && key !== 'brain')), template.file, id);
438
+ canonicalTemplates.set(id, { file: template.file, value });
439
+ const operational = deepSubStrict(Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'role' && key !== 'brain')), vars, template.file, `/agent_templates/${id}`);
440
+ templateSink[id] = {
441
+ role: { inline: deepSubStrict(templateRole.value, vars, templateRole.file, templateRole.pointer) },
442
+ brain: { inline: deepSubStrict(templateBrain.value, vars, templateBrain.file, templateBrain.pointer) },
443
+ ...operational,
444
+ };
445
+ }
321
446
  const documents = [];
322
447
  for (const [id, agent] of agents) {
323
- const a = assertBareKeys(agent.value, AGENT_KEYS, `${agent.file}: agent '${id}'`);
448
+ let a;
449
+ let agentTemplate;
450
+ const instanceKeys = Object.keys(agent.value);
451
+ if (instanceKeys.includes('template')) {
452
+ assertBareKeys(agent.value, AGENT_INSTANCE_KEYS, `${agent.file}: agent '${id}'`);
453
+ if (typeof agent.value.template !== 'string' || !ROLE_NAME_RE.test(agent.value.template))
454
+ schemaError(agent.file, `/agents/${id}/template`, 'must be a valid Agent Template ID');
455
+ const template = canonicalTemplates.get(agent.value.template);
456
+ if (!template)
457
+ throw new ConfigError(`${agent.file}: E_REF_MISSING: agent template '${agent.value.template}' not found`);
458
+ if (agent.value.overrides !== undefined && !isPlainObject(agent.value.overrides))
459
+ schemaError(agent.file, `/agents/${id}/overrides`, 'must be a mapping');
460
+ const overrides = assertBareKeys((agent.value.overrides ?? {}), AGENT_KEYS, `${agent.file}: agent '${id}' overrides`);
461
+ a = mergeTemplateDefinition(template.value, overrides, agent.file, id);
462
+ const resolvedTemplate = templateSink[agent.value.template];
463
+ agentTemplate = {
464
+ id: agent.value.template,
465
+ sourceFile: template.file,
466
+ contentHash: createHash('sha256').update(canonicalJson(resolvedTemplate)).digest('hex'),
467
+ };
468
+ }
469
+ else {
470
+ a = assertBareKeys(agent.value, AGENT_KEYS, `${agent.file}: agent '${id}'`);
471
+ }
324
472
  if (!('role' in a) || !('brain' in a))
325
473
  throw new ConfigError(`${agent.file}: agent '${id}' requires role and brain`);
326
474
  const role = selection(a.role, 'role', agent.file, roles, ROLE_PRESET_KEYS);
@@ -392,8 +540,16 @@ function composeSplitRoles(base, baseDoc, yamlMode, diagnostics, files) {
392
540
  : undefined);
393
541
  delete provenance.role;
394
542
  delete provenance.brain;
543
+ const canonicalDefinition = {
544
+ role: deepSubStrict(a.role, vars, agent.file, `/agents/${id}/role`),
545
+ brain: deepSubStrict(a.brain, vars, agent.file, `/agents/${id}/brain`),
546
+ ...operational,
547
+ };
395
548
  documents.push({ file: agent.file,
396
- doc: { roles: { [id]: { ...brainValue, ...roleValue, ...operational } } }, provenance });
549
+ doc: { roles: { [id]: { ...brainValue, ...roleValue, ...operational } } }, provenance,
550
+ agentSelections: structuredClone({ role: canonicalDefinition.role, brain: canonicalDefinition.brain }),
551
+ agentDefinition: canonicalDefinition,
552
+ ...(agentTemplate ? { agentTemplate } : {}) });
397
553
  }
398
554
  return documents;
399
555
  }
@@ -411,6 +567,9 @@ export function loadConfig(configPath, options = {}) {
411
567
  const base = configPath ?? defaultConfigPath();
412
568
  const files = [];
413
569
  const diagnostics = [];
570
+ const agentTemplates = {};
571
+ const rolePresets = {};
572
+ const brainPresets = {};
414
573
  let docs = [];
415
574
  let manifestDoc = {};
416
575
  if (existsSync(base)) {
@@ -429,14 +588,20 @@ export function loadConfig(configPath, options = {}) {
429
588
  const dd = fleetDDir();
430
589
  if (existsSync(dd) && readdirSync(dd).some(f => f.endsWith('.yaml') || f.endsWith('.yml')))
431
590
  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)];
591
+ docs = [{ file: base, doc: manifestDoc }, ...composeSplitRoles(base, manifestDoc, options.yamlMode ?? 'compat', diagnostics, files, options.additionalAgent, agentTemplates, rolePresets, brainPresets)];
433
592
  const baseDoc = manifestDoc;
434
593
  const vars = (baseDoc.vars ?? {});
435
594
  const defaults = (baseDoc.defaults ?? {});
595
+ const legacyRuntimeDefaults = ['harness', 'model', 'model_chain', 'effort', 'harness_options',
596
+ 'session', 'session_options', 'max_tokens', 'autocompact_pct']
597
+ .filter(key => Object.hasOwn(defaults, key));
598
+ if (legacyRuntimeDefaults.length)
599
+ throw new ConfigError(`${base}: E_LEGACY /defaults: Brain-owned field(s) ${legacyRuntimeDefaults.join(', ')} are unsupported; move them to a Brain definition`);
436
600
  const startStaggerMs = resolveStartStaggerMs(baseDoc.start_stagger_ms, base);
437
601
  const seen = new Map();
438
602
  const roles = [];
439
- for (const { file, doc, provenance } of docs) {
603
+ const agentDefinitions = {};
604
+ for (const { file, doc, provenance, agentSelections, agentDefinition, agentTemplate } of docs) {
440
605
  for (const [name, raw] of Object.entries((doc.roles ?? {}))) {
441
606
  if (!ROLE_NAME_RE.test(name))
442
607
  throw new ConfigError(`${file}: invalid role name '${name}' (allowed: [A-Za-z0-9_-])`);
@@ -444,6 +609,8 @@ export function loadConfig(configPath, options = {}) {
444
609
  if (prev)
445
610
  throw new ConfigError(`role '${name}' defined in both ${prev} and ${file}`);
446
611
  seen.set(name, file);
612
+ if (agentDefinition)
613
+ agentDefinitions[name] = structuredClone(agentDefinition);
447
614
  const r = deepSub(raw ?? {}, vars);
448
615
  const bad = Object.keys(r).filter(k => !ROLE_KEYS.includes(k));
449
616
  if (bad.length)
@@ -474,6 +641,8 @@ export function loadConfig(configPath, options = {}) {
474
641
  const worklog = resolveWorklogPolicy(defaults.worklog, r.worklog, file, name);
475
642
  const authProxy = resolveAuthProxy(defaults.auth_proxy, r.auth_proxy, file, name);
476
643
  const harness = r.harness ?? defaults.harness ?? 'claude-code';
644
+ if (session === 'codex-app-server' && harness !== 'codex')
645
+ throw new ConfigError(`${file}: role '${name}' session: codex-app-server requires harness: codex`);
477
646
  const defaultHarness = defaults.harness ?? 'claude-code';
478
647
  const inheritsModelDefaults = harness === defaultHarness && r.model !== null;
479
648
  if (authProxy && harness !== 'claude-code')
@@ -530,6 +699,8 @@ export function loadConfig(configPath, options = {}) {
530
699
  auth_proxy: authProxy,
531
700
  env: Object.keys(env).length ? env : undefined,
532
701
  loops: [],
702
+ ...(agentSelections ? { agentSelections } : {}),
703
+ ...(agentTemplate ? { agentTemplate } : {}),
533
704
  });
534
705
  // Forbidden-path enforcement: a mount that would breach the policy
535
706
  // is a configuration error, caught by `config` rather than at launch.
@@ -545,7 +716,12 @@ export function loadConfig(configPath, options = {}) {
545
716
  }
546
717
  }
547
718
  validateOwnerChannelIdentities(roles);
548
- const watchdogs = resolveWatchdogs(baseDoc, base, roles, vars, defaults);
719
+ const watchdogs = options.skipWatchdogs ? [] : resolveWatchdogs(baseDoc, base, roles, vars, agentDefinitions, (name, definition) => {
720
+ const id = `WatchdogAgent${name}`.slice(0, 64);
721
+ return findRole(loadConfig(base, {
722
+ yamlMode: options.yamlMode, additionalAgent: { id, definition }, skipWatchdogs: true,
723
+ }), id);
724
+ });
549
725
  const resolvedLoops = resolveLoops(baseDoc.loops, base, roles, vars);
550
726
  for (const role of roles)
551
727
  role.loops = resolvedLoops.byRole.get(role.name) ?? [];
@@ -558,6 +734,9 @@ export function loadConfig(configPath, options = {}) {
558
734
  let ownerInvite;
559
735
  let roomTemplates;
560
736
  let tasks;
737
+ const fileTemplates = readRoomTemplateDirectory(join(splitRootFor(base), 'room_templates'), options.yamlMode ?? 'compat', diagnostics, files);
738
+ if (Object.keys(fileTemplates).length)
739
+ roomTemplates = fileTemplates;
561
740
  for (const { file, doc } of docs) {
562
741
  if (doc.rooms !== undefined) {
563
742
  if (rooms)
@@ -570,7 +749,21 @@ export function loadConfig(configPath, options = {}) {
570
749
  }
571
750
  if (doc.room_templates !== undefined) {
572
751
  const validated = validateRoomTemplatesConfig(deepSub(doc.room_templates, vars), file);
573
- roomTemplates = { ...(roomTemplates ?? {}), ...validated };
752
+ const raw = doc.room_templates;
753
+ for (const [name, template] of Object.entries(validated)) {
754
+ const shadowed = roomTemplates?.[name];
755
+ const marker = raw[name]?.override_builtin;
756
+ if (shadowed && (marker !== true || template.version <= shadowed.version))
757
+ throw new ConfigError(`${file}: room_templates.${name} shadows file preset ${shadowed.sourceFile}; `
758
+ + 'set override_builtin: true and use a higher version');
759
+ if (marker === true)
760
+ diagnostics.push({
761
+ severity: 'warning', kind: 'deprecated-field', file, line: 1, column: 1,
762
+ message: `${file}: room_templates.${name}.override_builtin is deprecated; `
763
+ + 'it is retained only as an explicit manifest-over-file migration marker',
764
+ });
765
+ roomTemplates = { ...(roomTemplates ?? {}), [name]: { ...template, sourceFile: file } };
766
+ }
574
767
  }
575
768
  if (doc.tasks !== undefined) {
576
769
  if (tasks)
@@ -579,7 +772,7 @@ export function loadConfig(configPath, options = {}) {
579
772
  }
580
773
  }
581
774
  const result = {
582
- roles, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
775
+ roles, agentDefinitions, agentTemplates, rolePresets, brainPresets, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
583
776
  loops: resolvedLoops.loops,
584
777
  rooms, roomTemplates, tasks, ownerInviteFingerprint,
585
778
  configMode: 'split-v2',
@@ -588,10 +781,18 @@ export function loadConfig(configPath, options = {}) {
588
781
  return { kind: 'Manifest', path };
589
782
  const parent = basename(dirname(path));
590
783
  const kind = parent === 'agents' ? 'Agent'
591
- : parent === 'roles' ? 'Role' : 'Brain';
784
+ : parent === 'agent_templates' ? 'AgentTemplate'
785
+ : parent === 'roles' ? 'Role'
786
+ : parent === 'room_templates' ? 'RoomTemplate' : 'Brain';
592
787
  return { kind, id: basename(path, extname(path)), path };
593
788
  }),
594
789
  };
790
+ Object.defineProperty(result, 'resolveAgentDefinition', {
791
+ value: (id, definition) => resolveAgentDefinitionDryRun(base, id, definition),
792
+ enumerable: false,
793
+ writable: false,
794
+ configurable: false,
795
+ });
595
796
  if (ownerInvite !== undefined) {
596
797
  Object.defineProperty(result, 'ownerInvite', {
597
798
  value: ownerInvite,
@@ -602,6 +803,12 @@ export function loadConfig(configPath, options = {}) {
602
803
  }
603
804
  return result;
604
805
  }
806
+ /** Full read-only Agent resolution used by launch dry-runs and room plan validation. */
807
+ export function resolveAgentDefinitionDryRun(configPath, id, definition) {
808
+ return findRole(loadConfig(configPath, {
809
+ additionalAgent: { id, definition }, skipWatchdogs: true,
810
+ }), id);
811
+ }
605
812
  /**
606
813
  * Canonical form of a 64-hex container ID for authorization decisions. Hex
607
814
  * case is not identity: two casings of one CID are the same peer, so every
@@ -685,8 +892,8 @@ export function resolveOwnerChannelConfig(defaults, role, session, file = 'confi
685
892
  const maxRequestBytes = attachments.max_request_bytes ?? 20 * 1024 * 1024;
686
893
  if (maxRequestBytes < maxFileBytes)
687
894
  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`);
895
+ if (session !== 'acp' && session !== 'codex-app-server')
896
+ throw new ConfigError(`${file}: role '${name}' owner_channel requires a managed session with correlated final replies`);
690
897
  return {
691
898
  identity: merged.identity.trim(),
692
899
  owners,
@@ -706,7 +913,13 @@ export function resolveOwnerChannelConfig(defaults, role, session, file = 'confi
706
913
  };
707
914
  }
708
915
  function validateOwnerChannelIdentities(roles) {
709
- const roleIdentities = new Map(roles.map(role => [role.identity, role.name]));
916
+ const roleIdentities = new Map();
917
+ for (const role of roles) {
918
+ const prior = roleIdentities.get(role.identity);
919
+ if (prior)
920
+ throw new ConfigError(`persistent Agents '${prior}' and '${role.name}' resolve to duplicate identity '${role.identity}'`);
921
+ roleIdentities.set(role.identity, role.name);
922
+ }
710
923
  const channels = new Map();
711
924
  for (const role of roles) {
712
925
  const identity = role.owner_channel?.identity;
@@ -812,8 +1025,8 @@ function resolveSession(raw, file, name) {
812
1025
  if (value === 'tmux')
813
1026
  throw new ConfigError(`${file}: role '${name}' session: tmux is no longer supported; use session: acp `
814
1027
  + 'with the Codex or Claude Code adapter');
815
- if (value !== 'acp')
816
- throw new ConfigError(`${file}: role '${name}' session: must be: acp`);
1028
+ if (value !== 'acp' && value !== 'codex-app-server')
1029
+ throw new ConfigError(`${file}: role '${name}' session: must be: acp, codex-app-server`);
817
1030
  return value;
818
1031
  }
819
1032
  function resolveSessionOptions(defaults, role, session, file, name) {
@@ -828,26 +1041,40 @@ function resolveSessionOptions(defaults, role, session, file, name) {
828
1041
  ...((defaults?.acp) ?? {}),
829
1042
  ...(role?.acp ?? {}),
830
1043
  },
1044
+ codex_app_server: {
1045
+ ...((defaults?.codex_app_server) ?? {}),
1046
+ ...(role?.codex_app_server ?? {}),
1047
+ },
831
1048
  };
832
1049
  if (defaults?.tmux !== undefined
833
1050
  || role?.tmux !== undefined)
834
1051
  throw new ConfigError(`${file}: role '${name}' session_options.tmux is no longer supported; `
835
1052
  + 'use session: acp with session_options.acp');
836
- const bad = Object.keys(merged).filter(k => k !== 'acp');
1053
+ const bad = Object.keys(merged).filter(k => k !== 'acp' && k !== 'codex_app_server');
837
1054
  if (bad.length)
838
1055
  throw new ConfigError(`${file}: role '${name}' session_options: unknown key(s) ${bad.join(', ')}`);
839
1056
  if (!isPlainObject(merged.acp))
840
- throw new ConfigError(`${file}: role '${name}' session_options.${session} must be a map`);
1057
+ throw new ConfigError(`${file}: role '${name}' session_options.acp must be a map`);
1058
+ if (!isPlainObject(merged.codex_app_server))
1059
+ throw new ConfigError(`${file}: role '${name}' session_options.codex_app_server must be a map`);
841
1060
  const acpBad = Object.keys(merged.acp).filter(k => k !== 'command');
842
1061
  if (acpBad.length)
843
1062
  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;
1063
+ const nativeBad = Object.keys(merged.codex_app_server).filter(k => k !== 'command');
1064
+ if (nativeBad.length)
1065
+ throw new ConfigError(`${file}: role '${name}' session_options.codex_app_server: unknown key(s) ${nativeBad.join(', ')}`);
1066
+ for (const [key, command] of [
1067
+ ['acp', merged.acp.command],
1068
+ ['codex_app_server', merged.codex_app_server.command],
1069
+ ]) {
1070
+ if (command !== undefined
1071
+ && !(typeof command === 'string' && command.trim())
1072
+ && !(Array.isArray(command) && command.length > 0
1073
+ && command.every(v => typeof v === 'string' && v.length > 0)))
1074
+ throw new ConfigError(`${file}: role '${name}' session_options.${key}.command must be a non-empty string or string list`);
1075
+ }
1076
+ return Object.keys(merged.acp).length || Object.keys(merged.codex_app_server).length
1077
+ ? merged : undefined;
851
1078
  }
852
1079
  export function resolvePermissions(defaults, role, file = 'config', name = 'role') {
853
1080
  if (defaults !== undefined && !isPlainObject(defaults))
@@ -932,7 +1159,10 @@ export function resolveMonitorConfig(defMonitor, roleMonitor, labels = {}) {
932
1159
  }
933
1160
  export function findRole(cfg, name) {
934
1161
  const r = cfg.roles.find(r => r.name === name);
935
- if (!r)
1162
+ if (!r) {
1163
+ if (cfg.agentTemplates?.[name])
1164
+ throw new ConfigError(`'${name}' is an inert Agent Template, not a persistent Agent; lifecycle commands target only fleet/agents instances`);
936
1165
  throw new ConfigError(`no such role '${name}' in ${cfg.files.join(', ') || 'config'}`);
1166
+ }
937
1167
  return r;
938
1168
  }