agent-relay-runner 0.127.5 → 0.127.7
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 +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +81 -4
- package/src/adapters/claude-permission.ts +209 -0
- package/src/{claude-prompt-gates.ts → adapters/claude-prompt-gates.ts} +11 -5
- package/src/adapters/claude-session-capture.ts +235 -0
- package/src/adapters/claude.ts +18 -26
- package/src/{codex-version.ts → adapters/codex-version.ts} +1 -1
- package/src/adapters/{claude-quota-harvest.ts → provider-quota-harvest.ts} +3 -3
- package/src/control-server.ts +130 -278
- package/src/launch-assembly.ts +2 -2
- package/src/profile-home.ts +1 -1
- package/src/runner-core.ts +155 -322
- package/src/runner-helpers.ts +36 -1
package/src/control-server.ts
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
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, 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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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).
|
|
@@ -19,6 +18,11 @@ const PERMISSION_TIMEOUT_REASON =
|
|
|
19
18
|
"No response received within ~15 minutes (timeout, not a dismissal). Re-ask if you still need a decision.";
|
|
20
19
|
const PERMISSION_STOP_REASON = "The runner restarted while this permission request was pending; the original hook is no longer connected.";
|
|
21
20
|
const RESOLVED_APPROVAL_LIMIT = 1024;
|
|
21
|
+
// #1173: the immediate PreToolUse response for a "questions" prompt (see the deny-then-hold
|
|
22
|
+
// branch in handlePermissionRequest below). Bridges the hand-off so the model doesn't read a
|
|
23
|
+
// bare denial and re-ask — the real answer follows shortly as an injected user prompt.
|
|
24
|
+
const PENDING_QUESTION_DENY_REASON =
|
|
25
|
+
"The user is answering this question directly via Agent Relay, not through this tool call — do not re-ask it. Their answer will follow as your next message; wait for it and continue from there.";
|
|
22
26
|
|
|
23
27
|
// A hook that failed in a way it could not handle itself reports here so the
|
|
24
28
|
// failure is never silent (#198 item 5). Phase 1 logs it FATAL to the per-agent
|
|
@@ -35,6 +39,12 @@ interface MonitorSocketData {
|
|
|
35
39
|
|
|
36
40
|
type MonitorSocket = ServerWebSocket<MonitorSocketData>;
|
|
37
41
|
|
|
42
|
+
interface PendingPermissionWaiter {
|
|
43
|
+
resolve(input: ProviderPermissionDecisionInput): void;
|
|
44
|
+
timer: Timer;
|
|
45
|
+
suppressResolvedStatus?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
38
48
|
export interface ControlServer {
|
|
39
49
|
port: number;
|
|
40
50
|
url: string;
|
|
@@ -47,30 +57,22 @@ interface ControlServerOptions {
|
|
|
47
57
|
onStatus(status: ProviderStatusEvent): void;
|
|
48
58
|
onTerminalAttachSpec?(): Promise<TerminalAttachSpec>;
|
|
49
59
|
onReplyObligations?(): Promise<ReplyObligation[]>;
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
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.
|
|
60
|
+
// Provider session-final and pre-flush hooks hand over their capture source so
|
|
61
|
+
// the runner can surface the provider turn in dashboard chat without the agent
|
|
62
|
+
// re-emitting it via /reply.
|
|
61
63
|
onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void>;
|
|
62
64
|
onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
|
|
63
65
|
onPermissionAbandoned?(input: PendingPermissionRequestRecord & { reason: string; decisionReason: ProviderPermissionDecisionReason }): Promise<void> | void;
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
66
|
+
// #1173: a "questions" (AskUserQuestion) prompt's own PreToolUse hook is answered with `deny`
|
|
67
|
+
// immediately (see the branch in handlePermissionRequest) instead of held open for the human,
|
|
68
|
+
// so there is no live HTTP request left to answer once the decision resolves. This delivers
|
|
69
|
+
// that resolution instead: `text` is the prompt to inject as a normal user turn.
|
|
70
|
+
onAnswerInject?(input: { approvalId: string; text?: string }): Promise<void> | void;
|
|
71
|
+
// A provider user-prompt hook hands over text typed directly into the session
|
|
72
|
+
// so the runner can mirror it into dashboard chat.
|
|
68
73
|
onUserPrompt?(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void>;
|
|
69
|
-
// A provider session-boundary hook
|
|
70
|
-
//
|
|
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.
|
|
74
|
+
// A provider session-boundary hook signals an imminent context reset or
|
|
75
|
+
// termination so the runner can run end-of-session work before the operation.
|
|
74
76
|
onSessionBoundary?(input: { reason?: string; transcriptPath?: string }): Promise<void>;
|
|
75
77
|
// Phase 1 observability (#198): a hook reporting an unhandled failure. The
|
|
76
78
|
// control server already logs it FATAL; this is the seam for Phase 2 to also
|
|
@@ -81,30 +83,18 @@ interface ControlServerOptions {
|
|
|
81
83
|
permissionWaitMs?: number;
|
|
82
84
|
instanceId?: string;
|
|
83
85
|
pendingPermissionStore?: PendingPermissionStore;
|
|
86
|
+
permissionPromptHandler?: ProviderPermissionPromptHandler;
|
|
87
|
+
providerSourceId?: string;
|
|
88
|
+
stopObligationPath?: string;
|
|
89
|
+
monitorUnavailableMessage?: string;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
|
-
export
|
|
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
|
-
}
|
|
92
|
+
export type ResolvedPermissionPrompt = ProviderPermissionResolvedPrompt;
|
|
103
93
|
|
|
104
94
|
export function startControlServer(options: ControlServerOptions): ControlServer {
|
|
105
95
|
const monitors = new Set<MonitorSocket>();
|
|
106
96
|
const pendingDeliveries = new Map<string, { resolve(ids: number[]): void; timer: Timer }>();
|
|
107
|
-
const pendingPermissionRequests = new Map<string,
|
|
97
|
+
const pendingPermissionRequests = new Map<string, PendingPermissionWaiter>();
|
|
108
98
|
// approvalIds already resolved, so a duplicate decision (e.g. bus replay after a
|
|
109
99
|
// reconnect, or both the PreToolUse and PermissionRequest hooks racing one answer)
|
|
110
100
|
// is an idempotent no-op instead of a silent command failure (#693).
|
|
@@ -134,7 +124,7 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
134
124
|
.then((obligations) => Response.json(replyObligationSummary(obligations)))
|
|
135
125
|
.catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
|
|
136
126
|
}
|
|
137
|
-
if (url.pathname ===
|
|
127
|
+
if (url.pathname === `/reply-obligations/${options.stopObligationPath ?? "turn-stop"}` && req.method === "GET") {
|
|
138
128
|
if (!options.onReplyObligations) return Response.json({});
|
|
139
129
|
return options.onReplyObligations()
|
|
140
130
|
.then((obligations) => Response.json(replyObligationStopDecision(obligations)))
|
|
@@ -215,7 +205,7 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
215
205
|
server.stop(true);
|
|
216
206
|
},
|
|
217
207
|
async deliverToMonitor(messages: Message[], timeoutMs = 30_000): Promise<number[]> {
|
|
218
|
-
|
|
208
|
+
if (monitors.size === 0) throw new Error(options.monitorUnavailableMessage ?? "no provider monitor connected");
|
|
219
209
|
const deliveryId = crypto.randomUUID();
|
|
220
210
|
const payload = JSON.stringify({ type: "message.deliver", deliveryId, messages });
|
|
221
211
|
return await new Promise<number[]>((resolve, reject) => {
|
|
@@ -239,17 +229,19 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
239
229
|
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, input.approvalId);
|
|
240
230
|
recordResolvedApproval(resolvedApprovals, input.approvalId);
|
|
241
231
|
pending.resolve(withDecisionReason(input, input.decision === "deny" || input.decision === "abort" ? "dismissed" : "answered"));
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
232
|
+
if (!pending.suppressResolvedStatus) {
|
|
233
|
+
options.onStatus({
|
|
234
|
+
status: "busy",
|
|
235
|
+
reason: "provider-turn",
|
|
236
|
+
providerState: {
|
|
237
|
+
state: "active",
|
|
238
|
+
reason: "permissionResolved",
|
|
239
|
+
label: options.permissionPromptHandler?.resolvedLabel ?? "Provider approval resolved",
|
|
240
|
+
source: options.permissionPromptHandler?.source ?? options.providerSourceId ?? "provider",
|
|
241
|
+
updatedAt: Date.now(),
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
}
|
|
253
245
|
return true;
|
|
254
246
|
},
|
|
255
247
|
};
|
|
@@ -316,36 +308,88 @@ function withDecisionReason(input: ProviderPermissionDecisionInput, kind: Provid
|
|
|
316
308
|
};
|
|
317
309
|
}
|
|
318
310
|
|
|
319
|
-
function decisionReasonMessage(decision: ProviderPermissionDecisionInput, fallback: string): string {
|
|
320
|
-
return decision.reason || decision.decisionReason?.message || fallback;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
311
|
async function handlePermissionRequest(
|
|
324
312
|
req: Request,
|
|
325
|
-
|
|
326
|
-
|
|
313
|
+
options: ControlServerOptions,
|
|
314
|
+
pendingPermissionRequests: Map<string, PendingPermissionWaiter>,
|
|
327
315
|
): Promise<Response> {
|
|
316
|
+
const handler = options.permissionPromptHandler;
|
|
317
|
+
if (!handler) return Response.json({ ok: false, reason: "permission prompt handler unavailable" }, { status: 501 });
|
|
328
318
|
const occurredAt = Date.now();
|
|
329
319
|
const body = await req.json().catch(() => null);
|
|
330
320
|
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.
|
|
339
321
|
const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
|
|
340
322
|
const toolName = typeof body.tool_name === "string" ? body.tool_name : undefined;
|
|
341
323
|
const toolInput = isRecord(body.tool_input) ? body.tool_input : undefined;
|
|
324
|
+
const approvalId = crypto.randomUUID();
|
|
325
|
+
|
|
326
|
+
// #1173: a PreToolUse "questions" prompt (the provider's AskUserQuestion) used to block this hook
|
|
327
|
+
// response for up to ~15 minutes waiting on the human — which meant the turn's reasoning that
|
|
328
|
+
// led to the question stayed buffered in the provider's process (never flushed to the transcript
|
|
329
|
+
// JSONL) for exactly as long as the human took to answer, so the dashboard could only ever
|
|
330
|
+
// show the question AFTER the reasoning that motivated it. Denying the tool call immediately
|
|
331
|
+
// is the ordinary, already-supported "rejected tool call" hook contract: the provider persists the
|
|
332
|
+
// buffered turn + a rejected tool_result right away, on its own, same as any other denied tool
|
|
333
|
+
// — so the settle-poll harvest below (previously doomed to time out for this one case) now
|
|
334
|
+
// finds the freshly-flushed anchor within moments. The human's eventual answer is delivered
|
|
335
|
+
// afterward as a normal injected prompt (see the `.then` below), not as this hook's response.
|
|
336
|
+
const probeView = handler.buildView(approvalId, body, true);
|
|
337
|
+
if (body.hook_event_name === "PreToolUse" && probeView.kind === "questions" && handler.buildAnswerInjectionPrompt) {
|
|
338
|
+
const response = Response.json(handler.buildHookResponse(
|
|
339
|
+
{ approvalId, decision: "deny", reason: PENDING_QUESTION_DENY_REASON },
|
|
340
|
+
body,
|
|
341
|
+
));
|
|
342
|
+
void awaitPermissionDecision({
|
|
343
|
+
options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
|
|
344
|
+
blockedReason: "pendingQuestion",
|
|
345
|
+
recommendedAction: "Answer the question in Agent Relay.",
|
|
346
|
+
suppressResolvedStatus: true,
|
|
347
|
+
}).then(({ decision, view }) => {
|
|
348
|
+
const text = decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length
|
|
349
|
+
? handler.buildAnswerInjectionPrompt!(view, decision, body)
|
|
350
|
+
: pendingQuestionDismissalPrompt(decision);
|
|
351
|
+
return options.onAnswerInject?.({ approvalId, text });
|
|
352
|
+
}).catch(() => {});
|
|
353
|
+
return response;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const { decision } = await awaitPermissionDecision({
|
|
357
|
+
options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
|
|
358
|
+
blockedReason: "permissionRequest",
|
|
359
|
+
recommendedAction: "Review the permission request in Agent Relay.",
|
|
360
|
+
});
|
|
361
|
+
return Response.json(handler.buildHookResponse(decision, body));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Shared by both the traditional block-until-decided flow and the #1173 deny-then-hold flow:
|
|
365
|
+
// pre-flushes narrative (#435/#1116), builds+persists the interactive view, publishes the
|
|
366
|
+
// blocked providerState, and resolves once a decision arrives (or the wait times out). Callers
|
|
367
|
+
// differ only in what happens with the resolved decision (build a hook response vs. inject an
|
|
368
|
+
// answer prompt), so that part is left to them.
|
|
369
|
+
async function awaitPermissionDecision(params: {
|
|
370
|
+
options: ControlServerOptions;
|
|
371
|
+
pendingPermissionRequests: Map<string, PendingPermissionWaiter>;
|
|
372
|
+
handler: ProviderPermissionPromptHandler;
|
|
373
|
+
approvalId: string;
|
|
374
|
+
body: Record<string, unknown>;
|
|
375
|
+
occurredAt: number;
|
|
376
|
+
transcriptPath: string;
|
|
377
|
+
toolName?: string;
|
|
378
|
+
toolInput?: unknown;
|
|
379
|
+
blockedReason: string;
|
|
380
|
+
recommendedAction: string;
|
|
381
|
+
suppressResolvedStatus?: boolean;
|
|
382
|
+
}): Promise<{ decision: ProviderPermissionDecisionInput; view: InteractivePrompt }> {
|
|
383
|
+
const { options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput, blockedReason, recommendedAction, suppressResolvedStatus } = params;
|
|
384
|
+
// #435/#1116: flush any un-mirrored narrative before the blocking form appears in
|
|
385
|
+
// chat. The adapter owns how to settle its capture source; the transport only
|
|
386
|
+
// forwards the hook payload as an anchor and stamps the returned truth value.
|
|
342
387
|
let reasoningSettled = true;
|
|
343
388
|
if (transcriptPath && options.onSessionTurn) {
|
|
344
389
|
const result = await options.onSessionTurn({ transcriptPath, isPreFlush: true, toolName, toolInput }).catch(() => undefined);
|
|
345
390
|
if (result && typeof result.reasoningSettled === "boolean") reasoningSettled = result.reasoningSettled;
|
|
346
391
|
}
|
|
347
|
-
const
|
|
348
|
-
const view = claudePermissionApprovalView(approvalId, body, reasoningSettled);
|
|
392
|
+
const view = handler.buildView(approvalId, body, reasoningSettled);
|
|
349
393
|
const waitMs = options.permissionWaitMs ?? PERMISSION_WAIT_MS;
|
|
350
394
|
const hookDeadlineAt = occurredAt + waitMs;
|
|
351
395
|
bestEffortUpsertPendingPermissionRequest(options.pendingPermissionStore, {
|
|
@@ -360,10 +404,10 @@ async function handlePermissionRequest(
|
|
|
360
404
|
reason: "provider-turn",
|
|
361
405
|
providerState: {
|
|
362
406
|
state: "blocked",
|
|
363
|
-
reason:
|
|
407
|
+
reason: blockedReason,
|
|
364
408
|
label: view.title,
|
|
365
|
-
recommendedAction
|
|
366
|
-
source:
|
|
409
|
+
recommendedAction,
|
|
410
|
+
source: handler.source,
|
|
367
411
|
pendingApproval: view,
|
|
368
412
|
updatedAt: Date.now(),
|
|
369
413
|
},
|
|
@@ -375,211 +419,20 @@ async function handlePermissionRequest(
|
|
|
375
419
|
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
|
|
376
420
|
resolve(withDecisionReason({ approvalId, decision: "deny", reason: PERMISSION_TIMEOUT_REASON }, "timeout"));
|
|
377
421
|
}, waitMs);
|
|
378
|
-
pendingPermissionRequests.set(approvalId, { resolve, timer });
|
|
422
|
+
pendingPermissionRequests.set(approvalId, { resolve, timer, suppressResolvedStatus });
|
|
379
423
|
});
|
|
380
424
|
|
|
381
425
|
if (decision.decisionReason?.kind !== "timeout" && options.onPermissionResolved) {
|
|
382
|
-
await Promise.resolve(options.onPermissionResolved(
|
|
426
|
+
await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body, occurredAt))).catch(() => {});
|
|
383
427
|
}
|
|
384
428
|
|
|
385
|
-
return
|
|
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
|
-
};
|
|
507
|
-
}
|
|
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 }));
|
|
429
|
+
return { decision, view };
|
|
573
430
|
}
|
|
574
431
|
|
|
575
|
-
function
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
if (decision === "approve") return "Approved";
|
|
580
|
-
if (decision === "abort") return "Aborted";
|
|
581
|
-
if (decision === "answer") return "Answered";
|
|
582
|
-
return "Denied";
|
|
432
|
+
function pendingQuestionDismissalPrompt(decision: ProviderPermissionDecisionInput): string {
|
|
433
|
+
const timedOut = decision.decisionReason?.kind === "timeout";
|
|
434
|
+
const event = timedOut ? "timed out before the user answered" : "was dismissed before the user answered";
|
|
435
|
+
return `Your previous AskUserQuestion ${event}. Proceed on your best judgment, or re-ask if you still need a decision.`;
|
|
583
436
|
}
|
|
584
437
|
|
|
585
438
|
async function handleSessionTurn(req: Request, options: ControlServerOptions): Promise<Response> {
|
|
@@ -612,7 +465,7 @@ async function handleUserPrompt(req: Request, options: ControlServerOptions): Pr
|
|
|
612
465
|
if (!prompt.trim()) return Response.json({ ok: false, reason: "prompt required" }, { status: 400 });
|
|
613
466
|
const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
|
|
614
467
|
const promptId = isRecord(body) && typeof body.promptId === "string" && body.promptId ? body.promptId : undefined;
|
|
615
|
-
// Fire-and-forget: the hook must not block
|
|
468
|
+
// Fire-and-forget: the hook must not block the provider's turn on relay round-trips.
|
|
616
469
|
void Promise.resolve(options.onUserPrompt({ prompt, transcriptPath, ...(promptId ? { promptId } : {}) })).catch(() => {});
|
|
617
470
|
return Response.json({ ok: true });
|
|
618
471
|
}
|
|
@@ -622,7 +475,7 @@ async function handleSessionBoundary(req: Request, options: ControlServerOptions
|
|
|
622
475
|
const body = await req.json().catch(() => null);
|
|
623
476
|
const reason = isRecord(body) && typeof body.reason === "string" ? body.reason : undefined;
|
|
624
477
|
const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
|
|
625
|
-
// Fire-and-forget: a
|
|
478
|
+
// Fire-and-forget: a lifecycle hook must not block the provider compacting or exiting.
|
|
626
479
|
void Promise.resolve(options.onSessionBoundary({ reason, transcriptPath })).catch(() => {});
|
|
627
480
|
return Response.json({ ok: true });
|
|
628
481
|
}
|
|
@@ -685,8 +538,7 @@ async function handleStatus(req: Request, options: ControlServerOptions): Promis
|
|
|
685
538
|
}
|
|
686
539
|
|
|
687
540
|
// #286: the stop-failure hook reports a usage/rate-limit stall here. Build the
|
|
688
|
-
// provider-neutral `blocked` state (reason rate_limit)
|
|
689
|
-
// model-unavailable and Codex approvals ride — so the dashboard shows it
|
|
541
|
+
// provider-neutral `blocked` state (reason rate_limit) so the dashboard shows it
|
|
690
542
|
// distinctly (never as idle/available) and the relay's resume sweep can lift it
|
|
691
543
|
// at reset. The agent stays `idle` underneath (the turn truly ended); the runner
|
|
692
544
|
// carries the hold via its rateLimitHold field, not an active-work claim.
|
|
@@ -701,7 +553,7 @@ async function handleRateLimit(req: Request, options: ControlServerOptions): Pro
|
|
|
701
553
|
options.onStatus({
|
|
702
554
|
status: "idle",
|
|
703
555
|
clear: ["subagent"],
|
|
704
|
-
providerState: buildRateLimitProviderState({ errorType, resetAt, message: errorMessage, source: "
|
|
556
|
+
providerState: buildRateLimitProviderState({ errorType, resetAt, message: errorMessage, source: options.providerSourceId ?? "provider" }),
|
|
705
557
|
});
|
|
706
558
|
return Response.json({ ok: true, errorType, ...(resetAt ? { resetAt } : {}) });
|
|
707
559
|
}
|
package/src/launch-assembly.ts
CHANGED
|
@@ -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,
|
package/src/profile-home.ts
CHANGED
|
@@ -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;
|