agent-relay-runner 0.11.8 → 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.8",
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.4"
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.8",
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
@@ -82,3 +101,42 @@ relay_json_bool_field() {
82
101
  relay_json_escape() {
83
102
  printf '%s' "${1:-}" | sed 's/\\/\\\\/g; s/"/\\"/g'
84
103
  }
104
+
105
+ # Print a short "request-review when done" reminder to stdout IFF this agent owns
106
+ # an isolated workspace whose branch has committed work not yet integrated into
107
+ # base. Prints nothing otherwise — so plain chat, shared-mode, and no-change
108
+ # sessions never pay context/token cost. Reads the workspace from
109
+ # AGENT_RELAY_WORKSPACE_JSON (set by the orchestrator at spawn) and does a local,
110
+ # network-free git count. Always returns 0 (never aborts its caller).
111
+ relay_review_reminder_text() {
112
+ local ws="${AGENT_RELAY_WORKSPACE_JSON:-}"
113
+ [ -z "$ws" ] && return 0
114
+ local mode worktree base id branch ahead
115
+ mode="$(relay_json_string_field mode "$ws")"
116
+ [ "$mode" = "isolated" ] || return 0
117
+ worktree="$(relay_json_string_field worktreePath "$ws")"
118
+ base="$(relay_json_string_field baseSha "$ws")"
119
+ [ -z "$base" ] && base="$(relay_json_string_field baseRef "$ws")"
120
+ id="$(relay_json_string_field id "$ws")"
121
+ branch="$(relay_json_string_field branch "$ws")"
122
+ [ -n "$worktree" ] || return 0
123
+ [ -n "$base" ] || return 0
124
+ [ -n "$id" ] || return 0
125
+ ahead="$(git -C "$worktree" rev-list --count "${base}..HEAD" 2>/dev/null || echo 0)"
126
+ case "$ahead" in ''|*[!0-9]*) ahead=0 ;; esac
127
+ [ "$ahead" -gt 0 ] || return 0
128
+ printf '[agent-relay] You have %s committed change(s) on `%s` that are not yet integrated into base. If your task is complete, request review so Agent Relay can auto-land it: POST /api/workspaces/%s/actions with {"action":"request-review"}. If you are still working, ignore this — it only appears while there is unmerged committed work.' \
129
+ "$ahead" "${branch:-this branch}" "$id"
130
+ return 0
131
+ }
132
+
133
+ # Wrap reminder text in the Claude Code additionalContext envelope for a given
134
+ # hook event. Emits nothing when the text is empty.
135
+ relay_emit_additional_context() {
136
+ local event="${1:-}" text="${2:-}"
137
+ [ -z "$event" ] && return 0
138
+ [ -z "$text" ] && return 0
139
+ printf '{"hookSpecificOutput":{"hookEventName":"%s","additionalContext":"%s"}}' \
140
+ "$event" "$(relay_json_escape "$text")"
141
+ return 0
142
+ }
@@ -12,3 +12,9 @@ case "$source_kind" in
12
12
  *)
13
13
  ;;
14
14
  esac
15
+
16
+ # Re-prime the request-review reminder when a session (re)starts — crucially on
17
+ # source=="compact", which is how it survives a context compaction (PreCompact
18
+ # cannot inject post-compact context; SessionStart can). No-op on a fresh startup
19
+ # with no committed work, and silent for non-isolated/no-change sessions.
20
+ relay_emit_additional_context SessionStart "$(relay_review_reminder_text || true)"
@@ -1,4 +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"
9
+ # Re-surface the request-review reminder each turn while there is unmerged
10
+ # committed work — so a long session can't "forget" to land it. Silent otherwise.
11
+ relay_emit_additional_context UserPromptSubmit "$(relay_review_reminder_text || true)"
package/src/adapter.ts CHANGED
@@ -12,6 +12,10 @@ export interface ProviderStatusEvent {
12
12
  status: string;
13
13
  id?: string;
14
14
  timestamp?: number;
15
+ title?: string;
16
+ body?: string;
17
+ icon?: string;
18
+ metadata?: Record<string, unknown>;
15
19
  };
