agent-relay-runner 0.124.0 → 0.125.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.125.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.124.0",
4
+ "version": "0.125.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,17 @@ export class CodexAgentMessageCapture {
46
45
  this.currentSegment = [];
47
46
  }
48
47
 
48
+ // Deterministic turn-final response: the TRAILING agentMessage run only — all items after
49
+ // the LAST non-message item (tool call, file change, reasoning...) in the turn (design
50
+ // #1079 §3.2). Earlier agentMessage segments are mid-work narration, already mirrored live
51
+ // via `emit`, and must not be re-injected into the final (#1083 Finding 2). Codex's own
52
+ // phase:"commentary" tag (see completeItem above) still trims commentary items out of
53
+ // whichever segment they land in, but is not the sole guard against leading narration.
49
54
  finish(emit: EmitAgentMessage): string | undefined {
50
55
  this.closeSegment(emit);
51
- const selectedSegments = selectResponseSegments(this.segments);
56
+ const trailingSegment = this.segments.at(-1);
52
57
  this.segments = [];
53
- const text = selectedSegments.length ? selectedSegments.map(segmentBody).join("\n\n").trim() : undefined;
58
+ const text = trailingSegment ? segmentBody(trailingSegment) : undefined;
54
59
  return truncateCapturedResponse(text);
55
60
  }
56
61
 
@@ -96,43 +101,10 @@ export class CodexAgentMessageCapture {
96
101
  }
97
102
  }
98
103
 
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);
114
- }
115
- return [...selectedSegments, finalSegment];
116
- }
117
-
118
104
  function segmentBody(segment: ResponseEntry[]): string {
119
105
  return segment.map((entry) => entry.body).join("\n\n").trim();
120
106
  }
121
107
 
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);
126
- }
127
-
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;
134
- }
135
-
136
108
  function truncateCapturedResponse(text: string | undefined): string | undefined {
137
109
  if (!text || text.length <= MAX_CAPTURED_RESPONSE_CHARS) return text;
138
110
  return `${text.slice(0, MAX_CAPTURED_RESPONSE_CHARS).trimEnd()}\n\n${TRUNCATION_MARKER_PREFIX} showing first ${MAX_CAPTURED_RESPONSE_CHARS} of ${text.length} chars]`;
@@ -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";
@@ -1291,6 +1291,10 @@ export class AgentRunner {
1291
1291
  }
1292
1292
  if (status === "busy" && reason === "provider-turn") {
1293
1293
  if (!this.currentTurnId) {
1294
+ // For Claude, `update.id` carries the native prompt_id the UserPromptSubmit
1295
+ // hook reported (relay-status.sh → /status) — the same id the Stop hook and
1296
+ // transcript use for this turn. Falls back to a random mint only when no
1297
+ // native id was supplied (e.g. a provider/path that doesn't report one).
1294
1298
  this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
1295
1299
  this.currentTurnStartedAt = Date.now();
1296
1300
  this.compactionMidTurn = false;
@@ -1423,7 +1427,7 @@ export class AgentRunner {
1423
1427
  // isPreFlush: true (#435/#499) — mid-turn PreToolUse boundary. Drain the live
1424
1428
  // tail and force-flush session messages before showing the blocking control;
1425
1429
  // the entry cursor lets Stop capture skip already-emitted text.
1426
- private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void> {
1430
+ private async publishSessionTurn(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean }): Promise<void> {
1427
1431
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1428
1432
 
1429
1433
  if (input.isPreFlush) {
@@ -1443,7 +1447,13 @@ export class AgentRunner {
1443
1447
  return;
1444
1448
  }
1445
1449
 
1446
- const turnId = this.currentTurnId;
1450
+ // Claude's Stop hook reports its own native `prompt_id` — the same UUID the
1451
+ // transcript stamps on every entry of this turn (extractAssistantTurnForPrompt /
1452
+ // resolveEntryPromptId in claude-transcript.ts). It's the authoritative turn
1453
+ // identifier; currentTurnId should already equal it (seeded from the same
1454
+ // prompt_id at turn start — see setProviderStatus / handleUserPrompt), but this
1455
+ // prefers the freshest source so finalization is correct even if the two drift.
1456
+ const turnId = input.promptId ?? this.currentTurnId;
1447
1457
  this.stopReasoningTail();
1448
1458
  // Optional correlation for threading + obligation clearing — never a capture gate.
1449
1459
  let replyToMessageId: number | undefined, replyTarget = "user";
@@ -1464,32 +1474,41 @@ export class AgentRunner {
1464
1474
  const flushCount = this.narrativeFlushEntryCount;
1465
1475
  this.narrativeFlushEntryCount = 0;
1466
1476
 
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;
1477
+ // PRIMARY capture source: the Stop hook's own last_assistant_message. Claude
1478
+ // reports it synchronously in the hook payload itself, so there is no
1479
+ // transcript file-read race to retry against — this text is authoritative and
1480
+ // complete the moment the hook fires. The transcript is read at most ONCE
1481
+ // (never retried/looped) and only to enrich "full" mode with the turn's
1482
+ // intermediate narration; if that single read comes up empty (the file simply
1483
+ // hasn't caught up yet) the hook payload text still carries the turn.
1484
+ let body = extractHookAssistantMessage(input.lastAssistantMessage);
1485
+ if (this.options.providerConfig.chatCaptureMode === "full" && input.transcriptPath) {
1486
+ let jsonl: string | undefined;
1487
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1488
+ if (jsonl !== undefined && transcriptLooksComplete(jsonl)) {
1489
+ // Deterministic slicing via prompt_id→parentUuid chaining when the native id is
1490
+ // known; only fall back to the legacy "last real user prompt" heuristic when that
1491
+ // deterministic slice comes up EMPTY, not merely when turnId is falsy an old
1492
+ // Claude (<2.1.196) mints a truthy random turnId that never resolves via promptId
1493
+ // chaining, so branching on truthiness alone left this fallback unreachable and
1494
+ // silently dropped intermediate narration in full mode (#1086 review, Finding F1).
1495
+ let full = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
1496
+ if (!full) full = flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl);
1497
+ if (full) body = full;
1487
1498
  }
1488
1499
  }
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);
1500
+ // Last-resort safety net for the default "final" mode (#1086 review, Finding F2):
1501
+ // last_assistant_message is an undocumented Claude hook field. If a future Claude
1502
+ // version renames or drops it, do ONE (never retried same one-shot discipline as the
1503
+ // "full" mode enrichment above) transcript read rather than silently losing every
1504
+ // capture in the mode nearly every provider config runs in.
1505
+ if (!body && this.options.providerConfig.chatCaptureMode === "final" && input.transcriptPath) {
1506
+ let jsonl: string | undefined;
1507
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
1508
+ if (jsonl !== undefined) {
1509
+ const fallback = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
1510
+ body = fallback || (flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl));
1511
+ }
1493
1512
  }
1494
1513
  // A pure tool-use turn with no closing text is fine to skip — its reasoning and
1495
1514
  // tool steps already carried the visibility into chat.
@@ -1499,7 +1518,18 @@ export class AgentRunner {
1499
1518
  }
1500
1519
 
1501
1520
  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 } : {}) } });
