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 +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +6 -0
- package/src/adapters/claude-transcript.ts +52 -3
- package/src/control-server.ts +121 -38
- package/src/mcp-outbox.ts +54 -4
- package/src/pending-permission-store.ts +148 -0
- package/src/runner-core.ts +88 -31
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.127.
|
|
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.
|
|
26
|
+
"agent-relay-sdk": "0.2.120",
|
|
27
27
|
"callmux": "0.23.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
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
|
package/src/control-server.ts
CHANGED
|
@@ -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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
|
271
|
-
//
|
|
272
|
-
//
|
|
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
|
-
|
|
297
|
-
|
|
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.
|
|
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
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
// the server reads the typed contract, not provider identity.
|
|
313
|
-
function claudePermissionApprovalView(id: string, body: Record<string, unknown
|
|
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
|
|
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
|
|
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
|
|
451
|
+
reasoningSettled,
|
|
372
452
|
};
|
|
373
453
|
}
|
|
374
454
|
|
|
@@ -389,12 +469,12 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
|
|
|
389
469
|
};
|
|
390
470
|
}
|
|
391
471
|
return {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/src/runner-core.ts
CHANGED
|
@@ -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.
|
|
1439
|
-
//
|
|
1440
|
-
// the
|
|
1441
|
-
|
|
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
|
-
|
|
1447
|
-
|
|
1448
|
-
if (
|
|
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
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
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
|