agent-relay-runner 0.95.1 → 0.96.0

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.95.1",
3
+ "version": "0.96.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.76"
23
+ "agent-relay-sdk": "0.2.77"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -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.95.1",
4
+ "version": "0.96.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -50,6 +50,7 @@ interface ControlServerOptions {
50
50
  // isPreFlush: true signals a mid-turn call from a PreToolUse hook — flush any
51
51
  // un-mirrored narrative before the interactive form appears in chat (#435).
52
52
  onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void>;
53
+ onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
53
54
  // A provider UserPromptSubmit hook hands over the prompt the human typed
54
55
  // directly into the session (web terminal / TUI) so the runner can mirror it
55
56
  // into the dashboard chat and start tailing the turn transcript for reasoning.
@@ -69,6 +70,21 @@ interface ControlServerOptions {
69
70
  permissionWaitMs?: number;
70
71
  }
71
72
 
73
+ export interface ResolvedPermissionPrompt {
74
+ provider: "claude";
75
+ kind: "questions" | "plan" | "command" | "tool";
76
+ title: string;
77
+ body: string;
78
+ promptBody?: string;
79
+ toolName: string;
80
+ hookEventName: string;
81
+ approvalId: string;
82
+ decision: ProviderPermissionDecisionInput["decision"];
83
+ decisionLabel: string;
84
+ questions?: unknown[];
85
+ answers?: Record<string, string>;
86
+ }
87
+
72
88
  export function startControlServer(options: ControlServerOptions): ControlServer {
73
89
  const monitors = new Set<MonitorSocket>();
74
90
  const pendingDeliveries = new Map<string, { resolve(ids: number[]): void; timer: Timer }>();
@@ -279,6 +295,10 @@ async function handlePermissionRequest(
279
295
  pendingPermissionRequests.set(approvalId, { resolve, timer });
280
296
  });
281
297
 
298
+ if (decision.reason !== PERMISSION_TIMEOUT_REASON && options.onPermissionResolved) {
299
+ await Promise.resolve(options.onPermissionResolved(claudeResolvedPermissionPrompt(view, decision, body))).catch(() => {});
300
+ }
301
+
282
302
  return Response.json(claudePermissionHookResponse(decision, body));
283
303
  }
284
304
 
@@ -409,6 +429,68 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
409
429
  };
410
430
  }
411
431
 
