agent-relay-runner 0.124.0 → 0.126.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.124.0",
3
+ "version": "0.126.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "agent-relay-providers": "0.104.4",
26
- "agent-relay-sdk": "0.2.116",
26
+ "agent-relay-sdk": "0.2.117",
27
27
  "callmux": "0.23.0"
28
28
  },
29
29
  "devDependencies": {
@@ -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.124.0",
4
+ "version": "0.126.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -115,6 +115,7 @@ relay_post_session_turn() {
115
115
  # below, so the agent is not nagged to /reply (and does not re-emit).
116
116
  local transcript_path="${1:-}"
117
117
  local last_assistant_message="${2:-}"
118
+ local prompt_id="${3:-}"
118
119
  local port="${AGENT_RELAY_RUNNER_PORT:-}"
119
120
  [ -z "$port" ] && return 0
120
121
  # transcript_path is the primary capture source, but the Stop payload can omit it
@@ -127,6 +128,13 @@ relay_post_session_turn() {
127
128
  [ "$body" != "{" ] && body="${body},"
128
129
  body="${body}\"lastAssistantMessage\":${last_assistant_message}"
129
130
  fi
131
+ # prompt_id (#1086): Claude's native turn identifier — the stable id the runner
132
+ # uses instead of a minted random UUID, and the deterministic key it slices the
133
+ # transcript by for full-mode enrichment.
134
+ if [ -n "$prompt_id" ]; then
135
+ [ "$body" != "{" ] && body="${body},"
136
+ body="${body}\"promptId\":\"$(relay_json_escape "$prompt_id")\""
137
+ fi
130
138
  body="${body}}"
131
139
  curl -fsS -X POST "http://127.0.0.1:${port}/session-turn" \
132
140
  -H 'Content-Type: application/json' \
@@ -144,7 +152,9 @@ relay_post_user_prompt() {
144
152
  [ -z "$payload" ] && return 0
145
153
  command -v jq >/dev/null 2>&1 || return 0
146
154
  local body
147
- body="$(printf '%s' "$payload" | jq -c '{prompt: (.prompt // ""), transcriptPath: (.transcript_path // "")}' 2>/dev/null || true)"
155
+ # promptId (#1086): Claude's native prompt_id for the turn this prompt starts
156
+ # the stable turn identifier the runner uses instead of minting a random UUID.
157
+ body="$(printf '%s' "$payload" | jq -c '{prompt: (.prompt // ""), transcriptPath: (.transcript_path // ""), promptId: (.prompt_id // "")}' 2>/dev/null || true)"
148
158
  [ -z "$body" ] && return 0
149
159
  case "$body" in *'"prompt":""'*) return 0 ;; esac
150
160
  curl -fsS --max-time 3 -X POST "http://127.0.0.1:${port}/user-prompt" \
@@ -23,7 +23,12 @@ bg_count="$(relay_background_task_count "$payload")"
23
23
  # (stop_hook_active=true) must NOT suppress capturing this turn's response, or the live mirror
24
24
  # silently drops frames. Always post the turn; only the reply-obligation decision is gated below.
25
25
  last_assistant_msg="$(echo "$payload" | jq -c '.last_assistant_message // empty' 2>/dev/null || true)"
26
- relay_post_session_turn "$(relay_json_string_field transcript_path "$payload")" "$last_assistant_msg"
26
+ # prompt_id (#1086): Claude's own native turn identifier, correlating this Stop with
27
+ # the same UUID stamped on every transcript entry of the turn (and the id the
28
+ # UserPromptSubmit hook reported at turn start) — the stable replacement for a
29
+ # runner-minted random UUID.
30
+ prompt_id="$(relay_json_string_field prompt_id "$payload")"
31
+ relay_post_session_turn "$(relay_json_string_field transcript_path "$payload")" "$last_assistant_msg" "$prompt_id"
27
32
 
28
33
  if [ "$stop_hook_active" != "true" ]; then
29
34
  # `|| true`: under `set -e`, a non-zero from the obligation check must never abort
@@ -3,7 +3,12 @@ set -euo pipefail
3
3
  source "${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/hooks/relay-status.sh"
4
4
  relay_install_hook_guard user-prompt-submit
5
5
  payload="$(cat || true)"
6
- relay_post_status busy
6
+ # prompt_id (#1086): Claude's own native turn identifier for the prompt starting
7
+ # now — the same UUID the Stop hook reports and the transcript stamps on every
8
+ # entry of this turn. Passed as the busy report's `id` so the runner adopts it as
9
+ # currentTurnId instead of minting a random UUID (runner-core.ts setProviderStatus).
10
+ prompt_id="$(relay_json_string_field prompt_id "$payload")"
11
+ relay_post_status busy "" "$prompt_id"
7
12
  # Mirror a terminal/TUI-typed prompt into the dashboard chat and start reasoning
8
13
  # tailing for this turn. No-op for prompts the runner injected (chat box / relay).
9
14
  relay_post_user_prompt "$payload"
package/src/adapter.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AgentProfile, LivenessSignal, Message, ProviderState } from "agent-relay-sdk";
1
+ import type { AgentProfile, LivenessSignal, Message, MessageFinalityConfidence, MessageTurnEndReason, ProviderState } from "agent-relay-sdk";
2
2
  import { isRecord } from "agent-relay-sdk/types/guards";
3
3
  import type { LivenessInputs } from "./liveness";
4
4
  import type { SessionEvent } from "./session-insights";
@@ -50,6 +50,10 @@ export interface ProviderSessionEvent {
50
50
  turnId?: string;
51
51
  /** True only when this response is the completed provider turn, not an intermediate flush. */
52
52
  final?: boolean;
53
+ /** Whether the turn-final edge was provider-native or reconstructed. Set only alongside `final`. */
54
+ confidence?: MessageFinalityConfidence;
55
+ /** Why the provider turn ended. Set only alongside `final`. */
56
+ endedBy?: MessageTurnEndReason;
53
57
  label?: string;
54
58
  status?: "running" | "completed" | "failed";
55
59
  streaming?: boolean;
@@ -46,6 +46,16 @@ interface TranscriptEntry {
46
46
  // Events/countSubstantiveTurns) intentionally do NOT filter — changing them
47
47
  // would shift the #184/#185 baselines, a separate concern.
48
48
  isSidechain?: boolean;
49
+ // Every entry carries its own `uuid` and its `parentUuid` (the entry it was
50
+ // appended after), forming a causal chain for the whole session. A `user`-type
51
+ // entry — whether the real prompt or a synthetic tool_result — is stamped with
52
+ // `promptId`: the same UUID Claude's Stop-hook payload reports as `prompt_id`,
53
+ // shared by every entry from that prompt until the next one. Assistant entries
54
+ // don't carry `promptId` directly; their turn is resolved by walking `parentUuid`
55
+ // back to the nearest ancestor that has one (see resolveEntryPromptId).
56
+ uuid?: string;
57
+ parentUuid?: string;
58
+ promptId?: string;
49
59
  }
50
60
 
51
61
  function blocks(message: TranscriptMessage | undefined): TranscriptBlock[] {
@@ -147,59 +157,39 @@ export function extractLastAssistantTurnAfterEntry(jsonl: string, afterEntry: nu
147
157
  }
148
158
 
149
159
  /**
150
- * Count valid JSON entries in a JSONL string. Used to record the high-water-mark
151
- * after a pre-flush so the Stop hook knows where to resume in full-capture mode.
160
+ * Resolve the `promptId` (Stop hook's `prompt_id`) a transcript entry belongs to.
161
+ * A `user`-type entry (the real prompt, or a synthetic tool_result) carries its own
162
+ * `promptId`; an assistant entry doesn't, so its turn is resolved by walking
163
+ * `parentUuid` back through the causal chain until an ancestor with one is found.
164
+ * Returns undefined for an entry with no resolvable ancestor (e.g. the very first
165
+ * turn of a process, before any `promptId` has ever been stamped).
152
166
  */
153
- export function countTranscriptEntries(jsonl: string): number {
154
- let count = 0;
155
- for (const line of jsonl.split("\n")) {
156
- if (!line.trim()) continue;
157
- try { JSON.parse(line); count++; } catch {}
158
- }
159
- return count;
160
- }
161
-
162
- /**
163
- * Returns only the text from the LAST assistant entry in the current turn.
164
- * Unlike extractLastAssistantTurn which collects all intermediate text between
165
- * tool calls, this returns just the final response — what the user sees as the
166
- * concluding message.
167
- */
168
- export function extractFinalAssistantMessage(jsonl: string): string {
169
- const lines = jsonl.split("\n");
170
- let lastText = "";
171
- let pastLastUserPrompt = false;
172
- for (const line of lines) {
173
- const trimmed = line.trim();
174
- if (!trimmed) continue;
175
- let entry: TranscriptEntry;
176
- try {
177
- entry = JSON.parse(trimmed) as TranscriptEntry;
178
- } catch {
179
- continue;
180
- }
181
- if (isSidechainEntry(entry)) continue;
182
- if (isRealUserPrompt(entry)) {
183
- pastLastUserPrompt = true;
184
- lastText = "";
185
- continue;
186
- }
187
- if (!pastLastUserPrompt) continue;
188
- const text = assistantText(entry);
189
- if (text) lastText = text;
167
+ function resolveEntryPromptId(entry: TranscriptEntry, byUuid: ReadonlyMap<string, TranscriptEntry>): string | undefined {
168
+ let current: TranscriptEntry | undefined = entry;
169
+ const visited = new Set<string>();
170
+ while (current) {
171
+ if (typeof current.promptId === "string" && current.promptId) return current.promptId;
172
+ const parentUuid = current.parentUuid;
173
+ if (!parentUuid || visited.has(parentUuid)) return undefined;
174
+ visited.add(parentUuid);
175
+ current = byUuid.get(parentUuid);
190
176
  }
191
- return lastText.trim();
177
+ return undefined;
192
178
  }
193
179
 
194
180
  /**
195
- * Like extractFinalAssistantMessage but skips the first `afterEntry` valid JSON
196
- * entries. Used when a PreToolUse pre-flush already emitted the text before a
197
- * blocking control, so final-mode Stop capture only sees later assistant text.
181
+ * Deterministic replacement for extractLastAssistantTurnAfterEntry's "walk back to
182
+ * the last real user prompt" heuristic: collects assistant `text` blocks for entries
183
+ * whose resolved promptId (see resolveEntryPromptId) equals the Claude-native
184
+ * `promptId` the Stop hook reported for this turn. Skips the first `afterEntry`
185
+ * entries the same way, for pre-flush resume. Since entries never move once
186
+ * written, a turn's promptId membership is exact — no text-shape guessing about
187
+ * what counts as a "real" prompt.
198
188
  */
199
- export function extractFinalAssistantMessageAfterEntry(jsonl: string, afterEntry: number): string {
189
+ export function extractAssistantTurnForPrompt(jsonl: string, promptId: string, afterEntry = 0): string {
200
190
  const lines = jsonl.split("\n");
201
- let lastText = "";
202
- let pastLastUserPrompt = afterEntry > 0;
191
+ const byUuid = new Map<string, TranscriptEntry>();
192
+ const collected: string[] = [];
203
193
  let entryIndex = 0;
204
194
  for (const line of lines) {
205
195
  const trimmed = line.trim();
@@ -211,26 +201,30 @@ export function extractFinalAssistantMessageAfterEntry(jsonl: string, afterEntry
211
201
  continue;
212
202
  }
213
203
  entryIndex++;
204
+ if (typeof entry.uuid === "string" && entry.uuid) byUuid.set(entry.uuid, entry);
214
205
  if (isSidechainEntry(entry)) continue;
215
206
  if (entryIndex <= afterEntry) continue;
216
- if (isRealUserPrompt(entry)) {
217
- pastLastUserPrompt = true;
218
- lastText = "";
219
- continue;
220
- }
221
- if (!pastLastUserPrompt) continue;
207
+ if (entry.type !== "assistant") continue;
208
+ if (resolveEntryPromptId(entry, byUuid) !== promptId) continue;
222
209
  const text = assistantText(entry);
223
- if (text) lastText = text;
210
+ if (text) collected.push(text);
224
211
  }
225
- return lastText.trim();
212
+ return collected.join("\n\n").trim();
226
213
  }
227
214
 
228
215
  /**
229
- * Extract text from the `last_assistant_message` field in the Stop hook
230
- * payload. This is the content of the final assistant message either a plain
231
- * string or an array of content blocks (same shape as transcript entries).
232
- * Thinking and tool_use blocks are dropped, matching extractLastAssistantTurn.
216
+ * Count valid JSON entries in a JSONL string. Used to record the high-water-mark
217
+ * after a pre-flush so the Stop hook knows where to resume in full-capture mode.
233
218
  */
219
+ export function countTranscriptEntries(jsonl: string): number {
220
+ let count = 0;
221
+ for (const line of jsonl.split("\n")) {
222
+ if (!line.trim()) continue;
223
+ try { JSON.parse(line); count++; } catch {}
224
+ }
225
+ return count;
226
+ }
227
+
234
228
  /**
235
229
  * Extract the ordered narration, reasoning, and tool steps for the most recent
236
230
  * turn (since the last real user prompt). Used by the reasoning tailer to stream
@@ -4,7 +4,6 @@ type CompleteItemOptions = { includeInResponse?: boolean; phase?: unknown };
4
4
 
5
5
  const MAX_CAPTURED_RESPONSE_CHARS = 16_000;
6
6
  const TRUNCATION_MARKER_PREFIX = "[truncated:";
7
- const TERSE_FINAL_MAX_CHARS = 280;
8
7
 
9
8
  export class CodexAgentMessageCapture {
10
9
  private segments: ResponseEntry[][] = [];
@@ -46,11 +45,16 @@ export class CodexAgentMessageCapture {
46
45
  this.currentSegment = [];
47
46
  }
48
47
 
48
+ // Deterministic turn-final response: prefer the trailing structured report span, not a generic
49
+ // post-handoff coda. The original #1079/#1086 trailing-run rule correctly kept early narration
50
+ // out, but it over-trimmed Codex turns shaped "summary -> workspace ready -> Landed..." and made
51
+ // report-up deliver only the coda (#1136). We still drop leading narration; we just let structured
52
+ // report markers beat a terse final status line.
49
53
  finish(emit: EmitAgentMessage): string | undefined {
50
54
  this.closeSegment(emit);
51
- const selectedSegments = selectResponseSegments(this.segments);
55
+ const trailingSegment = selectFinalResponseSegments(this.segments);
52
56
  this.segments = [];
53
- const text = selectedSegments.length ? selectedSegments.map(segmentBody).join("\n\n").trim() : undefined;
57
+ const text = trailingSegment ? trailingSegment.map(segmentBody).join("\n\n").trim() : undefined;
54
58
  return truncateCapturedResponse(text);
55
59
  }
56
60
 
@@ -96,41 +100,39 @@ export class CodexAgentMessageCapture {
96
100
  }
97
101
  }
98
102
 
99
- function selectResponseSegments(segments: ResponseEntry[][]): ResponseEntry[][] {
100
- const nonEmptySegments = segments.filter((segment) => segment.length > 0);
101
- const finalSegment = nonEmptySegments.at(-1);
102
- if (!finalSegment) return [];
103
- const finalBody = segmentBody(finalSegment);
104
- if (!isTerseTerminalStatus(finalBody)) return [finalSegment];
105
-
106
- // Codex often posts the real wrap-up across multiple assistant segments before a
107
- // final workspace-ready/status line. Keep contiguous substantive segments after
108
- // the last procedural assistant segment, then append the terminal status.
109
- const selectedSegments: ResponseEntry[][] = [];
110
- for (let index = nonEmptySegments.length - 2; index >= 0; index -= 1) {
111
- const candidate = nonEmptySegments[index];
112
- if (!candidate || !isSubstantiveResponseSegment(segmentBody(candidate))) break;
113
- selectedSegments.unshift(candidate);
103
+ function segmentBody(segment: ResponseEntry[]): string {
104
+ return segment.map((entry) => entry.body).join("\n\n").trim();
105
+ }
106
+
107
+ function selectFinalResponseSegments(segments: ResponseEntry[][]): ResponseEntry[][] | undefined {
108
+ const bodies = segments.map(segmentBody).filter(Boolean);
109
+ if (!bodies.length) return undefined;
110
+ let end = bodies.length - 1;
111
+ while (end > 0 && isGenericFinalCoda(bodies[end]!) && bodies.slice(0, end).some(isSubstantiveReportSegment)) end--;
112
+ const firstReport = bodies.slice(0, end + 1).findIndex(isReportBoundarySegment);
113
+ if (firstReport >= 0) return segments.slice(firstReport, end + 1);
114
+ for (let i = end; i >= 0; i--) {
115
+ if (isSubstantiveReportSegment(bodies[i]!)) return [segments[i]!];
114
116
  }
115
- return [...selectedSegments, finalSegment];
117
+ return [segments[end]!];
116
118
  }
117
119
 
118
- function segmentBody(segment: ResponseEntry[]): string {
119
- return segment.map((entry) => entry.body).join("\n\n").trim();
120
+ const REPORT_BOUNDARY = /(^|\n)(#{1,6}\s|\*\*(summary|what changed|rca|fix|tests?|gates?|verification|risk)\b|(?:summary|what changed|rca|fix|tests?|gates?|verification|risk):)/i;
121
+ const REPORT_STRUCTURE = /(^|\n)\s*[-*]\s+\S|`[^`]+`|\b(typecheck|bun test|tests? pass(?:ed)?|gates? green|rca|verified|implemented)\b/i;
122
+ const GENERIC_FINAL_CODA = /^(?:done|landed(?: on main)?|committed|merged|ready|no further action|nothing further|all set)\b|(?:\bno further action needed\b|\bnothing further needed\b)$/i;
123
+
124
+ function isReportBoundarySegment(body: string): boolean {
125
+ return REPORT_BOUNDARY.test(body.trim());
120
126
  }
121
127
 
122
- function isTerseTerminalStatus(body: string): boolean {
123
- if (body.length > TERSE_FINAL_MAX_CHARS) return false;
124
- if (/\n\s*(?:[-*]|\d+\.)\s+/.test(body)) return false;
125
- return /\b(?:landed|ready for review|handed off|no further action|done|complete|completed|committed|merged)\b/i.test(body);
128
+ function isSubstantiveReportSegment(body: string): boolean {
129
+ const trimmed = body.trim();
130
+ return !isGenericFinalCoda(trimmed) && (trimmed.length >= 120 || isReportBoundarySegment(trimmed) || REPORT_STRUCTURE.test(trimmed));
126
131
  }
127
132
 
128
- function isSubstantiveResponseSegment(body: string): boolean {
129
- if (body.length >= 120) return true;
130
- if (/\n\s*(?:[-*]|\d+\.)\s+/.test(body)) return true;
131
- if (/\*\*(?:what changed|changes?|tests?|gates?|verification|risks?|residual risk|summary)\b/i.test(body)) return true;
132
- if (/^#{1,3}\s+\S/m.test(body)) return true;
133
- return body.split(/\n{2,}/).length >= 2;
133
+ function isGenericFinalCoda(body: string): boolean {
134
+ const trimmed = body.trim();
135
+ return trimmed.length <= 180 && GENERIC_FINAL_CODA.test(trimmed) && !isReportBoundarySegment(trimmed);
134
136
  }
135
137
 
136
138
  function truncateCapturedResponse(text: string | undefined): string | undefined {
@@ -1,7 +1,7 @@
1
1
  import { accessSync, constants, existsSync, readFileSync, realpathSync, readdirSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join, resolve } from "node:path";
4
- import type { ContextState, InteractivePrompt, LivenessSignal, Message, ProviderState } from "agent-relay-sdk";
4
+ import type { ContextState, InteractivePrompt, LivenessSignal, Message, MessageTurnEndReason, ProviderState } from "agent-relay-sdk";
5
5
  import { isRecord, stringValue } from "agent-relay-sdk";
6
6
  import { isPidAlive, killPid, processTreePids, processTreePidsFromTable, waitForPidsExit } from "agent-relay-sdk/process-utils";
7
7
  import { profileAllowsRelayFeature, providerMessageText, relayContext, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderSessionEvent, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
@@ -144,13 +144,13 @@ export class CodexAdapter implements ProviderAdapter {
144
144
  const turnStatus = isRecord(activeTurn) ? stringValue(activeTurn.status) : undefined;
145
145
  if (turnStatus === "inProgress") return "busy";
146
146
  if (turnStatus === "completed" || turnStatus === "interrupted" || turnStatus === "failed") {
147
- this.finishMainTurn();
147
+ this.finishMainTurn(codexEndedByFrom(turnStatus));
148
148
  return "idle";
149
149
  }
150
150
  const threadStatus = statusType(thread?.status);
151
151
  if (threadStatus === "active") return "busy";
152
152
  if (threadStatus === "idle" || threadStatus === "notLoaded" || threadStatus === "systemError") {
153
- this.finishMainTurn();
153
+ this.finishMainTurn(codexEndedByFrom(threadStatus));
154
154
  return "idle";
155
155
  }
156
156
  } catch {
@@ -508,7 +508,7 @@ export class CodexAdapter implements ProviderAdapter {
508
508
  if (threadId && this.subagentThreads.has(threadId)) {
509
509
  this.statusCb({ status: "idle", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
510
510
  } else {
511
- this.finishMainTurn();
511
+ this.finishMainTurn(codexEndedByFrom(method));
512
512
  }
513
513
  }
514
514
  if ((method.includes("thread/compacted") || method.includes("thread.compacted")) && threadId) {
@@ -527,15 +527,16 @@ export class CodexAdapter implements ProviderAdapter {
527
527
  if (status === "idle" || status === "notLoaded") this.statusCb({ status: "idle", reason: "subagent", id: threadId, ...this.subagentThreads.get(threadId) });
528
528
  } else {
529
529
  if (status === "active") this.statusCb({ status: "busy", reason: "provider-turn", providerState: this.providerStateFromThreadStatus(params?.status, params) });
530
- if (status === "idle" || status === "notLoaded" || status === "systemError") this.finishMainTurn();
530
+ if (status === "idle" || status === "notLoaded" || status === "systemError") this.finishMainTurn(codexEndedByFrom(status));
531
531
  }
532
532
  }
533
533
  }
534
534
 
535
535
  // Turn one completed Codex thread item into a session-mirror event. agentMessage text is
536
- // mirrored as narration as it lands; the captured turn-final response repeats the substantive
537
- // assistant answer, including a substantive wrap-up segment before a terse terminal status.
538
- // The rest (user prompt echo, reasoning, tool steps) is surfaced as it lands.
536
+ // mirrored as narration as it lands; the captured turn-final response repeats only the
537
+ // TRAILING agentMessage run the segment after the last tool/file-change/reasoning
538
+ // boundary (design #1079 §3.2) never earlier mid-work narration, which was already
539
+ // mirrored live. The rest (user prompt echo, reasoning, tool steps) is surfaced as it lands.
539
540
  private handleCodexItem(item: Record<string, unknown> | undefined): void {
540
541
  if (!item) return;
541
542
  const type = stringValue(item.type);
@@ -676,14 +677,23 @@ export class CodexAdapter implements ProviderAdapter {
676
677
  if (method.includes("outputDelta") || method.includes("output_delta") || method.includes("progress")) return;
677
678
  }
678
679
 
679
- private flushTurnResponse(): void {
680
+ // A turn-final capture is emitted ONLY for a genuine `completed` turn (design #1079 §3.2):
681
+ // failed/interrupted turns must never produce a turn-final. Every "response"-type
682
+ // ProviderSessionEvent is unconditionally dual-stamped turnFinal/final downstream
683
+ // (publishCapturedResponse's provider-agnostic stamp), so the only way to keep a
684
+ // failed/interrupted partial OUT of D's work-final ring is to never emit it as a
685
+ // "response" event at all (#1083 Finding 1). The partial text was already mirrored live
686
+ // as narration by the `emit` callback inside agentMessageCapture.finish(), so it's still
687
+ // visible in the dashboard.
688
+ private flushTurnResponse(endedBy: MessageTurnEndReason): void {
680
689
  const text = this.agentMessageCapture.finish((body) => this.emitAgentNarration(body));
681
- if (text) this.sessionEventCb({ type: "response", origin: "provider", body: text, final: true, ...(this.activeTurnId ? { turnId: this.activeTurnId } : {}) });
690
+ if (!text || endedBy !== "completed") return;
691
+ this.sessionEventCb({ type: "response", origin: "provider", body: text, final: true, confidence: "definitive", endedBy, ...(this.activeTurnId ? { turnId: this.activeTurnId } : {}) });
682
692
  }
683
693
 
684
- private finishMainTurn(): void {
694
+ private finishMainTurn(endedBy: MessageTurnEndReason): void {
685
695
  this.flushBufferedReasoning(); // surface any trailing reasoning before the closing response
686
- this.flushTurnResponse();
696
+ this.flushTurnResponse(endedBy);
687
697
  const turnId = this.activeTurnId;
688
698
  this.activeTurnId = undefined;
689
699
  this.pendingApprovals.clear();
@@ -1151,6 +1161,14 @@ function statusType(value: unknown): string | undefined {
1151
1161
  return isRecord(value) ? stringValue(value.type) : undefined;
1152
1162
  }
1153
1163
 
1164
+ // Maps any Codex turn-ending signal — a turn/failed|interrupted (dot-notation too) app-server
1165
+ // method name, a turn status ("completed"/"interrupted"/"failed"), or a thread status
1166
+ // ("idle"/"notLoaded"/"systemError") — to the Part A (#1082) turn-end reason. "completed" is
1167
+ // the default for every signal that isn't explicitly a failure or an interruption.
1168
+ function codexEndedByFrom(value: string | undefined): MessageTurnEndReason {
1169
+ return value === "systemError" || value?.includes("failed") ? "failed" : value?.includes("interrupted") ? "interrupted" : "completed";
1170
+ }
1171
+
1154
1172
  function activeFlags(value: unknown): string[] {
1155
1173
  if (!isRecord(value) || !Array.isArray(value.activeFlags)) return [];
1156
1174
  return value.activeFlags.filter((flag): flag is string => typeof flag === "string" && flag.length > 0);
@@ -49,12 +49,15 @@ interface ControlServerOptions {
49
49
  // dashboard chat without the agent re-emitting it via /reply.
50
50
  // isPreFlush: true signals a mid-turn call from a PreToolUse hook — flush any
51
51
  // un-mirrored narrative before the interactive form appears in chat (#435).
52
- onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void>;
52
+ // promptId (#1086): Claude's native `prompt_id` from the Stop hook payload — the
53
+ // stable, provider-native turn identifier used instead of a minted random UUID.
54
+ onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean }): Promise<void>;
53
55
  onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
54
56
  // A provider UserPromptSubmit hook hands over the prompt the human typed
55
57
  // directly into the session (web terminal / TUI) so the runner can mirror it
56
58
  // into the dashboard chat and start tailing the turn transcript for reasoning.
57
- onUserPrompt?(input: { prompt: string; transcriptPath?: string }): Promise<void>;
59
+ // promptId (#1086): Claude's native `prompt_id` for the turn this prompt starts.
60
+ onUserPrompt?(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void>;
58
61
  // A provider session-boundary hook (Claude PreCompact / SessionEnd) signals an imminent
59
62
  // context reset or termination so the runner can run end-of-session work (#183 pre-destroy
60
63
  // seam: #184 context-ratio capture) before the invasive operation. `reason` is the raw
@@ -501,6 +504,7 @@ async function handleSessionTurn(req: Request, options: ControlServerOptions): P
501
504
  const body = await req.json().catch(() => null);
502
505
  const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : "";
503
506
  const lastAssistantMessage = isRecord(body) ? body.lastAssistantMessage : undefined;
507
+ const promptId = isRecord(body) && typeof body.promptId === "string" && body.promptId ? body.promptId : undefined;
504
508
  // transcriptPath is the primary source, but it can be absent (known CC Stop edge case).
505
509
  // lastAssistantMessage is the fallback for exactly that — accept either; reject only when
506
510
  // both are missing so the turn still reaches the dashboard chat (#264).
@@ -511,7 +515,7 @@ async function handleSessionTurn(req: Request, options: ControlServerOptions): P
511
515
  // reply-obligation check, so the captured turn must be persisted (and the
512
516
  // obligation cleared) before this response returns.
513
517
  try {
514
- await options.onSessionTurn({ transcriptPath, lastAssistantMessage });
518
+ await options.onSessionTurn({ transcriptPath, lastAssistantMessage, ...(promptId ? { promptId } : {}) });
515
519
  return Response.json({ ok: true });
516
520
  } catch (error) {
517
521
  return Response.json({ ok: false, reason: errMessage(error) }, { status: 500 });
@@ -524,8 +528,9 @@ async function handleUserPrompt(req: Request, options: ControlServerOptions): Pr
524
528
  const prompt = isRecord(body) && typeof body.prompt === "string" ? body.prompt : "";
525
529
  if (!prompt.trim()) return Response.json({ ok: false, reason: "prompt required" }, { status: 400 });
526
530
  const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
531
+ const promptId = isRecord(body) && typeof body.promptId === "string" && body.promptId ? body.promptId : undefined;
527
532
  // Fire-and-forget: the hook must not block Claude's turn on relay round-trips.
528
- void Promise.resolve(options.onUserPrompt({ prompt, transcriptPath })).catch(() => {});
533
+ void Promise.resolve(options.onUserPrompt({ prompt, transcriptPath, ...(promptId ? { promptId } : {}) })).catch(() => {});
529
534
  return Response.json({ ok: true });
530
535
  }
531
536
 
@@ -12,8 +12,11 @@ type SessionPublisher = (input: {
12
12
  replyTo?: number;
13
13
  }) => void | Promise<void>;
14
14
 
15
+ // #1086: dual-stamp the Part A (#1082) turn-finality field alongside the legacy
16
+ // `final` alias so old consumers keep working while new ones can key off the
17
+ // typed `turnFinal` flag directly instead of every writer's own `final: true`.
15
18
  function finalSession(session: MessageSessionMeta): MessageSessionMeta {
16
- return { ...session, final: true };
19
+ return { ...session, turnFinal: true, final: true };
17
20
  }
18
21
 
19
22
  export async function publishCapturedResponse(input: {
@@ -16,7 +16,7 @@ import { readRunnerContextProbeState } from "./context-probe-state";
16
16
  import { startControlServer, type ControlServer, type ResolvedPermissionPrompt } from "./control-server";
17
17
  import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureReplyRoute } from "./reply-obligation-cache";
18
18
  import { Outbox, type OutboxRecord } from "./outbox";
19
- import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractFinalAssistantMessageAfterEntry, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
19
+ import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, extractAssistantTurnForPrompt, countTranscriptEntries, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
20
20
  import { IncrementalClaudeTranscriptTail } from "./adapters/claude-transcript-tail";
21
21
  import { getManifest } from "agent-relay-providers";
22
22
  import { profileUsesProviderHostGlobals } from "./profile-home";
@@ -51,7 +51,7 @@ import {
51
51
  runnerAgentStatus,
52
52
  runnerBusErrorAction,
53
53
  runnerMessageMatches,
54
- runnerMessageShouldDirtyObligations,
54
+ runnerMessageShouldDirtyObligations, restartOptionsFromCommand,
55
55
  runnerShouldResolveProviderExit,
56
56
  runnerShouldRestartUnexpectedProviderExit,
57
57
  runtimeProviderCapabilities,
@@ -60,7 +60,7 @@ import {
60
60
  shouldLogDeliveryFailure,
61
61
  stripRunnerClaimedGuidance,
62
62
  taskIdFromMessage,
63
- type ProbeModelInfo,
63
+ type ProbeModelInfo, type RestartProviderOptions,
64
64
  } from "./runner-helpers";
65
65
 
66
66
  // Pre-destroy work is best-effort and must never hang teardown. Capping it keeps a slow
@@ -69,6 +69,7 @@ import {
69
69
  // short so a wedged/down server can't stall an operator-requested shutdown for long; a
70
70
  // row that still can't land is logged, not silently dropped.
71
71
  const OUTBOX_FLUSH_TIMEOUT_MS = 3_000;
72
+ const STALE_PROVIDER_TURN_BUSY_MS = 10 * 60_000;
72
73
 
73
74
  interface RunnerOptions {
74
75
  provider: string;
@@ -535,7 +536,7 @@ export class AgentRunner {
535
536
  return mode === "isolated";
536
537
  }
537
538
 
538
- private async spawnProvider(opts: { resumeId?: string; suppressPrompt?: boolean } = {}): Promise<ManagedProcess> {
539
+ private async spawnProvider(opts: RestartProviderOptions = {}): Promise<ManagedProcess> {
539
540
  this.providerSessionId = crypto.randomUUID();
540
541
  this.lastTranscriptPath = undefined;
541
542
  const includeProviderGlobals = profileUsesProviderHostGlobals(this.options);
@@ -586,7 +587,7 @@ export class AgentRunner {
586
587
  ...(this.options.profile ? { profile: this.options.profile } : {}),
587
588
  ...(this.options.agentProfile ? { agentProfile: this.options.agentProfile } : {}),
588
589
  ...(this.options.prompt && !opts.resumeId && !opts.suppressPrompt ? { prompt: this.options.prompt } : {}),
589
- ...(this.options.systemPromptAppend ? { systemPromptAppend: this.options.systemPromptAppend } : {}),
590
+ ...((opts.systemPromptAppend ?? this.options.systemPromptAppend) ? { systemPromptAppend: opts.systemPromptAppend ?? this.options.systemPromptAppend } : {}),
590
591
  ...(this.options.tmuxSession ? { tmuxSession: this.options.tmuxSession } : {}),
591
592
  providerArgs: opts.resumeId ? [...this.options.providerArgs, "--resume", opts.resumeId] : this.options.providerArgs,
592
593
  providerConfig: this.options.providerConfig,
@@ -604,7 +605,7 @@ export class AgentRunner {
604
605
  };
605
606
  const launchSeededPrompt = this.options.adapter.seedsInitialPromptAtLaunch && !opts.resumeId && !opts.suppressPrompt ? this.options.prompt?.trim() : ""; if (launchSeededPrompt) this.recordInjectedPrompt(launchSeededPrompt);
606
607
  const managedProcess = await this.options.adapter.spawn(config);
607
- const injectionEvents = runnerLaunchInjectionSessionEvents({ carried: this.options.relayInjectionEvents, provider: this.options.provider, config, providerConfig: this.options.providerConfig, agentId: this.agentId, providerSessionId: this.providerSessionId });
608
+ const injectionEvents = runnerLaunchInjectionSessionEvents({ carried: opts.relayInjectionEvents ?? this.options.relayInjectionEvents, provider: this.options.provider, config, providerConfig: this.options.providerConfig, agentId: this.agentId, providerSessionId: this.providerSessionId });
608
609
  for (const event of injectionEvents) {
609
610
  this.publishSessionEvent(event);
610
611
  }
@@ -777,7 +778,7 @@ export class AgentRunner {
777
778
  else this.lifecycleAction = undefined;
778
779
  this.publishStatus();
779
780
  let providerResult: Record<string, unknown> | void = undefined;
780
- if (type === "agent.restart") await this.restartProvider();
781
+ if (type === "agent.restart") await this.restartProvider(restartOptionsFromCommand(params));
781
782
  else if (type === "agent.reconnect") this.publishStatus();
782
783
  else if (type === "agent.compact") {
783
784
  if (!this.options.adapter.compact || !this.process) throw new Error("provider does not support compact");
@@ -927,7 +928,7 @@ export class AgentRunner {
927
928
  return attachmentText ? `${body}\n\n${attachmentText}` : body;
928
929
  }
929
930
 
930
- private async restartProvider(opts: { resumeId?: string; suppressPrompt?: boolean } = {}): Promise<void> {
931
+ private async restartProvider(opts: RestartProviderOptions = {}): Promise<void> {
931
932
  this.restartInProgress = true;
932
933
  try {
933
934
  if (this.process) {
@@ -1204,7 +1205,7 @@ export class AgentRunner {
1204
1205
  const providerSessionId = typeof update === "string" ? undefined : update.providerSessionId;
1205
1206
  if (providerSessionId && providerSessionId !== this.providerSessionId) return;
1206
1207
  const reason = typeof update === "string" ? "provider-turn" : update.reason ?? "provider-turn";
1207
- const id = typeof update === "string" ? reason : update.id ?? reason;
1208
+ let id = typeof update === "string" ? reason : update.id ?? reason;
1208
1209
  if (typeof update !== "string" && update.timeline) {
1209
1210
  this.pendingTimelineEvent = {
1210
1211
  status: update.timeline.status,
@@ -1291,13 +1292,19 @@ export class AgentRunner {
1291
1292
  }
1292
1293
  if (status === "busy" && reason === "provider-turn") {
1293
1294
  if (!this.currentTurnId) {
1295
+ // For Claude, `update.id` carries the native prompt_id the UserPromptSubmit
1296
+ // hook reported (relay-status.sh → /status) — the same id the Stop hook and
1297
+ // transcript use for this turn. Falls back to a random mint only when no
1298
+ // native id was supplied (e.g. a provider/path that doesn't report one).
1294
1299
  this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
1295
1300
  this.currentTurnStartedAt = Date.now();
1296
1301
  this.compactionMidTurn = false;
1297
1302
  this.sessionLog(`turn started (turn ${this.currentTurnId})`);
1298
1303
  }
1304
+ id = this.currentTurnId;
1299
1305
  this.busyReconciler.arm();
1300
1306
  } else if (status === "idle" && reason === "provider-turn") {
1307
+ id = typeof update !== "string" && update.id ? update.id : this.currentTurnId ?? reason;
1301
1308
  if (this.currentTurnId) { this.completedProviderTurns += 1; this.sessionLog(`turn ended via provider idle (turn ${this.currentTurnId})`); }
1302
1309
  this.currentTurnId = undefined;
1303
1310
  this.currentTurnStartedAt = undefined;
@@ -1342,7 +1349,8 @@ export class AgentRunner {
1342
1349
  }
1343
1350
  } else if (status === "idle") {
1344
1351
  this.claims.clearTerminalStatus();
1345
- this.claims.finishWork(reason, id);
1352
+ if (reason === "provider-turn") this.claims.clearWorkKind("provider-turn");
1353
+ else this.claims.finishWork(reason, id);
1346
1354
  if (reason === "provider-turn") void this.completeObservedTaskClaims();
1347
1355
  }
1348
1356
  else if (status === "offline" || status === "error") this.claims.setTerminalStatus(status);
@@ -1423,7 +1431,7 @@ export class AgentRunner {
1423
1431
  // isPreFlush: true (#435/#499) — mid-turn PreToolUse boundary. Drain the live
1424
1432
  // tail and force-flush session messages before showing the blocking control;
1425
1433
  // the entry cursor lets Stop capture skip already-emitted text.
1426
- private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void> {
1434
+ private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean }): Promise<void> {
1427
1435
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1428
1436
 
1429
1437
  if (input.isPreFlush) {
@@ -1443,7 +1451,13 @@ export class AgentRunner {
1443
1451
  return;
1444
1452
  }
1445
1453
 
1446
- const turnId = this.currentTurnId;
1454
+ // Claude's Stop hook reports its own native `prompt_id` — the same UUID the
1455
+ // transcript stamps on every entry of this turn (extractAssistantTurnForPrompt /
1456
+ // resolveEntryPromptId in claude-transcript.ts). It's the authoritative turn
1457
+ // identifier; currentTurnId should already equal it (seeded from the same
1458
+ // prompt_id at turn start — see setProviderStatus / handleUserPrompt), but this
1459
+ // prefers the freshest source so finalization is correct even if the two drift.
1460
+ const turnId = input.promptId ?? this.currentTurnId;
1447
1461
  this.stopReasoningTail();
1448
1462
  // Optional correlation for threading + obligation clearing — never a capture gate.
1449
1463
  let replyToMessageId: number | undefined, replyTarget = "user";
@@ -1464,32 +1478,41 @@ export class AgentRunner {
1464
1478
  const flushCount = this.narrativeFlushEntryCount;
1465
1479
  this.narrativeFlushEntryCount = 0;
1466
1480
 
1467
- // The Stop hook can fire before the final assistant entry is flushed to disk.
1468
- // Claude writes thinking and text as separate entries (both with end_turn), so
1469
- // the transcript can "look complete" while the text entry is still pending.
1470
- // Retry until both the transcript has an end_turn AND extraction yields text.
1471
- let body = "";
1472
- let jsonl: string | undefined;
1473
- try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1474
- if (jsonl !== undefined) {
1475
- for (let attempt = 0; attempt < 5; attempt++) {
1476
- if (attempt > 0) {
1477
- await new Promise((r) => setTimeout(r, 100));
1478
- try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { break; }
1479
- }
1480
- if (!transcriptLooksComplete(jsonl)) continue;
1481
- // If a pre-flush already emitted entries 1..flushCount, resume after that mark to avoid double-emit (#435/#499).
1482
- const extract = this.options.providerConfig.chatCaptureMode === "full"
1483
- ? (j: string) => (flushCount > 0 ? extractLastAssistantTurnAfterEntry(j, flushCount) : extractLastAssistantTurn(j))
1484
- : (j: string) => (flushCount > 0 ? extractFinalAssistantMessageAfterEntry(j, flushCount) : extractFinalAssistantMessage(j));
1485
- body = extract(jsonl);
1486
- if (body) break;
1481
+ // PRIMARY capture source: the Stop hook's own last_assistant_message. Claude
1482
+ // reports it synchronously in the hook payload itself, so there is no
1483
+ // transcript file-read race to retry against — this text is authoritative and
1484
+ // complete the moment the hook fires. The transcript is read at most ONCE
1485
+ // (never retried/looped) and only to enrich "full" mode with the turn's
1486
+ // intermediate narration; if that single read comes up empty (the file simply
1487
+ // hasn't caught up yet) the hook payload text still carries the turn.
1488
+ let body = extractHookAssistantMessage(input.lastAssistantMessage);
1489
+ if (this.options.providerConfig.chatCaptureMode === "full" && input.transcriptPath) {
1490
+ let jsonl: string | undefined;
1491
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1492
+ if (jsonl !== undefined && transcriptLooksComplete(jsonl)) {
1493
+ // Deterministic slicing via prompt_id→parentUuid chaining when the native id is
1494
+ // known; only fall back to the legacy "last real user prompt" heuristic when that
1495
+ // deterministic slice comes up EMPTY, not merely when turnId is falsy an old
1496
+ // Claude (<2.1.196) mints a truthy random turnId that never resolves via promptId
1497
+ // chaining, so branching on truthiness alone left this fallback unreachable and
1498
+ // silently dropped intermediate narration in full mode (#1086 review, Finding F1).
1499
+ let full = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
1500
+ if (!full) full = flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl);
1501
+ if (full) body = full;
1487
1502
  }
1488
1503
  }
1489
- // Fallback: last_assistant_message from the Stop hook payload, which bypasses
1490
- // the transcript file race entirely.
1491
- if (!body && input.lastAssistantMessage) {
1492
- body = extractHookAssistantMessage(input.lastAssistantMessage);
1504
+ // Last-resort safety net for the default "final" mode (#1086 review, Finding F2):
1505
+ // last_assistant_message is an undocumented Claude hook field. If a future Claude
1506
+ // version renames or drops it, do ONE (never retried same one-shot discipline as the
1507
+ // "full" mode enrichment above) transcript read rather than silently losing every
1508
+ // capture in the mode nearly every provider config runs in.
1509
+ if (!body && this.options.providerConfig.chatCaptureMode === "final" && input.transcriptPath) {
1510
+ let jsonl: string | undefined;
1511
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1512
+ if (jsonl !== undefined) {
1513
+ const fallback = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
1514
+ body = fallback || (flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl));
1515
+ }
1493
1516
  }
1494
1517
  // A pure tool-use turn with no closing text is fine to skip — its reasoning and
1495
1518
  // tool steps already carried the visibility into chat.
@@ -1499,7 +1522,18 @@ export class AgentRunner {
1499
1522
  }
1500
1523
 
1501
1524
  this.sessionLog(`response captured for turn ${turnId ?? "?"} (${body.length} chars${replyToMessageId ? `, replyTo #${replyToMessageId}` : ", no replyTo"})`);
1502
- await publishCapturedResponse({ publishSessionEvent: (event) => this.publishSessionEvent(event), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: "provider", final: true, ...(turnId ? { turnId } : {}) } });
1525
+ await publishCapturedResponse({
1526
+ publishSessionEvent: (event) => this.publishSessionEvent(event),
1527
+ outbox: this.sessionOutbox,
1528
+ provider: this.options.provider,
1529
+ from: this.agentId,
1530
+ to: replyTarget,
1531
+ body,
1532
+ replyTo: replyToMessageId,
1533
+ // A Stop-hook-driven capture is by construction a definitive, provider-native
1534
+ // turn completion — Claude's own lifecycle hook fired, not a reconstructed guess.
1535
+ session: { type: "response", origin: "provider", final: true, confidence: "definitive", endedBy: "completed", ...(turnId ? { turnId } : {}) },
1536
+ });
1503
1537
  // The agent's reply may have cleared an obligation — refresh the snapshot so the next
1504
1538
  // turn-end doesn't re-prompt for a message already answered (#196).
1505
1539
  if (replyToMessageId) this.obligationCache.markDirty();
@@ -1609,10 +1643,15 @@ export class AgentRunner {
1609
1643
  // it into the dashboard chat so both surfaces stay in sync, and kick off reasoning
1610
1644
  // tailing for the turn. Skips prompts the runner itself injected (chat box, relay
1611
1645
  // deliveries) so those aren't double-posted.
1612
- private async handleUserPrompt(input: { prompt: string; transcriptPath?: string }): Promise<void> {
1646
+ private async handleUserPrompt(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void> {
1613
1647
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1614
1648
  if (!this.currentTurnId) {
1615
- this.currentTurnId = crypto.randomUUID();
1649
+ // Claude's own prompt_id (from the UserPromptSubmit hook payload) is the
1650
+ // native, stable turn identifier — the same id the Stop hook reports and the
1651
+ // transcript stamps on every entry of this turn. Falls back to a random mint
1652
+ // only for the rare case prompt_id wasn't available (see the ZS base schema
1653
+ // note: absent until the very first user input of the process lifetime).
1654
+ this.currentTurnId = input.promptId ?? crypto.randomUUID();
1616
1655
  this.currentTurnStartedAt = Date.now();
1617
1656
  }
1618
1657
  const text = input.prompt.trim();
@@ -1739,6 +1778,14 @@ export class AgentRunner {
1739
1778
  // Dashboard prompt injection is already answered by this captured App Server
1740
1779
  // response. User obligations still belong to the session mirror; spawner
1741
1780
  // obligations (#413) route to the spawn-parent with replyTo to clear the debt.
1781
+ // #1083: a turn-final capture is ALWAYS mirrored, regardless of reply route — an
1782
+ // autonomous Codex turn with no pending prompt and no eligible obligation used to
1783
+ // hit `if (!route) return` here and vanish before ever reaching publishCapturedResponse,
1784
+ // so it was neither shown in the dashboard nor eligible for report-up. The route only
1785
+ // decides whether an explicit reply/obligation-clear also happens; when there's none,
1786
+ // replyToMessageId stays undefined and the capture still lands as a plain session-mirror
1787
+ // response (enqueueCapturedResponseReport's own `!replyTo` gate then correctly skips
1788
+ // report-up — there's no spawner obligation to report up against).
1742
1789
  let replyToMessageId: number | undefined, replyTarget = "user";
1743
1790
  const pendingPrompt = this.pendingPromptMessageId;
1744
1791
  if (pendingPrompt) {
@@ -1746,10 +1793,9 @@ export class AgentRunner {
1746
1793
  this.pendingPromptMessageId = undefined;
1747
1794
  } else {
1748
1795
  const route = responseCaptureReplyRoute(this.obligationCache.get(), this.currentTurnStartedAt, { suppressUser: true });
1749
- if (!route) return; // The session mirror will satisfy the user obligation; don't double-post (#196).
1750
- replyToMessageId = route.replyToMessageId; replyTarget = route.to;
1796
+ if (route) { replyToMessageId = route.replyToMessageId; replyTarget = route.to; }
1751
1797
  }
1752
- await publishCapturedResponse({ publishSessionEvent: (sessionEvent) => this.publishSessionEvent(sessionEvent), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}) } });
1798
+ await publishCapturedResponse({ publishSessionEvent: (sessionEvent) => this.publishSessionEvent(sessionEvent), outbox: this.sessionOutbox, provider: this.options.provider, from: this.agentId, to: replyTarget, body, replyTo: replyToMessageId, session: { type: "response", origin: event.origin ?? "provider", ...(turnId ? { turnId } : {}), ...(event.confidence ? { confidence: event.confidence } : {}), ...(event.endedBy ? { endedBy: event.endedBy } : {}) } });
1753
1799
  if (replyToMessageId) this.obligationCache.markDirty();
1754
1800
  return;
1755
1801
  }
@@ -1965,15 +2011,25 @@ export class AgentRunner {
1965
2011
  });
1966
2012
  }
1967
2013
 
2014
+ private isStaleProviderTurnOnlyBusy(
2015
+ status: SemanticStatus,
2016
+ activeWork: Array<{ kind: string }>,
2017
+ busyReasons: string[],
2018
+ liveness: { hasLiveWork: boolean; lastProgressAt: number },
2019
+ ): boolean {
2020
+ if (status !== "busy") return false;
2021
+ if (activeWork.length === 0 || activeWork.some((work) => work.kind !== "provider-turn")) return false;
2022
+ if (busyReasons.some((reason) => reason !== "provider-turn")) return false;
2023
+ if (liveness.hasLiveWork || !liveness.lastProgressAt) return false;
2024
+ return Date.now() - liveness.lastProgressAt >= STALE_PROVIDER_TURN_BUSY_MS;
2025
+ }
2026
+
1968
2027
  private publishStatus(): void {
1969
2028
  this.claims.expire();
1970
2029
  const status = this.claims.currentStatus();
1971
- const agentStatus = runnerAgentStatus(status);
1972
2030
  const activeWork = this.claims.activeWork();
1973
2031
  const activeSubagents = activeWork.filter((item) => item.kind === "subagent");
1974
2032
  const terminalFailure = this.terminalFailure;
1975
- const providerState = terminalFailure?.providerState ?? this.rateLimitHold ?? providerStateFromActiveWork(activeWork);
1976
- this.bus.setSemanticStatus(status === "offline" || status === "error" ? "idle" : status);
1977
2033
  const timelineEvent = this.pendingTimelineEvent;
1978
2034
  this.pendingTimelineEvent = undefined;
1979
2035
  // §4.1 Runner↔provider liveness contract (#725 / #746): fold the raw transport/timeline/
@@ -1989,6 +2045,12 @@ export class AgentRunner {
1989
2045
  const liveness = this.options.adapter.liveness
1990
2046
  ? this.options.adapter.liveness(livenessInputs)
1991
2047
  : computeLivenessSignal(livenessInputs);
2048
+ const busyReasons = this.claims.reasons();
2049
+ const staleProviderTurnBusy = this.isStaleProviderTurnOnlyBusy(status, activeWork, busyReasons, liveness);
2050
+ const effectiveStatus = staleProviderTurnBusy ? "idle" : status;
2051
+ const agentStatus = runnerAgentStatus(effectiveStatus);
2052
+ const providerState = terminalFailure?.providerState ?? this.rateLimitHold ?? (staleProviderTurnBusy ? null : providerStateFromActiveWork(activeWork));
2053
+ this.bus.setSemanticStatus(effectiveStatus === "offline" || effectiveStatus === "error" ? "idle" : effectiveStatus);
1992
2054
  this.bus.status({
1993
2055
  agentStatus,
1994
2056
  ready: agentStatus !== "offline" && !this.stopped,
@@ -2020,7 +2082,7 @@ export class AgentRunner {
2020
2082
  ? { lastError: `Claude provider exited; resume id ${this.terminalProviderExit.claudeResumeId} captured for manual recovery` }
2021
2083
  : { lastError: "Claude provider exited; manual intervention required" }),
2022
2084
  } : {}),
2023
- busyReasons: this.claims.reasons(),
2085
+ busyReasons: staleProviderTurnBusy ? busyReasons.filter((reason) => reason !== "provider-turn") : busyReasons,
2024
2086
  completedProviderTurns: this.completedProviderTurns,
2025
2087
  activeWork,
2026
2088
  activeSubagents,
@@ -5,6 +5,7 @@ import { getManifest } from "agent-relay-providers";
5
5
  import type { BooleanCapabilityDefault, ProviderCapabilityCondition } from "agent-relay-providers";
6
6
  import { targetMatchesAgentIdentity } from "agent-relay-sdk/agent-target";
7
7
  import type { ProviderAdapter, ProviderConfig, RunnerSpawnConfig, SemanticStatus } from "./adapter";
8
+ import type { RunnerRelayInjectionEvent } from "./relay-injection-events";
8
9
  import { agentProfileProjectionReport } from "./profile-projection";
9
10
  import { assembleLaunch } from "./launch-assembly";
10
11
 
@@ -27,6 +28,13 @@ export interface ProbeModelInfo {
27
28
  effort?: string;
28
29
  }
29
30
 
31
+ export interface RestartProviderOptions {
32
+ resumeId?: string;
33
+ suppressPrompt?: boolean;
34
+ systemPromptAppend?: string;
35
+ relayInjectionEvents?: RunnerRelayInjectionEvent[];
36
+ }
37
+
30
38
  export function runnerMessageMatches(message: Pick<Message, "to" | "resolvedToAgent">, target: {
31
39
  agentId: string;
32
40
  label?: string;
@@ -62,6 +70,29 @@ export function stripRunnerClaimedGuidance(body: string): string {
62
70
  return body.replace(RUNNER_CLAIMED_GUIDANCE_RE, "");
63
71
  }
64
72
 
73
+ const RELAY_INJECTION_CATEGORIES = new Set(["memory", "instructions", "tools", "comms", "knowledge", "context"]);
74
+
75
+ function relayInjectionEventsFromParam(value: unknown): RunnerRelayInjectionEvent[] | undefined {
76
+ if (!Array.isArray(value)) return undefined;
77
+ const events = value.flatMap((item): RunnerRelayInjectionEvent[] => {
78
+ if (!item || typeof item !== "object" || Array.isArray(item)) return [];
79
+ const record = item as Record<string, unknown>;
80
+ const detail = record.detail;
81
+ if (typeof record.category !== "string" || !RELAY_INJECTION_CATEGORIES.has(record.category)) return [];
82
+ if (typeof record.summary !== "string" || !record.summary.trim()) return [];
83
+ if (!detail || typeof detail !== "object" || Array.isArray(detail)) return [];
84
+ return [{ category: record.category as RunnerRelayInjectionEvent["category"], summary: record.summary, detail: detail as Record<string, unknown> }];
85
+ });
86
+ return events.length ? events : undefined;
87
+ }
88
+
89
+ export function restartOptionsFromCommand(params: Record<string, unknown>): RestartProviderOptions {
90
+ return {
91
+ systemPromptAppend: typeof params.systemPromptAppend === "string" ? params.systemPromptAppend : undefined,
92
+ relayInjectionEvents: relayInjectionEventsFromParam(params.relayInjectionEvents),
93
+ };
94
+ }
95
+
65
96
  // Bound a runner's INITIAL relay registration. `connect` resolves on the first `registered`
66
97
  // frame and otherwise loops forever, so a token the relay rejects (401 at the WS upgrade)
67
98
  // hangs here silently. Race it against `timeoutMs`; on timeout, surface a fatal reason, tear