@ours.network/fleet 1.1.0-nightly.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/README.md +231 -46
  2. package/dist/application/capabilities.js +18 -6
  3. package/dist/application/fleet-query-service.js +7 -4
  4. package/dist/application/role-command-service.js +9 -0
  5. package/dist/application/role-creation-service.d.ts +4 -4
  6. package/dist/application/role-creation-service.js +8 -1
  7. package/dist/application/role-repository.d.ts +4 -2
  8. package/dist/application/role-repository.js +6 -3
  9. package/dist/application/session-control.d.ts +3 -1
  10. package/dist/application/session-control.js +4 -2
  11. package/dist/application/task-room-service.d.ts +92 -39
  12. package/dist/application/task-room-service.js +562 -289
  13. package/dist/briefing.js +79 -22
  14. package/dist/build-info.json +5 -5
  15. package/dist/canonical-json.d.ts +2 -0
  16. package/dist/canonical-json.js +10 -0
  17. package/dist/cli.js +129 -47
  18. package/dist/config.d.ts +38 -3
  19. package/dist/config.js +193 -28
  20. package/dist/creation.d.ts +1 -1
  21. package/dist/creation.js +7 -4
  22. package/dist/docs.d.ts +1 -1
  23. package/dist/docs.js +181 -41
  24. package/dist/doctor.d.ts +2 -0
  25. package/dist/doctor.js +40 -4
  26. package/dist/fleet-command-audit.d.ts +22 -1
  27. package/dist/fleet-command-audit.js +355 -50
  28. package/dist/fleet-proxy.d.ts +5 -2
  29. package/dist/harness/claude-code-session.js +2 -1
  30. package/dist/harness/codex-app-server-proxy.d.ts +40 -0
  31. package/dist/harness/codex-app-server-proxy.js +348 -14
  32. package/dist/harness/codex-session.d.ts +9 -2
  33. package/dist/harness/codex-session.js +18 -2
  34. package/dist/harness/codex.d.ts +5 -1
  35. package/dist/harness/codex.js +104 -10
  36. package/dist/harness/types.d.ts +1 -1
  37. package/dist/index.d.ts +4 -3
  38. package/dist/index.js +3 -2
  39. package/dist/init-guidance.d.ts +1 -1
  40. package/dist/init-guidance.js +1 -1
  41. package/dist/init-wizard.d.ts +98 -0
  42. package/dist/init-wizard.js +631 -0
  43. package/dist/lifecycle-summary.d.ts +134 -0
  44. package/dist/lifecycle-summary.js +179 -0
  45. package/dist/loops/config.d.ts +10 -1
  46. package/dist/loops/config.js +38 -5
  47. package/dist/model-recovery.d.ts +1 -1
  48. package/dist/ops.js +2 -17
  49. package/dist/owner-channel/channel.d.ts +19 -3
  50. package/dist/owner-channel/channel.js +301 -120
  51. package/dist/owner-channel/commands.d.ts +28 -6
  52. package/dist/owner-channel/commands.js +109 -27
  53. package/dist/owner-channel/lifecycle-outbox.d.ts +24 -0
  54. package/dist/owner-channel/lifecycle-outbox.js +87 -0
  55. package/dist/owner-channel/ours-client.d.ts +19 -0
  56. package/dist/owner-channel/ours-client.js +3 -0
  57. package/dist/permissions.js +1 -1
  58. package/dist/preset-migration.d.ts +42 -0
  59. package/dist/preset-migration.js +450 -0
  60. package/dist/reports/artifact.d.ts +10 -0
  61. package/dist/reports/artifact.js +32 -0
  62. package/dist/reports/index.d.ts +5 -0
  63. package/dist/reports/index.js +5 -0
  64. package/dist/reports/render.d.ts +6 -0
  65. package/dist/reports/render.js +240 -0
  66. package/dist/reports/service.d.ts +42 -0
  67. package/dist/reports/service.js +57 -0
  68. package/dist/reports/tasks.d.ts +52 -0
  69. package/dist/reports/tasks.js +177 -0
  70. package/dist/reports/types.d.ts +315 -0
  71. package/dist/reports/types.js +6 -0
  72. package/dist/resolved-plan.js +13 -1
  73. package/dist/rooms-tasks/cli.d.ts +2 -0
  74. package/dist/rooms-tasks/cli.js +361 -285
  75. package/dist/rooms-tasks/close.d.ts +9 -1
  76. package/dist/rooms-tasks/close.js +23 -7
  77. package/dist/rooms-tasks/config.js +16 -25
  78. package/dist/rooms-tasks/cowork-adapter.d.ts +5 -0
  79. package/dist/rooms-tasks/cowork-adapter.js +13 -0
  80. package/dist/rooms-tasks/deletion.d.ts +51 -0
  81. package/dist/rooms-tasks/deletion.js +217 -0
  82. package/dist/rooms-tasks/external-worker.d.ts +8 -0
  83. package/dist/rooms-tasks/external-worker.js +38 -0
  84. package/dist/rooms-tasks/launch-snapshot.d.ts +19 -0
  85. package/dist/rooms-tasks/launch-snapshot.js +181 -0
  86. package/dist/rooms-tasks/member-overrides.d.ts +30 -0
  87. package/dist/rooms-tasks/member-overrides.js +204 -0
  88. package/dist/rooms-tasks/member-startup.d.ts +1 -0
  89. package/dist/rooms-tasks/member-startup.js +9 -2
  90. package/dist/rooms-tasks/provision.js +146 -83
  91. package/dist/rooms-tasks/room-state.d.ts +1 -0
  92. package/dist/rooms-tasks/room-state.js +12 -1
  93. package/dist/rooms-tasks/task-room-name.d.ts +12 -0
  94. package/dist/rooms-tasks/task-room-name.js +61 -0
  95. package/dist/rooms-tasks/task-state.d.ts +115 -3
  96. package/dist/rooms-tasks/task-state.js +361 -11
  97. package/dist/rooms-tasks/templates.d.ts +4 -1
  98. package/dist/rooms-tasks/templates.js +34 -3
  99. package/dist/rooms-tasks/terminal.d.ts +3 -0
  100. package/dist/rooms-tasks/terminal.js +10 -4
  101. package/dist/rooms-tasks/types.d.ts +92 -7
  102. package/dist/rooms-tasks/types.js +14 -1
  103. package/dist/runner.d.ts +3 -0
  104. package/dist/runner.js +91 -44
  105. package/dist/session/acp.d.ts +4 -1
  106. package/dist/session/acp.js +29 -13
  107. package/dist/session/arbiter.d.ts +2 -1
  108. package/dist/session/arbiter.js +2 -0
  109. package/dist/session/codex-app-server-transport.d.ts +53 -0
  110. package/dist/session/codex-app-server-transport.js +184 -0
  111. package/dist/session/codex-app-server.d.ts +122 -0
  112. package/dist/session/codex-app-server.js +1031 -0
  113. package/dist/session/control.d.ts +1 -1
  114. package/dist/session/control.js +17 -1
  115. package/dist/session/conversation-types.d.ts +4 -4
  116. package/dist/session/types.d.ts +24 -6
  117. package/dist/session/types.js +28 -0
  118. package/dist/spawn.d.ts +9 -0
  119. package/dist/spawn.js +77 -9
  120. package/dist/web/fleet-config-service.d.ts +1 -0
  121. package/dist/web/fleet-config-service.js +25 -7
  122. package/dist/web/runtime.js +11 -6
  123. package/dist/web/server.js +28 -1
  124. package/dist/web-app/assets/{index-BbTc5KtZ.js → index-C0h3ALvs.js} +8 -8
  125. package/dist/web-app/index.html +1 -1
  126. package/package.json +4 -4
  127. package/presets/brain-catalog.json +17 -0
  128. package/presets/fleet/{agents → agent_templates}/Critic.yaml +2 -0
  129. package/presets/fleet/{agents/Agent.yaml → agent_templates/Developer.yaml} +3 -1
  130. package/presets/fleet/agent_templates/LocalCoordinator.yaml +50 -0
  131. package/presets/fleet/agents/{Developer.yaml → FleetCoordinator.yaml} +1 -1
  132. package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
  133. package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
  134. package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
  135. package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
  136. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
  137. package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
  138. package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
  139. package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
  140. package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
  141. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
  142. package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
  143. package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
  144. package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
  145. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
  146. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
  147. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
  148. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
  149. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
  150. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
  151. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
  152. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
  153. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
  154. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
  155. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
  156. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
  157. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
  158. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
  159. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
  160. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
  161. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
  162. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
  163. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
  164. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
  165. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
  166. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
  167. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
  168. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
  169. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
  170. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
  171. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
  172. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
  173. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
  174. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
  175. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
  176. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
  177. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
  178. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
  179. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
  180. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +5 -0
  181. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +5 -0
  182. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +5 -0
  183. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +5 -0
  184. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +5 -0
  185. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +5 -0
  186. package/presets/fleet/roles/Coordinator.yaml +37 -0
  187. package/presets/fleet/roles/Critic.yaml +23 -1
  188. package/presets/fleet/roles/Developer.yaml +27 -5
  189. package/presets/fleet/roles/LocalCoordinator.yaml +35 -0
  190. package/presets/fleet/room_templates/pair.yaml +9 -7
  191. package/presets/fleet/room_templates/single.yaml +5 -4
  192. package/presets/fleet/room_templates/team.yaml +12 -8
  193. package/presets/manifest.json +2 -1
  194. package/presets/fleet/agents/Architect.yaml +0 -3
  195. package/presets/fleet/agents/Secretary.yaml +0 -3
  196. package/presets/fleet/agents/Tester.yaml +0 -3
  197. package/presets/fleet/roles/Agent.yaml +0 -6
  198. package/presets/fleet/roles/Architect.yaml +0 -6
  199. package/presets/fleet/roles/Secretary.yaml +0 -6
  200. package/presets/fleet/roles/Tester.yaml +0 -6
