agent-relay-runner 0.94.4 → 0.94.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.
package/package.json
CHANGED
package/src/control-server.ts
CHANGED
|
@@ -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)
|
|
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:
|
|
255
|
-
},
|
|
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 {
|