@rynx-ai/runtime 0.1.11-beta.4 → 0.1.11-beta.41

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.
Files changed (65) hide show
  1. package/dist/claude/executor.d.ts +19 -5
  2. package/dist/claude/executor.js +56 -12
  3. package/dist/claude/models.d.ts +0 -5
  4. package/dist/claude/models.js +1 -7
  5. package/dist/claude/native-bridge.d.ts +103 -1
  6. package/dist/claude/native-bridge.js +445 -30
  7. package/dist/claude/native-hook-main.js +81 -1
  8. package/dist/claude/native-hooks.js +7 -0
  9. package/dist/claude/native-integration.d.ts +178 -26
  10. package/dist/claude/native-integration.js +1528 -170
  11. package/dist/claude/session-status.d.ts +39 -0
  12. package/dist/claude/session-status.js +163 -0
  13. package/dist/claude/transcript-clone.d.ts +18 -0
  14. package/dist/claude/transcript-clone.js +497 -0
  15. package/dist/claude/transcript.d.ts +27 -4
  16. package/dist/claude/transcript.js +158 -47
  17. package/dist/codex-app-server/client.d.ts +10 -6
  18. package/dist/codex-app-server/client.js +67 -15
  19. package/dist/codex-app-server/forwarder.d.ts +92 -3
  20. package/dist/codex-app-server/forwarder.js +532 -57
  21. package/dist/codex-app-server/mapping.d.ts +3 -6
  22. package/dist/codex-app-server/mapping.js +206 -36
  23. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  24. package/dist/codex-app-server/mcp-startup.js +63 -0
  25. package/dist/codex-app-server/process-registry.d.ts +36 -0
  26. package/dist/codex-app-server/process-registry.js +320 -0
  27. package/dist/codex-app-server/protocol.d.ts +64 -7
  28. package/dist/codex-app-server/ws-channel.d.ts +7 -0
  29. package/dist/codex-app-server/ws-channel.js +104 -28
  30. package/dist/codex-home.d.ts +35 -3
  31. package/dist/codex-home.js +323 -18
  32. package/dist/codex-session-store.d.ts +23 -0
  33. package/dist/codex-session-store.js +21 -0
  34. package/dist/host.d.ts +103 -46
  35. package/dist/host.js +1988 -634
  36. package/dist/index.d.ts +3 -3
  37. package/dist/index.js +1 -1
  38. package/dist/input-resources.d.ts +4 -0
  39. package/dist/input-resources.js +21 -5
  40. package/dist/models-catalog.d.ts +2 -1
  41. package/dist/models-catalog.js +94 -6
  42. package/dist/runner/child.d.ts +97 -28
  43. package/dist/runner/child.js +1486 -100
  44. package/dist/runner/manager.d.ts +110 -29
  45. package/dist/runner/manager.js +1481 -246
  46. package/dist/runner/protocol.d.ts +212 -24
  47. package/dist/runner/protocol.js +5 -0
  48. package/dist/runner/startup-policy.d.ts +7 -0
  49. package/dist/runner/startup-policy.js +10 -0
  50. package/dist/runner/transport.d.ts +18 -2
  51. package/dist/runner/transport.js +82 -3
  52. package/dist/runner-main.js +8 -3
  53. package/dist/terminal/claude-tui.d.ts +3 -1
  54. package/dist/terminal/claude-tui.js +3 -1
  55. package/dist/terminal/codex-tui.d.ts +4 -0
  56. package/dist/terminal/codex-tui.js +5 -0
  57. package/dist/terminal/control-parser.d.ts +39 -0
  58. package/dist/terminal/control-parser.js +172 -0
  59. package/dist/terminal/registry.d.ts +18 -15
  60. package/dist/terminal/registry.js +44 -23
  61. package/dist/terminal/spool.d.ts +47 -0
  62. package/dist/terminal/spool.js +231 -0
  63. package/dist/terminal/tmux.d.ts +126 -74
  64. package/dist/terminal/tmux.js +807 -211
  65. package/package.json +4 -4
@@ -5,11 +5,12 @@
5
5
  * the Claude Code TUI feeds through its hooks + transcript:
6
6
  *
7
7
  * - `hooks.jsonl` (bridge) — `SessionStart` reveals the transcript path + claude
8
- * session id (discovery); `Stop`/`StopFailure` CLOSE the turn (authoritative).
8
+ * session id; `StopFailure` supplies the failure edge and `Stop` carries the
9
+ * legacy fallback boundary.
9
10
  * - the transcript JSONL — the turn OPENS on a `role:user` prompt record and its
10
- * assistant/tool records become {@link AgentEvent}s. (Turn framing is
11
- * transcript-open + hook-close, matching the reference implementation re-audit; `running` is
12
- * implicit between open and close, so no PTY watcher is needed.)
11
+ * assistant/tool records become {@link AgentEvent}s.
12
+ * - Claude's per-process session file while readable, it is the source of
13
+ * truth for running/idle and supersedes the hook/pane heuristics.
13
14
  *
14
15
  * The mapped events drive the SAME per-turn {@link SessionNormalizer} sink shape
15
16
  * the codex forwarder uses (see the host), so mirroring is identical downstream.
@@ -17,15 +18,29 @@
17
18
  * backstop — but only when no tool call is still open, so a long Bash never trips
18
19
  * a false turn-end.
19
20
  */
