agent-relay-runner 0.127.6 → 0.127.8
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 +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapter.ts +10 -0
- package/src/adapters/claude-permission.ts +18 -0
- package/src/adapters/claude-session-capture.ts +27 -2
- package/src/adapters/claude-tmux.ts +133 -0
- package/src/adapters/claude-transcript.ts +29 -0
- package/src/adapters/claude.ts +6 -101
- package/src/control-server.ts +114 -25
- package/src/runner-core.ts +45 -1
package/package.json
CHANGED
package/src/adapter.ts
CHANGED
|
@@ -92,6 +92,11 @@ export interface ProviderSessionTurnContext {
|
|
|
92
92
|
|
|
93
93
|
export interface ProviderSessionTurnResult {
|
|
94
94
|
reasoningSettled?: boolean;
|
|
95
|
+
// The transcript-embedded event time of the exact tool_use anchor that triggered this
|
|
96
|
+
// pre-flush harvest (when resolvable) — a more accurate stamp for a resolved permission
|
|
97
|
+
// prompt's display ordering than the hook's own wall-clock receipt time, which a provider
|
|
98
|
+
// can flush/timestamp its transcript entry after.
|
|
99
|
+
toolUseOccurredAt?: number;
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
export interface ProviderUserPromptInput {
|
|
@@ -124,6 +129,11 @@ export interface ProviderPermissionPromptHandler {
|
|
|
124
129
|
buildView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt;
|
|
125
130
|
buildHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown>;
|
|
126
131
|
buildResolvedPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ProviderPermissionResolvedPrompt;
|
|
132
|
+
// #1173: for prompt kinds whose PreToolUse hook is answered immediately (deny-then-hold —
|
|
133
|
+
// see control-server.ts's AskUserQuestion branch) rather than held open for the decision,
|
|
134
|
+
// this renders the eventual "answer" decision as the normal user-prompt text to inject back
|
|
135
|
+
// into the session. Absent for prompt kinds that still ride the hook response directly.
|
|
136
|
+
buildAnswerInjectionPrompt?(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): string;
|
|
127
137
|
}
|
|
128
138
|
|
|
129
139
|
export interface ProviderConfig {
|
|
@@ -12,6 +12,7 @@ export const claudePermissionPromptHandler: ProviderPermissionPromptHandler = {
|
|
|
12
12
|
buildView: claudePermissionApprovalView,
|
|
13
13
|
buildHookResponse: claudePermissionHookResponse,
|
|
14
14
|
buildResolvedPrompt: claudeResolvedPermissionPrompt,
|
|
15
|
+
buildAnswerInjectionPrompt: claudeAnswerInjectionPrompt,
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
function claudePermissionApprovalView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt {
|
|
@@ -155,6 +156,23 @@ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: Provi
|
|
|
155
156
|
};
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
// #1173: AskUserQuestion's PreToolUse hook is answered with `deny` immediately (see
|
|
160
|
+
// control-server.ts) so Claude flushes its buffered reasoning instead of blocking on the human.
|
|
161
|
+
// The user's actual answer arrives afterward as a normal injected prompt, not as a hook
|
|
162
|
+
// response — this renders that prompt's text from the same view/decision shape the old
|
|
163
|
+
// blocking flow used, so the model sees a clear, structured hand-off instead of silence.
|
|
164
|
+
function claudeAnswerInjectionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): string {
|
|
165
|
+
const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
|
|
166
|
+
const questions = Array.isArray(view.questions) ? view.questions : Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
|
|
167
|
+
const rows = resolvedQuestionRows(questions, decision.answers, "(no answer given)");
|
|
168
|
+
const qa = rows.map((row) => `Q: ${row.question}\nA: ${row.answer}`).join("\n\n");
|
|
169
|
+
return [
|
|
170
|
+
"This was answered by the user via the Agent Relay dashboard — the AskUserQuestion tool call itself was intercepted and will not resolve; it will not return a result. Here is their answer:",
|
|
171
|
+
qa,
|
|
172
|
+
"Continue the task using their answer(s) above — do not call AskUserQuestion again for the same question(s), and do not wait for the original tool call to return.",
|
|
173
|
+
].join("\n\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
158
176
|
function claudeResolvedPermissionBody(input: { kind: string; title: string; toolName: string; promptBody: string; decisionLabel: string; questions?: unknown[]; answers?: Record<string, string> }): string {
|
|
159
177
|
if (input.kind === "questions") {
|
|
160
178
|
return [
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
stepDedupKeys,
|
|
14
14
|
transcriptHasToolUseAnchor,
|
|
15
15
|
transcriptLooksComplete,
|
|
16
|
+
transcriptToolUseOccurredAt,
|
|
16
17
|
} from "./claude-transcript";
|
|
17
18
|
import { IncrementalClaudeTranscriptTail } from "./claude-transcript-tail";
|
|
18
19
|
|
|
@@ -23,10 +24,22 @@ interface ReasoningTailState {
|
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
const REASONING_POLL_MS = 1_200;
|
|
27
|
+
// Bounded the same way as control-server.ts's resolvedApprovals so a long session's dedup
|
|
28
|
+
// guard (see emittedStepKeys) can't grow without limit.
|
|
29
|
+
const EMITTED_STEP_KEY_LIMIT = 2048;
|
|
26
30
|
|
|
27
31
|
export class ClaudeSessionCapture {
|
|
28
32
|
private narrativeFlushEntryCount = 0;
|
|
29
33
|
private reasoningTail?: ReasoningTailState;
|
|
34
|
+
// Step signatures already published to chat — by the deny-then-hold pre-flush harvest or a
|
|
35
|
+
// prior reasoning-tail instance — kept for the life of this capture (not reset when a new
|
|
36
|
+
// tail starts). A fresh tail is created on every new prompt (see startReasoningTail),
|
|
37
|
+
// including the runner's own injected AskUserQuestion answer; it re-reads the transcript
|
|
38
|
+
// from byte 0 and recomputes "latest turn" steps from scratch, so if that first poll lands
|
|
39
|
+
// before the injected prompt's own transcript entry has advanced the "latest turn" window,
|
|
40
|
+
// it can re-derive the exact same step signatures the earlier tail already emitted. Seeding
|
|
41
|
+
// each new tail's `seen` set from this closes that gap regardless of the exact timing.
|
|
42
|
+
private emittedStepKeys = new Set<string>();
|
|
30
43
|
private sessionEventCb: (event: ProviderSessionEvent) => void = () => {};
|
|
31
44
|
private sessionEventsCb: (events: ProviderSessionEventBatch) => void = (events) => { for (const event of events) this.sessionEventCb(event); };
|
|
32
45
|
|
|
@@ -55,7 +68,8 @@ export class ClaudeSessionCapture {
|
|
|
55
68
|
(ok) => { if (!ok) ctx.log(`pre-flush session outbox incomplete before blocking control (${ctx.pendingSessionEventCount()} pending)`); },
|
|
56
69
|
(error) => ctx.log(`pre-flush session outbox failed before blocking control: ${errMessage(error)}`),
|
|
57
70
|
);
|
|
58
|
-
|
|
71
|
+
const toolUseOccurredAt = input.toolName ? transcriptToolUseOccurredAt(jsonl, input.toolName, input.toolInput) : undefined;
|
|
72
|
+
return { reasoningSettled: settled.settled, ...(toolUseOccurredAt ? { toolUseOccurredAt } : {}) };
|
|
59
73
|
}
|
|
60
74
|
|
|
61
75
|
const turnId = input.promptId ?? ctx.turnId;
|
|
@@ -129,7 +143,7 @@ export class ClaudeSessionCapture {
|
|
|
129
143
|
private startReasoningTail(transcriptPath: string, ctx: ProviderSessionTurnContext): void {
|
|
130
144
|
if (ctx.reasoningCapture === false) return;
|
|
131
145
|
this.stopSessionTrace();
|
|
132
|
-
const seen = new Set<string>();
|
|
146
|
+
const seen = new Set<string>(this.emittedStepKeys);
|
|
133
147
|
const emittedNarrationKeys = new Set<string>();
|
|
134
148
|
const transcriptTail = new IncrementalClaudeTranscriptTail();
|
|
135
149
|
const turnIdAtStart = ctx.turnId;
|
|
@@ -153,6 +167,7 @@ export class ClaudeSessionCapture {
|
|
|
153
167
|
for (const { sig, step } of keyed) {
|
|
154
168
|
if (seen.has(sig)) continue;
|
|
155
169
|
seen.add(sig);
|
|
170
|
+
this.rememberEmittedStepKey(sig);
|
|
156
171
|
emitted += 1;
|
|
157
172
|
events.push({
|
|
158
173
|
type: step.type,
|
|
@@ -177,6 +192,16 @@ export class ClaudeSessionCapture {
|
|
|
177
192
|
void poll();
|
|
178
193
|
}
|
|
179
194
|
|
|
195
|
+
private rememberEmittedStepKey(sig: string): void {
|
|
196
|
+
this.emittedStepKeys.delete(sig);
|
|
197
|
+
this.emittedStepKeys.add(sig);
|
|
198
|
+
while (this.emittedStepKeys.size > EMITTED_STEP_KEY_LIMIT) {
|
|
199
|
+
const oldest = this.emittedStepKeys.values().next().value;
|
|
200
|
+
if (oldest === undefined) break;
|
|
201
|
+
this.emittedStepKeys.delete(oldest);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
180
205
|
private preFlushBodyAlreadyMirroredByReasoningTail(jsonl: string, body: string): boolean {
|
|
181
206
|
const tail = this.reasoningTail;
|
|
182
207
|
if (!tail || !tail.emittedNarrationKeys.size) return false;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Claude tmux transport plumbing (extracted from ./claude for the file-size
|
|
2
|
+
// ratchet, #300/#291): session/socket naming, pane capture, input-ready gating,
|
|
3
|
+
// paste/submit injection, launcher-script materialization, and env-key selection.
|
|
4
|
+
// The adapter (./claude) keeps the lifecycle decisions; this module owns the
|
|
5
|
+
// mechanical tmux interactions they ride on. Public symbols are re-exported from
|
|
6
|
+
// ./claude so existing import paths (including claude.test.ts) keep resolving.
|
|
7
|
+
import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
10
|
+
import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
|
|
11
|
+
import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
|
|
12
|
+
import { claudePaneLooksReady } from "./claude-status-detectors";
|
|
13
|
+
import { launcherScriptPathForSession } from "../session-scratch";
|
|
14
|
+
|
|
15
|
+
export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
|
|
16
|
+
export const CLAUDE_TMUX_SUBMIT_KEY = "C-m";
|
|
17
|
+
export const CLAUDE_TMUX_READY_TIMEOUT_MS = 10_000;
|
|
18
|
+
|
|
19
|
+
export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
|
|
20
|
+
if (label) return `${prefix}-${sanitizeFsName(label, { replacement: "-", collapse: false, lowercase: true })}`;
|
|
21
|
+
return `${prefix}-${instanceId.slice(0, 8)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function tmuxSocketName(sessionName: string): string {
|
|
25
|
+
const socketName = sanitizeFsName(`agent-relay-${sessionName}`, { replacement: "-", collapse: false, lowercase: true });
|
|
26
|
+
assertTmuxSocketPathFits(socketName);
|
|
27
|
+
return socketName;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function tmuxSocketPath(socketName: string, socketDir = defaultTmuxSocketDir()): string {
|
|
31
|
+
return join(socketDir, socketName);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function assertTmuxSocketPathFits(socketName: string, socketDir = defaultTmuxSocketDir()): void {
|
|
35
|
+
const path = tmuxSocketPath(socketName, socketDir);
|
|
36
|
+
const bytes = Buffer.byteLength(path);
|
|
37
|
+
if (bytes < 108) return;
|
|
38
|
+
throw new Error(`derived name too long for tmux socket; pass a short \`label\` (socket path is ${bytes} bytes; must be < 108)`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function defaultTmuxSocketDir(): string {
|
|
42
|
+
const base = process.env.TMUX_TMPDIR || "/tmp";
|
|
43
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : "user";
|
|
44
|
+
return join(base, `tmux-${uid}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function captureTmuxPane(sessionName: string, socketName?: string): string {
|
|
48
|
+
const result = Bun.spawnSync(tmuxCommand(socketName, "capture-pane", "-p", "-t", sessionName, "-S", "-80"), {
|
|
49
|
+
stdin: "ignore", stdout: "pipe", stderr: "pipe",
|
|
50
|
+
});
|
|
51
|
+
if (result.exitCode !== 0) {
|
|
52
|
+
const stderr = result.stderr.toString().trim();
|
|
53
|
+
throw new Error(`tmux capture-pane failed: ${stderr || `exit code ${result.exitCode}`}`);
|
|
54
|
+
}
|
|
55
|
+
return result.stdout.toString();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
|
|
59
|
+
const deadline = Date.now() + timeoutMs;
|
|
60
|
+
while (Date.now() < deadline) {
|
|
61
|
+
if (!tmuxHasSession(sessionName, socketName)) throw new Error("tmux session exited before Claude became ready");
|
|
62
|
+
if (claudePaneLooksReady(captureTmuxPane(sessionName, socketName))) return;
|
|
63
|
+
await Bun.sleep(200);
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Claude input was not ready within ${timeoutMs}ms`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function pasteTextIntoTmux(sessionName: string, text: string, socketName?: string): void {
|
|
69
|
+
const bufferName = `agent-relay-${crypto.randomUUID()}`;
|
|
70
|
+
const setResult = Bun.spawnSync(tmuxCommand(socketName, "set-buffer", "-b", bufferName, text), {
|
|
71
|
+
stdin: "ignore", stdout: "ignore", stderr: "pipe",
|
|
72
|
+
});
|
|
73
|
+
if (setResult.exitCode !== 0) {
|
|
74
|
+
const stderr = setResult.stderr.toString().trim();
|
|
75
|
+
throw new Error(`tmux set-buffer failed: ${stderr || `exit code ${setResult.exitCode}`}`);
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const pasteResult = Bun.spawnSync(tmuxCommand(socketName, "paste-buffer", "-b", bufferName, "-t", sessionName), {
|
|
79
|
+
stdin: "ignore", stdout: "ignore", stderr: "pipe",
|
|
80
|
+
});
|
|
81
|
+
if (pasteResult.exitCode !== 0) {
|
|
82
|
+
const stderr = pasteResult.stderr.toString().trim();
|
|
83
|
+
throw new Error(`tmux paste-buffer failed: ${stderr || `exit code ${pasteResult.exitCode}`}`);
|
|
84
|
+
}
|
|
85
|
+
} finally {
|
|
86
|
+
Bun.spawnSync(tmuxCommand(socketName, "delete-buffer", "-b", bufferName), {
|
|
87
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function submitTextToTmux(sessionName: string, text: string, socketName?: string): Promise<void> {
|
|
93
|
+
pasteTextIntoTmux(sessionName, text, socketName);
|
|
94
|
+
await Bun.sleep(CLAUDE_TMUX_SUBMIT_DELAY_MS);
|
|
95
|
+
const result = Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, CLAUDE_TMUX_SUBMIT_KEY), {
|
|
96
|
+
stdin: "ignore", stdout: "ignore", stderr: "pipe",
|
|
97
|
+
});
|
|
98
|
+
if (result.exitCode !== 0) {
|
|
99
|
+
const stderr = result.stderr.toString().trim();
|
|
100
|
+
throw new Error(`tmux submit failed: ${stderr || `exit code ${result.exitCode}`}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// tmux new-session has a hard command-length limit that includes every argv item,
|
|
105
|
+
// including `-e KEY=value`. The launcher script keeps tmux argv bounded by carrying
|
|
106
|
+
// both env payloads (profile JSON, tokens, etc.) and the provider command/prompt.
|
|
107
|
+
export function writeLauncherScript(sessionName: string, env: Record<string, string>, shellCmd: string): string {
|
|
108
|
+
const scriptPath = launcherScriptPathForSession(sessionName);
|
|
109
|
+
const launcherDir = dirname(scriptPath); mkdirSync(launcherDir, { recursive: true, mode: 0o700 }); chmodSync(launcherDir, 0o700);
|
|
110
|
+
const exports = Object.entries(env)
|
|
111
|
+
.map(([key, value]) => `export ${shellEnvKey(key)}=${shellQuote(value)}`)
|
|
112
|
+
.join("\n");
|
|
113
|
+
writeFileSync(scriptPath, `#!/usr/bin/env bash\n${exports ? `${exports}\n` : ""}exec ${shellCmd}\n`, { mode: 0o700 });
|
|
114
|
+
return scriptPath;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function shellEnvKey(key: string): string {
|
|
118
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return key;
|
|
119
|
+
throw new Error(`invalid environment variable name for Claude launcher: ${key}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>): string[] {
|
|
123
|
+
const keys = new Set<string>();
|
|
124
|
+
for (const key of Object.keys(env)) {
|
|
125
|
+
if (key.startsWith("AGENT_RELAY_") || key.startsWith("CLAUDE_")) {
|
|
126
|
+
keys.add(key);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const key of Object.keys(providerEnv)) {
|
|
130
|
+
keys.add(key);
|
|
131
|
+
}
|
|
132
|
+
return [...keys].sort();
|
|
133
|
+
}
|
|
@@ -267,6 +267,35 @@ export function transcriptHasToolUseAnchor(jsonl: string, toolName: string, tool
|
|
|
267
267
|
return false;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The transcript-embedded `timestamp` of the specific assistant entry carrying the exact
|
|
272
|
+
* tool_use block that triggered a PreToolUse hook (same anchor match as
|
|
273
|
+
* transcriptHasToolUseAnchor above). Used to stamp a resolved permission prompt with the
|
|
274
|
+
* tool call's real event time — a hook's own wall-clock receipt time can end up earlier than
|
|
275
|
+
* the entry the provider eventually flushes for that same call, which otherwise sorts the
|
|
276
|
+
* resolved prompt ahead of the narration that led up to it (#1185).
|
|
277
|
+
*/
|
|
278
|
+
export function transcriptToolUseOccurredAt(jsonl: string, toolName: string, toolInput: unknown): number | undefined {
|
|
279
|
+
const wantInput = canonicalJson(toolInput ?? {});
|
|
280
|
+
for (const line of jsonl.split("\n")) {
|
|
281
|
+
const trimmed = line.trim();
|
|
282
|
+
if (!trimmed) continue;
|
|
283
|
+
let entry: TranscriptEntry;
|
|
284
|
+
try {
|
|
285
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
286
|
+
} catch {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (entry.type !== "assistant" || isSidechainEntry(entry)) continue;
|
|
290
|
+
for (const block of blocks(entry.message)) {
|
|
291
|
+
if (block.type === "tool_use" && block.name === toolName && canonicalJson(block.input ?? {}) === wantInput) {
|
|
292
|
+
return transcriptEntryOccurredAt(entry);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
270
299
|
/**
|
|
271
300
|
* Extract the ordered narration, reasoning, and tool steps for the most recent
|
|
272
301
|
* turn (since the last real user prompt). Used by the reasoning tailer to stream
|
package/src/adapters/claude.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { type Message } from "agent-relay-sdk";
|
|
5
|
-
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
6
5
|
import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
|
|
7
6
|
import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
|
|
8
7
|
import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
|
|
@@ -14,8 +13,8 @@ import { claudeProviderMessageText } from "./claude-delivery";
|
|
|
14
13
|
import { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type ClaudeProbeActivity } from "./claude-session-probe";
|
|
15
14
|
import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "./claude-prompt-gates";
|
|
16
15
|
import { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
|
|
17
|
-
import { launcherScriptPathForSession } from "../session-scratch";
|
|
18
16
|
import { ClaudeSessionCapture, collectClaudeTranscriptArchiveSegment, collectClaudeTranscriptSessionEvents } from "./claude-session-capture";
|
|
17
|
+
import { CLAUDE_TMUX_READY_TIMEOUT_MS, captureTmuxPane, submitTextToTmux, tmuxEnvKeys, tmuxSessionName, tmuxSocketName, waitForClaudeInputReady, writeLauncherScript } from "./claude-tmux";
|
|
19
18
|
import { claudePermissionPromptHandler } from "./claude-permission";
|
|
20
19
|
|
|
21
20
|
export class ClaudeAdapter implements ProviderAdapter {
|
|
@@ -468,9 +467,9 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
468
467
|
}
|
|
469
468
|
|
|
470
469
|
export const CLAUDE_EXIT_COMMAND = "/exit";
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
export
|
|
470
|
+
// tmux transport plumbing moved to ./claude-tmux (#300 ratchet); re-exported so
|
|
471
|
+
// existing `./claude` consumers + tests keep their import paths.
|
|
472
|
+
export { CLAUDE_TMUX_SUBMIT_DELAY_MS, CLAUDE_TMUX_SUBMIT_KEY, CLAUDE_TMUX_READY_TIMEOUT_MS, tmuxSessionName, tmuxSocketName, tmuxSocketPath, assertTmuxSocketPathFits, tmuxEnvKeys, waitForClaudeInputReady, submitTextToTmux } from "./claude-tmux";
|
|
474
473
|
// Monitorless turn detection (Fix: isolated agents reach task "done" like Codex).
|
|
475
474
|
const CLAUDE_TURN_WATCH_ID = "tmux-turn";
|
|
476
475
|
const CLAUDE_TURN_POLL_MS = 1500;
|
|
@@ -528,107 +527,13 @@ function stripClaudePermissionArgs(args: string[], stripToolArgs = false): strin
|
|
|
528
527
|
}
|
|
529
528
|
|
|
530
529
|
|
|
531
|
-
export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
|
|
532
|
-
if (label) return `${prefix}-${sanitizeFsName(label, { replacement: "-", collapse: false, lowercase: true })}`;
|
|
533
|
-
return `${prefix}-${instanceId.slice(0, 8)}`;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
export function tmuxSocketName(sessionName: string): string {
|
|
537
|
-
return sanitizeFsName(`agent-relay-${sessionName}`, { replacement: "-", collapse: false, lowercase: true });
|
|
538
|
-
}
|
|
539
|
-
|
|
540
530
|
// Shared tmux helpers; tmuxHasSession re-exported for ./claude consumers + tests.
|
|
541
531
|
export { tmuxHasSession };
|
|
542
532
|
|
|
543
|
-
function captureTmuxPane(sessionName: string, socketName?: string): string {
|
|
544
|
-
const result = Bun.spawnSync(tmuxCommand(socketName, "capture-pane", "-p", "-t", sessionName, "-S", "-80"), {
|
|
545
|
-
stdin: "ignore", stdout: "pipe", stderr: "pipe",
|
|
546
|
-
});
|
|
547
|
-
if (result.exitCode !== 0) {
|
|
548
|
-
const stderr = result.stderr.toString().trim();
|
|
549
|
-
throw new Error(`tmux capture-pane failed: ${stderr || `exit code ${result.exitCode}`}`);
|
|
550
|
-
}
|
|
551
|
-
return result.stdout.toString();
|
|
552
|
-
}
|
|
553
|
-
|
|
554
533
|
// Re-export pane-status detectors (moved to ./claude-status-detectors) so existing
|
|
555
534
|
// import paths (including claude.test.ts) continue to resolve via this module.
|
|
556
535
|
export { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
|
|
557
536
|
|
|
558
|
-
async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
|
|
559
|
-
const deadline = Date.now() + timeoutMs;
|
|
560
|
-
while (Date.now() < deadline) {
|
|
561
|
-
if (!tmuxHasSession(sessionName, socketName)) throw new Error("tmux session exited before Claude became ready");
|
|
562
|
-
if (claudePaneLooksReady(captureTmuxPane(sessionName, socketName))) return;
|
|
563
|
-
await Bun.sleep(200);
|
|
564
|
-
}
|
|
565
|
-
throw new Error(`Claude input was not ready within ${timeoutMs}ms`);
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
function pasteTextIntoTmux(sessionName: string, text: string, socketName?: string): void {
|
|
569
|
-
const bufferName = `agent-relay-${crypto.randomUUID()}`;
|
|
570
|
-
const setResult = Bun.spawnSync(tmuxCommand(socketName, "set-buffer", "-b", bufferName, text), {
|
|
571
|
-
stdin: "ignore", stdout: "ignore", stderr: "pipe",
|
|
572
|
-
});
|
|
573
|
-
if (setResult.exitCode !== 0) {
|
|
574
|
-
const stderr = setResult.stderr.toString().trim();
|
|
575
|
-
throw new Error(`tmux set-buffer failed: ${stderr || `exit code ${setResult.exitCode}`}`);
|
|
576
|
-
}
|
|
577
|
-
try {
|
|
578
|
-
const pasteResult = Bun.spawnSync(tmuxCommand(socketName, "paste-buffer", "-b", bufferName, "-t", sessionName), {
|
|
579
|
-
stdin: "ignore", stdout: "ignore", stderr: "pipe",
|
|
580
|
-
});
|
|
581
|
-
if (pasteResult.exitCode !== 0) {
|
|
582
|
-
const stderr = pasteResult.stderr.toString().trim();
|
|
583
|
-
throw new Error(`tmux paste-buffer failed: ${stderr || `exit code ${pasteResult.exitCode}`}`);
|
|
584
|
-
}
|
|
585
|
-
} finally {
|
|
586
|
-
Bun.spawnSync(tmuxCommand(socketName, "delete-buffer", "-b", bufferName), {
|
|
587
|
-
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
588
|
-
});
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
async function submitTextToTmux(sessionName: string, text: string, socketName?: string): Promise<void> {
|
|
593
|
-
pasteTextIntoTmux(sessionName, text, socketName);
|
|
594
|
-
await Bun.sleep(CLAUDE_TMUX_SUBMIT_DELAY_MS);
|
|
595
|
-
const result = Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, CLAUDE_TMUX_SUBMIT_KEY), {
|
|
596
|
-
stdin: "ignore", stdout: "ignore", stderr: "pipe",
|
|
597
|
-
});
|
|
598
|
-
if (result.exitCode !== 0) {
|
|
599
|
-
const stderr = result.stderr.toString().trim();
|
|
600
|
-
throw new Error(`tmux submit failed: ${stderr || `exit code ${result.exitCode}`}`);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
function writeLauncherScript(sessionName: string, env: Record<string, string>, shellCmd: string): string {
|
|
605
|
-
const scriptPath = launcherScriptPathForSession(sessionName);
|
|
606
|
-
const launcherDir = dirname(scriptPath); mkdirSync(launcherDir, { recursive: true, mode: 0o700 }); chmodSync(launcherDir, 0o700);
|
|
607
|
-
const exports = Object.entries(env)
|
|
608
|
-
.map(([key, value]) => `export ${shellEnvKey(key)}=${shellQuote(value)}`)
|
|
609
|
-
.join("\n");
|
|
610
|
-
writeFileSync(scriptPath, `#!/usr/bin/env bash\n${exports ? `${exports}\n` : ""}exec ${shellCmd}\n`, { mode: 0o700 });
|
|
611
|
-
return scriptPath;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function shellEnvKey(key: string): string {
|
|
615
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return key;
|
|
616
|
-
throw new Error(`invalid environment variable name for Claude launcher: ${key}`);
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>): string[] {
|
|
620
|
-
const keys = new Set<string>();
|
|
621
|
-
for (const key of Object.keys(env)) {
|
|
622
|
-
if (key.startsWith("AGENT_RELAY_") || key.startsWith("CLAUDE_")) {
|
|
623
|
-
keys.add(key);
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
for (const key of Object.keys(providerEnv)) {
|
|
627
|
-
keys.add(key);
|
|
628
|
-
}
|
|
629
|
-
return [...keys].sort();
|
|
630
|
-
}
|
|
631
|
-
|
|
632
537
|
// Shared shell-quoting; re-exported so `./claude` consumers + tests resolve it.
|
|
633
538
|
export { shellQuote };
|
|
634
539
|
|
package/src/control-server.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Server, ServerWebSocket } from "bun";
|
|
2
|
-
import type { Message, ReplyObligation } from "agent-relay-sdk";
|
|
2
|
+
import type { InteractivePrompt, Message, ReplyObligation } from "agent-relay-sdk";
|
|
3
3
|
import { errMessage, isRecord } from "agent-relay-sdk";
|
|
4
4
|
import type { ProviderPermissionDecisionInput, ProviderPermissionDecisionReason, ProviderPermissionPromptHandler, ProviderPermissionResolvedPrompt, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
|
|
5
5
|
import { obligationRequiresExplicitReply } from "./reply-obligation-cache";
|
|
@@ -18,6 +18,11 @@ 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
19
|
const PERMISSION_STOP_REASON = "The runner restarted while this permission request was pending; the original hook is no longer connected.";
|
|
20
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.";
|
|
21
26
|
|
|
22
27
|
// A hook that failed in a way it could not handle itself reports here so the
|
|
23
28
|
// failure is never silent (#198 item 5). Phase 1 logs it FATAL to the per-agent
|
|
@@ -34,6 +39,12 @@ interface MonitorSocketData {
|
|
|
34
39
|
|
|
35
40
|
type MonitorSocket = ServerWebSocket<MonitorSocketData>;
|
|
36
41
|
|
|
42
|
+
interface PendingPermissionWaiter {
|
|
43
|
+
resolve(input: ProviderPermissionDecisionInput): void;
|
|
44
|
+
timer: Timer;
|
|
45
|
+
suppressResolvedStatus?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
export interface ControlServer {
|
|
38
49
|
port: number;
|
|
39
50
|
url: string;
|
|
@@ -49,9 +60,14 @@ interface ControlServerOptions {
|
|
|
49
60
|
// Provider session-final and pre-flush hooks hand over their capture source so
|
|
50
61
|
// the runner can surface the provider turn in dashboard chat without the agent
|
|
51
62
|
// re-emitting it via /reply.
|
|
52
|
-
onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean } | void>;
|
|
63
|
+
onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean; toolUseOccurredAt?: number } | void>;
|
|
53
64
|
onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
|
|
54
65
|
onPermissionAbandoned?(input: PendingPermissionRequestRecord & { reason: string; decisionReason: ProviderPermissionDecisionReason }): Promise<void> | void;
|
|
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;
|
|
55
71
|
// A provider user-prompt hook hands over text typed directly into the session
|
|
56
72
|
// so the runner can mirror it into dashboard chat.
|
|
57
73
|
onUserPrompt?(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void>;
|
|
@@ -78,7 +94,7 @@ export type ResolvedPermissionPrompt = ProviderPermissionResolvedPrompt;
|
|
|
78
94
|
export function startControlServer(options: ControlServerOptions): ControlServer {
|
|
79
95
|
const monitors = new Set<MonitorSocket>();
|
|
80
96
|
const pendingDeliveries = new Map<string, { resolve(ids: number[]): void; timer: Timer }>();
|
|
81
|
-
const pendingPermissionRequests = new Map<string,
|
|
97
|
+
const pendingPermissionRequests = new Map<string, PendingPermissionWaiter>();
|
|
82
98
|
// approvalIds already resolved, so a duplicate decision (e.g. bus replay after a
|
|
83
99
|
// reconnect, or both the PreToolUse and PermissionRequest hooks racing one answer)
|
|
84
100
|
// is an idempotent no-op instead of a silent command failure (#693).
|
|
@@ -213,17 +229,19 @@ export function startControlServer(options: ControlServerOptions): ControlServer
|
|
|
213
229
|
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, input.approvalId);
|
|
214
230
|
recordResolvedApproval(resolvedApprovals, input.approvalId);
|
|
215
231
|
pending.resolve(withDecisionReason(input, input.decision === "deny" || input.decision === "abort" ? "dismissed" : "answered"));
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
+
}
|
|
227
245
|
return true;
|
|
228
246
|
},
|
|
229
247
|
};
|
|
@@ -292,26 +310,91 @@ function withDecisionReason(input: ProviderPermissionDecisionInput, kind: Provid
|
|
|
292
310
|
|
|
293
311
|
async function handlePermissionRequest(
|
|
294
312
|
req: Request,
|
|
295
|
-
|
|
296
|
-
|
|
313
|
+
options: ControlServerOptions,
|
|
314
|
+
pendingPermissionRequests: Map<string, PendingPermissionWaiter>,
|
|
297
315
|
): Promise<Response> {
|
|
298
316
|
const handler = options.permissionPromptHandler;
|
|
299
317
|
if (!handler) return Response.json({ ok: false, reason: "permission prompt handler unavailable" }, { status: 501 });
|
|
300
318
|
const occurredAt = Date.now();
|
|
301
319
|
const body = await req.json().catch(() => null);
|
|
302
320
|
if (!isRecord(body)) return Response.json({});
|
|
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.
|
|
306
321
|
const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
|
|
307
322
|
const toolName = typeof body.tool_name === "string" ? body.tool_name : undefined;
|
|
308
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.
|
|
309
387
|
let reasoningSettled = true;
|
|
388
|
+
// Defaults to the hook's own wall-clock receipt time; refined below to the transcript's own
|
|
389
|
+
// tool_use event time when the pre-flush harvest can resolve it (#1185) — the hook can receive
|
|
390
|
+
// before the provider flushes/timestamps that entry, which otherwise sorts the resolved prompt
|
|
391
|
+
// ahead of the narration that led up to it.
|
|
392
|
+
let resolvedOccurredAt = occurredAt;
|
|
310
393
|
if (transcriptPath && options.onSessionTurn) {
|
|
311
394
|
const result = await options.onSessionTurn({ transcriptPath, isPreFlush: true, toolName, toolInput }).catch(() => undefined);
|
|
312
395
|
if (result && typeof result.reasoningSettled === "boolean") reasoningSettled = result.reasoningSettled;
|
|
396
|
+
if (result && typeof result.toolUseOccurredAt === "number") resolvedOccurredAt = result.toolUseOccurredAt;
|
|
313
397
|
}
|
|
314
|
-
const approvalId = crypto.randomUUID();
|
|
315
398
|
const view = handler.buildView(approvalId, body, reasoningSettled);
|
|
316
399
|
const waitMs = options.permissionWaitMs ?? PERMISSION_WAIT_MS;
|
|
317
400
|
const hookDeadlineAt = occurredAt + waitMs;
|
|
@@ -327,9 +410,9 @@ async function handlePermissionRequest(
|
|
|
327
410
|
reason: "provider-turn",
|
|
328
411
|
providerState: {
|
|
329
412
|
state: "blocked",
|
|
330
|
-
reason:
|
|
413
|
+
reason: blockedReason,
|
|
331
414
|
label: view.title,
|
|
332
|
-
recommendedAction
|
|
415
|
+
recommendedAction,
|
|
333
416
|
source: handler.source,
|
|
334
417
|
pendingApproval: view,
|
|
335
418
|
updatedAt: Date.now(),
|
|
@@ -342,14 +425,20 @@ async function handlePermissionRequest(
|
|
|
342
425
|
bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
|
|
343
426
|
resolve(withDecisionReason({ approvalId, decision: "deny", reason: PERMISSION_TIMEOUT_REASON }, "timeout"));
|
|
344
427
|
}, waitMs);
|
|
345
|
-
pendingPermissionRequests.set(approvalId, { resolve, timer });
|
|
428
|
+
pendingPermissionRequests.set(approvalId, { resolve, timer, suppressResolvedStatus });
|
|
346
429
|
});
|
|
347
430
|
|
|
348
431
|
if (decision.decisionReason?.kind !== "timeout" && options.onPermissionResolved) {
|
|
349
|
-
await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body,
|
|
432
|
+
await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body, resolvedOccurredAt))).catch(() => {});
|
|
350
433
|
}
|
|
351
434
|
|
|
352
|
-
return
|
|
435
|
+
return { decision, view };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function pendingQuestionDismissalPrompt(decision: ProviderPermissionDecisionInput): string {
|
|
439
|
+
const timedOut = decision.decisionReason?.kind === "timeout";
|
|
440
|
+
const event = timedOut ? "timed out before the user answered" : "was dismissed before the user answered";
|
|
441
|
+
return `Your previous AskUserQuestion ${event}. Proceed on your best judgment, or re-ask if you still need a decision.`;
|
|
353
442
|
}
|
|
354
443
|
|
|
355
444
|
async function handleSessionTurn(req: Request, options: ControlServerOptions): Promise<Response> {
|
package/src/runner-core.ts
CHANGED
|
@@ -277,6 +277,12 @@ export class AgentRunner {
|
|
|
277
277
|
// wakes one) so the blocked badge lifts on its own.
|
|
278
278
|
private rateLimitHold?: ProviderState;
|
|
279
279
|
private pendingRateLimitHold?: ProviderState;
|
|
280
|
+
// #1173: like rateLimitHold — a deny-then-hold "questions" (AskUserQuestion) prompt's own
|
|
281
|
+
// turn genuinely ends (the provider reacts to the denial and stops normally) before the human
|
|
282
|
+
// answers, so the pendingApproval can't ride the provider-turn active-work claim (it would
|
|
283
|
+
// vanish the instant that claim clears on the real idle). Persists across that idle; cleared
|
|
284
|
+
// once the answer is injected (or the prompt is dismissed/times out).
|
|
285
|
+
private pendingQuestionHold?: ProviderState;
|
|
280
286
|
// Settle-poll cadence for the pre-flush anchor (#1116) — instance fields (not just the
|
|
281
287
|
// module constants) so tests can shrink them without waiting out the real bound.
|
|
282
288
|
private settlePollIntervalMs = SETTLE_POLL_INTERVAL_MS;
|
|
@@ -429,6 +435,7 @@ export class AgentRunner {
|
|
|
429
435
|
onReplyObligations: () => Promise.resolve(this.obligationCache.get()),
|
|
430
436
|
onSessionTurn: (input) => this.publishSessionTurn(input), onPermissionResolved: (input) => this.publishSessionEvent({ from: this.agentId, to: "user", body: input.body, occurredAt: input.occurredAt, session: { type: "prompt-resolution", origin: "provider", label: input.decisionLabel, ...(this.currentTurnId ? { turnId: this.currentTurnId } : {}), stepId: `prompt-resolution:${input.approvalId}`, promptResolution: input } }),
|
|
431
437
|
onPermissionAbandoned: (input) => this.publishPermissionAbandonedNotice(input),
|
|
438
|
+
onAnswerInject: (input) => this.injectAnswerPrompt(input),
|
|
432
439
|
onUserPrompt: (input) => this.handleUserPrompt(input),
|
|
433
440
|
onSessionBoundary: (input) => this.handleSessionBoundary(input),
|
|
434
441
|
onHookFatal: (report) => this.reportHookFatal(report),
|
|
@@ -926,6 +933,29 @@ export class AgentRunner {
|
|
|
926
933
|
return { injected: true, messageId, attachmentCount: attachments.length };
|
|
927
934
|
}
|
|
928
935
|
|
|
936
|
+
// #1173: the resolution side of the deny-then-hold AskUserQuestion flow (see control-server.ts's
|
|
937
|
+
// handlePermissionRequest). By the time this fires, the original PreToolUse hook has long since
|
|
938
|
+
// been answered with `deny` — there's no live HTTP request to respond to — so the human's answer
|
|
939
|
+
// (or dismissal) is delivered by clearing the pending-question hold and injecting a normal
|
|
940
|
+
// user prompt via the same path the dashboard chat box uses.
|
|
941
|
+
private async injectAnswerPrompt(input: { approvalId: string; text?: string }): Promise<void> {
|
|
942
|
+
const heldApprovalId = typeof this.pendingQuestionHold?.pendingApproval?.id === "string" ? this.pendingQuestionHold.pendingApproval.id : undefined;
|
|
943
|
+
if (heldApprovalId && heldApprovalId !== input.approvalId) return;
|
|
944
|
+
if (heldApprovalId === input.approvalId) {
|
|
945
|
+
this.pendingQuestionHold = undefined;
|
|
946
|
+
this.publishStatus();
|
|
947
|
+
}
|
|
948
|
+
const text = input.text ?? "The question was dismissed or timed out before the user answered. Proceed on your best judgment, or re-ask if you still need a decision.";
|
|
949
|
+
if (!this.process || !this.options.adapter.deliverInitialPrompt) {
|
|
950
|
+
this.sessionLog(`could not inject AskUserQuestion answer for ${input.approvalId}: provider process/prompt-injection unavailable`);
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
this.recordInjectedPrompt(text.trim());
|
|
954
|
+
await this.options.adapter.deliverInitialPrompt(this.process, text).catch((error) => {
|
|
955
|
+
this.sessionLog(`failed to inject AskUserQuestion answer for ${input.approvalId}: ${errMessage(error)}`);
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
|
|
929
959
|
private async promptInjectionText(body: string, messageId: number | undefined, attachments: Record<string, unknown>[]): Promise<string> {
|
|
930
960
|
if (!attachments.length) return body;
|
|
931
961
|
const messages = await messagesWithCachedAttachments([{
|
|
@@ -1251,10 +1281,18 @@ export class AgentRunner {
|
|
|
1251
1281
|
this.rateLimitHold = update.providerState;
|
|
1252
1282
|
if (fresh) this.publishRateLimitNotice(update.providerState);
|
|
1253
1283
|
}
|
|
1284
|
+
} else if (ps.state === "blocked" && ps.reason === "pendingQuestion") {
|
|
1285
|
+
// #1173: set directly (never deferred) — unlike the rate-limit hold, this always
|
|
1286
|
+
// arrives while the turn that asked is still busy, well before the real idle it must
|
|
1287
|
+
// survive. Any other providerState (a fresh question, a plain approval, etc.) still
|
|
1288
|
+
// supersedes it via the `else` branch below.
|
|
1289
|
+
this.providerBlocked = true;
|
|
1290
|
+
this.pendingQuestionHold = update.providerState;
|
|
1254
1291
|
} else {
|
|
1255
1292
|
this.providerBlocked = ps.state === "blocked";
|
|
1256
1293
|
this.pendingRateLimitHold = undefined;
|
|
1257
1294
|
this.rateLimitHold = undefined;
|
|
1295
|
+
this.pendingQuestionHold = undefined;
|
|
1258
1296
|
}
|
|
1259
1297
|
} else if (status === "idle") {
|
|
1260
1298
|
this.providerBlocked = false;
|
|
@@ -1313,6 +1351,12 @@ export class AgentRunner {
|
|
|
1313
1351
|
this.currentTurnStartedAt = Date.now();
|
|
1314
1352
|
this.compactionMidTurn = false;
|
|
1315
1353
|
this.sessionLog(`turn started (turn ${this.currentTurnId})`);
|
|
1354
|
+
// #1173: a genuinely new turn starting (not the mid-turn busy update that sets the
|
|
1355
|
+
// hold itself — that arrives while currentTurnId is already set) means the human moved
|
|
1356
|
+
// on without using the form (a fresh message, a self-resume, etc.). The normal
|
|
1357
|
+
// answer/dismiss path already cleared this; this is the fallback for every other way
|
|
1358
|
+
// forward progress can happen, so a stale question never lingers past its own turn.
|
|
1359
|
+
this.pendingQuestionHold = undefined;
|
|
1316
1360
|
}
|
|
1317
1361
|
id = this.currentTurnId;
|
|
1318
1362
|
this.busyReconciler.arm();
|
|
@@ -1904,7 +1948,7 @@ export class AgentRunner {
|
|
|
1904
1948
|
const staleProviderTurnBusy = this.isStaleProviderTurnOnlyBusy(status, activeWork, busyReasons, liveness);
|
|
1905
1949
|
const effectiveStatus = staleProviderTurnBusy ? "idle" : status;
|
|
1906
1950
|
const agentStatus = runnerAgentStatus(effectiveStatus);
|
|
1907
|
-
const providerState = terminalFailure?.providerState ?? this.rateLimitHold ?? (staleProviderTurnBusy ? null : providerStateFromActiveWork(activeWork));
|
|
1951
|
+
const providerState = terminalFailure?.providerState ?? this.rateLimitHold ?? this.pendingQuestionHold ?? (staleProviderTurnBusy ? null : providerStateFromActiveWork(activeWork));
|
|
1908
1952
|
this.bus.setSemanticStatus(effectiveStatus === "offline" || effectiveStatus === "error" ? "idle" : effectiveStatus);
|
|
1909
1953
|
this.bus.status({
|
|
1910
1954
|
agentStatus,
|