agent-relay-server 0.124.0 → 0.126.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 (169) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +9 -5
  3. package/public/assets/{MessageBubble-SQK8-Mzp.js → MessageBubble-B6YgulnX.js} +2 -2
  4. package/public/assets/{MessageBubble-SQK8-Mzp.js.map → MessageBubble-B6YgulnX.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CS-aow4S.js → PlanGraphPanel--DNxHn1T.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CS-aow4S.js.map → PlanGraphPanel--DNxHn1T.js.map} +1 -1
  7. package/public/assets/{activity-W31XvhC2.js → activity-Fo5aLYD6.js} +2 -2
  8. package/public/assets/{activity-W31XvhC2.js.map → activity-Fo5aLYD6.js.map} +1 -1
  9. package/public/assets/agent-profiles-C3p0xclG.js +2 -0
  10. package/public/assets/{agent-profiles-DXysA9To.js.map → agent-profiles-C3p0xclG.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-BxUopALO.js → agent-type-icon-BmBR-9CH.js} +2 -2
  12. package/public/assets/{agent-type-icon-BxUopALO.js.map → agent-type-icon-BmBR-9CH.js.map} +1 -1
  13. package/public/assets/{agents-B5GRNRnN.js → agents-_GBQyF5b.js} +2 -2
  14. package/public/assets/{agents-B5GRNRnN.js.map → agents-_GBQyF5b.js.map} +1 -1
  15. package/public/assets/{analytics-DqAuUAwX.js → analytics-BZtRycyL.js} +2 -2
  16. package/public/assets/{analytics-DqAuUAwX.js.map → analytics-BZtRycyL.js.map} +1 -1
  17. package/public/assets/{automation-DYlgHYcb.js → automation-Cf_Ewi9f.js} +2 -2
  18. package/public/assets/{automation-DYlgHYcb.js.map → automation-Cf_Ewi9f.js.map} +1 -1
  19. package/public/assets/{badge-XEru6wto.js → badge-BCvp7LK3.js} +2 -2
  20. package/public/assets/{badge-XEru6wto.js.map → badge-BCvp7LK3.js.map} +1 -1
  21. package/public/assets/{branch-state-badge-Mf0Ow77K.js → branch-state-badge-Cscjqf-E.js} +2 -2
  22. package/public/assets/{branch-state-badge-Mf0Ow77K.js.map → branch-state-badge-Cscjqf-E.js.map} +1 -1
  23. package/public/assets/{button-C3CQSr4p.js → button-DyHjsHY3.js} +2 -2
  24. package/public/assets/{button-C3CQSr4p.js.map → button-DyHjsHY3.js.map} +1 -1
  25. package/public/assets/{card-C5XTIPvq.js → card-BoqYxpVW.js} +2 -2
  26. package/public/assets/{card-C5XTIPvq.js.map → card-BoqYxpVW.js.map} +1 -1
  27. package/public/assets/{channels-DVRkVeky.js → channels-DAtOnA6o.js} +2 -2
  28. package/public/assets/{channels-DVRkVeky.js.map → channels-DAtOnA6o.js.map} +1 -1
  29. package/public/assets/chat-BdbIWZpQ.js +2 -0
  30. package/public/assets/chat-BdbIWZpQ.js.map +1 -0
  31. package/public/assets/{connectors-Cqubkw9C.js → connectors-B2grC-hl.js} +2 -2
  32. package/public/assets/{connectors-Cqubkw9C.js.map → connectors-B2grC-hl.js.map} +1 -1
  33. package/public/assets/{copy-button-Bil2CjzT.js → copy-button-C67FUG3E.js} +2 -2
  34. package/public/assets/{copy-button-Bil2CjzT.js.map → copy-button-C67FUG3E.js.map} +1 -1
  35. package/public/assets/{display-GFbGUG0O.js → display-aTnwsqQh.js} +3 -3
  36. package/public/assets/{display-GFbGUG0O.js.map → display-aTnwsqQh.js.map} +1 -1
  37. package/public/assets/{dist-V3wu3BHw.js → dist-BD4Xc13v.js} +2 -2
  38. package/public/assets/{dist-V3wu3BHw.js.map → dist-BD4Xc13v.js.map} +1 -1
  39. package/public/assets/{dist-C5z05Vkz.js → dist-BFDfbfNC.js} +2 -2
  40. package/public/assets/{dist-C5z05Vkz.js.map → dist-BFDfbfNC.js.map} +1 -1
  41. package/public/assets/{dist-CjyfQThq.js → dist-BJKxqIQd.js} +2 -2
  42. package/public/assets/{dist-CjyfQThq.js.map → dist-BJKxqIQd.js.map} +1 -1
  43. package/public/assets/{dist-B8tD9ZcR.js → dist-BNB3fTO-.js} +2 -2
  44. package/public/assets/{dist-B8tD9ZcR.js.map → dist-BNB3fTO-.js.map} +1 -1
  45. package/public/assets/{dist-CFtFIhu3.js → dist-CHKra2JV.js} +2 -2
  46. package/public/assets/{dist-CFtFIhu3.js.map → dist-CHKra2JV.js.map} +1 -1
  47. package/public/assets/{dist-r8wyUTkK.js → dist-CQMQ5iku.js} +2 -2
  48. package/public/assets/{dist-r8wyUTkK.js.map → dist-CQMQ5iku.js.map} +1 -1
  49. package/public/assets/{dist-DKmTXfpP.js → dist-C_VOBSaK.js} +2 -2
  50. package/public/assets/{dist-DKmTXfpP.js.map → dist-C_VOBSaK.js.map} +1 -1
  51. package/public/assets/{dist-CGaHpxnN.js → dist-D5w04z0l.js} +2 -2
  52. package/public/assets/{dist-CGaHpxnN.js.map → dist-D5w04z0l.js.map} +1 -1
  53. package/public/assets/{dist-4VwhnLtj.js → dist-DG75bvPp.js} +2 -2
  54. package/public/assets/{dist-4VwhnLtj.js.map → dist-DG75bvPp.js.map} +1 -1
  55. package/public/assets/{dist-CrUl2aQp.js → dist-G8YBD-wp.js} +2 -2
  56. package/public/assets/{dist-CrUl2aQp.js.map → dist-G8YBD-wp.js.map} +1 -1
  57. package/public/assets/{dist-BJaV2BD4.js → dist-f6MW74eX.js} +2 -2
  58. package/public/assets/{dist-BJaV2BD4.js.map → dist-f6MW74eX.js.map} +1 -1
  59. package/public/assets/{es2015-5DTJe35n.js → es2015-a6fysEVz.js} +2 -2
  60. package/public/assets/{es2015-5DTJe35n.js.map → es2015-a6fysEVz.js.map} +1 -1
  61. package/public/assets/{formatted-body-impl-L1EMnNW6.js → formatted-body-impl-gOLBKeyW.js} +2 -2
  62. package/public/assets/{formatted-body-impl-L1EMnNW6.js.map → formatted-body-impl-gOLBKeyW.js.map} +1 -1
  63. package/public/assets/formatted-body-jjhFXTap.js +3 -0
  64. package/public/assets/{formatted-body-BYQ-SPtj.js.map → formatted-body-jjhFXTap.js.map} +1 -1
  65. package/public/assets/index-CgH1BZwq.js +26 -0
  66. package/public/assets/index-CgH1BZwq.js.map +1 -0
  67. package/public/assets/index-DHzXsQot.css +2 -0
  68. package/public/assets/{input-CXMbJ848.js → input-Rc4oWv4q.js} +2 -2
  69. package/public/assets/{input-CXMbJ848.js.map → input-Rc4oWv4q.js.map} +1 -1
  70. package/public/assets/{insights-DwZxq1Dn.js → insights-CTW85An9.js} +2 -2
  71. package/public/assets/{insights-DwZxq1Dn.js.map → insights-CTW85An9.js.map} +1 -1
  72. package/public/assets/{integrations-9G5TJnnU.js → integrations-9c6IdRU4.js} +2 -2
  73. package/public/assets/{integrations-9G5TJnnU.js.map → integrations-9c6IdRU4.js.map} +1 -1
  74. package/public/assets/{lib-BcUW1l-W.js → lib-BZ6WPg2t.js} +2 -2
  75. package/public/assets/{lib-BcUW1l-W.js.map → lib-BZ6WPg2t.js.map} +1 -1
  76. package/public/assets/lucide-react-NXHPIMsX.js +10 -0
  77. package/public/assets/lucide-react-NXHPIMsX.js.map +1 -0
  78. package/public/assets/{maintenance-COcmUDnf.js → maintenance-D5PEygZz.js} +2 -2
  79. package/public/assets/{maintenance-COcmUDnf.js.map → maintenance-D5PEygZz.js.map} +1 -1
  80. package/public/assets/{managed-agents-D9-dgMeX.js → managed-agents-pkpAzZy8.js} +2 -2
  81. package/public/assets/{managed-agents-D9-dgMeX.js.map → managed-agents-pkpAzZy8.js.map} +1 -1
  82. package/public/assets/{markdown-preview-impl-DH372bL-.js → markdown-preview-impl-DMzFE-Lo.js} +2 -2
  83. package/public/assets/{markdown-preview-impl-DH372bL-.js.map → markdown-preview-impl-DMzFE-Lo.js.map} +1 -1
  84. package/public/assets/{memory-B9hRK8sk.js → memory-BnquVYQz.js} +2 -2
  85. package/public/assets/{memory-B9hRK8sk.js.map → memory-BnquVYQz.js.map} +1 -1
  86. package/public/assets/{messages-BHkHlXzF.js → messages-DgxLlndC.js} +2 -2
  87. package/public/assets/{messages-BHkHlXzF.js.map → messages-DgxLlndC.js.map} +1 -1
  88. package/public/assets/{orchestrators-CXy3SDSn.js → orchestrators-BaWxeAlK.js} +2 -2
  89. package/public/assets/{orchestrators-CXy3SDSn.js.map → orchestrators-BaWxeAlK.js.map} +1 -1
  90. package/public/assets/{overview-D2yRyCQq.js → overview-Kvs-j2gg.js} +2 -2
  91. package/public/assets/{overview-D2yRyCQq.js.map → overview-Kvs-j2gg.js.map} +1 -1
  92. package/public/assets/{pairs-5tXdHylI.js → pairs-eDzbqcER.js} +2 -2
  93. package/public/assets/{pairs-5tXdHylI.js.map → pairs-eDzbqcER.js.map} +1 -1
  94. package/public/assets/{projects-CquHTMo1.js → projects-iDkxAGBW.js} +2 -2
  95. package/public/assets/{projects-CquHTMo1.js.map → projects-iDkxAGBW.js.map} +1 -1
  96. package/public/assets/{prompt-settings-Bn5mmi9w.js → prompt-settings-CjcqBG_r.js} +2 -2
  97. package/public/assets/{prompt-settings-Bn5mmi9w.js.map → prompt-settings-CjcqBG_r.js.map} +1 -1
  98. package/public/assets/{react-dom-BQICbUuF.js → react-dom-8yaD9iJG.js} +2 -2
  99. package/public/assets/{react-dom-BQICbUuF.js.map → react-dom-8yaD9iJG.js.map} +1 -1
  100. package/public/assets/{registry-C3sB7qv5.js → registry-C1DSnjjg.js} +2 -2
  101. package/public/assets/{registry-C3sB7qv5.js.map → registry-C1DSnjjg.js.map} +1 -1
  102. package/public/assets/{security-CmD-YE-_.js → security-VbEe_jwm.js} +2 -2
  103. package/public/assets/{security-CmD-YE-_.js.map → security-VbEe_jwm.js.map} +1 -1
  104. package/public/assets/{select-C0UlhK2k.js → select-CVfwgfKl.js} +2 -2
  105. package/public/assets/{select-C0UlhK2k.js.map → select-CVfwgfKl.js.map} +1 -1
  106. package/public/assets/{settings-DrqMG5nG.js → settings-ujA9hZW1.js} +4 -4
  107. package/public/assets/{settings-DrqMG5nG.js.map → settings-ujA9hZW1.js.map} +1 -1
  108. package/public/assets/store-D-Frk_EC.js +9 -0
  109. package/public/assets/store-D-Frk_EC.js.map +1 -0
  110. package/public/assets/{task-chip-BHM-e3I8.js → task-chip-CeujtXYT.js} +2 -2
  111. package/public/assets/{task-chip-BHM-e3I8.js.map → task-chip-CeujtXYT.js.map} +1 -1
  112. package/public/assets/{tasks-ChQjtXHu.js → tasks-BtbKaa-b.js} +2 -2
  113. package/public/assets/{tasks-ChQjtXHu.js.map → tasks-BtbKaa-b.js.map} +1 -1
  114. package/public/assets/{teams-itjNlDLp.js → teams-CrVDQ99v.js} +2 -2
  115. package/public/assets/{teams-itjNlDLp.js.map → teams-CrVDQ99v.js.map} +1 -1
  116. package/public/assets/{terminal-viewer-impl-B9M6s2vB.js → terminal-viewer-impl-Aays_e7-.js} +2 -2
  117. package/public/assets/{terminal-viewer-impl-B9M6s2vB.js.map → terminal-viewer-impl-Aays_e7-.js.map} +1 -1
  118. package/public/assets/{types-CRptbSYh.js → types-BqeSkWrv.js} +2 -2
  119. package/public/assets/types-BqeSkWrv.js.map +1 -0
  120. package/public/assets/{use-keyboard-viewport-CAa7IQTm.js → use-keyboard-viewport-cGEmrGE7.js} +2 -2
  121. package/public/assets/{use-keyboard-viewport-CAa7IQTm.js.map → use-keyboard-viewport-cGEmrGE7.js.map} +1 -1
  122. package/public/assets/{user-avatar-kYPfLgXp.js → user-avatar-qbo-B3dQ.js} +2 -2
  123. package/public/assets/{user-avatar-kYPfLgXp.js.map → user-avatar-qbo-B3dQ.js.map} +1 -1
  124. package/public/assets/{work-queue-Kw0uMkEF.js → work-queue-DDSGZO4Z.js} +2 -2
  125. package/public/assets/{work-queue-Kw0uMkEF.js.map → work-queue-DDSGZO4Z.js.map} +1 -1
  126. package/public/assets/{workspaces-CR6f7G0R.js → workspaces-CoDz-iZd.js} +2 -2
  127. package/public/assets/{workspaces-CR6f7G0R.js.map → workspaces-CoDz-iZd.js.map} +1 -1
  128. package/public/index.html +26 -26
  129. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  130. package/runner/plugins/claude/hooks/relay-status.sh +11 -1
  131. package/runner/plugins/claude/hooks/stop.sh +6 -1
  132. package/runner/plugins/claude/hooks/user-prompt-submit.sh +6 -1
  133. package/runner/src/adapter.ts +5 -1
  134. package/runner/src/adapters/claude-transcript.ts +52 -58
  135. package/runner/src/adapters/codex-agent-message-capture.ts +33 -31
  136. package/runner/src/adapters/codex.ts +30 -12
  137. package/runner/src/control-server.ts +9 -4
  138. package/runner/src/response-capture-report.ts +4 -1
  139. package/runner/src/runner-core.ts +108 -46
  140. package/runner/src/runner-helpers.ts +31 -0
  141. package/src/agent-lifecycle-events.ts +8 -0
  142. package/src/bus.ts +0 -24
  143. package/src/db/agents.ts +15 -1
  144. package/src/db/messages.ts +150 -43
  145. package/src/maintenance/jobs.ts +8 -2
  146. package/src/mcp/tools-workspace.ts +4 -0
  147. package/src/routes/agent-sessions.ts +10 -7
  148. package/src/routes/agents.ts +0 -4
  149. package/src/routes/orchestrator.ts +5 -2
  150. package/src/routes/settings.ts +31 -2
  151. package/src/services/restart-instructions.ts +36 -0
  152. package/src/services/send-message.ts +7 -18
  153. package/src/services/shutdown-agent.ts +6 -0
  154. package/src/services/task-entity.ts +4 -0
  155. package/src/services/transient-agent-reaper.ts +40 -1
  156. package/src/services/work-finality.ts +25 -0
  157. package/src/settings/registry.ts +14 -3
  158. package/public/assets/agent-profiles-DXysA9To.js +0 -2
  159. package/public/assets/chat-Clz6qOtn.js +0 -2
  160. package/public/assets/chat-Clz6qOtn.js.map +0 -1
  161. package/public/assets/formatted-body-BYQ-SPtj.js +0 -3
  162. package/public/assets/index-0uIGXT14.js +0 -25
  163. package/public/assets/index-0uIGXT14.js.map +0 -1
  164. package/public/assets/index-DOJ7KidA.css +0 -2
  165. package/public/assets/lucide-react-CoU_Xts2.js +0 -10
  166. package/public/assets/lucide-react-CoU_Xts2.js.map +0 -1
  167. package/public/assets/store-zFLzd0Jd.js +0 -9
  168. package/public/assets/store-zFLzd0Jd.js.map +0 -1
  169. package/public/assets/types-CRptbSYh.js.map +0 -1
