agent-relay-runner 0.127.3 → 0.127.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.127.3",
3
+ "version": "0.127.5",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "agent-relay-providers": "0.104.4",
26
- "agent-relay-sdk": "0.2.118",
26
+ "agent-relay-sdk": "0.2.120",
27
27
  "callmux": "0.23.0"
28
28
  },
29
29
  "devDependencies": {
@@ -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.3",
4
+ "version": "0.127.5",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -146,11 +146,17 @@ export interface ProviderPermissionDecisionInput {
146
146
  approvalId: string;
147
147
  decision: ProviderPermissionDecision;
148
148
  reason?: string;
149
+ decisionReason?: ProviderPermissionDecisionReason;
149
150
  // For "answer" decisions (Claude AskUserQuestion): maps each question's text
150
151
  // to the chosen option label(s). Multi-select labels are comma-joined.
151
152
  answers?: Record<string, string>;
152
153
  }
153
154
 
155
+ export interface ProviderPermissionDecisionReason {
156
+ kind: "timeout" | "dismissed" | "answered";
157
+ message?: string;
158
+ }
159
+
154
160
  export interface ProviderAdapter {
155
161
  provider: string;
156
162
  spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
@@ -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
@@ -1,11 +1,12 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
2
  import type { InteractivePrompt, Message, ReplyObligation } from "agent-relay-sdk";
3
3
  import { errMessage, isRecord } from "agent-relay-sdk";
4
- import type { ProviderPermissionDecisionInput, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
4
+ import type { ProviderPermissionDecisionInput, ProviderPermissionDecisionReason, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
5
5
  import { obligationRequiresExplicitReply } from "./reply-obligation-cache";
6
6
  import { relayReplyActionText } from "./relay-instructions";
7
7
  import { logger, parseLogLevel, LOG_LEVELS } from "./logger";
8
8
  import { buildRateLimitProviderState } from "./rate-limit";
9
+ import { bestEffortDeletePendingPermissionRequest, bestEffortListPendingPermissionRequests, bestEffortUpsertPendingPermissionRequest, type PendingPermissionRequestRecord, type PendingPermissionStore } from "./pending-permission-store";
9
10
 
10
11
  // The AskUserQuestion / PermissionRequest hook (runner/plugins/claude/hooks/hooks.json)
11
12
  // has a 900s Claude-side timeout. Resolve the server-side wait with a margin UNDER that
@@ -16,6 +17,8 @@ const PERMISSION_WAIT_MS = 880_000;
16
17
  // agent can tell "nobody answered in time" (retriable) from "the human said no" (#693).
17
18
  const PERMISSION_TIMEOUT_REASON =
18
19
  "No response received within ~15 minutes (timeout, not a dismissal). Re-ask if you still need a decision.";
20
+ const PERMISSION_STOP_REASON = "The runner restarted while this permission request was pending; the original hook is no longer connected.";
21
+ const RESOLVED_APPROVAL_LIMIT = 1024;
19
22
 
20
23
  // A hook that failed in a way it could not handle itself reports here so the
21
24
  // failure is never silent (#198 item 5). Phase 1 logs it FATAL to the per-agent
@@ -49,10 +52,15 @@ interface ControlServerOptions {
49
52
  // dashboard chat without the agent re-emitting it via /reply.
50
53
  // isPreFlush: true signals a mid-turn call from a PreToolUse hook — flush any
51
54
  // un-mirrored narrative before the interactive form appears in chat (#435).
55
+ // toolName/toolInput (#1116): the hook payload's own tool call — the exact anchor the
56
+ // runner settle-polls the transcript for before draining and returning. The resolved
57
+ // `reasoningSettled` reflects whether that anchor was actually found (vs a bounded
58
+ // timeout), so the interactive form can stamp an honest value instead of an assumed one.
52
59
  // promptId (#1086): Claude's native `prompt_id` from the Stop hook payload — the
53
60
  // 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>;
61
+ onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void>;
55
62
  onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
63
+ onPermissionAbandoned?(input: PendingPermissionRequestRecord & { reason: string; decisionReason: ProviderPermissionDecisionReason }): Promise<void> | void;
56
64
  // A provider UserPromptSubmit hook hands over the prompt the human typed
57
65
  // directly into the session (web terminal / TUI) so the runner can mirror it
58
66
  // into the dashboard chat and start tailing the turn transcript for reasoning.
@@ -71,6 +79,8 @@ interface ControlServerOptions {
71
79
  // Test seam: how long the permission hook waits for a dashboard decision before
72
80
  // resolving as a timeout. Defaults to PERMISSION_WAIT_MS (margin under the 900s hook).
73
81
  permissionWaitMs?: number;
82
+ instanceId?: string;
83
+ pendingPermissionStore?: PendingPermissionStore;
74
84
  }
75
85
 
76
86
  export interface ResolvedPermissionPrompt {
@@ -84,6 +94,9 @@ export interface ResolvedPermissionPrompt {
84
94
  approvalId: string;
85
95
  decision: ProviderPermissionDecisionInput["decision"];
86
96
  decisionLabel: string;
97
+ occurredAt: number;
98
+ resolvedAt: number;
99
+ decisionReason: ProviderPermissionDecisionReason;
87
100
  questions?: unknown[];
88
101
  answers?: Record<string, string>;
89
102
  }
@@ -97,6 +110,7 @@ export function startControlServer(options: ControlServerOptions): ControlServer
97
110
  // is an idempotent no-op instead of a silent command failure (#693).
98
111
  const resolvedApprovals = new Set<string>();
99
112
  let server!: Server<MonitorSocketData>;
113
+ recoverStoredPermissionRequests(options);
100
114
 
101
115
  server = Bun.serve<MonitorSocketData>({
102
116
  hostname: "127.0.0.1",
@@ -188,6 +202,15 @@ export function startControlServer(options: ControlServerOptions): ControlServer
188
202
  for (const pending of pendingDeliveries.values()) clearTimeout(pending.timer);
189
203
  pendingDeliveries.clear();
190
204
  for (const pending of pendingPermissionRequests.values()) clearTimeout(pending.timer);
205
+ for (const [approvalId, pending] of pendingPermissionRequests.entries()) {
206
+ const decision = withDecisionReason({
207
+ approvalId,
208
+ decision: "deny",
209
+ reason: PERMISSION_STOP_REASON,
210
+ }, "dismissed");
211
+ bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
212
+ pending.resolve(decision);
213
+ }
191
214
  pendingPermissionRequests.clear();
192
215
  server.stop(true);
193
216
  },
@@ -213,9 +236,9 @@ export function startControlServer(options: ControlServerOptions): ControlServer
213
236
  }
214
237
  clearTimeout(pending.timer);
215
238
  pendingPermissionRequests.delete(input.approvalId);
216
- resolvedApprovals.add(input.approvalId);
217
- if (resolvedApprovals.size > 1024) resolvedApprovals.clear();
218
- pending.resolve(input);
239
+ bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, input.approvalId);
240
+ recordResolvedApproval(resolvedApprovals, input.approvalId);
241
+ pending.resolve(withDecisionReason(input, input.decision === "deny" || input.decision === "abort" ? "dismissed" : "answered"));
219
242
  options.onStatus({
220
243
  status: "busy",
221
244
  reason: "provider-turn",
@@ -232,6 +255,16 @@ export function startControlServer(options: ControlServerOptions): ControlServer
232
255
  };
233
256
  }
234
257
 
258
+ export function recordResolvedApproval(resolvedApprovals: Set<string>, approvalId: string, limit = RESOLVED_APPROVAL_LIMIT): void {
259
+ resolvedApprovals.delete(approvalId);
260
+ resolvedApprovals.add(approvalId);
261
+ while (resolvedApprovals.size > limit) {
262
+ const oldest = resolvedApprovals.values().next().value;
263
+ if (!oldest) break;
264
+ resolvedApprovals.delete(oldest);
265
+ }
266
+ }
267
+
235
268
  function replyObligationSummary(obligations: ReplyObligation[]): Record<string, unknown> {
236
269
  const obligation = obligations[0];
237
270
  if (!obligation) return { pending: false, count: 0 };
@@ -259,23 +292,69 @@ function replyObligationStopDecision(obligations: ReplyObligation[]): Record<str
259
292
  };
260
293
  }
261
294
 
295
+ function recoverStoredPermissionRequests(options: ControlServerOptions): void {
296
+ const store = options.pendingPermissionStore;
297
+ if (!store) return;
298
+ for (const record of bestEffortListPendingPermissionRequests(store)) {
299
+ bestEffortDeletePendingPermissionRequest(store, record.approvalId);
300
+ const decisionReason: ProviderPermissionDecisionReason = {
301
+ kind: Date.now() >= record.hookDeadlineAt ? "timeout" : "dismissed",
302
+ message: Date.now() >= record.hookDeadlineAt ? PERMISSION_TIMEOUT_REASON : PERMISSION_STOP_REASON,
303
+ };
304
+ options.onPermissionAbandoned?.({ ...record, reason: decisionReason.message ?? PERMISSION_STOP_REASON, decisionReason });
305
+ }
306
+ }
307
+
308
+ function withDecisionReason(input: ProviderPermissionDecisionInput, kind: ProviderPermissionDecisionReason["kind"]): ProviderPermissionDecisionInput {
309
+ if (input.decisionReason) return input;
310
+ return {
311
+ ...input,
312
+ decisionReason: {
313
+ kind,
314
+ ...(input.reason ? { message: input.reason } : {}),
315
+ },
316
+ };
317
+ }
318
+
319
+ function decisionReasonMessage(decision: ProviderPermissionDecisionInput, fallback: string): string {
320
+ return decision.reason || decision.decisionReason?.message || fallback;
321
+ }
322
+
262
323
  async function handlePermissionRequest(
263
324
  req: Request,
264
- options: ControlServerOptions,
265
- pendingPermissionRequests: Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>,
325
+ options: ControlServerOptions,
326
+ pendingPermissionRequests: Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>,
266
327
  ): Promise<Response> {
328
+ const occurredAt = Date.now();
267
329
  const body = await req.json().catch(() => null);
268
330
  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.
331
+ // #435/#1116: flush any un-mirrored narrative from the transcript before the form
332
+ // appears in chat. The old comment here asserted the transcript already had the
333
+ // assistant text block at PreToolUse time empirically false: Claude's JSONL flush is
334
+ // async/bursty and can lag the hook firing by a long margin. onSessionTurn now
335
+ // settle-polls for the exact tool_use entry that triggered this hook (toolName/
336
+ // toolInput below are that anchor) before draining, and honestly reports back whether
337
+ // it found the anchor or hit its bounded timeout — reasoningSettled reflects that,
338
+ // rather than an assumption nothing here confirmed.
273
339
  const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
340
+ const toolName = typeof body.tool_name === "string" ? body.tool_name : undefined;
341
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : undefined;
342
+ let reasoningSettled = true;
274
343
  if (transcriptPath && options.onSessionTurn) {
275
- await options.onSessionTurn({ transcriptPath, isPreFlush: true }).catch(() => {});
344
+ const result = await options.onSessionTurn({ transcriptPath, isPreFlush: true, toolName, toolInput }).catch(() => undefined);
345
+ if (result && typeof result.reasoningSettled === "boolean") reasoningSettled = result.reasoningSettled;
276
346
  }
277
347
  const approvalId = crypto.randomUUID();
278
- const view = claudePermissionApprovalView(approvalId, body);
348
+ const view = claudePermissionApprovalView(approvalId, body, reasoningSettled);
349
+ const waitMs = options.permissionWaitMs ?? PERMISSION_WAIT_MS;
350
+ const hookDeadlineAt = occurredAt + waitMs;
351
+ bestEffortUpsertPendingPermissionRequest(options.pendingPermissionStore, {
352
+ approvalId,
353
+ view,
354
+ hookDeadlineAt,
355
+ ownerInstanceId: options.instanceId ?? "",
356
+ occurredAt,
357
+ });
279
358
  options.onStatus({
280
359
  status: "busy",
281
360
  reason: "provider-turn",
@@ -293,24 +372,25 @@ async function handlePermissionRequest(
293
372
  const decision = await new Promise<ProviderPermissionDecisionInput>((resolve) => {
294
373
  const timer = setTimeout(() => {
295
374
  pendingPermissionRequests.delete(approvalId);
296
- resolve({ approvalId, decision: "deny", reason: PERMISSION_TIMEOUT_REASON });
297
- }, options.permissionWaitMs ?? PERMISSION_WAIT_MS);
375
+ bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
376
+ resolve(withDecisionReason({ approvalId, decision: "deny", reason: PERMISSION_TIMEOUT_REASON }, "timeout"));
377
+ }, waitMs);
298
378
  pendingPermissionRequests.set(approvalId, { resolve, timer });
299
379
  });
300
380
 
301
- if (decision.reason !== PERMISSION_TIMEOUT_REASON && options.onPermissionResolved) {
302
- await Promise.resolve(options.onPermissionResolved(claudeResolvedPermissionPrompt(view, decision, body))).catch(() => {});
381
+ if (decision.decisionReason?.kind !== "timeout" && options.onPermissionResolved) {
382
+ await Promise.resolve(options.onPermissionResolved(claudeResolvedPermissionPrompt(view, decision, body, occurredAt))).catch(() => {});
303
383
  }
304
384
 
305
385
  return Response.json(claudePermissionHookResponse(decision, body));
306
386
  }
307
387
 
308
388
  // 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 {
389
+ // reasoningSettled is the caller's settle-poll result (#435/#723/#1116): true when
390
+ // handlePermissionRequest's pre-flush confirmed the turn's reasoning was drained before
391
+ // this view was built, false when it hit its bounded timeout without confirming. No
392
+ // `provider` tag — the server reads the typed contract, not provider identity.
393
+ function claudePermissionApprovalView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt {
314
394
  const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
315
395
  const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
316
396
  // AskUserQuestion is not a yes/no gate — it asks the user to pick answers.
@@ -326,7 +406,7 @@ function claudePermissionApprovalView(id: string, body: Record<string, unknown>)
326
406
  body: "",
327
407
  questions: toolInput.questions as InteractivePrompt["questions"],
328
408
  choices: [],
329
- reasoningSettled: true,
409
+ reasoningSettled,
330
410
  };
331
411
  }
332
412
  // ExitPlanMode arrives through the generic PermissionRequest hook (it doesn't
@@ -347,7 +427,7 @@ function claudePermissionApprovalView(id: string, body: Record<string, unknown>)
347
427
  { id: "approve", label: "Approve plan" },
348
428
  { id: "deny", label: "Keep planning" },
349
429
  ],
350
- reasoningSettled: true,
430
+ reasoningSettled,
351
431
  };
352
432
  }
353
433
  const command = typeof toolInput.command === "string" ? toolInput.command : "";
@@ -368,7 +448,7 @@ function claudePermissionApprovalView(id: string, body: Record<string, unknown>)
368
448
  { id: "deny", label: "Deny" },
369
449
  { id: "abort", label: "Abort" },
370
450
  ],
371
- reasoningSettled: true,
451
+ reasoningSettled,
372
452
  };