432
+ function claudeResolvedPermissionPrompt(view: Record<string, unknown>, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): ResolvedPermissionPrompt {
433
+ const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
434
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
435
+ const kind = view.kind === "questions" || view.kind === "plan" || view.kind === "command" || view.kind === "tool"
436
+ ? view.kind
437
+ : toolName.toLowerCase() === "bash" ? "command" : "tool";
438
+ const questions = Array.isArray(view.questions)
439
+ ? view.questions
440
+ : Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
441
+ const promptBody = typeof view.body === "string" ? view.body : "";
442
+ const decisionLabel = claudePermissionDecisionLabel(kind, decision.decision);
443
+ return {
444
+ provider: "claude",
445
+ kind,
446
+ title: typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`,
447
+ body: claudeResolvedPermissionBody({ kind, title: typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`, toolName, promptBody, decisionLabel, questions, answers: decision.answers }),
448
+ ...(promptBody ? { promptBody } : {}),
449
+ toolName,
450
+ hookEventName: typeof body.hook_event_name === "string" ? body.hook_event_name : "PermissionRequest",
451
+ approvalId: decision.approvalId,
452
+ decision: decision.decision,
453
+ decisionLabel,
454
+ ...(questions ? { questions } : {}),
455
+ ...(decision.answers ? { answers: decision.answers } : {}),
456
+ };
457
+ }
458
+
459
+ function claudeResolvedPermissionBody(input: { kind: ResolvedPermissionPrompt["kind"]; title: string; toolName: string; promptBody: string; decisionLabel: string; questions?: unknown[]; answers?: Record<string, string> }): string {
460
+ if (input.kind === "questions") {
461
+ return [
462
+ input.title,
463
+ input.decisionLabel,
464
+ ...resolvedQuestionRows(input.questions, input.answers, input.decisionLabel).map((row) => `Q: ${row.question}\nA: ${row.answer}`),
465
+ ].filter(Boolean).join("\n\n");
466
+ }
467
+ const subject = input.kind === "plan" ? "Plan prompt resolved" : `${input.toolName} permission resolved`;
468
+ return [subject, `Decision: ${input.decisionLabel}`, input.promptBody.trim()].filter(Boolean).join("\n\n");
469
+ }
470
+
471
+ function resolvedQuestionRows(questions: unknown[] | undefined, answers: Record<string, string> | undefined, fallback: string): Array<{ question: string; answer: string }> {
472
+ const answerMap = answers ?? {};
473
+ const rows = (questions ?? []).map((question, index) => {
474
+ const record = question && typeof question === "object" && !Array.isArray(question) ? question as Record<string, unknown> : {};
475
+ const label = typeof record.question === "string" && record.question.trim()
476
+ ? record.question.trim()
477
+ : typeof record.header === "string" && record.header.trim() ? record.header.trim() : `Question ${index + 1}`;
478
+ const rawAnswer = answerMap[label] ?? answerMap[String(record.question ?? "")] ?? answerMap[String(record.header ?? "")];
479
+ return { question: label, answer: typeof rawAnswer === "string" && rawAnswer.trim() ? rawAnswer.trim() : fallback };
480
+ });
481
+ return rows.length ? rows : Object.entries(answerMap).map(([question, answer]) => ({ question, answer }));
482
+ }
483
+
484
+ function claudePermissionDecisionLabel(kind: ResolvedPermissionPrompt["kind"], decision: ProviderPermissionDecisionInput["decision"]): string {
485
+ if (kind === "questions") return decision === "answer" ? "Answered" : "Dismissed";
486
+ if (kind === "plan") return decision === "approve" || decision === "approve-session" ? "Approved plan" : "Kept planning";
487
+ if (decision === "approve-session") return "Approved for session";
488
+ if (decision === "approve") return "Approved";
489
+ if (decision === "abort") return "Aborted";
490
+ if (decision === "answer") return "Answered";
491
+ return "Denied";
492
+ }
493
+
412
494
  async function handleSessionTurn(req: Request, options: ControlServerOptions): Promise<Response> {
413
495
  if (!options.onSessionTurn) return Response.json({ ok: false, reason: "session capture unavailable" });
414
496
  const body = await req.json().catch(() => null);
@@ -12,7 +12,7 @@ import { messagesWithCachedAttachments } from "./attachment-cache";
12
12
  import { ClaimTracker } from "./claim-tracker";
13
13
  import { ClaudeQuotaHarvest } from "./claude-quota-harvest";
14
14
  import { readRunnerContextProbeState } from "./context-probe-state";
15
- import { startControlServer, type ControlServer } from "./control-server";
15
+ import { startControlServer, type ControlServer, type ResolvedPermissionPrompt } from "./control-server";
16
16
  import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureReplyRoute } from "./reply-obligation-cache";
17
17
  import { Outbox, type OutboxRecord } from "./outbox";
18
18
  import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractFinalAssistantMessageAfterEntry, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
@@ -409,7 +409,7 @@ export class AgentRunner {
409
409
  // Hot-path-safe: answered instantly from the local snapshot, never a server
410
410
  // round-trip. The snapshot is kept warm by the background refresh below (#196).
411
411
  onReplyObligations: () => Promise.resolve(this.obligationCache.get()),
412
- onSessionTurn: (input) => this.publishSessionTurn(input),
412
+ 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 } }),
413
413
  onUserPrompt: (input) => this.handleUserPrompt(input),
414
414
  onSessionBoundary: (input) => this.handleSessionBoundary(input),
415
415
  onHookFatal: (report) => this.reportHookFatal(report),