agent-relay-runner 0.127.5 → 0.127.7

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.7",
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.7",
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,71 @@ 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
+ // #1173: for prompt kinds whose PreToolUse hook is answered immediately (deny-then-hold —
128
+ // see control-server.ts's AskUserQuestion branch) rather than held open for the decision,
129
+ // this renders the eventual "answer" decision as the normal user-prompt text to inject back
130
+ // into the session. Absent for prompt kinds that still ride the hook response directly.
131
+ buildAnswerInjectionPrompt?(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): string;
132
+ }
133
+
66
134
  export interface ProviderConfig {
67
135
  command: string;
68
136
  defaultArgs: string[];
@@ -159,6 +227,9 @@ export interface ProviderPermissionDecisionReason {
159
227
 
160
228
  export interface ProviderAdapter {
161
229
  provider: string;
230
+ statusSourceId?: string;
231
+ stopObligationPath?: string;
232
+ permissionPromptHandler?: ProviderPermissionPromptHandler;
162
233
  spawn(config: RunnerSpawnConfig): Promise<ManagedProcess>;
163
234
  shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void>;
164
235
  compact?(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown> | void>;
@@ -193,6 +264,12 @@ export interface ProviderAdapter {
193
264
  probePaneBusy?(process: ManagedProcess): Promise<boolean>;
194
265
  terminalAttachSpec?(process: ManagedProcess): Promise<TerminalAttachSpec>;
195
266
  respondToPermissionDecision?(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown> | void>;
267
+ captureSessionTurn?(input: ProviderSessionTurnInput, ctx: ProviderSessionTurnContext): Promise<ProviderSessionTurnResult | void>;
268
+ handleUserPrompt?(input: ProviderUserPromptInput, ctx: ProviderSessionTurnContext): Promise<void>;
269
+ drainSessionTrace?(ctx: ProviderSessionTurnContext): Promise<void>;
270
+ settleSessionTraceForToolUse?(transcriptPath: string, toolName: string | undefined, toolInput: unknown, ctx: ProviderSessionTurnContext): Promise<{ settled: boolean; jsonl?: string }>;
271
+ stopSessionTrace?(): void;
272
+ publishPassiveTelemetry?(): Promise<void>;
196
273
  // `options.readyTimeoutMs` lets the runner widen the provider-ready wait for the
197
274
  // first (cold-start) delivery vs. a fast re-attempt after a ready signal (#329).
198
275
  deliverInitialPrompt?(process: ManagedProcess, prompt: string, options?: { readyTimeoutMs?: number }): Promise<void>;
@@ -205,10 +282,10 @@ export interface ProviderAdapter {
205
282
  seedsInitialPromptAtLaunch?: boolean;
206
283
  deliver(process: ManagedProcess, messages: Message[]): Promise<void>;
207
284
  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.
285
+ // Subscribe to session-mirror events from providers. Batch callbacks preserve
286
+ // shared idempotency for multiple trace steps discovered in one provider poll.
211
287
  onSessionEvent?(cb: (event: ProviderSessionEvent) => void): void;
288
+ onSessionEvents?(cb: (events: ProviderSessionEventBatch) => void): void;
212
289
  // §4.1 of the Runner↔provider contract (#725 / #746): fold the runner-tracked raw
213
290
  // transport/timeline/token/work-kind signals into ONE normalized LivenessSignal the
214
291
  // server reads instead of six raw `meta` fields. Each adapter owns what counts as
@@ -0,0 +1,209 @@
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
+ buildAnswerInjectionPrompt: claudeAnswerInjectionPrompt,
16
+ };
17
+
18
+ function claudePermissionApprovalView(id: string, body: Record<string, unknown>, reasoningSettled: boolean): InteractivePrompt {
19
+ const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
20
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
21
+ if (toolName === "AskUserQuestion" && Array.isArray(toolInput.questions) && toolInput.questions.length) {
22
+ const count = toolInput.questions.length;
23
+ return {
24
+ id,
25
+ kind: "questions",
26
+ title: count > 1 ? `Claude is asking ${count} questions` : "Claude is asking a question",
27
+ body: "",
28
+ questions: toolInput.questions as InteractivePrompt["questions"],
29
+ choices: [],
30
+ reasoningSettled,
31
+ };
32
+ }
33
+ if (toolName === "ExitPlanMode") {
34
+ const plan = typeof toolInput.plan === "string" && toolInput.plan.trim()
35
+ ? toolInput.plan
36
+ : JSON.stringify(toolInput);
37
+ return {
38
+ id,
39
+ kind: "plan",
40
+ title: "Claude is ready to code",
41
+ body: plan,
42
+ choices: [
43
+ { id: "approve", label: "Approve plan" },
44
+ { id: "deny", label: "Keep planning" },
45
+ ],
46
+ reasoningSettled,
47
+ };
48
+ }
49
+ const command = typeof toolInput.command === "string" ? toolInput.command : "";
50
+ const description = typeof toolInput.description === "string" ? toolInput.description : "";
51
+ const bodyText = [
52
+ command || description || JSON.stringify(toolInput),
53
+ typeof body.cwd === "string" ? `CWD: ${body.cwd}` : "",
54
+ typeof body.permission_mode === "string" ? `Mode: ${body.permission_mode}` : "",
55
+ ].filter(Boolean).join("\n");
56
+ return {
57
+ id,
58
+ kind: toolName.toLowerCase() === "bash" ? "command" : "tool",
59
+ title: `Approve ${toolName}`,
60
+ body: bodyText,
61
+ choices: [
62
+ { id: "approve", label: "Approve" },
63
+ { id: "approve-session", label: "Approve session" },
64
+ { id: "deny", label: "Deny" },
65
+ { id: "abort", label: "Abort" },
66
+ ],
67
+ reasoningSettled,
68
+ };
69
+ }
70
+
71
+ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
72
+ if (body.hook_event_name === "PreToolUse") {
73
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
74
+ if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
75
+ return {
76
+ hookSpecificOutput: {
77
+ hookEventName: "PreToolUse",
78
+ permissionDecision: "allow",
79
+ updatedInput: { ...toolInput, answers: decision.answers },
80
+ },
81
+ };
82
+ }
83
+ return {
84
+ hookSpecificOutput: {
85
+ hookEventName: "PreToolUse",
86
+ permissionDecision: "deny",
87
+ permissionDecisionReason: decisionReasonMessage(decision, "Dismissed from Agent Relay dashboard"),
88
+ },
89
+ };
90
+ }
91
+ const hookEventName = "PermissionRequest";
92
+ if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
93
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
94
+ return {
95
+ hookSpecificOutput: {
96
+ hookEventName,
97
+ decision: {
98
+ behavior: "allow",
99
+ updatedInput: { ...toolInput, answers: decision.answers },
100
+ },
101
+ },
102
+ };
103
+ }
104
+ if (decision.decision === "approve" || decision.decision === "approve-session") {
105
+ const suggestions = Array.isArray(body.permission_suggestions) ? body.permission_suggestions : [];
106
+ return {
107
+ hookSpecificOutput: {
108
+ hookEventName,
109
+ decision: {
110
+ behavior: "allow",
111
+ ...(decision.decision === "approve-session" && suggestions.length ? { updatedPermissions: suggestions } : {}),
112
+ },
113
+ },
114
+ };
115
+ }
116
+ return {
117
+ hookSpecificOutput: {
118
+ hookEventName,
119
+ decision: {
120
+ behavior: "deny",
121
+ message: decisionReasonMessage(decision, "Denied from Agent Relay dashboard"),
122
+ interrupt: decision.decision === "abort",
123
+ },
124
+ },
125
+ };
126
+ }
127
+
128
+ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>, occurredAt: number): ProviderPermissionResolvedPrompt {
129
+ const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
130
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
131
+ const kind: ProviderPermissionResolvedPrompt["kind"] = view.kind === "questions" || view.kind === "plan" || view.kind === "command" || view.kind === "tool"
132
+ ? view.kind
133
+ : toolName.toLowerCase() === "bash" ? "command" : "tool";
134
+ const questions = Array.isArray(view.questions)
135
+ ? view.questions
136
+ : Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
137
+ const promptBody = typeof view.body === "string" ? view.body : "";
138
+ const title = typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`;
139
+ const decisionLabel = claudePermissionDecisionLabel(kind, decision.decision);
140
+ return {
141
+ provider: "claude",
142
+ kind,
143
+ title,
144
+ body: claudeResolvedPermissionBody({ kind, title, toolName, promptBody, decisionLabel, questions, answers: decision.answers }),
145
+ ...(promptBody ? { promptBody } : {}),
146
+ toolName,
147
+ hookEventName: typeof body.hook_event_name === "string" ? body.hook_event_name : "PermissionRequest",
148
+ approvalId: decision.approvalId,
149
+ decision: decision.decision,
150
+ decisionLabel,
151
+ decisionReason: decision.decisionReason ?? { kind: decision.decision === "deny" || decision.decision === "abort" ? "dismissed" : "answered", ...(decision.reason ? { message: decision.reason } : {}) },
152
+ occurredAt,
153
+ resolvedAt: Date.now(),
154
+ ...(questions ? { questions } : {}),
155
+ ...(decision.answers ? { answers: decision.answers } : {}),
156
+ };
157
+ }
158
+
159
+ // #1173: AskUserQuestion's PreToolUse hook is answered with `deny` immediately (see
160
+ // control-server.ts) so Claude flushes its buffered reasoning instead of blocking on the human.
161
+ // The user's actual answer arrives afterward as a normal injected prompt, not as a hook
162
+ // response — this renders that prompt's text from the same view/decision shape the old
163
+ // blocking flow used, so the model sees a clear, structured hand-off instead of silence.
164
+ function claudeAnswerInjectionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): string {
165
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
166
+ const questions = Array.isArray(view.questions) ? view.questions : Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
167
+ const rows = resolvedQuestionRows(questions, decision.answers, "(no answer given)");
168
+ const qa = rows.map((row) => `Q: ${row.question}\nA: ${row.answer}`).join("\n\n");
169
+ return [
170
+ "You previously called AskUserQuestion with the following question(s); it was intercepted so the user could answer directly via Agent Relay instead of through the tool call:",
171
+ qa,
172
+ "The user has now answered above. Continue the task using their answer(s) — do not call AskUserQuestion again for the same question(s).",
173
+ ].join("\n\n");
174
+ }
175
+
176
+ function claudeResolvedPermissionBody(input: { kind: string; title: string; toolName: string; promptBody: string; decisionLabel: string; questions?: unknown[]; answers?: Record<string, string> }): string {
177
+ if (input.kind === "questions") {
178
+ return [
179
+ input.title,
180
+ input.decisionLabel,
181
+ ...resolvedQuestionRows(input.questions, input.answers, input.decisionLabel).map((row) => `Q: ${row.question}\nA: ${row.answer}`),
182
+ ].filter(Boolean).join("\n\n");
183
+ }
184
+ const subject = input.kind === "plan" ? "Plan prompt resolved" : `${input.toolName} permission resolved`;
185
+ return [subject, `Decision: ${input.decisionLabel}`, input.promptBody.trim()].filter(Boolean).join("\n\n");
186
+ }
187
+
188
+ function resolvedQuestionRows(questions: unknown[] | undefined, answers: Record<string, string> | undefined, fallback: string): Array<{ question: string; answer: string }> {
189
+ const answerMap = answers ?? {};
190
+ const rows = (questions ?? []).map((question, index) => {
191
+ const record = question && typeof question === "object" && !Array.isArray(question) ? question as Record<string, unknown> : {};
192
+ const label = typeof record.question === "string" && record.question.trim()
193
+ ? record.question.trim()
194
+ : typeof record.header === "string" && record.header.trim() ? record.header.trim() : `Question ${index + 1}`;
195
+ const rawAnswer = answerMap[label] ?? answerMap[String(record.question ?? "")] ?? answerMap[String(record.header ?? "")];
196
+ return { question: label, answer: typeof rawAnswer === "string" && rawAnswer.trim() ? rawAnswer.trim() : fallback };
197
+ });
198
+ return rows.length ? rows : Object.entries(answerMap).map(([question, answer]) => ({ question, answer }));
199
+ }
200
+
201
+ function claudePermissionDecisionLabel(kind: string, decision: ProviderPermissionDecisionInput["decision"]): string {
202
+ if (kind === "questions") return decision === "answer" ? "Answered" : "Dismissed";
203
+ if (kind === "plan") return decision === "approve" || decision === "approve-session" ? "Approved plan" : "Kept planning";
204
+ if (decision === "approve-session") return "Approved for session";
205
+ if (decision === "approve") return "Approved";
206
+ if (decision === "abort") return "Aborted";
207
+ if (decision === "answer") return "Answered";
208
+ return "Denied";
209
+ }
@@ -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
+ }