16
20
  id?: string;
17
21
  label?: string;
@@ -23,6 +27,20 @@ export interface ProviderStatusEvent {
23
27
 
24
28
  export type ProviderStatusUpdate = SemanticStatus | ProviderStatusEvent;
25
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
+
26
44
  export interface ProviderConfig {
27
45
  command: string;
28
46
  defaultArgs: string[];
@@ -32,6 +50,9 @@ export interface ProviderConfig {
32
50
  defaultApprovalMode: string;
33
51
  defaultTags: string[];
34
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;
35
56
  headless: {
36
57
  tmuxPrefix: string;
37
58
  shutdownTimeoutMs: number;
@@ -106,11 +127,24 @@ export interface ProviderAdapter {
106
127
  shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
107
128
  compact?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
108
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">;
109
139
  terminalAttachSpec?(process: ManagedProcess): Promise<TerminalAttachSpec>;
110
140
  respondToPermissionDecision?(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown> | void>;
111
141
  deliverInitialPrompt?(process: ManagedProcess, prompt: string): Promise<void>;
112
142
  deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
113
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;
114
148
  // Headless providers with no tmux session (e.g. the Codex app-server) still
115
149
  // warrant an automatic restart on unexpected exit. Returning true opts the
116
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
@@ -1,15 +1,15 @@
1
1
  import { hostname } from "node:os";
2
- import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
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";
@@ -67,6 +67,32 @@ const UNEXPECTED_EXIT_WINDOW_MS = 2 * 60 * 1000;
67
67
  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
+ 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;
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;
86
+
87
+ interface RunnerTimelineEvent {
88
+ status: string;
89
+ id?: string;
90
+ timestamp: number;
91
+ title?: string;
92
+ body?: string;
93
+ icon?: string;
94
+ metadata?: Record<string, unknown>;
95
+ }
70
96
 
71
97
  export class AgentRunner {
72
98
  private readonly agentId: string;
@@ -101,8 +127,24 @@ export class AgentRunner {
101
127
  private readonly unexpectedExitTimes: number[] = [];
102
128
  private readonly pendingMessages = new Map<number, Message>();
103
129
  private readonly activeTaskClaims = new Map<number, ActiveTaskClaim>();
104
- private pendingTimelineEvent?: { status: string; id?: string; timestamp: number };
130
+ private pendingTimelineEvent?: RunnerTimelineEvent;
105
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 };
106
148
  private scratch?: SessionScratchLayout;
107
149
 
108
150
  constructor(private readonly options: RunnerOptions) {
@@ -183,6 +225,7 @@ export class AgentRunner {
183
225
  onTerminalAttachSpec: () => this.terminalAttachSpec(),
184
226
  onReplyObligations: () => this.http.listReplyObligations(this.agentId),
185
227
  onSessionTurn: (input) => this.publishSessionTurn(input),
228
+ onUserPrompt: (input) => this.handleUserPrompt(input),
186
229
  });
187
230
  this.writeRunnerInfoFile();
188
231
  this.options.adapter.onStatusChange((status) => {
@@ -195,6 +238,7 @@ export class AgentRunner {
195
238
  this.setProviderStatus(status);
196
239
  if (runnerShouldResolveProviderExit(semanticStatus, this.exitCommandInProgress)) this.options.onProviderExit?.(semanticStatus === "offline" ? 0 : 1);
197
240
  });
241
+ this.options.adapter.onSessionEvent?.((event) => { void this.publishProviderSessionEvent(event); });
198
242
  this.bus.on("message.new", (message) => this.enqueueMessage(message as Message));
199
243
  this.bus.on("command", (type, params, commandId, command) => {
200
244
  void this.handleCommand(type, params, commandId, command);
@@ -238,6 +282,8 @@ export class AgentRunner {
238
282
  this.httpLivenessTimer = undefined;
239
283
  if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
240
284
  this.tokenRenewTimer = undefined;
285
+ this.disarmBusyReconciler();
286
+ this.stopReasoningTail();
241
287
  this.control?.stop();
242
288
  await this.bus.close();
243
289
  }
@@ -416,7 +462,7 @@ export class AgentRunner {
416
462
  private async handleCommand(type: string, params: Record<string, unknown>, commandId: string, command?: Record<string, unknown>): Promise<void> {
417
463
  const target = typeof command?.target === "string" ? command.target : this.agentId;
418
464
  if (target !== this.agentId && target !== this.options.runnerId) return;
419
- 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;
420
466
 
421
467
  const exitAfterCommand = type === "agent.shutdown" || type === "agent.kill";
422
468
  if (exitAfterCommand) {
@@ -439,6 +485,9 @@ export class AgentRunner {
439
485
  } else if (type === "agent.clearContext") {
440
486
  if (!this.options.adapter.clearContext || !this.process) throw new Error("provider does not support clearContext");
441
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);
442
491
  } else if (type === "agent.injectContext") {
443
492
  if (!this.process) throw new Error("provider process is unavailable");
444
493
  providerResult = await this.injectContext(params);
@@ -534,6 +583,9 @@ export class AgentRunner {
534
583
  if (!this.options.adapter.deliverInitialPrompt) throw new Error("provider does not support prompt injection");
535
584
  const messageId = typeof params.messageId === "number" ? params.messageId : undefined;
536
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() };
537
589
  await this.options.adapter.deliverInitialPrompt(this.process, body);
538
590
  return { injected: true, messageId };
539
591
  }
@@ -579,9 +631,69 @@ export class AgentRunner {
579
631
  const recent = this.unexpectedExitTimes.filter((time) => now - time <= UNEXPECTED_EXIT_WINDOW_MS);
580
632
  recent.push(now);
581
633
  this.unexpectedExitTimes.splice(0, this.unexpectedExitTimes.length, ...recent);
634
+ const diagnostics = this.providerExitDiagnostics(status, runtimeMs);
635
+
636
+ this.publishRunnerTimelineEvent({
637
+ status: "provider.exit_detected",
638
+ id: `provider-exit-${this.providerSessionId}-${now}`,
639
+ timestamp: now,
640
+ title: "Provider exited",
641
+ body: `${this.options.provider} reported ${status} after ${Math.round(runtimeMs / 1000)}s`,
642
+ icon: "ti-plug-off",
643
+ metadata: {
644
+ eventType: "provider.exit_detected",
645
+ ...diagnostics,
646
+ },
647
+ });
648
+
649
+ if (this.shouldStopUnexpectedProviderExit(diagnostics)) {
650
+ const hasResumeId = typeof diagnostics.claudeResumeId === "string" && diagnostics.claudeResumeId.length > 0;
651
+ console.warn(`[runner] ${this.options.provider} exited; leaving agent offline for manual recovery`);
652
+ this.publishRunnerTimelineEvent({
653
+ status: "provider.restart_decision",
654
+ id: `provider-restart-decision-${this.providerSessionId}-${now}`,
655
+ timestamp: Date.now(),
656
+ title: "Provider restart skipped",
657
+ body: hasResumeId
658
+ ? "Claude exited; runner will not auto-resume. Resume id captured for manual recovery."
659
+ : "Claude exited; runner will not restart automatically.",
660
+ icon: "ti-player-stop",
661
+ metadata: {
662
+ eventType: "provider.restart_decision",
663
+ decision: "stop-surface",
664
+ reason: hasResumeId ? "claude-exit-manual-resume-available" : "claude-exit-manual-intervention-required",
665
+ ...diagnostics,
666
+ },
667
+ });
668
+ this.process = undefined;
669
+ this.setProviderStatus({
670
+ status,
671
+ reason: "provider-turn",
672
+ id: `provider-exit-${this.providerSessionId}`,
673
+ clear: ["provider-turn", "subagent"],
674
+ });
675
+ return;
676
+ }
582
677
 
583
678
  if (runtimeMs < RAPID_EXIT_MS && recent.length > MAX_RAPID_UNEXPECTED_EXITS) {
584
679
  console.error(`[runner] provider session exited ${recent.length} times within ${Math.round(UNEXPECTED_EXIT_WINDOW_MS / 1000)}s; giving up`);
680
+ this.publishRunnerTimelineEvent({
681
+ status: "provider.restart_decision",
682
+ id: `provider-restart-decision-${this.providerSessionId}-${now}`,
683
+ timestamp: Date.now(),
684
+ title: "Provider restart skipped",
685
+ body: `rapid unexpected exits exceeded ${MAX_RAPID_UNEXPECTED_EXITS}`,
686
+ icon: "ti-alert-triangle",
687
+ metadata: {
688
+ eventType: "provider.restart_decision",
689
+ decision: "give-up",
690
+ reason: "rapid-unexpected-provider-exits",
691
+ rapidExitCount: recent.length,
692
+ rapidExitWindowMs: UNEXPECTED_EXIT_WINDOW_MS,
693
+ maxRapidUnexpectedExits: MAX_RAPID_UNEXPECTED_EXITS,
694
+ ...diagnostics,
695
+ },
696
+ });
585
697
  this.setProviderStatus(status);
586
698
  this.options.onProviderExit?.(0);
587
699
  return;
@@ -589,6 +701,23 @@ export class AgentRunner {
589
701
 
590
702
  const delayMs = Math.min(10_000, Math.max(500, 500 * recent.length));
591
703
  console.warn(`[runner] provider session exited unexpectedly after ${Math.round(runtimeMs / 1000)}s; restarting in ${delayMs}ms`);
704
+ this.publishRunnerTimelineEvent({
705
+ status: "provider.restart_decision",
706
+ id: `provider-restart-decision-${this.providerSessionId}-${now}`,
707
+ timestamp: Date.now(),
708
+ title: "Provider restart scheduled",
709
+ body: `runner will start a fresh ${this.options.provider} provider in ${delayMs}ms`,
710
+ icon: "ti-refresh",
711
+ metadata: {
712
+ eventType: "provider.restart_decision",
713
+ decision: "restart-fresh",
714
+ reason: "unexpected-headless-terminal-exit",
715
+ delayMs,
716
+ rapidExitCount: recent.length,
717
+ rapidExitWindowMs: UNEXPECTED_EXIT_WINDOW_MS,
718
+ ...diagnostics,
719
+ },
720
+ });
592
721
  await Bun.sleep(delayMs);
593
722
  if (this.stopped || this.exitCommandInProgress) return;
594
723
  try {
@@ -605,6 +734,10 @@ export class AgentRunner {
605
734
  }
606
735
  }
607
736
 
737
+ private shouldStopUnexpectedProviderExit(diagnostics: Record<string, unknown>): boolean {
738
+ return this.options.provider === "claude" && diagnostics.exitCommandInProgress !== true;
739
+ }
740
+
608
741
  private async shutdownProvider(hard: boolean, timeoutMs = this.options.providerConfig.headless.shutdownTimeoutMs): Promise<void> {
609
742
  this.lifecycleAction = hard ? "killing" : "shutting-down";
610
743
  this.publishStatus();
@@ -620,6 +753,46 @@ export class AgentRunner {
620
753
  this.stopped = true;
621
754
  }
622
755
 
756
+ private publishRunnerTimelineEvent(event: RunnerTimelineEvent): void {
757
+ this.pendingTimelineEvent = {
758
+ ...event,
759
+ metadata: {
760
+ source: "runner",
761
+ provider: this.options.provider,
762
+ runnerId: this.options.runnerId,
763
+ agentId: this.agentId,
764
+ policyName: this.options.policyName ?? null,
765
+ spawnRequestId: this.options.spawnRequestId ?? null,
766
+ label: this.options.label ?? null,
767
+ providerSessionId: this.providerSessionId,
768
+ ...(event.metadata ?? {}),
769
+ },
770
+ };
771
+ this.publishStatus();
772
+ }
773
+
774
+ private providerExitDiagnostics(status: SemanticStatus, runtimeMs: number): Record<string, unknown> {
775
+ const tmuxSession = typeof this.process?.meta?.tmuxSession === "string" ? this.process.meta.tmuxSession : undefined;
776
+ const tmuxSocket = typeof this.process?.meta?.tmuxSocket === "string" ? this.process.meta.tmuxSocket : undefined;
777
+ const exitSource = tmuxSession ? "tmux-session-ended" : this.process?.process ? "process-exit" : "provider-status";
778
+ const logFile = typeof process.env.AGENT_RELAY_LOG_FILE === "string" ? process.env.AGENT_RELAY_LOG_FILE : undefined;
779
+ const claudeResumeId = this.options.provider === "claude" && logFile ? latestClaudeResumeIdFromLogFile(logFile) : undefined;
780
+ return {
781
+ status,
782
+ runtimeMs: Number.isFinite(runtimeMs) ? runtimeMs : null,
783
+ exitSource,
784
+ exitCommandInProgress: this.exitCommandInProgress,
785
+ stopped: this.stopped,
786
+ restartInProgress: this.restartInProgress,
787
+ restartPending: this.restartPending,
788
+ headless: this.options.headless,
789
+ hasTerminalSession: Boolean(tmuxSession),
790
+ tmuxSession: tmuxSession ?? null,
791
+ tmuxSocket: tmuxSocket ?? null,
792
+ claudeResumeId: claudeResumeId ?? null,
793
+ };
794
+ }
795
+
623
796
  private async updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<void> {
624
797
  await this.bus.updateCommand(commandId, { status, ...(result ? { result } : {}), ...(error ? { error } : {}) });
625
798
  }
@@ -646,8 +819,26 @@ export class AgentRunner {
646
819
  status: update.timeline.status,
647
820
  ...(update.timeline.id ? { id: update.timeline.id } : {}),
648
821
  timestamp: update.timeline.timestamp ?? Date.now(),
822
+ ...(update.timeline.title ? { title: update.timeline.title } : {}),
823
+ ...(update.timeline.body ? { body: update.timeline.body } : {}),
824
+ ...(update.timeline.icon ? { icon: update.timeline.icon } : {}),
825
+ ...(update.timeline.metadata ? { metadata: update.timeline.metadata } : {}),
649
826
  };
650
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
+ }
651
842
  if (status === "busy") {
652
843
  this.claims.clearTerminalStatus();
653
844
  this.claims.startWork(reason, id, typeof update === "string" ? {} : {
@@ -674,14 +865,16 @@ export class AgentRunner {
674
865
  this.publishStatus();
675
866
  }
676
867
 
677
- // Phase 1 live-session lane: capture the assistant turn from the Claude
678
- // transcript and post it as an observed "session" message so it shows in the
679
- // dashboard chat with zero agent tokens. Posting it as a reply to the
680
- // triggering message also clears the reply obligation, so the Stop hook no
681
- // 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.
682
874
  private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown }): Promise<void> {
683
- // Find the triggering message to reply to: either a pending prompt injection
684
- // (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.
685
878
  let replyToMessageId: number | undefined;
686
879
  const pendingPrompt = this.pendingPromptMessageId;
687
880
  if (pendingPrompt) {
@@ -693,52 +886,216 @@ export class AgentRunner {
693
886
  const obligation = [...obligations].reverse().find((o) => o.from === "user");
694
887
  replyToMessageId = obligation?.messageId;
695
888
  } catch {
696
- return;
889
+ // fall through and capture without correlation
697
890
  }
698
891
  }
699
- if (!replyToMessageId) return;
700
892
 
701
- let jsonl: string;
702
- try {
703
- jsonl = await readFile(input.transcriptPath, "utf8");
704
- } catch {
705
- return;
706
- }
707
- // The Stop hook can fire before the final assistant entry is flushed to
708
- // disk. Claude Code writes thinking and text as separate entries (both with
709
- // end_turn), so the transcript can "look complete" while the text entry is
710
- // still pending. Retry until both the transcript has an end_turn AND the
711
- // 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.
712
897
  let body = "";
713
- for (let attempt = 0; attempt < 5; attempt++) {
714
- if (attempt > 0) {
715
- await new Promise((r) => setTimeout(r, 100));
716
- 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;
717
910
  }
718
- if (!transcriptLooksComplete(jsonl)) continue;
719
- const extract = this.options.providerConfig.chatCaptureMode === "full" ? extractLastAssistantTurn : extractFinalAssistantMessage;
720
- body = extract(jsonl);
721
- if (body) break;
722
911
  }
723
- // Fallback: use last_assistant_message from the Stop hook payload directly.
724
- // This bypasses the transcript file race entirely — Claude Code provides the
725
- // 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.
726
914
  if (!body && input.lastAssistantMessage) {
727
915
  body = extractHookAssistantMessage(input.lastAssistantMessage);
728
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.
729
919
  if (!body) return;
730
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> {
731
941
  try {
732
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({
733
1008
  from: this.agentId,
734
1009
  to: "user",
735
- replyTo: replyToMessageId,
736
- kind: "session",
737
1010
  body,
1011
+ ...(replyToMessageId ? { replyTo: replyToMessageId } : {}),
1012
+ session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}) },
738
1013
  });
739
- } catch (error) {
740
- this.logRunnerDiagnostic(`session turn capture failed: ${error instanceof Error ? error.message : String(error)}`);
1014
+ return;
741
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;
742
1099
  }
743
1100
 
744
1101
  private publishStatus(): void {
@@ -874,9 +1231,20 @@ export class AgentRunner {
874
1231
  private scheduleRuntimeTokenRenewal(delayMs?: number): void {
875
1232
  if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
876
1233
  this.tokenRenewTimer = undefined;
877
- if (!this.isRuntimeTokenRenewable()) return;
878
- const computedDelay = delayMs ?? runtimeTokenRenewDelayMs(this.currentTokenExpiresAt!, Date.now());
879
- if (computedDelay === undefined) return;
1234
+ if (this.stopped) return;
1235
+ const canSelfRenew = this.isRuntimeTokenRenewable();
1236
+ const canRemint = this.canRemintViaOrchestrator();
1237
+ // Keep the renewal clock ticking as long as the session can recover its token
1238
+ // by EITHER path. Without the re-mint fallback an expired token would stop the
1239
+ // timer forever (the old deadlock that stranded live agents off the bus).
1240
+ if (!canSelfRenew && !canRemint) return;
1241
+ let computedDelay = delayMs;
1242
+ if (computedDelay === undefined) {
1243
+ computedDelay = canSelfRenew
1244
+ ? runtimeTokenRenewDelayMs(this.currentTokenExpiresAt!, Date.now())
1245
+ : TOKEN_RENEW_RETRY_MS; // expired but re-mintable → retry via orchestrator soon
1246
+ if (computedDelay === undefined) computedDelay = TOKEN_RENEW_RETRY_MS;
1247
+ }
880
1248
  const schedule = runtimeTokenRenewTimerSchedule(computedDelay);
881
1249
  if (!schedule) return;
882
1250
  this.tokenRenewTimer = setTimeout(() => {
@@ -889,6 +1257,8 @@ export class AgentRunner {
889
1257
  }, schedule.delayMs);
890
1258
  }
891
1259
 
1260
+ // Can the runner self-renew right now? Requires a non-expired runner-profile token
1261
+ // (the relay rejects renewal of an expired token).
892
1262
  private isRuntimeTokenRenewable(): boolean {
893
1263
  return Boolean(
894
1264
  this.currentToken &&
@@ -898,32 +1268,36 @@ export class AgentRunner {
898
1268
  );
899
1269
  }
900
1270
 
1271
+ // Can the runner recover its token via the orchestrator? Works even when the token
1272
+ // is already expired — the orchestrator's standing credential is the authority.
1273
+ private canRemintViaOrchestrator(): boolean {
1274
+ return Boolean(
1275
+ process.env.AGENT_RELAY_ORCHESTRATOR_URL &&
1276
+ this.currentToken &&
1277
+ (this.currentTokenProfileId === "provider-agent" || this.currentTokenProfileId === "provider-interactive"),
1278
+ );
1279
+ }
1280
+
901
1281
  private async renewRuntimeToken(): Promise<void> {
902
- if (this.stopped || this.tokenRenewInFlight || !this.isRuntimeTokenRenewable()) return;
1282
+ if (this.stopped || this.tokenRenewInFlight || !this.currentToken) return;
903
1283
  this.tokenRenewInFlight = true;
904
1284
  try {
905
- const renewed = await this.http.renewRuntimeToken();
906
- this.currentToken = renewed.token;
907
- this.currentTokenJti = renewed.record.jti;
908
- this.currentTokenProfileId = renewed.record.profileId ?? this.currentTokenProfileId;
909
- this.currentTokenExpiresAt = renewed.record.expiresAt;
910
- this.options.token = renewed.token;
911
- this.options.tokenJti = renewed.record.jti;
912
- this.options.tokenProfileId = this.currentTokenProfileId;
913
- this.options.tokenExpiresAt = this.currentTokenExpiresAt;
914
- this.http.setToken(renewed.token);
915
- this.bus.setToken(renewed.token);
916
- this.httpLivenessAuthFailed = false;
917
- this.pendingTimelineEvent = {
918
- status: "runtime-token-renewed",
919
- id: renewed.record.jti,
920
- timestamp: Date.now(),
921
- };
922
- this.bus.reconnectTransport("runtime token renewed");
923
- this.publishStatus();
924
- this.scheduleRuntimeTokenRenewal();
925
- } catch (error) {
926
- this.logRuntimeTokenRenewalFailure(error);
1285
+ // Preferred path: self-renew directly against the relay while the token is
1286
+ // still valid. Cheapest and needs no orchestrator round-trip.
1287
+ if (this.isRuntimeTokenRenewable()) {
1288
+ try {
1289
+ const renewed = await this.http.renewRuntimeToken();
1290
+ this.applyRenewedToken(renewed.token, renewed.record, "runtime-token-renewed");
1291
+ return;
1292
+ } catch (error) {
1293
+ this.logRuntimeTokenRenewalFailure(error);
1294
+ // Relay unreachable or token rejected — fall through to orchestrator re-mint.
1295
+ }
1296
+ }
1297
+ // Recovery path: token expired, or self-renew failed. Ask the orchestrator —
1298
+ // it holds a long-lived credential and can mint a fresh runner token, so a
1299
+ // live session heals instead of being stranded off the bus.
1300
+ if (this.canRemintViaOrchestrator() && await this.remintViaOrchestrator()) return;
927
1301
  this.pendingTimelineEvent = {
928
1302
  status: "runtime-token-renewal-failed",
929
1303
  timestamp: Date.now(),
@@ -935,6 +1309,56 @@ export class AgentRunner {
935
1309
  }
936
1310
  }
937
1311
 
1312
+ // Apply a freshly issued token across every live surface — runner state, the
1313
+ // RunnerOptions bag (re-injected into the provider on respawn), the HTTP client,
1314
+ // the bus client — then force a bus handshake with the new token and reschedule.
1315
+ private applyRenewedToken(
1316
+ token: string,
1317
+ record: { jti: string; profileId?: string; expiresAt?: number },
1318
+ status: "runtime-token-renewed" | "runtime-token-reminted",
1319
+ ): void {
1320
+ this.currentToken = token;
1321
+ this.currentTokenJti = record.jti;
1322
+ this.currentTokenProfileId = record.profileId ?? this.currentTokenProfileId;
1323
+ this.currentTokenExpiresAt = record.expiresAt;
1324
+ this.options.token = token;
1325
+ this.options.tokenJti = record.jti;
1326
+ this.options.tokenProfileId = this.currentTokenProfileId;
1327
+ this.options.tokenExpiresAt = this.currentTokenExpiresAt;
1328
+ this.http.setToken(token);
1329
+ this.bus.setToken(token);
1330
+ this.httpLivenessAuthFailed = false;
1331
+ this.pendingTimelineEvent = { status, id: record.jti, timestamp: Date.now() };
1332
+ this.bus.reconnectTransport(status === "runtime-token-reminted" ? "runtime token re-minted" : "runtime token renewed");
1333
+ this.publishStatus();
1334
+ this.scheduleRuntimeTokenRenewal();
1335
+ }
1336
+
1337
+ // Recover the runtime token through the orchestrator. The runner proxies its own
1338
+ // (possibly expired) token; the orchestrator re-mints it via the relay using its
1339
+ // standing credential. Returns true on success.
1340
+ private async remintViaOrchestrator(): Promise<boolean> {
1341
+ const orchUrl = process.env.AGENT_RELAY_ORCHESTRATOR_URL;
1342
+ if (!orchUrl || !this.currentToken) return false;
1343
+ try {
1344
+ const res = await fetch(`${orchUrl.replace(/\/+$/, "")}/api/runtime-tokens/runner-renew`, {
1345
+ method: "POST",
1346
+ headers: { "Content-Type": "application/json" },
1347
+ body: JSON.stringify({ token: this.currentToken }),
1348
+ signal: AbortSignal.timeout(10_000),
1349
+ });
1350
+ if (!res.ok) return false;
1351
+ const renewed = await res.json() as { token?: string; record?: { jti: string; profileId?: string; expiresAt?: number } };
1352
+ if (!renewed?.token || !renewed.record) return false;
1353
+ this.applyRenewedToken(renewed.token, renewed.record, "runtime-token-reminted");
1354
+ this.logRunnerDiagnostic(`[runner] runtime token re-minted via orchestrator (jti ${renewed.record.jti})`);
1355
+ return true;
1356
+ } catch (error) {
1357
+ this.logRuntimeTokenRenewalFailure(error);
1358
+ return false;
1359
+ }
1360
+ }
1361
+
938
1362
  private logRuntimeTokenRenewalFailure(error: unknown): void {
939
1363
  const key = httpErrorKey(error);
940
1364
  const now = Date.now();
@@ -1146,6 +1570,32 @@ export function runnerShouldRestartUnexpectedProviderExit(
1146
1570
  && input.hasTerminalSession;
1147
1571
  }
1148
1572
 
1573
+ export function latestClaudeResumeIdFromText(text: string): string | undefined {
1574
+ let latest: string | undefined;
1575
+ CLAUDE_RESUME_RE.lastIndex = 0;
1576
+ for (let match = CLAUDE_RESUME_RE.exec(text); match; match = CLAUDE_RESUME_RE.exec(text)) {
1577
+ latest = match[1];
1578
+ }
1579
+ return latest;
1580
+ }
1581
+
1582
+ export function latestClaudeResumeIdFromLogFile(path: string): string | undefined {
1583
+ let fd: number | undefined;
1584
+ try {
1585
+ const stat = statSync(path);
1586
+ const length = Math.min(stat.size, LOG_TAIL_BYTES);
1587
+ const offset = Math.max(0, stat.size - length);
1588
+ const buffer = Buffer.alloc(length);
1589
+ fd = openSync(path, "r");
1590
+ readSync(fd, buffer, 0, length, offset);
1591
+ return latestClaudeResumeIdFromText(buffer.toString("utf8"));
1592
+ } catch {
1593
+ return undefined;
1594
+ } finally {
1595
+ if (fd !== undefined) closeSync(fd);
1596
+ }
1597
+ }
1598
+
1149
1599
  function commandTimeoutMs(params: Record<string, unknown>, fallback = 10_000): number {
1150
1600
  const raw = params.timeoutMs;
1151
1601
  if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0) return fallback;
@@ -1221,6 +1671,13 @@ function runtimeProviderCapabilities(options: RunnerOptions, contextStats?: { so
1221
1671
  liveSession: {
1222
1672
  capture: true,
1223
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",
1224
1681
  },
1225
1682
  source: "runtime",
1226
1683
  confidence: "reported",