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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/README.md +285 -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 +42 -3
  19. package/dist/config.js +202 -29
  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 +195 -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 +5 -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 +21 -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 +97 -47
  105. package/dist/session/acp.d.ts +27 -1
  106. package/dist/session/acp.js +307 -17
  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/stall-watchdog.d.ts +60 -0
  117. package/dist/session/stall-watchdog.js +210 -0
  118. package/dist/session/types.d.ts +29 -8
  119. package/dist/session/types.js +28 -0
  120. package/dist/spawn.d.ts +9 -0
  121. package/dist/spawn.js +77 -9
  122. package/dist/web/fleet-config-service.d.ts +1 -0
  123. package/dist/web/fleet-config-service.js +25 -7
  124. package/dist/web/runtime.js +11 -6
  125. package/dist/web/server.js +28 -1
  126. package/dist/web-app/assets/{index-BbTc5KtZ.js → index-C0h3ALvs.js} +8 -8
  127. package/dist/web-app/index.html +1 -1
  128. package/package.json +4 -4
  129. package/presets/brain-catalog.json +17 -0
  130. package/presets/fleet/{agents → agent_templates}/Critic.yaml +2 -0
  131. package/presets/fleet/{agents/Agent.yaml → agent_templates/Developer.yaml} +3 -1
  132. package/presets/fleet/agent_templates/LocalCoordinator.yaml +50 -0
  133. package/presets/fleet/agents/{Developer.yaml → FleetCoordinator.yaml} +1 -1
  134. package/presets/fleet/brains/claude-fable-5-high.yaml +5 -0
  135. package/presets/fleet/brains/claude-fable-5-low.yaml +5 -0
  136. package/presets/fleet/brains/claude-fable-5-max.yaml +5 -0
  137. package/presets/fleet/brains/claude-fable-5-medium.yaml +5 -0
  138. package/presets/fleet/brains/claude-fable-5-xhigh.yaml +5 -0
  139. package/presets/fleet/brains/claude-opus-5-high.yaml +5 -0
  140. package/presets/fleet/brains/claude-opus-5-low.yaml +5 -0
  141. package/presets/fleet/brains/claude-opus-5-max.yaml +5 -0
  142. package/presets/fleet/brains/claude-opus-5-medium.yaml +5 -0
  143. package/presets/fleet/brains/claude-opus-5-xhigh.yaml +5 -0
  144. package/presets/fleet/brains/claude-sonnet-5-high.yaml +5 -0
  145. package/presets/fleet/brains/claude-sonnet-5-low.yaml +5 -0
  146. package/presets/fleet/brains/claude-sonnet-5-max.yaml +5 -0
  147. package/presets/fleet/brains/claude-sonnet-5-medium.yaml +5 -0
  148. package/presets/fleet/brains/claude-sonnet-5-xhigh.yaml +5 -0
  149. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-high.yaml +5 -0
  150. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-low.yaml +5 -0
  151. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-medium.yaml +5 -0
  152. package/presets/fleet/brains/codex-gpt-5-3-codex-spark-xhigh.yaml +5 -0
  153. package/presets/fleet/brains/codex-gpt-5-4-high.yaml +5 -0
  154. package/presets/fleet/brains/codex-gpt-5-4-low.yaml +5 -0
  155. package/presets/fleet/brains/codex-gpt-5-4-medium.yaml +5 -0
  156. package/presets/fleet/brains/codex-gpt-5-4-mini-high.yaml +5 -0
  157. package/presets/fleet/brains/codex-gpt-5-4-mini-low.yaml +5 -0
  158. package/presets/fleet/brains/codex-gpt-5-4-mini-medium.yaml +5 -0
  159. package/presets/fleet/brains/codex-gpt-5-4-mini-xhigh.yaml +5 -0
  160. package/presets/fleet/brains/codex-gpt-5-4-xhigh.yaml +5 -0
  161. package/presets/fleet/brains/codex-gpt-5-5-high.yaml +5 -0
  162. package/presets/fleet/brains/codex-gpt-5-5-low.yaml +5 -0
  163. package/presets/fleet/brains/codex-gpt-5-5-medium.yaml +5 -0
  164. package/presets/fleet/brains/codex-gpt-5-5-xhigh.yaml +5 -0
  165. package/presets/fleet/brains/codex-gpt-5-6-luna-high.yaml +5 -0
  166. package/presets/fleet/brains/codex-gpt-5-6-luna-low.yaml +5 -0
  167. package/presets/fleet/brains/codex-gpt-5-6-luna-max.yaml +5 -0
  168. package/presets/fleet/brains/codex-gpt-5-6-luna-medium.yaml +5 -0
  169. package/presets/fleet/brains/codex-gpt-5-6-luna-xhigh.yaml +5 -0
  170. package/presets/fleet/brains/codex-gpt-5-6-sol-high.yaml +5 -0
  171. package/presets/fleet/brains/codex-gpt-5-6-sol-low.yaml +5 -0
  172. package/presets/fleet/brains/codex-gpt-5-6-sol-max.yaml +5 -0
  173. package/presets/fleet/brains/codex-gpt-5-6-sol-medium.yaml +5 -0
  174. package/presets/fleet/brains/codex-gpt-5-6-sol-ultra.yaml +5 -0
  175. package/presets/fleet/brains/codex-gpt-5-6-sol-xhigh.yaml +5 -0
  176. package/presets/fleet/brains/codex-gpt-5-6-terra-high.yaml +5 -0
  177. package/presets/fleet/brains/codex-gpt-5-6-terra-low.yaml +5 -0
  178. package/presets/fleet/brains/codex-gpt-5-6-terra-max.yaml +5 -0
  179. package/presets/fleet/brains/codex-gpt-5-6-terra-medium.yaml +5 -0
  180. package/presets/fleet/brains/codex-gpt-5-6-terra-ultra.yaml +5 -0
  181. package/presets/fleet/brains/codex-gpt-5-6-terra-xhigh.yaml +5 -0
  182. package/presets/fleet/brains/codex-gpt-6-astra-high.yaml +5 -0
  183. package/presets/fleet/brains/codex-gpt-6-astra-low.yaml +5 -0
  184. package/presets/fleet/brains/codex-gpt-6-astra-max.yaml +5 -0
  185. package/presets/fleet/brains/codex-gpt-6-astra-medium.yaml +5 -0
  186. package/presets/fleet/brains/codex-gpt-6-astra-ultra.yaml +5 -0
  187. package/presets/fleet/brains/codex-gpt-6-astra-xhigh.yaml +5 -0
  188. package/presets/fleet/roles/Coordinator.yaml +37 -0
  189. package/presets/fleet/roles/Critic.yaml +23 -1
  190. package/presets/fleet/roles/Developer.yaml +27 -5
  191. package/presets/fleet/roles/LocalCoordinator.yaml +35 -0
  192. package/presets/fleet/room_templates/pair.yaml +9 -7
  193. package/presets/fleet/room_templates/single.yaml +5 -4
  194. package/presets/fleet/room_templates/team.yaml +12 -8
  195. package/presets/manifest.json +2 -1
  196. package/presets/fleet/agents/Architect.yaml +0 -3
  197. package/presets/fleet/agents/Secretary.yaml +0 -3
  198. package/presets/fleet/agents/Tester.yaml +0 -3
  199. package/presets/fleet/roles/Agent.yaml +0 -6
  200. package/presets/fleet/roles/Architect.yaml +0 -6
  201. package/presets/fleet/roles/Secretary.yaml +0 -6
  202. package/presets/fleet/roles/Tester.yaml +0 -6
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
- import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
3
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { canonicalCid } from '../config.js';
6
6
  import { DAEMON_RECOVERY_DEADLINE_MS } from '../daemon-recovery.js';