373
453
  }
374
454
 
@@ -389,12 +469,12 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
389
469
  };
390
470
  }
391
471
  return {
392
- hookSpecificOutput: {
393
- hookEventName: "PreToolUse",
394
- permissionDecision: "deny",
395
- permissionDecisionReason: decision.reason || "Dismissed from Agent Relay dashboard",
396
- },
397
- };
472
+ hookSpecificOutput: {
473
+ hookEventName: "PreToolUse",
474
+ permissionDecision: "deny",
475
+ permissionDecisionReason: decisionReasonMessage(decision, "Dismissed from Agent Relay dashboard"),
476
+ },
477
+ };
398
478
  }
399
479
  const hookEventName = "PermissionRequest";
400
480
  // AskUserQuestion can be gated by the PermissionRequest hook instead of PreToolUse
@@ -428,16 +508,16 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
428
508
  return {
429
509
  hookSpecificOutput: {
430
510
  hookEventName,
431
- decision: {
432
- behavior: "deny",
433
- message: decision.reason || "Denied from Agent Relay dashboard",
434
- interrupt: decision.decision === "abort",
435
- },
436
- },
511
+ decision: {
512
+ behavior: "deny",
513
+ message: decisionReasonMessage(decision, "Denied from Agent Relay dashboard"),
514
+ interrupt: decision.decision === "abort",
515
+ },
516
+ },
437
517
  };