@@ -1,5 +1,6 @@
1
1
  import { withFileLock } from '../atomic-file.js';
2
- import type { CoworkAdapter } from './cowork-adapter.js';
2
+ import { type TempLifecycleDeps } from '../temp-lifecycle.js';
3
+ import { type CoworkAdapter } from './cowork-adapter.js';
3
4
  import type { RoomMemberSeat, RoomOrchestrationRecord } from './types.js';
4
5
  export interface RoomCloseDeps {
5
6
  inspectMember?(seat: RoomMemberSeat): Promise<{
@@ -11,6 +12,13 @@ export interface RoomCloseDeps {
11
12
  removeIdentity?(seat: RoomMemberSeat): Promise<void>;
12
13
  lock?: typeof withFileLock;
13
14
  }
15
+ export declare function inspectMember(seat: RoomMemberSeat): Promise<{
16
+ launchId: string;
17
+ }>;
18
+ export declare function waitForLivenessAbsent(role: string, launchId: string, lifecycleDeps?: TempLifecycleDeps): Promise<void>;
19
+ /** Report whether any daemon identity — under any name — carries this exact CID. */
20
+ export declare function identityCidPresent(cid: string): Promise<boolean>;
21
+ export declare function removeExactMemberIdentity(seat: RoomMemberSeat): Promise<void>;
14
22
  /** One forward-only room close saga shared by every Fleet entry point. */
15
23
  export declare function acceptManagedRoomClose(roomId: string): Promise<RoomOrchestrationRecord>;
16
24
  export declare function recordManagedRoomCloseError(roomId: string, error: string, recoveryHint: string): Promise<RoomOrchestrationRecord>;
@@ -5,6 +5,7 @@ import { attachOursClient } from '@ours.network/sdk/client';
5
5
  import { withFileLock } from '../atomic-file.js';
6
6
  import { agentDir, stateRoot } from '../paths.js';
7
7
  import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempSupervisorLiveness, } from '../temp-lifecycle.js';
8
+ import { CoworkProtocolError } from './cowork-adapter.js';
8
9
  import { advanceMemberRetirement, advanceRoomClose, beginRoomClose, closeRoom, deleteRoomRecord, getRoomRecord, listRoomRecords, setRoomCloseError, } from './room-state.js';
9
10
  const CLOSE_LOCK_STALE_MS = 5 * 60_000;
10
11
  const STOP_POLLS = 50;
@@ -26,7 +27,7 @@ function exactMemberIdentity(seat) {
26
27
  throw new Error(`room member '${seat.role_name}' temp state binds identity '${identity}'; refusing mismatched retirement`);
27
28
  }
28
29
  }
