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.
- package/docs/openapi.json +1 -1
- package/package.json +7 -5
- package/public/assets/{MessageBubble-DnXnkt4a.js → MessageBubble-D2Wzxied.js} +2 -2
- package/public/assets/{MessageBubble-DnXnkt4a.js.map → MessageBubble-D2Wzxied.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-D6CPEpQD.js → PlanGraphPanel-DH_xoWZi.js} +2 -2
- package/public/assets/{PlanGraphPanel-D6CPEpQD.js.map → PlanGraphPanel-DH_xoWZi.js.map} +1 -1
- package/public/assets/{activity-ZPwHcPLK.js → activity-B-ozOwNq.js} +2 -2
- package/public/assets/{activity-ZPwHcPLK.js.map → activity-B-ozOwNq.js.map} +1 -1
- package/public/assets/{agent-profiles-CT0E9LV1.js → agent-profiles-DZgjO25A.js} +2 -2
- package/public/assets/{agent-profiles-CT0E9LV1.js.map → agent-profiles-DZgjO25A.js.map} +1 -1
- package/public/assets/{agent-type-icon-BmBR-9CH.js → agent-type-icon-B9cLaOoz.js} +2 -2
- package/public/assets/{agent-type-icon-BmBR-9CH.js.map → agent-type-icon-B9cLaOoz.js.map} +1 -1
- package/public/assets/agents-mXBLSY6O.js +2 -0
- package/public/assets/{agents-Dt5TncjC.js.map → agents-mXBLSY6O.js.map} +1 -1
- package/public/assets/{analytics-BZtRycyL.js → analytics-5Ize_UL5.js} +3 -3
- package/public/assets/{analytics-BZtRycyL.js.map → analytics-5Ize_UL5.js.map} +1 -1
- package/public/assets/{automation-yzUJbvT2.js → automation-CidMvW2e.js} +2 -2
- package/public/assets/{automation-yzUJbvT2.js.map → automation-CidMvW2e.js.map} +1 -1
- package/public/assets/{badge-BCvp7LK3.js → badge-BkNNvFNa.js} +2 -2
- package/public/assets/{badge-BCvp7LK3.js.map → badge-BkNNvFNa.js.map} +1 -1
- package/public/assets/{branch-state-badge-Cscjqf-E.js → branch-state-badge-DnS40KCb.js} +2 -2
- package/public/assets/{branch-state-badge-Cscjqf-E.js.map → branch-state-badge-DnS40KCb.js.map} +1 -1
- package/public/assets/{button-DyHjsHY3.js → button-DUaC2wSQ.js} +2 -2
- package/public/assets/{button-DyHjsHY3.js.map → button-DUaC2wSQ.js.map} +1 -1
- package/public/assets/{card-BoqYxpVW.js → card-r-yAV4cH.js} +2 -2
- package/public/assets/{card-BoqYxpVW.js.map → card-r-yAV4cH.js.map} +1 -1
- package/public/assets/{channels-DAtOnA6o.js → channels-MPO2FOBv.js} +2 -2
- package/public/assets/{channels-DAtOnA6o.js.map → channels-MPO2FOBv.js.map} +1 -1
- package/public/assets/chat-Ca4ERso5.js +2 -0
- package/public/assets/chat-Ca4ERso5.js.map +1 -0
- package/public/assets/{connectors-B2grC-hl.js → connectors-BbYdtRkW.js} +2 -2
- package/public/assets/{connectors-B2grC-hl.js.map → connectors-BbYdtRkW.js.map} +1 -1
- package/public/assets/copy-button-u463GPZR.js +2 -0
- package/public/assets/{copy-button-C67FUG3E.js.map → copy-button-u463GPZR.js.map} +1 -1
- package/public/assets/{display-aTnwsqQh.js → display-BNI-7rdq.js} +2 -2
- package/public/assets/{display-aTnwsqQh.js.map → display-BNI-7rdq.js.map} +1 -1
- package/public/assets/{dist-D5w04z0l.js → dist-5e_is3Xr.js} +2 -2
- package/public/assets/{dist-D5w04z0l.js.map → dist-5e_is3Xr.js.map} +1 -1
- package/public/assets/{dist-BD4Xc13v.js → dist-ActI2qb5.js} +2 -2
- package/public/assets/{dist-BD4Xc13v.js.map → dist-ActI2qb5.js.map} +1 -1
- package/public/assets/dist-B-FQ0-X3.js +2 -0
- package/public/assets/{dist-G8YBD-wp.js.map → dist-B-FQ0-X3.js.map} +1 -1
- package/public/assets/{dist-f6MW74eX.js → dist-BduURKro.js} +2 -2
- package/public/assets/{dist-f6MW74eX.js.map → dist-BduURKro.js.map} +1 -1
- package/public/assets/{dist-BFDfbfNC.js → dist-Bp0-OBcd.js} +2 -2
- package/public/assets/{dist-BFDfbfNC.js.map → dist-Bp0-OBcd.js.map} +1 -1
- package/public/assets/{dist-C_VOBSaK.js → dist-CJ7e7uqU.js} +2 -2
- package/public/assets/{dist-C_VOBSaK.js.map → dist-CJ7e7uqU.js.map} +1 -1
- package/public/assets/{dist-BNB3fTO-.js → dist-CYzOsD_n.js} +2 -2
- package/public/assets/{dist-BNB3fTO-.js.map → dist-CYzOsD_n.js.map} +1 -1
- package/public/assets/{dist-CQMQ5iku.js → dist-CpcQJrKV.js} +2 -2
- package/public/assets/{dist-CQMQ5iku.js.map → dist-CpcQJrKV.js.map} +1 -1
- package/public/assets/dist-DFgmDjLv.js +2 -0
- package/public/assets/{dist-CHKra2JV.js.map → dist-DFgmDjLv.js.map} +1 -1
- package/public/assets/{dist-DG75bvPp.js → dist-DrpSpMKL.js} +2 -2
- package/public/assets/{dist-DG75bvPp.js.map → dist-DrpSpMKL.js.map} +1 -1
- package/public/assets/dist-DzWoiN2z.js +2 -0
- package/public/assets/{dist-BJKxqIQd.js.map → dist-DzWoiN2z.js.map} +1 -1
- package/public/assets/{es2015-a6fysEVz.js → es2015-DNuudVtQ.js} +2 -2
- package/public/assets/{es2015-a6fysEVz.js.map → es2015-DNuudVtQ.js.map} +1 -1
- package/public/assets/formatted-body-BEI1zP7i.js +3 -0
- package/public/assets/{formatted-body-D5-1aiyB.js.map → formatted-body-BEI1zP7i.js.map} +1 -1
- package/public/assets/{formatted-body-impl-Do5NbJUA.js → formatted-body-impl-CNQ5sqwK.js} +2 -2
- package/public/assets/{formatted-body-impl-Do5NbJUA.js.map → formatted-body-impl-CNQ5sqwK.js.map} +1 -1
- package/public/assets/index-D2Mpzpft.js +26 -0
- package/public/assets/{index-BdNafiPz.js.map → index-D2Mpzpft.js.map} +1 -1
- package/public/assets/{input-Rc4oWv4q.js → input-BvNUEkDd.js} +2 -2
- package/public/assets/{input-Rc4oWv4q.js.map → input-BvNUEkDd.js.map} +1 -1
- package/public/assets/{insights-i6AEZT_a.js → insights-5rkMFRgf.js} +2 -2
- package/public/assets/{insights-i6AEZT_a.js.map → insights-5rkMFRgf.js.map} +1 -1
- package/public/assets/{integrations-9c6IdRU4.js → integrations-BEaqjfP4.js} +2 -2
- package/public/assets/{integrations-9c6IdRU4.js.map → integrations-BEaqjfP4.js.map} +1 -1
- package/public/assets/{lib-BZ6WPg2t.js → lib-BvrbNA2I.js} +2 -2
- package/public/assets/{lib-BZ6WPg2t.js.map → lib-BvrbNA2I.js.map} +1 -1
- package/public/assets/{lucide-react-NXHPIMsX.js → lucide-react-DD_zIava.js} +2 -2
- package/public/assets/{lucide-react-NXHPIMsX.js.map → lucide-react-DD_zIava.js.map} +1 -1
- package/public/assets/maintenance-CEiURaYZ.js +2 -0
- package/public/assets/{maintenance-CJ6wikpg.js.map → maintenance-CEiURaYZ.js.map} +1 -1
- package/public/assets/{managed-agents-JZn0xNJR.js → managed-agents-CetHyuQW.js} +4 -4
- package/public/assets/{managed-agents-JZn0xNJR.js.map → managed-agents-CetHyuQW.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-BsvAkpR4.js → markdown-preview-impl-ZwGYvObh.js} +2 -2
- package/public/assets/{markdown-preview-impl-BsvAkpR4.js.map → markdown-preview-impl-ZwGYvObh.js.map} +1 -1
- package/public/assets/memory-BTm8XzCg.js +2 -0
- package/public/assets/{memory-BE9IwCdC.js.map → memory-BTm8XzCg.js.map} +1 -1
- package/public/assets/{messages-vXgdc3H4.js → messages-fJd17pzv.js} +2 -2
- package/public/assets/{messages-vXgdc3H4.js.map → messages-fJd17pzv.js.map} +1 -1
- package/public/assets/{orchestrators-CribhLg-.js → orchestrators-C5XQ2UYy.js} +2 -2
- package/public/assets/{orchestrators-CribhLg-.js.map → orchestrators-C5XQ2UYy.js.map} +1 -1
- package/public/assets/overview-Bhk-tqKc.js +2 -0
- package/public/assets/{overview-q3O9K9VK.js.map → overview-Bhk-tqKc.js.map} +1 -1
- package/public/assets/{pairs-BNOnd0IE.js → pairs-CBrszSZh.js} +2 -2
- package/public/assets/{pairs-BNOnd0IE.js.map → pairs-CBrszSZh.js.map} +1 -1
- package/public/assets/{projects-iDkxAGBW.js → projects-DXy9JviE.js} +2 -2
- package/public/assets/{projects-iDkxAGBW.js.map → projects-DXy9JviE.js.map} +1 -1
- package/public/assets/prompt-settings-DEQhAt2X.js +2 -0
- package/public/assets/{prompt-settings-Bdz_xCgD.js.map → prompt-settings-DEQhAt2X.js.map} +1 -1
- package/public/assets/{react-dom-8yaD9iJG.js → react-dom-CADlISeR.js} +2 -2
- package/public/assets/{react-dom-8yaD9iJG.js.map → react-dom-CADlISeR.js.map} +1 -1
- package/public/assets/{registry-DPcyGypi.js → registry-DsZILce1.js} +4 -4
- package/public/assets/{registry-DPcyGypi.js.map → registry-DsZILce1.js.map} +1 -1
- package/public/assets/{security-Z3l3u-Pl.js → security-C-wVZm5k.js} +2 -2
- package/public/assets/{security-Z3l3u-Pl.js.map → security-C-wVZm5k.js.map} +1 -1
- package/public/assets/{select-BX_lW49D.js → select-DsEGBLJk.js} +2 -2
- package/public/assets/{select-BX_lW49D.js.map → select-DsEGBLJk.js.map} +1 -1
- package/public/assets/settings-D3_r5IGA.js +6 -0
- package/public/assets/{settings-Y381jZt7.js.map → settings-D3_r5IGA.js.map} +1 -1
- package/public/assets/{store-D-Frk_EC.js → store-DKTb9O0v.js} +5 -5
- package/public/assets/{store-D-Frk_EC.js.map → store-DKTb9O0v.js.map} +1 -1
- package/public/assets/{task-chip-CeujtXYT.js → task-chip-DwdauC08.js} +2 -2
- package/public/assets/{task-chip-CeujtXYT.js.map → task-chip-DwdauC08.js.map} +1 -1
- package/public/assets/{tasks-Di6d0_Ch.js → tasks-DnEzBku4.js} +2 -2
- package/public/assets/{tasks-Di6d0_Ch.js.map → tasks-DnEzBku4.js.map} +1 -1
- package/public/assets/{teams-DJv4l-_l.js → teams-DpAbZ3ye.js} +2 -2
- package/public/assets/{teams-DJv4l-_l.js.map → teams-DpAbZ3ye.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-3zBLpRel.js → terminal-viewer-impl-_IU5o8ya.js} +3 -3
- package/public/assets/{terminal-viewer-impl-3zBLpRel.js.map → terminal-viewer-impl-_IU5o8ya.js.map} +1 -1
- package/public/assets/types-BqeSkWrv.js.map +1 -1
- package/public/assets/{use-keyboard-viewport-cGEmrGE7.js → use-keyboard-viewport-wTh5CR4-.js} +2 -2
- package/public/assets/{use-keyboard-viewport-cGEmrGE7.js.map → use-keyboard-viewport-wTh5CR4-.js.map} +1 -1
- package/public/assets/user-avatar-CZ3XO8ml.js +2 -0
- package/public/assets/{user-avatar-qbo-B3dQ.js.map → user-avatar-CZ3XO8ml.js.map} +1 -1
- package/public/assets/{work-queue-D73-hP0c.js → work-queue-DKcLZWRl.js} +2 -2
- package/public/assets/{work-queue-D73-hP0c.js.map → work-queue-DKcLZWRl.js.map} +1 -1
- package/public/assets/workspaces-Cs2IZZOI.js +3 -0
- package/public/assets/{workspaces-CoDz-iZd.js.map → workspaces-Cs2IZZOI.js.map} +1 -1
- package/public/index.html +24 -24
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +4 -1
- package/runner/src/adapter.ts +5 -1
- package/runner/src/adapters/claude-delivery.ts +5 -1
- package/runner/src/adapters/claude-transcript.ts +52 -3
- package/runner/src/control-server.ts +34 -17
- package/runner/src/runner-core.ts +73 -32
- package/scripts/orchestrator-spawn-smoke.ts +81 -15
- package/src/bus.ts +6 -41
- package/src/db/messages.ts +20 -8
- package/src/prompt-resolver.ts +12 -12
- package/src/self-resume.ts +50 -8
- package/src/services/bus-command-update.ts +59 -0
- package/src/services/task-delivery.ts +30 -3
- package/public/assets/agents-Dt5TncjC.js +0 -2
- package/public/assets/chat-DhYX9BDG.js +0 -2
- package/public/assets/chat-DhYX9BDG.js.map +0 -1
- package/public/assets/copy-button-C67FUG3E.js +0 -2
- package/public/assets/dist-BJKxqIQd.js +0 -2
- package/public/assets/dist-CHKra2JV.js +0 -2
- package/public/assets/dist-G8YBD-wp.js +0 -2
- package/public/assets/formatted-body-D5-1aiyB.js +0 -3
- package/public/assets/index-BdNafiPz.js +0 -26
- package/public/assets/maintenance-CJ6wikpg.js +0 -2
- package/public/assets/memory-BE9IwCdC.js +0 -2
- package/public/assets/overview-q3O9K9VK.js +0 -2
- package/public/assets/prompt-settings-Bdz_xCgD.js +0 -2
- package/public/assets/settings-Y381jZt7.js +0 -6
- package/public/assets/user-avatar-qbo-B3dQ.js +0 -2
- package/public/assets/workspaces-CoDz-iZd.js +0 -3
|
@@ -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
|
package/runner/src/adapter.ts
CHANGED
|
@@ -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
|
|
271
|
-
//
|
|
272
|
-
//
|
|
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
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// the server reads the typed contract, not provider identity.
|
|
313
|
-
function claudePermissionApprovalView(id: string, body: Record<string, unknown
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
1432
|
-
//
|
|
1433
|
-
// the
|
|
1434
|
-
|
|
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
|
-
|
|
1440
|
-
|
|
1441
|
-
if (
|
|
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
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
+
}
|