agent-relay-runner 0.127.5 → 0.127.6

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.127.5",
3
+ "version": "0.127.6",
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.120",
26
+ "agent-relay-sdk": "0.2.121",
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.127.5",
4
+ "version": "0.127.6",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AgentProfile, LivenessSignal, Message, MessageFinalityConfidence, MessageTurnEndReason, ProviderState } from "agent-relay-sdk";
1
+ import type { AgentProfile, InteractivePrompt, 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";
@@ -48,12 +48,15 @@ export interface ProviderSessionEvent {
48
48
  body: string;
49
49
  origin?: "chat" | "terminal" | "provider";
50
50
  turnId?: string;
51
+ occurredAt?: number;
51
52
  /** True only when this response is the completed provider turn, not an intermediate flush. */
52
53
  final?: boolean;
53
54
  /** Whether the turn-final edge was provider-native or reconstructed. Set only alongside `final`. */
54
55
  confidence?: MessageFinalityConfidence;
55
56
  /** Why the provider turn ended. Set only alongside `final`. */
56
57
  endedBy?: MessageTurnEndReason;
58
+ /** Whether this response may clear a direct user obligation; Codex keeps this false. */
59
+ replyToUserObligation?: boolean;
57
60
  label?: string;
58
61
  status?: "running" | "completed" | "failed";
59
62
  streaming?: boolean;
@@ -63,6 +66,66 @@ export interface ProviderSessionEvent {
63
66
  stepId?: string;
64
67
  }
65
68
 
69
+ export type ProviderSessionEventBatch = ProviderSessionEvent[];
70
+
71
+ export interface ProviderSessionTurnInput {
72
+ transcriptPath: string;
73
+ lastAssistantMessage?: unknown;
74
+ promptId?: string;
75
+ isPreFlush?: boolean;
76
+ toolName?: string;
77
+ toolInput?: unknown;
78
+ }
79
+
80
+ export interface ProviderSessionTurnContext {
81
+ turnId?: string;
82
+ turnStartedAt?: number;
83
+ chatCaptureMode: ProviderConfig["chatCaptureMode"];
84
+ reasoningCapture?: boolean;
85
+ settlePollIntervalMs: number;
86
+ settlePollTimeoutMs: number;
87
+ flushSessionEvents(timeoutMs: number): Promise<boolean>;
88
+ pendingSessionEventCount(): number;
89
+ log(message: string): void;
90
+ debug(message: string): void;
91
+ }
92
+
93
+ export interface ProviderSessionTurnResult {
94
+ reasoningSettled?: boolean;
95
+ }
96
+
97
+ export interface ProviderUserPromptInput {
98
+ prompt: string;
99
+ transcriptPath?: string;
100
+ promptId?: string;
101
+ }
102
+
103
+ export interface ProviderPermissionResolvedPrompt {
104
+ provider: "claude";
105
+ kind: "questions" | "plan" | "command" | "tool";
106
+ title: string;
107
+ body: string;
108
+ promptBody?: string;
109
+ toolName: string;
110
+ hookEventName: string;
111
+ approvalId: string;
112
+ decision: ProviderPermissionDecisionInput["decision"];
113
+ decisionLabel: string;
114
+ occurredAt: number;
115
+ resolvedAt: number;
116
+ decisionReason: ProviderPermissionDecisionReason;
117
+ questions?: unknown[];
118
+ answers?: Record<string, string>;
119
+ }
120
+
121
+ export interface ProviderPermissionPromptHandler {
122
+ source: string;
123
+ resolvedLabel: string;
124
+ buildView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt;
125
+ buildHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown>;
126
+ buildResolvedPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ProviderPermissionResolvedPrompt;
127
+ }
128
+
66
129
  export interface ProviderConfig {
67
130
  command: string;
68
131
  defaultArgs: string[];
@@ -159,6 +222,9 @@ export interface ProviderPermissionDecisionReason {
159
222
 
160
223
  export interface ProviderAdapter {
161
224
  provider: string;
225
+ statusSourceId?: string;
226
+ stopObligationPath?: string;
227
+ permissionPromptHandler?: ProviderPermissionPromptHandler;
162
228
  spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
163
229
  shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
164
230
  compact?(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown> | void>;
@@ -193,6 +259,12 @@ export interface ProviderAdapter {
193
259
  probePaneBusy?(process: ManagedProcess): Promise<boolean>;
194
260
  terminalAttachSpec?(process: ManagedProcess): Promise<TerminalAttachSpec>;
195
261
  respondToPermissionDecision?(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown> | void>;
262
+ captureSessionTurn?(input: ProviderSessionTurnInput, ctx: ProviderSessionTurnContext): Promise<ProviderSessionTurnResult | void>;
263
+ handleUserPrompt?(input: ProviderUserPromptInput, ctx: ProviderSessionTurnContext): Promise<void>;
264
+ drainSessionTrace?(ctx: ProviderSessionTurnContext): Promise<void>;
265
+ settleSessionTraceForToolUse?(transcriptPath: string, toolName: string | undefined, toolInput: unknown, ctx: ProviderSessionTurnContext): Promise<{ settled: boolean; jsonl?: string }>;
266
+ stopSessionTrace?(): void;
267
+ publishPassiveTelemetry?(): Promise<void>;
196
268
  // `options.readyTimeoutMs` lets the runner widen the provider-ready wait for the
197
269
  // first (cold-start) delivery vs. a fast re-attempt after a ready signal (#329).
198
270
  deliverInitialPrompt?(process: ManagedProcess, prompt: string, options?: { readyTimeoutMs?: number }): Promise<void>;
@@ -205,10 +277,10 @@ export interface ProviderAdapter {
205
277
  seedsInitialPromptAtLaunch?: boolean;
206
278
  deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
207
279
  onStatusChange(cb: (status: ProviderStatusUpdate) => void): void;
208
- // Subscribe to session-mirror events from providers that emit them directly
209
- // (Codex app-server item events). Claude mirrors via hooks/transcript instead,
210
- // so it leaves this unimplemented.
280
+ // Subscribe to session-mirror events from providers. Batch callbacks preserve
281
+ // shared idempotency for multiple trace steps discovered in one provider poll.
211
282
  onSessionEvent?(cb: (event: ProviderSessionEvent) => void): void;
283
+ onSessionEvents?(cb: (events: ProviderSessionEventBatch) => void): void;
212
284
  // §4.1 of the Runner↔provider contract (#725 / #746): fold the runner-tracked raw
213
285
  // transport/timeline/token/work-kind signals into ONE normalized LivenessSignal the
214
286
  // server reads instead of six raw `meta` fields. Each adapter owns what counts as
@@ -0,0 +1,191 @@
1
+ import type { InteractivePrompt } from "agent-relay-sdk";
2
+ import { isRecord } from "agent-relay-sdk";
3
+ import type { ProviderPermissionDecisionInput, ProviderPermissionPromptHandler, ProviderPermissionResolvedPrompt } from "../adapter";
4
+
5
+ function decisionReasonMessage(decision: ProviderPermissionDecisionInput, fallback: string): string {
6
+ return decision.reason || decision.decisionReason?.message || fallback;
7
+ }
8
+
9
+ export const claudePermissionPromptHandler: ProviderPermissionPromptHandler = {
10
+ source: "claude",
11
+ resolvedLabel: "Claude approval resolved",
12
+ buildView: claudePermissionApprovalView,
13
+ buildHookResponse: claudePermissionHookResponse,
14
+ buildResolvedPrompt: claudeResolvedPermissionPrompt,
15
+ };
16
+
17
+ function claudePermissionApprovalView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt {
18
+ const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
19
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
20
+ if (toolName === "AskUserQuestion" && Array.isArray(toolInput.questions) && toolInput.questions.length) {
21
+ const count = toolInput.questions.length;
22
+ return {
23
+ id,
24
+ kind: "questions",
25
+ title: count > 1 ? `Claude is asking ${count} questions` : "Claude is asking a question",
26
+ body: "",
27
+ questions: toolInput.questions as InteractivePrompt["questions"],
28
+ choices: [],
29
+ reasoningSettled,
30
+ };
31
+ }
32
+ if (toolName === "ExitPlanMode") {
33
+ const plan = typeof toolInput.plan === "string" && toolInput.plan.trim()
34
+ ? toolInput.plan
35
+ : JSON.stringify(toolInput);
36
+ return {
37
+ id,
38
+ kind: "plan",
39
+ title: "Claude is ready to code",
40
+ body: plan,
41
+ choices: [
42
+ { id: "approve", label: "Approve plan" },
43
+ { id: "deny", label: "Keep planning" },
44
+ ],
45
+ reasoningSettled,
46
+ };
47
+ }
48
+ const command = typeof toolInput.command === "string" ? toolInput.command : "";
49
+ const description = typeof toolInput.description === "string" ? toolInput.description : "";
50
+ const bodyText = [
51
+ command || description || JSON.stringify(toolInput),
52
+ typeof body.cwd === "string" ? `CWD: ${body.cwd}` : "",
53
+ typeof body.permission_mode === "string" ? `Mode: ${body.permission_mode}` : "",
54
+ ].filter(Boolean).join("\n");
55
+ return {
56
+ id,
57
+ kind: toolName.toLowerCase() === "bash" ? "command" : "tool",
58
+ title: `Approve ${toolName}`,
59
+ body: bodyText,
60
+ choices: [
61
+ { id: "approve", label: "Approve" },
62
+ { id: "approve-session", label: "Approve session" },
63
+ { id: "deny", label: "Deny" },
64
+ { id: "abort", label: "Abort" },
65
+ ],
66
+ reasoningSettled,
67
+ };
68
+ }
69
+
70
+ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
71
+ if (body.hook_event_name === "PreToolUse") {
72
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
73
+ if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
74
+ return {
75
+ hookSpecificOutput: {
76
+ hookEventName: "PreToolUse",
77
+ permissionDecision: "allow",
78
+ updatedInput: { ...toolInput, answers: decision.answers },
79
+ },
80
+ };
81
+ }
82
+ return {
83
+ hookSpecificOutput: {
84
+ hookEventName: "PreToolUse",
85
+ permissionDecision: "deny",
86
+ permissionDecisionReason: decisionReasonMessage(decision, "Dismissed from Agent Relay dashboard"),
87
+ },
88
+ };
89
+ }
90
+ const hookEventName = "PermissionRequest";
91
+ if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
92
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
93
+ return {
94
+ hookSpecificOutput: {
95
+ hookEventName,
96
+ decision: {
97
+ behavior: "allow",
98
+ updatedInput: { ...toolInput, answers: decision.answers },
99
+ },
100
+ },
101
+ };
102
+ }
103
+ if (decision.decision === "approve" || decision.decision === "approve-session") {
104
+ const suggestions = Array.isArray(body.permission_suggestions) ? body.permission_suggestions : [];
105
+ return {
106
+ hookSpecificOutput: {
107
+ hookEventName,
108
+ decision: {
109
+ behavior: "allow",
110
+ ...(decision.decision === "approve-session" && suggestions.length ? { updatedPermissions: suggestions } : {}),
111
+ },
112
+ },
113
+ };
114
+ }
115
+ return {
116
+ hookSpecificOutput: {
117
+ hookEventName,
118
+ decision: {
119
+ behavior: "deny",
120
+ message: decisionReasonMessage(decision, "Denied from Agent Relay dashboard"),
121
+ interrupt: decision.decision === "abort",
122
+ },
123
+ },
124
+ };
125
+ }
126
+
127
+ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ProviderPermissionResolvedPrompt {
128
+ const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
129
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
130
+ const kind: ProviderPermissionResolvedPrompt["kind"] = view.kind === "questions" || view.kind === "plan" || view.kind === "command" || view.kind === "tool"
131
+ ? view.kind
132
+ : toolName.toLowerCase() === "bash" ? "command" : "tool";
133
+ const questions = Array.isArray(view.questions)
134
+ ? view.questions
135
+ : Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
136
+ const promptBody = typeof view.body === "string" ? view.body : "";
137
+ const title = typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`;
138
+ const decisionLabel = claudePermissionDecisionLabel(kind, decision.decision);
139
+ return {
140
+ provider: "claude",
141
+ kind,
142
+ title,
143
+ body: claudeResolvedPermissionBody({ kind, title, toolName, promptBody, decisionLabel, questions, answers: decision.answers }),
144
+ ...(promptBody ? { promptBody } : {}),
145
+ toolName,
146
+ hookEventName: typeof body.hook_event_name === "string" ? body.hook_event_name : "PermissionRequest",
147
+ approvalId: decision.approvalId,
148
+ decision: decision.decision,
149
+ decisionLabel,
150
+ decisionReason: decision.decisionReason ?? { kind: decision.decision === "deny" || decision.decision === "abort" ? "dismissed" : "answered", ...(decision.reason ? { message: decision.reason } : {}) },
151
+ occurredAt,
152
+ resolvedAt: Date.now(),
153
+ ...(questions ? { questions } : {}),
154
+ ...(decision.answers ? { answers: decision.answers } : {}),
155
+ };
156
+ }
157
+
158
+ function claudeResolvedPermissionBody(input: { kind: string; title: string; toolName: string; promptBody: string; decisionLabel: string; questions?: unknown[]; answers?: Record<string, string> }): string {
159
+ if (input.kind === "questions") {
160
+ return [
161
+ input.title,
162
+ input.decisionLabel,
163
+ ...resolvedQuestionRows(input.questions, input.answers, input.decisionLabel).map((row) => `Q: ${row.question}\nA: ${row.answer}`),
164
+ ].filter(Boolean).join("\n\n");
165
+ }
166
+ const subject = input.kind === "plan" ? "Plan prompt resolved" : `${input.toolName} permission resolved`;
167
+ return [subject, `Decision: ${input.decisionLabel}`, input.promptBody.trim()].filter(Boolean).join("\n\n");
168
+ }
169
+
170
+ function resolvedQuestionRows(questions: unknown[] | undefined, answers: Record<string, string> | undefined, fallback: string): Array<{ question: string; answer: string }> {
171
+ const answerMap = answers ?? {};
172
+ const rows = (questions ?? []).map((question, index) => {
173
+ const record = question && typeof question === "object" && !Array.isArray(question) ? question as Record<string, unknown> : {};
174
+ const label = typeof record.question === "string" && record.question.trim()
175
+ ? record.question.trim()
176
+ : typeof record.header === "string" && record.header.trim() ? record.header.trim() : `Question ${index + 1}`;
177
+ const rawAnswer = answerMap[label] ?? answerMap[String(record.question ?? "")] ?? answerMap[String(record.header ?? "")];
178
+ return { question: label, answer: typeof rawAnswer === "string" && rawAnswer.trim() ? rawAnswer.trim() : fallback };
179
+ });
180
+ return rows.length ? rows : Object.entries(answerMap).map(([question, answer]) => ({ question, answer }));
181
+ }
182
+
183
+ function claudePermissionDecisionLabel(kind: string, decision: ProviderPermissionDecisionInput["decision"]): string {
184
+ if (kind === "questions") return decision === "answer" ? "Answered" : "Dismissed";
185
+ if (kind === "plan") return decision === "approve" || decision === "approve-session" ? "Approved plan" : "Kept planning";
186
+ if (decision === "approve-session") return "Approved for session";
187
+ if (decision === "approve") return "Approved";
188
+ if (decision === "abort") return "Aborted";
189
+ if (decision === "answer") return "Answered";
190
+ return "Denied";
191
+ }
@@ -1,7 +1,7 @@
1
1
  import { resolve } from "node:path";
2
2
  import { isRecord, type ProviderState } from "agent-relay-sdk";
3
- import type { ProviderStatusUpdate, RunnerSpawnConfig } from "./adapter";
4
- import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "./rate-limit";
3
+ import type { ProviderStatusUpdate, RunnerSpawnConfig } from "../adapter";
4
+ import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
5
5
 
6
6
  export interface ClaudeConfigPromptGatePrevention {
7
7
  id: string;
@@ -37,6 +37,7 @@ export interface ClaudePromptGatePaneContext {
37
37
  sessionName?: string;
38
38
  busy?: boolean;
39
39
  nowMs?: number;
40
+ sourceId?: string;
40
41
  }
41
42
 
42
43
  export interface ClaudePromptGateAction {
@@ -44,7 +45,7 @@ export interface ClaudePromptGateAction {
44
45
  label: string;
45
46
  policy: string;
46
47
  keystroke: string;
47
- source: "claude-pane";
48
+ source: string;
48
49
  }
49
50
 
50
51
  export interface ClaudePromptGatePaneResult {
@@ -113,7 +114,7 @@ const panePromptGates: ClaudePanePromptGate[] = [
113
114
  kind: match.kind ?? "usage_limit",
114
115
  ...(match.resetAt ? { resetAt: match.resetAt } : {}),
115
116
  message: match.message,
116
- source: context.sessionName ? `claude-pane:${context.sessionName}` : "claude-pane",
117
+ source: paneSource(context),
117
118
  });
118
119
  },
119
120
  },
@@ -217,7 +218,7 @@ export function evaluateClaudePromptGatePane(
217
218
  label: detected.entry.label,
218
219
  policy: detected.entry.policy,
219
220
  keystroke: detected.entry.keystroke,
220
- source: "claude-pane",
221
+ source: paneSource(context),
221
222
  };
222
223
  return {
223
224
  action,
@@ -226,6 +227,11 @@ export function evaluateClaudePromptGatePane(
226
227
  };
227
228
  }
228
229
 
230
+ function paneSource(context: ClaudePromptGatePaneContext): string {
231
+ const source = context.sourceId ?? "claude-pane";
232
+ return context.sessionName ? `${source}:${context.sessionName}` : source;
233
+ }
234
+
229
235
  function firstDetectedPaneGate(text: string, nowMs?: number): { entry: ClaudePanePromptGate; match: PaneGateMatch } | null {
230
236
  for (const entry of panePromptGates) {
231
237
  const match = entry.match(text, nowMs);
@@ -0,0 +1,235 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { errMessage, type MessageSessionMeta } from "agent-relay-sdk";
3
+ import type { ManagedProcess, ProviderSessionEvent, ProviderSessionEventBatch, ProviderSessionTurnContext, ProviderSessionTurnInput, ProviderSessionTurnResult, ProviderUserPromptInput } from "../adapter";
4
+ import type { SessionEvent } from "../session-insights";
5
+ import {
6
+ collectClaudeSessionEvents,
7
+ countTranscriptEntries,
8
+ extractAssistantTurnForPrompt,
9
+ extractHookAssistantMessage,
10
+ extractLastAssistantTurn,
11
+ extractLastAssistantTurnAfterEntry,
12
+ extractLatestTurnSteps,
13
+ stepDedupKeys,
14
+ transcriptHasToolUseAnchor,
15
+ transcriptLooksComplete,
16
+ } from "./claude-transcript";
17
+ import { IncrementalClaudeTranscriptTail } from "./claude-transcript-tail";
18
+
19
+ interface ReasoningTailState {
20
+ timer: ReturnType<typeof setInterval>;
21
+ emittedNarrationKeys: Set<string>;
22
+ poll(): Promise<void>;
23
+ }
24
+
25
+ const REASONING_POLL_MS = 1_200;
26
+
27
+ export class ClaudeSessionCapture {
28
+ private narrativeFlushEntryCount = 0;
29
+ private reasoningTail?: ReasoningTailState;
30
+ private sessionEventCb: (event: ProviderSessionEvent) => void = () => {};
31
+ private sessionEventsCb: (events: ProviderSessionEventBatch) => void = (events) => { for (const event of events) this.sessionEventCb(event); };
32
+
33
+ onSessionEvent(cb: (event: ProviderSessionEvent) => void): void {
34
+ this.sessionEventCb = cb;
35
+ }
36
+
37
+ onSessionEvents(cb: (events: ProviderSessionEventBatch) => void): void {
38
+ this.sessionEventsCb = cb;
39
+ }
40
+
41
+ async captureSessionTurn(input: ProviderSessionTurnInput, ctx: ProviderSessionTurnContext): Promise<ProviderSessionTurnResult | void> {
42
+ if (input.isPreFlush) {
43
+ if (!input.transcriptPath) return { reasoningSettled: true };
44
+ const settled = await this.settlePollForToolUse(input.transcriptPath, input.toolName, input.toolInput, ctx);
45
+ const jsonl = settled.jsonl;
46
+ if (jsonl === undefined) return { reasoningSettled: false };
47
+ const body = extractLastAssistantTurnAfterEntry(jsonl, this.narrativeFlushEntryCount);
48
+ await this.drainReasoningTail(ctx);
49
+ this.narrativeFlushEntryCount = countTranscriptEntries(jsonl);
50
+ if (body && !this.preFlushBodyAlreadyMirroredByReasoningTail(jsonl, body)) {
51
+ ctx.log(`pre-flush narrative for turn ${ctx.turnId ?? "?"} (${body.length} chars)`);
52
+ this.emit({ type: "response", origin: "provider", body, final: false, ...(ctx.turnId ? { turnId: ctx.turnId } : {}) });
53
+ }
54
+ await ctx.flushSessionEvents(2_000).then(
55
+ (ok) => { if (!ok) ctx.log(`pre-flush session outbox incomplete before blocking control (${ctx.pendingSessionEventCount()} pending)`); },
56
+ (error) => ctx.log(`pre-flush session outbox failed before blocking control: ${errMessage(error)}`),
57
+ );
58
+ return { reasoningSettled: settled.settled };
59
+ }
60
+
61
+ const turnId = input.promptId ?? ctx.turnId;
62
+ await this.drainReasoningTail(ctx);
63
+ this.stopSessionTrace();
64
+ const flushCount = this.narrativeFlushEntryCount;
65
+ this.narrativeFlushEntryCount = 0;
66
+
67
+ let body = extractHookAssistantMessage(input.lastAssistantMessage);
68
+ if (ctx.chatCaptureMode === "full" && input.transcriptPath) {
69
+ let jsonl: string | undefined;
70
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
71
+ if (jsonl !== undefined && transcriptLooksComplete(jsonl)) {
72
+ let full = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
73
+ if (!full) full = flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl);
74
+ if (full) body = full;
75
+ }
76
+ }
77
+ if (!body && ctx.chatCaptureMode === "final" && input.transcriptPath) {
78
+ let jsonl: string | undefined;
79
+ try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { jsonl = undefined; }
80
+ if (jsonl !== undefined) {
81
+ const fallback = turnId ? extractAssistantTurnForPrompt(jsonl, turnId, flushCount) : "";
82
+ body = fallback || (flushCount > 0 ? extractLastAssistantTurnAfterEntry(jsonl, flushCount) : extractLastAssistantTurn(jsonl));
83
+ }
84
+ }
85
+ if (!body) {
86
+ ctx.log(`response capture: no closing text for turn ${turnId ?? "?"} (skipped)`);
87
+ return;
88
+ }
89
+
90
+ ctx.log(`response captured for turn ${turnId ?? "?"} (${body.length} chars)`);
91
+ this.emit({
92
+ type: "response",
93
+ origin: "provider",
94
+ body,
95
+ final: true,
96
+ confidence: "definitive",
97
+ endedBy: "completed",
98
+ replyToUserObligation: true,
99
+ ...(turnId ? { turnId } : {}),
100
+ });
101
+ }
102
+
103
+ async handleUserPrompt(input: ProviderUserPromptInput, ctx: ProviderSessionTurnContext): Promise<void> {
104
+ if (input.transcriptPath) this.startReasoningTail(input.transcriptPath, ctx);
105
+ }
106
+
107
+ async drainReasoningTail(ctx: ProviderSessionTurnContext): Promise<void> {
108
+ if (ctx.reasoningCapture === false) return;
109
+ await this.reasoningTail?.poll();
110
+ }
111
+
112
+ stopSessionTrace(): void {
113
+ if (this.reasoningTail) clearInterval(this.reasoningTail.timer);
114
+ this.reasoningTail = undefined;
115
+ }
116
+
117
+ async settlePollForToolUse(transcriptPath: string, toolName: string | undefined, toolInput: unknown, ctx: ProviderSessionTurnContext): Promise<{ settled: boolean; jsonl?: string }> {
118
+ const deadline = Date.now() + ctx.settlePollTimeoutMs;
119
+ for (;;) {
120
+ let jsonl: string | undefined;
121
+ try { jsonl = await readFile(transcriptPath, "utf8"); } catch { jsonl = undefined; }
122
+ if (!toolName) return { settled: Boolean(jsonl), jsonl };
123
+ if (jsonl !== undefined && transcriptHasToolUseAnchor(jsonl, toolName, toolInput)) return { settled: true, jsonl };
124
+ if (Date.now() >= deadline) return { settled: false, jsonl };
125
+ await Bun.sleep(ctx.settlePollIntervalMs);
126
+ }
127
+ }
128
+
129
+ private startReasoningTail(transcriptPath: string, ctx: ProviderSessionTurnContext): void {
130
+ if (ctx.reasoningCapture === false) return;
131
+ this.stopSessionTrace();
132
+ const seen = new Set<string>();
133
+ const emittedNarrationKeys = new Set<string>();
134
+ const transcriptTail = new IncrementalClaudeTranscriptTail();
135
+ const turnIdAtStart = ctx.turnId;
136
+ let seeded = false;
137
+ let pendingPoll = Promise.resolve();
138
+ const pollBody = async (): Promise<void> => {
139
+ const result = await transcriptTail.poll(transcriptPath);
140
+ if (!result.available) return;
141
+ if (result.reset) { seen.clear(); emittedNarrationKeys.clear(); seeded = false; }
142
+ if (!result.changed && (seeded || (!result.steps.length && !result.transcriptLooksComplete))) return;
143
+ const keyed = stepDedupKeys(result.steps).map((sig, i) => ({ sig, step: result.steps[i]! }));
144
+ if (!seeded) {
145
+ seeded = true;
146
+ if (result.transcriptLooksComplete) {
147
+ for (const { sig } of keyed) seen.add(sig);
148
+ }
149
+ }
150
+ const turnId = ctx.turnId ?? turnIdAtStart;
151
+ let emitted = 0;
152
+ const events: ProviderSessionEvent[] = [];
153
+ for (const { sig, step } of keyed) {
154
+ if (seen.has(sig)) continue;
155
+ seen.add(sig);
156
+ emitted += 1;
157
+ events.push({
158
+ type: step.type,
159
+ origin: "provider",
160
+ body: step.text,
161
+ ...(step.occurredAt ? { occurredAt: step.occurredAt } : {}),
162
+ ...(turnId ? { turnId } : {}),
163
+ ...(step.label ? { label: step.label } : {}),
164
+ });
165
+ if (step.type === "narration") emittedNarrationKeys.add(sig);
166
+ }
167
+ if (events.length) this.emitBatch(events);
168
+ if (emitted) ctx.debug(`reasoning tail emitted ${emitted} step(s) (turn ${turnId ?? "?"}, ${seen.size} total)`);
169
+ };
170
+ const poll = (): Promise<void> => {
171
+ const next = pendingPoll.then(pollBody, pollBody);
172
+ pendingPoll = next.catch(() => {});
173
+ return next;
174
+ };
175
+ this.reasoningTail = { emittedNarrationKeys, poll, timer: setInterval(() => { void poll(); }, REASONING_POLL_MS) };
176
+ ctx.log(`reasoning tail started (turn ${turnIdAtStart ?? "?"})`);
177
+ void poll();
178
+ }
179
+
180
+ private preFlushBodyAlreadyMirroredByReasoningTail(jsonl: string, body: string): boolean {
181
+ const tail = this.reasoningTail;
182
+ if (!tail || !tail.emittedNarrationKeys.size) return false;
183
+ const steps = extractLatestTurnSteps(jsonl);
184
+ const keys = stepDedupKeys(steps);
185
+ const mirrored = steps
186
+ .map((step, index) => ({ step, key: keys[index]! }))
187
+ .filter(({ step, key }) => step.type === "narration" && tail.emittedNarrationKeys.has(key))
188
+ .map(({ step }) => step.text)
189
+ .join("\n\n")
190
+ .trim();
191
+ const trimmed = body.trim();
192
+ return mirrored === trimmed || mirrored.endsWith(`\n\n${trimmed}`);
193
+ }
194
+
195
+ private emit(event: ProviderSessionEvent): void {
196
+ this.sessionEventCb(event);
197
+ }
198
+
199
+ private emitBatch(events: ProviderSessionEventBatch): void {
200
+ this.sessionEventsCb(events);
201
+ }
202
+ }
203
+
204
+ export function providerSessionEventToMessageSession(event: ProviderSessionEvent): MessageSessionMeta {
205
+ return {
206
+ type: event.type,
207
+ origin: event.origin ?? "provider",
208
+ ...(event.turnId ? { turnId: event.turnId } : {}),
209
+ ...(event.label ? { label: event.label } : {}),
210
+ ...(event.status ? { status: event.status } : {}),
211
+ ...(event.streaming !== undefined ? { streaming: event.streaming } : {}),
212
+ ...(event.stepId ? { stepId: event.stepId } : {}),
213
+ ...(event.final ? { final: true } : {}),
214
+ };
215
+ }
216
+
217
+ export async function collectClaudeTranscriptSessionEvents(_process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<SessionEvent[] | null> {
218
+ if (!ctx.transcriptPath) return null;
219
+ let jsonl: string;
220
+ try {
221
+ jsonl = await readFile(ctx.transcriptPath, "utf8");
222
+ } catch {
223
+ return null;
224
+ }
225
+ return collectClaudeSessionEvents(jsonl);
226
+ }
227
+
228
+ export async function collectClaudeTranscriptArchiveSegment(_process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<string | null> {
229
+ if (!ctx.transcriptPath) return null;
230
+ try {
231
+ return await readFile(ctx.transcriptPath, "utf8");
232
+ } catch {
233
+ return null;
234
+ }
235
+ }