@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,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { randomUUID } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { existsSync, lstatSync, readFileSync, readlinkSync, realpathSync, writeFileSync, } from 'node:fs';
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
5
5
  import { Readable, Writable } from 'node:stream';
@@ -7,7 +7,8 @@ import * as acp from '@agentclientprotocol/sdk';
7
7
  import { normalizeSessionUpdate } from './conversation-normalizer.js';
8
8
  import { ConversationEventStore } from './conversation-store.js';
9
9
  import { SessionEvents } from './events.js';
10
- import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, classifyChildExit, turnResult, } from './types.js';
10
+ import { DEFAULT_STALL_TIMEOUT_MS, STALL_RECOVERY_PROMPT, StallWatchdog, StallToolHistory, hasStallRecoveryClaim } from './stall-watchdog.js';
11
+ import { ACP_CANCEL_DEADLINE_EXCEEDED, SessionControlError, classifyChildExit, sessionBackendCapabilities, turnResult, } from './types.js';
11
12
  const CANCEL_SETTLE_GRACE_MS = 15_000;
12
13
  const CANCEL_TERMINATE_GRACE_MS = 5_000;
13
14
  /** A permission no human answered is eventually a decision nobody made. */
@@ -158,6 +159,7 @@ function canonicallyWithin(root, candidates) {
158
159
  function conversationSource(origin) {
159
160
  switch (origin?.kind) {
160
161
  case 'owner-admin-console': return { source: 'owner_admin_console', persistBody: true };
162
+ case 'stall-watchdog': return { source: 'fleet_monitor', persistBody: false };
161
163
  case 'startup': return { source: 'startup', persistBody: true };
162
164
  case 'owner': return { source: 'owner_channel', persistBody: false };
163
165
  case 'fleet-monitor': return { source: 'fleet_monitor', persistBody: false };
@@ -187,6 +189,12 @@ export function runtimeSelector(options, category) {
187
189
  const selected = choices.find(choice => choice.value === option.currentValue);
188
190
  return { value: option.currentValue, ...(selected?.name ? { label: selected.name } : {}) };
189
191
  }
192
+ function reasoningFromModelId(modelId) {
193
+ if (typeof modelId !== 'string')
194
+ return undefined;
195
+ const match = modelId.match(/\[([^\]]+)\]$/u);
196
+ return match?.[1] ? { value: match[1] } : undefined;
197
+ }
190
198
  /**
191
199
  * Classify an ACP `stopReason` into a terminal outcome. A refusal and a
192
200
  * cancellation are the two ways a delivered prompt ends without being carried
@@ -206,6 +214,7 @@ export function classifyStopReason(stopReason) {
206
214
  export class AcpSession {
207
215
  options;
208
216
  backend = 'acp';
217
+ capabilities;
209
218
  pid;
210
219
  child;
211
220
  events;
@@ -232,7 +241,7 @@ export class AcpSession {
232
241
  queueDepth = 0;
233
242
  exit = null;
234
243
  steeringSupported = false;
235
- capabilities;
244
+ agentCapabilities;
236
245
  runtimeModel;
237
246
  reasoningEffort;
238
247
  controllerCount = 0;
@@ -258,6 +267,15 @@ export class AcpSession {
258
267
  terminate;
259
268
  /** ACP-authenticated in-flight calls, including independently reserved permissions. */
260
269
  activeToolCalls = new Map();
270
+ stallWatchdog;
271
+ stallToolHistory;
272
+ stallRecoveryClaimed = false;
273
+ managedTurnCount = 0;
274
+ steeringWasUsed = false;
275
+ steeringRequests = 0;
276
+ retryNativeTurnId;
277
+ stallTimer;
278
+ stallAttempt;
261
279
  toolBoundaryWaiters = new Set();
262
280
  activeTurn;
263
281
  constructor(options, child, connection) {
@@ -265,6 +283,7 @@ export class AcpSession {
265
283
  this.child = child;
266
284
  this.connection = connection;
267
285
  this.pid = child.pid ?? -1;
286
+ this.capabilities = sessionBackendCapabilities('acp', options.harness);
268
287
  this.events = new SessionEvents(join(options.stateDir, '.session-events.jsonl'));
269
288
  this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
270
289
  roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
@@ -277,6 +296,9 @@ export class AcpSession {
277
296
  this.terminated.catch(() => undefined);
278
297
  child.stderr.on('data', chunk => options.log(`[${options.name}] acp: ${String(chunk).trimEnd()}`));
279
298
  child.once('exit', (code, signal) => {
299
+ if (this.stallTimer)
300
+ clearInterval(this.stallTimer);
301
+ this.stallTimer = undefined;
280
302
  if (this.cancelForceKill)
281
303
  clearTimeout(this.cancelForceKill);
282
304
  this.cancelForceKill = undefined;
@@ -333,7 +355,8 @@ export class AcpSession {
333
355
  let instance;
334
356
  const app = acp.client({ name: 'ours-fleet' })
335
357
  .onNotification(acp.methods.client.session.update, ({ params }) => {
336
- instance?.recordUpdate(params.update);
358
+ if (instance && (!instance.sessionId || params.sessionId === instance.sessionId))
359
+ instance.recordUpdate(params.update);
337
360
  })
338
361
  .onRequest(acp.methods.client.session.requestPermission, ({ params }) => {
339
362
  if (!instance)
@@ -345,6 +368,7 @@ export class AcpSession {
345
368
  instance = new AcpSession(options, child, connection);
346
369
  try {
347
370
  await instance.initialize();
371
+ instance.startStallWatchdog();
348
372
  instance.recoverOpenPrompts();
349
373
  return instance;
350
374
  }
@@ -463,10 +487,14 @@ export class AcpSession {
463
487
  reserveTool(toolCallId) {
464
488
  if (toolCallId)
465
489
  this.toolCall(toolCallId).lifecycle = true;
490
+ else if (this.activeTurn)
491
+ this.activeTurn.boundaryUnknown = true;
466
492
  }
467
493
  reservePermission(toolCallId, permissionId) {
468
494
  if (toolCallId)
469
495
  this.toolCall(toolCallId).permissions.set(permissionId, 'pending');
496
+ else if (this.activeTurn)
497
+ this.activeTurn.boundaryUnknown = true;
470
498
  }
471
499
  allowPermission(toolCallId, permissionId) {
472
500
  if (!toolCallId)
@@ -551,6 +579,8 @@ export class AcpSession {
551
579
  * steering response from becoming a tight replay loop.
552
580
  */
553
581
  async steerOrQueueWake(text, options) {
582
+ if (this.stallRecoveryClaimed)
583
+ return this.submitPrompt(text, { ...options, interrupt: false, steer: false });
554
584
  const steered = await this.steerPrompt(text);
555
585
  if (steered.accepted || steered.detail !== 'ACP steering failed'
556
586
  || this.closing || !this.isAlive())
@@ -614,6 +644,8 @@ export class AcpSession {
614
644
  * for it is what turned a busy agent into a timeout and then into "dead".
615
645
  */
616
646
  async queuePrompt(text, options = {}) {
647
+ if (this.stallRecoveryClaimed && options.origin?.kind === 'fleet-monitor')
648
+ options = { ...options, interrupt: false, steer: false };
617
649
  if (this.cancelRecoveryReason)
618
650
  throw new SessionControlError('control-unavailable', 'ACP adapter restart is in progress after the cancellation deadline', ACP_CANCEL_DEADLINE_EXCEEDED);
619
651
  if (this.closing || !this.sessionId || !this.isAlive())
@@ -773,6 +805,8 @@ export class AcpSession {
773
805
  }
774
806
  }
775
807
  async cancelActive(source) {
808
+ if (this.stallAttempt && source !== 'stall-watchdog' && source !== 'fleet-monitor')
809
+ this.stallAttempt.superseded = true;
776
810
  if (!this.sessionId)
777
811
  return;
778
812
  const active = this.activeTurn;
@@ -981,6 +1015,11 @@ export class AcpSession {
981
1015
  }
982
1016
  async close() {
983
1017
  this.closing = true;
1018
+ if (this.stallTimer)
1019
+ clearInterval(this.stallTimer);
1020
+ this.stallTimer = undefined;
1021
+ if (this.stallAttempt)
1022
+ this.stallAttempt.superseded = true;
984
1023
  if (this.cancelEscalation)
985
1024
  clearTimeout(this.cancelEscalation);
986
1025
  this.cancelEscalation = undefined;
@@ -994,7 +1033,7 @@ export class AcpSession {
994
1033
  for (const [permissionId, pending] of [...this.pendingPermissions])
995
1034
  this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the session closed while this request was pending');
996
1035
  this.releaseAllTools();
997
- if (this.sessionId && this.capabilities?.sessionCapabilities?.close != null) {
1036
+ if (this.sessionId && this.agentCapabilities?.sessionCapabilities?.close != null) {
998
1037
  await this.connection.agent.request(acp.methods.agent.session.close, { sessionId: this.sessionId }).catch(() => undefined);
999
1038
  }
1000
1039
  this.connection.close();
@@ -1020,7 +1059,7 @@ export class AcpSession {
1020
1059
  });
1021
1060
  if (initialized.protocolVersion !== acp.PROTOCOL_VERSION)
1022
1061
  throw new Error(`ACP protocol mismatch: agent selected ${initialized.protocolVersion}, client supports ${acp.PROTOCOL_VERSION}`);
1023
- this.capabilities = initialized.agentCapabilities;
1062
+ this.agentCapabilities = initialized.agentCapabilities;
1024
1063
  const steering = initialized._meta?.steering;
1025
1064
  this.steeringSupported = steering !== null && typeof steering === 'object'
1026
1065
  && steering.supported === true;
@@ -1028,17 +1067,19 @@ export class AcpSession {
1028
1067
  ? readFileSync(this.sessionFile, 'utf8').trim()
1029
1068
  : '';
1030
1069
  let advertisedConfigOptions;
1031
- if (persisted && this.capabilities?.sessionCapabilities?.resume != null) {
1070
+ let advertisedModelId;
1071
+ if (persisted && this.agentCapabilities?.sessionCapabilities?.resume != null) {
1032
1072
  const resumed = await this.connection.agent.request(acp.methods.agent.session.resume, {
1033
1073
  sessionId: persisted,
1034
1074
  cwd: this.options.cwd,
1035
1075
  mcpServers: this.declaredMcpServers(),
1036
1076
  });
1037
1077
  advertisedConfigOptions = resumed.configOptions;
1038
- this.captureRuntimeMetadata(advertisedConfigOptions);
1078
+ advertisedModelId = resumed.models?.currentModelId;
1079
+ this.captureRuntimeMetadata(advertisedConfigOptions, advertisedModelId);
1039
1080
  this.sessionId = persisted;
1040
1081
  }
1041
- else if (persisted && this.capabilities?.loadSession) {
1082
+ else if (persisted && this.agentCapabilities?.loadSession) {
1042
1083
  // `session/load` replays prior history as ordinary updates before the
1043
1084
  // response; those records carry `agent_replay` provenance, never `agent`.
1044
1085
  this.replaying = true;
@@ -1049,7 +1090,8 @@ export class AcpSession {
1049
1090
  mcpServers: this.declaredMcpServers(),
1050
1091
  });
1051
1092
  advertisedConfigOptions = loaded.configOptions;
1052
- this.captureRuntimeMetadata(advertisedConfigOptions);
1093
+ advertisedModelId = loaded.models?.currentModelId;
1094
+ this.captureRuntimeMetadata(advertisedConfigOptions, advertisedModelId);
1053
1095
  }
1054
1096
  finally {
1055
1097
  this.replaying = false;
@@ -1064,11 +1106,16 @@ export class AcpSession {
1064
1106
  });
1065
1107
  this.sessionId = created.sessionId;
1066
1108
  advertisedConfigOptions = created.configOptions;
1067
- this.captureRuntimeMetadata(advertisedConfigOptions);
1109
+ advertisedModelId = created.models?.currentModelId;
1110
+ this.captureRuntimeMetadata(advertisedConfigOptions, advertisedModelId);
1068
1111
  }
1069
1112
  for (const selection of this.options.configSelections ?? []) {
1070
- if (!advertisedConfigOptions?.some(option => option.id === selection.configId))
1113
+ if (!advertisedConfigOptions?.some(option => option.id === selection.configId)) {
1114
+ if (selection.configId === 'reasoning_effort'
1115
+ && reasoningFromModelId(advertisedModelId)?.value === selection.value)
1116
+ continue;
1071
1117
  throw new Error(`ACP agent did not advertise required session config option '${selection.configId}'`);
1118
+ }
1072
1119
  let configured;
1073
1120
  try {
1074
1121
  configured = await this.connection.agent.request(acp.methods.agent.session.setConfigOption, { sessionId: this.sessionId, configId: selection.configId, value: selection.value });
@@ -1078,7 +1125,7 @@ export class AcpSession {
1078
1125
  + (error instanceof Error ? error.message : String(error)));
1079
1126
  }
1080
1127
  advertisedConfigOptions = configured.configOptions;
1081
- this.captureRuntimeMetadata(advertisedConfigOptions);
1128
+ this.captureRuntimeMetadata(advertisedConfigOptions, advertisedModelId);
1082
1129
  const applied = advertisedConfigOptions.find(option => option.id === selection.configId);
1083
1130
  if (applied?.currentValue !== selection.value)
1084
1131
  throw new Error(`ACP agent did not apply required session config option '${selection.configId}' value '${selection.value}'`);
@@ -1110,17 +1157,162 @@ export class AcpSession {
1110
1157
  acpSessionId: this.sessionId, payload: { status: 'idle' },
1111
1158
  });
1112
1159
  }
1113
- captureRuntimeMetadata(options) {
1160
+ captureRuntimeMetadata(options, modelId) {
1114
1161
  this.runtimeModel = runtimeSelector(options, 'model');
1115
- this.reasoningEffort = runtimeSelector(options, 'thought_level');
1162
+ this.reasoningEffort = runtimeSelector(options, 'thought_level') ?? reasoningFromModelId(modelId);
1163
+ }
1164
+ startStallWatchdog() {
1165
+ if (!this.options.stallRecovery)
1166
+ return;
1167
+ const timeoutMs = this.options.stallRecovery.timeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
1168
+ this.stallRecoveryClaimed = hasStallRecoveryClaim(this.options.stateDir, this.sessionId);
1169
+ this.stallToolHistory = new StallToolHistory(this.options.stateDir, this.sessionId, this.options.mode === 'resume');
1170
+ this.stallWatchdog = new StallWatchdog({
1171
+ stateDir: this.options.stateDir, timeoutMs, previouslyClaimed: this.stallRecoveryClaimed, now: () => Date.now(),
1172
+ observe: () => this.stallObservation(),
1173
+ recover: (observed, report) => this.recoverStall(observed, report),
1174
+ diagnostic: diagnostic => {
1175
+ if (['interrupt_requested', 'blocked_previous_attempt', 'blocked_persistence'].includes(diagnostic.status))
1176
+ this.stallRecoveryClaimed = true;
1177
+ this.events.emit('stall_recovery', { status: diagnostic.status, stallDiagnostic: diagnostic });
1178
+ this.options.log(`[${this.options.name}] ACP stall recovery: ${diagnostic.status}; `
1179
+ + 'inspect structured session events before continuing; never replay uncertain side effects');
1180
+ },
1181
+ });
1182
+ this.stallTimer = setInterval(() => this.checkStallWatchdog(), this.options.stallRecovery.tickMs ?? Math.min(10_000, timeoutMs));
1183
+ this.stallTimer.unref?.();
1184
+ }
1185
+ checkStallWatchdog() {
1186
+ void this.stallWatchdog?.tick();
1187
+ const attempt = this.stallAttempt;
1188
+ if (attempt?.recoveryStartedAt === undefined || attempt.blocked || attempt.superseded)
1189
+ return;
1190
+ const observed = this.stallObservation();
1191
+ if (!observed?.safe || observed.turnId !== attempt.recoveryId)
1192
+ return;
1193
+ const last = observed.lastProgressAt || attempt.recoveryStartedAt;
1194
+ const timeoutMs = this.options.stallRecovery?.timeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
1195
+ if (Date.now() - last >= timeoutMs * 2) {
1196
+ attempt.blocked = true;
1197
+ try {
1198
+ attempt.report('blocked_restall');
1199
+ }
1200
+ catch { /* durable claim already prevents retry */ }
1201
+ }
1202
+ }
1203
+ stallObservation() {
1204
+ const active = this.activeTurn;
1205
+ if (!active || !this.sessionId)
1206
+ return undefined;
1207
+ return {
1208
+ sessionId: this.sessionId, generation: this.sessionGeneration, turnId: active.id,
1209
+ startedAt: active.startedAt, lastProgressAt: active.lastProgressAt, progressCount: active.progressCount,
1210
+ transportFailures: active.transportFailures,
1211
+ boundaryEvidenceAvailable: this.stallToolHistory?.available() !== false,
1212
+ safe: this.isAlive() && !this.closing && this.readiness === 'running'
1213
+ && !active.cancellationSource && !active.boundaryUnknown && !this.steeringOccupied
1214
+ && this.steeringRequests === 0 && this.stallToolHistory?.available() !== false
1215
+ && this.activeToolCalls.size === 0 && this.pendingPermissions.size === 0,
1216
+ };
1217
+ }
1218
+ async recoverStall(observed, report) {
1219
+ const current = this.stallObservation();
1220
+ if (!current?.safe || current.turnId !== observed.turnId
1221
+ || current.lastProgressAt !== observed.lastProgressAt || current.progressCount !== observed.progressCount) {
1222
+ report('superseded');
1223
+ return;
1224
+ }
1225
+ const active = this.activeTurn;
1226
+ let resolveReady;
1227
+ const ready = new Promise(resolve => { resolveReady = resolve; });
1228
+ const attempt = this.stallAttempt = {
1229
+ turnId: active.id, recoveryId: randomUUID(), ready, superseded: false,
1230
+ report, resumed: false, blocked: false,
1231
+ };
1232
+ active.cancellationSource = 'stall-watchdog';
1233
+ // This intentionally does not use cancelActive: automatic recovery may
1234
+ // never enter its SIGTERM/SIGKILL escalation or settle a permission.
1235
+ let timer;
1236
+ try {
1237
+ const completed = await Promise.race([
1238
+ (async () => {
1239
+ await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.sessionId });
1240
+ await active.settled;
1241
+ return true;
1242
+ })(),
1243
+ new Promise(resolve => {
1244
+ timer = setTimeout(() => resolve(false), this.options.stallRecovery?.cancelWaitMs ?? CANCEL_SETTLE_GRACE_MS);
1245
+ timer.unref?.();
1246
+ }),
1247
+ ]);
1248
+ if (!completed || attempt.superseded || this.closing || !this.isAlive()) {
1249
+ attempt.blocked = true;
1250
+ report(attempt.superseded || this.closing ? 'superseded' : 'blocked_cancel');
1251
+ resolveReady(false);
1252
+ }
1253
+ else
1254
+ resolveReady(true);
1255
+ }
1256
+ catch {
1257
+ attempt.blocked = true;
1258
+ try {
1259
+ report('blocked_cancel');
1260
+ }
1261
+ finally {
1262
+ resolveReady(false);
1263
+ }
1264
+ }
1265
+ finally {
1266
+ if (timer)
1267
+ clearTimeout(timer);
1268
+ }
1116
1269
  }
1270
+ /** Keep the original queue slot (including startup) until recovery finishes. */
1117
1271
  async runPrompt(text, turnId = randomUUID(), origin) {
1272
+ const result = await this.runSinglePrompt(text, turnId, origin);
1273
+ const attempt = this.stallAttempt;
1274
+ if (!attempt || attempt.turnId !== turnId)
1275
+ return result;
1276
+ const ready = await attempt.ready;
1277
+ if (attempt.superseded || this.closing)
1278
+ return result;
1279
+ if (!ready || result.outcome !== 'cancelled' || result.cancellationSource !== 'stall-watchdog') {
1280
+ // An RPC error/refusal/ambiguous terminal answer to cancellation is not
1281
+ // permission to replay work or to fail startup and restart the process.
1282
+ if (!attempt.blocked) {
1283
+ attempt.blocked = true;
1284
+ try {
1285
+ attempt.report('blocked_cancel');
1286
+ }
1287
+ catch { /* claim remains durable */ }
1288
+ }
1289
+ return turnResult(true, 'cancelled', 'diagnostic cancellation requires operator attention', undefined, 'stall-watchdog');
1290
+ }
1291
+ try {
1292
+ attempt.report('recovery_started');
1293
+ attempt.recoveryStartedAt = Date.now();
1294
+ const recoveryOrigin = origin?.kind === 'scheduled-loop'
1295
+ ? origin : { kind: 'stall-watchdog' };
1296
+ this.admitToLedger(attempt.recoveryId, STALL_RECOVERY_PROMPT, 0, { origin: recoveryOrigin });
1297
+ const recovered = await this.runSinglePrompt(STALL_RECOVERY_PROMPT, attempt.recoveryId, recoveryOrigin);
1298
+ attempt.report(attempt.superseded ? 'superseded'
1299
+ : recovered.succeeded && attempt.resumed ? 'recovery_completed' : 'blocked_recovery');
1300
+ // A failed diagnostic turn must not make startup tear down the session.
1301
+ return recovered.succeeded && attempt.resumed ? recovered : turnResult(true, 'cancelled', 'diagnostic recovery requires operator attention', undefined, 'stall-watchdog');
1302
+ }
1303
+ catch {
1304
+ return turnResult(true, 'cancelled', 'diagnostic recovery requires operator attention', undefined, 'stall-watchdog');
1305
+ }
1306
+ }
1307
+ async runSinglePrompt(text, turnId = randomUUID(), origin) {
1118
1308
  if (!this.sessionId || !this.isAlive())
1119
1309
  return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
1120
1310
  this.readiness = 'running';
1311
+ this.managedTurnCount++;
1312
+ this.retryNativeTurnId = undefined;
1121
1313
  let settle;
1122
1314
  const settled = new Promise(resolve => { settle = resolve; });
1123
- this.activeTurn = { id: turnId, output: '', origin, settled, settle };
1315
+ this.activeTurn = { toolEvidence: new Map(), startedAt: Date.now(), toolIds: new Set(), lastProgressAt: 0, progressCount: 0, transportFailures: 0, boundaryUnknown: false, id: turnId, output: '', origin, settled, settle };
1124
1316
  this.events.emit('state', { turnId, status: 'running', origin });
1125
1317
  this.conversation.appendSafe({
1126
1318
  kind: 'prompt.started', sessionGeneration: this.sessionGeneration,
@@ -1164,7 +1356,8 @@ export class AcpSession {
1164
1356
  this.lastError = origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : detail;
1165
1357
  this.readiness = this.isAlive() ? 'idle' : 'failed';
1166
1358
  this.events.emit('error', {
1167
- turnId, origin,
1359
+ turnId, origin: this.activeTurn?.cancellationSource === 'stall-watchdog'
1360
+ ? { kind: 'stall-watchdog' } : origin,
1168
1361
  text: origin?.kind === 'scheduled-loop' ? 'scheduled-loop turn failed' : this.lastError,
1169
1362
  });
1170
1363
  if (this.isAlive())
@@ -1192,8 +1385,10 @@ export class AcpSession {
1192
1385
  }
1193
1386
  }
1194
1387
  async steerPrompt(text) {
1388
+ this.steeringWasUsed = true;
1195
1389
  if (!this.sessionId || !this.isAlive())
1196
1390
  return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
1391
+ this.steeringRequests++;
1197
1392
  try {
1198
1393
  const response = await Promise.race([
1199
1394
  this.connection.agent.request('_session/steering', {
@@ -1218,6 +1413,9 @@ export class AcpSession {
1218
1413
  this.events.emit('error', { text: detail });
1219
1414
  return turnResult(false, 'failed', detail);
1220
1415
  }
1416
+ finally {
1417
+ this.steeringRequests--;
1418
+ }
1221
1419
  }
1222
1420
  requestPermission(params) {
1223
1421
  // `kinds` is a PRIORITY order. Scanning the agent's option array instead
@@ -1393,6 +1591,48 @@ export class AcpSession {
1393
1591
  && params.options.some(option => option.optionId === 'allow_once' && option.kind === 'allow_once')
1394
1592
  && params.options.some(option => option.optionId === 'decline' && option.kind === 'reject_once');
1395
1593
  }
1594
+ /** Pinned codex-acp 1.1.7 structured metadata, never stderr or assistant text. */
1595
+ recordStallMetadata(update) {
1596
+ if (this.options.permissionMetadataSource !== 'codex-acp'
1597
+ || update.sessionUpdate !== 'session_info_update' || !this.activeTurn)
1598
+ return;
1599
+ const meta = update._meta?.codex;
1600
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta))
1601
+ return;
1602
+ const codex = meta;
1603
+ const status = codex.threadStatus;
1604
+ if (Object.prototype.hasOwnProperty.call(codex, 'threadStatus')) {
1605
+ const value = status && typeof status === 'object' && !Array.isArray(status)
1606
+ ? status : {};
1607
+ // Unknown or modal thread status is a permanent conservative fence for
1608
+ // this turn. A later delayed idle/active status must not clear it.
1609
+ if (value.type !== 'active' || !Array.isArray(value.activeFlags)
1610
+ || value.activeFlags.length > 0)
1611
+ this.activeTurn.boundaryUnknown = true;
1612
+ }
1613
+ const error = codex.error;
1614
+ if (!error || typeof error !== 'object' || Array.isArray(error))
1615
+ return;
1616
+ const value = error;
1617
+ const info = value.codexErrorInfo;
1618
+ if (value.willRetry !== true || typeof value.turnId !== 'string' || !value.turnId
1619
+ || !info || typeof info !== 'object' || Array.isArray(info))
1620
+ return;
1621
+ if (!['responseStreamConnectionFailed', 'responseStreamDisconnected']
1622
+ .some(key => Object.prototype.hasOwnProperty.call(info, key)))
1623
+ return;
1624
+ // ACP 1.1.7 does not expose a native-turn-to-prompt mapping. Only the first
1625
+ // fresh managed turn with no steering can be correlated without guessing;
1626
+ // later/resumed turns retain the conservative generic no-progress path.
1627
+ if (this.options.mode !== 'fresh' || this.managedTurnCount !== 1 || this.steeringWasUsed)
1628
+ return;
1629
+ if (this.retryNativeTurnId && this.retryNativeTurnId !== value.turnId) {
1630
+ this.activeTurn.boundaryUnknown = true;
1631
+ return;
1632
+ }
1633
+ this.retryNativeTurnId = value.turnId;
1634
+ this.activeTurn.transportFailures++;
1635
+ }
1396
1636
  recordUpdate(update) {
1397
1637
  // Replayed history is not current activity: `session/load` would otherwise
1398
1638
  // make a cold session look like it had just been working. The same reason
@@ -1402,6 +1642,53 @@ export class AcpSession {
1402
1642
  this.lastUpdateAt = new Date().toISOString();
1403
1643
  this.refreshSteeringOccupancy();
1404
1644
  }
1645
+ if (!this.replaying && this.activeTurn) {
1646
+ this.recordStallMetadata(update);
1647
+ const active = this.activeTurn;
1648
+ const kind = update.sessionUpdate;
1649
+ if (this.options.stallRecovery && (kind === 'tool_call' || kind === 'tool_call_update')) {
1650
+ if (!update.toolCallId || active.toolIds.size >= 4096)
1651
+ active.boundaryUnknown = true;
1652
+ else
1653
+ active.toolIds.add(update.toolCallId);
1654
+ if (this.stallToolHistory?.observe(update.toolCallId, active.id) === false)
1655
+ active.boundaryUnknown = true;
1656
+ }
1657
+ let meaningful = ((kind === 'agent_message_chunk' || kind === 'agent_thought_chunk')
1658
+ && (update.content.type !== 'text' || update.content.text.length > 0))
1659
+ || kind === 'tool_call' || kind === 'tool_call_update' || kind === 'plan';
1660
+ if (this.options.stallRecovery && (kind === 'tool_call' || kind === 'tool_call_update' || kind === 'plan')) {
1661
+ const fingerprint = createHash('sha256').update(JSON.stringify(update)).digest('hex');
1662
+ if (kind === 'plan') {
1663
+ meaningful = fingerprint !== active.planEvidence;
1664
+ active.planEvidence = fingerprint;
1665
+ }
1666
+ else if (active.toolEvidence.size < 4096 || active.toolEvidence.has(update.toolCallId)) {
1667
+ meaningful = fingerprint !== active.toolEvidence.get(update.toolCallId);
1668
+ active.toolEvidence.set(update.toolCallId, fingerprint);
1669
+ }
1670
+ }
1671
+ if (this.options.stallRecovery && ![
1672
+ 'agent_message_chunk', 'agent_thought_chunk', 'tool_call', 'tool_call_update', 'plan',
1673
+ 'available_commands_update', 'current_mode_update', 'config_option_update', 'session_info_update', 'usage_update',
1674
+ ].includes(kind))
1675
+ active.boundaryUnknown = true;
1676
+ if (meaningful) {
1677
+ active.lastProgressAt = Date.now();
1678
+ active.progressCount++;
1679
+ active.transportFailures = 0;
1680
+ const attempt = this.stallAttempt;
1681
+ if (attempt?.recoveryId === active.id && !attempt.resumed) {
1682
+ attempt.resumed = true;
1683
+ try {
1684
+ attempt.report('progress_resumed');
1685
+ }
1686
+ catch {
1687
+ attempt.blocked = true;
1688
+ }
1689
+ }
1690
+ }
1691
+ }
1405
1692
  const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
1406
1693
  const messagePhase = update.sessionUpdate === 'agent_message_chunk'
1407
1694
  ? this.codexMessagePhase(update) : undefined;
@@ -1459,6 +1746,9 @@ export class AcpSession {
1459
1746
  this.releaseTool(update.toolCallId);
1460
1747
  else if (update.status !== undefined)
1461
1748
  this.reserveTool(update.toolCallId);
1749
+ else if (this.options.stallRecovery && this.activeTurn
1750
+ && !this.activeToolCalls.has(update.toolCallId))
1751
+ this.activeTurn.boundaryUnknown = true;
1462
1752
  break;
1463
1753
  default:
1464
1754
  break;
@@ -17,8 +17,9 @@ export type ScheduledAttempt = {
17
17
  */
18
18
  export declare class RoleTurnArbiter implements AgentSession {
19
19
  private readonly session;
20
- readonly backend: "acp";
20
+ readonly backend: import("../config.js").SessionBackendId;
21
21
  readonly pid: number;
22
+ readonly capabilities: import("./types.js").AgentSessionCapabilities | undefined;
22
23
  private tail;
23
24
  private unsettled;
24
25
  private stopping;
@@ -25,6 +25,7 @@ export class RoleTurnArbiter {
25
25
  session;
26
26
  backend;
27
27
  pid;
28
+ capabilities;
28
29
  tail = Promise.resolve();
29
30
  unsettled = 0;
30
31
  stopping = false;
@@ -36,6 +37,7 @@ export class RoleTurnArbiter {
36
37
  this.session = session;
37
38
  this.backend = session.backend;
38
39
  this.pid = session.pid;
40
+ this.capabilities = session.capabilities;
39
41
  }
40
42
  /**
41
43
  * Waiters race the tail against their own generation's retirement, so a
@@ -0,0 +1,53 @@
1
+ import { type ChildProcessWithoutNullStreams } from 'node:child_process';
2
+ import type { ExitRecord } from './types.js';
3
+ type JsonObject = Record<string, unknown>;
4
+ type RequestId = string | number;
5
+ export interface CodexAppServerTransportOptions {
6
+ name: string;
7
+ argv: string[];
8
+ cwd: string;
9
+ env: Record<string, string>;
10
+ log(line: string): void;
11
+ requestTimeoutMs?: number;
12
+ onNotification?(method: string, params: JsonObject): void;
13
+ onRequest?(method: string, id: RequestId, params: JsonObject): void;
14
+ onExit?(exit: ExitRecord): void;
15
+ }
16
+ export interface CodexAppServerConnection {
17
+ readonly pid: number;
18
+ readonly child: Pick<ChildProcessWithoutNullStreams, 'kill'>;
19
+ isAlive(): boolean;
20
+ exitResult(): ExitRecord | null;
21
+ request<T = unknown>(method: string, params?: JsonObject, timeoutMs?: number): Promise<T>;
22
+ notify(method: string, params?: JsonObject): void;
23
+ respond(id: RequestId, result: unknown): void;
24
+ respondError(id: RequestId, code: number, message: string): void;
25
+ close(): Promise<void>;
26
+ }
27
+ export type CodexAppServerTransportFactory = (options: CodexAppServerTransportOptions) => Promise<CodexAppServerConnection>;
28
+ /** Minimal, version-tolerant JSONL client for Codex app-server's stdio transport. */
29
+ export declare class CodexAppServerTransport implements CodexAppServerConnection {
30
+ private readonly options;
31
+ readonly child: ChildProcessWithoutNullStreams;
32
+ readonly pid: number;
33
+ private nextId;
34
+ private readonly pending;
35
+ private readonly requestTimeoutMs;
36
+ private readonly exited;
37
+ private resolveExited;
38
+ private closed;
39
+ private exit;
40
+ private constructor();
41
+ static start(options: CodexAppServerTransportOptions): Promise<CodexAppServerTransport>;
42
+ isAlive(): boolean;
43
+ exitResult(): ExitRecord | null;
44
+ request<T = unknown>(method: string, params?: JsonObject, timeoutMs?: number): Promise<T>;
45
+ notify(method: string, params?: JsonObject): void;
46
+ respond(id: RequestId, result: unknown): void;
47
+ respondError(id: RequestId, code: number, message: string): void;
48
+ close(): Promise<void>;
49
+ private waitForExit;
50
+ private write;
51
+ private receive;
52
+ }
53
+ export {};