@rynx-ai/runtime 0.1.11-beta.37 → 0.1.11-beta.39

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.
@@ -18,11 +18,17 @@
18
18
  * backstop — but only when no tool call is still open, so a long Bash never trips
19
19
  * a false turn-end.
20
20
  */
21
- import { statSync } from "node:fs";
22
- import { parseTerminalCommand, parseTranscriptRecord, readSubagentEvents, subagentTranscriptPath, transcriptHasForkedFrom, } from "./transcript.js";
23
- import { HOOKS_FILE, jsonlCursorFingerprint, interactionLeaseUpdatedAt, readClaimedInteractionResult, readClaudeStatus, readForwardState, readHookForwardState, readInteractionAcksFrom, readHookEventsFrom, readInteractionRequestsFrom, readJsonlFrom, readMessageDeltasFrom, resetForwardState, removeClaimedInteractionResult, removeInteractionLease, removeInteractionResult, scrubClaudeInteractionArtifacts, writeInteractionResult, writeForwardState, writeHookForwardState, } 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";
24
25
  import { boundInteractionRequest, redactInteractionResolution, validateInteractionResolution, } from "../interactions.js";
25
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
+ }
26
32
  const MAX_SETTLED_INTERACTIONS = 512;
27
33
  const MAX_MESSAGE_CORRELATION_BACKLOG = 64;
28
34
  const MAX_SUBMISSION_OBSERVATIONS = 64;
