agent-relay-runner 0.88.1 → 0.88.3

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.88.1",
3
+ "version": "0.88.3",
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.88.1",
4
+ "version": "0.88.3",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -25,6 +25,10 @@ export interface ProviderStatusEvent {
25
25
  label?: string;
26
26
  role?: string;
27
27
  parentId?: string;
28
+ // OS pids of the process(es) this busy work tracks (#650). When supplied, the
29
+ // runner reconciles them against actual liveness and clears the busy marker if
30
+ // every pid is dead — recovering from a tracked process killed out-of-band.
31
+ pids?: number[];
28
32
  providerState?: Record<string, unknown>;
29
33
  metadata?: Record<string, unknown>;
30
34
  }
@@ -12,7 +12,7 @@ import type { SessionEvent } from "../session-insights";
12
12
  import { prepareClaudeProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
13
13
  import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
14
14
  import { claudeProviderMessageText } from "./claude-delivery";
15
- import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
15
+ import { buildRateLimitProviderState, gatedClaudeRateLimitStatus, parseClaudeRateLimitPane } from "../rate-limit";
16
16
 
17
17
  export class ClaudeAdapter implements ProviderAdapter {
18
18
  readonly provider = "claude";
@@ -27,6 +27,7 @@ export class ClaudeAdapter implements ProviderAdapter {
27
27
  // #286: true while a usage-limit modal is being held, so the pane watcher dismisses
28
28
  // it + emits the hold once (not every 2s tick). Cleared when the modal leaves the pane.
29
29
  private rateLimitReported = false;
30
+ private rateLimitPaneDetections = 0;
30
31
 
31
32
  onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
32
33
  this.statusCb = cb;
@@ -369,25 +370,24 @@ export class ClaudeAdapter implements ProviderAdapter {
369
370
  this.statusCb(status);
370
371
  return;
371
372
  }
372
- // #286: the subscription usage-limit modal fires no hook, so detect it from the
373
- // pane. Dismiss it (Escape = "wait", which just stops the turn) so the agent
374
- // returns to a clean prompt the resume `continue` can land on, then hold it.
375
- const rateLimit = claudeRateLimitStatus(pane, sessionName);
376
- if (rateLimit) {
377
- if (!this.rateLimitReported) {
378
- this.rateLimitReported = true;
379
- this.dismissRateLimitModal(sessionName, socketName);
380
- this.statusCb(rateLimit);
381
- }
382
- } else if (this.rateLimitReported) {
383
- // Modal gone (dismissed, or a real turn resumed) — re-arm for the next limit.
384
- this.rateLimitReported = false;
373
+ // #286/#655: the subscription usage-limit modal fires no hook, so detect it
374
+ // from the pane. Act only after consecutive non-busy detections; a live turn
375
+ // mentioning quota/reset text must not be escaped or held.
376
+ const rateLimit = gatedClaudeRateLimitStatus(pane, {
377
+ consecutiveDetections: this.rateLimitPaneDetections,
378
+ reported: this.rateLimitReported,
379
+ }, { sessionName, busy: claudePaneIsBusy(pane) });
380
+ this.rateLimitPaneDetections = rateLimit.state.consecutiveDetections;
381
+ this.rateLimitReported = rateLimit.state.reported;
382
+ if (rateLimit.status) {
383
+ this.dismissRateLimitModal(sessionName, socketName);
384
+ this.statusCb(rateLimit.status);
385
385
  }
386
386
  }, 2000);
387
387
  }
388
388
 
389
- // Send Escape to dismiss the usage-limit modal — the "wait" choice, which stops the
390
- // turn (per the CC TUI) and returns the agent to a normal idle prompt. Best-effort:
389
+ // Send Escape to dismiss the confirmed usage-limit modal — the "wait" choice,
390
+ // which returns the agent to a normal idle prompt. Best-effort:
391
391
  // a failed send-keys must never wedge the pane watcher.
392
392
  private dismissRateLimitModal(sessionName: string, socketName?: string): void {
393
393
  try {
@@ -50,6 +50,9 @@ export class CodexAdapter implements ProviderAdapter {
50
50
  // Active turn id for the main thread, captured from turn/started so an interrupt
51
51
  // can target the in-flight turn. Cleared on turn/completed.
52
52
  private activeTurnId?: string;
53
+ // #653: monotonic id for a turn we synthesize from live item activity when a real
54
+ // turn/started never reached us — so a working Codex agent isn't reported `idle`.
55
+ private syntheticTurnSeq = 0;
53
56
  private readonly agentMessageCapture = new CodexAgentMessageCapture();
54
57
  private readonly itemTextBuffers = new Map<string, string>();
55
58
  private readonly itemTextBufferTypes = new Map<string, string>();
@@ -578,8 +581,26 @@ export class CodexAdapter implements ProviderAdapter {
578
581
  return [...this.sessionEvents];
579
582
  }
580
583
 
584
+ // #653: Codex busy-state normally flips on turn/started, but the app-server can stream
585
+ // item activity (which already drives the dashboard chat mirror) before — or without —
586
+ // a turn/started reaching us, leaving a visibly-working agent reported `idle` (a reap
587
+ // hazard). Derive busy from that SAME live activity: any in-flight item event with no
588
+ // active turn means a turn IS running we missed, so synthesize one and report busy.
589
+ // turn/completed → finishMainTurn clears it; we never synthesize on a closing
590
+ // item.completed, so a legitimately ended turn is not re-flipped to busy.
591
+ private ensureMainTurnActive(turnId?: string): void {
592
+ if (this.activeTurnId) return;
593
+ this.activeTurnId = turnId ?? `codex-synth-turn-${++this.syntheticTurnSeq}`;
594
+ this.statusCb({ status: "busy", reason: "provider-turn", id: this.activeTurnId });
595
+ }
596
+
581
597
  private handleCodexItemDelta(method: string, params: Record<string, unknown> | undefined): void {
582
598
  if (!method.includes("item/") && !method.includes("item.")) return;
599
+ // Live item stream (start/delta/progress) = a turn in flight. A closing
600
+ // item.completed is handled by handleCodexItem and must not synthesize a turn.
601
+ if (!method.includes("/completed") && !method.includes(".completed")) {
602
+ this.ensureMainTurnActive(stringValue(params?.turnId));
603
+ }
583
604
  const item = isRecord(params?.item) ? params.item : undefined;
584
605
  const itemId = codexItemId(params) ?? codexItemId(item);
585
606
  const type = codexItemTypeFromMethod(method) ?? stringValue(item?.type);
@@ -17,6 +17,10 @@ interface WorkRecord {
17
17
  role?: string;
18
18
  parentId?: string;
19
19
  metadata?: Record<string, unknown>;
20
+ // OS pids of the underlying process(es) this work tracks (#650). When present,
21
+ // a periodic liveness reconcile clears the record once every pid is dead — so a
22
+ // tracked process killed out-of-band (no exit event) can't strand the agent busy.
23
+ pids?: number[];
20
24
  startedAt: number;
21
25
  }
22
26
 
@@ -40,6 +44,7 @@ export class ClaimTracker {
40
44
  role: details.role ?? existing?.role,
41
45
  parentId: details.parentId ?? existing?.parentId,
42
46
  metadata: details.metadata ?? existing?.metadata,
47
+ pids: details.pids ?? existing?.pids,
43
48
  startedAt: existing?.startedAt ?? Date.now(),
44
49
  });
45
50
  return before !== this.currentStatus();
@@ -106,4 +111,30 @@ export class ClaimTracker {
106
111
  activeWork(): WorkRecord[] {
107
112
  return [...this.work.values()];
108
113
  }
114
+
115
+ /** True iff any active work carries pids the liveness reconcile can check (#650). */
116
+ hasLivenessTrackedWork(): boolean {
117
+ for (const item of this.work.values()) {
118
+ if (item.pids && item.pids.length > 0) return true;
119
+ }
120
+ return false;
121
+ }
122
+
123
+ /**
124
+ * Reconcile pid-bearing work against actual process liveness (#650): drop any
125
+ * record whose tracked pids are ALL dead — its underlying process died out-of-band
126
+ * (killed/crashed/orphaned) with no exit event to clear the busy marker. Records
127
+ * with no pids are never touched (no liveness signal to act on). Returns the removed
128
+ * records so the caller can emit the matching completion/abort + republish status.
129
+ */
130
+ reconcileLiveness(isAlive: (pid: number) => boolean): WorkRecord[] {
131
+ const removed: WorkRecord[] = [];
132
+ for (const [key, item] of this.work) {
133
+ if (!item.pids || item.pids.length === 0) continue;
134
+ if (item.pids.some(isAlive)) continue;
135
+ this.work.delete(key);
136
+ removed.push(item);
137
+ }
138
+ return removed;
139
+ }
109
140
  }
@@ -446,6 +446,12 @@ async function handleStatus(req: Request, options: ControlServerOptions): Promis
446
446
  const isWorkKind = (item: unknown): item is "provider-turn" | "subagent" | "background-script" =>
447
447
  item === "provider-turn" || item === "subagent" || item === "background-script";
448
448
  const clear = Array.isArray(body?.clear) ? body.clear.filter(isWorkKind) : undefined;
449
+ // #650: optional pids the runner reconciles against process liveness so a tracked
450
+ // command/shell killed out-of-band clears the busy marker instead of stranding it.
451
+ const pidsInput = Array.isArray((body as { pids?: unknown })?.pids) ? (body as { pids: unknown[] }).pids : undefined;
452
+ const pids = pidsInput
453
+ ?.map((p) => (typeof p === "number" ? p : Number(p)))
454
+ .filter((p) => Number.isInteger(p) && p > 0);
449
455
  const timeline = statusTimelineEvent(body);
450
456
  const update: ProviderStatusEvent = {
451
457
  status,
@@ -454,6 +460,7 @@ async function handleStatus(req: Request, options: ControlServerOptions): Promis
454
460
  ...(clear?.length ? { clear: [...new Set(clear)] } : {}),
455
461
  ...(timeline ? { timeline } : {}),
456
462
  ...(typeof body?.id === "string" ? { id: body.id } : {}),
463
+ ...(pids?.length ? { pids } : {}),
457
464
  ...(typeof body?.label === "string" ? { label: body.label } : {}),
458
465
  ...(typeof body?.role === "string" ? { role: body.role } : {}),
459
466
  ...(typeof body?.parentId === "string" ? { parentId: body.parentId } : {}),
package/src/rate-limit.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  // #286 — shared rate-limit hold construction + Claude pane detection.
2
+ import type { ProviderStatusUpdate } from "./adapter";
2
3
  //
3
4
  // Two detection arms feed the SAME provider-neutral `blocked` hold:
4
5
  // 1. The StopFailure hook (clean API-error turn-end: API-429/auth/billing) → control-server.
@@ -94,6 +95,37 @@ export function parseClaudeRateLimitPane(text: string, nowMs: number = Date.now(
94
95
  return null;
95
96
  }
96
97
 
98
+ export interface ClaudeRateLimitPaneGateState {
99
+ consecutiveDetections: number;
100
+ reported: boolean;
101
+ }
102
+
103
+ export function gatedClaudeRateLimitStatus(
104
+ text: string,
105
+ state: ClaudeRateLimitPaneGateState,
106
+ options: { sessionName?: string; busy?: boolean } = {},
107
+ ): { status: ProviderStatusUpdate | null; state: ClaudeRateLimitPaneGateState } {
108
+ const parsed = parseClaudeRateLimitPane(text);
109
+ if (!parsed) return { status: null, state: { consecutiveDetections: 0, reported: false } };
110
+ if (options.busy) return { status: null, state: { consecutiveDetections: 0, reported: state.reported } };
111
+ const nextState = { consecutiveDetections: state.consecutiveDetections + 1, reported: state.reported };
112
+ if (nextState.reported || nextState.consecutiveDetections < 2) return { status: null, state: nextState };
113
+ nextState.reported = true;
114
+ return {
115
+ status: {
116
+ status: "idle",
117
+ clear: ["subagent"],
118
+ providerState: buildRateLimitProviderState({
119
+ errorType: "session_limit",
120
+ ...(parsed.resetAt ? { resetAt: parsed.resetAt } : {}),
121
+ message: parsed.message,
122
+ source: options.sessionName ? `claude-pane:${options.sessionName}` : "claude-pane",
123
+ }),
124
+ },
125
+ state: nextState,
126
+ };
127
+ }
128
+
97
129
  // The clean limit line containing the match offset, stripped of box-drawing chrome.
98
130
  function limitLineAt(text: string, index: number): string {
99
131
  const start = text.lastIndexOf("\n", index) + 1;
@@ -5,6 +5,7 @@ import { dirname, join } from "node:path";
5
5
  import type { AgentLifecycle, AgentProfile, ContextState, Message, MessageSessionMeta, SendMessageInput, TaskStatusInput, WorkspaceMetadata } from "agent-relay-sdk";
6
6
  import { errMessage, RelayBusClient, RelayHttpClient } from "agent-relay-sdk";
7
7
  import { contextStateFromProbeMetrics, quotaStateFromProbeMetrics } from "agent-relay-sdk/context-probe";
8
+ import { isPidAlive } from "agent-relay-sdk/process-utils";
8
9
  import { providerAttachmentText, providerMessageText } from "./adapter";
9
10
  import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissionDecision, ProviderPermissionDecisionInput, ProviderSessionEvent, ProviderStatusUpdate, RunnerSpawnConfig, SemanticStatus, TerminalAttachSpec } from "./adapter";
10
11
  import { messagesWithCachedAttachments } from "./attachment-cache";
@@ -109,6 +110,8 @@ interface ActiveTaskClaim {
109
110
 
110
111
  const CLAIM_RENEW_INTERVAL_MS = 5 * 60 * 1000;
111
112
  const HTTP_LIVENESS_INTERVAL_MS = 20_000;
113
+ // #650: how often to reconcile pid-bearing busy work against actual process liveness.
114
+ const BUSY_LIVENESS_RECONCILE_MS = 5_000;
112
115
  const HTTP_LIVENESS_LOG_INTERVAL_MS = 5 * 60 * 1000;
113
116
  const TOKEN_RENEW_RETRY_MS = 60_000;
114
117
  // Debounce reactive token recovery so a burst of 401-ing calls in the same window
@@ -204,6 +207,7 @@ export class AgentRunner {
204
207
  private drainTimer?: Timer;
205
208
  private claimRenewTimer?: Timer;
206
209
  private httpLivenessTimer?: Timer;
210
+ private livenessReconcileTimer?: Timer;
207
211
  private httpLivenessInFlight = false;
208
212
  private httpLivenessAuthFailed = false;
209
213
  private httpLivenessLastLog?: { key: string; at: number };
@@ -264,6 +268,7 @@ export class AgentRunner {
264
268
  // providerState; it clears when a new turn starts (the relay's resume message
265
269
  // wakes one) so the blocked badge lifts on its own.
266
270
  private rateLimitHold?: Record<string, unknown>;
271
+ private pendingRateLimitHold?: Record<string, unknown>;
267
272
  // Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
268
273
  // from the Claude transcript into chat as discreet session events.
269
274
  private reasoningTail?: ReasoningTailState;
@@ -471,6 +476,7 @@ export class AgentRunner {
471
476
  this.httpLivenessTimer = undefined;
472
477
  if (this.tokenRenewTimer) clearTimeout(this.tokenRenewTimer);
473
478
  this.tokenRenewTimer = undefined;
479
+ this.disarmBusyLivenessReconcile();
474
480
  this.busyReconciler.disarm();
475
481
  this.stopReasoningTail();
476
482
  this.obligationCache.stop();
@@ -1150,16 +1156,28 @@ export class AgentRunner {
1150
1156
  ...(update.timeline.metadata ? { metadata: update.timeline.metadata } : {}),
1151
1157
  };
1152
1158
  }
1159
+ let deferredRateLimitHold = false;
1153
1160
  if (typeof update !== "string" && update.providerState) {
1154
- const ps = update.providerState as { state?: unknown; reason?: unknown };
1155
- this.providerBlocked = ps.state === "blocked";
1161
+ const ps = update.providerState as { state?: unknown; reason?: unknown; source?: unknown };
1156
1162
  // A rate-limit hold persists across the idle the turn ends on; any other
1157
1163
  // providerState supersedes it (clears a stale hold).
1158
1164
  if (ps.state === "blocked" && ps.reason === "rate_limit") {
1159
- const fresh = !this.rateLimitHold;
1160
- this.rateLimitHold = update.providerState;
1161
- if (fresh) this.publishRateLimitNotice(update.providerState);
1165
+ const paneRateLimitHold = typeof ps.source === "string" && ps.source.startsWith("claude-pane");
1166
+ if (status === "idle" && reason === "provider-turn" && paneRateLimitHold && this.claims.activeWork().some((work) => work.kind === "provider-turn")) {
1167
+ this.pendingRateLimitHold = update.providerState;
1168
+ this.providerBlocked = false;
1169
+ deferredRateLimitHold = true;
1170
+ this.sessionLog(`rate-limit hold deferred until active turn finishes (turn ${this.currentTurnId ?? "?"})`);
1171
+ } else {
1172
+ this.providerBlocked = true;
1173
+ this.pendingRateLimitHold = undefined;
1174
+ const fresh = !this.rateLimitHold;
1175
+ this.rateLimitHold = update.providerState;
1176
+ if (fresh) this.publishRateLimitNotice(update.providerState);
1177
+ }
1162
1178
  } else {
1179
+ this.providerBlocked = ps.state === "blocked";
1180
+ this.pendingRateLimitHold = undefined;
1163
1181
  this.rateLimitHold = undefined;
1164
1182
  }
1165
1183
  } else if (status === "idle") {
@@ -1167,6 +1185,13 @@ export class AgentRunner {
1167
1185
  }
1168
1186
  // Forward progress (a real turn) lifts the hold so the blocked badge clears.
1169
1187
  if (status === "busy") this.rateLimitHold = undefined;
1188
+ if (deferredRateLimitHold) {
1189
+ if (typeof update !== "string") {
1190
+ for (const kind of update.clear ?? []) this.claims.clearWorkKind(kind);
1191
+ }
1192
+ this.publishStatus();
1193
+ return;
1194
+ }
1170
1195
  if (typeof update !== "string" && status === "error") {
1171
1196
  const terminalReason = typeof update.metadata?.terminalFailureReason === "string"
1172
1197
  ? update.metadata.terminalFailureReason
@@ -1227,15 +1252,30 @@ export class AgentRunner {
1227
1252
  if (status === "busy" || status === "idle") this.terminalProviderExit = undefined;
1228
1253
  if (status === "busy") {
1229
1254
  this.claims.clearTerminalStatus();
1230
- this.claims.startWork(reason, id, typeof update === "string" ? {} : {
1255
+ // #650: liveness-track the pids a reporter explicitly supplied; otherwise, for a
1256
+ // provider-turn, fall back to the managed provider process itself. If that process
1257
+ // dies out-of-band with no exit event reaching the runner (crash/orphan/external
1258
+ // kill), the reconcile clears the stuck turn → idle. A dead pid means the turn is
1259
+ // definitively over, so this can never falsely clear a live turn (#653's hazard).
1260
+ const reportedPids = typeof update === "string" ? undefined : update.pids;
1261
+ const livenessPids = reportedPids?.length
1262
+ ? reportedPids
1263
+ : reason === "provider-turn" && typeof this.process?.pid === "number" && this.process.pid > 0
1264
+ ? [this.process.pid]
1265
+ : undefined;
1266
+ this.claims.startWork(reason, id, typeof update === "string" ? (livenessPids ? { pids: livenessPids } : {}) : {
1231
1267
  label: update.label,
1232
1268
  role: update.role,
1233
1269
  parentId: update.parentId,
1270
+ ...(livenessPids ? { pids: livenessPids } : {}),
1234
1271
  metadata: {
1235
1272
  ...(update.metadata ?? {}),
1236
1273
  ...(update.providerState ? { providerState: update.providerState } : {}),
1237
1274
  },
1238
1275
  });
1276
+ // A busy report with liveness-trackable pids arms the periodic reconcile (self-disarms
1277
+ // once no pid-bearing work remains, so idle/untracked agents pay nothing).
1278
+ if (livenessPids?.length) this.armBusyLivenessReconcile();
1239
1279
  if (reason === "provider-turn") {
1240
1280
  for (const claim of this.activeTaskClaims.values()) claim.observedProviderBusy = true;
1241
1281
  }
@@ -1253,6 +1293,14 @@ export class AgentRunner {
1253
1293
  if (status === "idle" && this.pendingInitialPrompt && !this.deliveringInitialPrompt) {
1254
1294
  void this.attemptInitialPromptDelivery(this.pendingInitialPrompt, INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS);
1255
1295
  }
1296
+ if (status === "idle" && this.pendingRateLimitHold && !this.claims.activeWork().some((work) => work.kind === "provider-turn")) {
1297
+ const hold = this.pendingRateLimitHold;
1298
+ this.pendingRateLimitHold = undefined;
1299
+ this.providerBlocked = true;
1300
+ const fresh = !this.rateLimitHold;
1301
+ this.rateLimitHold = hold;
1302
+ if (fresh) this.publishRateLimitNotice(hold);
1303
+ }
1256
1304
  this.publishStatus();
1257
1305
  }
1258
1306
 
@@ -1662,6 +1710,44 @@ export class AgentRunner {
1662
1710
  this.publishStatus();
1663
1711
  }
1664
1712
 
1713
+ // #650: reconcile pid-bearing busy work against actual process liveness. A tracked
1714
+ // command/shell that dies out-of-band (killed by the operator, crashes, orphaned)
1715
+ // sends no exit event, so its busy marker would otherwise never clear and the agent
1716
+ // shows `busy` forever — blocking transient auto-reap and (for Claude) holding quota.
1717
+ // The timer self-disarms once no pid-bearing work remains; it is armed lazily when a
1718
+ // busy report carrying pids arrives, so idle/non-tracked agents pay nothing.
1719
+ private armBusyLivenessReconcile(): void {
1720
+ if (this.livenessReconcileTimer || this.stopped) return;
1721
+ this.livenessReconcileTimer = setInterval(() => this.runBusyLivenessReconcile(), BUSY_LIVENESS_RECONCILE_MS);
1722
+ }
1723
+
1724
+ private disarmBusyLivenessReconcile(): void {
1725
+ if (this.livenessReconcileTimer) clearInterval(this.livenessReconcileTimer);
1726
+ this.livenessReconcileTimer = undefined;
1727
+ }
1728
+
1729
+ private runBusyLivenessReconcile(): void {
1730
+ if (this.stopped) {
1731
+ this.disarmBusyLivenessReconcile();
1732
+ return;
1733
+ }
1734
+ const removed = this.claims.reconcileLiveness(isPidAlive);
1735
+ if (removed.length > 0) {
1736
+ this.sessionLog(`cleared ${removed.length} stuck busy work item(s) whose tracked process died out-of-band: ${removed.map((w) => `${w.kind}:${w.id}`).join(", ")}`);
1737
+ // A cleared provider-turn must also reset the turn bookkeeping so a later idle
1738
+ // doesn't double-end it and the reasoning tail stops.
1739
+ if (removed.some((w) => w.kind === "provider-turn")) {
1740
+ this.currentTurnId = undefined;
1741
+ this.currentTurnStartedAt = undefined;
1742
+ this.compactionMidTurn = false;
1743
+ this.busyReconciler.disarm();
1744
+ this.stopReasoningTail();
1745
+ }
1746
+ this.publishStatus();
1747
+ }
1748
+ if (!this.claims.hasLivenessTrackedWork()) this.disarmBusyLivenessReconcile();
1749
+ }
1750
+
1665
1751
  // --- Turn-step tailer (item 5) ------------------------------------------------------
1666
1752
  // Tail the in-flight turn's Claude transcript and surface new narration/reasoning/tool
1667
1753
  // steps as session events, in transcript order. `narration` (the agent's intermediate