agent-relay-runner 0.127.5 → 0.127.6

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.
@@ -1,5 +1,4 @@
1
1
  import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
2
  import { homedir } from "node:os";
4
3
  import { dirname, join, resolve } from "node:path";
5
4
  import { type Message } from "agent-relay-sdk";
@@ -9,18 +8,22 @@ import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
9
8
  import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
10
9
  import { computeLivenessSignal, type LivenessInputs } from "../liveness";
11
10
  import type { LivenessSignal } from "agent-relay-sdk";
12
- import { collectClaudeSessionEvents } from "./claude-transcript";
13
- import type { SessionEvent } from "../session-insights";
14
11
  import { prepareClaudeProfileHome, profileUsesClaudeHostProviderGlobals } from "../profile-home";
15
12
  import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs, stripSettingsArgs } from "../launch-assembly";
16
13
  import { claudeProviderMessageText } from "./claude-delivery";
17
14
  import { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type ClaudeProbeActivity } from "./claude-session-probe";
18
- import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "../claude-prompt-gates";
15
+ import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "./claude-prompt-gates";
19
16
  import { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
20
17
  import { launcherScriptPathForSession } from "../session-scratch";
18
+ import { ClaudeSessionCapture, collectClaudeTranscriptArchiveSegment, collectClaudeTranscriptSessionEvents } from "./claude-session-capture";
19
+ import { claudePermissionPromptHandler } from "./claude-permission";
21
20
 
22
21
  export class ClaudeAdapter implements ProviderAdapter {
23
22
  readonly provider = "claude";
23
+ readonly statusSourceId = "claude";
24
+ private readonly paneSourceId = `${this.statusSourceId}-pane`;
25
+ readonly stopObligationPath = "claude-stop";
26
+ readonly permissionPromptHandler = claudePermissionPromptHandler;
24
27
  readonly compactSupportsInstructions = true;
25
28
  // #352: initial prompt is seeded as Claude's positional launch arg (buildSpawnArgs) — reliable,
26
29
  // no send-keys/onboarding race; tells the runner to skip the redundant post-launch delivery.
@@ -31,6 +34,16 @@ export class ClaudeAdapter implements ProviderAdapter {
31
34
  private modelUnavailableReported = false;
32
35
  private connectionRetryActive = false;
33
36
  private promptGatePaneState: ClaudePromptGatePaneState = initialClaudePromptGatePaneState();
37
+ private readonly sessionCapture = new ClaudeSessionCapture();
38
+ readonly onSessionEvent = this.sessionCapture.onSessionEvent.bind(this.sessionCapture);
39
+ readonly onSessionEvents = this.sessionCapture.onSessionEvents.bind(this.sessionCapture);
40
+ readonly captureSessionTurn = this.sessionCapture.captureSessionTurn.bind(this.sessionCapture);
41
+ readonly handleUserPrompt = this.sessionCapture.handleUserPrompt.bind(this.sessionCapture);
42
+ readonly drainSessionTrace = this.sessionCapture.drainReasoningTail.bind(this.sessionCapture);
43
+ readonly settleSessionTraceForToolUse = this.sessionCapture.settlePollForToolUse.bind(this.sessionCapture);
44
+ readonly stopSessionTrace = this.sessionCapture.stopSessionTrace.bind(this.sessionCapture);
45
+ readonly collectSessionEvents = collectClaudeTranscriptSessionEvents;
46
+ readonly collectSessionArchiveSegment = collectClaudeTranscriptArchiveSegment;
34
47
 
35
48
  onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
36
49
  this.statusCb = cb;
@@ -91,28 +104,6 @@ export class ClaudeAdapter implements ProviderAdapter {
91
104
  return { method: "tmux-inject", command: "/clear" };
92
105
  }
93
106
 
94
- // #183/#184: parse the full Claude transcript into the shared SessionEvent stream. The
95
- // runner slices per-segment, so we return the whole transcript's events each call.
96
- async collectSessionEvents(_process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<SessionEvent[] | null> {
97
- if (!ctx.transcriptPath) return null;
98
- let jsonl: string;
99
- try {
100
- jsonl = await readFile(ctx.transcriptPath, "utf8");
101
- } catch {
102
- return null;
103
- }
104
- return collectClaudeSessionEvents(jsonl);
105
- }
106
-
107
- async collectSessionArchiveSegment(_process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<string | null> {
108
- if (!ctx.transcriptPath) return null;
109
- try {
110
- return await readFile(ctx.transcriptPath, "utf8");
111
- } catch {
112
- return null;
113
- }
114
- }
115
-
116
107
  async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
117
108
  const session = process.meta?.tmuxSession as string | undefined;
118
109
  const socket = process.meta?.tmuxSocket as string | undefined;
@@ -434,6 +425,7 @@ export class ClaudeAdapter implements ProviderAdapter {
434
425
  const promptGate = evaluateClaudePromptGatePane(pane, this.promptGatePaneState, {
435
426
  sessionName,
436
427
  busy: claudePaneIsBusy(pane),
428
+ sourceId: this.paneSourceId,
437
429
  });
438
430
  this.promptGatePaneState = promptGate.state;
439
431
  if (promptGate.action) {
@@ -9,7 +9,7 @@
9
9
  // executes CODEX_HOME/hooks.json for these event names (Stop/PreToolUse/…), not just that the
10
10
  // version string clears the gate. The version probe is necessary but not sufficient.
11
11
 
12
- import { codexVersionOverrideFromEnv } from "./config";
12
+ import { codexVersionOverrideFromEnv } from "../config";
13
13
 
14
14
  // Codex releases from this version on ship stable hooks with Claude-compatible events.
15
15
  const CODEX_HOOKS_MIN_VERSION = [0, 124, 0] as const;
@@ -8,20 +8,20 @@ const PASSIVE_QUOTA_SAMPLE_STALE_MS = 10 * 60_000;
8
8
  const PASSIVE_QUOTA_LEASE_MAX_TTL_MS = 10 * 60_000;
9
9
  const PASSIVE_QUOTA_LEASE_PADDING_MS = 60_000;
10
10
 
11
- interface ClaudeQuotaHarvestOptions {
11
+ interface ProviderQuotaHarvestOptions {
12
12
  agentId: string;
13
13
  provider: string;
14
14
  http: () => Pick<RelayHttpClient, "acquireProviderQuotaPublisherLease" | "getProviderQuotaConfig" | "upsertProviderQuota">;
15
15
  }
16
16
 
17
- export class ClaudeQuotaHarvest {
17
+ export class ProviderQuotaHarvest {
18
18
  private config = { ...DEFAULT_PROVIDER_QUOTA_CONFIG };
19
19
  private configRefreshAt = 0;
20
20
  private lastReportAt?: number;
21
21
  private lease?: { accountKey: string; leaseToken: string; expiresAt: number };
22
22
  private nextLeaseAttemptAt?: number;
23
23
 
24
- constructor(private readonly options: ClaudeQuotaHarvestOptions) {}
24
+ constructor(private readonly options: ProviderQuotaHarvestOptions) {}
25
25
 
26
26
  async publish(): Promise<void> {
27
27
  if (this.options.provider !== "claude") return;
@@ -1,17 +1,16 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
- import type { InteractivePrompt, Message, ReplyObligation } from "agent-relay-sdk";
2
+ import type { Message, ReplyObligation } from "agent-relay-sdk";
3
3
  import { errMessage, isRecord } from "agent-relay-sdk";
4
- import type { ProviderPermissionDecisionInput, ProviderPermissionDecisionReason, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
4
+ import type { ProviderPermissionDecisionInput, ProviderPermissionDecisionReason, ProviderPermissionPromptHandler, ProviderPermissionResolvedPrompt, 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
9
  import { bestEffortDeletePendingPermissionRequest, bestEffortListPendingPermissionRequests, bestEffortUpsertPendingPermissionRequest, type PendingPermissionRequestRecord, type PendingPermissionStore } from "./pending-permission-store";
10
10
 
11
- // The AskUserQuestion / PermissionRequest hook (runner/plugins/claude/hooks/hooks.json)
12
- // has a 900s Claude-side timeout. Resolve the server-side wait with a margin UNDER that
13
- // so the server always returns a deterministic decision before Claude abandons the hook
14
- // at exact equality the two timers race with no winner (#693).
11
+ // Provider permission hooks can wait a long time. Resolve the server-side wait with
12
+ // a margin under the provider hook timeout so the server returns a deterministic
13
+ // decision before the provider abandons the hook.
15
14
  const PERMISSION_WAIT_MS = 880_000;
16
15
  // Distinct from a human dismissal ("Dismissed/Denied from Agent Relay dashboard") so the
17
16
  // agent can tell "nobody answered in time" (retriable) from "the human said no" (#693).
@@ -47,30 +46,17 @@ interface ControlServerOptions {
47
46
  onStatus(status: ProviderStatusEvent): void;
48
47
  onTerminalAttachSpec?(): Promise<TerminalAttachSpec>;
49
48
  onReplyObligations?(): Promise<ReplyObligation[]>;
50
- // Phase 1 live-session lane: a provider Stop hook hands over its transcript
51
- // path so the runner can capture the assistant turn and surface it in the
52
- // dashboard chat without the agent re-emitting it via /reply.
53
- // isPreFlush: true signals a mid-turn call from a PreToolUse hook — flush any
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.
59
- // promptId (#1086): Claude's native `prompt_id` from the Stop hook payload — the
60
- // stable, provider-native turn identifier used instead of a minted random UUID.
49
+ // Provider session-final and pre-flush hooks hand over their capture source so
50
+ // the runner can surface the provider turn in dashboard chat without the agent
51
+ // re-emitting it via /reply.
61
52
  onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void>;
62
53
  onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
63
54
  onPermissionAbandoned?(input: PendingPermissionRequestRecord & { reason: string; decisionReason: ProviderPermissionDecisionReason }): Promise<void> | void;
64
- // A provider UserPromptSubmit hook hands over the prompt the human typed
65
- // directly into the session (web terminal / TUI) so the runner can mirror it
66
- // into the dashboard chat and start tailing the turn transcript for reasoning.
67
- // promptId (#1086): Claude's native `prompt_id` for the turn this prompt starts.
55
+ // A provider user-prompt hook hands over text typed directly into the session
56
+ // so the runner can mirror it into dashboard chat.
68
57
  onUserPrompt?(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void>;
69
- // A provider session-boundary hook (Claude PreCompact / SessionEnd) signals an imminent
70
- // context reset or termination so the runner can run end-of-session work (#183 pre-destroy
71
- // seam: #184 context-ratio capture) before the invasive operation. `reason` is the raw
72
- // provider reason (compact, clear, logout, …); transcriptPath is optional — the runner
73
- // falls back to the last path it saw during the session.
58
+ // A provider session-boundary hook signals an imminent context reset or
59
+ // termination so the runner can run end-of-session work before the operation.
74
60
  onSessionBoundary?(input: { reason?: string; transcriptPath?: string }): Promise<void>;
75
61
  // Phase 1 observability (#198): a hook reporting an unhandled failure. The
76
62
  // control server already logs it FATAL; this is the seam for Phase 2 to also
@@ -81,25 +67,13 @@ interface ControlServerOptions {
81
67
  permissionWaitMs?: number;
82
68
  instanceId?: string;
83
69
  pendingPermissionStore?: PendingPermissionStore;
70
+ permissionPromptHandler?: ProviderPermissionPromptHandler;
71
+ providerSourceId?: string;
72
+ stopObligationPath?: string;
73
+ monitorUnavailableMessage?: string;
84
74
  }
85
75
 
86
- export interface ResolvedPermissionPrompt {
87
- provider: "claude";
88
- kind: "questions" | "plan" | "command" | "tool";
89
- title: string;
90
- body: string;
91
- promptBody?: string;
92
- toolName: string;
93
- hookEventName: string;
94
- approvalId: string;
95
- decision: ProviderPermissionDecisionInput["decision"];
96
- decisionLabel: string;
97
- occurredAt: number;
98
- resolvedAt: number;
99
- decisionReason: ProviderPermissionDecisionReason;
100
- questions?: unknown[];
101
- answers?: Record<string, string>;
102
- }
76
+ export type ResolvedPermissionPrompt = ProviderPermissionResolvedPrompt;
103
77
 
104
78
  export function startControlServer(options: ControlServerOptions): ControlServer {
105
79
  const monitors = new Set<MonitorSocket>();
@@ -134,7 +108,7 @@ export function startControlServer(options: ControlServerOptions): ControlServer
134
108
  .then((obligations) => Response.json(replyObligationSummary(obligations)))
135
109
  .catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
136
110
  }
137
- if (url.pathname === "/reply-obligations/claude-stop" && req.method === "GET") {
111
+ if (url.pathname === `/reply-obligations/${options.stopObligationPath ?? "turn-stop"}` && req.method === "GET") {
138
112
  if (!options.onReplyObligations) return Response.json({});
139
113
  return options.onReplyObligations()
140
114
  .then((obligations) => Response.json(replyObligationStopDecision(obligations)))
@@ -215,7 +189,7 @@ export function startControlServer(options: ControlServerOptions): ControlServer
215
189
  server.stop(true);
216
190
  },
217
191
  async deliverToMonitor(messages: Message[], timeoutMs = 30_000): Promise<number[]> {
218
- if (monitors.size === 0) throw new Error("no Claude monitor connected");
192
+ if (monitors.size === 0) throw new Error(options.monitorUnavailableMessage ?? "no provider monitor connected");
219
193
  const deliveryId = crypto.randomUUID();
220
194
  const payload = JSON.stringify({ type: "message.deliver", deliveryId, messages });
221
195
  return await new Promise<number[]>((resolve, reject) => {
@@ -245,8 +219,8 @@ export function startControlServer(options: ControlServerOptions): ControlServer
245
219
  providerState: {
246
220
  state: "active",
247
221
  reason: "permissionResolved",
248
- label: "Claude approval resolved",
249
- source: "claude",
222
+ label: options.permissionPromptHandler?.resolvedLabel ?? "Provider approval resolved",
223
+ source: options.permissionPromptHandler?.source ?? options.providerSourceId ?? "provider",
250
224
  updatedAt: Date.now(),
251
225
  },
252
226
  });
@@ -316,26 +290,19 @@ function withDecisionReason(input: ProviderPermissionDecisionInput, kind: Provid
316
290
  };
317
291
  }
318
292
 
319
- function decisionReasonMessage(decision: ProviderPermissionDecisionInput, fallback: string): string {
320
- return decision.reason || decision.decisionReason?.message || fallback;
321
- }
322
-
323
293
  async function handlePermissionRequest(
324
294
  req: Request,
325
295
  options: ControlServerOptions,
326
296
  pendingPermissionRequests: Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>,
327
297
  ): Promise<Response> {
298
+ const handler = options.permissionPromptHandler;
299
+ if (!handler) return Response.json({ ok: false, reason: "permission prompt handler unavailable" }, { status: 501 });
328
300
  const occurredAt = Date.now();
329
301
  const body = await req.json().catch(() => null);
330
302
  if (!isRecord(body)) return Response.json({});
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.
303
+ // #435/#1116: flush any un-mirrored narrative before the blocking form appears in
304
+ // chat. The adapter owns how to settle its capture source; the transport only
305
+ // forwards the hook payload as an anchor and stamps the returned truth value.
339
306
  const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
340
307
  const toolName = typeof body.tool_name === "string" ? body.tool_name : undefined;
341
308
  const toolInput = isRecord(body.tool_input) ? body.tool_input : undefined;
@@ -345,7 +312,7 @@ async function handlePermissionRequest(
345
312
  if (result && typeof result.reasoningSettled === "boolean") reasoningSettled = result.reasoningSettled;
346
313
  }
347
314
  const approvalId = crypto.randomUUID();
348
- const view = claudePermissionApprovalView(approvalId, body, reasoningSettled);
315
+ const view = handler.buildView(approvalId, body, reasoningSettled);
349
316
  const waitMs = options.permissionWaitMs ?? PERMISSION_WAIT_MS;
350
317
  const hookDeadlineAt = occurredAt + waitMs;
351
318
  bestEffortUpsertPendingPermissionRequest(options.pendingPermissionStore, {
@@ -363,7 +330,7 @@ async function handlePermissionRequest(
363
330
  reason: "permissionRequest",
364
331
  label: view.title,
365
332
  recommendedAction: "Review the permission request in Agent Relay.",
366
- source: "claude",
333
+ source: handler.source,
367
334
  pendingApproval: view,
368
335
  updatedAt: Date.now(),
369
336
  },
@@ -379,207 +346,10 @@ async function handlePermissionRequest(
379
346
  });
380
347
 
381
348
  if (decision.decisionReason?.kind !== "timeout" && options.onPermissionResolved) {
382
- await Promise.resolve(options.onPermissionResolved(claudeResolvedPermissionPrompt(view, decision, body, occurredAt))).catch(() => {});
383
- }
384
-
385
- return Response.json(claudePermissionHookResponse(decision, body));
386
- }
387
-
388
- // Build the normalized InteractivePrompt for a Claude PreToolUse permission/question hook.
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 {
394
- const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
395
- const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
396
- // AskUserQuestion is not a yes/no gate — it asks the user to pick answers.
397
- // Surface the structured questions so the dashboard can render a form and
398
- // return the selections via an "answer" decision (handled in the hook
399
- // response below as a PreToolUse allow + updatedInput).
400
- if (toolName === "AskUserQuestion" && Array.isArray(toolInput.questions) && toolInput.questions.length) {
401
- const count = toolInput.questions.length;
402
- return {
403
- id,
404
- kind: "questions",
405
- title: count > 1 ? `Claude is asking ${count} questions` : "Claude is asking a question",
406
- body: "",
407
- questions: toolInput.questions as InteractivePrompt["questions"],
408
- choices: [],
409
- reasoningSettled,
410
- };
411
- }
412
- // ExitPlanMode arrives through the generic PermissionRequest hook (it doesn't
413
- // match the AskUserQuestion matcher), which used to render the raw tool_input
414
- // JSON with generic Approve/Deny buttons. Surface the plan as markdown with the
415
- // real plan-mode choices instead. approve → allow (exit plan mode and proceed);
416
- // deny → keep planning.
417
- if (toolName === "ExitPlanMode") {
418
- const plan = typeof toolInput.plan === "string" && toolInput.plan.trim()
419
- ? toolInput.plan
420
- : JSON.stringify(toolInput);
421
- return {
422
- id,
423
- kind: "plan",
424
- title: "Claude is ready to code",
425
- body: plan,
426
- choices: [
427
- { id: "approve", label: "Approve plan" },
428
- { id: "deny", label: "Keep planning" },
429
- ],
430
- reasoningSettled,
431
- };
432
- }
433
- const command = typeof toolInput.command === "string" ? toolInput.command : "";
434
- const description = typeof toolInput.description === "string" ? toolInput.description : "";
435
- const bodyText = [
436
- command || description || JSON.stringify(toolInput),
437
- typeof body.cwd === "string" ? `CWD: ${body.cwd}` : "",
438
- typeof body.permission_mode === "string" ? `Mode: ${body.permission_mode}` : "",
439
- ].filter(Boolean).join("\n");
440
- return {
441
- id,
442
- kind: toolName.toLowerCase() === "bash" ? "command" : "tool",
443
- title: `Approve ${toolName}`,
444
- body: bodyText,
445
- choices: [
446
- { id: "approve", label: "Approve" },
447
- { id: "approve-session", label: "Approve session" },
448
- { id: "deny", label: "Deny" },
449
- { id: "abort", label: "Abort" },
450
- ],
451
- reasoningSettled,
452
- };
453
- }
454
-
455
- function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
456
- // AskUserQuestion comes through a PreToolUse hook. The only way to satisfy it
457
- // headlessly is permissionDecision "allow" + updatedInput carrying the answers
458
- // (echoing back the original questions). A bare "allow" is not sufficient, so
459
- // anything that is not a populated answer is treated as a deny.
460
- if (body.hook_event_name === "PreToolUse") {
461
- const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
462
- if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
463
- return {
464
- hookSpecificOutput: {
465
- hookEventName: "PreToolUse",
466
- permissionDecision: "allow",
467
- updatedInput: { ...toolInput, answers: decision.answers },
468
- },
469
- };
470
- }
471
- return {
472
- hookSpecificOutput: {
473
- hookEventName: "PreToolUse",
474
- permissionDecision: "deny",
475
- permissionDecisionReason: decisionReasonMessage(decision, "Dismissed from Agent Relay dashboard"),
476
- },
477
- };
478
- }
479
- const hookEventName = "PermissionRequest";
480
- // AskUserQuestion can be gated by the PermissionRequest hook instead of PreToolUse
481
- // (e.g. when the PreToolUse hook returned no decision and Claude fell back to the
482
- // default permission flow). The answer must still be honored, not turned into a deny
483
- // (#693). PermissionRequest supports updatedInput on an allow, same as PreToolUse.
484
- if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
485
- const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
486
- return {
487
- hookSpecificOutput: {
488
- hookEventName,
489
- decision: {
490
- behavior: "allow",
491
- updatedInput: { ...toolInput, answers: decision.answers },
492
- },
493
- },
494
- };
495
- }
496
- if (decision.decision === "approve" || decision.decision === "approve-session") {
497
- const suggestions = Array.isArray(body.permission_suggestions) ? body.permission_suggestions : [];
498
- return {
499
- hookSpecificOutput: {
500
- hookEventName,
501
- decision: {
502
- behavior: "allow",
503
- ...(decision.decision === "approve-session" && suggestions.length ? { updatedPermissions: suggestions } : {}),
504
- },
505
- },
506
- };
349
+ await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body, occurredAt))).catch(() => {});
507
350
  }
508
- return {
509
- hookSpecificOutput: {
510
- hookEventName,
511
- decision: {
512
- behavior: "deny",
513
- message: decisionReasonMessage(decision, "Denied from Agent Relay dashboard"),
514
- interrupt: decision.decision === "abort",
515
- },
516
- },
517
- };
518
- }
519
-
520
- function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ResolvedPermissionPrompt {
521
- const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
522
- const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
523
- const kind = view.kind === "questions" || view.kind === "plan" || view.kind === "command" || view.kind === "tool"
524
- ? view.kind
525
- : toolName.toLowerCase() === "bash" ? "command" : "tool";
526
- const questions = Array.isArray(view.questions)
527
- ? view.questions
528
- : Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
529
- const promptBody = typeof view.body === "string" ? view.body : "";
530
- const decisionLabel = claudePermissionDecisionLabel(kind, decision.decision);
531
- return {
532
- provider: "claude",
533
- kind,
534
- title: typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`,
535
- body: claudeResolvedPermissionBody({ kind, title: typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`, toolName, promptBody, decisionLabel, questions, answers: decision.answers }),
536
- ...(promptBody ? { promptBody } : {}),
537
- toolName,
538
- hookEventName: typeof body.hook_event_name === "string" ? body.hook_event_name : "PermissionRequest",
539
- approvalId: decision.approvalId,
540
- decision: decision.decision,
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(),
545
- ...(questions ? { questions } : {}),
546
- ...(decision.answers ? { answers: decision.answers } : {}),
547
- };
548
- }
549
-
550
- function claudeResolvedPermissionBody(input: { kind: ResolvedPermissionPrompt["kind"]; title: string; toolName: string; promptBody: string; decisionLabel: string; questions?: unknown[]; answers?: Record<string, string> }): string {
551
- if (input.kind === "questions") {
552
- return [
553
- input.title,
554
- input.decisionLabel,
555
- ...resolvedQuestionRows(input.questions, input.answers, input.decisionLabel).map((row) => `Q: ${row.question}\nA: ${row.answer}`),
556
- ].filter(Boolean).join("\n\n");
557
- }
558
- const subject = input.kind === "plan" ? "Plan prompt resolved" : `${input.toolName} permission resolved`;
559
- return [subject, `Decision: ${input.decisionLabel}`, input.promptBody.trim()].filter(Boolean).join("\n\n");
560
- }
561
-
562
- function resolvedQuestionRows(questions: unknown[] | undefined, answers: Record<string, string> | undefined, fallback: string): Array<{ question: string; answer: string }> {
563
- const answerMap = answers ?? {};
564
- const rows = (questions ?? []).map((question, index) => {
565
- const record = question && typeof question === "object" && !Array.isArray(question) ? question as Record<string, unknown> : {};
566
- const label = typeof record.question === "string" && record.question.trim()
567
- ? record.question.trim()
568
- : typeof record.header === "string" && record.header.trim() ? record.header.trim() : `Question ${index + 1}`;
569
- const rawAnswer = answerMap[label] ?? answerMap[String(record.question ?? "")] ?? answerMap[String(record.header ?? "")];
570
- return { question: label, answer: typeof rawAnswer === "string" && rawAnswer.trim() ? rawAnswer.trim() : fallback };
571
- });
572
- return rows.length ? rows : Object.entries(answerMap).map(([question, answer]) => ({ question, answer }));
573
- }
574
351
 
575
- function claudePermissionDecisionLabel(kind: ResolvedPermissionPrompt["kind"], decision: ProviderPermissionDecisionInput["decision"]): string {
576
- if (kind === "questions") return decision === "answer" ? "Answered" : "Dismissed";
577
- if (kind === "plan") return decision === "approve" || decision === "approve-session" ? "Approved plan" : "Kept planning";
578
- if (decision === "approve-session") return "Approved for session";
579
- if (decision === "approve") return "Approved";
580
- if (decision === "abort") return "Aborted";
581
- if (decision === "answer") return "Answered";
582
- return "Denied";
352
+ return Response.json(handler.buildHookResponse(decision, body));
583
353
  }
584
354
 
585
355
  async function handleSessionTurn(req: Request, options: ControlServerOptions): Promise<Response> {
@@ -612,7 +382,7 @@ async function handleUserPrompt(req: Request, options: ControlServerOptions): Pr
612
382
  if (!prompt.trim()) return Response.json({ ok: false, reason: "prompt required" }, { status: 400 });
613
383
  const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
614
384
  const promptId = isRecord(body) && typeof body.promptId === "string" && body.promptId ? body.promptId : undefined;
615
- // Fire-and-forget: the hook must not block Claude's turn on relay round-trips.
385
+ // Fire-and-forget: the hook must not block the provider's turn on relay round-trips.
616
386
  void Promise.resolve(options.onUserPrompt({ prompt, transcriptPath, ...(promptId ? { promptId } : {}) })).catch(() => {});
617
387
  return Response.json({ ok: true });
618
388
  }
@@ -622,7 +392,7 @@ async function handleSessionBoundary(req: Request, options: ControlServerOptions
622
392
  const body = await req.json().catch(() => null);
623
393
  const reason = isRecord(body) && typeof body.reason === "string" ? body.reason : undefined;
624
394
  const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
625
- // Fire-and-forget: a PreCompact/SessionEnd hook must not block Claude compacting or exiting.
395
+ // Fire-and-forget: a lifecycle hook must not block the provider compacting or exiting.
626
396
  void Promise.resolve(options.onSessionBoundary({ reason, transcriptPath })).catch(() => {});
627
397
  return Response.json({ ok: true });
628
398
  }
@@ -685,8 +455,7 @@ async function handleStatus(req: Request, options: ControlServerOptions): Promis
685
455
  }
686
456
 
687
457
  // #286: the stop-failure hook reports a usage/rate-limit stall here. Build the
688
- // provider-neutral `blocked` state (reason rate_limit) the same seam Claude
689
- // model-unavailable and Codex approvals ride — so the dashboard shows it
458
+ // provider-neutral `blocked` state (reason rate_limit) so the dashboard shows it
690
459
  // distinctly (never as idle/available) and the relay's resume sweep can lift it
691
460
  // at reset. The agent stays `idle` underneath (the turn truly ended); the runner
692
461
  // carries the hold via its rateLimitHold field, not an active-work claim.
@@ -701,7 +470,7 @@ async function handleRateLimit(req: Request, options: ControlServerOptions): Pro
701
470
  options.onStatus({
702
471
  status: "idle",
703
472
  clear: ["subagent"],
704
- providerState: buildRateLimitProviderState({ errorType, resetAt, message: errorMessage, source: "claude" }),
473
+ providerState: buildRateLimitProviderState({ errorType, resetAt, message: errorMessage, source: options.providerSourceId ?? "provider" }),
705
474
  });
706
475
  return Response.json({ ok: true, errorType, ...(resetAt ? { resetAt } : {}) });
707
476
  }
@@ -17,8 +17,8 @@ import {
17
17
  providerProvisioningHomePathFor,
18
18
  } from "./profile-home";
19
19
  import { getManifest } from "agent-relay-providers";
20
- import { codexHooksSupported } from "./codex-version";
21
- import { claudeLaunchPromptGateSettings } from "./claude-prompt-gates";
20
+ import { codexHooksSupported } from "./adapters/codex-version";
21
+ import { claudeLaunchPromptGateSettings } from "./adapters/claude-prompt-gates";
22
22
  import {
23
23
  materializeResolvedAssets,
24
24
  renderProvisionedHookSet,
@@ -7,7 +7,7 @@ import { getManifest } from "agent-relay-providers";
7
7
  import { profileAllowsRelayFeature, type RunnerSpawnConfig } from "./adapter";
8
8
  import { claudeRelayManual } from "./relay-instructions";
9
9
  import { providerHomeRootFromEnv } from "./config";
10
- import { applyClaudeConfigPromptGatePreventions } from "./claude-prompt-gates";
10
+ import { applyClaudeConfigPromptGatePreventions } from "./adapters/claude-prompt-gates";
11
11
 
12
12
  type ProviderHome = {
13
13
  path: string;