@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.
@@ -5,6 +5,9 @@ export declare const DELTAS_FILE = "message_deltas.jsonl";
5
5
  export declare const STATUS_FILE = "status.json";
6
6
  export declare const FORWARDER_STATE_FILE = "transcript_forwarder.json";
7
7
  export declare const HOOK_FORWARDER_STATE_FILE = "hook_forwarder.json";
8
+ export declare const SUBAGENT_FORWARDER_STATE_FILE = "subagent_forwarder.json";
9
+ export declare const DELTA_FORWARDER_STATE_FILE = "message_deltas_forwarder.json";
10
+ export declare const COMPACTION_FORWARDER_STATE_FILE = "compaction_forwarder.json";
8
11
  export declare const INTERACTIONS_FILE = "interactions.jsonl";
9
12
  export declare const INTERACTION_ACKS_FILE = "interaction-acks.jsonl";
10
13
  export declare const INTERACTION_RESULTS_DIR = "interaction-results";
@@ -83,9 +86,15 @@ export interface ClaudeBridgeState {
83
86
  transcriptPath?: string;
84
87
  /** Claude's own session uuid (from the hook payload `session_id`). */
85
88
  claudeSessionId?: string;
89
+ /** Native Session ids already observed by this stable bridge owner. */
90
+ seenClaudeSessionIds?: string[];
86
91
  lastHookEventName?: string;
87
92
  }
88
93
  export declare function readClaudeState(bridgeDir: string): ClaudeBridgeState;
94
+ /** Capture the pre-SessionStart identity before state.json is advanced. The
95
+ * observer hook and forwarder use these stable facts to distinguish a new
96
+ * branch from a later resume into an already-seen native Session. */
97
+ export declare function annotateClaudeResumeContext(bridgeDir: string, payload: Record<string, unknown>): void;
89
98
  /**
90
99
  * Append one Claude hook payload to `hooks.jsonl` and fold its key fields into
91
100
  * `state.json` (transcript path + claude session id + last event). The payload
@@ -101,14 +110,29 @@ export interface JsonlReadResult<T> {
101
110
  * line is left unconsumed so the next poll retries it once its newline lands. */
102
111
  nextOffset: number;
103
112
  }
113
+ export interface JsonlRecordEntry<T> {
114
+ record: T;
115
+ /** Byte offset at which this complete record begins. */
116
+ byteOffset: number;
117
+ /** One-based physical line number, including blank/malformed lines. */
118
+ lineNumber: number;
119
+ }
120
+ export interface JsonlEntryReadResult<T> extends JsonlReadResult<T> {
121
+ entries: JsonlRecordEntry<T>[];
122
+ nextLineCursor: number;
123
+ }
104
124
  /** Read complete newline-terminated JSON records appended after `byteOffset`. */
105
125
  export declare function readJsonlFrom<T = unknown>(path: string, byteOffset: number): JsonlReadResult<T>;
126
+ /** Read complete records with their stable byte/line source positions. */
127
+ export declare function readJsonlEntriesFrom<T = unknown>(path: string, byteOffset: number, startLineCursor?: number): JsonlEntryReadResult<T>;
106
128
  /** One parsed Claude hook event (the fields the forwarder acts on + the raw payload). */