1521
+ await publishCapturedResponse({
1522
+ publishSessionEvent: (event) => this.publishSessionEvent(event),
1523
+ outbox: this.sessionOutbox,
1524
+ provider: this.options.provider,
1525
+ from: this.agentId,
1526
+ to: replyTarget,
1527
+ body,
1528
+ replyTo: replyToMessageId,
1529
+ // A Stop-hook-driven capture is by construction a definitive, provider-native
1530
+ // turn completion — Claude's own lifecycle hook fired, not a reconstructed guess.
1531
+ session: { type: "response", origin: "provider", final: true, confidence: "definitive", endedBy: "completed", ...(turnId ? { turnId } : {}) },
1532
+ });
1503
1533
  // The agent's reply may have cleared an obligation — refresh the snapshot so the next
1504
1534
  // turn-end doesn't re-prompt for a message already answered (#196).
1505
1535
  if (replyToMessageId) this.obligationCache.markDirty();
@@ -1609,10 +1639,15 @@ export class AgentRunner {
1609
1639
  // it into the dashboard chat so both surfaces stay in sync, and kick off reasoning
1610
1640
  // tailing for the turn. Skips prompts the runner itself injected (chat box, relay
1611
1641
  // deliveries) so those aren't double-posted.
1612
- private async handleUserPrompt(input: { prompt: string; transcriptPath?: string }): Promise<void> {
1642
+ private async handleUserPrompt(input: { prompt: string; transcriptPath?: string; promptId?: string }): Promise<void> {
1613
1643
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1614
1644
  if (!this.currentTurnId) {
1615
- this.currentTurnId = crypto.randomUUID();
1645
+ // Claude's own prompt_id (from the UserPromptSubmit hook payload) is the
1646
+ // native, stable turn identifier — the same id the Stop hook reports and the
1647
+ // transcript stamps on every entry of this turn. Falls back to a random mint
1648
+ // only for the rare case prompt_id wasn't available (see the ZS base schema
1649
+ // note: absent until the very first user input of the process lifetime).
1650
+ this.currentTurnId = input.promptId ?? crypto.randomUUID();
1616
1651
  this.currentTurnStartedAt = Date.now();
1617
1652
  }
1618
1653
  const text = input.prompt.trim();
@@ -1739,6 +1774,14 @@ export class AgentRunner {
1739
1774
  // Dashboard prompt injection is already answered by this captured App Server
1740
1775
  // response. User obligations still belong to the session mirror; spawner
1741
1776
  // obligations (#413) route to the spawn-parent with replyTo to clear the debt.
1777
+ // #1083: a turn-final capture is ALWAYS mirrored, regardless of reply route — an
1778
+ // autonomous Codex turn with no pending prompt and no eligible obligation used to
1779
+ // hit `if (!route) return` here and vanish before ever reaching publishCapturedResponse,
1780
+ // so it was neither shown in the dashboard nor eligible for report-up. The route only
1781
+ // decides whether an explicit reply/obligation-clear also happens; when there's none,
1782
+ // replyToMessageId stays undefined and the capture still lands as a plain session-mirror
1783
+ // response (enqueueCapturedResponseReport's own `!replyTo` gate then correctly skips
1784
+ // report-up — there's no spawner obligation to report up against).
1742
1785
  let replyToMessageId: number | undefined, replyTarget = "user";
1743
1786
  const pendingPrompt = this.pendingPromptMessageId;
1744
1787
  if (pendingPrompt) {
@@ -1746,10 +1789,9 @@ export class AgentRunner {
1746
1789
  this.pendingPromptMessageId = undefined;
1747
1790
  } else {
1748
1791
  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;
1792
+ if (route) { replyToMessageId = route.replyToMessageId; replyTarget = route.to; }
1751
1793
  }
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 } : {}) } });
1794
+ 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
1795
  if (replyToMessageId) this.obligationCache.markDirty();
1754
1796
  return;
1755
1797
  }