agent-relay-runner 0.127.4 → 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.
- package/package.json +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +82 -4
- package/src/adapters/claude-permission.ts +191 -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 +102 -267
- package/src/launch-assembly.ts +2 -2
- package/src/mcp-outbox.ts +54 -4
- package/src/pending-permission-store.ts +148 -0
- package/src/profile-home.ts +1 -1
- package/src/runner-core.ts +134 -322
- package/src/runner-helpers.ts +36 -1
package/src/adapters/claude.ts
CHANGED
|
@@ -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 "
|
|
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 "
|
|
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
|
|
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
|
|
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:
|
|
24
|
+
constructor(private readonly options: ProviderQuotaHarvestOptions) {}
|
|
25
25
|
|
|
26
26
|
async publish(): Promise<void> {
|
|
27
27
|
if (this.options.provider !== "claude") return;
|
package/src/control-server.ts
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
|
-
import type {
|
|
2
|
+
import type { Message, ReplyObligation } from "agent-relay-sdk";
|
|
3
3
|
import { errMessage, isRecord } from "agent-relay-sdk";
|
|
4
|
-
import type { ProviderPermissionDecisionInput, 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
|
+
import { bestEffortDeletePendingPermissionRequest, bestEffortListPendingPermissionRequests, bestEffortUpsertPendingPermissionRequest, type PendingPermissionRequestRecord, type PendingPermissionStore } from "./pending-permission-store";
|
|
9
10
|
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// 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.
|
|
14
14
|
const PERMISSION_WAIT_MS = 880_000;
|
|
15
15
|
// Distinct from a human dismissal ("Dismissed/Denied from Agent Relay dashboard") so the
|
|
16
16
|
// agent can tell "nobody answered in time" (retriable) from "the human said no" (#693).
|
|
17
17
|
const PERMISSION_TIMEOUT_REASON =
|
|
18
18
|
"No response received within ~15 minutes (timeout, not a dismissal). Re-ask if you still need a decision.";
|
|
19
|
+
const PERMISSION_STOP_REASON = "The runner restarted while this permission request was pending; the original hook is no longer connected.";
|
|
20
|
+
const RESOLVED_APPROVAL_LIMIT = 1024;
|
|
19
21
|
|
|
20
22
|
// A hook that failed in a way it could not handle itself reports here so the
|
|
21
23
|
// failure is never silent (#198 item 5). Phase 1 logs it FATAL to the per-agent
|
|
@@ -44,29 +46,17 @@ interface ControlServerOptions {
|
|
|
44
46
|
onStatus(status: ProviderStatusEvent): void;
|
|
45
47
|
onTerminalAttachSpec?(): Promise<TerminalAttachSpec>;
|
|
46
48
|
onReplyObligations?(): Promise<ReplyObligation[]>;
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
// isPreFlush: true signals a mid-turn call from a PreToolUse hook — flush any
|
|
51
|
-
// un-mirrored narrative before the interactive form appears in chat (#435).
|
|
52
|
-
// toolName/toolInput (#1116): the hook payload's own tool call — the exact anchor the
|
|
53
|
-
// runner settle-polls the transcript for before draining and returning. The resolved
|
|
54
|
-
// `reasoningSettled` reflects whether that anchor was actually found (vs a bounded
|
|
55
|
-
// timeout), so the interactive form can stamp an honest value instead of an assumed one.
|
|
56
|
-
// promptId (#1086): Claude's native `prompt_id` from the Stop hook payload — the
|
|
57
|
-
// 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.
|
|
58
52
|
onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void>;
|
|
59
53
|
onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
|
|
60
|
-
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// promptId (#1086): Claude's native `prompt_id` for the turn this prompt starts.
|
|
54
|
+
onPermissionAbandoned?(input: PendingPermissionRequestRecord & { reason: string; decisionReason: ProviderPermissionDecisionReason }): Promise<void> | void;
|
|
55
|
+
// A provider user-prompt hook hands over text typed directly into the session
|
|
56
|
+
// so the runner can mirror it into dashboard chat.
|
|
64
57
|
onUserPrompt?(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void>;
|
|
65
|
-
// A provider session-boundary hook
|
|
66
|
-
//
|
|
67
|
-
// seam: #184 context-ratio capture) before the invasive operation. `reason` is the raw
|
|
68
|
-
// provider reason (compact, clear, logout, …); transcriptPath is optional — the runner
|
|
69
|
-
// 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.
|
|
70
60
|
onSessionBoundary?(input: { reason?: string; transcriptPath?: string }): Promise<void>;
|
|
71
61
|
// Phase 1 observability (#198): a hook reporting an unhandled failure. The
|
|
72
62
|
// control server already logs it FATAL; this is the seam for Phase 2 to also
|
|
@@ -75,24 +65,15 @@ interface ControlServerOptions {
|
|
|
75
65
|
// Test seam: how long the permission hook waits for a dashboard decision before
|
|
76
66
|
// resolving as a timeout. Defaults to PERMISSION_WAIT_MS (margin under the 900s hook).
|
|
77
67
|
permissionWaitMs?: number;
|
|
68
|
+
instanceId?: string;
|
|
69
|
+
pendingPermissionStore?: PendingPermissionStore;
|
|
70
|
+
permissionPromptHandler?: ProviderPermissionPromptHandler;
|
|
71
|
+
providerSourceId?: string;
|
|
72
|
+
stopObligationPath?: string;
|
|
73
|
+
monitorUnavailableMessage?: string;
|
|
78
74
|
}
|
|
79
75
|
|
|
80
|
-
export
|
|
81
|
-
provider: "claude";
|
|
82
|
-
kind: "questions" | "plan" | "command" | "tool";
|
|
83
|
-
title: string;
|
|
84
|
-
body: string;
|
|
85
|
-
promptBody?: string;
|
|
86
|
-
toolName: string;
|
|
87
|
-
hookEventName: string;
|
|
88
|
-
approvalId: string;
|
|
89
|
-
decision: ProviderPermissionDecisionInput["decision"];
|
|
90
|
-
decisionLabel: string;
|
|
91
|
-
occurredAt: number;
|
|
92
|
-
resolvedAt: number;
|
|
93
|
-
questions?: unknown[];
|
|
94
|
-
answers?: Record<string, string>;
|
|
95
|
-
}
|
|
76
|
+
export type ResolvedPermissionPrompt = ProviderPermissionResolvedPrompt;
|
|
96
77
|
|
|
97
78
|
export function startControlServer(options: ControlServerOptions): ControlServer {
|
|
98
79
|
const monitors = new Set<MonitorSocket>();
|
|
@@ -103,6 +84,7 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
103
84
|
// is an idempotent no-op instead of a silent command failure (#693).
|
|
104
85
|
const resolvedApprovals = new Set<string>();
|
|
105
86
|
let server!: Server<MonitorSocketData>;
|
|
87
|
+
recoverStoredPermissionRequests(options);
|
|
106
88
|
|
|
107
89
|
server = Bun.serve<MonitorSocketData>({
|
|
108
90
|
hostname: "127.0.0.1",
|
|
@@ -126,7 +108,7 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
126
108
|
.then((obligations) => Response.json(replyObligationSummary(obligations)))
|
|
127
109
|
.catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
|
|
128
110
|
}
|
|
129
|
-
if (url.pathname ===
|
|
111
|
+
if (url.pathname === `/reply-obligations/${options.stopObligationPath ?? "turn-stop"}` && req.method === "GET") {
|
|
130
112
|
if (!options.onReplyObligations) return Response.json({});
|
|
131
113
|
return options.onReplyObligations()
|
|
132
114
|
.then((obligations) => Response.json(replyObligationStopDecision(obligations)))
|
|
@@ -194,11 +176,20 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
194
176
|
for (const pending of pendingDeliveries.values()) clearTimeout(pending.timer);
|
|
195
177
|
pendingDeliveries.clear();
|
|
196
178
|
for (const pending of pendingPermissionRequests.values()) clearTimeout(pending.timer);
|
|
179
|
+
for (const [approvalId, pending] of pendingPermissionRequests.entries()) {
|
|
180
|
+
const decision = withDecisionReason({
|
|
181
|
+
approvalId,
|
|
182
|
+
decision: "deny",
|
|
183
|
+
reason: PERMISSION_STOP_REASON,
|
|
184
|
+
}, "dismissed");
|
|
185
|
+
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
|
|
186
|
+
pending.resolve(decision);
|
|
187
|
+
}
|
|
197
188
|
pendingPermissionRequests.clear();
|
|
198
189
|
server.stop(true);
|
|
199
190
|
},
|
|
200
191
|
async deliverToMonitor(messages: Message[], timeoutMs = 30_000): Promise<number[]> {
|
|
201
|
-
|
|
192
|
+
if (monitors.size === 0) throw new Error(options.monitorUnavailableMessage ?? "no provider monitor connected");
|
|
202
193
|
const deliveryId = crypto.randomUUID();
|
|
203
194
|
const payload = JSON.stringify({ type: "message.deliver", deliveryId, messages });
|
|
204
195
|
return await new Promise<number[]>((resolve, reject) => {
|
|
@@ -219,17 +210,17 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
219
210
|
}
|
|
220
211
|
clearTimeout(pending.timer);
|
|
221
212
|
pendingPermissionRequests.delete(input.approvalId);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
pending.resolve(input);
|
|
213
|
+
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, input.approvalId);
|
|
214
|
+
recordResolvedApproval(resolvedApprovals, input.approvalId);
|
|
215
|
+
pending.resolve(withDecisionReason(input, input.decision === "deny" || input.decision === "abort" ? "dismissed" : "answered"));
|
|
225
216
|
options.onStatus({
|
|
226
217
|
status: "busy",
|
|
227
218
|
reason: "provider-turn",
|
|
228
219
|
providerState: {
|
|
229
220
|
state: "active",
|
|
230
221
|
reason: "permissionResolved",
|
|
231
|
-
label: "
|
|
232
|
-
source: "
|
|
222
|
+
label: options.permissionPromptHandler?.resolvedLabel ?? "Provider approval resolved",
|
|
223
|
+
source: options.permissionPromptHandler?.source ?? options.providerSourceId ?? "provider",
|
|
233
224
|
updatedAt: Date.now(),
|
|
234
225
|
},
|
|
235
226
|
});
|
|
@@ -238,6 +229,16 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
238
229
|
};
|
|
239
230
|
}
|
|
240
231
|
|
|
232
|
+
export function recordResolvedApproval(resolvedApprovals: Set<string>, approvalId: string, limit = RESOLVED_APPROVAL_LIMIT): void {
|
|
233
|
+
resolvedApprovals.delete(approvalId);
|
|
234
|
+
resolvedApprovals.add(approvalId);
|
|
235
|
+
while (resolvedApprovals.size > limit) {
|
|
236
|
+
const oldest = resolvedApprovals.values().next().value;
|
|
237
|
+
if (!oldest) break;
|
|
238
|
+
resolvedApprovals.delete(oldest);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
241
242
|
function replyObligationSummary(obligations: ReplyObligation[]): Record<string, unknown> {
|
|
242
243
|
const obligation = obligations[0];
|
|
243
244
|
if (!obligation) return { pending: false, count: 0 };
|
|
@@ -265,22 +266,43 @@ function replyObligationStopDecision(obligations: ReplyObligation[]): Record<str
|
|
|
265
266
|
};
|
|
266
267
|
}
|
|
267
268
|
|
|
269
|
+
function recoverStoredPermissionRequests(options: ControlServerOptions): void {
|
|
270
|
+
const store = options.pendingPermissionStore;
|
|
271
|
+
if (!store) return;
|
|
272
|
+
for (const record of bestEffortListPendingPermissionRequests(store)) {
|
|
273
|
+
bestEffortDeletePendingPermissionRequest(store, record.approvalId);
|
|
274
|
+
const decisionReason: ProviderPermissionDecisionReason = {
|
|
275
|
+
kind: Date.now() >= record.hookDeadlineAt ? "timeout" : "dismissed",
|
|
276
|
+
message: Date.now() >= record.hookDeadlineAt ? PERMISSION_TIMEOUT_REASON : PERMISSION_STOP_REASON,
|
|
277
|
+
};
|
|
278
|
+
options.onPermissionAbandoned?.({ ...record, reason: decisionReason.message ?? PERMISSION_STOP_REASON, decisionReason });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function withDecisionReason(input: ProviderPermissionDecisionInput, kind: ProviderPermissionDecisionReason["kind"]): ProviderPermissionDecisionInput {
|
|
283
|
+
if (input.decisionReason) return input;
|
|
284
|
+
return {
|
|
285
|
+
...input,
|
|
286
|
+
decisionReason: {
|
|
287
|
+
kind,
|
|
288
|
+
...(input.reason ? { message: input.reason } : {}),
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
268
293
|
async function handlePermissionRequest(
|
|
269
294
|
req: Request,
|
|
270
|
-
|
|
271
|
-
|
|
295
|
+
options: ControlServerOptions,
|
|
296
|
+
pendingPermissionRequests: Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>,
|
|
272
297
|
): Promise<Response> {
|
|
298
|
+
const handler = options.permissionPromptHandler;
|
|
299
|
+
if (!handler) return Response.json({ ok: false, reason: "permission prompt handler unavailable" }, { status: 501 });
|
|
273
300
|
const occurredAt = Date.now();
|
|
274
301
|
const body = await req.json().catch(() => null);
|
|
275
302
|
if (!isRecord(body)) return Response.json({});
|
|
276
|
-
// #435/#1116: flush any un-mirrored narrative
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// async/bursty and can lag the hook firing by a long margin. onSessionTurn now
|
|
280
|
-
// settle-polls for the exact tool_use entry that triggered this hook (toolName/
|
|
281
|
-
// toolInput below are that anchor) before draining, and honestly reports back whether
|
|
282
|
-
// it found the anchor or hit its bounded timeout — reasoningSettled reflects that,
|
|
283
|
-
// 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.
|
|
284
306
|
const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
|
|
285
307
|
const toolName = typeof body.tool_name === "string" ? body.tool_name : undefined;
|
|
286
308
|
const toolInput = isRecord(body.tool_input) ? body.tool_input : undefined;
|
|
@@ -290,7 +312,16 @@ async function handlePermissionRequest(
|
|
|
290
312
|
if (result && typeof result.reasoningSettled === "boolean") reasoningSettled = result.reasoningSettled;
|
|
291
313
|
}
|
|
292
314
|
const approvalId = crypto.randomUUID();
|
|
293
|
-
const view =
|
|
315
|
+
const view = handler.buildView(approvalId, body, reasoningSettled);
|
|
316
|
+
const waitMs = options.permissionWaitMs ?? PERMISSION_WAIT_MS;
|
|
317
|
+
const hookDeadlineAt = occurredAt + waitMs;
|
|
318
|
+
bestEffortUpsertPendingPermissionRequest(options.pendingPermissionStore, {
|
|
319
|
+
approvalId,
|
|
320
|
+
view,
|
|
321
|
+
hookDeadlineAt,
|
|
322
|
+
ownerInstanceId: options.instanceId ?? "",
|
|
323
|
+
occurredAt,
|
|
324
|
+
});
|
|
294
325
|
options.onStatus({
|
|
295
326
|
status: "busy",
|
|
296
327
|
reason: "provider-turn",
|
|
@@ -299,7 +330,7 @@ async function handlePermissionRequest(
|
|
|
299
330
|
reason: "permissionRequest",
|
|
300
331
|
label: view.title,
|
|
301
332
|
recommendedAction: "Review the permission request in Agent Relay.",
|
|
302
|
-
source:
|
|
333
|
+
source: handler.source,
|
|
303
334
|
pendingApproval: view,
|
|
304
335
|
updatedAt: Date.now(),
|
|
305
336
|
},
|
|
@@ -308,212 +339,17 @@ async function handlePermissionRequest(
|
|
|
308
339
|
const decision = await new Promise<ProviderPermissionDecisionInput>((resolve) => {
|
|
309
340
|
const timer = setTimeout(() => {
|
|
310
341
|
pendingPermissionRequests.delete(approvalId);
|
|
311
|
-
|
|
312
|
-
|
|
342
|
+
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
|
|
343
|
+
resolve(withDecisionReason({ approvalId, decision: "deny", reason: PERMISSION_TIMEOUT_REASON }, "timeout"));
|
|
344
|
+
}, waitMs);
|
|
313
345
|
pendingPermissionRequests.set(approvalId, { resolve, timer });
|
|
314
346
|
});
|
|
315
347
|
|
|
316
|
-
if (decision.
|
|
317
|
-
await Promise.resolve(options.onPermissionResolved(
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
return Response.json(claudePermissionHookResponse(decision, body));
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Build the normalized InteractivePrompt for a Claude PreToolUse permission/question hook.
|
|
324
|
-
// reasoningSettled is the caller's settle-poll result (#435/#723/#1116): true when
|
|
325
|
-
// handlePermissionRequest's pre-flush confirmed the turn's reasoning was drained before
|
|
326
|
-
// this view was built, false when it hit its bounded timeout without confirming. No
|
|
327
|
-
// `provider` tag — the server reads the typed contract, not provider identity.
|
|
328
|
-
function claudePermissionApprovalView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt {
|
|
329
|
-
const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
|
|
330
|
-
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
331
|
-
// AskUserQuestion is not a yes/no gate — it asks the user to pick answers.
|
|
332
|
-
// Surface the structured questions so the dashboard can render a form and
|
|
333
|
-
// return the selections via an "answer" decision (handled in the hook
|
|
334
|
-
// response below as a PreToolUse allow + updatedInput).
|
|
335
|
-
if (toolName === "AskUserQuestion" && Array.isArray(toolInput.questions) && toolInput.questions.length) {
|
|
336
|
-
const count = toolInput.questions.length;
|
|
337
|
-
return {
|
|
338
|
-
id,
|
|
339
|
-
kind: "questions",
|
|
340
|
-
title: count > 1 ? `Claude is asking ${count} questions` : "Claude is asking a question",
|
|
341
|
-
body: "",
|
|
342
|
-
questions: toolInput.questions as InteractivePrompt["questions"],
|
|
343
|
-
choices: [],
|
|
344
|
-
reasoningSettled,
|
|
345
|
-
};
|
|
348
|
+
if (decision.decisionReason?.kind !== "timeout" && options.onPermissionResolved) {
|
|
349
|
+
await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body, occurredAt))).catch(() => {});
|
|
346
350
|
}
|
|
347
|
-
// ExitPlanMode arrives through the generic PermissionRequest hook (it doesn't
|
|
348
|
-
// match the AskUserQuestion matcher), which used to render the raw tool_input
|
|
349
|
-
// JSON with generic Approve/Deny buttons. Surface the plan as markdown with the
|
|
350
|
-
// real plan-mode choices instead. approve → allow (exit plan mode and proceed);
|
|
351
|
-
// deny → keep planning.
|
|
352
|
-
if (toolName === "ExitPlanMode") {
|
|
353
|
-
const plan = typeof toolInput.plan === "string" && toolInput.plan.trim()
|
|
354
|
-
? toolInput.plan
|
|
355
|
-
: JSON.stringify(toolInput);
|
|
356
|
-
return {
|
|
357
|
-
id,
|
|
358
|
-
kind: "plan",
|
|
359
|
-
title: "Claude is ready to code",
|
|
360
|
-
body: plan,
|
|
361
|
-
choices: [
|
|
362
|
-
{ id: "approve", label: "Approve plan" },
|
|
363
|
-
{ id: "deny", label: "Keep planning" },
|
|
364
|
-
],
|
|
365
|
-
reasoningSettled,
|
|
366
|
-
};
|
|
367
|
-
}
|
|
368
|
-
const command = typeof toolInput.command === "string" ? toolInput.command : "";
|
|
369
|
-
const description = typeof toolInput.description === "string" ? toolInput.description : "";
|
|
370
|
-
const bodyText = [
|
|
371
|
-
command || description || JSON.stringify(toolInput),
|
|
372
|
-
typeof body.cwd === "string" ? `CWD: ${body.cwd}` : "",
|
|
373
|
-
typeof body.permission_mode === "string" ? `Mode: ${body.permission_mode}` : "",
|
|
374
|
-
].filter(Boolean).join("\n");
|
|
375
|
-
return {
|
|
376
|
-
id,
|
|
377
|
-
kind: toolName.toLowerCase() === "bash" ? "command" : "tool",
|
|
378
|
-
title: `Approve ${toolName}`,
|
|
379
|
-
body: bodyText,
|
|
380
|
-
choices: [
|
|
381
|
-
{ id: "approve", label: "Approve" },
|
|
382
|
-
{ id: "approve-session", label: "Approve session" },
|
|
383
|
-
{ id: "deny", label: "Deny" },
|
|
384
|
-
{ id: "abort", label: "Abort" },
|
|
385
|
-
],
|
|
386
|
-
reasoningSettled,
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
|
|
391
|
-
// AskUserQuestion comes through a PreToolUse hook. The only way to satisfy it
|
|
392
|
-
// headlessly is permissionDecision "allow" + updatedInput carrying the answers
|
|
393
|
-
// (echoing back the original questions). A bare "allow" is not sufficient, so
|
|
394
|
-
// anything that is not a populated answer is treated as a deny.
|
|
395
|
-
if (body.hook_event_name === "PreToolUse") {
|
|
396
|
-
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
397
|
-
if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
|
|
398
|
-
return {
|
|
399
|
-
hookSpecificOutput: {
|
|
400
|
-
hookEventName: "PreToolUse",
|
|
401
|
-
permissionDecision: "allow",
|
|
402
|
-
updatedInput: { ...toolInput, answers: decision.answers },
|
|
403
|
-
},
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
return {
|
|
407
|
-
hookSpecificOutput: {
|
|
408
|
-
hookEventName: "PreToolUse",
|
|
409
|
-
permissionDecision: "deny",
|
|
410
|
-
permissionDecisionReason: decision.reason || "Dismissed from Agent Relay dashboard",
|
|
411
|
-
},
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
const hookEventName = "PermissionRequest";
|
|
415
|
-
// AskUserQuestion can be gated by the PermissionRequest hook instead of PreToolUse
|
|
416
|
-
// (e.g. when the PreToolUse hook returned no decision and Claude fell back to the
|
|
417
|
-
// default permission flow). The answer must still be honored, not turned into a deny
|
|
418
|
-
// (#693). PermissionRequest supports updatedInput on an allow, same as PreToolUse.
|
|
419
|
-
if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
|
|
420
|
-
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
421
|
-
return {
|
|
422
|
-
hookSpecificOutput: {
|
|
423
|
-
hookEventName,
|
|
424
|
-
decision: {
|
|
425
|
-
behavior: "allow",
|
|
426
|
-
updatedInput: { ...toolInput, answers: decision.answers },
|
|
427
|
-
},
|
|
428
|
-
},
|
|
429
|
-
};
|
|
430
|
-
}
|
|
431
|
-
if (decision.decision === "approve" || decision.decision === "approve-session") {
|
|
432
|
-
const suggestions = Array.isArray(body.permission_suggestions) ? body.permission_suggestions : [];
|
|
433
|
-
return {
|
|
434
|
-
hookSpecificOutput: {
|
|
435
|
-
hookEventName,
|
|
436
|
-
decision: {
|
|
437
|
-
behavior: "allow",
|
|
438
|
-
...(decision.decision === "approve-session" && suggestions.length ? { updatedPermissions: suggestions } : {}),
|
|
439
|
-
},
|
|
440
|
-
},
|
|
441
|
-
};
|
|
442
|
-
}
|
|
443
|
-
return {
|
|
444
|
-
hookSpecificOutput: {
|
|
445
|
-
hookEventName,
|
|
446
|
-
decision: {
|
|
447
|
-
behavior: "deny",
|
|
448
|
-
message: decision.reason || "Denied from Agent Relay dashboard",
|
|
449
|
-
interrupt: decision.decision === "abort",
|
|
450
|
-
},
|
|
451
|
-
},
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ResolvedPermissionPrompt {
|
|
456
|
-
const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
|
|
457
|
-
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
458
|
-
const kind = view.kind === "questions" || view.kind === "plan" || view.kind === "command" || view.kind === "tool"
|
|
459
|
-
? view.kind
|
|
460
|
-
: toolName.toLowerCase() === "bash" ? "command" : "tool";
|
|
461
|
-
const questions = Array.isArray(view.questions)
|
|
462
|
-
? view.questions
|
|
463
|
-
: Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
|
|
464
|
-
const promptBody = typeof view.body === "string" ? view.body : "";
|
|
465
|
-
const decisionLabel = claudePermissionDecisionLabel(kind, decision.decision);
|
|
466
|
-
return {
|
|
467
|
-
provider: "claude",
|
|
468
|
-
kind,
|
|
469
|
-
title: typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`,
|
|
470
|
-
body: claudeResolvedPermissionBody({ kind, title: typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`, toolName, promptBody, decisionLabel, questions, answers: decision.answers }),
|
|
471
|
-
...(promptBody ? { promptBody } : {}),
|
|
472
|
-
toolName,
|
|
473
|
-
hookEventName: typeof body.hook_event_name === "string" ? body.hook_event_name : "PermissionRequest",
|
|
474
|
-
approvalId: decision.approvalId,
|
|
475
|
-
decision: decision.decision,
|
|
476
|
-
decisionLabel,
|
|
477
|
-
occurredAt,
|
|
478
|
-
resolvedAt: Date.now(),
|
|
479
|
-
...(questions ? { questions } : {}),
|
|
480
|
-
...(decision.answers ? { answers: decision.answers } : {}),
|
|
481
|
-
};
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
function claudeResolvedPermissionBody(input: { kind: ResolvedPermissionPrompt["kind"]; title: string; toolName: string; promptBody: string; decisionLabel: string; questions?: unknown[]; answers?: Record<string, string> }): string {
|
|
485
|
-
if (input.kind === "questions") {
|
|
486
|
-
return [
|
|
487
|
-
input.title,
|
|
488
|
-
input.decisionLabel,
|
|
489
|
-
...resolvedQuestionRows(input.questions, input.answers, input.decisionLabel).map((row) => `Q: ${row.question}\nA: ${row.answer}`),
|
|
490
|
-
].filter(Boolean).join("\n\n");
|
|
491
|
-
}
|
|
492
|
-
const subject = input.kind === "plan" ? "Plan prompt resolved" : `${input.toolName} permission resolved`;
|
|
493
|
-
return [subject, `Decision: ${input.decisionLabel}`, input.promptBody.trim()].filter(Boolean).join("\n\n");
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
function resolvedQuestionRows(questions: unknown[] | undefined, answers: Record<string, string> | undefined, fallback: string): Array<{ question: string; answer: string }> {
|
|
497
|
-
const answerMap = answers ?? {};
|
|
498
|
-
const rows = (questions ?? []).map((question, index) => {
|
|
499
|
-
const record = question && typeof question === "object" && !Array.isArray(question) ? question as Record<string, unknown> : {};
|
|
500
|
-
const label = typeof record.question === "string" && record.question.trim()
|
|
501
|
-
? record.question.trim()
|
|
502
|
-
: typeof record.header === "string" && record.header.trim() ? record.header.trim() : `Question ${index + 1}`;
|
|
503
|
-
const rawAnswer = answerMap[label] ?? answerMap[String(record.question ?? "")] ?? answerMap[String(record.header ?? "")];
|
|
504
|
-
return { question: label, answer: typeof rawAnswer === "string" && rawAnswer.trim() ? rawAnswer.trim() : fallback };
|
|
505
|
-
});
|
|
506
|
-
return rows.length ? rows : Object.entries(answerMap).map(([question, answer]) => ({ question, answer }));
|
|
507
|
-
}
|
|
508
351
|
|
|
509
|
-
|
|
510
|
-
if (kind === "questions") return decision === "answer" ? "Answered" : "Dismissed";
|
|
511
|
-
if (kind === "plan") return decision === "approve" || decision === "approve-session" ? "Approved plan" : "Kept planning";
|
|
512
|
-
if (decision === "approve-session") return "Approved for session";
|
|
513
|
-
if (decision === "approve") return "Approved";
|
|
514
|
-
if (decision === "abort") return "Aborted";
|
|
515
|
-
if (decision === "answer") return "Answered";
|
|
516
|
-
return "Denied";
|
|
352
|
+
return Response.json(handler.buildHookResponse(decision, body));
|
|
517
353
|
}
|
|
518
354
|
|
|
519
355
|
async function handleSessionTurn(req: Request, options: ControlServerOptions): Promise<Response> {
|
|
@@ -546,7 +382,7 @@ async function handleUserPrompt(req: Request, options: ControlServerOptions): Pr
|
|
|
546
382
|
if (!prompt.trim()) return Response.json({ ok: false, reason: "prompt required" }, { status: 400 });
|
|
547
383
|
const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
|
|
548
384
|
const promptId = isRecord(body) && typeof body.promptId === "string" && body.promptId ? body.promptId : undefined;
|
|
549
|
-
// Fire-and-forget: the hook must not block
|
|
385
|
+
// Fire-and-forget: the hook must not block the provider's turn on relay round-trips.
|
|
550
386
|
void Promise.resolve(options.onUserPrompt({ prompt, transcriptPath, ...(promptId ? { promptId } : {}) })).catch(() => {});
|
|
551
387
|
return Response.json({ ok: true });
|
|
552
388
|
}
|
|
@@ -556,7 +392,7 @@ async function handleSessionBoundary(req: Request, options: ControlServerOptions
|
|
|
556
392
|
const body = await req.json().catch(() => null);
|
|
557
393
|
const reason = isRecord(body) && typeof body.reason === "string" ? body.reason : undefined;
|
|
558
394
|
const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
|
|
559
|
-
// Fire-and-forget: a
|
|
395
|
+
// Fire-and-forget: a lifecycle hook must not block the provider compacting or exiting.
|
|
560
396
|
void Promise.resolve(options.onSessionBoundary({ reason, transcriptPath })).catch(() => {});
|
|
561
397
|
return Response.json({ ok: true });
|
|
562
398
|
}
|
|
@@ -619,8 +455,7 @@ async function handleStatus(req: Request, options: ControlServerOptions): Promis
|
|
|
619
455
|
}
|
|
620
456
|
|
|
621
457
|
// #286: the stop-failure hook reports a usage/rate-limit stall here. Build the
|
|
622
|
-
// provider-neutral `blocked` state (reason rate_limit)
|
|
623
|
-
// model-unavailable and Codex approvals ride — so the dashboard shows it
|
|
458
|
+
// provider-neutral `blocked` state (reason rate_limit) so the dashboard shows it
|
|
624
459
|
// distinctly (never as idle/available) and the relay's resume sweep can lift it
|
|
625
460
|
// at reset. The agent stays `idle` underneath (the turn truly ended); the runner
|
|
626
461
|
// carries the hold via its rateLimitHold field, not an active-work claim.
|
|
@@ -635,7 +470,7 @@ async function handleRateLimit(req: Request, options: ControlServerOptions): Pro
|
|
|
635
470
|
options.onStatus({
|
|
636
471
|
status: "idle",
|
|
637
472
|
clear: ["subagent"],
|
|
638
|
-
providerState: buildRateLimitProviderState({ errorType, resetAt, message: errorMessage, source: "
|
|
473
|
+
providerState: buildRateLimitProviderState({ errorType, resetAt, message: errorMessage, source: options.providerSourceId ?? "provider" }),
|
|
639
474
|
});
|
|
640
475
|
return Response.json({ ok: true, errorType, ...(resetAt ? { resetAt } : {}) });
|
|
641
476
|
}
|
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,
|