29
- async function inspectMember(seat) {
30
+ export async function inspectMember(seat) {
30
31
  exactMemberIdentity(seat);
31
32
  const supervisor = readTempSupervisor(agentDir(seat.role_name, true));
32
33
  if (!supervisor || supervisor.role !== seat.role_name) {
@@ -34,7 +35,7 @@ async function inspectMember(seat) {
34
35
  }
35
36
  return { launchId: supervisor.launchId };
36
37
  }
37
- async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {}) {
38
+ export async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {}) {
38
39
  const dir = agentDir(role, true);
39
40
  const sleep = lifecycleDeps.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
40
41
  for (let attempt = 0; attempt < STOP_POLLS; attempt++) {
@@ -66,9 +67,16 @@ async function withIdentityClient(work) {
66
67
  }
67
68
  }
68
69
  function listedIdentity(rows, name) {
69
- return rows.find(row => row.name === name);
70
+ return rows.find((row) => row.name === name && 'cid' in row);
70
71
  }
71
- async function removeExactMemberIdentity(seat) {
72
+ /** Report whether any daemon identity — under any name — carries this exact CID. */
73
+ export async function identityCidPresent(cid) {
74
+ return withIdentityClient(async (client) => {
75
+ const rows = await client.listIdentities();
76
+ return rows.some(row => 'cid' in row && row.cid.toLowerCase() === cid.toLowerCase());
77
+ });
78
+ }
79
+ export async function removeExactMemberIdentity(seat) {
72
80
  await withIdentityClient(async (client) => {
73
81
  const before = listedIdentity(await client.listIdentities(), seat.role_name);
74
82
  if (!before)
@@ -163,7 +171,7 @@ export async function closeManagedRoom(input) {
163
171
  return closeRoom(input.roomId);
164
172
  }
165
173
  catch (error) {
166
- setRoomCloseError(input.roomId, errorText(error), `Retry 'ours-fleet room delete ${input.roomId} ${input.roomId}' or run room recover.`);
174
+ setRoomCloseError(input.roomId, errorText(error), `Retry 'ours-fleet room delete ${input.roomId} ${input.roomId}'.`);
167
175
  throw error;
168
176
  }
169
177
  }, {}, CLOSE_LOCK_STALE_MS);
@@ -172,7 +180,15 @@ export async function closeManagedRoom(input) {
172
180
  export async function deleteLegacyClosedRooms(input) {
173
181
  const deleted = [];
174
182
  for (const room of listRoomRecords({ state: 'closed' })) {
175
- await input.cowork.deleteRoom(room.room_id);
183
+ try {
184
+ await input.cowork.deleteRoom(room.room_id);
185
+ }
186
+ catch (error) {
187
+ // The retained Fleet record is the legacy state being migrated. If the
188
+ // exact Cowork room is already absent, the desired deletion is complete.
189
+ if (!(error instanceof CoworkProtocolError && error.code === 'not_found'))
190
+ throw error;
191
+ }
176
192
  deleteRoomRecord(room.room_id);
177
193
  deleted.push(room.room_id);
178
194
  }
@@ -185,7 +201,7 @@ export async function deleteManagedRoom(input) {
185
201
  await input.cowork.deleteRoom(input.roomId);
186
202
  }
187
203
  catch (error) {
188
- setRoomCloseError(input.roomId, errorText(error), `Retry 'ours-fleet room delete ${input.roomId} ${input.roomId}' or run room recover.`);
204
+ setRoomCloseError(input.roomId, errorText(error), `Retry 'ours-fleet room delete ${input.roomId} ${input.roomId}'.`);
189
205
  throw error;
190
206
  }
191
207
  deleteRoomRecord(input.roomId);
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
- import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, } from './types.js';
4
- import { isSensitiveConfigKey } from '../sensitive-config.js';
3
+ import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, TEMPLATE_ROOM_KEYS as TRK, } from './types.js';
5
4
  const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
6
5
  const CID_RE = /^[0-9a-fA-F]{64}$/;
7
6
  export class RoomsTasksConfigError extends Error {
@@ -133,39 +132,31 @@ export function validateRoomTemplatesConfig(raw, path) {
133
132
  throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].role: required string`);
134
133
  if (!Number.isInteger(m.count) || m.count < 1)
135
134
  throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].count: required positive integer`);
136
- if (!isPlainObject(m.agent))
137
- throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: required mapping`);
138
- const agentKeys = Object.keys(m.agent);
139
- const isRef = agentKeys.length === 1 && typeof m.agent.ref === 'string';
140
- const isDefinition = agentKeys.includes('role') && agentKeys.includes('brain');
141
- if (!isRef && !isDefinition)
142
- throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: must be {ref} or canonical {role, brain, ...}`);
143
- const containsSensitive = (value) => Array.isArray(value)
144
- ? value.some(containsSensitive)
145
- : Boolean(value && typeof value === 'object' && Object.entries(value)
146
- .some(([key, child]) => isSensitiveConfigKey(key) || containsSensitive(child)));
147
- if (isDefinition && containsSensitive(m.agent))
148
- throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: inline sensitive configuration cannot be persisted; use references`);
149
- if (isDefinition) {
150
- const definition = m.agent;
151
- const brain = isPlainObject(definition.brain) && isPlainObject(definition.brain.inline)
152
- ? definition.brain.inline : undefined;
153
- if (definition.env !== undefined || definition.owner_channel !== undefined
154
- || definition.auth_proxy !== undefined || brain?.harness_options !== undefined
155
- || brain?.session_options !== undefined)
156
- throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: secret-capable inline fields cannot be persisted; use references`);
157
- }
135
+ if (m.agent !== undefined)
136
+ throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent: persistent Agent references are no longer allowed; use agent_template: <ID>`);
137
+ if (typeof m.agent_template !== 'string' || !m.agent_template.trim())
138
+ throw new RoomsTasksConfigError(path, `room_templates.${name}.members[${i}].agent_template: required non-blank Agent Template ID`);
158
139
  return {
159
140
  slot: m.slot,
160
141
  role: m.role,
161
142
  count: m.count,
162
- agent: structuredClone(m.agent),
143
+ agent_template: m.agent_template,
163
144
  };
164
145
  });
146
+ const slots = new Set();
147
+ for (const member of members) {
148
+ if (slots.has(member.slot))
149
+ throw new RoomsTasksConfigError(path, `room_templates.${name}.members: duplicate slot '${member.slot}'`);
150
+ slots.add(member.slot);
151
+ }
165
152
  let room;
166
153
  if (tplRaw.room !== undefined) {
167
154
  if (!isPlainObject(tplRaw.room))
168
155
  throw new RoomsTasksConfigError(path, `room_templates.${name}.room: must be a mapping`);
156
+ rejectUnknown(tplRaw.room, TRK, path, `room_templates.${name}.room`);
157
+ for (const key of TRK)
158
+ if (tplRaw.room[key] !== undefined && typeof tplRaw.room[key] !== 'boolean')
159
+ throw new RoomsTasksConfigError(path, `room_templates.${name}.room.${key}: must be a boolean`);
169
160
  room = {
170
161
  quiet_membership: tplRaw.room.quiet_membership,
171
162
  anonymous: tplRaw.room.anonymous,
@@ -33,6 +33,7 @@ export interface CoworkRoomInfo {
33
33
  identity_cid: string;
34
34
  room_name: string;
35
35
  state: 'provisioning' | 'active' | 'closing' | 'closed';
36
+ anonymous: boolean;
36
37
  seats: CoworkSeatInfo[];
37
38
  goal?: string;
38
39
  briefing?: string;
@@ -72,6 +73,10 @@ export interface CoworkAdapter {
72
73
  role: string;
73
74
  text: string;
74
75
  }): Promise<CoworkRoleBriefingInfo>;
76
+ setRoleCommands(roomId: string, opts: {
77
+ role: string;
78
+ commands: Array<'list-members' | 'remove-member'>;
79
+ }): Promise<void>;
75
80
  getHistory(roomId: string, opts?: {
76
81
  after?: number;
77
82
  limit?: number;
@@ -47,6 +47,11 @@ function roomState(value, operation) {
47
47
  throw new CoworkProtocolError(operation, 'room state is invalid');
48
48
  return value;
49
49
  }
50
+ function boolean(value, operation, label) {
51
+ if (typeof value !== 'boolean')
52
+ throw new CoworkProtocolError(operation, `${label} must be a boolean`);
53
+ return value;
54
+ }
50
55
  function seatState(value, operation) {
51
56
  if (value !== 'pending' && value !== 'active' && value !== 'removed')
52
57
  throw new CoworkProtocolError(operation, 'seat state is invalid');
@@ -86,6 +91,7 @@ function projectRoom(value, operation) {
86
91
  identity_cid: text(room.identity_cid, operation, 'room.identity_cid'),
87
92
  room_name: string(room.room_name, operation, 'room.room_name'),
88
93
  state: roomState(room.state, operation),
94
+ anonymous: room.anonymous === undefined ? false : boolean(room.anonymous, operation, 'room.anonymous'),
89
95
  seats: room.seats.map((seat) => projectSeat(seat, operation)),
90
96
  role_briefings: projectedBriefings,
91
97
  ...(typeof mission?.goal === 'string' ? { goal: mission.goal } : {}),
@@ -384,6 +390,13 @@ export function createCoworkAdapter(options = {}) {
384
390
  throw new CoworkProtocolError('room.participants', 'result must be an array');
385
391
  return result.map((seat) => projectSeat(seat, 'room.participants'));
386
392
  },
393
+ async setRoleCommands(roomId, opts) {
394
+ const result = await call('room.command.role.set', {
395
+ room_id: roomId, role: opts.role, commands: opts.commands,
396
+ });
397
+ if (!Array.isArray(result))
398
+ throw new CoworkProtocolError('room.command.role.set', 'result must be an array');
399
+ },
387
400
  async recoverRoom(roomId) {
388
401
  // Cowork performs packet/state reconciliation during daemon recovery.
389
402
  // Its `room.recover` RPC is specifically invite-receipt recovery, so a
@@ -0,0 +1,51 @@
1
+ import { type RoomCloseDeps } from './close.js';
2
+ import { type CoworkAdapter } from './cowork-adapter.js';
3
+ import { type TaskDeletionAcceptance } from './task-state.js';
4
+ import type { TaskDeletionActor, TaskRecord } from './types.js';
5
+ export { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
6
+ type DeletionCowork = Pick<CoworkAdapter, 'closeRoom' | 'deleteRoom'>;
7
+ export interface TaskDeletionSettleDeps {
8
+ roomClose?: RoomCloseDeps;
9
+ /** Test seam for the temp-state existence proof. */
10
+ hasTempState?(name: string): boolean;
11
+ /** Test seam for the CID-wide daemon identity scan. */
12
+ identityCidPresent?(cid: string): Promise<boolean>;
13
+ }
14
+ export interface TaskDeletionSettleResult {
15
+ task_id: string;
16
+ deleted: boolean;
17
+ /** Lifecycle state observed before unlink, for completion audit. */
18
+ previous_state?: TaskRecord['state'];
19
+ /** Title observed before unlink, for completion audit. */
20
+ title?: string;
21
+ }
22
+ /**
23
+ * Accept a permanent task deletion under the common task-operation lock so it
24
+ * serializes against terminal settlement, recovery, and the room-publication
25
+ * window. Acceptance is durable before any side effect and never requires
26
+ * Cowork availability; settlement runs separately and converges through
27
+ * retries.
28
+ */
29
+ export declare function acceptTaskDeletion(taskId: string, actor: TaskDeletionActor): Promise<TaskDeletionAcceptance>;
30
+ /** Persist a settlement failure under the same task-operation lock. */
31
+ export declare function recordTaskDeletionError(taskId: string, error: string, recoveryHint: string): Promise<TaskRecord>;
32
+ /**
33
+ * Converge an accepted deletion to physical absence in two lock-ordered
34
+ * stages. Cleanup stage (task-operation lock): per surviving room record,
35
+ * register late seats → close saga (retires members) → durable evidence
36
+ * checkpoint → tolerant remote delete → local record delete; then
37
+ * cursor-based retirement for members with no room record and tolerant remote
38
+ * close+delete of a recorded room whose local record is gone. Finalization
39
+ * stage (launch-snapshot lock → task-operation lock, matching the global
40
+ * provisioning order): re-verify quiescence, delete the owned launch snapshot
41
+ * while excluding only this deleting task from the reference scan, and unlink
42
+ * the task record LAST — every crash seam leaves the hidden intent
43
+ * recoverable. Failures are recorded on the intent; the task stays hidden and
44
+ * recoverable, never falsely settled.
45
+ */
46
+ export declare function settleTaskDeletion(input: {
47
+ taskId: string;
48
+ /** Lazy: resolved only when room work exists, so no-room tasks settle without Cowork config. */
49
+ cowork: () => DeletionCowork;
50
+ deps?: TaskDeletionSettleDeps;
51
+ }): Promise<TaskDeletionSettleResult>;
@@ -0,0 +1,217 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { withFileLock } from '../atomic-file.js';
3
+ import { agentDir } from '../paths.js';
4
+ import { secureStoppedTempArchive, stopTempSupervisor } from '../temp-lifecycle.js';
5
+ import { closeManagedRoom, identityCidPresent, inspectMember, removeExactMemberIdentity, waitForLivenessAbsent, } from './close.js';
6
+ import { CoworkProtocolError } from './cowork-adapter.js';
7
+ import { deleteRoomRecord, getRoomRecord, listRoomRecords } from './room-state.js';
8
+ import { acquireLaunchSnapshotLock, releaseLaunchSnapshotForDeletingTask } from './launch-snapshot.js';
9
+ import { TASK_OPERATION_LOCK_STALE_MS, taskOperationLockPath } from './terminal.js';
10
+ import { advanceTaskDeletionMember, beginTaskDeletionIntent, completeTaskDeletionReceipt, ensureTaskDeletionReceipt, getDeletingTask, importTaskDeletionRetirementEvidence, setTaskDeletionError, TaskStateError, unlinkDeletedTask, upsertTaskDeletionMembersFromSeats, } from './task-state.js';
11
+ export { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
12
+ import { DELETION_MEMBER_ABSENT_VERIFIED } from './task-state.js';
13
+ /**
14
+ * Accept a permanent task deletion under the common task-operation lock so it
15
+ * serializes against terminal settlement, recovery, and the room-publication
16
+ * window. Acceptance is durable before any side effect and never requires
17
+ * Cowork availability; settlement runs separately and converges through
18
+ * retries.
19
+ */
20
+ export function acceptTaskDeletion(taskId, actor) {
21
+ return withFileLock(taskOperationLockPath(taskId), () => beginTaskDeletionIntent(taskId, actor), {}, TASK_OPERATION_LOCK_STALE_MS);
22
+ }
23
+ /** Persist a settlement failure under the same task-operation lock. */
24
+ export function recordTaskDeletionError(taskId, error, recoveryHint) {
25
+ return withFileLock(taskOperationLockPath(taskId), () => setTaskDeletionError(taskId, error, recoveryHint), {}, TASK_OPERATION_LOCK_STALE_MS);
26
+ }
27
+ function errorText(error) {
28
+ return error instanceof Error ? error.message : String(error);
29
+ }
30
+ /** Already-missing remote rooms are a settled outcome, not a failure. */
31
+ function tolerantCowork(cowork) {
32
+ const tolerate = async (work) => {
33
+ try {
34
+ await work();
35
+ }
36
+ catch (error) {
37
+ if (error instanceof CoworkProtocolError && error.code === 'not_found')
38
+ return;
39
+ throw error;
40
+ }
41
+ };
42
+ return {
43
+ closeRoom: roomId => tolerate(() => cowork.closeRoom(roomId)),
44
+ deleteRoom: roomId => tolerate(() => cowork.deleteRoom(roomId)),
45
+ };
46
+ }
47
+ function cursorSeat(cursor) {
48
+ return {
49
+ role_name: cursor.name, identity_cid: cursor.identity_cid,
50
+ slot: cursor.name, cowork_role: 'member', seat_state: 'active',
51
+ };
52
+ }
53
+ /**
54
+ * Retire one member from its durable deletion cursor when no room record
55
+ * carries the seat: the same evidence chain as close.ts, resumed at the
56
+ * recorded phase. A member with no temp state is verified absent through the
57
+ * CID-wide identity scan; a same-name stranger identity is never touched, and
58
+ * the recorded CID surviving under any name blocks settlement.
59
+ */
60
+ async function retireCursorMember(taskId, cursor, deps) {
61
+ let phase = cursor.phase;
62
+ let launchId = cursor.launch_id;
63
+ if (phase === 'identity_absent')
64
+ return;
65
+ const seat = cursorSeat(cursor);
66
+ const roomClose = deps.roomClose ?? {};
67
+ if (phase === 'pending') {
68
+ const hasState = (deps.hasTempState ?? (name => existsSync(agentDir(name, true))))(cursor.name);
69
+ if (!hasState) {
70
+ const present = await (deps.identityCidPresent ?? identityCidPresent)(cursor.identity_cid);
71
+ if (present) {
72
+ // Remove only an exact name+CID match; a stranger under the same name
73
+ // stays untouched and removeExactMemberIdentity refuses the mismatch.
74
+ // The recorded CID surviving under a different name throws below on
75
+ // its post-removal verification and blocks settlement.
76
+ await (roomClose.removeIdentity ?? removeExactMemberIdentity)(seat);
77
+ if (await (deps.identityCidPresent ?? identityCidPresent)(cursor.identity_cid))
78
+ throw new TaskStateError(`deletion member '${cursor.name}' identity CID survives under another name; refusing to claim retirement`);
79
+ }
80
+ advanceTaskDeletionMember(taskId, cursor.name, 'identity_absent', DELETION_MEMBER_ABSENT_VERIFIED);
81
+ return;
82
+ }
83
+ const ownership = await (roomClose.inspectMember ?? inspectMember)(seat);
84
+ advanceTaskDeletionMember(taskId, cursor.name, 'stop_requested', ownership.launchId);
85
+ phase = 'stop_requested';
86
+ launchId = ownership.launchId;
87
+ }
88
+ if (phase === 'stop_requested') {
89
+ await (roomClose.requestStop
90
+ ?? (async (role) => { await stopTempSupervisor(role); }))(cursor.name);
91
+ await (roomClose.waitForLivenessAbsent ?? waitForLivenessAbsent)(cursor.name, launchId);
92
+ advanceTaskDeletionMember(taskId, cursor.name, 'liveness_absent');
93
+ phase = 'liveness_absent';
94
+ }
95
+ if (phase === 'liveness_absent') {
96
+ const archivePath = await (roomClose.secureArchive ?? secureStoppedTempArchive)(cursor.name, launchId);
97
+ advanceTaskDeletionMember(taskId, cursor.name, 'archive_secured', undefined, archivePath);
98
+ phase = 'archive_secured';
99
+ }
100
+ if (phase === 'archive_secured') {
101
+ await (roomClose.removeIdentity ?? removeExactMemberIdentity)(seat);
102
+ advanceTaskDeletionMember(taskId, cursor.name, 'identity_absent');
103
+ }
104
+ }
105
+ /**
106
+ * Converge an accepted deletion to physical absence in two lock-ordered
107
+ * stages. Cleanup stage (task-operation lock): per surviving room record,
108
+ * register late seats → close saga (retires members) → durable evidence
109
+ * checkpoint → tolerant remote delete → local record delete; then
110
+ * cursor-based retirement for members with no room record and tolerant remote
111
+ * close+delete of a recorded room whose local record is gone. Finalization
112
+ * stage (launch-snapshot lock → task-operation lock, matching the global
113
+ * provisioning order): re-verify quiescence, delete the owned launch snapshot
114
+ * while excluding only this deleting task from the reference scan, and unlink
115
+ * the task record LAST — every crash seam leaves the hidden intent
116
+ * recoverable. Failures are recorded on the intent; the task stays hidden and
117
+ * recoverable, never falsely settled.
118
+ */
119
+ export async function settleTaskDeletion(input) {
120
+ const { taskId } = input;
121
+ const deps = input.deps ?? {};
122
+ const recoveryHint = `Re-run 'ours-fleet task delete ${taskId} ${taskId}'.`;
123
+ const cleanup = await withFileLock(taskOperationLockPath(taskId), async () => {
124
+ let task;
125
+ try {
126
+ task = getDeletingTask(taskId);
127
+ }
128
+ catch (error) {
129
+ if (error instanceof TaskStateError && /task not found/.test(error.message))
130
+ return { kind: 'already_absent' };
131
+ throw error;
132
+ }
133
+ if (task.deletion?.status !== 'pending')
134
+ throw new TaskStateError(`task ${taskId} has no pending deletion`);
135
+ try {
136
+ // Audit evidence precedes every side effect; a receipt write failure
137
+ // aborts settlement (fail closed).
138
+ ensureTaskDeletionReceipt(taskId);
139
+ const records = listRoomRecords().filter(room => room.task_id === taskId);
140
+ const recordIds = new Set(records.map(room => room.room_id));
141
+ const recordedRoomId = task.deletion.room_id ?? task.room_id;
142
+ const needsCowork = records.length > 0 || recordedRoomId !== undefined;
143
+ const cowork = needsCowork ? tolerantCowork(input.cowork()) : undefined;
144
+ for (const record of records) {
145
+ upsertTaskDeletionMembersFromSeats(taskId, record.member_seats);
146
+ await closeManagedRoom({ roomId: record.room_id, cowork: cowork, deps: deps.roomClose });
147
+ const closed = getRoomRecord(record.room_id);
148
+ importTaskDeletionRetirementEvidence(taskId, closed?.member_seats ?? record.member_seats);
149
+ await cowork.deleteRoom(record.room_id);
150
+ deleteRoomRecord(record.room_id);
151
+ }
152
+ // Members whose room record is gone (crash after record deletion, or
153
+ // legacy state): resume from the durable cursors.
154
+ for (const cursor of getDeletingTask(taskId).deletion.members) {
155
+ await retireCursorMember(taskId, cursor, deps);
156
+ }
157
+ // A recorded room without a local record may still be live remotely.
158
+ if (recordedRoomId && !recordIds.has(recordedRoomId) && !getRoomRecord(recordedRoomId)) {
159
+ await cowork.closeRoom(recordedRoomId);
160
+ await cowork.deleteRoom(recordedRoomId);
161
+ }
162
+ return {
163
+ kind: 'cleaned',
164
+ snapshotHash: task.execution_plan?.snapshot.launch_snapshot_hash,
165
+ previousState: task.state,
166
+ title: task.title,
167
+ };
168
+ }
169
+ catch (error) {
170
+ setTaskDeletionError(taskId, errorText(error), recoveryHint);
171
+ throw error;
172
+ }
173
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
174
+ if (cleanup.kind === 'already_absent') {
175
+ // Heal a crash between unlink and receipt completion.
176
+ completeTaskDeletionReceipt(taskId);
177
+ return { task_id: taskId, deleted: false };
178
+ }
179
+ const finalize = () => withFileLock(taskOperationLockPath(taskId), () => {
180
+ try {
181
+ const task = getDeletingTask(taskId);
182
+ if (task.deletion?.status !== 'pending')
183
+ throw new TaskStateError(`task ${taskId} has no pending deletion`);
184
+ if (listRoomRecords().some(room => room.task_id === taskId))
185
+ throw new TaskStateError(`task ${taskId} room records reappeared during deletion finalization`);
186
+ if (task.deletion.members.some(member => member.phase !== 'identity_absent'))
187
+ throw new TaskStateError(`task ${taskId} has unretired members at deletion finalization`);
188
+ if (cleanup.snapshotHash)
189
+ releaseLaunchSnapshotForDeletingTask(cleanup.snapshotHash, taskId);
190
+ unlinkDeletedTask(taskId);
191
+ }
192
+ catch (error) {
193
+ setTaskDeletionError(taskId, errorText(error), recoveryHint);
194
+ throw error;
195
+ }
196
+ // After unlink the task can no longer carry errors; receipt completion is
197
+ // idempotent and the already-absent retry path heals a crash here.
198
+ completeTaskDeletionReceipt(taskId);
199
+ }, {}, TASK_OPERATION_LOCK_STALE_MS);
200
+ if (cleanup.snapshotHash) {
201
+ // Global lock order: launch-snapshot lock → task-operation lock.
202
+ const releaseSnapshotLock = acquireLaunchSnapshotLock();
203
+ try {
204
+ await finalize();
205
+ }
206
+ finally {
207
+ releaseSnapshotLock();
208
+ }
209
+ }
210
+ else {
211
+ await finalize();
212
+ }
213
+ return {
214
+ task_id: taskId, deleted: true,
215
+ previous_state: cleanup.previousState, title: cleanup.title,
216
+ };
217
+ }
@@ -1,3 +1,11 @@
1
+ import type { FleetAuditPresentation } from '../fleet-command-audit.js';
2
+ /** Lifecycle-only return path for a trusted worker; never authorizes a fresh Fleet attempt. */
3
+ export declare const FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV = "OURS_FLEET_WORKER_LIFECYCLE_STATE_DIR";
1
4
  export declare function fleetWorkerEnv(source?: NodeJS.ProcessEnv): Record<string, string>;
5
+ /** Present a detached worker's durable outcome through the authenticated Owner sink. */
6
+ export declare function presentFleetWorkerLifecycle(presentations: FleetAuditPresentation[], deps?: {
7
+ now(): number;
8
+ sleep(ms: number): Promise<void>;
9
+ }): Promise<void>;
2
10
  /** Launch a Fleet CLI operation outside the caller's supervisor/session lifecycle. */
3
11
  export declare function launchFleetWorker(args: string[], operation: string, configPath?: string): Promise<void>;
@@ -1,18 +1,56 @@
1
1
  import { execFile, spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV } from '../fleet-proxy.js';
4
+ import { controlRequest } from '../session/control.js';
5
+ import { SessionControlError } from '../session/types.js';
6
+ /** Lifecycle-only return path for a trusted worker; never authorizes a fresh Fleet attempt. */
7
+ export const FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV = 'OURS_FLEET_WORKER_LIFECYCLE_STATE_DIR';
8
+ const LIFECYCLE_ACCEPT_TIMEOUT_MS = 10 * 60_000;
9
+ const LIFECYCLE_RETRY_MS = 250;
4
10
  export function fleetWorkerEnv(source = process.env) {
5
11
  const inheritedKeys = [
6
12
  'HOME', 'PATH', 'XDG_RUNTIME_DIR', 'OURS_FLEET_HOME', 'OURS_CONFIG',
7
13
  'OURS_PORT', 'OURS_STATE_DIR', 'OURS_API_TOKEN', 'OURS_COWORK_CONFIG',
8
14
  ];
9
15
  const env = Object.fromEntries(inheritedKeys.flatMap(key => source[key] === undefined ? [] : [[key, source[key]]]));
16
+ const lifecycleStateDir = source[FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV]
17
+ ?? source[FLEET_PROXY_STATE_DIR_ENV];
18
+ if (lifecycleStateDir)
19
+ env[FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV] = lifecycleStateDir;
10
20
  // Explicit defense: trusted settle/recover/delete/close workers are effects
11
21
  // nested inside the outer audited command, never fresh agent attempts.
12
22
  delete env[FLEET_PROXY_STATE_DIR_ENV];
13
23
  delete env[FLEET_PROXY_CALLER_ENV];
14
24
  return env;
15
25
  }
26
+ /** Present a detached worker's durable outcome through the authenticated Owner sink. */
27
+ export async function presentFleetWorkerLifecycle(presentations, deps = {
28
+ now: Date.now, sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
29
+ }) {
30
+ const stateDir = process.env[FLEET_WORKER_LIFECYCLE_STATE_DIR_ENV];
31
+ if (!stateDir || !presentations.length)
32
+ return;
33
+ const deadline = deps.now() + LIFECYCLE_ACCEPT_TIMEOUT_MS;
34
+ for (;;) {
35
+ try {
36
+ const response = await controlRequest(stateDir, {
37
+ command: 'fleet_audit_present', audit: { presentations },
38
+ }, 15_000);
39
+ if (!response.ok)
40
+ throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'detached Fleet lifecycle delivery failed');
41
+ return;
42
+ }
43
+ catch (error) {
44
+ // Missing token/socket and refused connect are classified before any
45
+ // request can cross the server boundary, so only this class is safe to
46
+ // retry. Timeout/backend/response failures may be uncertain and stop.
47
+ if (!(error instanceof SessionControlError)
48
+ || error.kind !== 'control-unavailable' || deps.now() >= deadline)
49
+ throw error;
50
+ await deps.sleep(LIFECYCLE_RETRY_MS);
51
+ }
52
+ }
53
+ }
16
54
  /** Launch a Fleet CLI operation outside the caller's supervisor/session lifecycle. */
17
55
  export function launchFleetWorker(args, operation, configPath) {
18
56
  const cli = [process.argv[1], ...args, ...(configPath ? ['-c', configPath] : [])];
@@ -0,0 +1,19 @@
1
+ import type { AgentTemplateDefinition } from '../config.js';
2
+ /** Cross-process exclusion used from seal through durable Room publication. */
3
+ export declare function acquireLaunchSnapshotLock(options?: {
4
+ beforePublish?: (claimPath: string, lockPath: string) => void;
5
+ }): () => void;
6
+ export declare function redactLaunchDefinition(value: unknown, key?: string): unknown;
7
+ export declare function sealLaunchSnapshot(definitions: Record<string, AgentTemplateDefinition>): string;
8
+ export declare function readLaunchSnapshot(hash: string): Record<string, AgentTemplateDefinition>;
9
+ /** Delete a sealed snapshot only after no retained Task/Room record references it. */
10
+ export declare function releaseLaunchSnapshot(hash: string): void;
11
+ /**
12
+ * Deletion-finalization variant: the caller already holds the global
13
+ * launch-snapshot lock and is about to unlink the deletion-pending task
14
+ * `taskId`. The reference scan excludes exactly that task file — every other
15
+ * retained Task/Room reference still protects the snapshot. Deleting the
16
+ * snapshot BEFORE the task unlink keeps every crash seam recoverable: the
17
+ * hidden intent survives, and a retry sees the snapshot already absent.
18
+ */
19
+ export declare function releaseLaunchSnapshotForDeletingTask(hash: string, taskId: string): void;