@@ -56,6 +62,76 @@ function userStringContent(rec) {
56
62
  return undefined; // tool_result records are arrays
57
63
  return content.trim() || undefined;
58
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
+ }
59
135
  function submissionText(value) {
60
136
  if (typeof value !== "string")
61
137
  return undefined;
@@ -83,15 +159,18 @@ function isRecord(value) {
83
159
  function readId(value) {
84
160
  return isRecord(value) && typeof value.id === "string" && value.id ? value.id : undefined;
85
161
  }
86
- /** Byte size of a file, or 0 if it can't be stat'd (used to start a fork tail at EOF). */
87
- function fileSize(path) {
162
+ function fileByteSize(path) {
88
163
  try {
89
164
  return statSync(path).size;
90
165
  }
91
166
  catch {
92
- return 0;
167
+ return undefined;
93
168
  }
94
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
+ }
95
174
  function readStr(value) {
96
175
  if (typeof value === "string" && value)
97
176
  return value;
@@ -129,17 +208,33 @@ function readTaskUpdates(rec) {
129
208
  function normalizeTodoStatus(status) {
130
209
  return status === "pending" || status === "in_progress" || status === "completed" ? status : undefined;
131
210
  }
132
- /** The `tool_use_id` of a record's tool_result block (the parent call it answers). */
133
- function toolResultCallId(rec) {
134
- const content = rec.message?.content;
135
- if (!Array.isArray(content))
211
+ function readNativeSubagentMeta(path) {
212
+ let value;
213
+ try {
214
+ value = JSON.parse(readFileSync(path, "utf8"));
215
+ }
216
+ catch {
136
217
  return undefined;
137
- for (const block of content) {
138
- if (isRecord(block) && block.type === "tool_result" && typeof block.tool_use_id === "string") {
139
- return block.tool_use_id;
140
- }
141
218
  }
142
- 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}`;
143
238
  }
144
239
  export class ClaudeLiveSession {
145
240
  bridgeDir;
@@ -156,9 +251,24 @@ export class ClaudeLiveSession {
156
251
  releaseSleep;
157
252
  hooksOffset = 0;
158
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;
159
262
  interactionsOffset = 0;
160
263
  interactionAcksOffset = 0;
161
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;
162
272
  /** Secondary dedup: source ids (record uuids) already forwarded, so a re-read
163
273
  * (fingerprint reset / mid-poll death) doesn't re-emit. Persisted in the
164
274
  * forwarder state; bounded there. */
@@ -170,11 +280,20 @@ export class ClaudeLiveSession {
170
280
  /** Persisted native id requested through `claude --resume`. */
171
281
  expectedClaudeSessionId;
172
282
  resumeAtEndOnDiscovery;
283
+ initialTranscriptOffsetOnDiscovery;
173
284
  /** Every claude session uuid seen — a resume into an UNSEEN id + a forkedFrom
174
285
  * marker signals a `/fork` (vs. resuming a known branch). */
175
286
  seenClaudeSessionIds = new Set();
176
287
  turnOpen = false;
177
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;
178
297
  /** The open turn received an explicit Escape/Stop and must close cancelled. */
179
298
  turnInterrupted = false;
180
299
  /** The Turn opened from a pre-transcript interaction. Its unique provisional
@@ -223,8 +342,17 @@ export class ClaudeLiveSession {
223
342
  * append its request immediately before the failure hook lands; closing here
224
343
  * would let the later interaction poll reopen an already-failed Turn. */
225
344
  turnFailurePending = null;
226
- /** Sub-agent (Task) ids already forwarded a Task tool_result is processed once. */
227
- 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;
228
356
  /** The agent's task list, keyed by task id in creation order (claude
229
357
  * `TaskCreate`/`TaskUpdate`). Emitted whole as a snapshot on any change. */
230
358
  todos = new Map();
@@ -232,6 +360,7 @@ export class ClaudeLiveSession {
232
360
  * a turn's usage on close. undefined until the statusLine hook first fires. */
233
361
  latestStatus;
234
362
  deltasOffset = 0;
363
+ seenDeltaKeys = new Set();
235
364
  /** Finalized MessageDisplay ids in completion order, FIFO-mapped onto the
236
365
  * transcript's assistant-text records so a streamed message and its final
237
366
  * item share an itemId (message_id is absent from the transcript). */
@@ -254,7 +383,15 @@ export class ClaudeLiveSession {
254
383
  ((interactionId) => interactionLeaseUpdatedAt(this.bridgeDir, interactionId));
255
384
  this.expectedClaudeSessionId = opts.claudeSessionId;
256
385
  this.resumeAtEndOnDiscovery = opts.resumeAtEndOnDiscovery ?? false;
386
+ this.initialTranscriptOffsetOnDiscovery =
387
+ opts.initialTranscriptOffsetOnDiscovery === undefined
388
+ ? undefined
389
+ : Math.max(0, Math.trunc(opts.initialTranscriptOffsetOnDiscovery));
257
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;
258
395
  const hookState = readHookForwardState(this.bridgeDir);
259
396
  if (hookState) {
260
397
  this.hooksOffset = hookState.byteOffset;
@@ -268,6 +405,7 @@ export class ClaudeLiveSession {
268
405
  this.seenClaudeSessionIds.add(opts.claudeSessionId);
269
406
  }
270
407
  this.restoreForwardState(opts.transcriptPath);
408
+ this.restoreSubagentForwardStates(opts.transcriptPath);
271
409
  }
272
410
  }
273
411
  /**
@@ -284,23 +422,59 @@ export class ClaudeLiveSession {
284
422
  return false;
285
423
  for (const id of state.seenSourceIds)
286
424
  this.seenSourceIds.add(id);
425
+ const transcriptSize = fileByteSize(transcriptPath);
426
+ const transcriptMissing = transcriptSize === undefined;
287
427
  const fingerprint = jsonlCursorFingerprint(transcriptPath, state.byteOffset);
288
- this.transcriptOffset =
289
- fingerprint !== undefined && fingerprint === state.cursorFingerprint
290
- ? state.byteOffset // cursor valid → resume here
291
- : 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();
292
453
  return true;
293
454
  }
294
455
  /** Persist the durable forwarder cursor (byte offset + seen ids + fingerprint). */
295
456
  persistForwardState() {
296
457
  if (!this.transcriptPath)
297
458
  return;
298
- const fingerprint = jsonlCursorFingerprint(this.transcriptPath, this.transcriptOffset);
459
+ const fingerprint = jsonlCursorFingerprint(this.transcriptPath, this.committedTranscriptOffset);
299
460
  writeForwardState(this.bridgeDir, {
300
461
  transcriptPath: this.transcriptPath,
301
- byteOffset: this.transcriptOffset,
462
+ byteOffset: this.committedTranscriptOffset,
463
+ lineCursor: this.committedTranscriptLineCursor,
302
464
  seenSourceIds: [...this.seenSourceIds],
303
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
+ : {}),
304
478
  });
305
479
  }
306
480
  /** True once SessionStart bound the transcript (or a resume path was given). */
@@ -401,15 +575,34 @@ export class ClaudeLiveSession {
401
575
  this.claimedInteractions.clear();
402
576
  this.scheduledClaimScrubs.clear();
403
577
  }
404
- /** 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
405
580
  * drive deterministically; the async {@link loop} just calls it on an interval. */
406
581
  tick() {
407
- // Read the transcript first so a user record opens its Turn before any
408
- // same-tick MessageDisplay chunks are handled. The correlation layer
409
- // supports either completion order; this also lets pollDeltas safely drain
410
- // 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();
411
596
  this.pollHooks();
412
- 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();
413
606
  this.pollDeltas();
414
607
  this.pollInteractions();
415
608
  this.drainInteractionCommits();
@@ -492,24 +685,126 @@ export class ClaudeLiveSession {
492
685
  this.releaseSleep = done;
493
686
  });
494
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
+ }
495
729
  pollHooks() {
730
+ if (this.rotationPending || this.compactionHookPending || this.pendingTerminalHook)
731
+ return;
496
732
  const { events } = readHookEventsFrom(this.bridgeDir, this.hooksOffset, this.hookEventCursor);
497
733
  for (const ev of events) {
498
- // Rotation is an exactly-once edge. Persist it before the callback so a
499
- // partial publication failure cannot create another replacement session
500
- // when this forwarder (or its supervisor) restarts.
501
- const rotation = ev.eventName === "SessionStart" && (ev.source === "clear" ||
502
- (ev.source === "resume" && Boolean(ev.sessionId &&
503
- ev.transcriptPath &&
504
- !this.seenClaudeSessionIds.has(ev.sessionId) &&
505
- transcriptHasForkedFrom(ev.transcriptPath, ev.sessionId))));
506
- if (rotation)
507
- this.persistHookCursor(ev);
508
- this.handleHook(ev);
509
- if (!rotation)
510
- this.persistHookCursor(ev);
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);
511
773
  }
512
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
+ }
513
808
  persistHookCursor(ev) {
514
809
  this.hooksOffset = ev.byteOffset;
515
810
  this.hookEventCursor = ev.eventCursor;
@@ -522,12 +817,22 @@ export class ClaudeLiveSession {
522
817
  }
523
818
  handleHook(ev) {
524
819
  if (ev.eventName === "SessionStart") {
525
- 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);
526
831
  return;
527
832
  }
528
833
  if (ev.eventName === "Stop" || ev.eventName === "StopFailure") {
529
834
  // Subagent stops (transcript_path under `subagents/`) don't end the parent turn.
530
- if (ev.transcriptPath?.includes("/subagents/"))
835
+ if (isSubagentTranscriptPath(ev.transcriptPath))
531
836
  return;
532
837
  // Stop only signals idle — it does NOT close the turn (see onIdle): closing
533
838
  // here would split a late-flushing assistant record into its own turn.
@@ -546,6 +851,173 @@ export class ClaudeLiveSession {
546
851
  }
547
852
  }
548
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
+ }
549
1021
  /** Process a provider failure only after this tick has discovered every
550
1022
  * blocking request already appended by its hook subprocess. */
551
1023
  flushTurnFailure() {
@@ -554,22 +1026,67 @@ export class ClaudeLiveSession {
554
1026
  return;
555
1027
  this.turnFailurePending = null;
556
1028
  this.providerFailureSticky = true;
557
- if (this.turnOpen)
558
- this.closeTurnError(error);
559
- else
560
- this.sink.onTurnError(error);
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);
561
1037
  }
562
1038
  /** Emit the Stop idle signal only after this tick has discovered native
563
1039
  * interactions. A pending question/permission is active execution, not idle. */
564
1040
  flushStopSignal() {
565
- 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
+ }
566
1046
  return;
1047
+ }
567
1048
  this.stopSignalPending = false;
568
1049
  if (this.pendingInteractions.size === 0) {
569
- this.sink.onIdle(this.stopBackgroundTaskCount);
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);
570
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;
571
1063
  }
572
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
+ });
1089
+ }
573
1090
  /** SessionStart drives discovery (first) and rotation (a later one with a NEW
574
1091
  * session id + transcript). `/clear` → source="clear"; `/fork` → source="resume"
575
1092
  * into an unseen id with a forkedFrom marker. Any other new-transcript
@@ -586,52 +1103,113 @@ export class ClaudeLiveSession {
586
1103
  }
587
1104
  this.transcriptPath = ev.transcriptPath;
588
1105
  this.transcriptOffset = 0;
1106
+ this.committedTranscriptOffset = 0;
1107
+ this.transcriptLineCursor = 0;
1108
+ this.committedTranscriptLineCursor = 0;
589
1109
  this.currentClaudeSessionId = ev.sessionId;
590
1110
  if (ev.sessionId)
591
1111
  this.seenClaudeSessionIds.add(ev.sessionId);
592
1112
  // Resume from the persisted cursor so a relaunched forwarder doesn't re-mirror
593
1113
  // already-logged turns.
594
1114
  const restored = this.restoreForwardState(ev.transcriptPath);
595
- 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) {
596
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();
597
1131
  }
598
1132
  if (!this.discovered && ev.sessionId) {
599
1133
  this.discovered = true;
600
1134
  this.sink.onSessionDiscovered?.(ev.sessionId, ev.transcriptPath);
601
1135
  }
602
- return;
1136
+ return ev.source === "compact" ? this.handleCompactionCompletionHook(ev) : undefined;
603
1137
  }
604
1138
  // Drain the old transcript before a SessionStart switches paths so a slash
605
1139
  // command recorded immediately before /clear or /fork can still
606
1140
  // acknowledge the web submission.
607
1141
  if (ev.transcriptPath !== this.transcriptPath)
608
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
+ }
609
1162
  // Same session id (a plain re-announce) → nothing to do.
610
1163
  if (!ev.sessionId || ev.sessionId === this.currentClaudeSessionId)
611
1164
  return;
612
- const wasSeen = this.seenClaudeSessionIds.has(ev.sessionId);
613
- this.seenClaudeSessionIds.add(ev.sessionId);
614
1165
  let kind;
615
1166
  if (ev.source === "clear")
616
1167
  kind = "clear";
617
- else if (ev.source === "resume" && !wasSeen && transcriptHasForkedFrom(ev.transcriptPath, ev.sessionId)) {
1168
+ else if (this.isForkHookRecord(ev))
618
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;
619
1184
  }
620
- // A fork copies the source history into the new transcript; start at its END
621
- // so those records aren't re-mirrored. A clear starts fresh at byte 0.
622
- this.repointTranscript(ev.transcriptPath, kind === "fork");
623
- this.currentClaudeSessionId = ev.sessionId;
624
- if (kind)
625
- 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();
626
1196
  }
627
1197
  /** Switch the tailed transcript and reset per-session accumulators (the bridge
628
1198
  * files — hooks/deltas/status — are shared by the same claude process across a
629
1199
  * rotation, so their cursors are NOT reset). */
630
- repointTranscript(newPath, atEof) {
1200
+ repointTranscript(newPath, initialOffset) {
631
1201
  this.closeTurn(); // finalize the prior session's last turn before switching
1202
+ this.activeTerminalCommand = undefined;
1203
+ this.committedTerminalCommand = undefined;
632
1204
  this.transcriptPath = newPath;
633
- this.transcriptOffset = atEof ? fileSize(newPath) : 0;
634
- 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);
635
1213
  this.todos.clear();
636
1214
  this.pendingQueuedSubmissions.length = 0;
637
1215
  this.resetMessageCorrelation();
@@ -642,16 +1220,366 @@ export class ClaudeLiveSession {
642
1220
  resetForwardState(this.bridgeDir);
643
1221
  this.persistForwardState();
644
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
+ }
645
1262
  pollTranscript() {
646
- if (!this.transcriptPath)
1263
+ if (!this.transcriptPath || this.transcriptDeliveryPending)
647
1264
  return;
648
- 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);
649
1268
  this.transcriptOffset = nextOffset;
650
- 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
+ });
651
1290
  this.handleRecord(rec);
652
- // Persist the durable cursor after consuming records so a relaunch resumes here.
653
- 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;
654
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
+ });
655
1583
  }
656
1584
  /** Tail blocking hook requests after transcript records, so an interaction
657
1585
  * emitted in the same poll attaches to the user Turn that caused it. */
@@ -923,11 +1851,57 @@ export class ClaudeLiveSession {
923
1851
  if (oldest)
924
1852
  this.settledInteractions.delete(oldest);
925
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
+ }
926
1878
  handleRecord(rec) {
927
1879
  if (rec.isSidechain === true)
928
1880
  return; // sub-agent turns (Phase 9 forwards them)
1881
+ if (rec.isCompactSummary === true) {
1882
+ this.handleCompactSummary(rec);
1883
+ return;
1884
+ }
929
1885
  if (rec.isMeta === true)
930
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
+ }
931
1905
  // Type-ahead is acknowledged at enqueue time. Its later
932
1906
  // `promptSource:"queued"` user record must not count again: another
933
1907
  // identical web message may already be awaiting its own acknowledgement.
@@ -945,17 +1919,7 @@ export class ClaudeLiveSession {
945
1919
  }
946
1920
  return;
947
1921
  }
948
- // Secondary dedup: skip a record whose source id was already forwarded (a
949
- // re-read after a fingerprint reset). rynx emits a record's items atomically,
950
- // so the record uuid is the source key (vs reference implementation's per-block key for its
951
- // per-item POST). Records without a uuid fall through undeduped (rare).
952
- const sourceId = typeof rec.uuid === "string" && rec.uuid ? rec.uuid : undefined;
953
- if (sourceId) {
954
- if (this.seenSourceIds.has(sourceId))
955
- return;
956
- this.seenSourceIds.add(sourceId);
957
- }
958
- const content = userStringContent(rec);
1922
+ const content = stringContent;
959
1923
  if (content !== undefined) {
960
1924
  const firstLine = content.trim().split("\n", 1)[0] ?? "";
961
1925
  if (CLAUDE_INTERRUPT_RECORD_RE.test(firstLine)) {
@@ -976,51 +1940,32 @@ export class ClaudeLiveSession {
976
1940
  for (const candidate of candidates)
977
1941
  this.rememberSubmission(candidate);
978
1942
  }
979
- // A local `!` command records its input+output as `<bash-*>` markers — mirror
980
- // it as a self-contained terminal_command turn (before the prompt check, as
981
- // it too is `<`-prefixed).
982
- const term = parseTerminalCommand(content);
983
- if (term) {
984
- this.emitTerminalCommand(rec, term);
1943
+ if (isClaudeUserScaffolding(content))
985
1944
  return;
1945
+ if (isTaskNotificationText(content)) {
1946
+ this.sink.onMetaUserMessage?.(content, rec.uuid ?? rec.requestId);
986
1947
  }
987
- // A real prompt (not an XML-marker bookkeeping record) opens a new turn.
988
- if (!content.startsWith("<")) {
989
- const queuedIntoOpenTurn = this.turnOpen && (queuedPromotion || rec.promptSource === "queued" || rec.promptSource === "sdk");
990
- if (queuedIntoOpenTurn) {
991
- // Claude type-ahead is one native busy interval: enqueue now, promote
992
- // later, and emit one final Stop. Do not close/re-open the canonical
993
- // response when the promoted user record arrives mid-turn.
994
- this.sink.onUserMessage(content);
995
- this.lastActivityAt = this.now();
996
- return;
997
- }
998
- const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
999
- if (this.turnOpen && this.syntheticTurn) {
1000
- // A hook can flush just before the transcript's user record. The
1001
- // interaction already opened a unique provisional response; adopt the
1002
- // real turn id internally without closing/cancelling that same Turn.
1003
- this.currentTurnId = turnId;
1004
- this.syntheticTurn = false;
1005
- }
1006
- else {
1007
- this.closeTurn();
1008
- this.currentTurnId = turnId;
1009
- this.turnOpen = true;
1010
- this.turnInterrupted = false;
1011
- this.syntheticTurn = false;
1012
- this.providerIdleAt = null;
1013
- this.openToolIds.clear();
1014
- this.stopPendingAt = null;
1015
- this.stopBackgroundTaskCount = undefined;
1016
- this.stopBackgroundTaskCountDelivered = false;
1017
- this.sink.onTurnStart(this.currentTurnId);
1018
- }
1019
- this.sink.onUserMessage(content);
1020
- this.lastActivityAt = this.now();
1948
+ else {
1949
+ this.emitUserMessageRecord(rec, content, queuedPromotion);
1021
1950
  }
1022
1951
  return; // string-content user record: prompt, terminal_command, or skipped marker
1023
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
+ }
1024
1969
  const events = parseTranscriptRecord(rec);
1025
1970
  if (events.length === 0)
1026
1971
  return;
@@ -1028,15 +1973,75 @@ export class ClaudeLiveSession {
1028
1973
  for (const event of events) {
1029
1974
  const mapped = this.remapMessageItem(event);
1030
1975
  this.trackTool(mapped);
1976
+ this.rememberNativeSubagentParentResponse(mapped);
1031
1977
  this.sink.onEvent(mapped);
1032
1978
  }
1033
- // A Task tool_result carries `toolUseResult.agentId` — replay the (now
1034
- // complete) sub-agent transcript nested under this parent Task call.
1035
- this.maybeForwardSubagent(rec);
1036
1979
  // TaskCreate/TaskUpdate records also refresh the task-list snapshot.
1037
1980
  this.maybeUpdateTodos(rec);
1038
1981
  this.lastActivityAt = this.now();
1039
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
+ }
1040
2045
  /** Fold a `TaskCreate` (new pending task) or `TaskUpdate` (status/subject/delete)
1041
2046
  * record into the task list; on any change emit the whole list as a snapshot. */
1042
2047
  maybeUpdateTodos(rec) {
@@ -1063,28 +2068,6 @@ export class ClaudeLiveSession {
1063
2068
  if (changed)
1064
2069
  this.sink.onTodos([...this.todos.values()]);
1065
2070
  }
1066
- /** On a `Task` tool_result, replay the sub-agent's own transcript
1067
- * (`subagents/agent-<agentId>.jsonl`) as events tagged with the parent Task
1068
- * tool-use id, so the canonical layer nests them under that call. Fired once
1069
- * per sub-agent (at tool_result time the file is complete — no live race). */
1070
- maybeForwardSubagent(rec) {
1071
- if (!this.transcriptPath)
1072
- return;
1073
- const result = rec.toolUseResult;
1074
- const agentId = isRecord(result) && typeof result.agentId === "string" ? result.agentId : undefined;
1075
- if (!agentId || this.seenSubagents.has(agentId))
1076
- return;
1077
- const parentToolUseId = toolResultCallId(rec);
1078
- if (!parentToolUseId)
1079
- return;
1080
- this.seenSubagents.add(agentId);
1081
- const file = subagentTranscriptPath(this.transcriptPath, agentId);
1082
- // Emit raw (no remap/track): sub-agent messages have their own itemIds and
1083
- // must not consume the parent's MessageDisplay FIFO, and their tool pairs are
1084
- // already complete so they need no open-tool tracking.
1085
- for (const event of readSubagentEvents(file, parentToolUseId))
1086
- this.sink.onEvent(event);
1087
- }
1088
2071
  /** Tail streamed assistant-text chunks (MessageDisplay) into live `token`
1089
2072
  * events. Guarded on an open turn — deltas belong to the turn the user record
1090
2073
  * opened; the offset advances only once processed. */
@@ -1093,9 +2076,21 @@ export class ClaudeLiveSession {
1093
2076
  this.deltasOffset = nextOffset;
1094
2077
  // MessageDisplay has no Turn id. Chunks observed while idle belong to the
1095
2078
  // Turn that just closed and must never be replayed into a later Turn.
1096
- if (!this.turnOpen)
2079
+ if (!this.turnOpen) {
2080
+ writeDeltaForwardState(this.bridgeDir, { byteOffset: nextOffset });
1097
2081
  return;
2082
+ }
1098
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
+ }
1099
2094
  this.streamedMessageText.set(d.messageId, (this.streamedMessageText.get(d.messageId) ?? "") + d.delta);
1100
2095
  this.sink.onEvent({
1101
2096
  type: "token",
@@ -1111,6 +2106,9 @@ export class ClaudeLiveSession {
1111
2106
  this.finalizeMessageDisplay(d.messageId);
1112
2107
  }
1113
2108
  }
2109
+ if (nextOffset > 0 || deltas.length > 0) {
2110
+ writeDeltaForwardState(this.bridgeDir, { byteOffset: nextOffset });
2111
+ }
1114
2112
  if (deltas.length)
1115
2113
  this.lastActivityAt = this.now();
1116
2114
  }
@@ -1143,8 +2141,10 @@ export class ClaudeLiveSession {
1143
2141
  this.providerIdleAt = null;
1144
2142
  }
1145
2143
  handleSessionStatus(status) {
1146
- this.stopSignalPending = false;
1147
- this.stopPendingAt = null;
2144
+ if (this.pendingTerminalHook?.eventName !== "Stop") {
2145
+ this.stopSignalPending = false;
2146
+ this.stopPendingAt = null;
2147
+ }
1148
2148
  if (status.runnerStatus === "running") {
1149
2149
  this.providerFailureSticky = false;
1150
2150
  // A new native busy interval supersedes the prior Stop snapshot. The
@@ -1216,6 +2216,25 @@ export class ClaudeLiveSession {
1216
2216
  this.openToolIds.delete(id);
1217
2217
  }
1218
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
+ }
1219
2238
  ensureTurn(provisionalTurnId) {
1220
2239
  if (this.turnOpen)
1221
2240
  return;
@@ -1230,15 +2249,30 @@ export class ClaudeLiveSession {
1230
2249
  this.stopBackgroundTaskCountDelivered = false;
1231
2250
  this.sink.onTurnStart(this.currentTurnId);
1232
2251
  }
1233
- /** Mirror a local `!` command as its own mini-turn: close any open turn, then
1234
- * open→emit→close so it groups as one response with a stable id (the record
1235
- * uuid) and never lingers "running" (input+output are complete in one record). */
1236
- emitTerminalCommand(rec, cmd) {
1237
- this.closeTurn();
1238
- const turnId = typeof rec.uuid === "string" ? rec.uuid : undefined;
1239
- this.sink.onTurnStart(turnId);
1240
- this.sink.onTerminalCommand(cmd);
1241
- 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
+ }
1242
2276
  }
1243
2277
  /** The web Stop button interrupted this session (host sent Escape). claude
1244
2278
  * records the interrupt in its own transcript but may not fire a Stop hook when
@@ -1281,7 +2315,7 @@ export class ClaudeLiveSession {
1281
2315
  this.sink.onTurnError(error);
1282
2316
  return failedOpenTurn;
1283
2317
  }
1284
- closeTurn() {
2318
+ closeTurn(reason) {
1285
2319
  if (!this.turnOpen)
1286
2320
  return;
1287
2321
  this.drainInteractionCommits();
@@ -1304,9 +2338,11 @@ export class ClaudeLiveSession {
1304
2338
  if (interrupted && this.sink.onTurnInterrupted)
1305
2339
  this.sink.onTurnInterrupted(usage);
1306
2340
  else
1307
- this.sink.onTurnEnd(usage, backgroundTaskCount);
2341
+ this.sink.onTurnEnd(usage, backgroundTaskCount, reason);
2342
+ if (!this.transcriptDeliveryPending)
2343
+ this.persistForwardState();
1308
2344
  }
1309
- closeTurnError(error) {
2345
+ closeTurnError(error, sourceId) {
1310
2346
  if (!this.turnOpen)
1311
2347
  return;
1312
2348
  this.drainInteractionCommits();
@@ -1321,7 +2357,10 @@ export class ClaudeLiveSession {
1321
2357
  this.stopBackgroundTaskCount = undefined;
1322
2358
  this.stopBackgroundTaskCountDelivered = false;
1323
2359
  this.resetMessageCorrelation();
1324
- this.sink.onTurnError(error);
2360
+ const delivery = this.sink.onTurnError(error, sourceId);
2361
+ if (!this.transcriptDeliveryPending)
2362
+ this.persistForwardState();
2363
+ return delivery;
1325
2364
  }
1326
2365
  resetMessageCorrelation() {
1327
2366
  this.finalizedMessageIds.clear();