20
- import { statSync } from "node:fs";
21
- import { parseTerminalCommand, parseTranscriptRecord, readSubagentEvents, subagentTranscriptPath, transcriptHasForkedFrom, } from "./transcript.js";
22
- import { jsonlCursorFingerprint, interactionLeaseUpdatedAt, readClaimedInteractionResult, readClaudeStatus, readForwardState, readInteractionAcksFrom, readHookEventsFrom, readInteractionRequestsFrom, readJsonlFrom, readMessageDeltasFrom, resetForwardState, removeClaimedInteractionResult, removeInteractionLease, removeInteractionResult, scrubClaudeInteractionArtifacts, writeInteractionResult, writeForwardState, } from "./native-bridge.js";
21
+ import { readdirSync, readFileSync, statSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { parseTerminalCommandFragments, parseTranscriptRecord, subagentTranscriptPath, transcriptHasForkedFrom, transcriptHasRecentLocalCommand, } from "./transcript.js";
24
+ import { HOOKS_FILE, jsonlCursorFingerprint, interactionLeaseUpdatedAt, readClaimedInteractionResult, readClaudeState, readClaudeStatus, readCompactionForwardState, readDeltaForwardState, readForwardState, readHookForwardState, readInteractionAcksFrom, readHookEventsFrom, readInteractionRequestsFrom, readJsonlEntriesFrom, readMessageDeltasFrom, readSubagentForwardStates, readSubagentParentResponses, resetForwardState, resetSubagentForwardStates, removeClaimedInteractionResult, removeInteractionLease, removeInteractionResult, scrubClaudeInteractionArtifacts, writeInteractionResult, writeForwardState, writeDeltaForwardState, writeCompactionForwardState, writeHookForwardState, writeSubagentForwardStates, } from "./native-bridge.js";
23
25
  import { boundInteractionRequest, redactInteractionResolution, validateInteractionResolution, } from "../interactions.js";
26
+ import { ClaudeSessionStatusPoller, } from "./session-status.js";
27
+ function cloneTerminalCommand(command) {
28
+ return command
29
+ ? { command: command.command, ...(command.turnId ? { turnId: command.turnId } : {}) }
30
+ : undefined;
31
+ }
24
32
  const MAX_SETTLED_INTERACTIONS = 512;
25
33
  const MAX_MESSAGE_CORRELATION_BACKLOG = 64;
26
34
  const MAX_SUBMISSION_OBSERVATIONS = 64;
27
35
  const INTERACTION_ACK_TIMEOUT_MS = 5_000;
36
+ // Claude writes this synthetic user record after Escape (including a tool-use
37
+ // interruption). It is lifecycle, not a new prompt. Kept aligned with
38
+ // reference implementation's `_CLAUDE_INTERRUPT_RECORD_RE`.
39
+ const CLAUDE_INTERRUPT_RECORD_RE = /^\[Request interrupted by user(?: for tool use)?\]$/;
28
40
  const INTERACTION_LEASE_TIMEOUT_MS = 30_000;
41
+ const FORWARDER_RESTART_INITIAL_MS = 1_000;
42
+ const FORWARDER_RESTART_MAX_MS = 30_000;
43
+ const FORWARDER_HEALTHY_UPTIME_MS = 60_000;
29
44
  function processIsAlive(pid) {
30
45
  try {
31
46
  process.kill(pid, 0);
@@ -47,6 +62,76 @@ function userStringContent(rec) {
47
62
  return undefined; // tool_result records are arrays
48
63
  return content.trim() || undefined;
49
64
  }
65
+ function queuedCommandPrompt(rec) {
66
+ if (rec.type !== "attachment" || !isRecord(rec.attachment))
67
+ return undefined;
68
+ if (rec.attachment.type !== "queued_command" ||
69
+ rec.attachment.commandMode !== "prompt")
70
+ return undefined;
71
+ return typeof rec.attachment.prompt === "string" && rec.attachment.prompt
72
+ ? rec.attachment.prompt
73
+ : undefined;
74
+ }
75
+ function topLevelLocalCommand(rec) {
76
+ if (rec.subtype !== "local_command" || typeof rec.content !== "string")
77
+ return undefined;
78
+ return parseTerminalCommandFragments(rec.content);
79
+ }
80
+ function transcriptLocalCommand(rec) {
81
+ const stringContent = userStringContent(rec);
82
+ return topLevelLocalCommand(rec) ??
83
+ (stringContent === undefined ? undefined : parseTerminalCommandFragments(stringContent));
84
+ }
85
+ function terminalCommandData(fragments, priorCommand) {
86
+ return {
87
+ command: fragments.command ?? priorCommand ?? "",
88
+ ...(fragments.stdout !== undefined ? { stdout: fragments.stdout } : {}),
89
+ ...(fragments.stderr !== undefined ? { stderr: fragments.stderr } : {}),
90
+ };
91
+ }
92
+ const CLI_SCAFFOLDING_MARKERS = [
93
+ "<command-message>",
94
+ "<command-args>",
95
+ "<local-command-caveat>",
96
+ "<local-command-stdout>",
97
+ "<local-command-stderr>",
98
+ ];
99
+ function isTaskNotificationText(text) {
100
+ const stripped = text.trimStart();
101
+ return stripped.startsWith("<task-notification>") &&
102
+ stripped.includes("<task-id>") &&
103
+ stripped.includes("</task-notification>");
104
+ }
105
+ function isClaudeUserScaffolding(text) {
106
+ const stripped = text.trimStart();
107
+ return stripped.includes("<command-name>") ||
108
+ CLI_SCAFFOLDING_MARKERS.some((marker) => stripped.startsWith(marker));
109
+ }
110
+ function userListTextMessages(rec) {
111
+ if (rec.type !== "user" || rec.message?.role !== "user")
112
+ return [];
113
+ const content = rec.message.content;
114
+ if (!Array.isArray(content))
115
+ return [];
116
+ const userTexts = [];
117
+ const messages = [];
118
+ for (const block of content) {
119
+ if (!isRecord(block) || block.type !== "text" || typeof block.text !== "string")
120
+ continue;
121
+ if (!block.text || isClaudeUserScaffolding(block.text))
122
+ continue;
123
+ if (isTaskNotificationText(block.text)) {
124
+ messages.push({ text: block.text, isMeta: true });
125
+ }
126
+ else {
127
+ userTexts.push(block.text);
128
+ }
129
+ }
130
+ if (userTexts.length > 0) {
131
+ messages.unshift({ text: userTexts.join(""), isMeta: false });
132
+ }
133
+ return messages;
134
+ }
50
135
  function submissionText(value) {
51
136
  if (typeof value !== "string")
52
137
  return undefined;
@@ -74,15 +159,18 @@ function isRecord(value) {
74
159
  function readId(value) {
75
160
  return isRecord(value) && typeof value.id === "string" && value.id ? value.id : undefined;
76
161
  }
77
- /** Byte size of a file, or 0 if it can't be stat'd (used to start a fork tail at EOF). */
78
- function fileSize(path) {
162
+ function fileByteSize(path) {
79
163
  try {
80
164
  return statSync(path).size;
81
165
  }
82
166
  catch {
83
- return 0;
167
+ return undefined;
84
168
  }
85
169
  }
170
+ /** Byte size of a file, or 0 if it can't be stat'd (used to start a fork tail at EOF). */
171
+ function fileSize(path) {
172
+ return fileByteSize(path) ?? 0;
173
+ }
86
174
  function readStr(value) {
87
175
  if (typeof value === "string" && value)
88
176
  return value;
@@ -120,17 +208,33 @@ function readTaskUpdates(rec) {
120
208
  function normalizeTodoStatus(status) {
121
209
  return status === "pending" || status === "in_progress" || status === "completed" ? status : undefined;
122
210
  }
123
- /** The `tool_use_id` of a record's tool_result block (the parent call it answers). */
124
- function toolResultCallId(rec) {
125
- const content = rec.message?.content;
126
- if (!Array.isArray(content))
211
+ function readNativeSubagentMeta(path) {
212
+ let value;
213
+ try {
214
+ value = JSON.parse(readFileSync(path, "utf8"));
215
+ }
216
+ catch {
127
217
  return undefined;
128
- for (const block of content) {
129
- if (isRecord(block) && block.type === "tool_result" && typeof block.tool_use_id === "string") {
130
- return block.tool_use_id;
131
- }
132
218
  }
133
- return undefined;
219
+ if (!isRecord(value))
220
+ return undefined;
221
+ const agentType = typeof value.agentType === "string" && value.agentType
222
+ ? value.agentType
223
+ : undefined;
224
+ const description = typeof value.description === "string" ? value.description : undefined;
225
+ const toolUseId = typeof value.toolUseId === "string" && value.toolUseId
226
+ ? value.toolUseId
227
+ : undefined;
228
+ return agentType && description !== undefined && toolUseId
229
+ ? { agentType, description, toolUseId }
230
+ : undefined;
231
+ }
232
+ function isSubagentTranscriptPath(path) {
233
+ return Boolean(path && /(?:^|[\\/])subagents(?:[\\/]|$)/u.test(path));
234
+ }
235
+ function transcriptSourceBase(rec, byteOffset, lineNumber) {
236
+ const stableId = rec.uuid || rec.requestId;
237
+ return stableId || `byte-${byteOffset}` || `line-${lineNumber}`;
134
238
  }
135
239
  export class ClaudeLiveSession {
136
240
  bridgeDir;
@@ -143,10 +247,28 @@ export class ClaudeLiveSession {
143
247
  leaseUpdatedAt;
144
248
  started = false;
145
249
  stopped = false;
250
+ supervisorTask;
251
+ releaseSleep;
146
252
  hooksOffset = 0;
253
+ hookEventCursor = 0;
254
+ /** Rotation publication is a checkpoint barrier. No source is polled under
255
+ * either logical Session until the daemon confirms the new binding. */
256
+ rotationPending = false;
257
+ /** A non-rotation async hook (currently compaction completion) holds only the
258
+ * hook cursor and parent transcript. Native sub-agents, deltas, interactions,
259
+ * and live status continue to converge while its delivery retries. */
260
+ compactionHookPending = false;
261
+ pendingCompactionHook;
147
262
  interactionsOffset = 0;
148
263
  interactionAcksOffset = 0;
149
264
  transcriptOffset = 0;
265
+ committedTranscriptOffset = 0;
266
+ transcriptLineCursor = 0;
267
+ committedTranscriptLineCursor = 0;
268
+ transcriptDeliveryPending = false;
269
+ /** A transcript isCompactSummary boundary is currently in its durable lane.
270
+ * The completion-hook fallback must wait so the real summary remains primary. */
271
+ transcriptCompactionPending = false;
150
272
  /** Secondary dedup: source ids (record uuids) already forwarded, so a re-read
151
273
  * (fingerprint reset / mid-poll death) doesn't re-emit. Persisted in the
152
274
  * forwarder state; bounded there. */
@@ -158,17 +280,40 @@ export class ClaudeLiveSession {
158
280
  /** Persisted native id requested through `claude --resume`. */
159
281
  expectedClaudeSessionId;
160
282
  resumeAtEndOnDiscovery;
283
+ initialTranscriptOffsetOnDiscovery;
161
284
  /** Every claude session uuid seen — a resume into an UNSEEN id + a forkedFrom
162
285
  * marker signals a `/fork` (vs. resuming a known branch). */
163
286
  seenClaudeSessionIds = new Set();
164
287
  turnOpen = false;
165
288
  currentTurnId;
289
+ /** Input half of a split Claude `!cmd`, plus the turn id its later output
290
+ * must reuse. Kept across a terminal Stop edge like the current terminal
291
+ * response id. */
292
+ activeTerminalCommand;
293
+ /** Split-command state at the latest individually handled source boundary.
294
+ * Parsing may run ahead through later records, so durable state must not use
295
+ * the mutable live value until those later items are ACKed too. */
296
+ committedTerminalCommand;
297
+ /** The open turn received an explicit Escape/Stop and must close cancelled. */
298
+ turnInterrupted = false;
166
299
  /** The Turn opened from a pre-transcript interaction. Its unique provisional
167
300
  * id keeps responses distinct until the real prompt uuid can be adopted. */
168
301
  syntheticTurn = false;
169
302
  lastActivityAt = 0;
170
303
  /** When the Stop hook fired (null = not yet) — drives the grace-period close. */
171
304
  stopPendingAt = null;
305
+ /** Authoritative live background-shell count from the pending Stop hook. */
306
+ stopBackgroundTaskCount;
307
+ /** Whether the pending Stop count already rode an immediate idle edge. */
308
+ stopBackgroundTaskCountDelivered = false;
309
+ /** An idle status from Claude's session metadata. The short delay keeps a
310
+ * final transcript record in the response without letting stale tool state
311
+ * override the provider's terminal status. */
312
+ providerIdleAt = null;
313
+ statusPoller;
314
+ /** A failed hook is authoritative until the next running edge. Claude writes
315
+ * idle after failures too, so that trailing file update must not erase it. */
316
+ providerFailureSticky = false;
172
317
  /** Open tool-call ids (start seen, end not yet) — suppresses the idle backstop. */
173
318
  openToolIds = new Set();
174
319
  /** Transcript-backed acknowledgements for web→TUI input. A direct idle
@@ -197,8 +342,17 @@ export class ClaudeLiveSession {
197
342
  * append its request immediately before the failure hook lands; closing here
198
343
  * would let the later interaction poll reopen an already-failed Turn. */
199
344
  turnFailurePending = null;
200
- /** Sub-agent (Task) ids already forwarded a Task tool_result is processed once. */
201
- seenSubagents = new Set();
345
+ /** Stop/StopFailure owns the hook cursor until its terminal status reaches a
346
+ * handled delivery outcome. This preserves replay on runner restart. */
347
+ pendingTerminalHook;
348
+ terminalHookDelivery;
349
+ pendingTranscriptCompactions = [];
350
+ /** Claude Code native Task/Agent sidechains. Each child owns an independent
351
+ * cursor/retry lane so one unavailable delivery cannot block its siblings or
352
+ * the parent transcript. */
353
+ nativeSubagents = new Map();
354
+ nativeSubagentParentResponses = new Map();
355
+ subagentStateParentPath;
202
356
  /** The agent's task list, keyed by task id in creation order (claude
203
357
  * `TaskCreate`/`TaskUpdate`). Emitted whole as a snapshot on any change. */
204
358
  todos = new Map();
@@ -206,6 +360,7 @@ export class ClaudeLiveSession {
206
360
  * a turn's usage on close. undefined until the statusLine hook first fires. */
207
361
  latestStatus;
208
362
  deltasOffset = 0;
363
+ seenDeltaKeys = new Set();
209
364
  /** Finalized MessageDisplay ids in completion order, FIFO-mapped onto the
210
365
  * transcript's assistant-text records so a streamed message and its final
211
366
  * item share an itemId (message_id is absent from the transcript). */
@@ -228,7 +383,20 @@ export class ClaudeLiveSession {
228
383
  ((interactionId) => interactionLeaseUpdatedAt(this.bridgeDir, interactionId));
229
384
  this.expectedClaudeSessionId = opts.claudeSessionId;
230
385
  this.resumeAtEndOnDiscovery = opts.resumeAtEndOnDiscovery ?? false;
386
+ this.initialTranscriptOffsetOnDiscovery =
387
+ opts.initialTranscriptOffsetOnDiscovery === undefined
388
+ ? undefined
389
+ : Math.max(0, Math.trunc(opts.initialTranscriptOffsetOnDiscovery));
231
390
  this.transcriptPath = opts.transcriptPath;
391
+ for (const sessionId of readClaudeState(this.bridgeDir).seenClaudeSessionIds ?? []) {
392
+ this.seenClaudeSessionIds.add(sessionId);
393
+ }
394
+ this.deltasOffset = readDeltaForwardState(this.bridgeDir).byteOffset;
395
+ const hookState = readHookForwardState(this.bridgeDir);
396
+ if (hookState) {
397
+ this.hooksOffset = hookState.byteOffset;
398
+ this.hookEventCursor = hookState.eventCursor;
399
+ }
232
400
  // Resume path: bind the session id + restore the persisted forwarder cursor so
233
401
  // we continue from where a prior forwarder left off (no re-mirror on relaunch).
234
402
  if (opts.transcriptPath) {
@@ -237,6 +405,7 @@ export class ClaudeLiveSession {
237
405
  this.seenClaudeSessionIds.add(opts.claudeSessionId);
238
406
  }
239
407
  this.restoreForwardState(opts.transcriptPath);
408
+ this.restoreSubagentForwardStates(opts.transcriptPath);
240
409
  }
241
410
  }
242
411
  /**
@@ -253,23 +422,59 @@ export class ClaudeLiveSession {
253
422
  return false;
254
423
  for (const id of state.seenSourceIds)
255
424
  this.seenSourceIds.add(id);
425
+ const transcriptSize = fileByteSize(transcriptPath);
426
+ const transcriptMissing = transcriptSize === undefined;
256
427
  const fingerprint = jsonlCursorFingerprint(transcriptPath, state.byteOffset);
257
- this.transcriptOffset =
258
- fingerprint !== undefined && fingerprint === state.cursorFingerprint
259
- ? state.byteOffset // cursor valid → resume here
260
- : fileSize(transcriptPath); // truncated/replaced skip to EOF
428
+ const cursorValid = fingerprint !== undefined && fingerprint === state.cursorFingerprint;
429
+ const adoptEmptyCursor = state.cursorFingerprint === undefined &&
430
+ state.byteOffset === 0 &&
431
+ (state.lineCursor ?? 0) === 0;
432
+ const restoreLogicalState = transcriptMissing || cursorValid || adoptEmptyCursor;
433
+ this.transcriptOffset = restoreLogicalState
434
+ ? state.byteOffset
435
+ : transcriptSize ?? 0; // truncated/replaced → skip to EOF
436
+ this.committedTranscriptOffset = this.transcriptOffset;
437
+ this.transcriptLineCursor = restoreLogicalState ? state.lineCursor ?? 0 : 0;
438
+ this.committedTranscriptLineCursor = this.transcriptLineCursor;
439
+ if (restoreLogicalState && state.activeTerminalCommand) {
440
+ this.activeTerminalCommand = cloneTerminalCommand(state.activeTerminalCommand);
441
+ this.committedTerminalCommand = cloneTerminalCommand(state.activeTerminalCommand);
442
+ }
443
+ if (restoreLogicalState && state.turnOpen) {
444
+ this.turnOpen = true;
445
+ this.currentTurnId = state.currentTurnId;
446
+ this.sink.onTurnStart(this.currentTurnId);
447
+ }
448
+ // Seed an adopted empty cursor, or rewrite the replacement state
449
+ // (EOF + seen ids only), so another crash cannot resurrect stale logical
450
+ // response or split-command state from a replaced transcript.
451
+ if (!transcriptMissing && !cursorValid)
452
+ this.persistForwardState();
261
453
  return true;
262
454
  }
263
455
  /** Persist the durable forwarder cursor (byte offset + seen ids + fingerprint). */
264
456
  persistForwardState() {
265
457
  if (!this.transcriptPath)
266
458
  return;
267
- const fingerprint = jsonlCursorFingerprint(this.transcriptPath, this.transcriptOffset);
459
+ const fingerprint = jsonlCursorFingerprint(this.transcriptPath, this.committedTranscriptOffset);
268
460
  writeForwardState(this.bridgeDir, {
269
461
  transcriptPath: this.transcriptPath,
270
- byteOffset: this.transcriptOffset,
462
+ byteOffset: this.committedTranscriptOffset,
463
+ lineCursor: this.committedTranscriptLineCursor,
271
464
  seenSourceIds: [...this.seenSourceIds],
272
465
  ...(fingerprint ? { cursorFingerprint: fingerprint } : {}),
466
+ ...(this.currentTurnId ? { currentTurnId: this.currentTurnId } : {}),
467
+ ...(this.committedTerminalCommand
468
+ ? { activeTerminalCommand: this.committedTerminalCommand }
469
+ : {}),
470
+ ...(this.turnOpen
471
+ ? {
472
+ turnOpen: true,
473
+ currentResponseId: this.currentTurnId
474
+ ? `resp_claude_${this.currentTurnId}`
475
+ : "resp_claude_native",
476
+ }
477
+ : {}),
273
478
  });
274
479
  }
275
480
  /** True once SessionStart bound the transcript (or a resume path was given). */
@@ -322,12 +527,14 @@ export class ClaudeLiveSession {
322
527
  if (this.started)
323
528
  return;
324
529
  this.started = true;
325
- void this.loop();
530
+ this.supervisorTask = this.supervise();
326
531
  }
327
532
  stop() {
328
533
  if (this.stopped)
329
534
  return;
330
535
  this.stopped = true;
536
+ this.releaseSleep?.();
537
+ this.statusPoller?.retire();
331
538
  // A hook may have appended its request just before shutdown but not yet
332
539
  // reached a scheduled poll. Drain it so the subprocess receives cancellation
333
540
  // instead of remaining blocked until Claude's one-day hook timeout.
@@ -339,6 +546,24 @@ export class ClaudeLiveSession {
339
546
  this.closeTurn();
340
547
  this.scheduleClaimedInteractionScrubs();
341
548
  }
549
+ /** Wait for the supervised polling task to retire. Terminal teardown starts
550
+ * synchronously in {@link stop}; callers use this bounded join before their
551
+ * runner process exits. */
552
+ async waitForStop(timeoutMs = 5_000) {
553
+ const task = this.supervisorTask;
554
+ if (!task)
555
+ return;
556
+ let timer;
557
+ await Promise.race([
558
+ task,
559
+ new Promise((resolve) => {
560
+ timer = setTimeout(resolve, timeoutMs);
561
+ timer.unref?.();
562
+ }),
563
+ ]);
564
+ if (timer)
565
+ clearTimeout(timer);
566
+ }
342
567
  /** Phase two of runner shutdown. The caller must stop the Claude terminal
343
568
  * first so no hook can still be opening an atomically claimed answer. This is
344
569
  * synchronous because the runner process exits immediately afterwards. */
@@ -350,24 +575,80 @@ export class ClaudeLiveSession {
350
575
  this.claimedInteractions.clear();
351
576
  this.scheduledClaimScrubs.clear();
352
577
  }
353
- /** One poll cycle (hooks → transcript → idle backstop). Exposed for tests to
578
+ /** One poll cycle (rotation/PreCompact prescan → transcript → hooks live
579
+ * lanes → idle backstop). Exposed for tests to
354
580
  * drive deterministically; the async {@link loop} just calls it on an interval. */
355
581
  tick() {
356
- // Read the transcript first so a user record opens its Turn before any
357
- // same-tick MessageDisplay chunks are handled. The correlation layer
358
- // supports either completion order; this also lets pollDeltas safely drain
359
- // late chunks while no Turn is open instead of leaking them into the next.
582
+ // Preserve the required phase order: rotation and PreCompact are pre-scanned,
583
+ // transcript items (including the real compact summary) run next, then the
584
+ // durable hook cursor catches up. This keeps completion hooks secondary
585
+ // without letting an older poison hook block clear/fork or token minting.
586
+ const hadTranscript = this.transcriptPath !== undefined;
587
+ this.prescanSessionRotation();
588
+ if (this.rotationPending)
589
+ return;
590
+ this.prescanPrecompactEdges();
591
+ // The hook and transcript compaction paths share one pending token. Do not
592
+ // let them race the same boundary, but do keep every independent live lane
593
+ // moving while the hook path waits for its ACK.
594
+ if (!this.compactionHookPending)
595
+ this.pollTranscript();
360
596
  this.pollHooks();
361
- this.pollTranscript();
597
+ if (this.rotationPending)
598
+ return;
599
+ // Initial SessionStart is the only time the transcript path is unavailable
600
+ // during the pre-items phase. Once the hook discovers it, tail it in this
601
+ // same tick before Stop/StopFailure is flushed below.
602
+ if (!hadTranscript && this.transcriptPath && !this.compactionHookPending) {
603
+ this.pollTranscript();
604
+ }
605
+ this.pollNativeSubagents();
362
606
  this.pollDeltas();
363
607
  this.pollInteractions();
364
608
  this.drainInteractionCommits();
365
609
  this.pollAbandonedInteractions();
366
610
  this.flushTurnFailure();
367
611
  this.pollStatus();
612
+ this.pollSessionStatus();
368
613
  this.flushStopSignal();
369
614
  this.maybeIdleClose();
370
615
  }
616
+ /** Bind Claude's per-process session metadata after the terminal exists. */
617
+ attachStatusSource({ panePid, configDir, }) {
618
+ this.statusPoller?.retire();
619
+ this.statusPoller = new ClaudeSessionStatusPoller({
620
+ panePid,
621
+ sessionId: () => this.currentClaudeSessionId,
622
+ onStatus: (status) => this.handleSessionStatus(status),
623
+ ...(configDir ? { configDir } : {}),
624
+ });
625
+ }
626
+ async supervise() {
627
+ let backoffMs = FORWARDER_RESTART_INITIAL_MS;
628
+ while (!this.stopped) {
629
+ const startedAt = this.now();
630
+ let crashed;
631
+ try {
632
+ await this.loop();
633
+ if (!this.stopped) {
634
+ console.warn("[claude-forwarder] polling loop returned unexpectedly; restarting");
635
+ }
636
+ }
637
+ catch (error) {
638
+ crashed = error;
639
+ }
640
+ if (this.stopped)
641
+ break;
642
+ if (this.now() - startedAt >= FORWARDER_HEALTHY_UPTIME_MS) {
643
+ backoffMs = FORWARDER_RESTART_INITIAL_MS;
644
+ }
645
+ if (crashed !== undefined) {
646
+ console.error(`[claude-forwarder] polling loop crashed; restarting in ${backoffMs}ms:`, crashed);
647
+ }
648
+ await this.sleepUntilWake(backoffMs);
649
+ backoffMs = Math.min(backoffMs * 2, FORWARDER_RESTART_MAX_MS);
650
+ }
651
+ }
371
652
  async loop() {
372
653
  while (!this.stopped) {
373
654
  try {
@@ -379,7 +660,7 @@ export class ClaudeLiveSession {
379
660
  // per-iteration `except Exception` in the transcript forwarder loop.
380
661
  console.error("[claude-forwarder] tick failed; continuing:", err);
381
662
  }
382
- await new Promise((r) => setTimeout(r, this.pollMs));
663
+ await this.sleepUntilWake(this.pollMs);
383
664
  }
384
665
  // Catch a request appended while the loop was waking up to observe stopped.
385
666
  this.pollInteractions();
@@ -388,34 +669,355 @@ export class ClaudeLiveSession {
388
669
  this.closeTurn();
389
670
  this.scheduleClaimedInteractionScrubs();
390
671
  }
672
+ sleepUntilWake(delayMs) {
673
+ if (this.stopped)
674
+ return Promise.resolve();
675
+ return new Promise((resolve) => {
676
+ const timer = setTimeout(done, delayMs);
677
+ timer.unref?.();
678
+ const self = this;
679
+ function done() {
680
+ if (self.releaseSleep === done)
681
+ self.releaseSleep = undefined;
682
+ clearTimeout(timer);
683
+ resolve();
684
+ }
685
+ this.releaseSleep = done;
686
+ });
687
+ }
688
+ /** Rotation is a control edge, not an ordinary hook-stream item. Scan past a
689
+ * held Stop/compaction cursor so an old retry can never strand the physical
690
+ * Claude process on a new Session while Rynx still targets the old one. */
691
+ prescanSessionRotation() {
692
+ if (this.rotationPending)
693
+ return;
694
+ const { events } = readHookEventsFrom(this.bridgeDir, this.hooksOffset, this.hookEventCursor);
695
+ const rotation = events.find((event) => this.isSessionRotationHook(event));
696
+ if (!rotation)
697
+ return;
698
+ // Abandon older hook deliveries. Their runner generation is cancelled by
699
+ // retargetMirror; the identity checks below prevent a late completion from
700
+ // moving the durable hook cursor backwards after the rotation commits.
701
+ this.pendingTerminalHook = undefined;
702
+ this.terminalHookDelivery = undefined;
703
+ this.stopSignalPending = false;
704
+ this.turnFailurePending = null;
705
+ this.pendingCompactionHook = undefined;
706
+ this.compactionHookPending = false;
707
+ const publication = this.handleSessionStart(rotation);
708
+ if (!publication || typeof publication.then !== "function") {
709
+ this.persistHookCursor(rotation);
710
+ return;
711
+ }
712
+ this.rotationPending = true;
713
+ void publication.then(() => this.persistHookCursor(rotation), (error) => {
714
+ console.error("[claude-forwarder] Session rotation publication failed; retaining hook cursor:", error);
715
+ }).finally(() => {
716
+ this.rotationPending = false;
717
+ });
718
+ }
719
+ /** Mint compaction tokens before transcript parsing without advancing the
720
+ * hook cursor. The ordinary hook pass later observes the same cursor-keyed
721
+ * edge and converges on the already-created token. */
722
+ prescanPrecompactEdges() {
723
+ const { events } = readHookEventsFrom(this.bridgeDir, this.hooksOffset, this.hookEventCursor);
724
+ for (const event of events) {
725
+ if (event.eventName === "PreCompact")
726
+ this.notePrecompact(event);
727
+ }
728
+ }
391
729
  pollHooks() {
392
- const { events, nextOffset } = readHookEventsFrom(this.bridgeDir, this.hooksOffset);
393
- this.hooksOffset = nextOffset;
394
- for (const ev of events)
395
- this.handleHook(ev);
730
+ if (this.rotationPending || this.compactionHookPending || this.pendingTerminalHook)
731
+ return;
732
+ const { events } = readHookEventsFrom(this.bridgeDir, this.hooksOffset, this.hookEventCursor);
733
+ for (const ev of events) {
734
+ if (this.transcriptCompactionPending &&
735
+ ev.eventName === "SessionStart" &&
736
+ ev.source === "compact") {
737
+ return;
738
+ }
739
+ const terminalHook = (ev.eventName === "Stop" || ev.eventName === "StopFailure") &&
740
+ !isSubagentTranscriptPath(ev.transcriptPath);
741
+ if (terminalHook) {
742
+ this.pendingTerminalHook = ev;
743
+ this.handleHook(ev);
744
+ // Interactions from the same filesystem window are discovered later in
745
+ // this tick. flushStopSignal/flushTurnFailure owns the cursor commit.
746
+ return;
747
+ }
748
+ const handled = this.handleHook(ev);
749
+ if (handled && typeof handled.then === "function") {
750
+ const rotation = this.isSessionRotationHook(ev);
751
+ if (rotation)
752
+ this.rotationPending = true;
753
+ else {
754
+ this.compactionHookPending = true;
755
+ this.pendingCompactionHook = ev;
756
+ }
757
+ void handled.then(() => {
758
+ if (rotation || this.pendingCompactionHook === ev)
759
+ this.persistHookCursor(ev);
760
+ }, (error) => {
761
+ console.error("[claude-forwarder] asynchronous hook publication failed; retaining hook cursor:", error);
762
+ }).finally(() => {
763
+ if (rotation)
764
+ this.rotationPending = false;
765
+ else if (this.pendingCompactionHook === ev) {
766
+ this.pendingCompactionHook = undefined;
767
+ this.compactionHookPending = false;
768
+ }
769
+ });
770
+ return;
771
+ }
772
+ this.persistHookCursor(ev);
773
+ }
774
+ }
775
+ isSessionRotationHook(ev) {
776
+ if (ev.eventName !== "SessionStart" || !this.transcriptPath || !ev.transcriptPath) {
777
+ return false;
778
+ }
779
+ if (!ev.sessionId || ev.sessionId === this.currentClaudeSessionId)
780
+ return false;
781
+ if (ev.source === "clear")
782
+ return true;
783
+ return this.isForkHookRecord(ev);
784
+ }
785
+ isForkHookRecord(ev) {
786
+ if (ev.payload.rynx_fork_detected === true &&
787
+ ev.eventName === "SessionStart" &&
788
+ ev.source === "resume")
789
+ return true;
790
+ if (ev.eventName !== "SessionStart" ||
791
+ ev.source !== "resume" ||
792
+ !ev.transcriptPath ||
793
+ !ev.sessionId)
794
+ return false;
795
+ const sourceSessionId = typeof ev.payload.rynx_previous_claude_session_id === "string"
796
+ ? ev.payload.rynx_previous_claude_session_id
797
+ : this.currentClaudeSessionId;
798
+ if (!sourceSessionId || sourceSessionId === ev.sessionId)
799
+ return false;
800
+ const wasSeen = typeof ev.payload.rynx_claude_session_was_seen === "boolean"
801
+ ? ev.payload.rynx_claude_session_was_seen
802
+ : this.seenClaudeSessionIds.has(ev.sessionId);
803
+ if (wasSeen)
804
+ return false;
805
+ return transcriptHasForkedFrom(ev.transcriptPath, ev.sessionId, sourceSessionId) || (ev.recordedAt !== undefined &&
806
+ transcriptHasRecentLocalCommand(ev.transcriptPath, ev.sessionId, ev.recordedAt));
807
+ }
808
+ persistHookCursor(ev) {
809
+ this.hooksOffset = ev.byteOffset;
810
+ this.hookEventCursor = ev.eventCursor;
811
+ const fingerprint = jsonlCursorFingerprint(`${this.bridgeDir}/${HOOKS_FILE}`, ev.byteOffset);
812
+ writeHookForwardState(this.bridgeDir, {
813
+ eventCursor: ev.eventCursor,
814
+ byteOffset: ev.byteOffset,
815
+ ...(fingerprint ? { cursorFingerprint: fingerprint } : {}),
816
+ });
396
817
  }
397
818
  handleHook(ev) {
398
819
  if (ev.eventName === "SessionStart") {
399
- this.handleSessionStart(ev);
820
+ return this.handleSessionStart(ev);
821
+ }
822
+ if (ev.eventName === "PreCompact") {
823
+ this.sink.onCompactionStatus?.("in_progress");
824
+ this.notePrecompact(ev);
825
+ return;
826
+ }
827
+ if (ev.eventName === "TaskCreated" ||
828
+ ev.eventName === "TaskCompleted" ||
829
+ ev.eventName === "PostToolUse") {
830
+ this.handleTodoHook(ev);
400
831
  return;
401
832
  }
402
833
  if (ev.eventName === "Stop" || ev.eventName === "StopFailure") {
403
834
  // Subagent stops (transcript_path under `subagents/`) don't end the parent turn.
404
- if (ev.transcriptPath?.includes("/subagents/"))
835
+ if (isSubagentTranscriptPath(ev.transcriptPath))
405
836
  return;
406
837
  // Stop only signals idle — it does NOT close the turn (see onIdle): closing
407
838
  // here would split a late-flushing assistant record into its own turn.
408
839
  if (ev.eventName === "StopFailure") {
409
840
  this.stopSignalPending = false;
410
841
  this.stopPendingAt = null;
842
+ this.stopBackgroundTaskCount = undefined;
843
+ this.stopBackgroundTaskCountDelivered = false;
411
844
  this.turnFailurePending = new Error("claude turn failed");
412
845
  }
413
846
  else {
414
847
  this.stopSignalPending = true;
415
848
  this.stopPendingAt = this.now(); // close after a short grace (late assistant flush)
849
+ this.stopBackgroundTaskCount = ev.backgroundTaskCount ?? 0;
850
+ this.stopBackgroundTaskCountDelivered = false;
416
851
  }
417
852
  }
418
853
  }
854
+ handleTodoHook(ev) {
855
+ const payload = ev.payload;
856
+ if (ev.eventName === "TaskCreated") {
857
+ const id = readStr(payload.task_id);
858
+ if (!id)
859
+ return;
860
+ this.todos.set(id, {
861
+ id,
862
+ subject: readStr(payload.task_subject) ?? id,
863
+ status: "pending",
864
+ });
865
+ this.sink.onTodos([...this.todos.values()]);
866
+ return;
867
+ }
868
+ if (ev.eventName === "TaskCompleted") {
869
+ const id = readStr(payload.task_id);
870
+ const existing = id ? this.todos.get(id) : undefined;
871
+ if (!id || !existing)
872
+ return;
873
+ this.todos.set(id, {
874
+ id,
875
+ subject: existing.subject,
876
+ status: "completed",
877
+ });
878
+ this.sink.onTodos([...this.todos.values()]);
879
+ return;
880
+ }
881
+ if (ev.eventName !== "PostToolUse" || !isRecord(payload.tool_input))
882
+ return;
883
+ const input = payload.tool_input;
884
+ if (payload.tool_name === "TodoWrite" && Array.isArray(input.todos)) {
885
+ const snapshot = [];
886
+ for (const [index, raw] of input.todos.entries()) {
887
+ if (!isRecord(raw))
888
+ continue;
889
+ const subject = readStr(raw.content) ?? readStr(raw.subject);
890
+ if (!subject)
891
+ continue;
892
+ snapshot.push({
893
+ id: readStr(raw.id) ?? `todo-${index}`,
894
+ subject,
895
+ status: normalizeTodoStatus(readStr(raw.status)) ?? "pending",
896
+ });
897
+ }
898
+ this.todos.clear();
899
+ for (const todo of snapshot)
900
+ this.todos.set(todo.id, todo);
901
+ this.sink.onTodos(snapshot);
902
+ return;
903
+ }
904
+ if (payload.tool_name !== "TaskUpdate")
905
+ return;
906
+ const id = readStr(input.taskId);
907
+ if (!id)
908
+ return;
909
+ const existing = this.todos.get(id);
910
+ if (!existing)
911
+ return;
912
+ if (input.status === "deleted") {
913
+ if (this.todos.delete(id))
914
+ this.sink.onTodos([...this.todos.values()]);
915
+ return;
916
+ }
917
+ this.todos.set(id, {
918
+ id,
919
+ subject: readStr(input.subject) ?? existing.subject,
920
+ status: normalizeTodoStatus(readStr(input.status)) ?? existing.status,
921
+ });
922
+ this.sink.onTodos([...this.todos.values()]);
923
+ }
924
+ compactionGeneration() {
925
+ return this.currentClaudeSessionId ?? this.transcriptPath ?? "unbound";
926
+ }
927
+ readCompactionState() {
928
+ return readCompactionForwardState(this.bridgeDir, this.compactionGeneration());
929
+ }
930
+ notePrecompact(ev) {
931
+ const state = this.readCompactionState();
932
+ if (ev.eventCursor <= state.lastPrecompactCursor)
933
+ return;
934
+ const sequence = state.lastSequence + 1;
935
+ writeCompactionForwardState(this.bridgeDir, {
936
+ generation: state.generation,
937
+ lastSequence: sequence,
938
+ persistedSequences: state.persistedSequences,
939
+ lastPrecompactCursor: ev.eventCursor,
940
+ pending: {
941
+ sequence,
942
+ ...(ev.sessionId ? { claudeSessionId: ev.sessionId } : {}),
943
+ ...(ev.transcriptPath ? { transcriptPath: ev.transcriptPath } : {}),
944
+ },
945
+ });
946
+ }
947
+ compactionPendingMatches(state, claudeSessionId, transcriptPath) {
948
+ const pending = state.pending;
949
+ if (!pending)
950
+ return false;
951
+ if (pending.claudeSessionId && claudeSessionId &&
952
+ pending.claudeSessionId !== claudeSessionId)
953
+ return false;
954
+ if (pending.transcriptPath && transcriptPath &&
955
+ pending.transcriptPath !== transcriptPath)
956
+ return false;
957
+ return !state.persistedSequences.includes(pending.sequence);
958
+ }
959
+ markCompactionPersisted(sequence, expectCompletionAck, generation = this.compactionGeneration()) {
960
+ if (this.compactionGeneration() !== generation)
961
+ return;
962
+ const state = this.readCompactionState();
963
+ if (state.generation !== generation)
964
+ return;
965
+ const persistedSequences = state.persistedSequences.includes(sequence)
966
+ ? state.persistedSequences
967
+ : [...state.persistedSequences, sequence].slice(-16);
968
+ writeCompactionForwardState(this.bridgeDir, {
969
+ generation: state.generation,
970
+ lastSequence: Math.max(state.lastSequence, sequence),
971
+ persistedSequences,
972
+ lastPrecompactCursor: state.lastPrecompactCursor,
973
+ ...(state.pending?.sequence !== sequence && state.pending
974
+ ? { pending: state.pending }
975
+ : {}),
976
+ ...(expectCompletionAck ? { expectCompletionAckSequence: sequence } : {}),
977
+ });
978
+ }
979
+ claimHookCompaction(ev) {
980
+ const state = this.readCompactionState();
981
+ if (this.compactionPendingMatches(state, ev.sessionId, ev.transcriptPath)) {
982
+ return state.pending.sequence;
983
+ }
984
+ if (state.expectCompletionAckSequence &&
985
+ state.persistedSequences.includes(state.expectCompletionAckSequence)) {
986
+ writeCompactionForwardState(this.bridgeDir, {
987
+ ...state,
988
+ expectCompletionAckSequence: undefined,
989
+ });
990
+ return undefined;
991
+ }
992
+ const sequence = state.lastSequence + 1;
993
+ writeCompactionForwardState(this.bridgeDir, {
994
+ generation: state.generation,
995
+ lastSequence: sequence,
996
+ persistedSequences: state.persistedSequences,
997
+ lastPrecompactCursor: state.lastPrecompactCursor,
998
+ pending: {
999
+ sequence,
1000
+ ...(ev.sessionId ? { claudeSessionId: ev.sessionId } : {}),
1001
+ ...(ev.transcriptPath ? { transcriptPath: ev.transcriptPath } : {}),
1002
+ },
1003
+ });
1004
+ return sequence;
1005
+ }
1006
+ handleCompactionCompletionHook(ev) {
1007
+ this.sink.onCompactionStatus?.("completed");
1008
+ const generation = this.compactionGeneration();
1009
+ const sequence = this.claimHookCompaction(ev);
1010
+ if (sequence === undefined || !this.sink.onCompactionBoundary)
1011
+ return;
1012
+ const result = this.sink.onCompactionBoundary("[Claude Code compaction — context was compacted in the terminal]", sequence, "hook");
1013
+ const finish = (handled) => {
1014
+ // A bounded permanent hook-path rejection advances the hook but leaves
1015
+ // the token for the primary transcript summary path.
1016
+ if (handled)
1017
+ this.markCompactionPersisted(sequence, false, generation);
1018
+ };
1019
+ return Promise.resolve(result).then(finish);
1020
+ }
419
1021
  /** Process a provider failure only after this tick has discovered every
420
1022
  * blocking request already appended by its hook subprocess. */
421
1023
  flushTurnFailure() {
@@ -423,16 +1025,67 @@ export class ClaudeLiveSession {
423
1025
  if (!error)
424
1026
  return;
425
1027
  this.turnFailurePending = null;
426
- this.closeTurnError(error);
1028
+ this.providerFailureSticky = true;
1029
+ const sourceId = this.pendingTerminalHook
1030
+ ? `hook:${this.pendingTerminalHook.eventCursor}:${this.pendingTerminalHook.byteOffset}:failed`
1031
+ : undefined;
1032
+ const delivery = this.turnOpen
1033
+ ? this.closeTurnError(error, sourceId)
1034
+ : this.sink.onTurnError(error, sourceId);
1035
+ if (this.pendingTerminalHook)
1036
+ this.finishTerminalHookDelivery(delivery);
427
1037
  }
428
1038
  /** Emit the Stop idle signal only after this tick has discovered native
429
1039
  * interactions. A pending question/permission is active execution, not idle. */
430
1040
  flushStopSignal() {
431
- if (!this.stopSignalPending)
1041
+ if (!this.stopSignalPending) {
1042
+ if (this.pendingTerminalHook?.eventName === "Stop" && this.pendingInteractions.size > 0) {
1043
+ this.persistHookCursor(this.pendingTerminalHook);
1044
+ this.pendingTerminalHook = undefined;
1045
+ }
432
1046
  return;
1047
+ }
433
1048
  this.stopSignalPending = false;
434
- if (this.pendingInteractions.size === 0)
435
- this.sink.onIdle();
1049
+ if (this.pendingInteractions.size === 0) {
1050
+ const sourceId = this.pendingTerminalHook
1051
+ ? `hook:${this.pendingTerminalHook.eventCursor}:${this.pendingTerminalHook.byteOffset}:idle`
1052
+ : undefined;
1053
+ const delivery = this.sink.onIdle(this.stopBackgroundTaskCount, sourceId);
1054
+ this.stopBackgroundTaskCountDelivered = true;
1055
+ if (this.pendingTerminalHook)
1056
+ this.finishTerminalHookDelivery(delivery);
1057
+ }
1058
+ else if (this.pendingTerminalHook) {
1059
+ // A pending native interaction means Stop is not a real idle edge, but
1060
+ // the hook record itself is handled and may advance.
1061
+ this.persistHookCursor(this.pendingTerminalHook);
1062
+ this.pendingTerminalHook = undefined;
1063
+ }
1064
+ }
1065
+ finishTerminalHookDelivery(delivery) {
1066
+ const hook = this.pendingTerminalHook;
1067
+ if (!hook || this.terminalHookDelivery)
1068
+ return;
1069
+ this.terminalHookDelivery = hook;
1070
+ void Promise.resolve(delivery).then(() => {
1071
+ if (this.pendingTerminalHook !== hook)
1072
+ return;
1073
+ this.persistHookCursor(hook);
1074
+ this.pendingTerminalHook = undefined;
1075
+ }, (error) => {
1076
+ if (this.pendingTerminalHook !== hook)
1077
+ return;
1078
+ console.error("[claude-forwarder] terminal hook delivery failed; retaining cursor:", error);
1079
+ if (hook.eventName === "StopFailure") {
1080
+ this.turnFailurePending = new Error("claude turn failed");
1081
+ }
1082
+ else {
1083
+ this.stopSignalPending = true;
1084
+ }
1085
+ }).finally(() => {
1086
+ if (this.terminalHookDelivery === hook)
1087
+ this.terminalHookDelivery = undefined;
1088
+ });
436
1089
  }
437
1090
  /** SessionStart drives discovery (first) and rotation (a later one with a NEW
438
1091
  * session id + transcript). `/clear` → source="clear"; `/fork` → source="resume"
@@ -450,52 +1103,113 @@ export class ClaudeLiveSession {
450
1103
  }
451
1104
  this.transcriptPath = ev.transcriptPath;
452
1105
  this.transcriptOffset = 0;
1106
+ this.committedTranscriptOffset = 0;
1107
+ this.transcriptLineCursor = 0;
1108
+ this.committedTranscriptLineCursor = 0;
453
1109
  this.currentClaudeSessionId = ev.sessionId;
454
1110
  if (ev.sessionId)
455
1111
  this.seenClaudeSessionIds.add(ev.sessionId);
456
1112
  // Resume from the persisted cursor so a relaunched forwarder doesn't re-mirror
457
1113
  // already-logged turns.
458
1114
  const restored = this.restoreForwardState(ev.transcriptPath);
459
- if (!restored && this.resumeAtEndOnDiscovery) {
1115
+ this.restoreSubagentForwardStates(ev.transcriptPath);
1116
+ if (!restored && this.initialTranscriptOffsetOnDiscovery !== undefined) {
1117
+ this.transcriptOffset = Math.min(this.initialTranscriptOffsetOnDiscovery, fileSize(ev.transcriptPath));
1118
+ this.committedTranscriptOffset = this.transcriptOffset;
1119
+ // Seed the exact rotation boundary even when the transcript is empty or
1120
+ // no bytes are consumed this tick. This closes a second-crash window
1121
+ // between SessionStart discovery and the first target record.
1122
+ this.persistForwardState();
1123
+ }
1124
+ else if (!restored && this.resumeAtEndOnDiscovery) {
460
1125
  this.transcriptOffset = fileSize(ev.transcriptPath);
1126
+ this.committedTranscriptOffset = this.transcriptOffset;
1127
+ // Persist the fallback baseline just like an exact prepared prefix.
1128
+ // Otherwise a crash with the first new record still awaiting ACK can
1129
+ // make the next launch seed at the newer EOF and lose that record.
1130
+ this.persistForwardState();
461
1131
  }
462
1132
  if (!this.discovered && ev.sessionId) {
463
1133
  this.discovered = true;
464
1134
  this.sink.onSessionDiscovered?.(ev.sessionId, ev.transcriptPath);
465
1135
  }
466
- return;
1136
+ return ev.source === "compact" ? this.handleCompactionCompletionHook(ev) : undefined;
467
1137
  }
468
1138
  // Drain the old transcript before a SessionStart switches paths so a slash
469
1139
  // command recorded immediately before /clear or /fork can still
470
1140
  // acknowledge the web submission.
471
1141
  if (ev.transcriptPath !== this.transcriptPath)
472
1142
  this.pollTranscript();
1143
+ if (ev.source === "compact") {
1144
+ const follow = () => {
1145
+ if (ev.transcriptPath !== this.transcriptPath) {
1146
+ this.transcriptPath = ev.transcriptPath;
1147
+ this.transcriptOffset = 0;
1148
+ this.committedTranscriptOffset = 0;
1149
+ this.transcriptLineCursor = 0;
1150
+ this.committedTranscriptLineCursor = 0;
1151
+ this.seenSourceIds.clear();
1152
+ resetForwardState(this.bridgeDir);
1153
+ this.persistForwardState();
1154
+ }
1155
+ };
1156
+ const completion = this.handleCompactionCompletionHook(ev);
1157
+ if (completion && typeof completion.then === "function")
1158
+ return completion.then(follow);
1159
+ follow();
1160
+ return;
1161
+ }
473
1162
  // Same session id (a plain re-announce) → nothing to do.
474
1163
  if (!ev.sessionId || ev.sessionId === this.currentClaudeSessionId)
475
1164
  return;
476
- const wasSeen = this.seenClaudeSessionIds.has(ev.sessionId);
477
- this.seenClaudeSessionIds.add(ev.sessionId);
478
1165
  let kind;
479
1166
  if (ev.source === "clear")
480
1167
  kind = "clear";
481
- else if (ev.source === "resume" && !wasSeen && transcriptHasForkedFrom(ev.transcriptPath, ev.sessionId)) {
1168
+ else if (this.isForkHookRecord(ev))
482
1169
  kind = "fork";
1170
+ // Capture the target boundary at SessionStart, before target publication
1171
+ // can wait or retry. Fork has copied source history up to this byte; clear
1172
+ // has no inherited prefix. A later EOF would also include new target work.
1173
+ const initialTranscriptOffset = kind === "fork" ? fileSize(ev.transcriptPath) : 0;
1174
+ const commitRepoint = () => {
1175
+ // A fork copies the source history into the new transcript; start at its
1176
+ // END so those records aren't re-mirrored. A clear starts fresh at byte 0.
1177
+ this.repointTranscript(ev.transcriptPath, initialTranscriptOffset);
1178
+ this.currentClaudeSessionId = ev.sessionId;
1179
+ this.seenClaudeSessionIds.add(ev.sessionId);
1180
+ };
1181
+ if (!kind) {
1182
+ commitRepoint();
1183
+ return;
483
1184
  }
484
- // A fork copies the source history into the new transcript; start at its END
485
- // so those records aren't re-mirrored. A clear starts fresh at byte 0.
486
- this.repointTranscript(ev.transcriptPath, kind === "fork");
487
- this.currentClaudeSessionId = ev.sessionId;
488
- if (kind)
489
- this.sink.onSessionRotated?.(kind, ev.sessionId, ev.transcriptPath);
1185
+ // Close the old Response before the host publishes session.rotated and
1186
+ // retargets the IPC generation. The host serializes this settlement on the
1187
+ // control lane so it cannot be cancelled into the new Session.
1188
+ this.closeTurn("rotation");
1189
+ // Do not switch source state or consume the hook until the host has
1190
+ // persisted and published the logical Session rotation.
1191
+ const publication = this.sink.onSessionRotated?.(kind, ev.sessionId, ev.transcriptPath, initialTranscriptOffset);
1192
+ if (publication && typeof publication.then === "function") {
1193
+ return publication.then(commitRepoint);
1194
+ }
1195
+ commitRepoint();
490
1196
  }
491
1197
  /** Switch the tailed transcript and reset per-session accumulators (the bridge
492
1198
  * files — hooks/deltas/status — are shared by the same claude process across a
493
1199
  * rotation, so their cursors are NOT reset). */
494
- repointTranscript(newPath, atEof) {
1200
+ repointTranscript(newPath, initialOffset) {
495
1201
  this.closeTurn(); // finalize the prior session's last turn before switching
1202
+ this.activeTerminalCommand = undefined;
1203
+ this.committedTerminalCommand = undefined;
496
1204
  this.transcriptPath = newPath;
497
- this.transcriptOffset = atEof ? fileSize(newPath) : 0;
498
- this.seenSubagents.clear();
1205
+ this.transcriptOffset = Math.min(Math.max(0, initialOffset), fileSize(newPath));
1206
+ this.committedTranscriptOffset = this.transcriptOffset;
1207
+ this.transcriptLineCursor = 0;
1208
+ this.committedTranscriptLineCursor = 0;
1209
+ this.nativeSubagents.clear();
1210
+ this.nativeSubagentParentResponses.clear();
1211
+ this.subagentStateParentPath = newPath;
1212
+ resetSubagentForwardStates(this.bridgeDir);
499
1213
  this.todos.clear();
500
1214
  this.pendingQueuedSubmissions.length = 0;
501
1215
  this.resetMessageCorrelation();
@@ -506,16 +1220,366 @@ export class ClaudeLiveSession {
506
1220
  resetForwardState(this.bridgeDir);
507
1221
  this.persistForwardState();
508
1222
  }
1223
+ /** Revalidate the committed cursor every poll, not only after process restart.
1224
+ * Claude may replace/truncate a transcript in place. Treat a stale
1225
+ * nonzero fingerprint as a new file and skips to its current EOF while
1226
+ * preserving the bounded seen set. A temporarily missing file keeps the
1227
+ * cursor unchanged. */
1228
+ validateLiveTranscriptCursor() {
1229
+ const transcriptPath = this.transcriptPath;
1230
+ if (!transcriptPath)
1231
+ return;
1232
+ const state = readForwardState(this.bridgeDir);
1233
+ if (!state ||
1234
+ state.transcriptPath !== transcriptPath ||
1235
+ state.byteOffset !== this.committedTranscriptOffset)
1236
+ return;
1237
+ const currentFingerprint = jsonlCursorFingerprint(transcriptPath, this.committedTranscriptOffset);
1238
+ if (currentFingerprint === undefined) {
1239
+ if (fileByteSize(transcriptPath) === undefined)
1240
+ return;
1241
+ }
1242
+ else if (state.cursorFingerprint === currentFingerprint) {
1243
+ return;
1244
+ }
1245
+ else if (state.cursorFingerprint === undefined &&
1246
+ this.committedTranscriptOffset === 0 &&
1247
+ this.committedTranscriptLineCursor === 0) {
1248
+ this.persistForwardState();
1249
+ return;
1250
+ }
1251
+ console.warn("[claude-forwarder] transcript cursor fingerprint changed; skipping to current EOF");
1252
+ this.closeTurn();
1253
+ this.activeTerminalCommand = undefined;
1254
+ this.committedTerminalCommand = undefined;
1255
+ const endOffset = fileSize(transcriptPath);
1256
+ this.transcriptOffset = endOffset;
1257
+ this.committedTranscriptOffset = endOffset;
1258
+ this.transcriptLineCursor = 0;
1259
+ this.committedTranscriptLineCursor = 0;
1260
+ this.persistForwardState();
1261
+ }
509
1262
  pollTranscript() {
510
- if (!this.transcriptPath)
1263
+ if (!this.transcriptPath || this.transcriptDeliveryPending)
511
1264
  return;
512
- const { records, nextOffset } = readJsonlFrom(this.transcriptPath, this.transcriptOffset);
1265
+ this.validateLiveTranscriptCursor();
1266
+ const transcriptPath = this.transcriptPath;
1267
+ const { entries, nextOffset, nextLineCursor } = readJsonlEntriesFrom(transcriptPath, this.transcriptOffset, this.transcriptLineCursor);
513
1268
  this.transcriptOffset = nextOffset;
514
- for (const rec of records)
1269
+ this.transcriptLineCursor = nextLineCursor;
1270
+ this.pendingTranscriptCompactions = [];
1271
+ const deliveries = [];
1272
+ const fallbackSourceIds = [];
1273
+ for (const entry of entries) {
1274
+ const rec = entry.record;
1275
+ const sourceBase = transcriptSourceBase(rec, entry.byteOffset, entry.lineNumber);
1276
+ // Compatibility with the old record-level seen set. New writes persist
1277
+ // per-item keys returned by onTranscriptRecordEnd.
1278
+ if (this.seenSourceIds.has(sourceBase))
1279
+ continue;
1280
+ const terminalCheckpoint = this.terminalCommandCheckpointForRecord(rec);
1281
+ this.sink.onTranscriptRecordStart?.(sourceBase, (sourceId) => this.seenSourceIds.has(sourceId), {
1282
+ onHandled: (sourceId) => {
1283
+ this.rememberSourceId(sourceId);
1284
+ if (terminalCheckpoint) {
1285
+ this.committedTerminalCommand = cloneTerminalCommand(terminalCheckpoint.next);
1286
+ }
1287
+ this.persistForwardState();
1288
+ },
1289
+ });
515
1290
  this.handleRecord(rec);
516
- // Persist the durable cursor after consuming records so a relaunch resumes here.
517
- if (records.length > 0)
1291
+ const delivery = this.sink.onTranscriptRecordEnd?.();
1292
+ if (delivery)
1293
+ deliveries.push(delivery);
1294
+ else
1295
+ fallbackSourceIds.push(sourceBase);
1296
+ }
1297
+ const commit = (sourceGroups) => {
1298
+ if (this.transcriptPath !== transcriptPath)
1299
+ return;
1300
+ for (const sourceId of [...fallbackSourceIds, ...sourceGroups.flat()]) {
1301
+ this.rememberSourceId(sourceId);
1302
+ }
1303
+ this.committedTerminalCommand = cloneTerminalCommand(this.activeTerminalCommand);
1304
+ this.committedTranscriptOffset = nextOffset;
1305
+ this.committedTranscriptLineCursor = nextLineCursor;
518
1306
  this.persistForwardState();
1307
+ };
1308
+ if (deliveries.length === 0 && this.pendingTranscriptCompactions.length === 0) {
1309
+ if (nextOffset !== this.committedTranscriptOffset)
1310
+ commit([]);
1311
+ return;
1312
+ }
1313
+ const compactions = this.pendingTranscriptCompactions;
1314
+ this.pendingTranscriptCompactions = [];
1315
+ this.transcriptDeliveryPending = true;
1316
+ this.transcriptCompactionPending = compactions.length > 0;
1317
+ void Promise.all([
1318
+ Promise.all(deliveries),
1319
+ Promise.all(compactions.map(async ({ sequence, generation, delivery }) => {
1320
+ if (!await delivery)
1321
+ throw new Error(`compaction ${sequence} delivery was not handled`);
1322
+ this.markCompactionPersisted(sequence, true, generation);
1323
+ })),
1324
+ ]).then(([sourceGroups]) => commit(sourceGroups), (error) => {
1325
+ if (this.transcriptPath !== transcriptPath)
1326
+ return;
1327
+ this.transcriptOffset = this.committedTranscriptOffset;
1328
+ this.transcriptLineCursor = this.committedTranscriptLineCursor;
1329
+ this.activeTerminalCommand = cloneTerminalCommand(this.committedTerminalCommand);
1330
+ console.error("[claude-forwarder] transcript delivery failed; retrying window:", error);
1331
+ }).finally(() => {
1332
+ this.transcriptDeliveryPending = false;
1333
+ this.transcriptCompactionPending = false;
1334
+ });
1335
+ }
1336
+ rememberSourceId(sourceId) {
1337
+ if (this.seenSourceIds.has(sourceId))
1338
+ return;
1339
+ this.seenSourceIds.add(sourceId);
1340
+ while (this.seenSourceIds.size > 2_000) {
1341
+ const oldest = this.seenSourceIds.values().next().value;
1342
+ if (!oldest)
1343
+ break;
1344
+ this.seenSourceIds.delete(oldest);
1345
+ }
1346
+ }
1347
+ restoreSubagentForwardStates(parentTranscriptPath) {
1348
+ if (this.subagentStateParentPath === parentTranscriptPath)
1349
+ return;
1350
+ this.nativeSubagents.clear();
1351
+ this.nativeSubagentParentResponses.clear();
1352
+ this.subagentStateParentPath = parentTranscriptPath;
1353
+ for (const [parentToolCallId, responseId] of readSubagentParentResponses(this.bridgeDir, parentTranscriptPath)) {
1354
+ this.nativeSubagentParentResponses.set(parentToolCallId, responseId);
1355
+ this.sink.onSubagentParentResponse?.(parentToolCallId, responseId);
1356
+ }
1357
+ for (const state of readSubagentForwardStates(this.bridgeDir, parentTranscriptPath)) {
1358
+ this.nativeSubagents.set(state.agentId, {
1359
+ agentId: state.agentId,
1360
+ parentToolCallId: state.parentToolCallId,
1361
+ ...(state.parentResponseId ? { parentResponseId: state.parentResponseId } : {}),
1362
+ transcriptPath: state.transcriptPath,
1363
+ readOffset: state.byteOffset,
1364
+ committedOffset: state.byteOffset,
1365
+ lineCursor: state.lineCursor ?? 0,
1366
+ committedLineCursor: state.lineCursor ?? 0,
1367
+ seenSourceIds: new Set(state.seenSourceIds),
1368
+ deliveryPending: false,
1369
+ ...(state.terminalCommand
1370
+ ? {
1371
+ terminalCommand: state.terminalCommand,
1372
+ committedTerminalCommand: state.terminalCommand,
1373
+ }
1374
+ : {}),
1375
+ });
1376
+ if (state.parentResponseId) {
1377
+ this.sink.onSubagentParentResponse?.(state.parentToolCallId, state.parentResponseId);
1378
+ }
1379
+ }
1380
+ }
1381
+ persistSubagentForwardStates() {
1382
+ const parentTranscriptPath = this.transcriptPath;
1383
+ if (!parentTranscriptPath)
1384
+ return;
1385
+ const states = [...this.nativeSubagents.values()]
1386
+ .sort((a, b) => a.agentId.localeCompare(b.agentId))
1387
+ .map((state) => ({
1388
+ agentId: state.agentId,
1389
+ parentToolCallId: state.parentToolCallId,
1390
+ ...(state.parentResponseId ? { parentResponseId: state.parentResponseId } : {}),
1391
+ transcriptPath: state.transcriptPath,
1392
+ byteOffset: state.committedOffset,
1393
+ lineCursor: state.committedLineCursor,
1394
+ seenSourceIds: [...state.seenSourceIds],
1395
+ ...(state.committedTerminalCommand
1396
+ ? { terminalCommand: state.committedTerminalCommand }
1397
+ : {}),
1398
+ }));
1399
+ writeSubagentForwardStates(this.bridgeDir, parentTranscriptPath, states, this.nativeSubagentParentResponses);
1400
+ }
1401
+ pollNativeSubagents() {
1402
+ const parentTranscriptPath = this.transcriptPath;
1403
+ if (!parentTranscriptPath)
1404
+ return;
1405
+ this.restoreSubagentForwardStates(parentTranscriptPath);
1406
+ const directory = join(parentTranscriptPath.replace(/\.jsonl$/, ""), "subagents");
1407
+ let metaFiles;
1408
+ try {
1409
+ metaFiles = readdirSync(directory)
1410
+ .filter((name) => /^agent-.+\.meta\.json$/.test(name))
1411
+ .sort();
1412
+ }
1413
+ catch {
1414
+ metaFiles = [];
1415
+ }
1416
+ for (const metaFile of metaFiles) {
1417
+ const agentId = metaFile.slice("agent-".length, -".meta.json".length);
1418
+ if (!agentId)
1419
+ continue;
1420
+ const meta = readNativeSubagentMeta(join(directory, metaFile));
1421
+ if (!meta)
1422
+ continue; // partial/malformed meta is retried next tick
1423
+ const transcriptPath = subagentTranscriptPath(parentTranscriptPath, agentId);
1424
+ let state = this.nativeSubagents.get(agentId);
1425
+ if (!state) {
1426
+ const parentResponseId = this.nativeSubagentParentResponses.get(meta.toolUseId) ??
1427
+ this.sink.resolveSubagentParentResponseId?.(meta.toolUseId);
1428
+ state = {
1429
+ agentId,
1430
+ parentToolCallId: meta.toolUseId,
1431
+ ...(parentResponseId ? { parentResponseId } : {}),
1432
+ transcriptPath,
1433
+ readOffset: 0,
1434
+ committedOffset: 0,
1435
+ lineCursor: 0,
1436
+ committedLineCursor: 0,
1437
+ seenSourceIds: new Set(),
1438
+ deliveryPending: false,
1439
+ };
1440
+ this.nativeSubagents.set(agentId, state);
1441
+ }
1442
+ }
1443
+ // Once registered, a child lane is owned by its persisted cursor. Claude
1444
+ // may remove or temporarily rewrite the discovery meta while the child
1445
+ // transcript is still growing; do not make continued delivery depend on
1446
+ // rediscovering that meta on every poll.
1447
+ for (const state of this.nativeSubagents.values()) {
1448
+ if (!state.parentResponseId) {
1449
+ const parentResponseId = this.nativeSubagentParentResponses.get(state.parentToolCallId) ?? this.sink.resolveSubagentParentResponseId?.(state.parentToolCallId);
1450
+ if (parentResponseId) {
1451
+ state.parentResponseId = parentResponseId;
1452
+ this.nativeSubagentParentResponses.set(state.parentToolCallId, parentResponseId);
1453
+ this.sink.onSubagentParentResponse?.(state.parentToolCallId, parentResponseId);
1454
+ this.persistSubagentForwardStates();
1455
+ }
1456
+ }
1457
+ else {
1458
+ this.sink.onSubagentParentResponse?.(state.parentToolCallId, state.parentResponseId);
1459
+ }
1460
+ this.pollNativeSubagent(state);
1461
+ }
1462
+ }
1463
+ pollNativeSubagent(state) {
1464
+ if (state.deliveryPending)
1465
+ return;
1466
+ const size = fileByteSize(state.transcriptPath);
1467
+ if (size === undefined)
1468
+ return;
1469
+ if (size < state.readOffset) {
1470
+ // Sub-agent state has no transcript fingerprint. A truncation rewinds to zero;
1471
+ // the bounded per-item seen set suppresses recent duplicates.
1472
+ state.readOffset = 0;
1473
+ state.committedOffset = 0;
1474
+ state.lineCursor = 0;
1475
+ state.committedLineCursor = 0;
1476
+ state.terminalCommand = undefined;
1477
+ state.committedTerminalCommand = undefined;
1478
+ }
1479
+ const { entries, nextOffset, nextLineCursor } = readJsonlEntriesFrom(state.transcriptPath, state.readOffset, state.lineCursor);
1480
+ state.readOffset = nextOffset;
1481
+ state.lineCursor = nextLineCursor;
1482
+ const deliveries = [];
1483
+ const fallbackSourceIds = [];
1484
+ for (const entry of entries) {
1485
+ const sourceBase = transcriptSourceBase(entry.record, entry.byteOffset, entry.lineNumber);
1486
+ if (state.seenSourceIds.has(sourceBase))
1487
+ continue;
1488
+ const queuedPrompt = queuedCommandPrompt(entry.record);
1489
+ const prompt = userStringContent(entry.record);
1490
+ const localCommand = transcriptLocalCommand(entry.record);
1491
+ const terminalCheckpoint = localCommand?.hasOutput
1492
+ ? { next: undefined }
1493
+ : localCommand?.command !== undefined
1494
+ ? { next: localCommand.command }
1495
+ : undefined;
1496
+ this.sink.onTranscriptRecordStart?.(sourceBase, (sourceId) => state.seenSourceIds.has(sourceId), {
1497
+ lane: `claude-subagent:${state.agentId}`,
1498
+ deadLetterPath: join(this.bridgeDir, "dead_letter.jsonl"),
1499
+ onHandled: (sourceId) => {
1500
+ if (!state.seenSourceIds.has(sourceId)) {
1501
+ state.seenSourceIds.add(sourceId);
1502
+ while (state.seenSourceIds.size > 2_000) {
1503
+ const oldest = state.seenSourceIds.values().next().value;
1504
+ if (!oldest)
1505
+ break;
1506
+ state.seenSourceIds.delete(oldest);
1507
+ }
1508
+ }
1509
+ if (terminalCheckpoint) {
1510
+ state.committedTerminalCommand = terminalCheckpoint.next;
1511
+ }
1512
+ this.persistSubagentForwardStates();
1513
+ },
1514
+ });
1515
+ if (queuedPrompt !== undefined) {
1516
+ this.sink.onSubagentUserMessage?.(queuedPrompt, state.parentToolCallId);
1517
+ }
1518
+ if (localCommand) {
1519
+ this.sink.onSubagentTerminalCommand?.(terminalCommandData(localCommand, state.terminalCommand), state.parentToolCallId);
1520
+ if (localCommand.command !== undefined)
1521
+ state.terminalCommand = localCommand.command;
1522
+ if (localCommand.hasOutput)
1523
+ state.terminalCommand = undefined;
1524
+ }
1525
+ else if (prompt !== undefined && !isClaudeUserScaffolding(prompt)) {
1526
+ if (isTaskNotificationText(prompt)) {
1527
+ this.sink.onMetaUserMessage?.(prompt, sourceBase, state.parentToolCallId);
1528
+ }
1529
+ else {
1530
+ this.sink.onSubagentUserMessage?.(prompt, state.parentToolCallId);
1531
+ }
1532
+ }
1533
+ for (const message of userListTextMessages(entry.record)) {
1534
+ if (message.isMeta) {
1535
+ this.sink.onMetaUserMessage?.(message.text, sourceBase, state.parentToolCallId);
1536
+ }
1537
+ else {
1538
+ this.sink.onSubagentUserMessage?.(message.text, state.parentToolCallId);
1539
+ }
1540
+ }
1541
+ for (const event of parseTranscriptRecord(entry.record, {
1542
+ parentToolUseId: state.parentToolCallId,
1543
+ })) {
1544
+ this.sink.onEvent(event);
1545
+ }
1546
+ const delivery = this.sink.onTranscriptRecordEnd?.();
1547
+ if (delivery)
1548
+ deliveries.push(delivery);
1549
+ else
1550
+ fallbackSourceIds.push(sourceBase);
1551
+ }
1552
+ const commit = (sourceGroups) => {
1553
+ for (const sourceId of [...fallbackSourceIds, ...sourceGroups.flat()]) {
1554
+ if (state.seenSourceIds.has(sourceId))
1555
+ continue;
1556
+ state.seenSourceIds.add(sourceId);
1557
+ while (state.seenSourceIds.size > 2_000) {
1558
+ const oldest = state.seenSourceIds.values().next().value;
1559
+ if (!oldest)
1560
+ break;
1561
+ state.seenSourceIds.delete(oldest);
1562
+ }
1563
+ }
1564
+ state.committedTerminalCommand = state.terminalCommand;
1565
+ state.committedOffset = nextOffset;
1566
+ state.committedLineCursor = nextLineCursor;
1567
+ this.persistSubagentForwardStates();
1568
+ };
1569
+ if (deliveries.length === 0) {
1570
+ if (nextOffset !== state.committedOffset)
1571
+ commit([]);
1572
+ return;
1573
+ }
1574
+ state.deliveryPending = true;
1575
+ void Promise.all(deliveries).then((sourceGroups) => commit(sourceGroups), (error) => {
1576
+ state.readOffset = state.committedOffset;
1577
+ state.lineCursor = state.committedLineCursor;
1578
+ state.terminalCommand = state.committedTerminalCommand;
1579
+ console.error(`[claude-forwarder] sub-agent ${state.agentId} delivery failed; retrying window:`, error);
1580
+ }).finally(() => {
1581
+ state.deliveryPending = false;
1582
+ });
519
1583
  }
520
1584
  /** Tail blocking hook requests after transcript records, so an interaction
521
1585
  * emitted in the same poll attaches to the user Turn that caused it. */
@@ -787,11 +1851,57 @@ export class ClaudeLiveSession {
787
1851
  if (oldest)
788
1852
  this.settledInteractions.delete(oldest);
789
1853
  }
1854
+ /** State change represented by this record once its durable terminal item is
1855
+ * handled. Parsing can run through multiple records before their deliveries
1856
+ * settle, so this checkpoint is intentionally separate from the live state. */
1857
+ terminalCommandCheckpointForRecord(rec) {
1858
+ if (rec.isSidechain === true || rec.isCompactSummary === true || rec.isMeta === true) {
1859
+ return undefined;
1860
+ }
1861
+ const localCommand = transcriptLocalCommand(rec);
1862
+ if (localCommand) {
1863
+ if (localCommand.hasOutput)
1864
+ return { next: undefined };
1865
+ if (localCommand.command !== undefined) {
1866
+ const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
1867
+ return {
1868
+ next: {
1869
+ command: localCommand.command,
1870
+ ...(turnId ? { turnId } : {}),
1871
+ },
1872
+ };
1873
+ }
1874
+ return undefined;
1875
+ }
1876
+ return this.activeTerminalCommand ? { next: undefined } : undefined;
1877
+ }
790
1878
  handleRecord(rec) {
791
1879
  if (rec.isSidechain === true)
792
1880
  return; // sub-agent turns (Phase 9 forwards them)
1881
+ if (rec.isCompactSummary === true) {
1882
+ this.handleCompactSummary(rec);
1883
+ return;
1884
+ }
793
1885
  if (rec.isMeta === true)
794
1886
  return; // Claude-generated UI metadata, not a human message
1887
+ const stringContent = userStringContent(rec);
1888
+ const localCommand = transcriptLocalCommand(rec);
1889
+ if (localCommand) {
1890
+ this.emitTerminalCommand(rec, localCommand);
1891
+ return;
1892
+ }
1893
+ if (this.activeTerminalCommand) {
1894
+ this.closeTurn();
1895
+ this.activeTerminalCommand = undefined;
1896
+ }
1897
+ const queuedPrompt = queuedCommandPrompt(rec);
1898
+ if (queuedPrompt !== undefined) {
1899
+ for (const candidate of submissionCandidates(queuedPrompt)) {
1900
+ this.rememberSubmission(candidate);
1901
+ }
1902
+ this.emitUserMessageRecord(rec, queuedPrompt, false);
1903
+ return;
1904
+ }
795
1905
  // Type-ahead is acknowledged at enqueue time. Its later
796
1906
  // `promptSource:"queued"` user record must not count again: another
797
1907
  // identical web message may already be awaiting its own acknowledgement.
@@ -809,18 +1919,14 @@ export class ClaudeLiveSession {
809
1919
  }
810
1920
  return;
811
1921
  }
812
- // Secondary dedup: skip a record whose source id was already forwarded (a
813
- // re-read after a fingerprint reset). rynx emits a record's items atomically,
814
- // so the record uuid is the source key (vs reference implementation's per-block key for its
815
- // per-item POST). Records without a uuid fall through undeduped (rare).
816
- const sourceId = typeof rec.uuid === "string" && rec.uuid ? rec.uuid : undefined;
817
- if (sourceId) {
818
- if (this.seenSourceIds.has(sourceId))
819
- return;
820
- this.seenSourceIds.add(sourceId);
821
- }
822
- const content = userStringContent(rec);
1922
+ const content = stringContent;
823
1923
  if (content !== undefined) {
1924
+ const firstLine = content.trim().split("\n", 1)[0] ?? "";
1925
+ if (CLAUDE_INTERRUPT_RECORD_RE.test(firstLine)) {
1926
+ this.noteInterrupted();
1927
+ this.lastActivityAt = this.now();
1928
+ return;
1929
+ }
824
1930
  const candidates = submissionCandidates(content);
825
1931
  const queuedPromotion = rec.promptSource !== "typed" &&
826
1932
  candidates.some((candidate) => this.consumeQueuedPromotion(candidate));
@@ -834,38 +1940,32 @@ export class ClaudeLiveSession {
834
1940
  for (const candidate of candidates)
835
1941
  this.rememberSubmission(candidate);
836
1942
  }
837
- // A local `!` command records its input+output as `<bash-*>` markers — mirror
838
- // it as a self-contained terminal_command turn (before the prompt check, as
839
- // it too is `<`-prefixed).
840
- const term = parseTerminalCommand(content);
841
- if (term) {
842
- this.emitTerminalCommand(rec, term);
1943
+ if (isClaudeUserScaffolding(content))
843
1944
  return;
1945
+ if (isTaskNotificationText(content)) {
1946
+ this.sink.onMetaUserMessage?.(content, rec.uuid ?? rec.requestId);
844
1947
  }
845
- // A real prompt (not an XML-marker bookkeeping record) opens a new turn.
846
- if (!content.startsWith("<")) {
847
- const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
848
- if (this.turnOpen && this.syntheticTurn) {
849
- // A hook can flush just before the transcript's user record. The
850
- // interaction already opened a unique provisional response; adopt the
851
- // real turn id internally without closing/cancelling that same Turn.
852
- this.currentTurnId = turnId;
853
- this.syntheticTurn = false;
854
- }
855
- else {
856
- this.closeTurn();
857
- this.currentTurnId = turnId;
858
- this.turnOpen = true;
859
- this.syntheticTurn = false;
860
- this.openToolIds.clear();
861
- this.stopPendingAt = null;
862
- this.sink.onTurnStart(this.currentTurnId);
863
- }
864
- this.sink.onUserMessage(content);
865
- this.lastActivityAt = this.now();
1948
+ else {
1949
+ this.emitUserMessageRecord(rec, content, queuedPromotion);
866
1950
  }
867
1951
  return; // string-content user record: prompt, terminal_command, or skipped marker
868
1952
  }
1953
+ for (const message of userListTextMessages(rec)) {
1954
+ if (message.isMeta) {
1955
+ this.sink.onMetaUserMessage?.(message.text, rec.uuid ?? rec.requestId);
1956
+ continue;
1957
+ }
1958
+ const candidates = submissionCandidates(message.text);
1959
+ const queuedPromotion = rec.promptSource !== "typed" &&
1960
+ candidates.some((candidate) => this.consumeQueuedPromotion(candidate));
1961
+ if (!queuedPromotion &&
1962
+ rec.promptSource !== "queued" &&
1963
+ rec.promptSource !== "sdk") {
1964
+ for (const candidate of candidates)
1965
+ this.rememberSubmission(candidate);
1966
+ }
1967
+ this.emitUserMessageRecord(rec, message.text, queuedPromotion);
1968
+ }
869
1969
  const events = parseTranscriptRecord(rec);
870
1970
  if (events.length === 0)
871
1971
  return;
@@ -873,15 +1973,75 @@ export class ClaudeLiveSession {
873
1973
  for (const event of events) {
874
1974
  const mapped = this.remapMessageItem(event);
875
1975
  this.trackTool(mapped);
1976
+ this.rememberNativeSubagentParentResponse(mapped);
876
1977
  this.sink.onEvent(mapped);
877
1978
  }
878
- // A Task tool_result carries `toolUseResult.agentId` — replay the (now
879
- // complete) sub-agent transcript nested under this parent Task call.
880
- this.maybeForwardSubagent(rec);
881
1979
  // TaskCreate/TaskUpdate records also refresh the task-list snapshot.
882
1980
  this.maybeUpdateTodos(rec);
883
1981
  this.lastActivityAt = this.now();
884
1982
  }
1983
+ emitUserMessageRecord(rec, content, queuedPromotion) {
1984
+ const queuedIntoOpenTurn = this.turnOpen && (queuedPromotion || rec.promptSource === "queued" || rec.promptSource === "sdk");
1985
+ if (queuedIntoOpenTurn) {
1986
+ // Claude type-ahead is one native busy interval: enqueue now, promote
1987
+ // later, and emit one final Stop. Do not close/re-open the canonical
1988
+ // response when the promoted user record arrives mid-turn.
1989
+ this.sink.onUserMessage(content);
1990
+ this.lastActivityAt = this.now();
1991
+ return;
1992
+ }
1993
+ const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
1994
+ if (this.turnOpen && this.syntheticTurn) {
1995
+ // A hook can flush just before the transcript's user record. The
1996
+ // interaction already opened a unique provisional response; adopt the
1997
+ // real turn id internally without closing/cancelling that same Turn.
1998
+ this.currentTurnId = turnId;
1999
+ this.syntheticTurn = false;
2000
+ }
2001
+ else {
2002
+ this.closeTurn();
2003
+ this.currentTurnId = turnId;
2004
+ this.turnOpen = true;
2005
+ this.turnInterrupted = false;
2006
+ this.syntheticTurn = false;
2007
+ this.providerIdleAt = null;
2008
+ this.openToolIds.clear();
2009
+ this.stopPendingAt = null;
2010
+ this.stopBackgroundTaskCount = undefined;
2011
+ this.stopBackgroundTaskCountDelivered = false;
2012
+ this.sink.onTurnStart(this.currentTurnId);
2013
+ }
2014
+ this.sink.onUserMessage(content);
2015
+ this.lastActivityAt = this.now();
2016
+ }
2017
+ handleCompactSummary(rec) {
2018
+ const state = this.readCompactionState();
2019
+ if (!this.compactionPendingMatches(state)) {
2020
+ if (state.persistedSequences.length === 0 && !state.expectCompletionAckSequence) {
2021
+ console.warn("[claude-forwarder] skipping isCompactSummary without a pending PreCompact token");
2022
+ }
2023
+ if (state.expectCompletionAckSequence) {
2024
+ const { expectCompletionAckSequence: _omitted, ...withoutAck } = state;
2025
+ writeCompactionForwardState(this.bridgeDir, withoutAck);
2026
+ }
2027
+ return;
2028
+ }
2029
+ const sequence = state.pending.sequence;
2030
+ const content = rec.message?.content;
2031
+ const summary = typeof content === "string"
2032
+ ? content
2033
+ : Array.isArray(content)
2034
+ ? content.flatMap((block) => isRecord(block) && typeof block.text === "string" ? [block.text] : []).join("\n")
2035
+ : "";
2036
+ const delivery = this.sink.onCompactionBoundary?.(summary || "[Claude Code compaction — context was compacted in the terminal]", sequence, "transcript");
2037
+ if (delivery === undefined)
2038
+ return;
2039
+ this.pendingTranscriptCompactions.push({
2040
+ sequence,
2041
+ generation: state.generation,
2042
+ delivery: Promise.resolve(delivery),
2043
+ });
2044
+ }
885
2045
  /** Fold a `TaskCreate` (new pending task) or `TaskUpdate` (status/subject/delete)
886
2046
  * record into the task list; on any change emit the whole list as a snapshot. */
887
2047
  maybeUpdateTodos(rec) {
@@ -908,28 +2068,6 @@ export class ClaudeLiveSession {
908
2068
  if (changed)
909
2069
  this.sink.onTodos([...this.todos.values()]);
910
2070
  }
911
- /** On a `Task` tool_result, replay the sub-agent's own transcript
912
- * (`subagents/agent-<agentId>.jsonl`) as events tagged with the parent Task
913
- * tool-use id, so the canonical layer nests them under that call. Fired once
914
- * per sub-agent (at tool_result time the file is complete — no live race). */
915
- maybeForwardSubagent(rec) {
916
- if (!this.transcriptPath)
917
- return;
918
- const result = rec.toolUseResult;
919
- const agentId = isRecord(result) && typeof result.agentId === "string" ? result.agentId : undefined;
920
- if (!agentId || this.seenSubagents.has(agentId))
921
- return;
922
- const parentToolUseId = toolResultCallId(rec);
923
- if (!parentToolUseId)
924
- return;
925
- this.seenSubagents.add(agentId);
926
- const file = subagentTranscriptPath(this.transcriptPath, agentId);
927
- // Emit raw (no remap/track): sub-agent messages have their own itemIds and
928
- // must not consume the parent's MessageDisplay FIFO, and their tool pairs are
929
- // already complete so they need no open-tool tracking.
930
- for (const event of readSubagentEvents(file, parentToolUseId))
931
- this.sink.onEvent(event);
932
- }
933
2071
  /** Tail streamed assistant-text chunks (MessageDisplay) into live `token`
934
2072
  * events. Guarded on an open turn — deltas belong to the turn the user record
935
2073
  * opened; the offset advances only once processed. */
@@ -938,9 +2076,21 @@ export class ClaudeLiveSession {
938
2076
  this.deltasOffset = nextOffset;
939
2077
  // MessageDisplay has no Turn id. Chunks observed while idle belong to the
940
2078
  // Turn that just closed and must never be replayed into a later Turn.
941
- if (!this.turnOpen)
2079
+ if (!this.turnOpen) {
2080
+ writeDeltaForwardState(this.bridgeDir, { byteOffset: nextOffset });
942
2081
  return;
2082
+ }
943
2083
  for (const d of deltas) {
2084
+ const key = `${d.messageId}:${d.index}`;
2085
+ if (this.seenDeltaKeys.has(key))
2086
+ continue;
2087
+ this.seenDeltaKeys.add(key);
2088
+ while (this.seenDeltaKeys.size > 5_000) {
2089
+ const oldest = this.seenDeltaKeys.values().next().value;
2090
+ if (!oldest)
2091
+ break;
2092
+ this.seenDeltaKeys.delete(oldest);
2093
+ }
944
2094
  this.streamedMessageText.set(d.messageId, (this.streamedMessageText.get(d.messageId) ?? "") + d.delta);
945
2095
  this.sink.onEvent({
946
2096
  type: "token",
@@ -956,6 +2106,9 @@ export class ClaudeLiveSession {
956
2106
  this.finalizeMessageDisplay(d.messageId);
957
2107
  }
958
2108
  }
2109
+ if (nextOffset > 0 || deltas.length > 0) {
2110
+ writeDeltaForwardState(this.bridgeDir, { byteOffset: nextOffset });
2111
+ }
959
2112
  if (deltas.length)
960
2113
  this.lastActivityAt = this.now();
961
2114
  }
@@ -978,6 +2131,34 @@ export class ClaudeLiveSession {
978
2131
  if (status)
979
2132
  this.latestStatus = status;
980
2133
  }
2134
+ pollSessionStatus() {
2135
+ const poller = this.statusPoller;
2136
+ if (!poller)
2137
+ return;
2138
+ const wasActive = poller.active;
2139
+ poller.tick();
2140
+ if (wasActive && !poller.active)
2141
+ this.providerIdleAt = null;
2142
+ }
2143
+ handleSessionStatus(status) {
2144
+ if (this.pendingTerminalHook?.eventName !== "Stop") {
2145
+ this.stopSignalPending = false;
2146
+ this.stopPendingAt = null;
2147
+ }
2148
+ if (status.runnerStatus === "running") {
2149
+ this.providerFailureSticky = false;
2150
+ // A new native busy interval supersedes the prior Stop snapshot. The
2151
+ // Session running edge clears its sticky tally; do not re-emit that old
2152
+ // count if the previous transcript Turn closes a moment later.
2153
+ this.stopBackgroundTaskCount = undefined;
2154
+ this.stopBackgroundTaskCountDelivered = false;
2155
+ }
2156
+ else if (this.providerFailureSticky) {
2157
+ return;
2158
+ }
2159
+ this.sink.onStatus?.(status.runnerStatus, status.blockedOn);
2160
+ this.providerIdleAt = status.runnerStatus === "idle" ? this.now() : null;
2161
+ }
981
2162
  /** A usage record from the latest statusLine snapshot (snake_case, the keys the
982
2163
  * normalizer's `turn_completed` + the web `readUsageTokens` read), or undefined
983
2164
  * if the statusLine hook has not fired yet. */
@@ -1035,6 +2216,25 @@ export class ClaudeLiveSession {
1035
2216
  this.openToolIds.delete(id);
1036
2217
  }
1037
2218
  }
2219
+ rememberNativeSubagentParentResponse(event) {
2220
+ if (event.type !== "tool" || event.event !== "on_tool_start")
2221
+ return;
2222
+ const parentToolCallId = readId(event.input) ?? readId(event.data);
2223
+ if (!parentToolCallId || this.nativeSubagentParentResponses.has(parentToolCallId))
2224
+ return;
2225
+ const responseId = this.currentTurnId
2226
+ ? `resp_claude_${this.currentTurnId}`
2227
+ : "resp_claude_native";
2228
+ this.nativeSubagentParentResponses.set(parentToolCallId, responseId);
2229
+ while (this.nativeSubagentParentResponses.size > 2_000) {
2230
+ const oldest = this.nativeSubagentParentResponses.keys().next().value;
2231
+ if (!oldest)
2232
+ break;
2233
+ this.nativeSubagentParentResponses.delete(oldest);
2234
+ }
2235
+ this.sink.onSubagentParentResponse?.(parentToolCallId, responseId);
2236
+ this.persistSubagentForwardStates();
2237
+ }
1038
2238
  ensureTurn(provisionalTurnId) {
1039
2239
  if (this.turnOpen)
1040
2240
  return;
@@ -1042,18 +2242,37 @@ export class ClaudeLiveSession {
1042
2242
  this.currentTurnId = provisionalTurnId;
1043
2243
  this.syntheticTurn = true;
1044
2244
  }
2245
+ this.providerIdleAt = null;
1045
2246
  this.turnOpen = true;
2247
+ this.turnInterrupted = false;
2248
+ this.stopBackgroundTaskCount = undefined;
2249
+ this.stopBackgroundTaskCountDelivered = false;
1046
2250
  this.sink.onTurnStart(this.currentTurnId);
1047
2251
  }
1048
- /** Mirror a local `!` command as its own mini-turn: close any open turn, then
1049
- * open→emit→close so it groups as one response with a stable id (the record
1050
- * uuid) and never lingers "running" (input+output are complete in one record). */
1051
- emitTerminalCommand(rec, cmd) {
1052
- this.closeTurn();
1053
- const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
1054
- this.sink.onTurnStart(turnId);
1055
- this.sink.onTerminalCommand(cmd);
1056
- this.sink.onTurnEnd();
2252
+ /** Mirror a Claude `!cmd` as one logical mini-turn even when input and output
2253
+ * are separate transcript records. The output repeats the remembered command
2254
+ * in Rynx's combined terminal item shape; the transcript projection merges
2255
+ * that completion into the preceding command row. */
2256
+ emitTerminalCommand(rec, fragments) {
2257
+ if (fragments.command !== undefined) {
2258
+ this.closeTurn();
2259
+ const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
2260
+ this.ensureTurn(turnId);
2261
+ this.syntheticTurn = false;
2262
+ this.activeTerminalCommand = { command: fragments.command, ...(turnId ? { turnId } : {}) };
2263
+ }
2264
+ else if (!this.turnOpen) {
2265
+ const turnId = this.activeTerminalCommand?.turnId ??
2266
+ (typeof rec.uuid === "string" ? rec.uuid : undefined);
2267
+ this.ensureTurn(turnId);
2268
+ this.syntheticTurn = false;
2269
+ }
2270
+ this.sink.onTerminalCommand(terminalCommandData(fragments, this.activeTerminalCommand?.command));
2271
+ this.lastActivityAt = this.now();
2272
+ if (fragments.hasOutput) {
2273
+ this.closeTurn();
2274
+ this.activeTerminalCommand = undefined;
2275
+ }
1057
2276
  }
1058
2277
  /** The web Stop button interrupted this session (host sent Escape). claude
1059
2278
  * records the interrupt in its own transcript but may not fire a Stop hook when
@@ -1063,10 +2282,14 @@ export class ClaudeLiveSession {
1063
2282
  * turn's close via the same short grace. The Escape has stopped claude, so this
1064
2283
  * does not race a still-running response. */
1065
2284
  noteInterrupted() {
1066
- if (!this.turnOpen)
2285
+ if (!this.turnOpen || this.turnInterrupted)
1067
2286
  return;
2287
+ this.turnInterrupted = true;
2288
+ this.sink.onTurnInterruptRequested?.();
1068
2289
  this.cancelPendingInteractions("turn_interrupted");
1069
2290
  this.stopSignalPending = false;
2291
+ if (this.statusPoller?.active)
2292
+ return;
1070
2293
  this.sink.onIdle();
1071
2294
  this.stopPendingAt = this.now();
1072
2295
  }
@@ -1077,7 +2300,22 @@ export class ClaudeLiveSession {
1077
2300
  isTurnOpen() {
1078
2301
  return this.turnOpen;
1079
2302
  }
1080
- closeTurn() {
2303
+ /** Fail an open turn exactly once when its native terminal/runner disappears. */
2304
+ failOpenTurn(error) {
2305
+ if (!this.turnOpen)
2306
+ return false;
2307
+ this.closeTurnError(error);
2308
+ return true;
2309
+ }
2310
+ /** Retire process-scoped metadata before classifying a terminal exit. */
2311
+ noteTerminalExit(error) {
2312
+ this.statusPoller?.retire();
2313
+ const failedOpenTurn = this.failOpenTurn(error);
2314
+ if (!failedOpenTurn)
2315
+ this.sink.onTurnError(error);
2316
+ return failedOpenTurn;
2317
+ }
2318
+ closeTurn(reason) {
1081
2319
  if (!this.turnOpen)
1082
2320
  return;
1083
2321
  this.drainInteractionCommits();
@@ -1089,9 +2327,22 @@ export class ClaudeLiveSession {
1089
2327
  this.stopSignalPending = false;
1090
2328
  this.stopPendingAt = null;
1091
2329
  this.resetMessageCorrelation();
1092
- this.sink.onTurnEnd(this.statusUsage());
2330
+ const interrupted = this.turnInterrupted;
2331
+ this.turnInterrupted = false;
2332
+ const usage = this.statusUsage();
2333
+ const backgroundTaskCount = this.stopBackgroundTaskCountDelivered
2334
+ ? undefined
2335
+ : this.stopBackgroundTaskCount;
2336
+ this.stopBackgroundTaskCount = undefined;
2337
+ this.stopBackgroundTaskCountDelivered = false;
2338
+ if (interrupted && this.sink.onTurnInterrupted)
2339
+ this.sink.onTurnInterrupted(usage);
2340
+ else
2341
+ this.sink.onTurnEnd(usage, backgroundTaskCount, reason);
2342
+ if (!this.transcriptDeliveryPending)
2343
+ this.persistForwardState();
1093
2344
  }
1094
- closeTurnError(error) {
2345
+ closeTurnError(error, sourceId) {
1095
2346
  if (!this.turnOpen)
1096
2347
  return;
1097
2348
  this.drainInteractionCommits();
@@ -1100,10 +2351,16 @@ export class ClaudeLiveSession {
1100
2351
  this.currentTurnId = undefined;
1101
2352
  this.syntheticTurn = false;
1102
2353
  this.openToolIds.clear();
2354
+ this.turnInterrupted = false;
1103
2355
  this.stopSignalPending = false;
1104
2356
  this.stopPendingAt = null;
2357
+ this.stopBackgroundTaskCount = undefined;
2358
+ this.stopBackgroundTaskCountDelivered = false;
1105
2359
  this.resetMessageCorrelation();
1106
- this.sink.onTurnError(error);
2360
+ const delivery = this.sink.onTurnError(error, sourceId);
2361
+ if (!this.transcriptDeliveryPending)
2362
+ this.persistForwardState();
2363
+ return delivery;
1107
2364
  }
1108
2365
  resetMessageCorrelation() {
1109
2366
  this.finalizedMessageIds.clear();
@@ -1112,6 +2369,14 @@ export class ClaudeLiveSession {
1112
2369
  this.streamedMessageText.clear();
1113
2370
  }
1114
2371
  maybeIdleClose() {
2372
+ if (this.statusPoller?.active) {
2373
+ if (this.turnOpen &&
2374
+ this.providerIdleAt !== null &&
2375
+ this.now() - this.providerIdleAt >= this.stopGraceMs) {
2376
+ this.closeTurn();
2377
+ }
2378
+ return;
2379
+ }
1115
2380
  if (!this.turnOpen ||
1116
2381
  this.openToolIds.size > 0 ||
1117
2382
  this.pendingInteractions.size > 0)
@@ -1132,14 +2397,79 @@ export class ClaudeLiveSession {
1132
2397
  /** Claude Code renders this glyph once the input box is mounted (ready-gate). */
1133
2398
  const CLAUDE_PROMPT_GLYPH = "❯";
1134
2399
  const CLAUDE_PROMPT_SCAN_TAIL_LINES = 5;
2400
+ const CLAUDE_BOX_RULE_CHARS = new Set([..."─━╭╮╰╯│┃╌╍"]);
2401
+ const CLAUDE_PASTED_PLACEHOLDER_PREFIX = "[Pasted text";
2402
+ const CLAUDE_DRAFT_NEEDLE_MAX_CHARS = 24;
2403
+ /** Claude-native readiness window: tmux may exist well before the
2404
+ * first interactive composer mounts on a cold start. */
2405
+ const CLAUDE_PROMPT_READY_TIMEOUT_MS = 30_000;
2406
+ const CLAUDE_PASTE_COMMIT_TIMEOUT_MS = 5_000;
2407
+ const CLAUDE_PASTE_SETTLE_MS = 100;
2408
+ const CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS = 10_000;
2409
+ const CLAUDE_SUBMIT_RETRY_MS = 1_000;
2410
+ function selectedMenuRow(line, glyph) {
2411
+ const index = line.indexOf(glyph);
2412
+ if (index < 0)
2413
+ return false;
2414
+ return /^\d+\.\s/.test(line.slice(index + glyph.length).trimStart());
2415
+ }
2416
+ function boxRule(line) {
2417
+ const text = line.trim();
2418
+ return text.length >= 3 && [...text].every((character) => CLAUDE_BOX_RULE_CHARS.has(character));
2419
+ }
1135
2420
  /** The live composer sits at the bottom of the pane. Restricting readiness to
1136
2421
  * its trailing non-empty lines prevents an old prompt glyph in scrollback from
1137
2422
  * accepting input while Claude is still booting or showing another screen. */
1138
2423
  function claudePromptRendered(pane, glyph) {
1139
2424
  const nonEmpty = pane.split(/\r?\n/).filter((line) => line.trim());
1140
- return nonEmpty
1141
- .slice(-CLAUDE_PROMPT_SCAN_TAIL_LINES)
1142
- .some((line) => line.includes(glyph));
2425
+ const tailStart = Math.max(0, nonEmpty.length - CLAUDE_PROMPT_SCAN_TAIL_LINES);
2426
+ for (let index = tailStart; index < nonEmpty.length; index += 1) {
2427
+ const line = nonEmpty[index] ?? "";
2428
+ if (!line.includes(glyph))
2429
+ continue;
2430
+ const framed = nonEmpty.slice(index + 1).some(boxRule);
2431
+ if (framed)
2432
+ return true;
2433
+ if (!selectedMenuRow(line, glyph))
2434
+ return true;
2435
+ }
2436
+ // A fan-out of subagents can make the running footer arbitrarily tall. Above
2437
+ // the fixed tail, only a prompt framed by the composer's closing rule counts.
2438
+ for (let index = 0; index < tailStart; index += 1) {
2439
+ const line = nonEmpty[index] ?? "";
2440
+ if (line.includes(glyph) && nonEmpty.slice(index + 1).some(boxRule))
2441
+ return true;
2442
+ }
2443
+ return false;
2444
+ }
2445
+ function draftNeedle(content) {
2446
+ const normalized = content.replace(/\r\n?/g, "\n");
2447
+ for (const rawLine of normalized.split("\n")) {
2448
+ const control = rawLine.search(/[\x00-\x1f]/);
2449
+ const line = (control >= 0 ? rawLine.slice(0, control) : rawLine).trim();
2450
+ if (line)
2451
+ return line.slice(0, CLAUDE_DRAFT_NEEDLE_MAX_CHARS);
2452
+ }
2453
+ return "";
2454
+ }
2455
+ function draftInInputBox(pane, glyph, needle) {
2456
+ const lines = pane.split(/\r?\n/).filter((line) => line.includes(glyph));
2457
+ const line = lines.at(-1);
2458
+ if (!line)
2459
+ return false;
2460
+ const tail = line.slice(line.lastIndexOf(glyph) + glyph.length);
2461
+ if (tail.includes(CLAUDE_PASTED_PLACEHOLDER_PREFIX))
2462
+ return true;
2463
+ return Boolean(needle) && tail.includes(needle);
2464
+ }
2465
+ function terminalFailureTail(pane) {
2466
+ const lines = pane.split(/\r?\n/).filter((line) => line.trim()).slice(-12);
2467
+ if (lines.length === 0)
2468
+ return "";
2469
+ let tail = lines.join("\n");
2470
+ if (tail.length > 800)
2471
+ tail = `…${tail.slice(-800)}`;
2472
+ return `\nlast terminal output:\n${tail}`;
1143
2473
  }
1144
2474
  async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
1145
2475
  const deadline = now() + timeoutMs;
@@ -1159,9 +2489,9 @@ async function pollUntil(pred, timeoutMs, pollMs, now, sleep, signal) {
1159
2489
  *
1160
2490
  * THROWS if the prompt never appears within the ready-gate window (reference implementation
1161
2491
  * `_wait_for_claude_prompt_ready` RAISE) — a not-ready pane is a hard error the
1162
- * caller reports, NOT a signal to fall through to a second output path. With a
1163
- * transcript observer, Enter is retried only when Claude has durably recorded
1164
- * neither a direct user prompt nor a type-ahead enqueue.
2492
+ * caller reports, NOT a signal to fall through to a second output path. Enter
2493
+ * is retried only while the exact draft remains visible and Claude has durably
2494
+ * recorded neither a direct user prompt nor a type-ahead enqueue.
1165
2495
  */
1166
2496
  export async function injectViaTerminal(injector, text, opts = {}) {
1167
2497
  const glyph = opts.promptGlyph ?? CLAUDE_PROMPT_GLYPH;
@@ -1180,12 +2510,23 @@ export async function injectViaTerminal(injector, text, opts = {}) {
1180
2510
  };
1181
2511
  // 1. Ready-gate. No prompt within the window → THROW (reference implementation RAISE): a
1182
2512
  // not-ready pane is a hard error, never a fall-through-to-run signal.
1183
- const ready = await pollUntil(() => claudePromptRendered(injector.capturePane(), glyph), opts.promptTimeoutMs ?? 20_000, pollMs, now, sleep, signal);
2513
+ let promptPolls = 0;
2514
+ let emptyPromptPolls = 0;
2515
+ let lastPromptPane = "";
2516
+ const ready = await pollUntil(() => {
2517
+ const pane = injector.capturePane();
2518
+ promptPolls += 1;
2519
+ if (pane.trim())
2520
+ lastPromptPane = pane;
2521
+ else
2522
+ emptyPromptPolls += 1;
2523
+ return claudePromptRendered(pane, glyph);
2524
+ }, opts.promptTimeoutMs ?? CLAUDE_PROMPT_READY_TIMEOUT_MS, pollMs, now, sleep, signal);
1184
2525
  if (cancelled())
1185
2526
  return false;
1186
2527
  if (!ready) {
1187
- const tail = injector.capturePane().split("\n").slice(-5).join("\n");
1188
- throw new Error(`claude prompt not ready (no "${glyph}" within timeout)\npane tail:\n${tail}`);
2528
+ throw new Error(`claude prompt not ready (no input composer within timeout; ${promptPolls} polls, ` +
2529
+ `${emptyPromptPolls} empty captures)${terminalFailureTail(lastPromptPane)}`);
1189
2530
  }
1190
2531
  // 2. Clear leftover, then bracketed-paste the draft. A final "\" is Claude's
1191
2532
  // documented soft-newline escape: a bare submit Enter would consume it and
@@ -1193,30 +2534,47 @@ export async function injectViaTerminal(injector, text, opts = {}) {
1193
2534
  // the bracketed paste; normal messages must not gain an empty input row.
1194
2535
  injector.clearInputLine();
1195
2536
  injector.paste(text.endsWith("\\") ? `${text}\n` : text);
1196
- // 3. `paste-buffer` returns after tmux wrote the bracketed-paste frame. Give
1197
- // Claude a short, fixed settle window before the separate submit key; screen
1198
- // text is not used as an acknowledgement because submitted prompts remain in
1199
- // scrollback and are indistinguishable from an editable draft.
1200
- await sleep(opts.settleMs ?? 500);
2537
+ // 3. tmux accepting `paste-buffer` does not mean Claude consumed it. Wait for
2538
+ // the draft (or its large-paste placeholder) to appear in the live composer;
2539
+ // otherwise Enter can be coalesced into the paste as a newline.
2540
+ const needle = draftNeedle(text);
2541
+ const draftSeen = await pollUntil(() => draftInInputBox(injector.capturePane(), glyph, needle), opts.pasteCommitMs ?? CLAUDE_PASTE_COMMIT_TIMEOUT_MS, pollMs, now, sleep, signal);
2542
+ await sleep(opts.settleMs ?? CLAUDE_PASTE_SETTLE_MS);
1201
2543
  if (cancelled())
1202
2544
  return false; // Stop pressed mid-paste → don't submit
1203
- // 4. Submit. Without a transcript observer this remains a one-Enter
1204
- // best-effort operation. With one, a bounded retry handles an Enter swallowed
1205
- // by the TUI without ever consulting stale pane text. A direct prompt or
1206
- // queue enqueue ends the loop immediately.
2545
+ // 4. Submit and verify. Retry only while this exact draft is still visible;
2546
+ // once it leaves the composer, another Enter could hit a permission dialog or
2547
+ // an empty prompt. Transcript acknowledgement is the strongest success proof.
1207
2548
  const observed = opts.submissionObserved;
1208
- const attempts = observed
1209
- ? Math.max(1, Math.floor(opts.maxSubmitAttempts ?? 2))
1210
- : 1;
1211
- for (let attempt = 0; attempt < attempts; attempt += 1) {
1212
- injector.sendEnter();
1213
- if (!observed)
2549
+ let attempts = 1;
2550
+ const maxAttempts = Math.max(1, Math.floor(opts.maxSubmitAttempts ?? Number.MAX_SAFE_INTEGER));
2551
+ injector.sendEnter();
2552
+ // Preserve the old blind-submit fallback when capture-pane cannot
2553
+ // identify the draft; its absence would otherwise "prove" success trivially.
2554
+ if (!draftSeen)
2555
+ return true;
2556
+ const deadline = now() + (opts.submitConfirmMs ?? CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS);
2557
+ const retryMs = opts.submitRetryMs ?? CLAUDE_SUBMIT_RETRY_MS;
2558
+ let lastEnterAt = now();
2559
+ while (now() < deadline) {
2560
+ if (cancelled())
2561
+ return false;
2562
+ if (observed?.())
1214
2563
  return true;
1215
- if (await pollUntil(observed, opts.submitConfirmMs ?? 2_000, pollMs, now, sleep, signal)) {
2564
+ if (!draftInInputBox(injector.capturePane(), glyph, needle))
1216
2565
  return true;
2566
+ if (now() - lastEnterAt >= retryMs && attempts < maxAttempts) {
2567
+ injector.sendEnter();
2568
+ attempts += 1;
2569
+ lastEnterAt = now();
1217
2570
  }
1218
- if (cancelled())
1219
- return false;
2571
+ await sleep(pollMs);
1220
2572
  }
1221
- return false;
2573
+ if (observed?.())
2574
+ return true;
2575
+ if (!draftInInputBox(injector.capturePane(), glyph, needle))
2576
+ return true;
2577
+ throw new Error(`Claude Code did not accept the submitted message within ` +
2578
+ `${opts.submitConfirmMs ?? CLAUDE_SUBMIT_CONFIRM_TIMEOUT_MS}ms ` +
2579
+ "(the draft is still in the input box). The message was not delivered.");
1222
2580
  }