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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.127.6",
3
+ "version": "0.127.8",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.127.6",
4
+ "version": "0.127.8",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
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
- return { reasoningSettled: settled.settled };
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
@@ -1,8 +1,7 @@
1
- import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join, resolve } from "node:path";
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
- export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
472
- export const CLAUDE_TMUX_SUBMIT_KEY = "C-m";
473
- export const CLAUDE_TMUX_READY_TIMEOUT_MS = 10_000;
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
 
@@ -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, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>();
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
- options.onStatus({
217
- status: "busy",
218
- reason: "provider-turn",
219
- providerState: {
220
- state: "active",
221
- reason: "permissionResolved",
222
- label: options.permissionPromptHandler?.resolvedLabel ?? "Provider approval resolved",
223
- source: options.permissionPromptHandler?.source ?? options.providerSourceId ?? "provider",
224
- updatedAt: Date.now(),
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
- options: ControlServerOptions,
296
- pendingPermissionRequests: Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>,
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: "permissionRequest",
413
+ reason: blockedReason,
331
414
  label: view.title,
332
- recommendedAction: "Review the permission request in Agent Relay.",
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, occurredAt))).catch(() => {});
432
+ await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body, resolvedOccurredAt))).catch(() => {});
350
433
  }
351
434
 
352
- return Response.json(handler.buildHookResponse(decision, body));
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> {
@@ -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,