agent-relay-runner 0.11.9 → 0.12.0

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.11.9",
3
+ "version": "0.12.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.5"
23
+ "agent-relay-sdk": "0.2.6"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -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.11.9",
4
+ "version": "0.12.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -53,6 +53,25 @@ relay_post_session_turn() {
53
53
  -d "$body" >/dev/null 2>&1 || true
54
54
  }
55
55
 
56
+ relay_post_user_prompt() {
57
+ # Mirror a prompt the human typed directly into this Claude session (web
58
+ # terminal / TUI) into the dashboard chat, and hand over the transcript path so
59
+ # the runner can tail reasoning/tool steps for the turn. The runner dedups
60
+ # prompts it injected itself. Fire-and-forget; never blocks or fails the turn.
61
+ local payload="${1:-}"
62
+ local port="${AGENT_RELAY_RUNNER_PORT:-}"
63
+ [ -z "$port" ] && return 0
64
+ [ -z "$payload" ] && return 0
65
+ command -v jq >/dev/null 2>&1 || return 0
66
+ local body
67
+ body="$(printf '%s' "$payload" | jq -c '{prompt: (.prompt // ""), transcriptPath: (.transcript_path // "")}' 2>/dev/null || true)"
68
+ [ -z "$body" ] && return 0
69
+ case "$body" in *'"prompt":""'*) return 0 ;; esac
70
+ curl -fsS --max-time 3 -X POST "http://127.0.0.1:${port}/user-prompt" \
71
+ -H 'Content-Type: application/json' \
72
+ -d "$body" >/dev/null 2>&1 || true
73
+ }
74
+
56
75
  relay_pending_reply_stop_decision() {
57
76
  local port="${AGENT_RELAY_RUNNER_PORT:-}"
58
77
  [ -z "$port" ] && return 0
@@ -1,7 +1,11 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
  source "${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/hooks/relay-status.sh"
4
+ payload="$(cat || true)"
4
5
  relay_post_status busy
6
+ # Mirror a terminal/TUI-typed prompt into the dashboard chat and start reasoning
7
+ # tailing for this turn. No-op for prompts the runner injected (chat box / relay).
8
+ relay_post_user_prompt "$payload"
5
9
  # Re-surface the request-review reminder each turn while there is unmerged
6
10
  # committed work — so a long session can't "forget" to land it. Silent otherwise.
7
11
  relay_emit_additional_context UserPromptSubmit "$(relay_review_reminder_text || true)"
package/src/adapter.ts CHANGED
@@ -27,6 +27,20 @@ export interface ProviderStatusEvent {
27
27
 
28
28
  export type ProviderStatusUpdate = SemanticStatus | ProviderStatusEvent;
29
29
 
30
+ /**
31
+ * A session-mirror event surfaced by an adapter that learns about session
32
+ * activity through provider events rather than hooks/transcripts (e.g. the Codex
33
+ * app-server). The runner turns these into `kind: "session"` chat messages, the
34
+ * same lane Claude's transcript capture uses. Provider-independent boundary.
35
+ */
36
+ export interface ProviderSessionEvent {
37
+ type: "prompt" | "response" | "reasoning" | "tool";
38
+ body: string;
39
+ origin?: "chat" | "terminal" | "provider";
40
+ turnId?: string;
41
+ label?: string;
42
+ }
43
+
30
44
  export interface ProviderConfig {
31
45
  command: string;
32
46
  defaultArgs: string[];
@@ -36,6 +50,9 @@ export interface ProviderConfig {
36
50
  defaultApprovalMode: string;
37
51
  defaultTags: string[];
38
52
  chatCaptureMode: "final" | "full";
53
+ // When false, the runner does not stream reasoning/tool steps into chat. Defaults
54
+ // to enabled (steps render discreetly, never as chat bubbles).
55
+ reasoningCapture?: boolean;
39
56
  headless: {
40
57
  tmuxPrefix: string;
41
58
  shutdownTimeoutMs: number;
@@ -110,11 +127,24 @@ export interface ProviderAdapter {
110
127
  shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
111
128
  compact?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
112
129
  clearContext?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
130
+ // Interrupt the in-flight turn without ending the session (ESC for Claude's
131
+ // tmux pane, turn/interrupt for the Codex app-server). Provider-independent at
132
+ // the runner boundary; each adapter does what its provider actually supports.
133
+ interrupt?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
134
+ // Out-of-band activity probe for the busy-state reconciler: returns the real
135
+ // provider activity when the runner's claim state may have gone stale (e.g. the
136
+ // turn was interrupted from the web terminal so no Stop hook fired). "unknown"
137
+ // means the provider can't be cheaply probed and the reconciler should defer.
138
+ probeActivity?(process: ManagedProcess): Promise<"busy" | "idle" | "unknown">;
113
139
  terminalAttachSpec?(process: ManagedProcess): Promise<TerminalAttachSpec>;
114
140
  respondToPermissionDecision?(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown> | void>;
115
141
  deliverInitialPrompt?(process: ManagedProcess, prompt: string): Promise<void>;
116
142
  deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
117
143
  onStatusChange(cb: (status: ProviderStatusUpdate) => void): void;
144
+ // Subscribe to session-mirror events from providers that emit them directly
145
+ // (Codex app-server item events). Claude mirrors via hooks/transcript instead,
146
+ // so it leaves this unimplemented.
147
+ onSessionEvent?(cb: (event: ProviderSessionEvent) => void): void;
118
148
  // Headless providers with no tmux session (e.g. the Codex app-server) still
119
149
  // warrant an automatic restart on unexpected exit. Returning true opts the
120
150
  // provider into the runner's restart-with-backoff path.
@@ -13,6 +13,15 @@
13
13
  interface TranscriptBlock {
14
14
  type?: string;
15
15
  text?: string;
16
+ thinking?: string;
17
+ name?: string;
18
+ input?: Record<string, unknown>;
19
+ }
20
+
21
+ export interface TurnStep {
22
+ type: "reasoning" | "tool";
23
+ text: string;
24
+ label?: string;
16
25
  }
17
26
 
18
27
  interface TranscriptMessage {
@@ -134,6 +143,49 @@ export function extractFinalAssistantMessage(jsonl: string): string {
134
143
  * string or an array of content blocks (same shape as transcript entries).
135
144
  * Thinking and tool_use blocks are dropped, matching extractLastAssistantTurn.
136
145
  */
146
+ /**
147
+ * Extract the ordered reasoning and tool steps for the most recent turn (since
148
+ * the last real user prompt). Used by the reasoning tailer to stream discreet
149
+ * progress into chat while a turn is in flight. Returns steps in transcript order
150
+ * so the tailer can emit only the ones it hasn't seen yet by index.
151
+ */
152
+ export function extractLatestTurnSteps(jsonl: string): TurnStep[] {
153
+ const lines = jsonl.split("\n");
154
+ let steps: TurnStep[] = [];
155
+ for (const line of lines) {
156
+ const trimmed = line.trim();
157
+ if (!trimmed) continue;
158
+ let entry: TranscriptEntry;
159
+ try {
160
+ entry = JSON.parse(trimmed) as TranscriptEntry;
161
+ } catch {
162
+ continue;
163
+ }
164
+ if (isRealUserPrompt(entry)) {
165
+ steps = [];
166
+ continue;
167
+ }
168
+ if (entry.type !== "assistant") continue;
169
+ for (const b of blocks(entry.message)) {
170
+ if (b.type === "thinking" && typeof b.thinking === "string" && b.thinking.trim()) {
171
+ steps.push({ type: "reasoning", text: b.thinking.trim() });
172
+ } else if (b.type === "tool_use" && typeof b.name === "string" && b.name) {
173
+ steps.push({ type: "tool", label: b.name, text: summarizeToolUse(b.name, b.input) });
174
+ }
175
+ }
176
+ }
177
+ return steps;
178
+ }
179
+
180
+ /** Compact one-line summary of a tool invocation for the discreet activity row. */
181
+ export function summarizeToolUse(name: string, input: Record<string, unknown> | undefined): string {
182
+ const str = (key: string): string | undefined => (input && typeof input[key] === "string" ? (input[key] as string) : undefined);
183
+ const candidate = str("command") ?? str("file_path") ?? str("path") ?? str("pattern") ?? str("query") ?? str("url") ?? str("description") ?? str("prompt");
184
+ const summary = candidate ? candidate.replace(/\s+/g, " ").trim() : "";
185
+ if (!summary) return name;
186
+ return summary.length > 200 ? `${summary.slice(0, 197)}…` : summary;
187
+ }
188
+
137
189
  export function extractHookAssistantMessage(content: unknown): string {
138
190
  if (typeof content === "string") return content.trim();
139
191
  if (!Array.isArray(content)) return "";
@@ -60,6 +60,33 @@ export class ClaudeAdapter implements ProviderAdapter {
60
60
  return { method: "tmux-inject", command: "/clear" };
61
61
  }
62
62
 
63
+ async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
64
+ const session = process.meta?.tmuxSession as string | undefined;
65
+ const socket = process.meta?.tmuxSocket as string | undefined;
66
+ if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session to interrupt");
67
+ // The same ESC the web terminal's aux key sends: cancels the in-flight turn
68
+ // and drops Claude back to its input box without ending the session.
69
+ const result = Bun.spawnSync(tmuxCommand(socket, "send-keys", "-t", session, "Escape"), {
70
+ stdin: "ignore", stdout: "ignore", stderr: "pipe",
71
+ });
72
+ if (result.exitCode !== 0) {
73
+ const stderr = result.stderr.toString().trim();
74
+ throw new Error(`tmux interrupt failed: ${stderr || `exit code ${result.exitCode}`}`);
75
+ }
76
+ return { method: "tmux-escape" };
77
+ }
78
+
79
+ async probeActivity(process: ManagedProcess): Promise<"busy" | "idle" | "unknown"> {
80
+ const session = process.meta?.tmuxSession as string | undefined;
81
+ const socket = process.meta?.tmuxSocket as string | undefined;
82
+ if (!session || !tmuxHasSession(session, socket)) return "unknown";
83
+ let pane: string;
84
+ try { pane = captureTmuxPane(session, socket); } catch { return "unknown"; }
85
+ if (claudePaneIsBusy(pane)) return "busy";
86
+ if (claudePaneLooksReady(pane)) return "idle";
87
+ return "unknown";
88
+ }
89
+
63
90
  async deliver(process: ManagedProcess, messages: Message[]): Promise<void> {
64
91
  const monitor = process.meta?.monitor as { deliver?(messages: Message[]): Promise<number[]> } | undefined;
65
92
  // A monitor object always exists for headless claude (it proxies to the runner
@@ -2,7 +2,7 @@ import { accessSync, constants, existsSync, readFileSync, realpathSync, readdirS
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join, resolve } from "node:path";
4
4
  import type { ContextState, Message } from "agent-relay-sdk";
5
- import { profileAllowsRelayFeature, providerMessageText, RELAY_CONTEXT, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
5
+ import { profileAllowsRelayFeature, providerMessageText, RELAY_CONTEXT, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderSessionEvent, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
6
6
  import { workspaceDepsNoteFromEnv } from "../relay-instructions";
7
7
 
8
8
  /** Relay context prepended to a Codex agent's first turn: the standard relay
@@ -24,13 +24,42 @@ type PendingCodexApproval = {
24
24
  export class CodexAdapter implements ProviderAdapter {
25
25
  readonly provider = "codex";
26
26
  private statusCb: (status: ProviderStatusUpdate) => void = () => {};
27
+ private sessionEventCb: (event: ProviderSessionEvent) => void = () => {};
27
28
  private readonly subagentThreads = new Map<string, { label?: string; role?: string; parentId?: string }>();
28
29
  private readonly pendingApprovals = new Map<string, PendingCodexApproval>();
30
+ // Active turn id for the main thread, captured from turn/started so an interrupt
31
+ // can target the in-flight turn. Cleared on turn/completed.
32
+ private activeTurnId?: string;
33
+ // Assistant message text accumulated across the current turn's agentMessage items,
34
+ // flushed as one session response on turn/completed (mirrors Claude's chatCaptureMode).
35
+ private turnMessages: string[] = [];
36
+ private captureMode: "final" | "full" = "final";
29
37
 
30
38
  onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
31
39
  this.statusCb = cb;
32
40
  }
33
41
 
42
+ onSessionEvent(cb: (event: ProviderSessionEvent) => void): void {
43
+ this.sessionEventCb = cb;
44
+ }
45
+
46
+ async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
47
+ const client = process.meta?.client as CodexAppClient | undefined;
48
+ if (!client) throw new Error("Codex App Server client is unavailable");
49
+ const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
50
+ if (!threadId) throw new Error("Codex thread is not ready");
51
+ if (!this.activeTurnId) throw new Error("no active Codex turn to interrupt");
52
+ await client.turnInterrupt(threadId, this.activeTurnId);
53
+ return { method: "turn-interrupt", turnId: this.activeTurnId };
54
+ }
55
+
56
+ // Codex streams thread/status continuously, so the runner's claim state never
57
+ // goes stale the way Claude's can after an out-of-band interrupt. No cheap probe
58
+ // is needed — defer to the live status stream.
59
+ async probeActivity(): Promise<"busy" | "idle" | "unknown"> {
60
+ return "unknown";
61
+ }
62
+
34
63
  // The Codex app-server is headless and has no tmux session, but an unexpected
35
64
  // exit should still be restarted with backoff rather than resolved as a final exit.
36
65
  supportsUnexpectedExitRestart(): boolean {
@@ -38,6 +67,7 @@ export class CodexAdapter implements ProviderAdapter {
38
67
  }
39
68
 
40
69
  async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
70
+ this.captureMode = (config.providerConfig as ProviderConfig).chatCaptureMode ?? "final";
41
71
  const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig);
42
72
  const appServer = Bun.spawn([args.command, ...args.args], {
43
73
  cwd: args.cwd,
@@ -295,16 +325,25 @@ export class CodexAdapter implements ProviderAdapter {
295
325
  if (threadId && this.subagentThreads.has(threadId)) {
296
326
  this.statusCb({ status: "busy", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
297
327
  } else {
298
- this.statusCb({ status: "busy", reason: "provider-turn" });
328
+ const turn = isRecord(params?.turn) ? params.turn : undefined;
329
+ this.activeTurnId = stringValue(turn?.id);
330
+ this.turnMessages = [];
331
+ this.statusCb({ status: "busy", reason: "provider-turn", id: this.activeTurnId });
299
332
  }
300
333
  }
301
334
  if (method.includes("turn/completed") || method.includes("turn.completed")) {
302
335
  if (threadId && this.subagentThreads.has(threadId)) {
303
336
  this.statusCb({ status: "idle", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
304
337
  } else {
305
- this.statusCb({ status: "idle", reason: "provider-turn" });
338
+ this.flushTurnResponse();
339
+ const completedTurnId = this.activeTurnId;
340
+ this.activeTurnId = undefined;
341
+ this.statusCb({ status: "idle", reason: "provider-turn", id: completedTurnId });
306
342
  }
307
343
  }
344
+ if ((method.includes("item/completed") || method.includes("item.completed")) && !isSubagent) {
345
+ this.handleCodexItem(isRecord(params?.item) ? params.item : undefined);
346
+ }
308
347
  if (method.includes("thread/status")) {
309
348
  const status = statusType(params?.status);
310
349
  if (threadId && this.subagentThreads.has(threadId)) {
@@ -317,6 +356,40 @@ export class CodexAdapter implements ProviderAdapter {
317
356
  }
318
357
  }
319
358
 
359
+ // Turn one completed Codex thread item into a session-mirror event. agentMessage
360
+ // text is accumulated and flushed as a single response on turn/completed; the rest
361
+ // (user prompt echo, reasoning, tool steps) is surfaced as it lands.
362
+ private handleCodexItem(item: Record<string, unknown> | undefined): void {
363
+ if (!item) return;
364
+ const type = stringValue(item.type);
365
+ const turnId = this.activeTurnId;
366
+ if (type === "agentMessage") {
367
+ const text = stringValue(item.text)?.trim();
368
+ if (text) this.turnMessages.push(text);
369
+ return;
370
+ }
371
+ if (type === "userMessage") {
372
+ const text = codexUserMessageText(item.content);
373
+ if (text) this.sessionEventCb({ type: "prompt", origin: "terminal", body: text, ...(turnId ? { turnId } : {}) });
374
+ return;
375
+ }
376
+ if (type === "reasoning") {
377
+ const text = codexReasoningText(item);
378
+ if (text) this.sessionEventCb({ type: "reasoning", origin: "provider", body: text, ...(turnId ? { turnId } : {}) });
379
+ return;
380
+ }
381
+ const tool = codexToolSummary(type, item);
382
+ if (tool) this.sessionEventCb({ type: "tool", origin: "provider", body: tool.body, label: tool.label, ...(turnId ? { turnId } : {}) });
383
+ }
384
+
385
+ private flushTurnResponse(): void {
386
+ if (!this.turnMessages.length) return;
387
+ const joined = this.captureMode === "full" ? this.turnMessages.join("\n\n") : this.turnMessages[this.turnMessages.length - 1]!;
388
+ this.turnMessages = [];
389
+ const text = joined.trim();
390
+ if (text) this.sessionEventCb({ type: "response", origin: "provider", body: text, ...(this.activeTurnId ? { turnId: this.activeTurnId } : {}) });
391
+ }
392
+
320
393
  private providerStateFromThreadStatus(status: unknown, params?: Record<string, unknown>): Record<string, unknown> | undefined {
321
394
  const state = codexProviderStateFromThreadStatus(status, params);
322
395
  if (state?.state !== "blocked" || state.reason !== "waitingOnApproval" || state.pendingApproval) return state;
@@ -337,6 +410,52 @@ function codexApprovalFromServerRequest(message: { id: string | number; method:
337
410
  };
338
411
  }
339
412
 
413
+ /** Extract the human text from a Codex userMessage item's content (UserInput[]). */
414
+ export function codexUserMessageText(content: unknown): string {
415
+ if (typeof content === "string") return content.trim();
416
+ if (!Array.isArray(content)) return "";
417
+ return content
418
+ .filter(isRecord)
419
+ .filter((part) => part.type === "text" || part.type === "input_text" || part.type === "output_text")
420
+ .map((part) => (typeof part.text === "string" ? part.text : ""))
421
+ .filter(Boolean)
422
+ .join("")
423
+ .trim();
424
+ }
425
+
426
+ /** Extract reasoning text from a Codex reasoning item (content[] preferred, summary[] fallback). */
427
+ export function codexReasoningText(item: Record<string, unknown>): string {
428
+ const stringsOf = (value: unknown): string[] =>
429
+ Array.isArray(value) ? value.filter((v): v is string => typeof v === "string" && v.trim().length > 0) : [];
430
+ const content = stringsOf(item.content);
431
+ const text = (content.length ? content : stringsOf(item.summary)).join("\n\n").trim();
432
+ return text;
433
+ }
434
+
435
+ /** Build a compact { label, body } activity summary for a Codex tool item. */
436
+ export function codexToolSummary(type: string | undefined, item: Record<string, unknown>): { label: string; body: string } | null {
437
+ const oneLine = (value: unknown): string => (typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "");
438
+ const clip = (text: string): string => (text.length > 200 ? `${text.slice(0, 197)}…` : text);
439
+ if (type === "commandExecution") {
440
+ const command = oneLine(item.command);
441
+ return { label: "Shell", body: clip(command || "command") };
442
+ }
443
+ if (type === "fileChange") {
444
+ const changes = Array.isArray(item.changes) ? item.changes.filter(isRecord) : [];
445
+ const files = changes.map((c) => stringValue(c.path) ?? stringValue(c.file) ?? "").filter(Boolean);
446
+ return { label: "Edit", body: clip(files.length ? files.join(", ") : "file changes") };
447
+ }
448
+ if (type === "mcpToolCall" || type === "dynamicToolCall") {
449
+ const tool = stringValue(item.tool) ?? "tool";
450
+ const server = stringValue(item.server) ?? stringValue(item.namespace);
451
+ return { label: server ? `${server}/${tool}` : tool, body: clip(oneLine(JSON.stringify(item.arguments ?? {})) || tool) };
452
+ }
453
+ if (type === "webSearch") {
454
+ return { label: "Search", body: clip(oneLine(item.query) || "web search") };
455
+ }
456
+ return null;
457
+ }
458
+
340
459
  function codexApprovalMethod(method: string): boolean {
341
460
  return method === "execCommandApproval" ||
342
461
  method === "applyPatchApproval" ||
@@ -24,6 +24,10 @@ interface ControlServerOptions {
24
24
  // path so the runner can capture the assistant turn and surface it in the
25
25
  // dashboard chat without the agent re-emitting it via /reply.
26
26
  onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown }): Promise<void>;
27
+ // A provider UserPromptSubmit hook hands over the prompt the human typed
28
+ // directly into the session (web terminal / TUI) so the runner can mirror it
29
+ // into the dashboard chat and start tailing the turn transcript for reasoning.
30
+ onUserPrompt?(input: { prompt: string; transcriptPath?: string }): Promise<void>;
27
31
  }
28
32
 
29
33
  export function startControlServer(options: ControlServerOptions): ControlServer {
@@ -66,6 +70,9 @@ export function startControlServer(options: ControlServerOptions): ControlServer
66
70
  if (url.pathname === "/session-turn" && req.method === "POST") {
67
71
  return handleSessionTurn(req, options);
68
72
  }
73
+ if (url.pathname === "/user-prompt" && req.method === "POST") {
74
+ return handleUserPrompt(req, options);
75
+ }
69
76
  if (url.pathname === "/monitor") {
70
77
  const upgraded = srv.upgrade(req, { data: { kind: "monitor" } });
71
78
  return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
@@ -216,6 +223,27 @@ export function claudePermissionApprovalView(id: string, body: Record<string, un
216
223
  choices: [],
217
224
  };
218
225
  }
226
+ // ExitPlanMode arrives through the generic PermissionRequest hook (it doesn't
227
+ // match the AskUserQuestion matcher), which used to render the raw tool_input
228
+ // JSON with generic Approve/Deny buttons. Surface the plan as markdown with the
229
+ // real plan-mode choices instead. approve → allow (exit plan mode and proceed);
230
+ // deny → keep planning.
231
+ if (toolName === "ExitPlanMode") {
232
+ const plan = typeof toolInput.plan === "string" && toolInput.plan.trim()
233
+ ? toolInput.plan
234
+ : JSON.stringify(toolInput);
235
+ return {
236
+ id,
237
+ provider: "claude",
238
+ kind: "plan",
239
+ title: "Claude is ready to code",
240
+ body: plan,
241
+ choices: [
242
+ { id: "approve", label: "Approve plan" },
243
+ { id: "deny", label: "Keep planning" },
244
+ ],
245
+ };
246
+ }
219
247
  const command = typeof toolInput.command === "string" ? toolInput.command : "";
220
248
  const description = typeof toolInput.description === "string" ? toolInput.description : "";
221
249
  const bodyText = [
@@ -304,6 +332,17 @@ async function handleSessionTurn(req: Request, options: ControlServerOptions): P
304
332
  }
305
333
  }
306
334
 
335
+ async function handleUserPrompt(req: Request, options: ControlServerOptions): Promise<Response> {
336
+ if (!options.onUserPrompt) return Response.json({ ok: false, reason: "prompt echo unavailable" });
337
+ const body = await req.json().catch(() => null);
338
+ const prompt = isRecord(body) && typeof body.prompt === "string" ? body.prompt : "";
339
+ if (!prompt.trim()) return Response.json({ ok: false, reason: "prompt required" }, { status: 400 });
340
+ const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
341
+ // Fire-and-forget: the hook must not block Claude's turn on relay round-trips.
342
+ void Promise.resolve(options.onUserPrompt({ prompt, transcriptPath })).catch(() => {});
343
+ return Response.json({ ok: true });
344
+ }
345
+
307
346
  async function handleStatus(req: Request, options: ControlServerOptions): Promise<Response> {
308
347
  const body = await req.json().catch(() => null) as Partial<ProviderStatusEvent> | null;
309
348
  const status = body?.status;
package/src/runner.ts CHANGED
@@ -2,14 +2,14 @@ import { hostname } from "node:os";
2
2
  import { appendFileSync, closeSync, mkdirSync, openSync, readSync, statSync, writeFileSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { dirname, join } from "node:path";
5
- import type { AgentProfile, ContextState, Message, ProviderCapabilities, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
5
+ import type { AgentProfile, ContextState, Message, MessageSessionMeta, ProviderCapabilities, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
6
6
  import { RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
7
7
  import { contextStateFromProbeMetrics, readContextProbeState } from "agent-relay-sdk/context-probe";
8
- import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
8
+ import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
9
9
  import { messagesWithCachedAttachments } from "./attachment-cache";
10
10
  import { ClaimTracker } from "./claim-tracker";
11
11
  import { startControlServer, type ControlServer } from "./control-server";
12
- import { extractLastAssistantTurn, extractFinalAssistantMessage, extractHookAssistantMessage, transcriptLooksComplete } from "./adapters/claude-transcript";
12
+ import { extractLastAssistantTurn, extractFinalAssistantMessage, extractHookAssistantMessage, extractLatestTurnSteps, transcriptLooksComplete } from "./adapters/claude-transcript";
13
13
  import { agentProfileProjectionReport } from "./profile-projection";
14
14
  import { profileUsesHostProviderGlobals } from "./profile-home";
15
15
  import { runtimeMetadata } from "./version";
@@ -68,6 +68,20 @@ const RAPID_EXIT_MS = 30 * 1000;
68
68
  const MAX_RAPID_UNEXPECTED_EXITS = 3;
69
69
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
70
70
  const LOG_TAIL_BYTES = 128 * 1024;
71
+ // A UserPromptSubmit echo matching a runner-injected prompt within this window is
72
+ // the same prompt arriving back from the provider — drop it to avoid a duplicate.
73
+ const PROMPT_ECHO_DEDUP_MS = 30_000;
74
+ // Busy reconciler: how often to probe real provider activity, and how many
75
+ // consecutive idle probes confirm a stuck-busy state should be cleared.
76
+ const BUSY_RECONCILE_POLL_MS = 4_000;
77
+ const BUSY_RECONCILE_IDLE_CONFIRM = 3;
78
+ // Relay-injected content (delivered messages, memory context) is wrapped with
79
+ // these markers; a UserPromptSubmit echo starting with one is a runner injection,
80
+ // not a human typing into the terminal, so it must not be mirrored as a prompt.
81
+ const RELAY_INJECTION_MARKERS = ["[relay message #", "[agent-relay"];
82
+ // Reasoning tailer poll cadence (item 5). Coarse on purpose — reasoning is a
83
+ // discreet progress signal, not a token stream, so ~1.2s keeps it light.
84
+ const REASONING_POLL_MS = 1_200;
71
85
  const CLAUDE_RESUME_RE = /\bclaude\s+--resume\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gi;
72
86
 
73
87
  interface RunnerTimelineEvent {
@@ -115,6 +129,22 @@ export class AgentRunner {
115
129
  private readonly activeTaskClaims = new Map<number, ActiveTaskClaim>();
116
130
  private pendingTimelineEvent?: RunnerTimelineEvent;
117
131
  private pendingPromptMessageId?: number;
132
+ // Session-mirror: a synthesized id grouping a turn's reasoning/tool steps and
133
+ // its final response. Set when a provider-turn starts, cleared when it ends.
134
+ private currentTurnId?: string;
135
+ // Prompt-echo dedup: the last prompt the runner itself injected (chat box or
136
+ // initial prompt). A UserPromptSubmit hook echo matching this within the window
137
+ // is the same prompt arriving back from the provider and must not double-post.
138
+ private lastInjectedPrompt?: { text: string; at: number };
139
+ // Busy reconciler: consecutive idle probes observed while claims still say busy.
140
+ private busyReconcileIdleStreak = 0;
141
+ private busyReconcileTimer?: ReturnType<typeof setInterval>;
142
+ // Tracks whether the provider is in a legitimate blocked/approval state, so the
143
+ // busy reconciler doesn't mistake a permission prompt for a stuck-busy turn.
144
+ private providerBlocked = false;
145
+ // Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
146
+ // from the Claude transcript into chat as discreet session events.
147
+ private reasoningTail?: { timer: ReturnType<typeof setInterval>; emitted: number };
118
148
  private scratch?: SessionScratchLayout;
119
149
 
120
150
  constructor(private readonly options: RunnerOptions) {
@@ -195,6 +225,7 @@ export class AgentRunner {
195
225
  onTerminalAttachSpec: () => this.terminalAttachSpec(),
196
226
  onReplyObligations: () => this.http.listReplyObligations(this.agentId),
197
227
  onSessionTurn: (input) => this.publishSessionTurn(input),
228
+ onUserPrompt: (input) => this.handleUserPrompt(input),
198
229
  });
199
230
  this.writeRunnerInfoFile();
200
231
  this.options.adapter.onStatusChange((status) => {
@@ -207,6 +238,7 @@ export class AgentRunner {
207
238
  this.setProviderStatus(status);
208
239
  if (runnerShouldResolveProviderExit(semanticStatus, this.exitCommandInProgress)) this.options.onProviderExit?.(semanticStatus === "offline" ? 0 : 1);
209
240
  });
241
+ this.options.adapter.onSessionEvent?.((event) => { void this.publishProviderSessionEvent(event); });
210
242
  this.bus.on("message.new", (message) => this.enqueueMessage(message as Message));
211
243
  this.bus.on("command", (type, params, commandId, command) => {
212
244
  void this.handleCommand(type, params, commandId, command);
@@ -250,6 +282,8 @@ export class AgentRunner {
250
282
  this.httpLivenessTimer = undefined;
251
283
  if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
252
284
  this.tokenRenewTimer = undefined;
285
+ this.disarmBusyReconciler();
286
+ this.stopReasoningTail();
253
287
  this.control?.stop();
254
288
  await this.bus.close();
255
289
  }
@@ -428,7 +462,7 @@ export class AgentRunner {
428
462
  private async handleCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>): Promise<void> {
429
463
  const target = typeof command?.target === "string" ? command.target : this.agentId;
430
464
  if (target !== this.agentId && target !== this.options.runnerId) return;
431
- if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.reconnect" && type !== "agent.kill" && type !== "agent.compact" && type !== "agent.clearContext" && type !== "agent.injectContext" && type !== "agent.permissionDecision" && type !== "prompt.inject") return;
465
+ if (type !== "agent.shutdown" && type !== "agent.restart" && type !== "agent.reconnect" && type !== "agent.kill" && type !== "agent.compact" && type !== "agent.clearContext" && type !== "agent.injectContext" && type !== "agent.permissionDecision" && type !== "agent.interrupt" && type !== "prompt.inject") return;
432
466
 
433
467
  const exitAfterCommand = type === "agent.shutdown" || type === "agent.kill";
434
468
  if (exitAfterCommand) {
@@ -451,6 +485,9 @@ export class AgentRunner {
451
485
  } else if (type === "agent.clearContext") {
452
486
  if (!this.options.adapter.clearContext || !this.process) throw new Error("provider does not support clearContext");
453
487
  providerResult = await this.options.adapter.clearContext(this.process);
488
+ } else if (type === "agent.interrupt") {
489
+ if (!this.options.adapter.interrupt || !this.process) throw new Error("provider does not support interrupt");
490
+ providerResult = await this.options.adapter.interrupt(this.process);
454
491
  } else if (type === "agent.injectContext") {
455
492
  if (!this.process) throw new Error("provider process is unavailable");
456
493
  providerResult = await this.injectContext(params);
@@ -546,6 +583,9 @@ export class AgentRunner {
546
583
  if (!this.options.adapter.deliverInitialPrompt) throw new Error("provider does not support prompt injection");
547
584
  const messageId = typeof params.messageId === "number" ? params.messageId : undefined;
548
585
  if (messageId) this.pendingPromptMessageId = messageId;
586
+ // Mark so the matching UserPromptSubmit echo isn't double-posted: a chat-box
587
+ // prompt already created its own session message shown in the dashboard.
588
+ this.lastInjectedPrompt = { text: body.trim(), at: Date.now() };
549
589
  await this.options.adapter.deliverInitialPrompt(this.process, body);
550
590
  return { injected: true, messageId };
551
591
  }
@@ -785,6 +825,20 @@ export class AgentRunner {
785
825
  ...(update.timeline.metadata ? { metadata: update.timeline.metadata } : {}),
786
826
  };
787
827
  }
828
+ if (typeof update !== "string" && update.providerState) {
829
+ const state = (update.providerState as { state?: unknown }).state;
830
+ this.providerBlocked = state === "blocked";
831
+ } else if (status === "idle") {
832
+ this.providerBlocked = false;
833
+ }
834
+ if (status === "busy" && reason === "provider-turn") {
835
+ if (!this.currentTurnId) this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
836
+ this.armBusyReconciler();
837
+ } else if (status === "idle" && reason === "provider-turn") {
838
+ this.currentTurnId = undefined;
839
+ this.disarmBusyReconciler();
840
+ this.stopReasoningTail();
841
+ }
788
842
  if (status === "busy") {
789
843
  this.claims.clearTerminalStatus();
790
844
  this.claims.startWork(reason, id, typeof update === "string" ? {} : {
@@ -811,14 +865,16 @@ export class AgentRunner {
811
865
  this.publishStatus();
812
866
  }
813
867
 
814
- // Phase 1 live-session lane: capture the assistant turn from the Claude
815
- // transcript and post it as an observed "session" message so it shows in the
816
- // dashboard chat with zero agent tokens. Posting it as a reply to the
817
- // triggering message also clears the reply obligation, so the Stop hook no
818
- // longer nags the agent to /reply which is what made it re-emit before.
868
+ // Session-mirror lane: capture the assistant turn from the Claude transcript and
869
+ // post it as a "session" message so it shows in the dashboard chat with zero
870
+ // agent tokens. Capture is UNCONDITIONAL it no longer depends on a triggering
871
+ // relay message existing, so turns started from the web terminal (which create
872
+ // no relay message) are mirrored too. A reply obligation, when present, is still
873
+ // used as replyTo so the Stop hook stops nagging the agent to /reply.
819
874
  private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown }): Promise<void> {
820
- // Find the triggering message to reply to: either a pending prompt injection
821
- // (Phase 2 direct lane) or a reply obligation from the dashboard human.
875
+ const turnId = this.currentTurnId;
876
+ this.stopReasoningTail();
877
+ // Optional correlation for threading + obligation clearing — never a capture gate.
822
878
  let replyToMessageId: number | undefined;
823
879
  const pendingPrompt = this.pendingPromptMessageId;
824
880
  if (pendingPrompt) {
@@ -830,52 +886,216 @@ export class AgentRunner {
830
886
  const obligation = [...obligations].reverse().find((o) => o.from === "user");
831
887
  replyToMessageId = obligation?.messageId;
832
888
  } catch {
833
- return;
889
+ // fall through and capture without correlation
834
890
  }
835
891
  }
836
- if (!replyToMessageId) return;
837
892
 
838
- let jsonl: string;
839
- try {
840
- jsonl = await readFile(input.transcriptPath, "utf8");
841
- } catch {
842
- return;
843
- }
844
- // The Stop hook can fire before the final assistant entry is flushed to
845
- // disk. Claude Code writes thinking and text as separate entries (both with
846
- // end_turn), so the transcript can "look complete" while the text entry is
847
- // still pending. Retry until both the transcript has an end_turn AND the
848
- // extraction yields non-empty text.
893
+ // The Stop hook can fire before the final assistant entry is flushed to disk.
894
+ // Claude writes thinking and text as separate entries (both with end_turn), so
895
+ // the transcript can "look complete" while the text entry is still pending.
896
+ // Retry until both the transcript has an end_turn AND extraction yields text.
849
897
  let body = "";
850
- for (let attempt = 0; attempt < 5; attempt++) {
851
- if (attempt > 0) {
852
- await new Promise((r) => setTimeout(r, 100));
853
- try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { return; }
898
+ let jsonl: string | undefined;
899
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
900
+ if (jsonl !== undefined) {
901
+ for (let attempt = 0; attempt < 5; attempt++) {
902
+ if (attempt > 0) {
903
+ await new Promise((r) => setTimeout(r, 100));
904
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { break; }
905
+ }
906
+ if (!transcriptLooksComplete(jsonl)) continue;
907
+ const extract = this.options.providerConfig.chatCaptureMode === "full" ? extractLastAssistantTurn : extractFinalAssistantMessage;
908
+ body = extract(jsonl);
909
+ if (body) break;
854
910
  }
855
- if (!transcriptLooksComplete(jsonl)) continue;
856
- const extract = this.options.providerConfig.chatCaptureMode === "full" ? extractLastAssistantTurn : extractFinalAssistantMessage;
857
- body = extract(jsonl);
858
- if (body) break;
859
911
  }
860
- // Fallback: use last_assistant_message from the Stop hook payload directly.
861
- // This bypasses the transcript file race entirely — Claude Code provides the
862
- // content in-memory before the hook even fires.
912
+ // Fallback: last_assistant_message from the Stop hook payload, which bypasses
913
+ // the transcript file race entirely.
863
914
  if (!body && input.lastAssistantMessage) {
864
915
  body = extractHookAssistantMessage(input.lastAssistantMessage);
865
916
  }
917
+ // A pure tool-use turn with no closing text is fine to skip — its reasoning and
918
+ // tool steps already carried the visibility into chat.
866
919
  if (!body) return;
867
920
 
921
+ await this.publishSessionEvent({
922
+ from: this.agentId,
923
+ to: "user",
924
+ body,
925
+ ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
926
+ session: { type: "response", origin: "provider", ...(turnId ? { turnId } : {}) },
927
+ });
928
+ }
929
+
930
+ // Post one session-mirror event (prompt echo, assistant response, reasoning or
931
+ // tool step) as a `kind: "session"` relay message tagged with payload.session so
932
+ // the dashboard can render the live provider session faithfully. Display-only:
933
+ // session messages are never delivered back into a provider.
934
+ private async publishSessionEvent(input: {
935
+ from: string;
936
+ to: string;
937
+ body: string;
938
+ session: MessageSessionMeta;
939
+ replyTo?: number;
940
+ }): Promise<void> {
868
941
  try {
869
942
  await this.http.sendMessage({
943
+ from: input.from,
944
+ to: input.to,
945
+ ...(input.replyTo ? { replyTo: input.replyTo } : {}),
946
+ kind: "session",
947
+ body: input.body,
948
+ payload: { session: { provider: this.options.provider, ...input.session } },
949
+ });
950
+ } catch (error) {
951
+ this.logRunnerDiagnostic(`session ${input.session.type} capture failed: ${error instanceof Error ? error.message : String(error)}`);
952
+ }
953
+ }
954
+
955
+ // A human typed a prompt directly into the provider (web terminal / TUI). Mirror
956
+ // it into the dashboard chat so both surfaces stay in sync, and kick off reasoning
957
+ // tailing for the turn. Skips prompts the runner itself injected (chat box, relay
958
+ // deliveries) so those aren't double-posted.
959
+ private async handleUserPrompt(input: { prompt: string; transcriptPath?: string }): Promise<void> {
960
+ if (!this.currentTurnId) this.currentTurnId = crypto.randomUUID();
961
+ const text = input.prompt.trim();
962
+ if (text && !this.isRunnerInjectedPrompt(text)) {
963
+ await this.publishSessionEvent({
964
+ from: "user",
965
+ to: this.agentId,
966
+ body: text,
967
+ session: { type: "prompt", origin: "terminal", turnId: this.currentTurnId },
968
+ });
969
+ }
970
+ if (input.transcriptPath) this.startReasoningTail(input.transcriptPath);
971
+ }
972
+
973
+ // Route a provider-emitted session event (Codex app-server) into the chat mirror.
974
+ // Mirrors the same semantics as the Claude lane: prompts are echoed with dedup,
975
+ // and a response is only auto-captured when the agent won't separately reply to a
976
+ // relay obligation (so relay-triggered turns aren't double-posted).
977
+ private async publishProviderSessionEvent(event: ProviderSessionEvent): Promise<void> {
978
+ const body = event.body.trim();
979
+ if (!body) return;
980
+ const turnId = event.turnId ?? this.currentTurnId;
981
+ if (event.type === "prompt") {
982
+ if (this.isRunnerInjectedPrompt(body)) return;
983
+ await this.publishSessionEvent({
984
+ from: "user",
985
+ to: this.agentId,
986
+ body,
987
+ session: { type: "prompt", origin: event.origin ?? "terminal", ...(turnId ? { turnId } : {}) },
988
+ });
989
+ return;
990
+ }
991
+ if (event.type === "response") {
992
+ // If a relay message is awaiting the agent's own reply, let the agent answer
993
+ // it (Codex agents reply via their relay skills) instead of double-posting.
994
+ let replyToMessageId: number | undefined;
995
+ const pendingPrompt = this.pendingPromptMessageId;
996
+ if (pendingPrompt) {
997
+ replyToMessageId = pendingPrompt;
998
+ this.pendingPromptMessageId = undefined;
999
+ } else {
1000
+ try {
1001
+ const obligations = await this.http.listReplyObligations(this.agentId);
1002
+ if (obligations.some((o) => o.from === "user")) return;
1003
+ } catch {
1004
+ // capture anyway on lookup failure
1005
+ }
1006
+ }
1007
+ await this.publishSessionEvent({
870
1008
  from: this.agentId,
871
1009
  to: "user",
872
- replyTo: replyToMessageId,
873
- kind: "session",
874
1010
  body,
1011
+ ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
1012
+ session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}) },
875
1013
  });
876
- } catch (error) {
877
- this.logRunnerDiagnostic(`session turn capture failed: ${error instanceof Error ? error.message : String(error)}`);
1014
+ return;
878
1015
  }
1016
+ if (this.options.providerConfig.reasoningCapture === false) return;
1017
+ await this.publishSessionEvent({
1018
+ from: this.agentId,
1019
+ to: "user",
1020
+ body,
1021
+ session: { type: event.type, origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}), ...(event.label ? { label: event.label } : {}) },
1022
+ });
1023
+ }
1024
+
1025
+ private isRunnerInjectedPrompt(text: string): boolean {
1026
+ if (RELAY_INJECTION_MARKERS.some((marker) => text.startsWith(marker))) return true;
1027
+ const recent = this.lastInjectedPrompt;
1028
+ if (recent && recent.text === text && Date.now() - recent.at < PROMPT_ECHO_DEDUP_MS) {
1029
+ this.lastInjectedPrompt = undefined;
1030
+ return true;
1031
+ }
1032
+ return false;
1033
+ }
1034
+
1035
+ // --- Busy-state reconciler (item 2) -------------------------------------------------
1036
+ // A safety net for turns that end out of band (interrupted from the web terminal,
1037
+ // a hook that never fired) where the runner would otherwise stay stuck "busy".
1038
+ private armBusyReconciler(): void {
1039
+ if (this.busyReconcileTimer || !this.options.adapter.probeActivity) return;
1040
+ this.busyReconcileIdleStreak = 0;
1041
+ this.busyReconcileTimer = setInterval(() => { void this.runBusyReconcile(); }, BUSY_RECONCILE_POLL_MS);
1042
+ }
1043
+
1044
+ private disarmBusyReconciler(): void {
1045
+ if (this.busyReconcileTimer) clearInterval(this.busyReconcileTimer);
1046
+ this.busyReconcileTimer = undefined;
1047
+ this.busyReconcileIdleStreak = 0;
1048
+ }
1049
+
1050
+ private async runBusyReconcile(): Promise<void> {
1051
+ if (this.stopped || !this.process || !this.options.adapter.probeActivity) { this.disarmBusyReconciler(); return; }
1052
+ // Only act while the runner still believes a provider turn is in flight, and
1053
+ // never override a legitimate approval/blocked state.
1054
+ if (this.claims.currentStatus() !== "busy" || this.providerBlocked) { this.busyReconcileIdleStreak = 0; return; }
1055
+ if (!this.claims.activeWork().some((w) => w.kind === "provider-turn")) { this.disarmBusyReconciler(); return; }
1056
+ let activity: "busy" | "idle" | "unknown";
1057
+ try { activity = await this.options.adapter.probeActivity(this.process); } catch { return; }
1058
+ if (activity !== "idle") { this.busyReconcileIdleStreak = 0; return; }
1059
+ this.busyReconcileIdleStreak += 1;
1060
+ if (this.busyReconcileIdleStreak < BUSY_RECONCILE_IDLE_CONFIRM) return;
1061
+ this.logRunnerDiagnostic(`busy reconciler cleared a stuck provider-turn (idle confirmed ${this.busyReconcileIdleStreak}x)`);
1062
+ const turnId = this.currentTurnId;
1063
+ this.disarmBusyReconciler();
1064
+ this.setProviderStatus({ status: "idle", reason: "provider-turn", id: turnId ?? "provider-turn" });
1065
+ }
1066
+
1067
+ // --- Reasoning tailer (item 5) ------------------------------------------------------
1068
+ // Tail the in-flight turn's Claude transcript and surface new reasoning/tool steps
1069
+ // as discreet session events. Coalesced and coarse; the final response still comes
1070
+ // through publishSessionTurn.
1071
+ private startReasoningTail(transcriptPath: string): void {
1072
+ if (this.options.providerConfig.reasoningCapture === false) return;
1073
+ this.stopReasoningTail();
1074
+ const state = { emitted: 0, timer: undefined as unknown as ReturnType<typeof setInterval> };
1075
+ const poll = async (): Promise<void> => {
1076
+ let jsonl: string;
1077
+ try { jsonl = await readFile(transcriptPath, "utf8"); } catch { return; }
1078
+ const steps = extractLatestTurnSteps(jsonl);
1079
+ const turnId = this.currentTurnId;
1080
+ for (let i = state.emitted; i < steps.length; i++) {
1081
+ const step = steps[i]!;
1082
+ void this.publishSessionEvent({
1083
+ from: this.agentId,
1084
+ to: "user",
1085
+ body: step.text,
1086
+ session: { type: step.type, origin: "provider", ...(turnId ? { turnId } : {}), ...(step.label ? { label: step.label } : {}) },
1087
+ });
1088
+ }
1089
+ if (steps.length > state.emitted) state.emitted = steps.length;
1090
+ };
1091
+ state.timer = setInterval(() => { void poll(); }, REASONING_POLL_MS);
1092
+ this.reasoningTail = state;
1093
+ void poll();
1094
+ }
1095
+
1096
+ private stopReasoningTail(): void {
1097
+ if (this.reasoningTail) clearInterval(this.reasoningTail.timer);
1098
+ this.reasoningTail = undefined;
879
1099
  }
880
1100
 
881
1101
  private publishStatus(): void {
@@ -1451,6 +1671,13 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextStats?: { so
1451
1671
  liveSession: {
1452
1672
  capture: true,
1453
1673
  inject: Boolean(options.adapter.deliverInitialPrompt),
1674
+ interrupt: Boolean(options.adapter.interrupt),
1675
+ // Both providers mirror directly-typed prompts and stream reasoning/tool
1676
+ // activity into chat (Claude via hooks + transcript tail, Codex via
1677
+ // app-server item events).
1678
+ promptEcho: true,
1679
+ reasoning: true,
1680
+ slashCommands: options.provider === "claude" || options.provider === "codex",
1454
1681
  },
1455
1682
  source: "runtime",
1456
1683
  confidence: "reported",