@@ -11,14 +11,15 @@ import { RoleRepository } from '../application/role-repository.js';
11
11
  import { FleetQueryService } from '../application/fleet-query-service.js';
12
12
  import { interruptSession, queueSessionPrompt } from '../application/session-mutations.js';
13
13
  import { pickBackend } from '../supervisor/index.js';
14
- import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, } from '../session/types.js';
14
+ import { ACP_CANCEL_DEADLINE_EXCEEDED, CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED, SessionControlError, } from '../session/types.js';
15
15
  import { VERSION } from '../version.js';
16
- import { renderMarkdownFailure, renderMarkdownResult, roomStatus, taskStatus, } from '../rooms-tasks/markdown.js';
17
- import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, } from './commands.js';
18
- import { FleetCommandAuditStore, lifecycleEventDigestBasis, renderFleetLifecycleEvent, } from '../fleet-command-audit.js';
16
+ import { renderMarkdownFailure, renderMarkdownResult, taskStatus, } from '../rooms-tasks/markdown.js';
17
+ import { dispatchOwnerCommand, fleetCliOps, isOwnerCommandText, ownerTypedCommandCatalog, ownerTypedCommandText, } from './commands.js';
18
+ import { beginFleetAuditCollection, consumeFleetAuditCollection, FleetCommandAuditStore, lifecycleEventDigestBasis, renderFleetLifecycleEvent, setFleetAuditLifecycleCheckpoint, } from '../fleet-command-audit.js';
19
19
  import { OURS_BOUND_ELSEWHERE, OursSdkClient, oursErrorCode, } from './ours-client.js';
20
20
  import { ownerNotices, } from './notices.js';
21
21
  import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
22
+ import { FleetLifecycleOutbox } from './lifecycle-outbox.js';
22
23
  import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
23
24
  import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, writeRecoveredAttachment, } from './attachments.js';
24
25
  import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
