agent-relay-server 0.127.2 → 0.127.4

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 (156) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +7 -5
  3. package/public/assets/{MessageBubble-DnXnkt4a.js → MessageBubble-D2Wzxied.js} +2 -2
  4. package/public/assets/{MessageBubble-DnXnkt4a.js.map → MessageBubble-D2Wzxied.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-D6CPEpQD.js → PlanGraphPanel-DH_xoWZi.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-D6CPEpQD.js.map → PlanGraphPanel-DH_xoWZi.js.map} +1 -1
  7. package/public/assets/{activity-ZPwHcPLK.js → activity-B-ozOwNq.js} +2 -2
  8. package/public/assets/{activity-ZPwHcPLK.js.map → activity-B-ozOwNq.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CT0E9LV1.js → agent-profiles-DZgjO25A.js} +2 -2
  10. package/public/assets/{agent-profiles-CT0E9LV1.js.map → agent-profiles-DZgjO25A.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-BmBR-9CH.js → agent-type-icon-B9cLaOoz.js} +2 -2
  12. package/public/assets/{agent-type-icon-BmBR-9CH.js.map → agent-type-icon-B9cLaOoz.js.map} +1 -1
  13. package/public/assets/agents-mXBLSY6O.js +2 -0
  14. package/public/assets/{agents-Dt5TncjC.js.map → agents-mXBLSY6O.js.map} +1 -1
  15. package/public/assets/{analytics-BZtRycyL.js → analytics-5Ize_UL5.js} +3 -3
  16. package/public/assets/{analytics-BZtRycyL.js.map → analytics-5Ize_UL5.js.map} +1 -1
  17. package/public/assets/{automation-yzUJbvT2.js → automation-CidMvW2e.js} +2 -2
  18. package/public/assets/{automation-yzUJbvT2.js.map → automation-CidMvW2e.js.map} +1 -1
  19. package/public/assets/{badge-BCvp7LK3.js → badge-BkNNvFNa.js} +2 -2
  20. package/public/assets/{badge-BCvp7LK3.js.map → badge-BkNNvFNa.js.map} +1 -1
  21. package/public/assets/{branch-state-badge-Cscjqf-E.js → branch-state-badge-DnS40KCb.js} +2 -2
  22. package/public/assets/{branch-state-badge-Cscjqf-E.js.map → branch-state-badge-DnS40KCb.js.map} +1 -1
  23. package/public/assets/{button-DyHjsHY3.js → button-DUaC2wSQ.js} +2 -2
  24. package/public/assets/{button-DyHjsHY3.js.map → button-DUaC2wSQ.js.map} +1 -1
  25. package/public/assets/{card-BoqYxpVW.js → card-r-yAV4cH.js} +2 -2
  26. package/public/assets/{card-BoqYxpVW.js.map → card-r-yAV4cH.js.map} +1 -1
  27. package/public/assets/{channels-DAtOnA6o.js → channels-MPO2FOBv.js} +2 -2
  28. package/public/assets/{channels-DAtOnA6o.js.map → channels-MPO2FOBv.js.map} +1 -1
  29. package/public/assets/chat-Ca4ERso5.js +2 -0
  30. package/public/assets/chat-Ca4ERso5.js.map +1 -0
  31. package/public/assets/{connectors-B2grC-hl.js → connectors-BbYdtRkW.js} +2 -2
  32. package/public/assets/{connectors-B2grC-hl.js.map → connectors-BbYdtRkW.js.map} +1 -1
  33. package/public/assets/copy-button-u463GPZR.js +2 -0
  34. package/public/assets/{copy-button-C67FUG3E.js.map → copy-button-u463GPZR.js.map} +1 -1
  35. package/public/assets/{display-aTnwsqQh.js → display-BNI-7rdq.js} +2 -2
  36. package/public/assets/{display-aTnwsqQh.js.map → display-BNI-7rdq.js.map} +1 -1
  37. package/public/assets/{dist-D5w04z0l.js → dist-5e_is3Xr.js} +2 -2
  38. package/public/assets/{dist-D5w04z0l.js.map → dist-5e_is3Xr.js.map} +1 -1
  39. package/public/assets/{dist-BD4Xc13v.js → dist-ActI2qb5.js} +2 -2
  40. package/public/assets/{dist-BD4Xc13v.js.map → dist-ActI2qb5.js.map} +1 -1
  41. package/public/assets/dist-B-FQ0-X3.js +2 -0
  42. package/public/assets/{dist-G8YBD-wp.js.map → dist-B-FQ0-X3.js.map} +1 -1
  43. package/public/assets/{dist-f6MW74eX.js → dist-BduURKro.js} +2 -2
  44. package/public/assets/{dist-f6MW74eX.js.map → dist-BduURKro.js.map} +1 -1
  45. package/public/assets/{dist-BFDfbfNC.js → dist-Bp0-OBcd.js} +2 -2
  46. package/public/assets/{dist-BFDfbfNC.js.map → dist-Bp0-OBcd.js.map} +1 -1
  47. package/public/assets/{dist-C_VOBSaK.js → dist-CJ7e7uqU.js} +2 -2
  48. package/public/assets/{dist-C_VOBSaK.js.map → dist-CJ7e7uqU.js.map} +1 -1
  49. package/public/assets/{dist-BNB3fTO-.js → dist-CYzOsD_n.js} +2 -2
  50. package/public/assets/{dist-BNB3fTO-.js.map → dist-CYzOsD_n.js.map} +1 -1
  51. package/public/assets/{dist-CQMQ5iku.js → dist-CpcQJrKV.js} +2 -2
  52. package/public/assets/{dist-CQMQ5iku.js.map → dist-CpcQJrKV.js.map} +1 -1
  53. package/public/assets/dist-DFgmDjLv.js +2 -0
  54. package/public/assets/{dist-CHKra2JV.js.map → dist-DFgmDjLv.js.map} +1 -1
  55. package/public/assets/{dist-DG75bvPp.js → dist-DrpSpMKL.js} +2 -2
  56. package/public/assets/{dist-DG75bvPp.js.map → dist-DrpSpMKL.js.map} +1 -1
  57. package/public/assets/dist-DzWoiN2z.js +2 -0
  58. package/public/assets/{dist-BJKxqIQd.js.map → dist-DzWoiN2z.js.map} +1 -1
  59. package/public/assets/{es2015-a6fysEVz.js → es2015-DNuudVtQ.js} +2 -2
  60. package/public/assets/{es2015-a6fysEVz.js.map → es2015-DNuudVtQ.js.map} +1 -1
  61. package/public/assets/formatted-body-BEI1zP7i.js +3 -0
  62. package/public/assets/{formatted-body-D5-1aiyB.js.map → formatted-body-BEI1zP7i.js.map} +1 -1
  63. package/public/assets/{formatted-body-impl-Do5NbJUA.js → formatted-body-impl-CNQ5sqwK.js} +2 -2
  64. package/public/assets/{formatted-body-impl-Do5NbJUA.js.map → formatted-body-impl-CNQ5sqwK.js.map} +1 -1
  65. package/public/assets/index-D2Mpzpft.js +26 -0
  66. package/public/assets/{index-BdNafiPz.js.map → index-D2Mpzpft.js.map} +1 -1
  67. package/public/assets/{input-Rc4oWv4q.js → input-BvNUEkDd.js} +2 -2
  68. package/public/assets/{input-Rc4oWv4q.js.map → input-BvNUEkDd.js.map} +1 -1
  69. package/public/assets/{insights-i6AEZT_a.js → insights-5rkMFRgf.js} +2 -2
  70. package/public/assets/{insights-i6AEZT_a.js.map → insights-5rkMFRgf.js.map} +1 -1
  71. package/public/assets/{integrations-9c6IdRU4.js → integrations-BEaqjfP4.js} +2 -2
  72. package/public/assets/{integrations-9c6IdRU4.js.map → integrations-BEaqjfP4.js.map} +1 -1
  73. package/public/assets/{lib-BZ6WPg2t.js → lib-BvrbNA2I.js} +2 -2
  74. package/public/assets/{lib-BZ6WPg2t.js.map → lib-BvrbNA2I.js.map} +1 -1
  75. package/public/assets/{lucide-react-NXHPIMsX.js → lucide-react-DD_zIava.js} +2 -2
  76. package/public/assets/{lucide-react-NXHPIMsX.js.map → lucide-react-DD_zIava.js.map} +1 -1
  77. package/public/assets/maintenance-CEiURaYZ.js +2 -0
  78. package/public/assets/{maintenance-CJ6wikpg.js.map → maintenance-CEiURaYZ.js.map} +1 -1
  79. package/public/assets/{managed-agents-JZn0xNJR.js → managed-agents-CetHyuQW.js} +4 -4
  80. package/public/assets/{managed-agents-JZn0xNJR.js.map → managed-agents-CetHyuQW.js.map} +1 -1
  81. package/public/assets/{markdown-preview-impl-BsvAkpR4.js → markdown-preview-impl-ZwGYvObh.js} +2 -2
  82. package/public/assets/{markdown-preview-impl-BsvAkpR4.js.map → markdown-preview-impl-ZwGYvObh.js.map} +1 -1
  83. package/public/assets/memory-BTm8XzCg.js +2 -0
  84. package/public/assets/{memory-BE9IwCdC.js.map → memory-BTm8XzCg.js.map} +1 -1
  85. package/public/assets/{messages-vXgdc3H4.js → messages-fJd17pzv.js} +2 -2
  86. package/public/assets/{messages-vXgdc3H4.js.map → messages-fJd17pzv.js.map} +1 -1
  87. package/public/assets/{orchestrators-CribhLg-.js → orchestrators-C5XQ2UYy.js} +2 -2
  88. package/public/assets/{orchestrators-CribhLg-.js.map → orchestrators-C5XQ2UYy.js.map} +1 -1
  89. package/public/assets/overview-Bhk-tqKc.js +2 -0
  90. package/public/assets/{overview-q3O9K9VK.js.map → overview-Bhk-tqKc.js.map} +1 -1
  91. package/public/assets/{pairs-BNOnd0IE.js → pairs-CBrszSZh.js} +2 -2
  92. package/public/assets/{pairs-BNOnd0IE.js.map → pairs-CBrszSZh.js.map} +1 -1
  93. package/public/assets/{projects-iDkxAGBW.js → projects-DXy9JviE.js} +2 -2
  94. package/public/assets/{projects-iDkxAGBW.js.map → projects-DXy9JviE.js.map} +1 -1
  95. package/public/assets/prompt-settings-DEQhAt2X.js +2 -0
  96. package/public/assets/{prompt-settings-Bdz_xCgD.js.map → prompt-settings-DEQhAt2X.js.map} +1 -1
  97. package/public/assets/{react-dom-8yaD9iJG.js → react-dom-CADlISeR.js} +2 -2
  98. package/public/assets/{react-dom-8yaD9iJG.js.map → react-dom-CADlISeR.js.map} +1 -1
  99. package/public/assets/{registry-DPcyGypi.js → registry-DsZILce1.js} +4 -4
  100. package/public/assets/{registry-DPcyGypi.js.map → registry-DsZILce1.js.map} +1 -1
  101. package/public/assets/{security-Z3l3u-Pl.js → security-C-wVZm5k.js} +2 -2
  102. package/public/assets/{security-Z3l3u-Pl.js.map → security-C-wVZm5k.js.map} +1 -1
  103. package/public/assets/{select-BX_lW49D.js → select-DsEGBLJk.js} +2 -2
  104. package/public/assets/{select-BX_lW49D.js.map → select-DsEGBLJk.js.map} +1 -1
  105. package/public/assets/settings-D3_r5IGA.js +6 -0
  106. package/public/assets/{settings-Y381jZt7.js.map → settings-D3_r5IGA.js.map} +1 -1
  107. package/public/assets/{store-D-Frk_EC.js → store-DKTb9O0v.js} +5 -5
  108. package/public/assets/{store-D-Frk_EC.js.map → store-DKTb9O0v.js.map} +1 -1
  109. package/public/assets/{task-chip-CeujtXYT.js → task-chip-DwdauC08.js} +2 -2
  110. package/public/assets/{task-chip-CeujtXYT.js.map → task-chip-DwdauC08.js.map} +1 -1
  111. package/public/assets/{tasks-Di6d0_Ch.js → tasks-DnEzBku4.js} +2 -2
  112. package/public/assets/{tasks-Di6d0_Ch.js.map → tasks-DnEzBku4.js.map} +1 -1
  113. package/public/assets/{teams-DJv4l-_l.js → teams-DpAbZ3ye.js} +2 -2
  114. package/public/assets/{teams-DJv4l-_l.js.map → teams-DpAbZ3ye.js.map} +1 -1
  115. package/public/assets/{terminal-viewer-impl-3zBLpRel.js → terminal-viewer-impl-_IU5o8ya.js} +3 -3
  116. package/public/assets/{terminal-viewer-impl-3zBLpRel.js.map → terminal-viewer-impl-_IU5o8ya.js.map} +1 -1
  117. package/public/assets/types-BqeSkWrv.js.map +1 -1
  118. package/public/assets/{use-keyboard-viewport-cGEmrGE7.js → use-keyboard-viewport-wTh5CR4-.js} +2 -2
  119. package/public/assets/{use-keyboard-viewport-cGEmrGE7.js.map → use-keyboard-viewport-wTh5CR4-.js.map} +1 -1
  120. package/public/assets/user-avatar-CZ3XO8ml.js +2 -0
  121. package/public/assets/{user-avatar-qbo-B3dQ.js.map → user-avatar-CZ3XO8ml.js.map} +1 -1
  122. package/public/assets/{work-queue-D73-hP0c.js → work-queue-DKcLZWRl.js} +2 -2
  123. package/public/assets/{work-queue-D73-hP0c.js.map → work-queue-DKcLZWRl.js.map} +1 -1
  124. package/public/assets/workspaces-Cs2IZZOI.js +3 -0
  125. package/public/assets/{workspaces-CoDz-iZd.js.map → workspaces-Cs2IZZOI.js.map} +1 -1
  126. package/public/index.html +24 -24
  127. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  128. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -1
  129. package/runner/src/adapter.ts +5 -1
  130. package/runner/src/adapters/claude-delivery.ts +5 -1
  131. package/runner/src/adapters/claude-transcript.ts +52 -3
  132. package/runner/src/control-server.ts +34 -17
  133. package/runner/src/runner-core.ts +73 -32
  134. package/scripts/orchestrator-spawn-smoke.ts +81 -15
  135. package/src/bus.ts +6 -41
  136. package/src/db/messages.ts +20 -8
  137. package/src/prompt-resolver.ts +12 -12
  138. package/src/self-resume.ts +50 -8
  139. package/src/services/bus-command-update.ts +59 -0
  140. package/src/services/task-delivery.ts +30 -3
  141. package/public/assets/agents-Dt5TncjC.js +0 -2
  142. package/public/assets/chat-DhYX9BDG.js +0 -2
  143. package/public/assets/chat-DhYX9BDG.js.map +0 -1
  144. package/public/assets/copy-button-C67FUG3E.js +0 -2
  145. package/public/assets/dist-BJKxqIQd.js +0 -2
  146. package/public/assets/dist-CHKra2JV.js +0 -2
  147. package/public/assets/dist-G8YBD-wp.js +0 -2
  148. package/public/assets/formatted-body-D5-1aiyB.js +0 -3
  149. package/public/assets/index-BdNafiPz.js +0 -26
  150. package/public/assets/maintenance-CJ6wikpg.js +0 -2
  151. package/public/assets/memory-BE9IwCdC.js +0 -2
  152. package/public/assets/overview-q3O9K9VK.js +0 -2
  153. package/public/assets/prompt-settings-Bdz_xCgD.js +0 -2
  154. package/public/assets/settings-Y381jZt7.js +0 -6
  155. package/public/assets/user-avatar-qbo-B3dQ.js +0 -2
  156. package/public/assets/workspaces-CoDz-iZd.js +0 -3
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.127.2",
4
+ "version": "0.127.4",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -320,6 +320,9 @@ function shouldShowReplyReminder(deliveryCount) {
320
320
  function latestReplyableMessage(messages) {
321
321
  return messages.filter((message) => isPersistedRelayMessage2(message) && !isMemoryInjection(message) && !isReactionNotification(message) && !isSpawnObligationMessage(message) && message.replyExpected !== false).at(-1);
322
322
  }
323
+ function isSelfResumeInjection(message) {
324
+ return isRecord(message.payload) && message.payload.resumeInjection === true;
325
+ }
323
326
  function formatMessage(message, relaySurface) {
324
327
  const subject = message.subject ? `Subject: ${message.subject}
325
328
  ` : "";
@@ -331,7 +334,7 @@ function formatMessage(message, relaySurface) {
331
334
  `) : undefined;
332
335
  if (isMemoryContext) {
333
336
  return [
334
- `[agent-relay context from ${message.from}]`,
337
+ `[agent-relay ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
335
338
  providerAttachmentText(message),
336
339
  `${subject}${preview.body}`,
337
340
  truncationGuidance
@@ -327,6 +327,10 @@ function isMemoryInjection(message: Message): boolean {
327
327
  return isRecord(message.payload) && message.payload.memoryInjection === true;
328
328
  }
329
329
 
330
+ function isSelfResumeInjection(message: Message): boolean {
331
+ return isRecord(message.payload) && message.payload.resumeInjection === true;
332
+ }
333
+
330
334
  function isReactionNotification(message: Message): boolean {
331
335
  const event = isRecord(message.payload?.event) ? message.payload.event : undefined;
332
336
  return message.payload?.reactionNotification === true || event?.type === "message.reaction";
@@ -434,7 +438,7 @@ export function providerMessageText(messages: Message[]): string {
434
438
  : undefined;
435
439
  if (isMemoryContext) {
436
440
  return [
437
- `[agent-relay context from ${message.from}]`,
441
+ `[agent-relay ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
438
442
  "Guide: agent-relay /guide",
439
443
  providerAttachmentText(message),
440
444
  `${subject}${preview.body}`,
@@ -69,6 +69,10 @@ function latestReplyableMessage(messages: Message[]): Message | undefined {
69
69
  .at(-1);
70
70
  }
71
71
 
72
+ function isSelfResumeInjection(message: Message): boolean {
73
+ return isRecord(message.payload) && message.payload.resumeInjection === true;
74
+ }
75
+
72
76
  function formatMessage(message: Message, relaySurface: boolean): string {
73
77
  const subject = message.subject ? `Subject: ${message.subject}\n` : "";
74
78
  const isMemoryContext = isMemoryInjection(message);
@@ -81,7 +85,7 @@ function formatMessage(message: Message, relaySurface: boolean): string {
81
85
 
82
86
  if (isMemoryContext) {
83
87
  return [
84
- `[agent-relay context from ${message.from}]`,
88
+ `[agent-relay ${isSelfResumeInjection(message) ? "self-resume continuation" : "context"} from ${message.from}]`,
85
89
  providerAttachmentText(message),
86
90
  `${subject}${preview.body}`,
87
91
  truncationGuidance,
@@ -25,6 +25,7 @@ export interface TurnStep {
25
25
  type: "narration" | "reasoning" | "tool";
26
26
  text: string;
27
27
  label?: string;
28
+ occurredAt?: number;
28
29
  }
29
30
 
30
31
  interface TranscriptMessage {
@@ -35,6 +36,7 @@ interface TranscriptMessage {
35
36
 
36
37
  interface TranscriptEntry {
37
38
  type?: string;
39
+ timestamp?: string;
38
40
  message?: TranscriptMessage;
39
41
  // Claude Code stamps every transcript entry with `isSidechain`: true for
40
42
  // entries belonging to a Task (subagent) run, false for the root session.
@@ -225,6 +227,46 @@ export function countTranscriptEntries(jsonl: string): number {
225
227
  return count;
226
228
  }
227
229
 
230
+ /** Key-order-independent structural equality, cheap enough for a small tool_input object. */
231
+ function canonicalJson(value: unknown): string {
232
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
233
+ if (value && typeof value === "object") {
234
+ const keys = Object.keys(value as Record<string, unknown>).sort();
235
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson((value as Record<string, unknown>)[key])}`).join(",")}}`;
236
+ }
237
+ return JSON.stringify(value);
238
+ }
239
+
240
+ /**
241
+ * True when the transcript already contains the exact assistant tool_use block that
242
+ * triggered a PreToolUse hook — the settle-poll anchor (#1116). The hook payload's own
243
+ * `tool_name`/`tool_input` are the precise call that fired it, so matching them against
244
+ * the transcript's tool_use blocks (name + structural input equality) is exact, unlike
245
+ * guessing from transcript shape alone. Claude's JSONL flush is async/bursty and can
246
+ * lag well behind the hook firing, so the entry backing the hook's own call may still be
247
+ * missing from disk the instant the hook runs — this is what the settle-poll waits for.
248
+ * #1086 should adopt this same anchor for its own turn-final settle marker rather than
249
+ * inventing a second one.
250
+ */
251
+ export function transcriptHasToolUseAnchor(jsonl: string, toolName: string, toolInput: unknown): boolean {
252
+ const wantInput = canonicalJson(toolInput ?? {});
253
+ for (const line of jsonl.split("\n")) {
254
+ const trimmed = line.trim();
255
+ if (!trimmed) continue;
256
+ let entry: TranscriptEntry;
257
+ try {
258
+ entry = JSON.parse(trimmed) as TranscriptEntry;
259
+ } catch {
260
+ continue;
261
+ }
262
+ if (entry.type !== "assistant" || isSidechainEntry(entry)) continue;
263
+ for (const block of blocks(entry.message)) {
264
+ if (block.type === "tool_use" && block.name === toolName && canonicalJson(block.input ?? {}) === wantInput) return true;
265
+ }
266
+ }
267
+ return false;
268
+ }
269
+
228
270
  /**
229
271
  * Extract the ordered narration, reasoning, and tool steps for the most recent
230
272
  * turn (since the last real user prompt). Used by the reasoning tailer to stream
@@ -283,18 +325,25 @@ export class LatestTurnStepAccumulator {
283
325
  return;
284
326
  }
285
327
  if (entry.type !== "assistant") return;
328
+ const occurredAt = transcriptEntryOccurredAt(entry);
286
329
  for (const b of blocks(entry.message)) {
287
330
  if (b.type === "text" && typeof b.text === "string" && b.text.trim()) {
288
- this.latestSteps.push({ type: "narration", text: b.text.trim() });
331
+ this.latestSteps.push({ type: "narration", text: b.text.trim(), ...(occurredAt ? { occurredAt } : {}) });
289
332
  } else if (b.type === "thinking" && typeof b.thinking === "string" && b.thinking.trim()) {
290
- this.latestSteps.push({ type: "reasoning", text: b.thinking.trim() });
333
+ this.latestSteps.push({ type: "reasoning", text: b.thinking.trim(), ...(occurredAt ? { occurredAt } : {}) });
291
334
  } else if (b.type === "tool_use" && typeof b.name === "string" && b.name) {
292
- this.latestSteps.push({ type: "tool", label: b.name, text: summarizeToolUse(b.name, b.input) });
335
+ this.latestSteps.push({ type: "tool", label: b.name, text: summarizeToolUse(b.name, b.input), ...(occurredAt ? { occurredAt } : {}) });
293
336
  }
294
337
  }
295
338
  }
296
339
  }
297
340
 
341
+ function transcriptEntryOccurredAt(entry: TranscriptEntry): number | undefined {
342
+ if (typeof entry.timestamp !== "string" || !entry.timestamp) return undefined;
343
+ const parsed = Date.parse(entry.timestamp);
344
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
345
+ }
346
+
298
347
  /**
299
348
  * Stable dedup keys for a turn's steps, in order. Each key is salted with how many
300
349
  * identical (type,label,text) steps preceded it in the same window — so running the
@@ -49,9 +49,13 @@ interface ControlServerOptions {
49
49
  // dashboard chat without the agent re-emitting it via /reply.
50
50
  // isPreFlush: true signals a mid-turn call from a PreToolUse hook — flush any
51
51
  // un-mirrored narrative before the interactive form appears in chat (#435).
52
+ // toolName/toolInput (#1116): the hook payload's own tool call — the exact anchor the
53
+ // runner settle-polls the transcript for before draining and returning. The resolved
54
+ // `reasoningSettled` reflects whether that anchor was actually found (vs a bounded
55
+ // timeout), so the interactive form can stamp an honest value instead of an assumed one.
52
56
  // promptId (#1086): Claude's native `prompt_id` from the Stop hook payload — the
53
57
  // stable, provider-native turn identifier used instead of a minted random UUID.
54
- onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean }): Promise<void>;
58
+ onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void>;
55
59
  onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
56
60
  // A provider UserPromptSubmit hook hands over the prompt the human typed
57
61
  // directly into the session (web terminal / TUI) so the runner can mirror it
@@ -84,6 +88,8 @@ export interface ResolvedPermissionPrompt {
84
88
  approvalId: string;
85
89
  decision: ProviderPermissionDecisionInput["decision"];
86
90
  decisionLabel: string;
91
+ occurredAt: number;
92
+ resolvedAt: number;
87
93
  questions?: unknown[];
88
94
  answers?: Record<string, string>;
89
95
  }
@@ -264,18 +270,27 @@ async function handlePermissionRequest(
264
270
  options: ControlServerOptions,
265
271
  pendingPermissionRequests: Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>,
266
272
  ): Promise<Response> {
273
+ const occurredAt = Date.now();
267
274
  const body = await req.json().catch(() => null);
268
275
  if (!isRecord(body)) return Response.json({});
269
- // #435: flush any un-mirrored narrative from the transcript before the form
270
- // appears in chat the transcript already has the assistant text block at
271
- // PreToolUse time, but the Stop hook (which normally captures it) won't fire
272
- // until after the user answers, so the form would otherwise arrive first.
276
+ // #435/#1116: flush any un-mirrored narrative from the transcript before the form
277
+ // appears in chat. The old comment here asserted the transcript already had the
278
+ // assistant text block at PreToolUse time empirically false: Claude's JSONL flush is
279
+ // async/bursty and can lag the hook firing by a long margin. onSessionTurn now
280
+ // settle-polls for the exact tool_use entry that triggered this hook (toolName/
281
+ // toolInput below are that anchor) before draining, and honestly reports back whether
282
+ // it found the anchor or hit its bounded timeout — reasoningSettled reflects that,
283
+ // rather than an assumption nothing here confirmed.
273
284
  const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
285
+ const toolName = typeof body.tool_name === "string" ? body.tool_name : undefined;
286
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : undefined;
287
+ let reasoningSettled = true;
274
288
  if (transcriptPath && options.onSessionTurn) {
275
- await options.onSessionTurn({ transcriptPath, isPreFlush: true }).catch(() => {});
289
+ const result = await options.onSessionTurn({ transcriptPath, isPreFlush: true, toolName, toolInput }).catch(() => undefined);
290
+ if (result && typeof result.reasoningSettled === "boolean") reasoningSettled = result.reasoningSettled;
276
291
  }
277
292
  const approvalId = crypto.randomUUID();
278
- const view = claudePermissionApprovalView(approvalId, body);
293
+ const view = claudePermissionApprovalView(approvalId, body, reasoningSettled);
279
294
  options.onStatus({
280
295
  status: "busy",
281
296
  reason: "provider-turn",
@@ -299,18 +314,18 @@ async function handlePermissionRequest(
299
314
  });
300
315
 
301
316
  if (decision.reason !== PERMISSION_TIMEOUT_REASON && options.onPermissionResolved) {
302
- await Promise.resolve(options.onPermissionResolved(claudeResolvedPermissionPrompt(view, decision, body))).catch(() => {});
317
+ await Promise.resolve(options.onPermissionResolved(claudeResolvedPermissionPrompt(view, decision, body, occurredAt))).catch(() => {});
303
318
  }
304
319
 
305
320
  return Response.json(claudePermissionHookResponse(decision, body));
306
321
  }
307
322
 
308
323
  // Build the normalized InteractivePrompt for a Claude PreToolUse permission/question hook.
309
- // reasoningSettled is stamped `true` because handlePermissionRequest pre-flushes the
310
- // transcript narrative (onSessionTurn isPreFlush) BEFORE this view is built and emitted, so
311
- // the turn's reasoning is already mirrored ahead of the form (#435/#723). No `provider` tag —
312
- // the server reads the typed contract, not provider identity.
313
- function claudePermissionApprovalView(id: string, body: Record<string, unknown>): InteractivePrompt {
324
+ // reasoningSettled is the caller's settle-poll result (#435/#723/#1116): true when
325
+ // handlePermissionRequest's pre-flush confirmed the turn's reasoning was drained before
326
+ // this view was built, false when it hit its bounded timeout without confirming. No
327
+ // `provider` tag — the server reads the typed contract, not provider identity.
328
+ function claudePermissionApprovalView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt {
314
329
  const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
315
330
  const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
316
331
  // AskUserQuestion is not a yes/no gate — it asks the user to pick answers.
@@ -326,7 +341,7 @@ function claudePermissionApprovalView(id: string, body: Record<string, unknown>)
326
341
  body: "",
327
342
  questions: toolInput.questions as InteractivePrompt["questions"],
328
343
  choices: [],
329
- reasoningSettled: true,
344
+ reasoningSettled,
330
345
  };
331
346
  }
332
347
  // ExitPlanMode arrives through the generic PermissionRequest hook (it doesn't
@@ -347,7 +362,7 @@ function claudePermissionApprovalView(id: string, body: Record<string, unknown>)
347
362
  { id: "approve", label: "Approve plan" },
348
363
  { id: "deny", label: "Keep planning" },
349
364
  ],
350
- reasoningSettled: true,
365
+ reasoningSettled,
351
366
  };
352
367
  }
353
368
  const command = typeof toolInput.command === "string" ? toolInput.command : "";
@@ -368,7 +383,7 @@ function claudePermissionApprovalView(id: string, body: Record<string, unknown>)
368
383
  { id: "deny", label: "Deny" },
369
384
  { id: "abort", label: "Abort" },
370
385
  ],
371
- reasoningSettled: true,
386
+ reasoningSettled,
372
387
  };
373
388
  }
374
389
 
@@ -437,7 +452,7 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
437
452
  };
438
453
  }
439
454
 
440
- function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): ResolvedPermissionPrompt {
455
+ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ResolvedPermissionPrompt {
441
456
  const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
442
457
  const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
443
458
  const kind = view.kind === "questions" || view.kind === "plan" || view.kind === "command" || view.kind === "tool"
@@ -459,6 +474,8 @@ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: Provi
459
474
  approvalId: decision.approvalId,
460
475
  decision: decision.decision,
461
476
  decisionLabel,
477
+ occurredAt,
478
+ resolvedAt: Date.now(),
462
479
  ...(questions ? { questions } : {}),
463
480
  ...(decision.answers ? { answers: decision.answers } : {}),
464
481
  };
@@ -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, extractAssistantTurnForPrompt, countTranscriptEntries, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
19
+ import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, extractAssistantTurnForPrompt, countTranscriptEntries, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptHasToolUseAnchor, 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";
@@ -153,6 +153,13 @@ const MAX_INITIAL_PROMPT_ATTEMPTS = 6;
153
153
  // transcript bytes.
154
154
  const REASONING_POLL_MS = 1_200;
155
155
  const SESSION_MIRROR_BATCH_MAX = 100;
156
+ // #1116: bounded settle-poll for the PreToolUse pre-flush anchor. Claude's JSONL flush is
157
+ // async/bursty and can lag the hook firing by a long margin, so a one-shot read (the old
158
+ // #435/#499 behavior) often finds nothing. Poll at this cadence until the exact triggering
159
+ // tool_use entry lands on disk, but never past the timeout — the question must never block
160
+ // the user more than a few seconds waiting on a transcript write that might not be coming.
161
+ const SETTLE_POLL_INTERVAL_MS = 250;
162
+ const SETTLE_POLL_TIMEOUT_MS = 4_000;
156
163
  interface RunnerTimelineEvent {
157
164
  status: string;
158
165
  id?: string;
@@ -285,6 +292,10 @@ export class AgentRunner {
285
292
  // Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
286
293
  // from the Claude transcript into chat as discreet session events.
287
294
  private reasoningTail?: ReasoningTailState;
295
+ // Settle-poll cadence for the pre-flush anchor (#1116) — instance fields (not just the
296
+ // module constants) so tests can shrink them without waiting out the real bound.
297
+ private settlePollIntervalMs = SETTLE_POLL_INTERVAL_MS;
298
+ private settlePollTimeoutMs = SETTLE_POLL_TIMEOUT_MS;
288
299
  private scratch?: SessionScratchLayout;
289
300
  // #417: for non-claude providers, tracks how many times each spawner obligation has
290
301
  // been re-injected so a non-responsive agent can't spin in a re-injection loop.
@@ -424,7 +435,7 @@ export class AgentRunner {
424
435
  // Hot-path-safe: answered instantly from the local snapshot, never a server
425
436
  // round-trip. The snapshot is kept warm by the background refresh below (#196).
426
437
  onReplyObligations: () => Promise.resolve(this.obligationCache.get()),
427
- onSessionTurn: (input) => this.publishSessionTurn(input), onPermissionResolved: (input) => this.publishSessionEvent({ from: this.agentId, to: "user", body: input.body, session: { type: "prompt-resolution", origin: "provider", label: input.decisionLabel, ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}), stepId: `prompt-resolution:${input.approvalId}`, promptResolution: input } }),
438
+ onSessionTurn: (input) => this.publishSessionTurn(input), onPermissionResolved: (input) => this.publishSessionEvent({ from: this.agentId, to: "user", body: input.body, occurredAt: input.occurredAt, session: { type: "prompt-resolution", origin: "provider", label: input.decisionLabel, ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}), stepId: `prompt-resolution:${input.approvalId}`, promptResolution: input } }),
428
439
  onUserPrompt: (input) => this.handleUserPrompt(input),
429
440
  onSessionBoundary: (input) => this.handleSessionBoundary(input),
430
441
  onHookFatal: (report) => this.reportHookFatal(report),
@@ -842,17 +853,24 @@ export class AgentRunner {
842
853
  const content = typeof params.content === "string" ? params.content.trim() : "";
843
854
  if (!content) throw new Error("content required");
844
855
  const memoryIds = Array.isArray(params.memoryIds) ? params.memoryIds.filter((id): id is string => typeof id === "string") : [];
856
+ const reason = typeof params.reason === "string" ? params.reason : undefined;
857
+ const selfResume = params.selfResume !== null && typeof params.selfResume === "object" && !Array.isArray(params.selfResume)
858
+ ? params.selfResume as Record<string, unknown>
859
+ : undefined;
860
+ const isSelfResumeContinuation = reason === "self-resume-continuation" || Boolean(selfResume);
845
861
  await this.options.adapter.deliver(this.process!, [{
846
862
  id: 0,
847
863
  from: "system",
848
864
  to: this.agentId,
849
865
  kind: "system",
850
- subject: "Agent Relay memory context",
866
+ subject: isSelfResumeContinuation ? "Agent Relay self-resume continuation" : "Agent Relay memory context",
851
867
  body: content,
852
868
  payload: {
853
869
  memoryInjection: true,
854
- reason: typeof params.reason === "string" ? params.reason : undefined,
870
+ reason,
855
871
  memoryIds,
872
+ ...(isSelfResumeContinuation ? { resumeInjection: true } : {}),
873
+ ...(selfResume ? { selfResume } : {}),
856
874
  },
857
875
  readBy: [],
858
876
  createdAt: Date.now(),
@@ -1428,17 +1446,21 @@ export class AgentRunner {
1428
1446
  // no relay message) are mirrored too. A reply obligation, when present, is still
1429
1447
  // used as replyTo so the Stop hook stops nagging the agent to /reply.
1430
1448
  //
1431
- // isPreFlush: true (#435/#499) — mid-turn PreToolUse boundary. Drain the live
1432
- // tail and force-flush session messages before showing the blocking control;
1433
- // the entry cursor lets Stop capture skip already-emitted text.
1434
- private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean }): Promise<void> {
1449
+ // isPreFlush: true (#435/#499/#1116) — mid-turn PreToolUse boundary. Settle-polls the
1450
+ // transcript for the exact tool_use entry that triggered the hook (toolName/toolInput,
1451
+ // the hook payload's own anchor), then drains the live tail and force-flushes session
1452
+ // messages before showing the blocking control; the entry cursor lets Stop capture skip
1453
+ // already-emitted text. Returns whether the settle-poll actually found the anchor
1454
+ // (reasoningSettled) — an honest `false` on timeout rather than a stamped `true` on a
1455
+ // premise nothing here confirmed (#1116; see InteractivePrompt.reasoningSettled).
1456
+ private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void> {
1435
1457
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1436
1458
 
1437
1459
  if (input.isPreFlush) {
1438
- if (!input.transcriptPath) return;
1439
- let jsonl: string | undefined;
1440
- try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { return; }
1441
- if (!jsonl) return;
1460
+ if (!input.transcriptPath) return { reasoningSettled: true };
1461
+ const settled = await this.settlePollForToolUse(input.transcriptPath, input.toolName, input.toolInput);
1462
+ const jsonl = settled.jsonl;
1463
+ if (jsonl === undefined) return { reasoningSettled: false };
1442
1464
  const body = extractLastAssistantTurnAfterEntry(jsonl, this.narrativeFlushEntryCount);
1443
1465
  await this.drainReasoningTail();
1444
1466
  this.narrativeFlushEntryCount = countTranscriptEntries(jsonl);
@@ -1448,7 +1470,7 @@ export class AgentRunner {
1448
1470
  await this.publishSessionEvent({ from: this.agentId, to: "user", body, session: { type: "response", origin: "provider", ...(turnId ? { turnId } : {}) } });
1449
1471
  }
1450
1472
  await this.sessionOutbox.flush(2_000).then((ok) => { if (!ok) this.sessionLog(`pre-flush session outbox incomplete before blocking control (${this.sessionOutbox.pendingCount()} pending)`); }, (error) => this.sessionLog(`pre-flush session outbox failed before blocking control: ${errMessage(error)}`));
1451
- return;
1473
+ return { reasoningSettled: settled.settled };
1452
1474
  }
1453
1475
 
1454
1476
  // Claude's Stop hook reports its own native `prompt_id` — the same UUID the
@@ -1458,6 +1480,11 @@ export class AgentRunner {
1458
1480
  // prompt_id at turn start — see setProviderStatus / handleUserPrompt), but this
1459
1481
  // prefers the freshest source so finalization is correct even if the two drift.
1460
1482
  const turnId = input.promptId ?? this.currentTurnId;
1483
+ // #1116: drain whatever the tail hasn't polled yet BEFORE stopping it — stopping first
1484
+ // (the old order) clears the poll/timer state, so any steps Claude flushed in the last
1485
+ // window between the tail's previous tick and this Stop hook were silently dropped from
1486
+ // the step lane instead of reaching chat.
1487
+ await this.drainReasoningTail();
1461
1488
  this.stopReasoningTail();
1462
1489
  // Optional correlation for threading + obligation clearing — never a capture gate.
1463
1490
  let replyToMessageId: number | undefined, replyTarget = "user";
@@ -1543,22 +1570,18 @@ export class AgentRunner {
1543
1570
  // tool step) as a `kind: "session"` relay message tagged with payload.session so
1544
1571
  // the dashboard can render the live provider session faithfully. Display-only:
1545
1572
  // session messages are never delivered back into a provider.
1546
- private publishSessionEvent(input: {
1547
- from: string;
1548
- to: string;
1549
- body: string;
1550
- session: MessageSessionMeta;
1551
- replyTo?: number;
1552
- }): void {
1553
- // Durable, ordered, timestamped (#196): the actual POST happens in deliverOutboxEvent,
1554
- // retried until it lands. occurredAt is stamped now so a queued event reports when it
1555
- // truly happened, not when the server finally accepted it. Routed through the fast-lane
1556
- // sessionOutbox (#332) so a transient trace failure can't head-of-line block real messages.
1573
+ private publishSessionEvent(input: { from: string; to: string; body: string; session: MessageSessionMeta; replyTo?: number; occurredAt?: number }): void {
1574
+ // Durable, ordered, timestamped (#196/#1115): the actual POST happens in
1575
+ // deliverOutboxEvent, retried until it lands. An explicit occurredAt preserves the
1576
+ // provider-native event time; otherwise the outbox stamps enqueue time. Routed through
1577
+ // the fast-lane sessionOutbox (#332) so a transient trace failure can't head-of-line
1578
+ // block real messages.
1557
1579
  // A stepId-bearing step (Codex tool running→completed, streamed reasoning/response) uses a
1558
1580
  // STABLE idempotency key so the server upserts the row in place instead of appending a dup.
1559
1581
  const stepId = input.session.stepId;
1560
1582
  this.sessionOutbox.enqueue({
1561
1583
  kind: "session-message",
1584
+ ...(input.occurredAt ? { occurredAt: input.occurredAt } : {}),
1562
1585
  ...(stepId ? { idempotencyKey: `session-step:${input.from}:${input.session.turnId ?? ""}:${stepId}` } : {}),
1563
1586
  payload: {
1564
1587
  from: input.from,
@@ -1571,13 +1594,7 @@ export class AgentRunner {
1571
1594
  });
1572
1595
  }
1573
1596
 
1574
- private publishSessionEventsBatch(inputs: Array<{
1575
- from: string;
1576
- to: string;
1577
- body: string;
1578
- session: MessageSessionMeta;
1579
- replyTo?: number;
1580
- }>): void {
1597
+ private publishSessionEventsBatch(inputs: Array<{ from: string; to: string; body: string; session: MessageSessionMeta; replyTo?: number; occurredAt?: number }>): void {
1581
1598
  for (let i = 0; i < inputs.length; i += SESSION_MIRROR_BATCH_MAX) {
1582
1599
  const chunk = inputs.slice(i, i + SESSION_MIRROR_BATCH_MAX);
1583
1600
  if (chunk.length === 1) {
@@ -1592,6 +1609,7 @@ export class AgentRunner {
1592
1609
  ...(input.replyTo ? { replyTo: input.replyTo } : {}),
1593
1610
  kind: "session",
1594
1611
  body: input.body,
1612
+ ...(input.occurredAt ? { occurredAt: input.occurredAt } : {}),
1595
1613
  ...(stepId ? { idempotencyKey: `session-step:${input.from}:${input.session.turnId ?? ""}:${stepId}` } : {}),
1596
1614
  payload: { session: { provider: this.options.provider, ...input.session } },
1597
1615
  } satisfies SendMessageInput;
@@ -1933,7 +1951,7 @@ export class AgentRunner {
1933
1951
  }
1934
1952
  const turnId = this.currentTurnId ?? turnIdAtStart;
1935
1953
  let emitted = 0;
1936
- const batch: Array<{ from: string; to: string; body: string; session: MessageSessionMeta }> = [];
1954
+ const batch: Array<{ from: string; to: string; body: string; session: MessageSessionMeta; occurredAt?: number }> = [];
1937
1955
  for (const { sig, step } of keyed) {
1938
1956
  if (seen.has(sig)) continue;
1939
1957
  seen.add(sig);
@@ -1942,6 +1960,7 @@ export class AgentRunner {
1942
1960
  from: this.agentId,
1943
1961
  to: "user",
1944
1962
  body: step.text,
1963
+ ...(step.occurredAt ? { occurredAt: step.occurredAt } : {}),
1945
1964
  session: { type: step.type, origin: "provider", ...(turnId ? { turnId } : {}), ...(step.label ? { label: step.label } : {}) },
1946
1965
  });
1947
1966
  if (step.type === "narration") emittedNarrationKeys.add(sig);
@@ -1964,6 +1983,28 @@ export class AgentRunner {
1964
1983
  await this.reasoningTail?.poll();
1965
1984
  }
1966
1985
 
1986
+ // #1116: bounded settle-poll for the PreToolUse pre-flush anchor. The old pre-flush read
1987
+ // the transcript exactly once and, on the false premise that Claude's JSONL write is
1988
+ // synchronous with the hook firing, treated whatever it found (often nothing) as complete.
1989
+ // Claude's flush is actually async/bursty — sometimes gated on the hook's own resolution —
1990
+ // so this polls at settlePollIntervalMs until the transcript contains the EXACT tool_use
1991
+ // entry that triggered the hook (toolName/toolInput from the hook payload is the precise
1992
+ // anchor), then returns the freshest read. Never polls past settlePollTimeoutMs — the
1993
+ // question must ship regardless, just honestly flagged as `settled: false` when it does.
1994
+ // No toolName (a caller that can't identify an anchor) skips straight to a single read,
1995
+ // matching the old one-shot behavior.
1996
+ private async settlePollForToolUse(transcriptPath: string, toolName?: string, toolInput?: unknown): Promise<{ settled: boolean; jsonl?: string }> {
1997
+ const deadline = Date.now() + this.settlePollTimeoutMs;
1998
+ for (;;) {
1999
+ let jsonl: string | undefined;
2000
+ try { jsonl = await readFile(transcriptPath, "utf8"); } catch { jsonl = undefined; }
2001
+ if (!toolName) return { settled: Boolean(jsonl), jsonl };
2002
+ if (jsonl !== undefined && transcriptHasToolUseAnchor(jsonl, toolName, toolInput)) return { settled: true, jsonl };
2003
+ if (Date.now() >= deadline) return { settled: false, jsonl };
2004
+ await Bun.sleep(this.settlePollIntervalMs);
2005
+ }
2006
+ }
2007
+
1967
2008
  private preFlushBodyAlreadyMirroredByReasoningTail(jsonl: string, body: string): boolean {
1968
2009
  const tail = this.reasoningTail;
1969
2010
  if (!tail || !tail.emittedNarrationKeys.size) return false;
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { hostname } from "node:os";
4
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
4
5
  import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
5
6
 
6
7
  type Orchestrator = {
@@ -33,14 +34,16 @@ let cwd = process.env.AGENT_RELAY_SMOKE_CWD || "";
33
34
  let timeoutMs = Number(process.env.AGENT_RELAY_SMOKE_TIMEOUT_MS || 90_000);
34
35
  let providers = (process.env.AGENT_RELAY_SMOKE_PROVIDERS || "codex,claude").split(",").filter(Boolean);
35
36
 
36
- for (let i = 0; i < args.length; i++) {
37
- const arg = args[i]!;
38
- if (arg === "--relay-url" && args[i + 1]) relayUrl = args[++i]!;
39
- else if (arg === "--orchestrator" && args[i + 1]) orchestratorId = args[++i]!;
40
- else if (arg === "--cwd" && args[i + 1]) cwd = args[++i]!;
41
- else if (arg === "--timeout" && args[i + 1]) timeoutMs = Number(args[++i]);
42
- else if (arg === "--providers" && args[i + 1]) providers = args[++i]!.split(",").map((p) => p.trim()).filter(Boolean);
43
- else throw new Error(`Unknown option: ${arg}`);
37
+ function parseArgs(): void {
38
+ for (let i = 0; i < args.length; i++) {
39
+ const arg = args[i]!;
40
+ if (arg === "--relay-url" && args[i + 1]) relayUrl = args[++i]!;
41
+ else if (arg === "--orchestrator" && args[i + 1]) orchestratorId = args[++i]!;
42
+ else if (arg === "--cwd" && args[i + 1]) cwd = args[++i]!;
43
+ else if (arg === "--timeout" && args[i + 1]) timeoutMs = Number(args[++i]);
44
+ else if (arg === "--providers" && args[i + 1]) providers = args[++i]!.split(",").map((p) => p.trim()).filter(Boolean);
45
+ else throw new Error(`Unknown option: ${arg}`);
46
+ }
44
47
  }
45
48
 
46
49
  relayUrl = relayUrl.replace(/\/+$/, "");
@@ -92,6 +95,68 @@ function localRepoRoot(): string | null {
92
95
  return result.stdout.trim() || null;
93
96
  }
94
97
 
98
+ function localRepoName(): string | null {
99
+ const result = spawnSync("git", ["config", "--get", "remote.origin.url"], {
100
+ cwd: process.cwd(),
101
+ encoding: "utf8",
102
+ });
103
+ if (result.status !== 0) return null;
104
+ const remote = result.stdout.trim();
105
+ if (!remote) return null;
106
+ const name = basename(remote.replace(/\.git$/, ""));
107
+ return name || null;
108
+ }
109
+
110
+ function pathContainedBy(path: string, parent: string): boolean {
111
+ const rel = relative(resolve(parent), resolve(path));
112
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
113
+ }
114
+
115
+ function isGitRepo(path: string): boolean {
116
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], {
117
+ cwd: path,
118
+ encoding: "utf8",
119
+ });
120
+ return result.status === 0;
121
+ }
122
+
123
+ export function resolveDefaultSpawnCwd(
124
+ orchestrator: Pick<Orchestrator, "id" | "baseDir">,
125
+ deps: {
126
+ hostname?: () => string;
127
+ localRepoRoot?: () => string | null;
128
+ localRepoName?: () => string | null;
129
+ isGitRepo?: (path: string) => boolean;
130
+ } = {},
131
+ ): string {
132
+ const host = deps.hostname ?? hostname;
133
+ if (orchestrator.id !== host()) {
134
+ throw new Error(`orchestrator ${orchestrator.id} is remote; pass --cwd or set AGENT_RELAY_SMOKE_CWD to a git repo on that host`);
135
+ }
136
+ if (!orchestrator.baseDir) {
137
+ throw new Error(`orchestrator ${orchestrator.id} did not report a baseDir; pass --cwd or set AGENT_RELAY_SMOKE_CWD`);
138
+ }
139
+
140
+ const gitRoot = (deps.localRepoRoot ?? localRepoRoot)();
141
+ if (gitRoot && pathContainedBy(gitRoot, orchestrator.baseDir)) return gitRoot;
142
+
143
+ const repoName = (deps.localRepoName ?? localRepoName)();
144
+ const gitCheck = deps.isGitRepo ?? isGitRepo;
145
+ const candidates = [
146
+ repoName ? join(orchestrator.baseDir, "oss", repoName) : "",
147
+ repoName ? join(orchestrator.baseDir, repoName) : "",
148
+ orchestrator.baseDir,
149
+ ].filter(Boolean);
150
+ for (const candidate of candidates) {
151
+ if (pathContainedBy(candidate, orchestrator.baseDir) && gitCheck(candidate)) return candidate;
152
+ }
153
+
154
+ throw new Error(
155
+ `could not resolve a git repo inside orchestrator ${orchestrator.id} base dir ${orchestrator.baseDir}; ` +
156
+ "pass --cwd or set AGENT_RELAY_SMOKE_CWD",
157
+ );
158
+ }
159
+
95
160
  function findSpawnedAgent(agents: Agent[], provider: string, label: string, startedAt: number): Agent | null {
96
161
  return agents
97
162
  .filter((agent) => agent.ready && agent.status !== "offline")
@@ -127,6 +192,8 @@ function promptForCase(provider: string, approvalMode: ApprovalMode, label: stri
127
192
  };
128
193
  }
129
194
 
195
+ async function main(): Promise<void> {
196
+ parseArgs();
130
197
  const orchestrators = await api<Orchestrator[]>("GET", "/orchestrators");
131
198
  // Default selection prefers the orchestrator on the same host as the relay (the
132
199
  // gate runs there): a local spawn is faster and avoids cross-host teardown
@@ -146,13 +213,7 @@ if (orchestrator.status !== "online") throw new Error(`orchestrator ${orchestrat
146
213
  // An explicit --cwd / env override still wins (and is subject to the
147
214
  // orchestrator's containment check).
148
215
  if (!cwd) {
149
- if (orchestrator.id !== hostname()) {
150
- throw new Error(`orchestrator ${orchestrator.id} is remote; pass --cwd or set AGENT_RELAY_SMOKE_CWD to a git repo on that host`);
151
- }
152
- cwd = localRepoRoot() || "";
153
- if (!cwd) {
154
- throw new Error("could not resolve the local git repo root; pass --cwd or set AGENT_RELAY_SMOKE_CWD");
155
- }
216
+ cwd = resolveDefaultSpawnCwd(orchestrator);
156
217
  }
157
218
  console.log(`using cwd ${cwd} on ${orchestrator.id} (base ${orchestrator.baseDir})`);
158
219
 
@@ -239,3 +300,8 @@ for (const provider of providers) {
239
300
  }
240
301
 
241
302
  console.log("spawn smoke passed");
303
+ }
304
+
305
+ if (import.meta.main) {
306
+ await main();
307
+ }