agent-relay-runner 0.11.9 → 0.12.1

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.1",
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.1",
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,27 @@ 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: a conservative LAST-RESORT backstop for a turn that ended
75
+ // without the provider's Stop hook clearing busy (e.g. ESC straight into the web
76
+ // terminal). It must never fire during a live turn, so it (a) only counts idle
77
+ // after it has actually observed the provider busy, and (b) requires a long,
78
+ // unbroken idle streak — an active turn shows its working spinner well within
79
+ // this window, which resets the streak. ~32s of uninterrupted idle = really done.
80
+ const BUSY_RECONCILE_POLL_MS = 4_000;
81
+ const BUSY_RECONCILE_IDLE_CONFIRM = 8;
82
+ // After a dashboard interrupt, give the provider a moment to drop out of its turn,
83
+ // then reconcile immediately so the user sees "stopped" without waiting for the backstop.
84
+ const INTERRUPT_RECONCILE_DELAY_MS = 1_500;
85
+ // Relay-injected content (delivered messages, memory context) is wrapped with
86
+ // these markers; a UserPromptSubmit echo starting with one is a runner injection,
87
+ // not a human typing into the terminal, so it must not be mirrored as a prompt.
88
+ const RELAY_INJECTION_MARKERS = ["[relay message #", "[agent-relay"];
89
+ // Reasoning tailer poll cadence (item 5). Coarse on purpose — reasoning is a
90
+ // discreet progress signal, not a token stream, so ~1.2s keeps it light.
91
+ const REASONING_POLL_MS = 1_200;
71
92
  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
93
 