438
518
  }
439
519
 
440
- function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): ResolvedPermissionPrompt {
520
+ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ResolvedPermissionPrompt {
441
521
  const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
442
522
  const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
443
523
  const kind = view.kind === "questions" || view.kind === "plan" || view.kind === "command" || view.kind === "tool"
@@ -459,6 +539,9 @@ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: Provi
459
539
  approvalId: decision.approvalId,
460
540
  decision: decision.decision,
461
541
  decisionLabel,
542
+ decisionReason: decision.decisionReason ?? { kind: decision.decision === "deny" || decision.decision === "abort" ? "dismissed" : "answered", ...(decision.reason ? { message: decision.reason } : {}) },
543
+ occurredAt,
544
+ resolvedAt: Date.now(),
462
545
  ...(questions ? { questions } : {}),
463
546
  ...(decision.answers ? { answers: decision.answers } : {}),
464
547
  };
package/src/mcp-outbox.ts CHANGED
@@ -68,8 +68,15 @@ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Pro
68
68
  logger.warn("outbox", `dropping event with unknown kind: ${record.kind}`);
69
69
  } catch (error) {
70
70
  if (isHttpStatusError(error, 409)) {
71
+ const reason = errMessage(error);
71
72
  if (record.kind === "session-message-batch") {
72
- logger.error("outbox", `relay reported duplicate session batch; acking row seq=${record.seq} batchKey=${record.idempotencyKey} itemKeys=${sessionBatchItemKeys(record).join(",")} error=${errMessage(error)}`);
73
+ const messages = sessionBatchMessages(record);
74
+ logger.error("outbox", `relay reported duplicate session batch; acking row seq=${record.seq} batchKey=${record.idempotencyKey} itemKeys=${sessionBatchItemKeys(record).join(",")} error=${reason}`);
75
+ await emitMirrorDropNotice(input, messages, messages.length, `duplicate idempotency key: ${reason}`, "duplicate-batch");
76
+ } else if (record.kind === "session-message") {
77
+ const message = record.payload as SendMessageInput;
78
+ logger.error("outbox", `relay reported duplicate session message; acking row seq=${record.seq} key=${record.idempotencyKey} error=${reason}`);
79
+ await emitMirrorDropNotice(input, [message], 1, `duplicate idempotency key: ${reason}`, "duplicate-message");
73
80
  }
74
81
  return;
75
82
  }
@@ -79,14 +86,20 @@ export async function deliverRunnerOutboxEvent(input: RunnerOutboxDelivery): Pro
79
86
  }