@@ -78,6 +79,8 @@ export class OwnerChannel {
78
79
  * (a crash must replay them) but must not be queued twice while live.
79
80
  */
80
81
  inFlight = new Set();
82
+ /** SDK handlers entered during the current getMessages call. */
83
+ typedHandlersEntered = new Set();
81
84
  /** Wires already NACKed to the managed agent, so a history replay stays quiet. */
82
85
  relayNacks = new Set();
83
86
  /**
@@ -112,6 +115,7 @@ export class OwnerChannel {
112
115
  fleetOps;
113
116
  prepareRestart;
114
117
  commandAudits;
118
+ lifecycleOutbox;
115
119
  constructor(options) {
116
120
  this.options = options;
117
121
  this.client = options.client ?? new OursSdkClient(options.env, line => options.log(`[${options.role}] owner channel ${line}`));
@@ -128,6 +132,7 @@ export class OwnerChannel {
128
132
  });
129
133
  this.state = new OwnerChannelState(join(options.stateDir, '.owner-channel-state.json'));
130
134
  this.commandAudits = new FleetCommandAuditStore(join(options.stateDir, '.owner-channel-command-audit.json'));
135
+ this.lifecycleOutbox = new FleetLifecycleOutbox(join(options.stateDir, '.owner-channel-lifecycle-outbox.json'));
131
136
  this.authorizations = new OwnerAuthorizationState(join(options.stateDir, '.owner-channel-owners.json'), options.config.owners);
132
137
  this.conversations = new OwnerConversationState(join(options.stateDir, '.owner-channel-conversations.json'));
133
138
  this.tasks = new OwnerTaskState(join(options.stateDir, '.owner-channel-tasks.json'));
@@ -153,6 +158,8 @@ export class OwnerChannel {
153
158
  options.log(`[${options.role}] owner message recovery state corrupt; message intake disabled`);
154
159
  if (!this.attachmentRecovery.integrity())
155
160
  options.log(`[${options.role}] owner attachment recovery state corrupt; attachments disabled`);
161
+ if (!this.lifecycleOutbox.integrity().ok)
162
+ options.log(`[${options.role}] owner lifecycle outbox corrupt; lifecycle delivery disabled`);
156
163
  }
157
164
  async start() {
158
165
  this.stopping = false;
@@ -168,6 +175,7 @@ export class OwnerChannel {
168
175
  for (;;) {
169
176
  try {
170
177
  await this.client.bindIdentity(this.options.config.identity);
178
+ await this.registerTypedCommands();
171
179
  break;
172
180
  }
173
181
  catch (error) {
@@ -199,6 +207,8 @@ export class OwnerChannel {
199
207
  }
200
208
  this.startedOnce = true;
201
209
  this.ready = true;
210
+ await this.flushPendingFleetCommandAudits();
211
+ await this.flushPendingFleetLifecycle();
202
212
  // Do not make role startup wait for an old owner request to finish a turn.
203
213
  // watchLoop itself drains before every establishment, including this first
204
214
  // one, so there is no drain-to-tip race.
@@ -270,9 +280,41 @@ export class OwnerChannel {
270
280
  lifetime: event.lifetime, brain: event.brainSummary, role: event.roleSummary,
271
281
  harness: event.harness, session: event.session, model: event.model,
272
282
  permissions: permission, parent: event.caller, actionId: event.creationActionId,
273
- inherited: event.inherited }), `fleet-spawn\0${event.creationActionId}`, 0);
283
+ inherited: event.inherited,
284
+ ...(event.configuration ? { configuration: event.configuration } : {}) }), `fleet-spawn\0${event.creationActionId}`, 0);
274
285
  });
275
286
  }
287
+ notifyFleetLifecycle(presentations) {
288
+ const run = this.managementTail.then(async () => {
289
+ // Acceptance is durable before delivery. A detached worker may exit
290
+ // while the Owner channel is reconnecting; startup/recovery flushes the
291
+ // pending event through the existing exactly-once send ledger.
292
+ this.lifecycleOutbox.enqueue(presentations);
293
+ if (this.ready && !this.stopping)
294
+ await this.flushPendingFleetLifecycle();
295
+ });
296
+ this.managementTail = run.then(() => undefined, () => undefined);
297
+ return run;
298
+ }
299
+ async flushPendingFleetLifecycle() {
300
+ for (const entry of this.lifecycleOutbox.pending()) {
301
+ try {
302
+ await this.sendProactiveMessage(renderFleetLifecycleEvent(entry.presentation), `fleet-lifecycle\0${lifecycleEventDigestBasis(entry.presentation)}`, 0);
303
+ this.lifecycleOutbox.finish(entry.digest, 'delivered');
304
+ }
305
+ catch (error) {
306
+ if (error instanceof DuplicateSendError) {
307
+ this.lifecycleOutbox.finish(entry.digest, error.status === 'delivered' ? 'delivered' : 'uncertain');
308
+ this.options.log(`[${this.options.role}] duplicate Fleet lifecycle event suppressed`);
309
+ continue;
310
+ }
311
+ // If the send crossed the uncertain boundary, the proactive ledger
312
+ // will refuse it on the next flush. A pre-send outage stays pending.
313
+ this.logError('pending Fleet lifecycle delivery deferred', error);
314
+ return;
315
+ }
316
+ }
317
+ }
276
318
  beginFleetCommandAudit(requestId, argv) {
277
319
  const run = this.managementTail.then(async () => {
278
320
  let attempt = this.commandAudits.begin(requestId, this.options.role, argv);
@@ -300,33 +342,50 @@ export class OwnerChannel {
300
342
  return attempt;
301
343
  if (attempt.outcome?.delivery === 'uncertain')
302
344
  return attempt;
303
- let uncertain = false;
304
- try {
305
- for (const event of attempt.outcome?.presentations ?? []) {
306
- try {
307
- await this.sendProactiveMessage(renderFleetLifecycleEvent(event), `fleet-lifecycle\0${lifecycleEventDigestBasis(event)}`, 0);
308
- }
309
- catch (error) {
310
- if (error instanceof DuplicateSendError) {
311
- if (error.status !== 'delivered')
312
- uncertain = true;
313
- this.options.log(`[${this.options.role}] duplicate Fleet lifecycle event suppressed`);
314
- continue;
315
- }
316
- throw error;
317
- }
318
- }
319
- }
320
- catch (error) {
321
- uncertain = true;
322
- this.logError(`fleet lifecycle delivery uncertain correlation=${attempt.correlationId}`, error);
323
- }
324
- attempt = this.commandAudits.outcome(attempt.correlationId, this.options.role, uncertain ? 'uncertain' : 'delivered');
325
- return attempt;
345
+ // Before the Owner sink is attached, ordinary commands have nothing to
346
+ // deliver. Lifecycle presentations remain in the never-attempted
347
+ // `sending` state and are flushed exactly once when start() makes the
348
+ // authenticated sink ready. A restart converts them to `uncertain`, so
349
+ // effects are never replayed after an unproven delivery boundary.
350
+ if (!attempt.outcome?.presentations?.length)
351
+ return this.commandAudits.outcome(attempt.correlationId, this.options.role, 'delivered');
352
+ if (!this.ready)
353
+ return attempt;
354
+ return this.deliverFleetCommandOutcome(attempt);
326
355
  });
327
356
  this.managementTail = run.then(() => undefined, () => undefined);
328
357
  return run;
329
358
  }
359
+ async flushPendingFleetCommandAudits() {
360
+ for (const attempt of this.commandAudits.list())
361
+ if (attempt.outcome?.delivery === 'sending')
362
+ await this.deliverFleetCommandOutcome(attempt);
363
+ }
364
+ async deliverFleetCommandOutcome(attempt) {
365
+ let uncertain = false;
366
+ try {
367
+ for (const event of attempt.outcome?.presentations ?? []) {
368
+ try {
369
+ await this.sendProactiveMessage(renderFleetLifecycleEvent(event), `fleet-lifecycle\0${lifecycleEventDigestBasis(event)}`, 0);
370
+ }
371
+ catch (error) {
372
+ if (error instanceof DuplicateSendError) {
373
+ if (error.status !== 'delivered')
374
+ uncertain = true;
375
+ this.options.log(`[${this.options.role}] duplicate Fleet lifecycle event suppressed`);
376
+ continue;
377
+ }
378
+ throw error;
379
+ }
380
+ }
381
+ }
382
+ catch (error) {
383
+ uncertain = true;
384
+ this.logError(`fleet lifecycle delivery uncertain correlation=${attempt.correlationId}`, error);
385
+ }
386
+ attempt = this.commandAudits.outcome(attempt.correlationId, this.options.role, uncertain ? 'uncertain' : 'delivered');
387
+ return attempt;
388
+ }
330
389
  recover(epoch) {
331
390
  if (this.stopping)
332
391
  return Promise.reject(new Error('owner channel is stopping'));
@@ -368,6 +427,9 @@ export class OwnerChannel {
368
427
  if (superseded())
369
428
  throw new Error('owner recovery epoch superseded');
370
429
  await this.recoveryStage('bind', this.client.bindIdentity(this.options.config.identity), deadlineAt);
430
+ if (superseded())
431
+ throw new Error('owner recovery epoch superseded');
432
+ await this.recoveryStage('register_commands', this.registerTypedCommands(), deadlineAt);
371
433
  if (superseded())
372
434
  throw new Error('owner recovery epoch superseded');
373
435
  // Journal-aware drain is the only operation allowed to mark owner mail
@@ -377,6 +439,7 @@ export class OwnerChannel {
377
439
  if (superseded())
378
440
  throw new Error('owner recovery epoch superseded');
379
441
  this.ready = true;
442
+ await this.flushPendingFleetLifecycle();
380
443
  this.watchTask = this.watchLoop(watchGeneration);
381
444
  }
382
445
  catch (error) {
@@ -827,27 +890,61 @@ export class OwnerChannel {
827
890
  throw new Error('the ours daemon returned invalid unread message metadata');
828
891
  const preflight = listed.slice(0, OWNER_MESSAGE_BATCH_LIMIT);
829
892
  const now = Date.now();
830
- const claims = preflight.map(item => this.messageClaim(item, now));
831
- const claimKeys = new Set(claims.map(item => `${item.seq}\0${item.wireId}`));
832
- if (claimKeys.size !== claims.length)
893
+ const metadata = preflight.map(item => ({ item, claim: this.messageClaim(item, now),
894
+ kind: item.message_kind }));
895
+ if (metadata.some(item => !['text', 'command', 'command_result'].includes(item.kind)))
896
+ throw new Error('the ours daemon returned an unknown unread message kind');
897
+ // The SDK marks the entire batch read before invoking typed handlers. Claim
898
+ // every text AND command row first: if an earlier lifecycle handler exits
899
+ // this process, a later command is recovered from persistent history even
900
+ // though its SDK handler was never entered. Command-result rows are inert.
901
+ const recoverable = metadata.filter(item => item.kind !== 'command_result');
902
+ const claims = recoverable.map(item => item.claim);
903
+ const textClaims = metadata.filter(item => item.kind === 'text').map(item => item.claim);
904
+ const textClaimKeys = new Set(textClaims.map(item => `${item.seq}\0${item.wireId}`));
905
+ const preflightKeys = new Set(metadata.map(item => `${item.claim.seq}\0${item.claim.wireId}`));
906
+ if (preflightKeys.size !== metadata.length)
833
907
  throw new Error('the ours daemon returned duplicate unread message metadata');
834
908
  this.messageRecovery.claim(claims);
835
909
  let fresh = [];
836
910
  let remaining = 0;
837
911
  // SDK batchLimit rejects zero. An empty preflight is a read-only drain.
838
- if (claims.length) {
839
- const payload = await this.client.getMessages(claims.length);
840
- if (!payload || !Array.isArray(payload.messages)
912
+ if (preflight.length) {
913
+ const payload = await this.client.getMessages(preflight.length);
914
+ if (!payload || !Array.isArray(payload.messages) || !Array.isArray(payload.command_results)
915
+ || !Number.isSafeInteger(payload.commands_handled) || payload.commands_handled < 0
841
916
  || !Number.isSafeInteger(payload.remaining) || payload.remaining < 0)
842
917
  throw new Error('the ours daemon returned an invalid claimed message batch');
843
918
  fresh = payload.messages.map(message => this.historyMessage(message));
844
919
  remaining = payload.remaining;
845
- const expected = claimKeys;
920
+ const expected = textClaimKeys;
846
921
  const actual = new Set(fresh.map(item => `${item.seq}\0${item.wire_id}`));
847
- if (fresh.length !== claims.length || actual.size !== fresh.length
922
+ if (fresh.length !== textClaims.length || actual.size !== fresh.length
848
923
  || actual.size !== expected.size
849
924
  || [...expected].some(item => !actual.has(item)))
850
925
  throw new Error('the ours daemon claimed a different message batch than fleet journaled');
926
+ const expectedCommands = metadata.filter(item => item.kind === 'command');
927
+ if (payload.commands_handled !== expectedCommands.length)
928
+ throw new Error('the ours daemon handled a different typed-command batch than fleet inspected');
929
+ const expectedResults = new Set(metadata.filter(item => item.kind === 'command_result')
930
+ .map(item => `${item.claim.seq}\0${item.claim.wireId}`));
931
+ const actualResults = new Set(payload.command_results
932
+ .map(item => `${item.seq}\0${item.wire_id}`));
933
+ if (actualResults.size !== payload.command_results.length
934
+ || actualResults.size !== expectedResults.size
935
+ || [...expectedResults].some(item => !actualResults.has(item)))
936
+ throw new Error('the ours daemon returned a different typed-command result batch than fleet inspected');
937
+ for (const item of expectedCommands) {
938
+ // Invalid envelopes and unregistered commands never enter a handler;
939
+ // the SDK has already returned handler_failed, so consume those now.
940
+ // Entered harness commands keep their claim until async completion.
941
+ if (!this.typedHandlersEntered.has(item.claim.wireId))
942
+ this.state.remember(item.claim.wireId);
943
+ this.typedHandlersEntered.delete(item.claim.wireId);
944
+ }
945
+ for (const item of metadata)
946
+ if (item.kind === 'command_result')
947
+ this.state.remember(item.claim.wireId);
851
948
  }
852
949
  const merged = new Map();
853
950
  for (const message of [...recovered, ...fresh]) {
@@ -1103,6 +1200,8 @@ export class OwnerChannel {
1103
1200
  const wireId = this.wireId(message);
1104
1201
  if (!wireId || this.state.has(wireId) || this.inFlight.has(wireId))
1105
1202
  return false;
1203
+ if (message.message_kind === 'command')
1204
+ return this.handleRecoveredTypedCommand(message, wireId);
1106
1205
  const sender = this.sender(message);
1107
1206
  if (this.isAgentSender(sender.id)) {
1108
1207
  try {
@@ -1170,12 +1269,13 @@ export class OwnerChannel {
1170
1269
  catch (error) {
1171
1270
  await rm(outbox, { recursive: true, force: true });
1172
1271
  if (error instanceof SessionControlError
1173
- && error.reasonCode === ACP_CANCEL_DEADLINE_EXCEEDED) {
1272
+ && (error.reasonCode === ACP_CANCEL_DEADLINE_EXCEEDED
1273
+ || error.reasonCode === CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED)) {
1174
1274
  // drainAll journaled this authenticated message before delivery. The
1175
1275
  // adapter generation is terminating, so leave the wire unhandled and
1176
1276
  // body-free: the resumed owner channel recovers it exactly once.
1177
1277
  this.options.log(`[${this.options.role}] owner request ${requestId.slice(0, 12)} `
1178
- + `held for adapter resume reason=${ACP_CANCEL_DEADLINE_EXCEEDED}`);
1278
+ + `held for adapter resume reason=${error.reasonCode}`);
1179
1279
  return false;
1180
1280
  }
1181
1281
  this.logError('request delivery failed', error);
@@ -1215,7 +1315,21 @@ export class OwnerChannel {
1215
1315
  * agent itself can execute /force-restart, /model, or any other command.
1216
1316
  */
1217
1317
  async handleCommand(sender, text, wireId) {
1318
+ const provisioningCommand = async (run) => {
1319
+ beginFleetAuditCollection();
1320
+ setFleetAuditLifecycleCheckpoint(events => this.notifyFleetLifecycle(events));
1321
+ try {
1322
+ return await run();
1323
+ }
1324
+ finally {
1325
+ setFleetAuditLifecycleCheckpoint(undefined);
1326
+ const remaining = consumeFleetAuditCollection().presentations;
1327
+ if (remaining?.length)
1328
+ await this.notifyFleetLifecycle(remaining);
1329
+ }
1330
+ };
1218
1331
  const ctx = {
1332
+ authenticatedCid: sender.id,
1219
1333
  role: this.options.role,
1220
1334
  harness: this.options.harness,
1221
1335
  version: VERSION,
@@ -1233,16 +1347,36 @@ export class OwnerChannel {
1233
1347
  },
1234
1348
  fleetList: () => this.fleetOps.list(),
1235
1349
  closeRoom: roomId => this.closeRoomFromOwner(sender, roomId, wireId),
1236
- recoverRoom: roomId => this.recoverRoomFromOwner(sender, roomId, wireId),
1237
1350
  terminalTask: (taskId, kind, outcome) => this.terminalTaskFromOwner(sender, taskId, kind, outcome, wireId),
1238
- recoverTask: taskId => this.recoverTaskFromOwner(sender, taskId, wireId),
1239
- createTask: input => new TaskRoomApplicationService(this.options.configPath).createTask({
1240
- ...input,
1241
- actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
1351
+ createTask: input => provisioningCommand(async () => {
1352
+ const service = new TaskRoomApplicationService(this.options.configPath);
1353
+ const task = await service.createTask({
1354
+ ...input,
1355
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
1356
+ });
1357
+ if (!input.backlog && !input.noRoom && task.room_id) {
1358
+ const outcome = service.taskProvisioningOutcome(task.task_id);
1359
+ if (outcome.kind === 'in_progress' && !outcome.next_action)
1360
+ await this.fleetOps.provisionTask(task.task_id);
1361
+ }
1362
+ return task;
1242
1363
  }),
1243
- startTask: taskId => new TaskRoomApplicationService(this.options.configPath).startTask({
1244
- actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1364
+ startTask: taskId => provisioningCommand(async () => {
1365
+ const service = new TaskRoomApplicationService(this.options.configPath);
1366
+ const task = await service.startTask({
1367
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1368
+ });
1369
+ // A zero-duration observation repairs a ready notice after a crash or
1370
+ // duplicate start without extending the start command's existing wait.
1371
+ const outcome = await service.awaitTaskProvisioning({
1372
+ actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
1373
+ taskId: task.task_id, waitMs: 0,
1374
+ });
1375
+ if (outcome.kind === 'in_progress' && !outcome.next_action)
1376
+ await this.fleetOps.provisionTask(task.task_id);
1377
+ return outcome;
1245
1378
  }),
1379
+ taskProvisioningOutcome: taskId => new TaskRoomApplicationService(this.options.configPath).taskProvisioningOutcome(taskId),
1246
1380
  listTasks: filter => new TaskRoomApplicationService(this.options.configPath).listTasks(filter),
1247
1381
  groupedTasks: filter => new TaskRoomApplicationService(this.options.configPath).groupedTasks(filter),
1248
1382
  listTaskLists: () => new TaskRoomApplicationService(this.options.configPath).listTaskLists(),
@@ -1268,19 +1402,29 @@ export class OwnerChannel {
1268
1402
  reviewTask: taskId => new TaskRoomApplicationService(this.options.configPath).reviewTask({
1269
1403
  actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1270
1404
  }),
1271
- deleteTask: taskId => new TaskRoomApplicationService(this.options.configPath).deleteTask({
1272
- actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id }, taskId,
1273
- }),
1405
+ deleteTask: taskId => this.deleteTaskFromOwner(sender, taskId, wireId),
1274
1406
  listRoomQueries: filter => new TaskRoomApplicationService(this.options.configPath).listRooms(filter),
1275
1407
  getRoomQuery: id => new TaskRoomApplicationService(this.options.configPath).getRoomDetail(id),
1276
1408
  listTemplateQueries: () => new TaskRoomApplicationService(this.options.configPath).listTemplates(),
1277
1409
  getTemplateQuery: name => new TaskRoomApplicationService(this.options.configPath).getTemplate(name),
1278
- createRoom: input => new TaskRoomApplicationService(this.options.configPath).createRoom({
1410
+ createRoom: input => provisioningCommand(() => new TaskRoomApplicationService(this.options.configPath).createRoom({
1279
1411
  ...input, actor: { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id },
1280
- }),
1412
+ })),
1281
1413
  recentEvents: limit => this.options.session.eventsSince(0).slice(-limit),
1282
1414
  readWorklogTail: maxChars => this.readWorklogTail(maxChars),
1283
1415
  reply: async (replyText) => { await this.send(sender.id, replyText, wireId); },
1416
+ replyHtml: async (filename, html) => {
1417
+ const directory = join(this.options.stateDir, '.owner-channel-report-outbox', this.requestId(wireId));
1418
+ const path = join(directory, filename);
1419
+ await mkdir(directory, { recursive: true, mode: 0o700 });
1420
+ try {
1421
+ await writeFile(path, html, { flag: 'wx', mode: 0o600 });
1422
+ await this.client.sendFile({ contact: sender.id, path, filename, replyToWireId: wireId });
1423
+ }
1424
+ finally {
1425
+ await rm(directory, { recursive: true, force: true });
1426
+ }
1427
+ },
1284
1428
  };
1285
1429
  try {
1286
1430
  await dispatchOwnerCommand(text, ctx);
@@ -1293,6 +1437,84 @@ export class OwnerChannel {
1293
1437
  if (!this.inFlight.has(wireId))
1294
1438
  this.state.remember(wireId);
1295
1439
  }
1440
+ /**
1441
+ * Register one typed adapter per primary slash command. The adapter performs
1442
+ * the live CID check before constructing the same slash text and entering the
1443
+ * existing dispatcher; its null SDK result avoids duplicating the ordinary
1444
+ * owner-channel replies that remain the command's result contract.
1445
+ */
1446
+ registerTypedCommands() {
1447
+ const commands = ownerTypedCommandCatalog().map(definition => ({
1448
+ name: definition.name,
1449
+ description: definition.description,
1450
+ input_schema: definition.input_schema,
1451
+ handler: (input, context) => {
1452
+ this.typedHandlersEntered.add(context.request_wire_id);
1453
+ return this.handleTypedCommand(definition.name, input, context);
1454
+ },
1455
+ }));
1456
+ return this.client.registerCommands(commands);
1457
+ }
1458
+ async handleRecoveredTypedCommand(message, wireId) {
1459
+ let payload;
1460
+ try {
1461
+ const parsed = JSON.parse(String(message.body ?? message.text ?? ''));
1462
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)
1463
+ || Object.keys(parsed).length !== 2
1464
+ || typeof parsed.command !== 'string'
1465
+ || !('arguments' in parsed))
1466
+ throw new Error('invalid command payload');
1467
+ payload = parsed;
1468
+ }
1469
+ catch {
1470
+ // The original SDK attempt would have emitted handler_failed without
1471
+ // entering Fleet. Recovery mirrors that terminal no-effect outcome.
1472
+ this.state.remember(wireId);
1473
+ return true;
1474
+ }
1475
+ const sender = this.sender(message);
1476
+ try {
1477
+ await this.handleTypedCommand(payload.command, payload.arguments, {
1478
+ sender_cid: sender.id, sender_name: sender.name, request_wire_id: wireId,
1479
+ });
1480
+ }
1481
+ catch (error) {
1482
+ // Expected handler failures (unauthorized/invalid typed input) are
1483
+ // terminal and already recorded, just as the SDK swallows them.
1484
+ if (!this.state.has(wireId))
1485
+ throw error;
1486
+ }
1487
+ return true;
1488
+ }
1489
+ async handleTypedCommand(name, input, context) {
1490
+ const sender = { id: context.sender_cid, name: context.sender_name };
1491
+ const wireId = context.request_wire_id;
1492
+ if (!this.isEffectiveOwner(sender.id)) {
1493
+ this.options.log(`[${this.options.role}] owner channel ignored unauthorized typed-command sender `
1494
+ + `${sender.id || '<unknown>'}`);
1495
+ await this.warnOwnerOfUnauthorizedSender(sender.id);
1496
+ if (wireId)
1497
+ this.state.remember(wireId);
1498
+ throw new Error('typed owner command denied');
1499
+ }
1500
+ try {
1501
+ try {
1502
+ this.conversations.recordInbound(sender.id, wireId);
1503
+ }
1504
+ catch (error) {
1505
+ this.logError('owner conversation route update failed', error);
1506
+ }
1507
+ await this.handleCommand(sender, ownerTypedCommandText(name, input), wireId);
1508
+ return null;
1509
+ }
1510
+ catch (error) {
1511
+ // Structural typed-input failures are terminal handler_failed outcomes,
1512
+ // not indefinitely replayable work.
1513
+ if (wireId)
1514
+ this.state.remember(wireId);
1515
+ throw error;
1516
+ }
1517
+ }
1296
1518
  /** Queue raw slash text to the harness and report the turn's outcome. */
1297
1519
  async runHarnessCommand(sender, command, wireId) {
1298
1520
  const requestId = this.requestId(wireId);
@@ -1340,7 +1562,7 @@ export class OwnerChannel {
1340
1562
  await this.send(sender.id, renderMarkdownFailure({
1341
1563
  kind: 'pending', subject: `/room delete ${roomId} ${roomId}`,
1342
1564
  detail: 'The deletion request was accepted and is still being settled.',
1343
- action: `Run /room recover ${roomId} if deletion remains pending.`,
1565
+ action: `Run /room delete ${roomId} ${roomId} again if deletion remains pending.`,
1344
1566
  }), wireId);
1345
1567
  this.state.remember(wireId);
1346
1568
  try {
@@ -1353,78 +1575,35 @@ export class OwnerChannel {
1353
1575
  throw error;
1354
1576
  }
1355
1577
  }
1356
- async recoverRoomFromOwner(sender, roomId, wireId) {
1357
- const app = new TaskRoomApplicationService(this.options.configPath);
1358
- const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
1359
- const result = await app.recoverRoom({ actor, roomId });
1360
- if (result.kind === 'deletion_worker_required') {
1361
- await this.send(sender.id, renderMarkdownFailure({ kind: 'pending',
1362
- subject: `/room recover ${roomId}`,
1363
- detail: 'The deletion recovery is still being settled.',
1364
- action: `Run /room recover ${roomId} if deletion remains pending.` }), wireId);
1365
- this.state.remember(wireId);
1366
- try {
1367
- await this.fleetOps.closeRoom(roomId);
1368
- }
1369
- catch (error) {
1370
- await app.recordRoomSettlementError({ actor, roomId,
1371
- error: error instanceof Error ? error.message : String(error),
1372
- recoveryHint: `External delete worker failed to start. Retry /room delete ${roomId} ${roomId}.` });
1373
- throw error;
1374
- }
1375
- return;
1376
- }
1377
- const r = result.orchestration;
1378
- await this.send(sender.id, renderMarkdownResult({ icon: '🛟', title: 'Room recovery',
1379
- fields: [{ label: 'Room', value: result.room.room_id, kind: 'code' },
1380
- { label: 'Status', value: roomStatus(result.room.state), kind: 'markdown' },
1381
- ...(r ? [{ label: 'Saga', value: r.saga.phase, kind: 'code' }] : [])],
1382
- sections: result.issues.length ? [{ heading: 'Next steps', items: result.issues }]
1383
- : [{ heading: 'Result', items: ['No recovery action is needed.'] }] }), wireId);
1384
- this.state.remember(wireId);
1385
- }
1386
- /** Carry a task terminal request through a worker that survives this role. */
1387
- async recoverTaskFromOwner(sender, taskId, wireId) {
1578
+ /** Accept a permanent any-state deletion, acknowledge it, then hand cleanup to a durable worker. */
1579
+ async deleteTaskFromOwner(sender, taskId, wireId) {
1388
1580
  const app = new TaskRoomApplicationService(this.options.configPath);
1389
1581
  const actor = { kind: 'authenticated_owner', surface: 'messenger', cid: sender.id };
1390
- const begin = await app.beginTaskRecovery({ actor, taskId });
1391
- if (begin.kind === 'terminal_worker_required') {
1392
- await this.send(sender.id, renderMarkdownFailure({
1393
- kind: 'pending', subject: `/task recover ${taskId}`,
1394
- detail: 'The recovery request was accepted and is still being settled.',
1395
- action: `Run /task recover ${taskId} if it remains pending.`,
1582
+ const accepted = await app.requestTaskDeletion({ actor, taskId });
1583
+ if (accepted.status === 'already_absent') {
1584
+ await this.send(sender.id, renderMarkdownResult({
1585
+ icon: '🗑️', title: 'Task already absent',
1586
+ fields: [{ label: 'ID', value: taskId, kind: 'code' }],
1396
1587
  }), wireId);
1397
1588
  this.state.remember(wireId);
1398
- try {
1399
- await this.fleetOps.recoverTask(taskId);
1400
- }
1401
- catch (error) {
1402
- await app.recordSettlementError({ actor, taskId,
1403
- error: error instanceof Error ? error.message : String(error),
1404
- recoveryHint: `External settle worker failed to start. Retry /task recover ${taskId}.` });
1405
- throw error;
1406
- }
1407
1589
  return;
1408
1590
  }
1409
- const { task, room, issues } = begin.result;
1410
- const hints = issues.map(issue => issue.code === 'waiting_cowork' ? 'Cowork socket unreachable'
1411
- : issue.code === 'waiting_owner_invite' ? 'Owner invite missing or invalid'
1412
- : issue.code === 'owner_cid_mismatch' ? 'Owner CID mismatch'
1413
- : issue.code === 'member_failed' ? `Member failed at step ${issue.stepIndex}`
1414
- : issue.code === 'resume_failed' ? `Resume failed: ${issue.error}`
1415
- : issue.code === 'provisioning_resumed' ? 'Provisioning resumed successfully'
1416
- : issue.code);
1417
- await this.send(sender.id, renderMarkdownResult({
1418
- icon: '🛟', title: 'Task recovery',
1419
- fields: [{ label: 'Task', value: task.task_id, kind: 'code' },
1420
- { label: 'Status', value: taskStatus(task.state), kind: 'markdown' },
1421
- ...(room ? [{ label: 'Room', value: room.room_id, kind: 'code' },
1422
- { label: 'Room status', value: roomStatus(room.state), kind: 'markdown' },
1423
- { label: 'Saga', value: room.saga.phase, kind: 'code' }] : [])],
1424
- sections: hints.length ? [{ heading: 'Next steps', items: hints }]
1425
- : [{ heading: 'Result', items: ['No automated recovery action is available.'] }],
1591
+ await this.send(sender.id, renderMarkdownFailure({
1592
+ kind: 'pending', subject: `/task delete ${taskId} ${taskId}`,
1593
+ detail: 'The deletion was accepted; cleanup is settling in the background.',
1594
+ action: `Repeat /task delete ${taskId} ${taskId} if it remains pending.`,
1426
1595
  }), wireId);
1427
1596
  this.state.remember(wireId);
1597
+ try {
1598
+ await this.fleetOps.settleTaskDeletion(taskId);
1599
+ }
1600
+ catch (error) {
1601
+ await app.recordDeletionError({
1602
+ actor, taskId, error: error instanceof Error ? error.message : String(error),
1603
+ recoveryHint: `External delete worker failed to start. Retry /task delete ${taskId} ${taskId}.`,
1604
+ });
1605
+ throw error;
1606
+ }
1428
1607
  }
1429
1608
  async terminalTaskFromOwner(sender, taskId, kind, outcome, wireId) {
1430
1609
  const app = new TaskRoomApplicationService(this.options.configPath);
@@ -1446,7 +1625,7 @@ export class OwnerChannel {
1446
1625
  await this.send(sender.id, renderMarkdownFailure({
1447
1626
  kind: 'pending', subject: `/task ${kind === 'done' ? 'done' : 'cancel'} ${taskId}`,
1448
1627
  detail: 'The terminal request was accepted and is still being settled.',
1449
- action: `Run /task recover ${taskId} if it remains pending.`,
1628
+ action: `Repeat /task ${kind === 'done' ? 'done' : 'cancel'} ${taskId} if it remains pending.`,
1450
1629
  }), wireId);
1451
1630
  this.state.remember(wireId);
1452
1631
  try {
@@ -1455,7 +1634,7 @@ export class OwnerChannel {
1455
1634
  catch (error) {
1456
1635
  await app.recordSettlementError({
1457
1636
  actor, taskId, error: error instanceof Error ? error.message : String(error),
1458
- recoveryHint: `External settle worker failed to start. Retry the identical task command or run task recover ${taskId}.`,
1637
+ recoveryHint: `External settle worker failed to start. Retry the identical task command.`,
1459
1638
  });
1460
1639
  throw error;
1461
1640
  }
@@ -1913,7 +2092,8 @@ export class OwnerChannel {
1913
2092
  startProgress(event);
1914
2093
  // Automatic commentary is an ACP phase extension. Other backends and
1915
2094
  // older adapters retain their established final-only behavior.
1916
- if (this.options.session.backend === 'acp')
2095
+ if (this.options.session.capabilities?.messagePhases
2096
+ ?? this.options.session.backend === 'acp')
1917
2097
  acceptCommentary(event);
1918
2098
  })
1919
2099
  : () => undefined;
@@ -1955,7 +2135,8 @@ export class OwnerChannel {
1955
2135
  enabled: this.commentsEnabled,
1956
2136
  baseline: this.commentsBaseline,
1957
2137
  // Only the ACP backend emits the phase marker commentary relaying needs.
1958
- supported: this.options.session.backend === 'acp',
2138
+ supported: this.options.session.capabilities?.messagePhases
2139
+ ?? this.options.session.backend === 'acp',
1959
2140
  };
1960
2141
  }
1961
2142
  /** Model-authored commentary only; raw protocol/tool data never reaches here. */