agent-relay-runner 0.127.4 → 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.4",
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.119",
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.4",
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>;
@@ -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
@@ -57,6 +60,7 @@ interface ControlServerOptions {
57
60
  // stable, provider-native turn identifier used instead of a minted random UUID.
58
61
  onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void>;
59
62
  onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
63
+ onPermissionAbandoned?(input: PendingPermissionRequestRecord & { reason: string; decisionReason: ProviderPermissionDecisionReason }): Promise<void> | void;
60
64
  // A provider UserPromptSubmit hook hands over the prompt the human typed
61
65
  // directly into the session (web terminal / TUI) so the runner can mirror it
62
66
  // into the dashboard chat and start tailing the turn transcript for reasoning.
@@ -75,6 +79,8 @@ interface ControlServerOptions {
75
79
  // Test seam: how long the permission hook waits for a dashboard decision before
76
80
  // resolving as a timeout. Defaults to PERMISSION_WAIT_MS (margin under the 900s hook).
77
81
  permissionWaitMs?: number;
82
+ instanceId?: string;
83
+ pendingPermissionStore?: PendingPermissionStore;
78
84
  }
79
85
 
80
86
  export interface ResolvedPermissionPrompt {
@@ -90,6 +96,7 @@ export interface ResolvedPermissionPrompt {
90
96
  decisionLabel: string;
91
97
  occurredAt: number;
92
98
  resolvedAt: number;
99
+ decisionReason: ProviderPermissionDecisionReason;
93
100
  questions?: unknown[];
94
101
  answers?: Record<string, string>;
95
102
  }
@@ -103,6 +110,7 @@ export function startControlServer(options: ControlServerOptions): ControlServer
103
110
  // is an idempotent no-op instead of a silent command failure (#693).
104
111
  const resolvedApprovals = new Set<string>();
105
112
  let server!: Server<MonitorSocketData>;
113
+ recoverStoredPermissionRequests(options);
106
114
 
107
115
  server = Bun.serve<MonitorSocketData>({
108
116
  hostname: "127.0.0.1",
@@ -194,6 +202,15 @@ export function startControlServer(options: ControlServerOptions): ControlServer
194
202
  for (const pending of pendingDeliveries.values()) clearTimeout(pending.timer);
195
203
  pendingDeliveries.clear();
196
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
+ }
197
214
  pendingPermissionRequests.clear();
198
215
  server.stop(true);
199
216
  },
@@ -219,9 +236,9 @@ export function startControlServer(options: ControlServerOptions): ControlServer
219
236
  }
220
237
  clearTimeout(pending.timer);
221
238
  pendingPermissionRequests.delete(input.approvalId);
222
- resolvedApprovals.add(input.approvalId);
223
- if (resolvedApprovals.size > 1024) resolvedApprovals.clear();
224
- 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"));
225
242
  options.onStatus({
226
243
  status: "busy",
227
244
  reason: "provider-turn",
@@ -238,6 +255,16 @@ export function startControlServer(options: ControlServerOptions): ControlServer
238
255
  };
239
256
  }
240
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
+
241
268
  function replyObligationSummary(obligations: ReplyObligation[]): Record<string, unknown> {
242
269
  const obligation = obligations[0];
243
270
  if (!obligation) return { pending: false, count: 0 };
@@ -265,10 +292,38 @@ function replyObligationStopDecision(obligations: ReplyObligation[]): Record<str
265
292
  };
266
293
  }
267
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
+
268
323
  async function handlePermissionRequest(
269
324
  req: Request,
270
- options: ControlServerOptions,
271
- pendingPermissionRequests: Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>,
325
+ options: ControlServerOptions,
326
+ pendingPermissionRequests: Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>,
272
327
  ): Promise<Response> {
273
328
  const occurredAt = Date.now();
274
329
  const body = await req.json().catch(() => null);
@@ -291,6 +346,15 @@ async function handlePermissionRequest(
291
346
  }
292
347
  const approvalId = crypto.randomUUID();
293
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
+ });
294
358
  options.onStatus({
295
359
  status: "busy",
296
360
  reason: "provider-turn",
@@ -308,12 +372,13 @@ async function handlePermissionRequest(
308
372
  const decision = await new Promise<ProviderPermissionDecisionInput>((resolve) => {
309
373
  const timer = setTimeout(() => {
310
374
  pendingPermissionRequests.delete(approvalId);
311
- resolve({ approvalId, decision: "deny", reason: PERMISSION_TIMEOUT_REASON });
312
- }, options.permissionWaitMs ?? PERMISSION_WAIT_MS);
375
+ bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
376
+ resolve(withDecisionReason({ approvalId, decision: "deny", reason: PERMISSION_TIMEOUT_REASON }, "timeout"));
377
+ }, waitMs);
313
378
  pendingPermissionRequests.set(approvalId, { resolve, timer });
314
379
  });
315
380
 
316
- if (decision.reason !== PERMISSION_TIMEOUT_REASON && options.onPermissionResolved) {
381
+ if (decision.decisionReason?.kind !== "timeout" && options.onPermissionResolved) {
317
382
  await Promise.resolve(options.onPermissionResolved(claudeResolvedPermissionPrompt(view, decision, body, occurredAt))).catch(() => {});
318
383
  }
319
384
 
@@ -404,12 +469,12 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
404
469
  };
405
470
  }