80
87
 
81
88
  async function deliverSessionBatchItems(input: RunnerOutboxDelivery, messages: SendMessageInput[]): Promise<void> {
89
+ const dropped: Array<{ message: SendMessageInput; reason: string }> = [];
82
90
  for (const message of messages) {
83
91
  try {
84
92
  await input.http.sendMessage(message);
85
93
  } catch (error) {
86
94
  if (!isDeterministicValidationError(error)) throw error;
87
- logger.warn("outbox", `dropping invalid session batch item: ${errMessage(error)}`);
95
+ const reason = errMessage(error);
96
+ logger.error("outbox", `dropping invalid session batch item key=${message.idempotencyKey ?? "(none)"} error=${reason}`);
97
+ dropped.push({ message, reason });
88
98
  }
89
99
  }
100
+ for (const item of dropped) {
101
+ await emitMirrorDropNotice(input, [item.message], 1, item.reason, "invalid-item");
102
+ }
90
103
  }
91
104
 
92
105
  function isDeterministicValidationError(error: unknown): boolean {
@@ -95,10 +108,47 @@ function isDeterministicValidationError(error: unknown): boolean {
95
108
  }
96
109
 
97
110
  function sessionBatchItemKeys(record: OutboxRecord): string[] {
98
- const messages = Array.isArray((record.payload as { messages?: unknown }).messages)
111
+ const messages = sessionBatchMessages(record);
112
+ return messages.map((message, index) => message.idempotencyKey ?? `${record.idempotencyKey}:${index}`);
113
+ }
114
+
115
+ function sessionBatchMessages(record: OutboxRecord): SendMessageInput[] {
116
+ return Array.isArray((record.payload as { messages?: unknown }).messages)
99
117
  ? (record.payload as { messages: SendMessageInput[] }).messages
100
118
  : [];
101
- return messages.map((message, index) => message.idempotencyKey ?? `${record.idempotencyKey}:${index}`);
119
+ }
120
+
121
+ async function emitMirrorDropNotice(input: RunnerOutboxDelivery, messages: SendMessageInput[], count: number, reason: string, suffix: string): Promise<void> {
122
+ if (count <= 0) return;
123
+ const anchor = messages[0];
124
+ const from = typeof anchor?.from === "string" && anchor.from ? anchor.from : "system";
125
+ const to = typeof anchor?.to === "string" && anchor.to ? anchor.to : "user";
126
+ const occurredAt = typeof anchor?.occurredAt === "number" ? anchor.occurredAt : input.record.occurredAt;
127
+ const body = `${count} steps could not be mirrored: ${reason}`;
128
+ const idempotencyKey = `session-mirror-drop:${input.record.idempotencyKey}:${suffix}:${shortHash(body)}`;
129
+ logger.error("outbox", `session mirror drop notice seq=${input.record.seq} count=${count} reason=${reason} noticeKey=${idempotencyKey}`);
130
+ try {
131
+ await input.http.sendMessage({
132
+ from,
133
+ to,
134
+ kind: "session",
135
+ body,
136
+ idempotencyKey,
137
+ occurredAt,
138
+ payload: { session: { type: "notice", origin: "provider", label: "mirror-drop", stepId: idempotencyKey } },
139
+ });
140
+ } catch (error) {
141
+ logger.error("outbox", `failed to emit session mirror drop notice seq=${input.record.seq}; acking original row anyway: ${errMessage(error)}`);
142
+ }
143
+ }
144
+
145
+ function shortHash(value: string): string {
146
+ let h = 0x811c9dc5;
147
+ for (let i = 0; i < value.length; i++) {
148
+ h ^= value.charCodeAt(i);
149
+ h = Math.imul(h, 0x01000193);
150
+ }
151
+ return (h >>> 0).toString(36);
102
152
  }
103
153
 
104
154
  export async function deliverBufferedMcpCall(input: {
@@ -0,0 +1,148 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { InteractivePrompt } from "agent-relay-sdk";
3
+ import { mkdirSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
7
+ import { runnerOutboxDirFromEnv } from "./config";
8
+ import { logger } from "./logger";
9
+ import { errMessage } from "agent-relay-sdk";
10
+
11
+ export interface PendingPermissionRequestRecord {
12
+ approvalId: string;
13
+ view: InteractivePrompt;
14
+ hookDeadlineAt: number;
15
+ ownerInstanceId: string;
16
+ occurredAt: number;
17
+ }
18
+
19
+ interface PendingPermissionRow {
20
+ approval_id: string;
21
+ view: string;
22
+ hook_deadline_at: number;
23
+ owner_instance_id: string;
24
+ occurred_at: number;
25
+ }
26
+
27
+ export class PendingPermissionStore {
28
+ private db?: Database;
29
+ readonly path: string;
30
+
31
+ constructor(options: { agentId: string; dir?: string }) {
32
+ const dir = options.dir ?? runnerOutboxDirFromEnv() ?? join(tmpdir(), "agent-relay-outbox");
33
+ this.path = options.dir === ":memory:" ? ":memory:" : join(dir, `pending-permissions-${safeName(options.agentId)}.sqlite`);
34
+ try {
35
+ if (this.path !== ":memory:") mkdirSync(dirname(this.path), { recursive: true });
36
+ const db = new Database(this.path, { create: true });
37
+ db.exec("PRAGMA journal_mode = WAL");
38
+ db.exec("PRAGMA busy_timeout = 2000");
39
+ db.exec(`
40
+ CREATE TABLE IF NOT EXISTS pending_permission_requests (
41
+ approval_id TEXT PRIMARY KEY,
42
+ view TEXT NOT NULL,
43
+ hook_deadline_at INTEGER NOT NULL,
44
+ owner_instance_id TEXT NOT NULL,
45
+ occurred_at INTEGER NOT NULL,
46
+ created_at INTEGER NOT NULL
47
+ )
48
+ `);
49
+ this.db = db;
50
+ } catch (error) {
51
+ logger.error("pending-permission-store", `disabled after open failed path=${this.path}: ${errMessage(error)}`);
52
+ this.db = undefined;
53
+ }
54
+ }
55
+
56
+ upsert(record: PendingPermissionRequestRecord): void {
57
+ if (!this.db) return;
58
+ try {
59
+ this.db.query(`
60
+ INSERT INTO pending_permission_requests (approval_id, view, hook_deadline_at, owner_instance_id, occurred_at, created_at)
61
+ VALUES (?, ?, ?, ?, ?, ?)
62
+ ON CONFLICT(approval_id) DO UPDATE SET
63
+ view = excluded.view,
64
+ hook_deadline_at = excluded.hook_deadline_at,
65
+ owner_instance_id = excluded.owner_instance_id,
66
+ occurred_at = excluded.occurred_at
67
+ `).run(record.approvalId, JSON.stringify(record.view), record.hookDeadlineAt, record.ownerInstanceId, record.occurredAt, Date.now());
68
+ } catch (error) {
69
+ logger.error("pending-permission-store", `upsert failed approvalId=${record.approvalId}; continuing in-memory only: ${errMessage(error)}`);
70
+ }
71
+ }
72
+
73
+ delete(approvalId: string): void {
74
+ if (!this.db) return;
75
+ try {
76
+ this.db.query("DELETE FROM pending_permission_requests WHERE approval_id = ?").run(approvalId);
77
+ } catch (error) {
78
+ logger.error("pending-permission-store", `delete failed approvalId=${approvalId}; continuing in-memory only: ${errMessage(error)}`);
79
+ }
80
+ }
81
+
82
+ list(): PendingPermissionRequestRecord[] {
83
+ if (!this.db) return [];
84
+ try {
85
+ const rows = this.db.query("SELECT * FROM pending_permission_requests ORDER BY occurred_at ASC").all() as PendingPermissionRow[];
86
+ return rows.map((row) => ({
87
+ approvalId: row.approval_id,
88
+ view: safeParseView(row.view),
89
+ hookDeadlineAt: row.hook_deadline_at,
90
+ ownerInstanceId: row.owner_instance_id,
91
+ occurredAt: row.occurred_at,
92
+ }));
93
+ } catch (error) {
94
+ logger.error("pending-permission-store", `list failed; continuing without persisted approvals: ${errMessage(error)}`);
95
+ return [];
96
+ }
97
+ }
98
+
99
+ close(): void {
100
+ try { this.db?.close(); } catch { /* already closed */ }
101
+ }
102
+ }
103
+
104
+ export function bestEffortListPendingPermissionRequests(store: PendingPermissionStore | undefined): PendingPermissionRequestRecord[] {
105
+ try {
106
+ return store?.list() ?? [];
107
+ } catch (error) {
108
+ logger.error("pending-permission-store", `list failed; continuing without persisted approvals: ${errMessage(error)}`);
109
+ return [];
110
+ }
111
+ }
112
+
113
+ export function bestEffortUpsertPendingPermissionRequest(store: PendingPermissionStore | undefined, record: PendingPermissionRequestRecord): void {
114
+ try {
115
+ store?.upsert(record);
116
+ } catch (error) {
117
+ logger.error("pending-permission-store", `upsert failed approvalId=${record.approvalId}; continuing in-memory only: ${errMessage(error)}`);
118
+ }
119
+ }
120
+
121
+ export function bestEffortDeletePendingPermissionRequest(store: PendingPermissionStore | undefined, approvalId: string): void {
122
+ try {
123
+ store?.delete(approvalId);
124
+ } catch (error) {
125
+ logger.error("pending-permission-store", `delete failed approvalId=${approvalId}; continuing in-memory only: ${errMessage(error)}`);
126
+ }
127
+ }
128
+
129
+ function safeName(value: string): string {
130
+ return sanitizeFsName(value, { replacement: "_", maxLen: 180, fallback: "agent" });
131
+ }
132
+
133
+ function safeParseView(json: string): InteractivePrompt {
134
+ try {
135
+ const parsed = JSON.parse(json);
136
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed as InteractivePrompt;
137
+ } catch {
138
+ // Fall through to a valid placeholder so recovery can still emit a notice and clear the row.
139
+ }
140
+ return {
141
+ id: "unknown",
142
+ kind: "tool",
143
+ title: "Recovered permission request",
144
+ body: "The original permission request could not be decoded.",
145
+ choices: [],
146
+ reasoningSettled: false,
147
+ };
148
+ }
@@ -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";
@@ -29,6 +29,7 @@ import { deliverBufferedMcpCall as deliverBufferedMcpOutboxCall, deliverRunnerOu
29
29
  import { RunnerInsights } from "./runner-insights";
30
30
  import { BusyReconciler } from "./busy-reconciler";
31
31
  import { publishCapturedResponse } from "./response-capture-report";
32
+ import { PendingPermissionStore, type PendingPermissionRequestRecord } from "./pending-permission-store";
32
33
  import { runnerLaunchInjectionSessionEvents, type RunnerRelayInjectionEvent } from "./relay-injection-events";
33
34
  import { providerTerminalSession, providerTerminalSocket } from "./process-meta";
34
35
  import { capsFromEnv, logLevelFromEnv, mcpProxyEnabledFromEnv, orchestratorBaseDirFromEnv, orchestratorUrlFromEnv, registrationTimeoutMsFromEnv, runnerInfoFileFromEnv, runnerLogFileFromEnv, runnerOutboxDirWithInfoFallback, sessionDebugEnabled, tagsFromEnv, workspaceModeFromEnv } from "./config";
@@ -153,6 +154,13 @@ const MAX_INITIAL_PROMPT_ATTEMPTS = 6;
153
154
  // transcript bytes.
154
155
  const REASONING_POLL_MS = 1_200;
155
156
  const SESSION_MIRROR_BATCH_MAX = 100;
157
+ // #1116: bounded settle-poll for the PreToolUse pre-flush anchor. Claude's JSONL flush is
158
+ // async/bursty and can lag the hook firing by a long margin, so a one-shot read (the old
159
+ // #435/#499 behavior) often finds nothing. Poll at this cadence until the exact triggering
160
+ // tool_use entry lands on disk, but never past the timeout — the question must never block
161
+ // the user more than a few seconds waiting on a transcript write that might not be coming.
162
+ const SETTLE_POLL_INTERVAL_MS = 250;
163
+ const SETTLE_POLL_TIMEOUT_MS = 4_000;
156
164
  interface RunnerTimelineEvent {
157
165
  status: string;
158
166
  id?: string;
@@ -184,6 +192,7 @@ export class AgentRunner {
184
192
  // their own FIFO queue stops one transient trace failure from head-of-line blocking real-message
185
193
  // delivery (and vice versa); its backoff is capped low since a stale live-mirror frame is cheap.
186
194
  private readonly sessionOutbox: Outbox;
195
+ private readonly pendingPermissionStore: PendingPermissionStore;
187
196
  private sessionBatchSeq = 0;
188
197
  private readonly insights: RunnerInsights;
189
198
  private readonly busyReconciler: BusyReconciler;
@@ -285,6 +294,10 @@ export class AgentRunner {
285
294
  // Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
286
295
  // from the Claude transcript into chat as discreet session events.
287
296
  private reasoningTail?: ReasoningTailState;
297
+ // Settle-poll cadence for the pre-flush anchor (#1116) — instance fields (not just the
298
+ // module constants) so tests can shrink them without waiting out the real bound.
299
+ private settlePollIntervalMs = SETTLE_POLL_INTERVAL_MS;
300
+ private settlePollTimeoutMs = SETTLE_POLL_TIMEOUT_MS;
288
301
  private scratch?: SessionScratchLayout;
289
302
  // #417: for non-claude providers, tracks how many times each spawner obligation has
290
303
  // been re-injected so a non-responsive agent can't spin in a re-injection loop.
@@ -322,6 +335,7 @@ export class AgentRunner {
322
335
  maxBackoffMs: 5_000,
323
336
  maxAttempts: 6,
324
337
  });
338
+ this.pendingPermissionStore = new PendingPermissionStore({ agentId: this.agentId, dir: outboxDir });
325
339
  this.insights = new RunnerInsights({
326
340
  agentId: this.agentId,
327
341
  cwd: options.cwd,
@@ -424,10 +438,13 @@ export class AgentRunner {
424
438
  // Hot-path-safe: answered instantly from the local snapshot, never a server
425
439
  // round-trip. The snapshot is kept warm by the background refresh below (#196).
426
440
  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 } }),
441
+ 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 } }),
442
+ onPermissionAbandoned: (input) => this.publishPermissionAbandonedNotice(input),
428
443
  onUserPrompt: (input) => this.handleUserPrompt(input),
429
444
  onSessionBoundary: (input) => this.handleSessionBoundary(input),
430
445
  onHookFatal: (report) => this.reportHookFatal(report),
446
+ instanceId: this.options.instanceId,
447
+ pendingPermissionStore: this.pendingPermissionStore,
431
448
  });
432
449
  this.startMcpProxy();
433
450
  this.writeRunnerInfoFile();
@@ -496,7 +513,7 @@ export class AgentRunner {
496
513
  this.busyReconciler.disarm();
497
514
  this.stopReasoningTail();
498
515
  this.obligationCache.stop();
499
- this.outbox.close(); this.sessionOutbox.close();
516
+ this.outbox.close(); this.sessionOutbox.close(); this.pendingPermissionStore.close();
500
517
  this.proxy?.stop();
501
518
  this.control?.stop();
502
519
  await this.bus.close();
@@ -1435,17 +1452,21 @@ export class AgentRunner {
1435
1452
  // no relay message) are mirrored too. A reply obligation, when present, is still
1436
1453
  // used as replyTo so the Stop hook stops nagging the agent to /reply.
1437
1454
  //
1438
- // isPreFlush: true (#435/#499) — mid-turn PreToolUse boundary. Drain the live
1439
- // tail and force-flush session messages before showing the blocking control;
1440
- // the entry cursor lets Stop capture skip already-emitted text.
1441
- private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean }): Promise<void> {
1455
+ // isPreFlush: true (#435/#499/#1116) — mid-turn PreToolUse boundary. Settle-polls the
1456
+ // transcript for the exact tool_use entry that triggered the hook (toolName/toolInput,
1457
+ // the hook payload's own anchor), then drains the live tail and force-flushes session
1458
+ // messages before showing the blocking control; the entry cursor lets Stop capture skip
1459
+ // already-emitted text. Returns whether the settle-poll actually found the anchor
1460
+ // (reasoningSettled) — an honest `false` on timeout rather than a stamped `true` on a
1461
+ // premise nothing here confirmed (#1116; see InteractivePrompt.reasoningSettled).
1462
+ private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void> {
1442
1463
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1443
1464
 
1444
1465
  if (input.isPreFlush) {
1445
- if (!input.transcriptPath) return;
1446
- let jsonl: string | undefined;
1447
- try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { return; }
1448
- if (!jsonl) return;
1466
+ if (!input.transcriptPath) return { reasoningSettled: true };
1467
+ const settled = await this.settlePollForToolUse(input.transcriptPath, input.toolName, input.toolInput);
1468
+ const jsonl = settled.jsonl;
1469
+ if (jsonl === undefined) return { reasoningSettled: false };
1449
1470
  const body = extractLastAssistantTurnAfterEntry(jsonl, this.narrativeFlushEntryCount);
1450
1471
  await this.drainReasoningTail();
1451
1472
  this.narrativeFlushEntryCount = countTranscriptEntries(jsonl);
@@ -1455,7 +1476,7 @@ export class AgentRunner {
1455
1476
  await this.publishSessionEvent({ from: this.agentId, to: "user", body, session: { type: "response", origin: "provider", ...(turnId ? { turnId } : {}) } });
1456
1477
  }
1457
1478
  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)}`));
1458
- return;
1479
+ return { reasoningSettled: settled.settled };
1459
1480
  }
1460
1481
 
1461
1482
  // Claude's Stop hook reports its own native `prompt_id` — the same UUID the
@@ -1465,6 +1486,11 @@ export class AgentRunner {
1465
1486
  // prompt_id at turn start — see setProviderStatus / handleUserPrompt), but this
1466
1487
  // prefers the freshest source so finalization is correct even if the two drift.
1467
1488
  const turnId = input.promptId ?? this.currentTurnId;
1489
+ // #1116: drain whatever the tail hasn't polled yet BEFORE stopping it — stopping first
1490
+ // (the old order) clears the poll/timer state, so any steps Claude flushed in the last
1491
+ // window between the tail's previous tick and this Stop hook were silently dropped from
1492
+ // the step lane instead of reaching chat.
1493
+ await this.drainReasoningTail();
1468
1494
  this.stopReasoningTail();
1469
1495
  // Optional correlation for threading + obligation clearing — never a capture gate.
1470
1496
  let replyToMessageId: number | undefined, replyTarget = "user";
@@ -1550,22 +1576,18 @@ export class AgentRunner {
1550
1576
  // tool step) as a `kind: "session"` relay message tagged with payload.session so
1551
1577
  // the dashboard can render the live provider session faithfully. Display-only:
1552
1578
  // session messages are never delivered back into a provider.
1553
- private publishSessionEvent(input: {
1554
- from: string;
1555
- to: string;
1556
- body: string;
1557
- session: MessageSessionMeta;
1558
- replyTo?: number;
1559
- }): void {
1560
- // Durable, ordered, timestamped (#196): the actual POST happens in deliverOutboxEvent,
1561
- // retried until it lands. occurredAt is stamped now so a queued event reports when it
1562
- // truly happened, not when the server finally accepted it. Routed through the fast-lane
1563
- // sessionOutbox (#332) so a transient trace failure can't head-of-line block real messages.
1579
+ private publishSessionEvent(input: { from: string; to: string; body: string; session: MessageSessionMeta; replyTo?: number; occurredAt?: number }): void {
1580
+ // Durable, ordered, timestamped (#196/#1115): the actual POST happens in
1581
+ // deliverOutboxEvent, retried until it lands. An explicit occurredAt preserves the
1582
+ // provider-native event time; otherwise the outbox stamps enqueue time. Routed through
1583
+ // the fast-lane sessionOutbox (#332) so a transient trace failure can't head-of-line
1584
+ // block real messages.
1564
1585
  // A stepId-bearing step (Codex tool running→completed, streamed reasoning/response) uses a
1565
1586
  // STABLE idempotency key so the server upserts the row in place instead of appending a dup.
1566
1587
  const stepId = input.session.stepId;
1567
1588
  this.sessionOutbox.enqueue({
1568
1589
  kind: "session-message",
1590
+ ...(input.occurredAt ? { occurredAt: input.occurredAt } : {}),
1569
1591
  ...(stepId ? { idempotencyKey: `session-step:${input.from}:${input.session.turnId ?? ""}:${stepId}` } : {}),
1570
1592
  payload: {
1571
1593
  from: input.from,
@@ -1578,13 +1600,7 @@ export class AgentRunner {
1578
1600
  });
1579
1601
  }
1580
1602
 
1581
- private publishSessionEventsBatch(inputs: Array<{
1582
- from: string;
1583
- to: string;
1584
- body: string;
1585
- session: MessageSessionMeta;
1586
- replyTo?: number;
1587
- }>): void {
1603
+ private publishSessionEventsBatch(inputs: Array<{ from: string; to: string; body: string; session: MessageSessionMeta; replyTo?: number; occurredAt?: number }>): void {
1588
1604
  for (let i = 0; i < inputs.length; i += SESSION_MIRROR_BATCH_MAX) {
1589
1605
  const chunk = inputs.slice(i, i + SESSION_MIRROR_BATCH_MAX);
1590
1606
  if (chunk.length === 1) {
@@ -1599,6 +1615,7 @@ export class AgentRunner {
1599
1615
  ...(input.replyTo ? { replyTo: input.replyTo } : {}),
1600
1616
  kind: "session",
1601
1617
  body: input.body,
1618
+ ...(input.occurredAt ? { occurredAt: input.occurredAt } : {}),
1602
1619
  ...(stepId ? { idempotencyKey: `session-step:${input.from}:${input.session.turnId ?? ""}:${stepId}` } : {}),
1603
1620
  payload: { session: { provider: this.options.provider, ...input.session } },
1604
1621
  } satisfies SendMessageInput;
@@ -1940,7 +1957,7 @@ export class AgentRunner {
1940
1957
  }
1941
1958
  const turnId = this.currentTurnId ?? turnIdAtStart;
1942
1959
  let emitted = 0;
1943
- const batch: Array<{ from: string; to: string; body: string; session: MessageSessionMeta }> = [];
1960
+ const batch: Array<{ from: string; to: string; body: string; session: MessageSessionMeta; occurredAt?: number }> = [];
1944
1961
  for (const { sig, step } of keyed) {
1945
1962
  if (seen.has(sig)) continue;
1946
1963
  seen.add(sig);
@@ -1949,6 +1966,7 @@ export class AgentRunner {
1949
1966
  from: this.agentId,
1950
1967
  to: "user",
1951
1968
  body: step.text,
1969
+ ...(step.occurredAt ? { occurredAt: step.occurredAt } : {}),
1952
1970
  session: { type: step.type, origin: "provider", ...(turnId ? { turnId } : {}), ...(step.label ? { label: step.label } : {}) },
1953
1971
  });
1954
1972
  if (step.type === "narration") emittedNarrationKeys.add(sig);
@@ -1971,6 +1989,28 @@ export class AgentRunner {
1971
1989
  await this.reasoningTail?.poll();
1972
1990
  }
1973
1991
 
1992
+ // #1116: bounded settle-poll for the PreToolUse pre-flush anchor. The old pre-flush read
1993
+ // the transcript exactly once and, on the false premise that Claude's JSONL write is
1994
+ // synchronous with the hook firing, treated whatever it found (often nothing) as complete.
1995
+ // Claude's flush is actually async/bursty — sometimes gated on the hook's own resolution —
1996
+ // so this polls at settlePollIntervalMs until the transcript contains the EXACT tool_use
1997
+ // entry that triggered the hook (toolName/toolInput from the hook payload is the precise
1998
+ // anchor), then returns the freshest read. Never polls past settlePollTimeoutMs — the
1999
+ // question must ship regardless, just honestly flagged as `settled: false` when it does.
2000
+ // No toolName (a caller that can't identify an anchor) skips straight to a single read,
2001
+ // matching the old one-shot behavior.
2002
+ private async settlePollForToolUse(transcriptPath: string, toolName?: string, toolInput?: unknown): Promise<{ settled: boolean; jsonl?: string }> {
2003
+ const deadline = Date.now() + this.settlePollTimeoutMs;
2004
+ for (;;) {
2005
+ let jsonl: string | undefined;
2006
+ try { jsonl = await readFile(transcriptPath, "utf8"); } catch { jsonl = undefined; }
2007
+ if (!toolName) return { settled: Boolean(jsonl), jsonl };
2008
+ if (jsonl !== undefined && transcriptHasToolUseAnchor(jsonl, toolName, toolInput)) return { settled: true, jsonl };
2009
+ if (Date.now() >= deadline) return { settled: false, jsonl };
2010
+ await Bun.sleep(this.settlePollIntervalMs);
2011
+ }
2012
+ }
2013
+
1974
2014
  private preFlushBodyAlreadyMirroredByReasoningTail(jsonl: string, body: string): boolean {
1975
2015
  const tail = this.reasoningTail;
1976
2016
  if (!tail || !tail.emittedNarrationKeys.size) return false;
@@ -2003,6 +2043,23 @@ export class AgentRunner {
2003
2043
  });
2004
2044
  }
2005
2045
 
2046
+ private publishPermissionAbandonedNotice(input: PendingPermissionRequestRecord & { reason: string }): void {
2047
+ const title = input.view.title || "permission request";
2048
+ this.publishSessionEvent({
2049
+ from: this.agentId,
2050
+ to: "user",
2051
+ body: `Permission request denied after runner restart: ${title}. ${input.reason}`,
2052
+ occurredAt: Date.now(),
2053
+ session: {
2054
+ type: "notice",
2055
+ origin: "provider",
2056
+ label: "permission-denied",
2057
+ stepId: `permission-abandoned:${input.approvalId}`,
2058
+ ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}),
2059
+ },
2060
+ });
2061
+ }
2062
+
2006
2063
  // #286: a discreet, durable chat marker when a usage/rate-limit hold begins, via
2007
2064
  // the same session-mirror lane as the compaction notice. Outbound session event
2008
2065
  // (NOT an inbound message) so it shows in the dashboard chat WITHOUT waking a turn