@@ -16,7 +16,7 @@ import { readRunnerContextProbeState } from "./context-probe-state";
16
16
  import { startControlServer, type ControlServer, type ResolvedPermissionPrompt } from "./control-server";
17
17
  import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureReplyRoute } from "./reply-obligation-cache";
18
18
  import { Outbox, type OutboxRecord } from "./outbox";
19
- import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractFinalAssistantMessageAfterEntry, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
19
+ import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, extractAssistantTurnForPrompt, countTranscriptEntries, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
20
20
  import { IncrementalClaudeTranscriptTail } from "./adapters/claude-transcript-tail";
21
21
  import { getManifest } from "agent-relay-providers";
22
22
  import { profileUsesProviderHostGlobals } from "./profile-home";
@@ -51,7 +51,7 @@ import {
51
51
  runnerAgentStatus,
52
52
  runnerBusErrorAction,
53
53
  runnerMessageMatches,
54
- runnerMessageShouldDirtyObligations,
54
+ runnerMessageShouldDirtyObligations, restartOptionsFromCommand,
55
55
  runnerShouldResolveProviderExit,
56
56
  runnerShouldRestartUnexpectedProviderExit,
57
57
  runtimeProviderCapabilities,
@@ -60,7 +60,7 @@ import {
60
60
  shouldLogDeliveryFailure,
61
61
  stripRunnerClaimedGuidance,
62
62
  taskIdFromMessage,
63
- type ProbeModelInfo,
63
+ type ProbeModelInfo, type RestartProviderOptions,
64
64
  } from "./runner-helpers";
65
65
 
66
66
  // Pre-destroy work is best-effort and must never hang teardown. Capping it keeps a slow
@@ -69,6 +69,7 @@ import {
69
69
  // short so a wedged/down server can't stall an operator-requested shutdown for long; a
70
70
  // row that still can't land is logged, not silently dropped.
71
71
  const OUTBOX_FLUSH_TIMEOUT_MS = 3_000;
72
+ const STALE_PROVIDER_TURN_BUSY_MS = 10 * 60_000;
72
73
 
73
74
  interface RunnerOptions {
74
75
  provider: string;
@@ -535,7 +536,7 @@ export class AgentRunner {
535
536
  return mode === "isolated";
536
537
  }
537
538
 
538
- private async spawnProvider(opts: { resumeId?: string; suppressPrompt?: boolean } = {}): Promise<ManagedProcess> {
539
+ private async spawnProvider(opts: RestartProviderOptions = {}): Promise<ManagedProcess> {
539
540
  this.providerSessionId = crypto.randomUUID();
540
541
  this.lastTranscriptPath = undefined;
541
542
  const includeProviderGlobals = profileUsesProviderHostGlobals(this.options);
@@ -586,7 +587,7 @@ export class AgentRunner {
586
587
  ...(this.options.profile ? { profile: this.options.profile } : {}),
587
588
  ...(this.options.agentProfile ? { agentProfile: this.options.agentProfile } : {}),
588
589
  ...(this.options.prompt && !opts.resumeId && !opts.suppressPrompt ? { prompt: this.options.prompt } : {}),
589
- ...(this.options.systemPromptAppend ? { systemPromptAppend: this.options.systemPromptAppend } : {}),
590
+ ...((opts.systemPromptAppend ?? this.options.systemPromptAppend) ? { systemPromptAppend: opts.systemPromptAppend ?? this.options.systemPromptAppend } : {}),
590
591
  ...(this.options.tmuxSession ? { tmuxSession: this.options.tmuxSession } : {}),
591
592
  providerArgs: opts.resumeId ? [...this.options.providerArgs, "--resume", opts.resumeId] : this.options.providerArgs,
592
593
  providerConfig: this.options.providerConfig,
@@ -604,7 +605,7 @@ export class AgentRunner {
604
605
  };
605
606
  const launchSeededPrompt = this.options.adapter.seedsInitialPromptAtLaunch && !opts.resumeId && !opts.suppressPrompt ? this.options.prompt?.trim() : ""; if (launchSeededPrompt) this.recordInjectedPrompt(launchSeededPrompt);
606
607
  const managedProcess = await this.options.adapter.spawn(config);
607
- const injectionEvents = runnerLaunchInjectionSessionEvents({ carried: this.options.relayInjectionEvents, provider: this.options.provider, config, providerConfig: this.options.providerConfig, agentId: this.agentId, providerSessionId: this.providerSessionId });
608
+ const injectionEvents = runnerLaunchInjectionSessionEvents({ carried: opts.relayInjectionEvents ?? this.options.relayInjectionEvents, provider: this.options.provider, config, providerConfig: this.options.providerConfig, agentId: this.agentId, providerSessionId: this.providerSessionId });
608
609
  for (const event of injectionEvents) {
609
610
  this.publishSessionEvent(event);
610
611
  }
@@ -777,7 +778,7 @@ export class AgentRunner {
777
778
  else this.lifecycleAction = undefined;
778
779
  this.publishStatus();
779
780
  let providerResult: Record<string, unknown> | void = undefined;
780
- if (type === "agent.restart") await this.restartProvider();
781
+ if (type === "agent.restart") await this.restartProvider(restartOptionsFromCommand(params));
781
782
  else if (type === "agent.reconnect") this.publishStatus();
782
783
  else if (type === "agent.compact") {
783
784
  if (!this.options.adapter.compact || !this.process) throw new Error("provider does not support compact");
@@ -927,7 +928,7 @@ export class AgentRunner {
927
928
  return attachmentText ? `${body}\n\n${attachmentText}` : body;
928
929
  }
929
930
 
930
- private async restartProvider(opts: { resumeId?: string; suppressPrompt?: boolean } = {}): Promise<void> {
931
+ private async restartProvider(opts: RestartProviderOptions = {}): Promise<void> {
931
932
  this.restartInProgress = true;
932
933
  try {
933
934
  if (this.process) {
@@ -1204,7 +1205,7 @@ export class AgentRunner {
1204
1205
  const providerSessionId = typeof update === "string" ? undefined : update.providerSessionId;
1205
1206
  if (providerSessionId && providerSessionId !== this.providerSessionId) return;
1206
1207
  const reason = typeof update === "string" ? "provider-turn" : update.reason ?? "provider-turn";
1207
- const id = typeof update === "string" ? reason : update.id ?? reason;
1208
+ let id = typeof update === "string" ? reason : update.id ?? reason;
1208
1209
  if (typeof update !== "string" && update.timeline) {
1209
1210
  this.pendingTimelineEvent = {
1210
1211
  status: update.timeline.status,
@@ -1291,13 +1292,19 @@ export class AgentRunner {
1291
1292
  }
1292
1293
  if (status === "busy" && reason === "provider-turn") {
1293
1294
  if (!this.currentTurnId) {
1295
+ // For Claude, `update.id` carries the native prompt_id the UserPromptSubmit
1296
+ // hook reported (relay-status.sh → /status) — the same id the Stop hook and
1297
+ // transcript use for this turn. Falls back to a random mint only when no
1298
+ // native id was supplied (e.g. a provider/path that doesn't report one).
1294
1299
  this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
1295
1300
  this.currentTurnStartedAt = Date.now();
1296
1301
  this.compactionMidTurn = false;
1297
1302
  this.sessionLog(`turn started (turn ${this.currentTurnId})`);
1298
1303
  }
1304
+ id = this.currentTurnId;
1299
1305
  this.busyReconciler.arm();
1300
1306
  } else if (status === "idle" && reason === "provider-turn") {
1307
+ id = typeof update !== "string" && update.id ? update.id : this.currentTurnId ?? reason;
1301
1308
  if (this.currentTurnId) { this.completedProviderTurns += 1; this.sessionLog(`turn ended via provider idle (turn ${this.currentTurnId})`); }
1302
1309
  this.currentTurnId = undefined;
1303
1310
  this.currentTurnStartedAt = undefined;
@@ -1342,7 +1349,8 @@ export class AgentRunner {
1342
1349
  }
1343
1350
  } else if (status === "idle") {
1344
1351
  this.claims.clearTerminalStatus();
1345
- this.claims.finishWork(reason, id);
1352
+ if (reason === "provider-turn") this.claims.clearWorkKind("provider-turn");
1353
+ else this.claims.finishWork(reason, id);
1346
1354
  if (reason === "provider-turn") void this.completeObservedTaskClaims();
1347
1355
  }
1348
1356
  else if (status === "offline" || status === "error") this.claims.setTerminalStatus(status);
@@ -1423,7 +1431,7 @@ export class AgentRunner {
1423
1431
  // isPreFlush: true (#435/#499) — mid-turn PreToolUse boundary. Drain the live
1424
1432
  // tail and force-flush session messages before showing the blocking control;
1425
1433
  // the entry cursor lets Stop capture skip already-emitted text.
1426
- private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void> {
1434
+ private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean }): Promise<void> {
1427
1435
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1428
1436
 
1429
1437
  if (input.isPreFlush) {
@@ -1443,7 +1451,13 @@ export class AgentRunner {
1443
1451
  return;
1444
1452
  }
1445
1453
 
1446
- const turnId = this.currentTurnId;
1454
+ // Claude's Stop hook reports its own native `prompt_id` — the same UUID the
1455
+ // transcript stamps on every entry of this turn (extractAssistantTurnForPrompt /
1456
+ // resolveEntryPromptId in claude-transcript.ts). It's the authoritative turn
1457
+ // identifier; currentTurnId should already equal it (seeded from the same
1458
+ // prompt_id at turn start — see setProviderStatus / handleUserPrompt), but this
1459
+ // prefers the freshest source so finalization is correct even if the two drift.
1460
+ const turnId = input.promptId ?? this.currentTurnId;
1447
1461
  this.stopReasoningTail();
1448
1462
  // Optional correlation for threading + obligation clearing — never a capture gate.
1449
1463
  let replyToMessageId: number | undefined, replyTarget = "user";
@@ -1464,32 +1478,41 @@ export class AgentRunner {
1464
1478
  const flushCount = this.narrativeFlushEntryCount;
1465
1479
  this.narrativeFlushEntryCount = 0;
1466
1480
 
1467
- // The Stop hook can fire before the final assistant entry is flushed to disk.
1468
- // Claude writes thinking and text as separate entries (both with end_turn), so
1469
- // the transcript can "look complete" while the text entry is still pending.
1470
- // Retry until both the transcript has an end_turn AND extraction yields text.
1471
- let body = "";
1472
- let jsonl: string | undefined;
1473
- try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1474
- if (jsonl !== undefined) {
1475
- for (let attempt = 0; attempt < 5; attempt++) {
1476
- if (attempt > 0) {
1477
- await new Promise((r) => setTimeout(r, 100));
1478
- try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { break; }
1479
- }
1480
- if (!transcriptLooksComplete(jsonl)) continue;
1481
- // If a pre-flush already emitted entries 1..flushCount, resume after that mark to avoid double-emit (#435/#499).
1482
- const extract = this.options.providerConfig.chatCaptureMode === "full"
1483
- ? (j: string) => (flushCount > 0 ? extractLastAssistantTurnAfterEntry(j, flushCount) : extractLastAssistantTurn(j))
1484
- : (j: string) => (flushCount > 0 ? extractFinalAssistantMessageAfterEntry(j, flushCount) : extractFinalAssistantMessage(j));
1485
- body = extract(jsonl);
1486
- if (body) break;
1481
+ // PRIMARY capture source: the Stop hook's own last_assistant_message. Claude
1482
+ // reports it synchronously in the hook payload itself, so there is no
1483
+ // transcript file-read race to retry against — this text is authoritative and
1484
+ // complete the moment the hook fires. The transcript is read at most ONCE
1485
+ // (never retried/looped) and only to enrich "full" mode with the turn's
1486
+ // intermediate narration; if that single read comes up empty (the file simply
1487
+ // hasn't caught up yet) the hook payload text still carries the turn.
1488
+ let body = extractHookAssistantMessage(input.lastAssistantMessage);
1489
+ if (this.options.providerConfig.chatCaptureMode === "full" && input.transcriptPath) {
1490
+ let jsonl: string | undefined;
1491
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1492
+ if (jsonl !== undefined && transcriptLooksComplete(jsonl)) {
1493
+ // Deterministic slicing via prompt_id→parentUuid chaining when the native id is
1494
+ // known; only fall back to the legacy "last real user prompt" heuristic when that
1495
+ // deterministic slice comes up EMPTY, not merely when turnId is falsy an old
1496
+ // Claude (<2.1.196) mints a truthy random turnId that never resolves via promptId
1497
+ // chaining, so branching on truthiness alone left this fallback unreachable and
1498
+ // silently dropped intermediate narration in full mode (#1086 review, Finding F1).
1499
+ let full = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
1500
+ if (!full) full = flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl);
1501
+ if (full) body = full;
1487
1502
  }
1488
1503
  }
1489
- // Fallback: last_assistant_message from the Stop hook payload, which bypasses
1490
- // the transcript file race entirely.
1491
- if (!body && input.lastAssistantMessage) {
1492
- body = extractHookAssistantMessage(input.lastAssistantMessage);
1504
+ // Last-resort safety net for the default "final" mode (#1086 review, Finding F2):
1505
+ // last_assistant_message is an undocumented Claude hook field. If a future Claude
1506
+ // version renames or drops it, do ONE (never retried same one-shot discipline as the
1507
+ // "full" mode enrichment above) transcript read rather than silently losing every
1508
+ // capture in the mode nearly every provider config runs in.
1509
+ if (!body && this.options.providerConfig.chatCaptureMode === "final" && input.transcriptPath) {
1510
+ let jsonl: string | undefined;
1511
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1512
+ if (jsonl !== undefined) {
1513
+ const fallback = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
1514
+ body = fallback || (flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl));
1515
+ }
1493
1516
  }
1494
1517
  // A pure tool-use turn with no closing text is fine to skip — its reasoning and
1495
1518
  // tool steps already carried the visibility into chat.
@@ -1499,7 +1522,18 @@ export class AgentRunner {
1499
1522
  }
1500
1523
 
1501
1524
  this.sessionLog(`response captured for turn ${turnId ?? "?"} (${body.length} chars${replyToMessageId ? `, replyTo #${replyToMessageId}` : ", no replyTo"})`);
1502
- await publishCapturedResponse({ publishSessionEvent: (event) => this.publishSessionEvent(event), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: "provider", final: true, ...(turnId ? { turnId } : {}) } });
1525
+ await publishCapturedResponse({
1526
+ publishSessionEvent: (event) => this.publishSessionEvent(event),
1527
+ outbox: this.sessionOutbox,
1528
+ provider: this.options.provider,
1529
+ from: this.agentId,
1530
+ to: replyTarget,
1531
+ body,
1532
+ replyTo: replyToMessageId,
1533
+ // A Stop-hook-driven capture is by construction a definitive, provider-native
1534
+ // turn completion — Claude's own lifecycle hook fired, not a reconstructed guess.
1535
+ session: { type: "response", origin: "provider", final: true, confidence: "definitive", endedBy: "completed", ...(turnId ? { turnId } : {}) },
1536
+ });
1503
1537
  // The agent's reply may have cleared an obligation — refresh the snapshot so the next
1504
1538
  // turn-end doesn't re-prompt for a message already answered (#196).
1505
1539
  if (replyToMessageId) this.obligationCache.markDirty();
@@ -1609,10 +1643,15 @@ export class AgentRunner {
1609
1643
  // it into the dashboard chat so both surfaces stay in sync, and kick off reasoning
1610
1644
  // tailing for the turn. Skips prompts the runner itself injected (chat box, relay
1611
1645
  // deliveries) so those aren't double-posted.
1612
- private async handleUserPrompt(input: { prompt: string; transcriptPath?: string }): Promise<void> {
1646
+ private async handleUserPrompt(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void> {
1613
1647
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1614
1648
  if (!this.currentTurnId) {
1615
- this.currentTurnId = crypto.randomUUID();
1649
+ // Claude's own prompt_id (from the UserPromptSubmit hook payload) is the
1650
+ // native, stable turn identifier — the same id the Stop hook reports and the
1651
+ // transcript stamps on every entry of this turn. Falls back to a random mint
1652
+ // only for the rare case prompt_id wasn't available (see the ZS base schema
1653
+ // note: absent until the very first user input of the process lifetime).
1654
+ this.currentTurnId = input.promptId ?? crypto.randomUUID();
1616
1655
  this.currentTurnStartedAt = Date.now();
1617
1656
  }
1618
1657
  const text = input.prompt.trim();
@@ -1739,6 +1778,14 @@ export class AgentRunner {
1739
1778
  // Dashboard prompt injection is already answered by this captured App Server
1740
1779
  // response. User obligations still belong to the session mirror; spawner
1741
1780
  // obligations (#413) route to the spawn-parent with replyTo to clear the debt.
1781
+ // #1083: a turn-final capture is ALWAYS mirrored, regardless of reply route — an
1782
+ // autonomous Codex turn with no pending prompt and no eligible obligation used to
1783
+ // hit `if (!route) return` here and vanish before ever reaching publishCapturedResponse,
1784
+ // so it was neither shown in the dashboard nor eligible for report-up. The route only
1785
+ // decides whether an explicit reply/obligation-clear also happens; when there's none,
1786
+ // replyToMessageId stays undefined and the capture still lands as a plain session-mirror
1787
+ // response (enqueueCapturedResponseReport's own `!replyTo` gate then correctly skips
1788
+ // report-up — there's no spawner obligation to report up against).
1742
1789
  let replyToMessageId: number | undefined, replyTarget = "user";
1743
1790
  const pendingPrompt = this.pendingPromptMessageId;
1744
1791
  if (pendingPrompt) {
@@ -1746,10 +1793,9 @@ export class AgentRunner {
1746
1793
  this.pendingPromptMessageId = undefined;
1747
1794
  } else {
1748
1795
  const route = responseCaptureReplyRoute(this.obligationCache.get(), this.currentTurnStartedAt, { suppressUser: true });
1749
- if (!route) return; // The session mirror will satisfy the user obligation; don't double-post (#196).
1750
- replyToMessageId = route.replyToMessageId; replyTarget = route.to;
1796
+ if (route) { replyToMessageId = route.replyToMessageId; replyTarget = route.to; }
1751
1797
  }
1752
- await publishCapturedResponse({ publishSessionEvent: (sessionEvent) => this.publishSessionEvent(sessionEvent), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}) } });
1798
+ await publishCapturedResponse({ publishSessionEvent: (sessionEvent) => this.publishSessionEvent(sessionEvent), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}), ...(event.confidence ? { confidence: event.confidence } : {}), ...(event.endedBy ? { endedBy: event.endedBy } : {}) } });
1753
1799
  if (replyToMessageId) this.obligationCache.markDirty();
1754
1800
  return;
1755
1801
  }
@@ -1965,15 +2011,25 @@ export class AgentRunner {
1965
2011
  });
1966
2012
  }
1967
2013
 
2014
+ private isStaleProviderTurnOnlyBusy(
2015
+ status: SemanticStatus,
2016
+ activeWork: Array<{ kind: string }>,
2017
+ busyReasons: string[],
2018
+ liveness: { hasLiveWork: boolean; lastProgressAt: number },
2019
+ ): boolean {
2020
+ if (status !== "busy") return false;
2021
+ if (activeWork.length === 0 || activeWork.some((work) => work.kind !== "provider-turn")) return false;
2022
+ if (busyReasons.some((reason) => reason !== "provider-turn")) return false;
2023
+ if (liveness.hasLiveWork || !liveness.lastProgressAt) return false;
2024
+ return Date.now() - liveness.lastProgressAt >= STALE_PROVIDER_TURN_BUSY_MS;
2025
+ }
2026
+
1968
2027
  private publishStatus(): void {
1969
2028
  this.claims.expire();
1970
2029
  const status = this.claims.currentStatus();
1971
- const agentStatus = runnerAgentStatus(status);
1972
2030
  const activeWork = this.claims.activeWork();
1973
2031
  const activeSubagents = activeWork.filter((item) => item.kind === "subagent");
1974
2032
  const terminalFailure = this.terminalFailure;
1975
- const providerState = terminalFailure?.providerState ?? this.rateLimitHold ?? providerStateFromActiveWork(activeWork);
1976
- this.bus.setSemanticStatus(status === "offline" || status === "error" ? "idle" : status);
1977
2033
  const timelineEvent = this.pendingTimelineEvent;
1978
2034
  this.pendingTimelineEvent = undefined;
1979
2035
  // §4.1 Runner↔provider liveness contract (#725 / #746): fold the raw transport/timeline/
@@ -1989,6 +2045,12 @@ export class AgentRunner {
1989
2045
  const liveness = this.options.adapter.liveness
1990
2046
  ? this.options.adapter.liveness(livenessInputs)
1991
2047
  : computeLivenessSignal(livenessInputs);
2048
+ const busyReasons = this.claims.reasons();
2049
+ const staleProviderTurnBusy = this.isStaleProviderTurnOnlyBusy(status, activeWork, busyReasons, liveness);
2050
+ const effectiveStatus = staleProviderTurnBusy ? "idle" : status;
2051
+ const agentStatus = runnerAgentStatus(effectiveStatus);
2052
+ const providerState = terminalFailure?.providerState ?? this.rateLimitHold ?? (staleProviderTurnBusy ? null : providerStateFromActiveWork(activeWork));
2053
+ this.bus.setSemanticStatus(effectiveStatus === "offline" || effectiveStatus === "error" ? "idle" : effectiveStatus);
1992
2054
  this.bus.status({
1993
2055
  agentStatus,
1994
2056
  ready: agentStatus !== "offline" && !this.stopped,
@@ -2020,7 +2082,7 @@ export class AgentRunner {
2020
2082
  ? { lastError: `Claude provider exited; resume id ${this.terminalProviderExit.claudeResumeId} captured for manual recovery` }
2021
2083
  : { lastError: "Claude provider exited; manual intervention required" }),
2022
2084
  } : {}),
2023
- busyReasons: this.claims.reasons(),
2085
+ busyReasons: staleProviderTurnBusy ? busyReasons.filter((reason) => reason !== "provider-turn") : busyReasons,
2024
2086
  completedProviderTurns: this.completedProviderTurns,
2025
2087
  activeWork,
2026
2088
  activeSubagents,
@@ -5,6 +5,7 @@ import { getManifest } from "agent-relay-providers";
5
5
  import type { BooleanCapabilityDefault, ProviderCapabilityCondition } from "agent-relay-providers";
6
6
  import { targetMatchesAgentIdentity } from "agent-relay-sdk/agent-target";
7
7
  import type { ProviderAdapter, ProviderConfig, RunnerSpawnConfig, SemanticStatus } from "./adapter";
8
+ import type { RunnerRelayInjectionEvent } from "./relay-injection-events";
8
9
  import { agentProfileProjectionReport } from "./profile-projection";
9
10
  import { assembleLaunch } from "./launch-assembly";
10
11
 
@@ -27,6 +28,13 @@ export interface ProbeModelInfo {
27
28
  effort?: string;
28
29
  }
29
30
 
31
+ export interface RestartProviderOptions {
32
+ resumeId?: string;
33
+ suppressPrompt?: boolean;
34
+ systemPromptAppend?: string;
35
+ relayInjectionEvents?: RunnerRelayInjectionEvent[];
36
+ }
37
+
30
38
  export function runnerMessageMatches(message: Pick<Message, "to" | "resolvedToAgent">, target: {
31
39
  agentId: string;
32
40
  label?: string;
@@ -62,6 +70,29 @@ export function stripRunnerClaimedGuidance(body: string): string {
62
70
  return body.replace(RUNNER_CLAIMED_GUIDANCE_RE, "");
63
71
  }
64
72
 
73
+ const RELAY_INJECTION_CATEGORIES = new Set(["memory", "instructions", "tools", "comms", "knowledge", "context"]);
74
+
75
+ function relayInjectionEventsFromParam(value: unknown): RunnerRelayInjectionEvent[] | undefined {
76
+ if (!Array.isArray(value)) return undefined;
77
+ const events = value.flatMap((item): RunnerRelayInjectionEvent[] => {
78
+ if (!item || typeof item !== "object" || Array.isArray(item)) return [];
79
+ const record = item as Record<string, unknown>;
80
+ const detail = record.detail;
81
+ if (typeof record.category !== "string" || !RELAY_INJECTION_CATEGORIES.has(record.category)) return [];
82
+ if (typeof record.summary !== "string" || !record.summary.trim()) return [];
83
+ if (!detail || typeof detail !== "object" || Array.isArray(detail)) return [];
84
+ return [{ category: record.category as RunnerRelayInjectionEvent["category"], summary: record.summary, detail: detail as Record<string, unknown> }];
85
+ });
86
+ return events.length ? events : undefined;
87
+ }
88
+
89
+ export function restartOptionsFromCommand(params: Record<string, unknown>): RestartProviderOptions {
90
+ return {
91
+ systemPromptAppend: typeof params.systemPromptAppend === "string" ? params.systemPromptAppend : undefined,
92
+ relayInjectionEvents: relayInjectionEventsFromParam(params.relayInjectionEvents),
93
+ };
94
+ }
95
+
65
96
  // Bound a runner's INITIAL relay registration. `connect` resolves on the first `registered`
66
97
  // frame and otherwise loops forever, so a token the relay rejects (401 at the WS upgrade)
67
98
  // hangs here silently. Race it against `timeoutMs`; on timeout, surface a fatal reason, tear
@@ -4,6 +4,7 @@ import { LIFECYCLE_STATE_WORKING, childSignalKey, stampLifecycleSignal, taskSign
4
4
  import { deliverableTaskIdsBySpawnRequestId, getAgent, markAgentReadyNotified, recordCrashReport, recordTerminalEvent } from "./db";
5
5
  import { crashReportFor } from "./agent-death-diagnosis";
6
6
  import { renderTemplate } from "./prompt-resolver";
7
+ import { markAgentWorkFinal } from "./services/work-finality";
7
8
  import type { AgentCard, AgentDeathDiagnosis } from "./types";
8
9
 
9
10
  /** One-line cause tag appended to a death notification body, e.g. " · why: Clean exit (exit code 0)". */
@@ -187,6 +188,13 @@ export function notifyAgentOffline(childId: string, reason: string, diagnosis?:
187
188
  const child = getAgent(childId);
188
189
  const parent = child?.spawnedBy;
189
190
  if (!child || !parent) return;
191
+ // #1084 crash-final-loss fix-forward — this is the platform confirming the child is gone for
192
+ // good (past the reconnect/stale grace, or an explicit exit-diagnostics report). A genuine final
193
+ // report captured+held in the ring before the crash must go out now or it is lost forever: no
194
+ // later signal (relay_workspace_ready, task→done) is coming from a process that no longer exists.
195
+ // Idempotent (flushWorkFinalReports no-ops once the ring is empty), so a redundant call from a
196
+ // second offline classification of the same agent is harmless.
197
+ markAgentWorkFinal(childId, "edge-fallback");
190
198
  // Classify from the live set OR the durable readiness stamp. The `delete` always runs (cleanup of
191
199
  // the in-memory set); the persisted stamp covers a child that became ready before a relay restart.
192
200
  const wasReady = readyFired.delete(childId) || everReady(child);
package/src/bus.ts CHANGED
@@ -15,7 +15,6 @@ import { agentProviderTimelineEvent, auditRunnerTimelineEvent } from "./provider
15
15
  import { ServiceAuthError } from "./services/errors";
16
16
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
17
17
  import { flushQueuedDirectMessages } from "./services/managed-running";
18
- import { flushIdleLineageReport } from "./services/send-message";
19
18
  import { isAgentUnavailable } from "./db/agent-predicates";
20
19
  import { applyCommandToRecipe } from "./recipe-runner";
21
20
  import { enqueueContinuationInjectionAfterSelfResume } from "./self-resume";
@@ -106,10 +105,6 @@ export function busHandleClose(ws: BusWebSocket): void {
106
105
  if (!conn.transportReconnect) {
107
106
  setStatus(conn.agentId, "stale", { instanceId: conn.instanceId, epoch: conn.epoch });
108
107
  emitAgentStatusEvent(conn.agentId);
109
- // #1040 — a WS close is a busy→exit edge (the runner crashed/dropped before its stop-hook
110
- // status frame). Flush any held turn-final report-up so it still reaches the spawner instead
111
- // of dying with the socket. No-op when nothing was deferred.
112
- flushIdleLineageReport(conn.agentId);
113
108
  }
114
109
  }
115
110
  }
@@ -134,9 +129,6 @@ export function expireStaleBusAgents(graceMs = busStaleGraceMs(), lagGraceMs = 0
134
129
  // (a fresh instanceId → epoch bump), never on a heartbeat lapse.
135
130
  setStatus(row.id, "offline");
136
131
  emitAgentStatusEvent(row.id);
137
- // #1040 — the stale sweep is the terminal busy→offline edge for a runner that dropped without a
138
- // clean idle transition. Flush any held turn-final report-up before the agent ages out.
139
- flushIdleLineageReport(row.id);
140
132
  const tasks = orphanTasksForAgent(row.id);
141
133
  for (const task of tasks) {
142
134
  orphanedTasks.push(task);
@@ -172,16 +164,6 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
172
164
  heartbeat(conn.agentId, { instanceId: conn.instanceId, epoch: conn.epoch }, runtime);
173
165
  if (frame.payload.status) {
174
166
  setStatus(conn.agentId, frame.payload.status, { instanceId: conn.instanceId, epoch: conn.epoch });
175
- // #1040 — a heartbeat frame can carry the busy→idle transition instead of a `status`
176
- // frame. Flush the held report-up on THIS edge too, else `before.status` is already
177
- // non-busy at the next status frame → the flush never fires → the child deadlocks idle
178
- // with its verdict held and the coordinator waits forever.
179
- // #1037 (re-regression) — gate on a genuine at-rest `idle`, NOT merely "not busy". A
180
- // real turn-end always reports `idle`; a transient non-idle status (a stray `online`,
181
- // a malformed frame) is NOT the end of the child's work and must not flush its held
182
- // mid-work capture upstream. Terminal exit edges (WS-close/stale sweep) keep their
183
- // unconditional flush for the #1040 delivery guarantee — this only tightens the LIVE edge.
184
- if (current?.status === "busy" && frame.payload.status === "idle") flushIdleLineageReport(conn.agentId);
185
167
  }
186
168
  emitAgentStatusEvent(conn.agentId);
187
169
  // #416 — flush queued messages if the agent transitioned from unavailable to available.
@@ -226,12 +208,6 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
226
208
  emitAgentStatusEvent(conn.agentId);
227
209
  // #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
228
210
  probeWorkspaceOnTurnEnd(before, after);
229
- // #1037 — the same busy→idle turn-end edge flushes a quiet child's deferred turn-final
230
- // report-up to its spawner (held back while the child was mid-work). Gate on a genuine
231
- // at-rest `idle`: a real turn-end reports `idle`, whereas a transient non-idle status
232
- // (stray `online`, reconnect) is not the child going to rest and must not flush its held
233
- // mid-work capture. Terminal exit edges keep the #1040 unconditional flush.
234
- if (before?.status === "busy" && after && after.status === "idle") flushIdleLineageReport(conn.agentId);
235
211
  // #633 — silent clean-exit: a runner that stays alive after its provider terminally exited
236
212
  // marks the offline edge with `terminalProviderExit`; the owning service turns that into a
237
213
  // terminal event + #636 diagnosis (and clears the hold on a genuine resume).
package/src/db/agents.ts CHANGED
@@ -566,7 +566,20 @@ export function reapStaleAgents(ttlMs: number = STALE_TTL_MS, lagGraceMs = 0): s
566
566
 
567
567
  export { settleSingleTargetOnDemandTasks } from "./task-maintenance.ts";
568
568
 
569
- export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
569
+ export function pruneOfflineAgents(
570
+ maxOfflineMs: number = DAY_MS,
571
+ opts?: {
572
+ /**
573
+ * #1084 crash-final-loss fix-forward — defense-in-depth for a held work-final ring that
574
+ * somehow survived to prune time (the primary flush is `notifyAgentOffline`, fired when the
575
+ * agent first goes offline). Called per pruned agent id BEFORE its row is deleted, so a
576
+ * caller can flush any still-held final report while `getAgent(id)` can still resolve it —
577
+ * kept a plain callback (not a direct import) so this db-layer module stays free of
578
+ * service/SSE-layer dependencies.
579
+ */
580
+ beforeDelete?: (agentId: string) => void;
581
+ },
582
+ ): string[] {
570
583
  const cutoff = Date.now() - maxOfflineMs;
571
584
  return getDb().transaction(() => {
572
585
  const rows = getDb()
@@ -597,6 +610,7 @@ export function pruneOfflineAgents(maxOfflineMs: number = DAY_MS): string[] {
597
610
 
598
611
  for (const row of rows) {
599
612
  // Offline past the prune horizon and being deleted — a hard kill, not recoverable.
613
+ opts?.beforeDelete?.(row.id);
600
614
  revokeRuntimeTokensForAgent(row.id, now, "pruned");
601
615
  closeOpenPairsForAgent(row.id, now);
602
616
  removeSubscriptionsForAgent(row.id);