agent-relay-runner 0.94.5 → 0.95.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.94.5",
3
+ "version": "0.95.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.75"
23
+ "agent-relay-sdk": "0.2.76"
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.94.5",
4
+ "version": "0.95.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -7,6 +7,16 @@ import { relayReplyActionText } from "./relay-instructions";
7
7
  import { logger, parseLogLevel, LOG_LEVELS } from "./logger";
8
8
  import { buildRateLimitProviderState } from "./rate-limit";
9
9
 
10
+ // The AskUserQuestion / PermissionRequest hook (runner/plugins/claude/hooks/hooks.json)
11
+ // has a 900s Claude-side timeout. Resolve the server-side wait with a margin UNDER that
12
+ // so the server always returns a deterministic decision before Claude abandons the hook —
13
+ // at exact equality the two timers race with no winner (#693).
14
+ const PERMISSION_WAIT_MS = 880_000;
15
+ // Distinct from a human dismissal ("Dismissed/Denied from Agent Relay dashboard") so the
16
+ // agent can tell "nobody answered in time" (retriable) from "the human said no" (#693).
17
+ const PERMISSION_TIMEOUT_REASON =
18
+ "No response received within ~15 minutes (timeout, not a dismissal). Re-ask if you still need a decision.";
19
+
10
20
  // A hook that failed in a way it could not handle itself reports here so the
11
21
  // failure is never silent (#198 item 5). Phase 1 logs it FATAL to the per-agent
12
22
  // log; Phase 2 (#196) will additionally route it through the runner outbox to the
@@ -54,12 +64,19 @@ interface ControlServerOptions {
54
64
  // control server already logs it FATAL; this is the seam for Phase 2 to also
55
65
  // surface it to the server via the runner outbox.
56
66
  onHookFatal?(report: HookFatalReport): void;
67
+ // Test seam: how long the permission hook waits for a dashboard decision before
68
+ // resolving as a timeout. Defaults to PERMISSION_WAIT_MS (margin under the 900s hook).
69
+ permissionWaitMs?: number;
57
70
  }
58
71
 
59
72
  export function startControlServer(options: ControlServerOptions): ControlServer {
60
73
  const monitors = new Set<MonitorSocket>();
61
74
  const pendingDeliveries = new Map<string, { resolve(ids: number[]): void; timer: Timer }>();
62
75
  const pendingPermissionRequests = new Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>();
76
+ // approvalIds already resolved, so a duplicate decision (e.g. bus replay after a
77
+ // reconnect, or both the PreToolUse and PermissionRequest hooks racing one answer)
78
+ // is an idempotent no-op instead of a silent command failure (#693).
79
+ const resolvedApprovals = new Set<string>();
63
80
  let server!: Server<MonitorSocketData>;
64
81
 
65
82
  server = Bun.serve<MonitorSocketData>({
@@ -170,9 +187,15 @@ export function startControlServer(options: ControlServerOptions): ControlServer
170
187
  },
171
188
  resolvePermissionDecision(input: ProviderPermissionDecisionInput): boolean {
172
189
  const pending = pendingPermissionRequests.get(input.approvalId);
173
- if (!pending) return false;
190
+ if (!pending) {
191
+ // Idempotent: a duplicate decision for an already-resolved approval is a
192
+ // benign no-op, not a dropped answer that fails the command (#693).
193
+ return resolvedApprovals.has(input.approvalId);
194
+ }
174
195
  clearTimeout(pending.timer);
175
196
  pendingPermissionRequests.delete(input.approvalId);
197
+ resolvedApprovals.add(input.approvalId);
198
+ if (resolvedApprovals.size > 1024) resolvedApprovals.clear();
176
199
  pending.resolve(input);
177
200
  options.onStatus({
178
201
  status: "busy",
@@ -251,8 +274,8 @@ async function handlePermissionRequest(
251
274
  const decision = await new Promise<ProviderPermissionDecisionInput>((resolve) => {
252
275
  const timer = setTimeout(() => {
253
276
  pendingPermissionRequests.delete(approvalId);
254
- resolve({ approvalId, decision: "deny", reason: "permission request timed out" });
255
- }, 15 * 60 * 1000);
277
+ resolve({ approvalId, decision: "deny", reason: PERMISSION_TIMEOUT_REASON });
278
+ }, options.permissionWaitMs ?? PERMISSION_WAIT_MS);
256
279
  pendingPermissionRequests.set(approvalId, { resolve, timer });
257
280
  });
258
281
 
@@ -346,6 +369,22 @@ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput,
346
369
  };
347
370
  }
348
371
  const hookEventName = "PermissionRequest";
372
+ // AskUserQuestion can be gated by the PermissionRequest hook instead of PreToolUse
373
+ // (e.g. when the PreToolUse hook returned no decision and Claude fell back to the
374
+ // default permission flow). The answer must still be honored, not turned into a deny
375
+ // (#693). PermissionRequest supports updatedInput on an allow, same as PreToolUse.
376
+ if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
377
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
378
+ return {
379
+ hookSpecificOutput: {
380
+ hookEventName,
381
+ decision: {
382
+ behavior: "allow",
383
+ updatedInput: { ...toolInput, answers: decision.answers },
384
+ },
385
+ },
386
+ };
387
+ }
349
388
  if (decision.decision === "approve" || decision.decision === "approve-session") {
350
389
  const suggestions = Array.isArray(body.permission_suggestions) ? body.permission_suggestions : [];
351
390
  return {