73
94
  interface RunnerTimelineEvent {
@@ -115,6 +136,28 @@ export class AgentRunner {
115
136
  private readonly activeTaskClaims = new Map<number, ActiveTaskClaim>();
116
137
  private pendingTimelineEvent?: RunnerTimelineEvent;
117
138
  private pendingPromptMessageId?: number;
139
+ // Session-mirror: a synthesized id grouping a turn's reasoning/tool steps and
140
+ // its final response. Set when a provider-turn starts, cleared when it ends.
141
+ private currentTurnId?: string;
142
+ // Prompt-echo dedup: the last prompt the runner itself injected (chat box or
143
+ // initial prompt). A UserPromptSubmit hook echo matching this within the window
144
+ // is the same prompt arriving back from the provider and must not double-post.
145
+ private lastInjectedPrompt?: { text: string; at: number };
146
+ // Busy reconciler: consecutive idle probes observed while claims still say busy.
147
+ private busyReconcileIdleStreak = 0;
148
+ private busyReconcileTimer?: ReturnType<typeof setInterval>;
149
+ // The reconciler only trusts an "idle" reading once it has seen the provider
150
+ // actually busy this turn — so a flaky/always-idle probe can never false-clear.
151
+ private busyReconcileSawBusy = false;
152
+ // Verbose session-mirror diagnostics (turn lifecycle, reconciler probes, tail
153
+ // emits) → a dedicated clean log. Always on for key transitions; AGENT_RELAY_SESSION_DEBUG=1 adds the high-frequency probe/emit lines.
154
+ private readonly sessionDebugVerbose = process.env.AGENT_RELAY_SESSION_DEBUG === "1";
155
+ // Tracks whether the provider is in a legitimate blocked/approval state, so the
156
+ // busy reconciler doesn't mistake a permission prompt for a stuck-busy turn.
157
+ private providerBlocked = false;
158
+ // Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
159
+ // from the Claude transcript into chat as discreet session events.
160
+ private reasoningTail?: { timer: ReturnType<typeof setInterval>; seen: Set<string> };
118
161
  private scratch?: SessionScratchLayout;
119
162
 
120
163
  constructor(private readonly options: RunnerOptions) {
@@ -195,6 +238,7 @@ export class AgentRunner {
195
238
  onTerminalAttachSpec: () => this.terminalAttachSpec(),
196
239
  onReplyObligations: () => this.http.listReplyObligations(this.agentId),
197
240
  onSessionTurn: (input) => this.publishSessionTurn(input),
241
+ onUserPrompt: (input) => this.handleUserPrompt(input),
198
242
  });
199
243
  this.writeRunnerInfoFile();
200
244
  this.options.adapter.onStatusChange((status) => {
@@ -207,6 +251,7 @@ export class AgentRunner {
207
251
  this.setProviderStatus(status);
208
252
  if (runnerShouldResolveProviderExit(semanticStatus, this.exitCommandInProgress)) this.options.onProviderExit?.(semanticStatus === "offline" ? 0 : 1);
209
253
  });
254
+ this.options.adapter.onSessionEvent?.((event) => { void this.publishProviderSessionEvent(event); });
210
255
  this.bus.on("message.new", (message) => this.enqueueMessage(message as Message));
211
256
  this.bus.on("command", (type, params, commandId, command) => {
212
257
  void this.handleCommand(type, params, commandId, command);
@@ -250,6 +295,8 @@ export class AgentRunner {
250
295
  this.httpLivenessTimer = undefined;
251
296
  if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
252
297
  this.tokenRenewTimer = undefined;
298
+ this.disarmBusyReconciler();
299
+ this.stopReasoningTail();
253
300
  this.control?.stop();
254
301
  await this.bus.close();
255
302
  }
@@ -428,7 +475,7 @@ export class AgentRunner {
428
475
  private async handleCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>): Promise<void> {
429
476
  const target = typeof command?.target === "string" ? command.target : this.agentId;
430
477
  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;
478
+ 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
479
 
433
480
  const exitAfterCommand = type === "agent.shutdown" || type === "agent.kill";
434
481
  if (exitAfterCommand) {
@@ -451,6 +498,11 @@ export class AgentRunner {
451
498
  } else if (type === "agent.clearContext") {
452
499
  if (!this.options.adapter.clearContext || !this.process) throw new Error("provider does not support clearContext");
453
500
  providerResult = await this.options.adapter.clearContext(this.process);
501
+ } else if (type === "agent.interrupt") {
502
+ if (!this.options.adapter.interrupt || !this.process) throw new Error("provider does not support interrupt");
503
+ this.sessionLog("interrupt requested from dashboard");
504
+ providerResult = await this.options.adapter.interrupt(this.process);
505
+ this.scheduleInterruptReconcile();
454
506
  } else if (type === "agent.injectContext") {
455
507
  if (!this.process) throw new Error("provider process is unavailable");
456
508
  providerResult = await this.injectContext(params);
@@ -546,6 +598,9 @@ export class AgentRunner {
546
598
  if (!this.options.adapter.deliverInitialPrompt) throw new Error("provider does not support prompt injection");
547
599
  const messageId = typeof params.messageId === "number" ? params.messageId : undefined;
548
600
  if (messageId) this.pendingPromptMessageId = messageId;
601
+ // Mark so the matching UserPromptSubmit echo isn't double-posted: a chat-box
602
+ // prompt already created its own session message shown in the dashboard.
603
+ this.lastInjectedPrompt = { text: body.trim(), at: Date.now() };
549
604
  await this.options.adapter.deliverInitialPrompt(this.process, body);
550
605
  return { injected: true, messageId };
551
606
  }
@@ -785,6 +840,24 @@ export class AgentRunner {
785
840
  ...(update.timeline.metadata ? { metadata: update.timeline.metadata } : {}),
786
841
  };
787
842
  }
843
+ if (typeof update !== "string" && update.providerState) {
844
+ const state = (update.providerState as { state?: unknown }).state;
845
+ this.providerBlocked = state === "blocked";
846
+ } else if (status === "idle") {
847
+ this.providerBlocked = false;
848
+ }
849
+ if (status === "busy" && reason === "provider-turn") {
850
+ if (!this.currentTurnId) {
851
+ this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
852
+ this.sessionLog(`turn started (turn ${this.currentTurnId})`);
853
+ }
854
+ this.armBusyReconciler();
855
+ } else if (status === "idle" && reason === "provider-turn") {
856
+ if (this.currentTurnId) this.sessionLog(`turn ended via provider idle (turn ${this.currentTurnId})`);
857
+ this.currentTurnId = undefined;
858
+ this.disarmBusyReconciler();
859
+ this.stopReasoningTail();
860
+ }
788
861
  if (status === "busy") {
789
862
  this.claims.clearTerminalStatus();
790
863
  this.claims.startWork(reason, id, typeof update === "string" ? {} : {
@@ -811,14 +884,16 @@ export class AgentRunner {
811
884
  this.publishStatus();
812
885
  }
813
886
 
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.
887
+ // Session-mirror lane: capture the assistant turn from the Claude transcript and
888
+ // post it as a "session" message so it shows in the dashboard chat with zero
889
+ // agent tokens. Capture is UNCONDITIONAL it no longer depends on a triggering
890
+ // relay message existing, so turns started from the web terminal (which create
891
+ // no relay message) are mirrored too. A reply obligation, when present, is still
892
+ // used as replyTo so the Stop hook stops nagging the agent to /reply.
819
893
  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.
894
+ const turnId = this.currentTurnId;
895
+ this.stopReasoningTail();
896
+ // Optional correlation for threading + obligation clearing — never a capture gate.
822
897
  let replyToMessageId: number | undefined;
823
898
  const pendingPrompt = this.pendingPromptMessageId;
824
899
  if (pendingPrompt) {
@@ -830,52 +905,269 @@ export class AgentRunner {
830
905
  const obligation = [...obligations].reverse().find((o) => o.from === "user");
831
906
  replyToMessageId = obligation?.messageId;
832
907
  } catch {
833
- return;
908
+ // fall through and capture without correlation
834
909
  }
835
910
  }
836
- if (!replyToMessageId) return;
837
911
 
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.
912
+ // The Stop hook can fire before the final assistant entry is flushed to disk.
913
+ // Claude writes thinking and text as separate entries (both with end_turn), so
914
+ // the transcript can "look complete" while the text entry is still pending.
915
+ // Retry until both the transcript has an end_turn AND extraction yields text.
849
916
  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; }
917
+ let jsonl: string | undefined;
918
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
919
+ if (jsonl !== undefined) {
920
+ for (let attempt = 0; attempt < 5; attempt++) {
921
+ if (attempt > 0) {
922
+ await new Promise((r) => setTimeout(r, 100));
923
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { break; }
924
+ }
925
+ if (!transcriptLooksComplete(jsonl)) continue;
926
+ const extract = this.options.providerConfig.chatCaptureMode === "full" ? extractLastAssistantTurn : extractFinalAssistantMessage;
927
+ body = extract(jsonl);
928
+ if (body) break;
854
929
  }
855
- if (!transcriptLooksComplete(jsonl)) continue;
856
- const extract = this.options.providerConfig.chatCaptureMode === "full" ? extractLastAssistantTurn : extractFinalAssistantMessage;
857
- body = extract(jsonl);
858
- if (body) break;
859
930
  }
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.
931
+ // Fallback: last_assistant_message from the Stop hook payload, which bypasses
932
+ // the transcript file race entirely.
863
933
  if (!body && input.lastAssistantMessage) {
864
934
  body = extractHookAssistantMessage(input.lastAssistantMessage);
865
935
  }
866
- if (!body) return;
936
+ // A pure tool-use turn with no closing text is fine to skip — its reasoning and
937
+ // tool steps already carried the visibility into chat.
938
+ if (!body) {
939
+ this.sessionLog(`response capture: no closing text for turn ${turnId ?? "?"} (skipped)`);
940
+ return;
941
+ }
867
942
 
943
+ this.sessionLog(`response captured for turn ${turnId ?? "?"} (${body.length} chars${replyToMessageId ? `, replyTo #${replyToMessageId}` : ", no replyTo"})`);
944
+ await this.publishSessionEvent({
945
+ from: this.agentId,
946
+ to: "user",
947
+ body,
948
+ ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
949
+ session: { type: "response", origin: "provider", ...(turnId ? { turnId } : {}) },
950
+ });
951
+ }
952
+
953
+ // Post one session-mirror event (prompt echo, assistant response, reasoning or
954
+ // tool step) as a `kind: "session"` relay message tagged with payload.session so
955
+ // the dashboard can render the live provider session faithfully. Display-only:
956
+ // session messages are never delivered back into a provider.
957
+ private async publishSessionEvent(input: {
958
+ from: string;
959
+ to: string;
960
+ body: string;
961
+ session: MessageSessionMeta;
962
+ replyTo?: number;
963
+ }): Promise<void> {
868
964
  try {
869
965
  await this.http.sendMessage({
966
+ from: input.from,
967
+ to: input.to,
968
+ ...(input.replyTo ? { replyTo: input.replyTo } : {}),
969
+ kind: "session",
970
+ body: input.body,
971
+ payload: { session: { provider: this.options.provider, ...input.session } },
972
+ });
973
+ } catch (error) {
974
+ this.logRunnerDiagnostic(`session ${input.session.type} capture failed: ${error instanceof Error ? error.message : String(error)}`);
975
+ }
976
+ }
977
+
978
+ // A human typed a prompt directly into the provider (web terminal / TUI). Mirror
979
+ // it into the dashboard chat so both surfaces stay in sync, and kick off reasoning
980
+ // tailing for the turn. Skips prompts the runner itself injected (chat box, relay
981
+ // deliveries) so those aren't double-posted.
982
+ private async handleUserPrompt(input: { prompt: string; transcriptPath?: string }): Promise<void> {
983
+ if (!this.currentTurnId) this.currentTurnId = crypto.randomUUID();
984
+ const text = input.prompt.trim();
985
+ if (text && !this.isRunnerInjectedPrompt(text)) {
986
+ this.sessionLog(`prompt echoed from terminal (${text.length} chars)`);
987
+ await this.publishSessionEvent({
988
+ from: "user",
989
+ to: this.agentId,
990
+ body: text,
991
+ session: { type: "prompt", origin: "terminal", turnId: this.currentTurnId },
992
+ });
993
+ } else if (text) {
994
+ this.sessionDebug("user-prompt hook: skipped echo (runner-injected)");
995
+ }
996
+ if (input.transcriptPath) this.startReasoningTail(input.transcriptPath);
997
+ }
998
+
999
+ // Route a provider-emitted session event (Codex app-server) into the chat mirror.
1000
+ // Mirrors the same semantics as the Claude lane: prompts are echoed with dedup,
1001
+ // and a response is only auto-captured when the agent won't separately reply to a
1002
+ // relay obligation (so relay-triggered turns aren't double-posted).
1003
+ private async publishProviderSessionEvent(event: ProviderSessionEvent): Promise<void> {
1004
+ const body = event.body.trim();
1005
+ if (!body) return;
1006
+ const turnId = event.turnId ?? this.currentTurnId;
1007
+ if (event.type === "prompt") {
1008
+ if (this.isRunnerInjectedPrompt(body)) return;
1009
+ await this.publishSessionEvent({
1010
+ from: "user",
1011
+ to: this.agentId,
1012
+ body,
1013
+ session: { type: "prompt", origin: event.origin ?? "terminal", ...(turnId ? { turnId } : {}) },
1014
+ });
1015
+ return;
1016
+ }
1017
+ if (event.type === "response") {
1018
+ // If a relay message is awaiting the agent's own reply, let the agent answer
1019
+ // it (Codex agents reply via their relay skills) instead of double-posting.
1020
+ let replyToMessageId: number | undefined;
1021
+ const pendingPrompt = this.pendingPromptMessageId;
1022
+ if (pendingPrompt) {
1023
+ replyToMessageId = pendingPrompt;
1024
+ this.pendingPromptMessageId = undefined;
1025
+ } else {
1026
+ try {
1027
+ const obligations = await this.http.listReplyObligations(this.agentId);
1028
+ if (obligations.some((o) => o.from === "user")) return;
1029
+ } catch {
1030
+ // capture anyway on lookup failure
1031
+ }
1032
+ }
1033
+ await this.publishSessionEvent({
870
1034
  from: this.agentId,
871
1035
  to: "user",
872
- replyTo: replyToMessageId,
873
- kind: "session",
874
1036
  body,
1037
+ ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
1038
+ session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}) },
875
1039
  });
876
- } catch (error) {
877
- this.logRunnerDiagnostic(`session turn capture failed: ${error instanceof Error ? error.message : String(error)}`);
1040
+ return;
1041
+ }
1042
+ if (this.options.providerConfig.reasoningCapture === false) return;
1043
+ await this.publishSessionEvent({
1044
+ from: this.agentId,
1045
+ to: "user",
1046
+ body,
1047
+ session: { type: event.type, origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}), ...(event.label ? { label: event.label } : {}) },
1048
+ });
1049
+ }
1050
+
1051
+ private isRunnerInjectedPrompt(text: string): boolean {
1052
+ if (RELAY_INJECTION_MARKERS.some((marker) => text.startsWith(marker))) return true;
1053
+ const recent = this.lastInjectedPrompt;
1054
+ if (recent && recent.text === text && Date.now() - recent.at < PROMPT_ECHO_DEDUP_MS) {
1055
+ this.lastInjectedPrompt = undefined;
1056
+ return true;
1057
+ }
1058
+ return false;
1059
+ }
1060
+
1061
+ // --- Busy-state reconciler (item 2) -------------------------------------------------
1062
+ // A safety net for turns that end out of band (interrupted from the web terminal,
1063
+ // a hook that never fired) where the runner would otherwise stay stuck "busy".
1064
+ private armBusyReconciler(): void {
1065
+ if (this.busyReconcileTimer || !this.options.adapter.probeActivity) return;
1066
+ this.busyReconcileIdleStreak = 0;
1067
+ this.busyReconcileSawBusy = false;
1068
+ this.busyReconcileTimer = setInterval(() => { void this.runBusyReconcile(); }, BUSY_RECONCILE_POLL_MS);
1069
+ }
1070
+
1071
+ private disarmBusyReconciler(): void {
1072
+ if (this.busyReconcileTimer) clearInterval(this.busyReconcileTimer);
1073
+ this.busyReconcileTimer = undefined;
1074
+ this.busyReconcileIdleStreak = 0;
1075
+ this.busyReconcileSawBusy = false;
1076
+ }
1077
+
1078
+ private async runBusyReconcile(): Promise<void> {
1079
+ if (this.stopped || !this.process || !this.options.adapter.probeActivity) { this.disarmBusyReconciler(); return; }
1080
+ // Only act while the runner still believes a provider turn is in flight, and
1081
+ // never override a legitimate approval/blocked state.
1082
+ if (this.claims.currentStatus() !== "busy" || this.providerBlocked) { this.busyReconcileIdleStreak = 0; return; }
1083
+ if (!this.claims.activeWork().some((w) => w.kind === "provider-turn")) { this.disarmBusyReconciler(); return; }
1084
+ let activity: "busy" | "idle" | "unknown";
1085
+ try { activity = await this.options.adapter.probeActivity(this.process); } catch { return; }
1086
+ if (activity === "busy") this.busyReconcileSawBusy = true;
1087
+ // Reset the streak on anything that isn't a confident idle — and never start
1088
+ // counting until we've actually observed the provider busy this turn.
1089
+ if (activity !== "idle" || !this.busyReconcileSawBusy) {
1090
+ if (activity !== "idle") this.busyReconcileIdleStreak = 0;
1091
+ this.sessionDebug(`reconcile probe=${activity} sawBusy=${this.busyReconcileSawBusy} streak=${this.busyReconcileIdleStreak}`);
1092
+ return;
878
1093
  }
1094
+ this.busyReconcileIdleStreak += 1;
1095
+ this.sessionDebug(`reconcile probe=idle streak=${this.busyReconcileIdleStreak}/${BUSY_RECONCILE_IDLE_CONFIRM}`);
1096
+ if (this.busyReconcileIdleStreak < BUSY_RECONCILE_IDLE_CONFIRM) return;
1097
+ this.disarmBusyReconciler();
1098
+ this.forceClearProviderTurn("backstop reconciler");
1099
+ }
1100
+
1101
+ // Force-clear a stuck provider-turn claim directly. Unlike the idle status path
1102
+ // it does NOT depend on a matching claim id (the Stop hook keys busy as
1103
+ // provider-turn:provider-turn, but reconciliation has no specific id), and it
1104
+ // deliberately leaves the reasoning tail alone so a late clear can't truncate
1105
+ // a turn's activity stream.
1106
+ private forceClearProviderTurn(reason: string): void {
1107
+ if (!this.claims.activeWork().some((w) => w.kind === "provider-turn")) return;
1108
+ this.sessionLog(`force-clearing stuck provider-turn (${reason})`);
1109
+ this.claims.clearWorkKind("provider-turn");
1110
+ this.currentTurnId = undefined;
1111
+ this.publishStatus();
1112
+ }
1113
+
1114
+ // After a dashboard interrupt, the provider should drop out of its turn; reconcile
1115
+ // promptly so the busy indicator clears even if the Stop hook doesn't fire.
1116
+ private scheduleInterruptReconcile(): void {
1117
+ setTimeout(() => {
1118
+ if (this.stopped || !this.process) return;
1119
+ void (async () => {
1120
+ if (this.claims.currentStatus() !== "busy" || this.providerBlocked) return;
1121
+ let activity: "busy" | "idle" | "unknown" = "unknown";
1122
+ try { if (this.options.adapter.probeActivity) activity = await this.options.adapter.probeActivity(this.process!); } catch { return; }
1123
+ this.sessionDebug(`post-interrupt reconcile probe=${activity}`);
1124
+ if (activity === "idle") this.forceClearProviderTurn("post-interrupt");
1125
+ })();
1126
+ }, INTERRUPT_RECONCILE_DELAY_MS);
1127
+ }
1128
+
1129
+ // --- Reasoning tailer (item 5) ------------------------------------------------------
1130
+ // Tail the in-flight turn's Claude transcript and surface new reasoning/tool steps
1131
+ // as discreet session events. Coalesced and coarse; the final response still comes
1132
+ // through publishSessionTurn.
1133
+ private startReasoningTail(transcriptPath: string): void {
1134
+ if (this.options.providerConfig.reasoningCapture === false) return;
1135
+ this.stopReasoningTail();
1136
+ // Track emitted steps by content signature, not by index/count: the "latest
1137
+ // turn" window in the transcript can shrink/reset (a tool_result entry, a
1138
+ // mid-turn user line), and an index cursor would then either re-emit or stall
1139
+ // and drop the rest of the turn. A seen-set is idempotent under any reshuffle.
1140
+ const seen = new Set<string>();
1141
+ const turnIdAtStart = this.currentTurnId;
1142
+ const poll = async (): Promise<void> => {
1143
+ let jsonl: string;
1144
+ try { jsonl = await readFile(transcriptPath, "utf8"); } catch { return; }
1145
+ let steps: ReturnType<typeof extractLatestTurnSteps>;
1146
+ try { steps = extractLatestTurnSteps(jsonl); } catch { return; }
1147
+ const turnId = this.currentTurnId ?? turnIdAtStart;
1148
+ let emitted = 0;
1149
+ for (const step of steps) {
1150
+ const sig = `${step.type}${step.label ?? ""}${step.text}`;
1151
+ if (seen.has(sig)) continue;
1152
+ seen.add(sig);
1153
+ emitted += 1;
1154
+ void this.publishSessionEvent({
1155
+ from: this.agentId,
1156
+ to: "user",
1157
+ body: step.text,
1158
+ session: { type: step.type, origin: "provider", ...(turnId ? { turnId } : {}), ...(step.label ? { label: step.label } : {}) },
1159
+ });
1160
+ }
1161
+ if (emitted) this.sessionDebug(`reasoning tail emitted ${emitted} step(s) (turn ${turnId ?? "?"}, ${seen.size} total)`);
1162
+ };
1163
+ this.reasoningTail = { seen, timer: setInterval(() => { void poll(); }, REASONING_POLL_MS) };
1164
+ this.sessionLog(`reasoning tail started (turn ${turnIdAtStart ?? "?"})`);
1165
+ void poll();
1166
+ }
1167
+
1168
+ private stopReasoningTail(): void {
1169
+ if (this.reasoningTail) clearInterval(this.reasoningTail.timer);
1170
+ this.reasoningTail = undefined;
879
1171
  }
880
1172
 
881
1173
  private publishStatus(): void {
@@ -981,6 +1273,24 @@ export class AgentRunner {
981
1273
  }
982
1274
  }
983
1275
 
1276
+ // Session-mirror diagnostics → a dedicated, ANSI-free, greppable log per agent
1277
+ // (NOT the provider's TUI stdout, which is unreadable). This is the single place
1278
+ // to look when chat/terminal sync misbehaves. Key transitions always log here.
1279
+ private sessionLog(message: string): void {
1280
+ try {
1281
+ const logDir = join(process.env.HOME || ".", ".agent-relay", "logs");
1282
+ mkdirSync(logDir, { recursive: true });
1283
+ appendFileSync(join(logDir, `session-mirror-${safeLogName(this.agentId)}.log`), `[${new Date().toISOString()}] ${message}\n`);
1284
+ } catch {
1285
+ // best-effort
1286
+ }
1287
+ }
1288
+
1289
+ // Verbose, high-frequency lines (per-probe, per-emit) — only when AGENT_RELAY_SESSION_DEBUG=1.
1290
+ private sessionDebug(message: string): void {
1291
+ if (this.sessionDebugVerbose) this.sessionLog(message);
1292
+ }
1293
+
984
1294
  private ensureScratch(): void {
985
1295
  try {
986
1296
  this.scratch = ensureSessionScratch({
@@ -1451,6 +1761,13 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextStats?: { so
1451
1761
  liveSession: {
1452
1762
  capture: true,
1453
1763
  inject: Boolean(options.adapter.deliverInitialPrompt),
1764
+ interrupt: Boolean(options.adapter.interrupt),
1765
+ // Both providers mirror directly-typed prompts and stream reasoning/tool
1766
+ // activity into chat (Claude via hooks + transcript tail, Codex via
1767
+ // app-server item events).
1768
+ promptEcho: true,
1769
+ reasoning: true,
1770
+ slashCommands: options.provider === "claude" || options.provider === "codex",
1454
1771
  },
1455
1772
  source: "runtime",
1456
1773
  confidence: "reported",