406
471
  return {
407
- hookSpecificOutput: {
408
- hookEventName: "PreToolUse",
409
- permissionDecision: "deny",
410
- permissionDecisionReason: decision.reason || "Dismissed from Agent Relay dashboard",
411
- },
412
- };
472
+ hookSpecificOutput: {
473
+ hookEventName: "PreToolUse",
474
+ permissionDecision: "deny",
475
+ permissionDecisionReason: decisionReasonMessage(decision, "Dismissed from Agent Relay dashboard"),
476
+ },
477
+ };
413
478
  }
414
479
  const hookEventName = "PermissionRequest";
415
480
  // AskUserQuestion can be gated by the PermissionRequest hook instead of PreToolUse
@@ -443,12 +508,12 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
443
508
  return {
444
509
  hookSpecificOutput: {
445
510
  hookEventName,
446
- decision: {
447
- behavior: "deny",
448
- message: decision.reason || "Denied from Agent Relay dashboard",
449
- interrupt: decision.decision === "abort",
450
- },
451
- },
511
+ decision: {
512
+ behavior: "deny",
513
+ message: decisionReasonMessage(decision, "Denied from Agent Relay dashboard"),
514
+ interrupt: decision.decision === "abort",
515
+ },
516
+ },
452
517
  };
453
518
  }
454
519
 
@@ -474,6 +539,7 @@ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: Provi
474
539
  approvalId: decision.approvalId,
475
540
  decision: decision.decision,
476
541
  decisionLabel,
542
+ decisionReason: decision.decisionReason ?? { kind: decision.decision === "deny" || decision.decision === "abort" ? "dismissed" : "answered", ...(decision.reason ? { message: decision.reason } : {}) },
477
543
  occurredAt,
478
544
  resolvedAt: Date.now(),
479
545
  ...(questions ? { questions } : {}),
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
+ }
@@ -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";
@@ -191,6 +192,7 @@ export class AgentRunner {
191
192
  // their own FIFO queue stops one transient trace failure from head-of-line blocking real-message
192
193
  // delivery (and vice versa); its backoff is capped low since a stale live-mirror frame is cheap.
193
194
  private readonly sessionOutbox: Outbox;
195
+ private readonly pendingPermissionStore: PendingPermissionStore;
194
196
  private sessionBatchSeq = 0;
195
197
  private readonly insights: RunnerInsights;
196
198
  private readonly busyReconciler: BusyReconciler;
@@ -333,6 +335,7 @@ export class AgentRunner {
333
335
  maxBackoffMs: 5_000,
334
336
  maxAttempts: 6,
335
337
  });
338
+ this.pendingPermissionStore = new PendingPermissionStore({ agentId: this.agentId, dir: outboxDir });
336
339
  this.insights = new RunnerInsights({
337
340
  agentId: this.agentId,
338
341
  cwd: options.cwd,
@@ -436,9 +439,12 @@ export class AgentRunner {
436
439
  // round-trip. The snapshot is kept warm by the background refresh below (#196).
437
440
  onReplyObligations: () => Promise.resolve(this.obligationCache.get()),
438
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),
439
443
  onUserPrompt: (input) => this.handleUserPrompt(input),
440
444
  onSessionBoundary: (input) => this.handleSessionBoundary(input),
441
445
  onHookFatal: (report) => this.reportHookFatal(report),
446
+ instanceId: this.options.instanceId,
447
+ pendingPermissionStore: this.pendingPermissionStore,
442
448
  });
443
449
  this.startMcpProxy();
444
450
  this.writeRunnerInfoFile();
@@ -507,7 +513,7 @@ export class AgentRunner {
507
513
  this.busyReconciler.disarm();
508
514
  this.stopReasoningTail();
509
515
  this.obligationCache.stop();
510
- this.outbox.close(); this.sessionOutbox.close();
516
+ this.outbox.close(); this.sessionOutbox.close(); this.pendingPermissionStore.close();
511
517
  this.proxy?.stop();
512
518
  this.control?.stop();
513
519
  await this.bus.close();
@@ -2037,6 +2043,23 @@ export class AgentRunner {
2037
2043
  });
2038
2044
  }
2039
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
+
2040
2063
  // #286: a discreet, durable chat marker when a usage/rate-limit hold begins, via
2041
2064
  // the same session-mirror lane as the compaction notice. Outbound session event
2042
2065
  // (NOT an inbound message) so it shows in the dashboard chat WITHOUT waking a turn