107
129
  export interface HookEvent {
108
130
  /** One-based record position in the append-only hook stream. */
109
131
  eventCursor: number;
110
132
  /** Byte offset immediately after this complete JSONL record. */
111
133
  byteOffset: number;
134
+ /** Wall-clock time when the hook observer captured this payload. */
135
+ recordedAt?: number;
112
136
  eventName?: string;
113
137
  transcriptPath?: string;
114
138
  /** Claude's session uuid — subagent hooks report a `subagents/…` transcript path. */
@@ -140,6 +164,31 @@ export interface MessageDelta {
140
164
  final: boolean;
141
165
  delta: string;
142
166
  }
167
+ export interface DeltaForwardState {
168
+ byteOffset: number;
169
+ }
170
+ export interface PendingCompactionState {
171
+ sequence: number;
172
+ claudeSessionId?: string;
173
+ transcriptPath?: string;
174
+ }
175
+ export interface CompactionForwardState {
176
+ /** Logical Provider generation. clear/fork starts a fresh reconciliation
177
+ * domain even though the bridge directory remains stable. */
178
+ generation: string;
179
+ lastSequence: number;
180
+ persistedSequences: number[];
181
+ lastPrecompactCursor: number;
182
+ pending?: PendingCompactionState;
183
+ expectCompletionAckSequence?: number;
184
+ }
185
+ export declare function readCompactionForwardState(bridgeDir: string, generation: string): CompactionForwardState;
186
+ export declare function writeCompactionForwardState(bridgeDir: string, state: CompactionForwardState): void;
187
+ /** Read the process-wide MessageDisplay cursor. It deliberately survives
188
+ * logical Session rotation; if the append log was truncated by a fresh Claude
189
+ * process, restart at zero. */
190
+ export declare function readDeltaForwardState(bridgeDir: string): DeltaForwardState;
191
+ export declare function writeDeltaForwardState(bridgeDir: string, state: DeltaForwardState): void;
143
192
  /** Tail `message_deltas.jsonl` from a byte offset into {@link MessageDelta}s. */
144
193
  export declare function readMessageDeltasFrom(bridgeDir: string, byteOffset: number): {
145
194
  deltas: MessageDelta[];
@@ -183,12 +232,26 @@ export interface TranscriptForwardState {
183
232
  transcriptPath: string;
184
233
  /** Primary dedup: bytes already forwarded. A restart seeks here. */
185
234
  byteOffset: number;
235
+ /** Complete physical JSONL lines consumed at `byteOffset`. */
236
+ lineCursor?: number;
186
237
  /** Secondary dedup: recently-forwarded record source ids (a bounded ring), so a
187
238
  * re-read (fingerprint reset / mid-poll death) doesn't re-emit. */
188
239
  seenSourceIds: string[];
189
240
  /** SHA-256 of `byteOffset` (8-byte BE) + up to 256 bytes before it. On restart a
190
241
  * mismatch means the file was truncated/replaced → don't seek into a stale offset. */
191
242
  cursorFingerprint?: string;
243
+ /** Open Claude turn restored across a runner restart so later records remain
244
+ * grouped under the same canonical response. */
245
+ currentTurnId?: string;
246
+ currentResponseId?: string;
247
+ turnOpen?: boolean;
248
+ /** Split local-command input awaiting its later output record. Rynx stores a
249
+ * combined terminal item, so the command text and owning turn must survive a
250
+ * runner restart between those two native transcript records. */
251
+ activeTerminalCommand?: {
252
+ command: string;
253
+ turnId?: string;
254
+ };
192
255
  }
193
256
  /** Cap on `seenSourceIds` (matches reference implementation's `_MAX_SEEN_SOURCE_IDS`). */
194
257
  export declare const MAX_SEEN_SOURCE_IDS = 2000;
@@ -203,5 +266,27 @@ export declare function writeForwardState(bridgeDir: string, state: TranscriptFo
203
266
  export declare function readForwardState(bridgeDir: string): TranscriptForwardState | undefined;
204
267
  /** Drop the forwarder cursor (a `/clear` starts fresh). */
205
268
  export declare function resetForwardState(bridgeDir: string): void;
269
+ export interface NativeSubagentForwardState {
270
+ agentId: string;
271
+ parentToolCallId: string;
272
+ /** Canonical parent response retained after the parent transcript cursor has
273
+ * advanced, so a runner restart keeps late child items in the same Turn. */
274
+ parentResponseId?: string;
275
+ transcriptPath: string;
276
+ byteOffset: number;
277
+ lineCursor?: number;
278
+ seenSourceIds: string[];
279
+ /** Split local-command input represented by the latest handled child item. */
280
+ terminalCommand?: string;
281
+ }
282
+ /** Parent Task/tool ids already consumed by the main transcript cursor. Kept
283
+ * independently of concrete child agents because Claude may create the child
284
+ * meta file only after the parent record was durably acknowledged. */
285
+ export declare function readSubagentParentResponses(bridgeDir: string, parentTranscriptPath: string): Map<string, string>;
286
+ /** Read independent Claude native sub-agent cursors for one parent transcript. */
287
+ export declare function readSubagentForwardStates(bridgeDir: string, parentTranscriptPath: string): NativeSubagentForwardState[];
288
+ /** Atomically persist every native sub-agent's independent delivery cursor. */
289
+ export declare function writeSubagentForwardStates(bridgeDir: string, parentTranscriptPath: string, states: readonly NativeSubagentForwardState[], parentResponses?: ReadonlyMap<string, string>): void;
290
+ export declare function resetSubagentForwardStates(bridgeDir: string): void;
206
291
  /** Read the latest `status.json` snapshot, or undefined if absent/malformed. */
207
292
  export declare function readClaudeStatus(bridgeDir: string): ClaudeStatusState | undefined;
@@ -11,8 +11,8 @@
11
11
  * Dependency-light on purpose (node builtins plus erased protocol types) so the
12
12
  * standalone hook entrypoints do not pull the whole runtime into every process.
13
13
  */
14
- import { appendFileSync, closeSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
15
- import { createHash } from "node:crypto";
14
+ import { appendFileSync, closeSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
15
+ import { createHash, randomUUID } from "node:crypto";
16
16
  import { join } from "node:path";
17
17
  import { adoptLegacyRuntimeDirectory, legacyRuntimeStateRoot, runtimeSessionDigest, runtimeSessionStateDir, } from "../runtime-state-paths.js";
18
18
  export const HOOKS_FILE = "hooks.jsonl";
@@ -21,12 +21,41 @@ export const DELTAS_FILE = "message_deltas.jsonl";
21
21
  export const STATUS_FILE = "status.json";
22
22
  export const FORWARDER_STATE_FILE = "transcript_forwarder.json";
23
23
  export const HOOK_FORWARDER_STATE_FILE = "hook_forwarder.json";
24
+ export const SUBAGENT_FORWARDER_STATE_FILE = "subagent_forwarder.json";
25
+ export const DELTA_FORWARDER_STATE_FILE = "message_deltas_forwarder.json";
26
+ export const COMPACTION_FORWARDER_STATE_FILE = "compaction_forwarder.json";
24
27
  export const INTERACTIONS_FILE = "interactions.jsonl";
25
28
  export const INTERACTION_ACKS_FILE = "interaction-acks.jsonl";
26
29
  export const INTERACTION_RESULTS_DIR = "interaction-results";
27
30
  export const INTERACTION_CLAIMS_DIR = "interaction-claims";
28
31
  export const INTERACTION_LEASES_DIR = "interaction-leases";
29
32
  export const MANAGED_SETTINGS_FILE = "managed-settings.json";
33
+ /** Reference bridge state uses temp + fsync + replace so a hook crash can
34
+ * never leave readers with a truncated JSON object. A unique temp name also
35
+ * keeps concurrent native hook subprocesses from clobbering one another's
36
+ * staging file. */
37
+ function writeJsonFileAtomic(path, payload) {
38
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
39
+ let fd;
40
+ try {
41
+ fd = openSync(tmp, "wx", 0o600);
42
+ writeFileSync(fd, JSON.stringify(payload));
43
+ fsyncSync(fd);
44
+ closeSync(fd);
45
+ fd = undefined;
46
+ renameSync(tmp, path);
47
+ }
48
+ finally {
49
+ if (fd !== undefined)
50
+ closeSync(fd);
51
+ try {
52
+ rmSync(tmp);
53
+ }
54
+ catch {
55
+ // rename succeeded, or a competing cleanup already removed the temp.
56
+ }
57
+ }
58
+ }
30
59
  /** The deterministic bridge directory for a rynx session id. */
31
60
  export function claudeBridgeDir(sessionId) {
32
61
  return join(runtimeSessionStateDir(sessionId), "claude-bridge");
@@ -293,12 +322,35 @@ export function readClaudeState(bridgeDir) {
293
322
  if (!raw || typeof raw !== "object")
294
323
  return {};
295
324
  const s = raw;
325
+ const seen = Array.isArray(s.seenClaudeSessionIds)
326
+ ? s.seenClaudeSessionIds.filter((value) => typeof value === "string" && value.length > 0)
327
+ : [];
328
+ const current = asString(s.claudeSessionId);
329
+ if (current && !seen.includes(current))
330
+ seen.push(current);
296
331
  return {
297
332
  transcriptPath: asString(s.transcriptPath),
298
- claudeSessionId: asString(s.claudeSessionId),
333
+ claudeSessionId: current,
334
+ ...(seen.length > 0 ? { seenClaudeSessionIds: seen } : {}),
299
335
  lastHookEventName: asString(s.lastHookEventName),
300
336
  };
301
337
  }
338
+ /** Capture the pre-SessionStart identity before state.json is advanced. The
339
+ * observer hook and forwarder use these stable facts to distinguish a new
340
+ * branch from a later resume into an already-seen native Session. */
341
+ export function annotateClaudeResumeContext(bridgeDir, payload) {
342
+ if (payload.hook_event_name !== "SessionStart" || payload.source !== "resume")
343
+ return;
344
+ const next = asString(payload.session_id);
345
+ if (!next)
346
+ return;
347
+ const state = readClaudeState(bridgeDir);
348
+ if (state.claudeSessionId && state.claudeSessionId !== next) {
349
+ payload.rynx_previous_claude_session_id = state.claudeSessionId;
350
+ }
351
+ payload.rynx_claude_session_was_seen =
352
+ (state.seenClaudeSessionIds ?? []).includes(next);
353
+ }
302
354
  /**
303
355
  * Append one Claude hook payload to `hooks.jsonl` and fold its key fields into
304
356
  * `state.json` (transcript path + claude session id + last event). The payload
@@ -309,31 +361,58 @@ export function readClaudeState(bridgeDir) {
309
361
  */
310
362
  export function recordHookEvent(bridgeDir, payload) {
311
363
  mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
312
- const envelope = { recordedAt: Date.now(), payload };
313
- appendFileSync(join(bridgeDir, HOOKS_FILE), `${JSON.stringify(envelope)}\n`);
364
+ if (payload.rynx_claude_session_was_seen === undefined) {
365
+ annotateClaudeResumeContext(bridgeDir, payload);
366
+ }
314
367
  const state = readClaudeState(bridgeDir);
315
368
  const eventName = asString(payload.hook_event_name);
316
369
  if (eventName)
317
370
  state.lastHookEventName = eventName;
318
- const transcriptPath = asString(payload.transcript_path);
319
- if (transcriptPath)
320
- state.transcriptPath = transcriptPath;
321
371
  const claudeSessionId = asString(payload.session_id);
322
- if (claudeSessionId)
323
- state.claudeSessionId = claudeSessionId;
324
- writeFileSync(join(bridgeDir, STATE_FILE), JSON.stringify(state));
372
+ const identityAllowed = eventName === "SessionStart" || (claudeSessionId !== undefined &&
373
+ (!state.claudeSessionId || claudeSessionId === state.claudeSessionId));
374
+ if (identityAllowed) {
375
+ const transcriptPath = asString(payload.transcript_path);
376
+ if (transcriptPath)
377
+ state.transcriptPath = transcriptPath;
378
+ if (claudeSessionId) {
379
+ state.claudeSessionId = claudeSessionId;
380
+ const seen = new Set(state.seenClaudeSessionIds ?? []);
381
+ seen.add(claudeSessionId);
382
+ state.seenClaudeSessionIds = [...seen].sort();
383
+ }
384
+ }
385
+ const envelope = { recordedAt: Date.now(), payload };
386
+ appendFileSync(join(bridgeDir, HOOKS_FILE), `${JSON.stringify(envelope)}\n`);
387
+ writeJsonFileAtomic(join(bridgeDir, STATE_FILE), state);
325
388
  }
326
389
  /** Read complete newline-terminated JSON records appended after `byteOffset`. */
327
390
  export function readJsonlFrom(path, byteOffset) {
391
+ const result = readJsonlEntriesFrom(path, byteOffset);
392
+ return { records: result.records, nextOffset: result.nextOffset };
393
+ }
394
+ /** Read complete records with their stable byte/line source positions. */
395
+ export function readJsonlEntriesFrom(path, byteOffset, startLineCursor = 0) {
328
396
  let info;
329
397
  try {
330
398
  info = statSync(path);
331
399
  }
332
400
  catch {
333
- return { records: [], nextOffset: byteOffset };
401
+ return {
402
+ records: [],
403
+ entries: [],
404
+ nextOffset: byteOffset,
405
+ nextLineCursor: startLineCursor,
406
+ };
407
+ }
408
+ if (info.size <= byteOffset) {
409
+ return {
410
+ records: [],
411
+ entries: [],
412
+ nextOffset: byteOffset,
413
+ nextLineCursor: startLineCursor,
414
+ };
334
415
  }
335
- if (info.size <= byteOffset)
336
- return { records: [], nextOffset: byteOffset };
337
416
  const fd = openSync(path, "r");
338
417
  try {
339
418
  const length = info.size - byteOffset;
@@ -344,18 +423,31 @@ export function readJsonlFrom(path, byteOffset) {
344
423
  const trailing = lines.pop() ?? ""; // partial (no trailing newline yet)
345
424
  const consumed = length - Buffer.byteLength(trailing, "utf8");
346
425
  const records = [];
426
+ const entries = [];
427
+ let recordOffset = byteOffset;
428
+ let lineNumber = startLineCursor;
347
429
  for (const line of lines) {
430
+ const lineOffset = recordOffset;
431
+ recordOffset += Buffer.byteLength(`${line}\n`, "utf8");
432
+ lineNumber += 1;
348
433
  const trimmed = line.trim();
349
434
  if (!trimmed)
350
435
  continue;
351
436
  try {
352
- records.push(JSON.parse(trimmed));
437
+ const record = JSON.parse(trimmed);
438
+ records.push(record);
439
+ entries.push({ record, byteOffset: lineOffset, lineNumber });
353
440
  }
354
441
  catch {
355
442
  // Skip a malformed complete line; the offset still advances past it.
356
443
  }
357
444
  }
358
- return { records, nextOffset: byteOffset + consumed };
445
+ return {
446
+ records,
447
+ entries,
448
+ nextOffset: byteOffset + consumed,
449
+ nextLineCursor: lineNumber,
450
+ };
359
451
  }
360
452
  finally {
361
453
  closeSync(fd);
@@ -407,6 +499,9 @@ export function readHookEventsFrom(bridgeDir, byteOffset, startEventCursor = 0)
407
499
  events.push({
408
500
  eventCursor,
409
501
  byteOffset: nextOffset,
502
+ ...(typeof rec.recordedAt === "number" && Number.isFinite(rec.recordedAt)
503
+ ? { recordedAt: rec.recordedAt }
504
+ : {}),
410
505
  eventName: asString(payload.hook_event_name),
411
506
  transcriptPath: asString(payload.transcript_path),
412
507
  sessionId: asString(payload.session_id),
@@ -455,6 +550,118 @@ export function writeHookForwardState(bridgeDir, state) {
455
550
  writeFileSync(tmp, JSON.stringify(state));
456
551
  renameSync(tmp, join(bridgeDir, HOOK_FORWARDER_STATE_FILE));
457
552
  }
553
+ export function readCompactionForwardState(bridgeDir, generation) {
554
+ let raw;
555
+ try {
556
+ raw = JSON.parse(readFileSync(join(bridgeDir, COMPACTION_FORWARDER_STATE_FILE), "utf8"));
557
+ }
558
+ catch {
559
+ return {
560
+ generation,
561
+ lastSequence: 0,
562
+ persistedSequences: [],
563
+ lastPrecompactCursor: 0,
564
+ };
565
+ }
566
+ if (!raw || typeof raw !== "object") {
567
+ return {
568
+ generation,
569
+ lastSequence: 0,
570
+ persistedSequences: [],
571
+ lastPrecompactCursor: 0,
572
+ };
573
+ }
574
+ const value = raw;
575
+ if (asString(value.generation) !== generation) {
576
+ return {
577
+ generation,
578
+ lastSequence: 0,
579
+ persistedSequences: [],
580
+ lastPrecompactCursor: 0,
581
+ };
582
+ }
583
+ const lastSequence = asNumber(value.lastSequence);
584
+ const lastPrecompactCursor = asNumber(value.lastPrecompactCursor);
585
+ const persistedSequences = Array.isArray(value.persistedSequences)
586
+ ? value.persistedSequences.filter((entry) => typeof entry === "number" && Number.isInteger(entry) && entry > 0).slice(-16)
587
+ : [];
588
+ const pendingRaw = value.pending;
589
+ let pending;
590
+ if (pendingRaw && typeof pendingRaw === "object") {
591
+ const candidate = pendingRaw;
592
+ const sequence = asNumber(candidate.sequence);
593
+ if (sequence !== undefined && Number.isInteger(sequence) && sequence > 0) {
594
+ pending = {
595
+ sequence,
596
+ ...(asString(candidate.claudeSessionId)
597
+ ? { claudeSessionId: asString(candidate.claudeSessionId) }
598
+ : {}),
599
+ ...(asString(candidate.transcriptPath)
600
+ ? { transcriptPath: asString(candidate.transcriptPath) }
601
+ : {}),
602
+ };
603
+ }
604
+ }
605
+ const expectCompletionAckSequence = asNumber(value.expectCompletionAckSequence);
606
+ return {
607
+ generation,
608
+ lastSequence: lastSequence !== undefined && Number.isInteger(lastSequence) && lastSequence >= 0
609
+ ? lastSequence
610
+ : 0,
611
+ persistedSequences,
612
+ lastPrecompactCursor: lastPrecompactCursor !== undefined && Number.isInteger(lastPrecompactCursor) &&
613
+ lastPrecompactCursor >= 0
614
+ ? lastPrecompactCursor
615
+ : 0,
616
+ ...(pending ? { pending } : {}),
617
+ ...(expectCompletionAckSequence !== undefined &&
618
+ Number.isInteger(expectCompletionAckSequence) && expectCompletionAckSequence > 0
619
+ ? { expectCompletionAckSequence }
620
+ : {}),
621
+ };
622
+ }
623
+ export function writeCompactionForwardState(bridgeDir, state) {
624
+ mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
625
+ const tmp = join(bridgeDir, `${COMPACTION_FORWARDER_STATE_FILE}.tmp`);
626
+ writeFileSync(tmp, JSON.stringify({
627
+ ...state,
628
+ persistedSequences: state.persistedSequences.slice(-16),
629
+ }));
630
+ renameSync(tmp, join(bridgeDir, COMPACTION_FORWARDER_STATE_FILE));
631
+ }
632
+ /** Read the process-wide MessageDisplay cursor. It deliberately survives
633
+ * logical Session rotation; if the append log was truncated by a fresh Claude
634
+ * process, restart at zero. */
635
+ export function readDeltaForwardState(bridgeDir) {
636
+ let raw;
637
+ try {
638
+ raw = JSON.parse(readFileSync(join(bridgeDir, DELTA_FORWARDER_STATE_FILE), "utf8"));
639
+ }
640
+ catch {
641
+ return { byteOffset: 0 };
642
+ }
643
+ const byteOffset = raw && typeof raw === "object"
644
+ ? asNumber(raw.byteOffset)
645
+ : undefined;
646
+ if (byteOffset === undefined || byteOffset < 0 || !Number.isInteger(byteOffset)) {
647
+ return { byteOffset: 0 };
648
+ }
649
+ try {
650
+ if (statSync(join(bridgeDir, DELTAS_FILE)).size < byteOffset)
651
+ return { byteOffset: 0 };
652
+ }
653
+ catch {
654
+ if (byteOffset > 0)
655
+ return { byteOffset: 0 };
656
+ }
657
+ return { byteOffset };
658
+ }
659
+ export function writeDeltaForwardState(bridgeDir, state) {
660
+ mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
661
+ const tmp = join(bridgeDir, `${DELTA_FORWARDER_STATE_FILE}.tmp`);
662
+ writeFileSync(tmp, JSON.stringify({ byteOffset: state.byteOffset }));
663
+ renameSync(tmp, join(bridgeDir, DELTA_FORWARDER_STATE_FILE));
664
+ }
458
665
  /** Tail `message_deltas.jsonl` from a byte offset into {@link MessageDelta}s. */
459
666
  export function readMessageDeltasFrom(bridgeDir, byteOffset) {
460
667
  const { records, nextOffset } = readJsonlFrom(join(bridgeDir, DELTAS_FILE), byteOffset);
@@ -548,7 +755,36 @@ export function readForwardState(bridgeDir) {
548
755
  ? s.seenSourceIds.filter((x) => typeof x === "string")
549
756
  : [];
550
757
  const cursorFingerprint = asString(s.cursorFingerprint);
551
- return { transcriptPath, byteOffset, seenSourceIds, ...(cursorFingerprint ? { cursorFingerprint } : {}) };
758
+ const lineCursor = asNumber(s.lineCursor);
759
+ const currentTurnId = asString(s.currentTurnId);
760
+ const currentResponseId = asString(s.currentResponseId);
761
+ const activeTerminalCommandRaw = s.activeTerminalCommand;
762
+ const activeTerminalCommandRecord = activeTerminalCommandRaw &&
763
+ typeof activeTerminalCommandRaw === "object" &&
764
+ !Array.isArray(activeTerminalCommandRaw)
765
+ ? activeTerminalCommandRaw
766
+ : undefined;
767
+ const activeTerminalCommandText = asString(activeTerminalCommandRecord?.command);
768
+ const activeTerminalCommandTurnId = asString(activeTerminalCommandRecord?.turnId);
769
+ const activeTerminalCommand = activeTerminalCommandText
770
+ ? {
771
+ command: activeTerminalCommandText,
772
+ ...(activeTerminalCommandTurnId ? { turnId: activeTerminalCommandTurnId } : {}),
773
+ }
774
+ : undefined;
775
+ return {
776
+ transcriptPath,
777
+ byteOffset,
778
+ seenSourceIds,
779
+ ...(lineCursor !== undefined && Number.isInteger(lineCursor) && lineCursor >= 0
780
+ ? { lineCursor }
781
+ : {}),
782
+ ...(cursorFingerprint ? { cursorFingerprint } : {}),
783
+ ...(currentTurnId ? { currentTurnId } : {}),
784
+ ...(currentResponseId ? { currentResponseId } : {}),
785
+ ...(s.turnOpen === true ? { turnOpen: true } : {}),
786
+ ...(activeTerminalCommand ? { activeTerminalCommand } : {}),
787
+ };
552
788
  }
553
789
  /** Drop the forwarder cursor (a `/clear` starts fresh). */
554
790
  export function resetForwardState(bridgeDir) {
@@ -559,6 +795,88 @@ export function resetForwardState(bridgeDir) {
559
795
  // absent — fine
560
796
  }
561
797
  }
798
+ /** Parent Task/tool ids already consumed by the main transcript cursor. Kept
799
+ * independently of concrete child agents because Claude may create the child
800
+ * meta file only after the parent record was durably acknowledged. */
801
+ export function readSubagentParentResponses(bridgeDir, parentTranscriptPath) {
802
+ const raw = readJsonFile(join(bridgeDir, SUBAGENT_FORWARDER_STATE_FILE));
803
+ if (!raw || typeof raw !== "object")
804
+ return new Map();
805
+ const file = raw;
806
+ if (file.parentTranscriptPath !== parentTranscriptPath)
807
+ return new Map();
808
+ const responses = file.parentResponses;
809
+ if (!responses || typeof responses !== "object" || Array.isArray(responses))
810
+ return new Map();
811
+ const result = new Map();
812
+ for (const [parentToolCallId, responseId] of Object.entries(responses)) {
813
+ if (!parentToolCallId || typeof responseId !== "string" || !responseId)
814
+ continue;
815
+ result.set(parentToolCallId, responseId);
816
+ }
817
+ return result;
818
+ }
819
+ /** Read independent Claude native sub-agent cursors for one parent transcript. */
820
+ export function readSubagentForwardStates(bridgeDir, parentTranscriptPath) {
821
+ const raw = readJsonFile(join(bridgeDir, SUBAGENT_FORWARDER_STATE_FILE));
822
+ if (!raw || typeof raw !== "object")
823
+ return [];
824
+ const file = raw;
825
+ if (file.parentTranscriptPath !== parentTranscriptPath || !Array.isArray(file.agents)) {
826
+ return [];
827
+ }
828
+ const states = [];
829
+ for (const value of file.agents) {
830
+ if (!value || typeof value !== "object" || Array.isArray(value))
831
+ continue;
832
+ const state = value;
833
+ const agentId = asString(state.agentId);
834
+ const parentToolCallId = asString(state.parentToolCallId);
835
+ const parentResponseId = asString(state.parentResponseId);
836
+ const transcriptPath = asString(state.transcriptPath);
837
+ const byteOffset = asNumber(state.byteOffset);
838
+ const lineCursor = asNumber(state.lineCursor);
839
+ const terminalCommand = asString(state.terminalCommand);
840
+ if (!agentId || !parentToolCallId || !transcriptPath || byteOffset === undefined)
841
+ continue;
842
+ states.push({
843
+ agentId,
844
+ parentToolCallId,
845
+ ...(parentResponseId ? { parentResponseId } : {}),
846
+ transcriptPath,
847
+ byteOffset,
848
+ ...(lineCursor !== undefined && Number.isInteger(lineCursor) && lineCursor >= 0
849
+ ? { lineCursor }
850
+ : {}),
851
+ ...(terminalCommand ? { terminalCommand } : {}),
852
+ seenSourceIds: Array.isArray(state.seenSourceIds)
853
+ ? state.seenSourceIds
854
+ .filter((id) => typeof id === "string")
855
+ .slice(-MAX_SEEN_SOURCE_IDS)
856
+ : [],
857
+ });
858
+ }
859
+ return states;
860
+ }
861
+ /** Atomically persist every native sub-agent's independent delivery cursor. */
862
+ export function writeSubagentForwardStates(bridgeDir, parentTranscriptPath, states, parentResponses = new Map()) {
863
+ mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
864
+ const file = {
865
+ parentTranscriptPath,
866
+ parentResponses: Object.fromEntries([...parentResponses].slice(-MAX_SEEN_SOURCE_IDS)),
867
+ agents: states.map((state) => ({
868
+ ...state,
869
+ seenSourceIds: state.seenSourceIds.slice(-MAX_SEEN_SOURCE_IDS),
870
+ })),
871
+ };
872
+ const target = join(bridgeDir, SUBAGENT_FORWARDER_STATE_FILE);
873
+ const tmp = `${target}.tmp`;
874
+ writeFileSync(tmp, JSON.stringify(file));
875
+ renameSync(tmp, target);
876
+ }
877
+ export function resetSubagentForwardStates(bridgeDir) {
878
+ rmSync(join(bridgeDir, SUBAGENT_FORWARDER_STATE_FILE), { force: true });
879
+ }
562
880
  /** Read the latest `status.json` snapshot, or undefined if absent/malformed. */
563
881
  export function readClaudeStatus(bridgeDir) {
564
882
  const raw = readJsonFile(join(bridgeDir, STATUS_FILE));
@@ -3,7 +3,8 @@
3
3
  * interaction hooks rendezvous with the runner through the session bridge. */
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import { realpathSync } from "node:fs";
6
- import { claimInteractionResult, recordInteractionAck, recordHookEvent, recordInteractionRequest, removeClaimedInteractionResult, removeInteractionLease, touchInteractionLease, } from "./native-bridge.js";
6
+ import { claimInteractionResult, annotateClaudeResumeContext, recordInteractionAck, recordHookEvent, recordInteractionRequest, removeClaimedInteractionResult, removeInteractionLease, touchInteractionLease, } from "./native-bridge.js";
7
+ import { waitForTranscriptForkSignal, } from "./transcript.js";
7
8
  import { boundInteractionRequest, redactInteractionResolution, } from "../interactions.js";
8
9
  function argValue(argv, flag) {
9
10
  const i = argv.indexOf(flag);
@@ -23,6 +24,21 @@ function asRecord(value) {
23
24
  function asString(value) {
24
25
  return typeof value === "string" && value ? value : undefined;
25
26
  }
27
+ async function annotateClaudeForkSignal(bridgeDir, payload) {
28
+ annotateClaudeResumeContext(bridgeDir, payload);
29
+ if (payload.hook_event_name !== "SessionStart" ||
30
+ payload.source !== "resume" ||
31
+ payload.rynx_claude_session_was_seen === true)
32
+ return;
33
+ const transcriptPath = asString(payload.transcript_path);
34
+ const sessionId = asString(payload.session_id);
35
+ const sourceSessionId = asString(payload.rynx_previous_claude_session_id);
36
+ if (!transcriptPath || !sessionId || !sourceSessionId)
37
+ return;
38
+ const recordedAt = Date.now();
39
+ if (await waitForTranscriptForkSignal(transcriptPath, sessionId, sourceSessionId, recordedAt))
40
+ payload.rynx_fork_detected = true;
41
+ }
26
42
  function interactionId(payload) {
27
43
  // PermissionRequest does not carry a tool_use_id. A per-process nonce keeps
28
44
  // repeated or concurrent identical prompts from collapsing into one bridge id.
@@ -364,6 +380,7 @@ async function main() {
364
380
  }
365
381
  if (!hookKind) {
366
382
  try {
383
+ await annotateClaudeForkSignal(bridgeDir, payload);
367
384
  recordHookEvent(bridgeDir, payload);
368
385
  }
369
386
  catch {
@@ -48,6 +48,13 @@ export function buildClaudeHookSettings(options) {
48
48
  SessionStart: [{ hooks: [observerHook] }],
49
49
  Stop: [{ hooks: [observerHook] }],
50
50
  StopFailure: [{ hooks: [observerHook] }],
51
+ TaskCreated: [{ hooks: [observerHook] }],
52
+ TaskCompleted: [{ hooks: [observerHook] }],
53
+ PostToolUse: [
54
+ { matcher: "TodoWrite", hooks: [observerHook] },
55
+ { matcher: "TaskUpdate", hooks: [observerHook] },
56
+ ],
57
+ PreCompact: [{ hooks: [observerHook] }],
51
58
  };
52
59
  if (options.permissionMode === "bypassPermissions") {
53
60
  const ask = shJoin([node, entry, "ask-user-question", "--bridge-dir", options.bridgeDir]);