@rynx-ai/runtime 0.1.11-beta.5 → 0.1.11-beta.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/claude/executor.d.ts +19 -5
  2. package/dist/claude/executor.js +56 -12
  3. package/dist/claude/models.d.ts +0 -5
  4. package/dist/claude/models.js +1 -7
  5. package/dist/claude/native-bridge.d.ts +103 -1
  6. package/dist/claude/native-bridge.js +460 -30
  7. package/dist/claude/native-hook-main.js +81 -1
  8. package/dist/claude/native-hooks.js +7 -0
  9. package/dist/claude/native-integration.d.ts +178 -26
  10. package/dist/claude/native-integration.js +1571 -170
  11. package/dist/claude/session-status.d.ts +39 -0
  12. package/dist/claude/session-status.js +163 -0
  13. package/dist/claude/transcript-clone.d.ts +18 -0
  14. package/dist/claude/transcript-clone.js +497 -0
  15. package/dist/claude/transcript.d.ts +27 -4
  16. package/dist/claude/transcript.js +183 -46
  17. package/dist/codex-app-server/client.d.ts +10 -6
  18. package/dist/codex-app-server/client.js +67 -15
  19. package/dist/codex-app-server/forwarder.d.ts +96 -3
  20. package/dist/codex-app-server/forwarder.js +542 -57
  21. package/dist/codex-app-server/mapping.d.ts +3 -6
  22. package/dist/codex-app-server/mapping.js +228 -36
  23. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  24. package/dist/codex-app-server/mcp-startup.js +63 -0
  25. package/dist/codex-app-server/process-registry.d.ts +36 -0
  26. package/dist/codex-app-server/process-registry.js +320 -0
  27. package/dist/codex-app-server/protocol.d.ts +68 -7
  28. package/dist/codex-app-server/transport.d.ts +2 -2
  29. package/dist/codex-app-server/transport.js +4 -0
  30. package/dist/codex-app-server/ws-channel.d.ts +8 -1
  31. package/dist/codex-app-server/ws-channel.js +113 -28
  32. package/dist/codex-child-env.js +3 -32
  33. package/dist/codex-home.d.ts +35 -3
  34. package/dist/codex-home.js +323 -18
  35. package/dist/codex-session-store.d.ts +23 -0
  36. package/dist/codex-session-store.js +21 -0
  37. package/dist/host.d.ts +106 -48
  38. package/dist/host.js +2024 -640
  39. package/dist/index.d.ts +3 -3
  40. package/dist/index.js +1 -1
  41. package/dist/input-resources.d.ts +4 -0
  42. package/dist/input-resources.js +21 -5
  43. package/dist/models-catalog.js +12 -3
  44. package/dist/runner/child.d.ts +105 -28
  45. package/dist/runner/child.js +1625 -100
  46. package/dist/runner/manager.d.ts +114 -46
  47. package/dist/runner/manager.js +1523 -446
  48. package/dist/runner/protocol.d.ts +212 -24
  49. package/dist/runner/protocol.js +5 -0
  50. package/dist/runner/startup-policy.d.ts +7 -0
  51. package/dist/runner/startup-policy.js +10 -0
  52. package/dist/runner/transport.d.ts +18 -2
  53. package/dist/runner/transport.js +82 -3
  54. package/dist/runner-main.js +8 -3
  55. package/dist/terminal/codex-tui.d.ts +4 -0
  56. package/dist/terminal/codex-tui.js +5 -0
  57. package/dist/terminal/control-parser.d.ts +39 -0
  58. package/dist/terminal/control-parser.js +172 -0
  59. package/dist/terminal/registry.d.ts +18 -15
  60. package/dist/terminal/registry.js +44 -23
  61. package/dist/terminal/spool.d.ts +47 -0
  62. package/dist/terminal/spool.js +231 -0
  63. package/dist/terminal/tmux.d.ts +126 -74
  64. package/dist/terminal/tmux.js +807 -211
  65. package/package.json +4 -4
@@ -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, copyFileSync, 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";
@@ -20,12 +20,42 @@ export const STATE_FILE = "state.json";
20
20
  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
+ 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";
23
27
  export const INTERACTIONS_FILE = "interactions.jsonl";
24
28
  export const INTERACTION_ACKS_FILE = "interaction-acks.jsonl";
25
29
  export const INTERACTION_RESULTS_DIR = "interaction-results";
26
30
  export const INTERACTION_CLAIMS_DIR = "interaction-claims";
27
31
  export const INTERACTION_LEASES_DIR = "interaction-leases";
28
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
+ }
29
59
  /** The deterministic bridge directory for a rynx session id. */
30
60
  export function claudeBridgeDir(sessionId) {
31
61
  return join(runtimeSessionStateDir(sessionId), "claude-bridge");
@@ -39,6 +69,21 @@ export function prepareClaudeBridgeDir(sessionId) {
39
69
  const dir = claudeBridgeDir(sessionId);
40
70
  adoptLegacyRuntimeDirectory(join(legacyRuntimeStateRoot(), "claude-native", runtimeSessionDigest(sessionId)), dir);
41
71
  mkdirSync(dir, { recursive: true, mode: 0o700 });
72
+ // Preserve original bridge logs before the normal runtime refresh clears its
73
+ // rendezvous. Collection itself never calls this function or changes files.
74
+ const history = join(dir, "history", randomUUID());
75
+ for (const file of [HOOKS_FILE, STATE_FILE, DELTAS_FILE, INTERACTIONS_FILE, INTERACTION_ACKS_FILE]) {
76
+ try {
77
+ if (!statSync(join(dir, file)).isFile())
78
+ continue;
79
+ mkdirSync(history, { recursive: true, mode: 0o700 });
80
+ copyFileSync(join(dir, file), join(history, file));
81
+ }
82
+ catch (error) {
83
+ if (error.code !== "ENOENT")
84
+ process.stderr.write(`Claude bridge log preservation failed: ${String(error)}\n`);
85
+ }
86
+ }
42
87
  for (const file of [
43
88
  HOOKS_FILE,
44
89
  STATE_FILE,
@@ -47,6 +92,7 @@ export function prepareClaudeBridgeDir(sessionId) {
47
92
  INTERACTIONS_FILE,
48
93
  INTERACTION_ACKS_FILE,
49
94
  MANAGED_SETTINGS_FILE,
95
+ HOOK_FORWARDER_STATE_FILE,
50
96
  ]) {
51
97
  try {
52
98
  rmSync(join(dir, file));
@@ -267,17 +313,59 @@ function readJsonFile(path) {
267
313
  function asString(value) {
268
314
  return typeof value === "string" && value ? value : undefined;
269
315
  }
316
+ const TERMINAL_BACKGROUND_TASK_STATUSES = new Set([
317
+ "completed",
318
+ "failed",
319
+ "stopped",
320
+ "killed",
321
+ ]);
322
+ function backgroundTaskCount(payload) {
323
+ if (payload.hook_event_name !== "Stop")
324
+ return undefined;
325
+ const tasks = payload.background_tasks;
326
+ if (!Array.isArray(tasks))
327
+ return 0;
328
+ return tasks.filter((task) => {
329
+ if (!task || typeof task !== "object" || Array.isArray(task))
330
+ return true;
331
+ const status = task.status;
332
+ return typeof status !== "string" || !TERMINAL_BACKGROUND_TASK_STATUSES.has(status);
333
+ }).length;
334
+ }
270
335
  export function readClaudeState(bridgeDir) {
271
336
  const raw = readJsonFile(join(bridgeDir, STATE_FILE));
272
337
  if (!raw || typeof raw !== "object")
273
338
  return {};
274
339
  const s = raw;
340
+ const seen = Array.isArray(s.seenClaudeSessionIds)
341
+ ? s.seenClaudeSessionIds.filter((value) => typeof value === "string" && value.length > 0)
342
+ : [];
343
+ const current = asString(s.claudeSessionId);
344
+ if (current && !seen.includes(current))
345
+ seen.push(current);
275
346
  return {
276
347
  transcriptPath: asString(s.transcriptPath),
277
- claudeSessionId: asString(s.claudeSessionId),
348
+ claudeSessionId: current,
349
+ ...(seen.length > 0 ? { seenClaudeSessionIds: seen } : {}),
278
350
  lastHookEventName: asString(s.lastHookEventName),
279
351
  };
280
352
  }
353
+ /** Capture the pre-SessionStart identity before state.json is advanced. The
354
+ * observer hook and forwarder use these stable facts to distinguish a new
355
+ * branch from a later resume into an already-seen native Session. */
356
+ export function annotateClaudeResumeContext(bridgeDir, payload) {
357
+ if (payload.hook_event_name !== "SessionStart" || payload.source !== "resume")
358
+ return;
359
+ const next = asString(payload.session_id);
360
+ if (!next)
361
+ return;
362
+ const state = readClaudeState(bridgeDir);
363
+ if (state.claudeSessionId && state.claudeSessionId !== next) {
364
+ payload.rynx_previous_claude_session_id = state.claudeSessionId;
365
+ }
366
+ payload.rynx_claude_session_was_seen =
367
+ (state.seenClaudeSessionIds ?? []).includes(next);
368
+ }
281
369
  /**
282
370
  * Append one Claude hook payload to `hooks.jsonl` and fold its key fields into
283
371
  * `state.json` (transcript path + claude session id + last event). The payload
@@ -288,31 +376,58 @@ export function readClaudeState(bridgeDir) {
288
376
  */
289
377
  export function recordHookEvent(bridgeDir, payload) {
290
378
  mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
291
- const envelope = { recordedAt: Date.now(), payload };
292
- appendFileSync(join(bridgeDir, HOOKS_FILE), `${JSON.stringify(envelope)}\n`);
379
+ if (payload.rynx_claude_session_was_seen === undefined) {
380
+ annotateClaudeResumeContext(bridgeDir, payload);
381
+ }
293
382
  const state = readClaudeState(bridgeDir);
294
383
  const eventName = asString(payload.hook_event_name);
295
384
  if (eventName)
296
385
  state.lastHookEventName = eventName;
297
- const transcriptPath = asString(payload.transcript_path);
298
- if (transcriptPath)
299
- state.transcriptPath = transcriptPath;
300
386
  const claudeSessionId = asString(payload.session_id);
301
- if (claudeSessionId)
302
- state.claudeSessionId = claudeSessionId;
303
- writeFileSync(join(bridgeDir, STATE_FILE), JSON.stringify(state));
387
+ const identityAllowed = eventName === "SessionStart" || (claudeSessionId !== undefined &&
388
+ (!state.claudeSessionId || claudeSessionId === state.claudeSessionId));
389
+ if (identityAllowed) {
390
+ const transcriptPath = asString(payload.transcript_path);
391
+ if (transcriptPath)
392
+ state.transcriptPath = transcriptPath;
393
+ if (claudeSessionId) {
394
+ state.claudeSessionId = claudeSessionId;
395
+ const seen = new Set(state.seenClaudeSessionIds ?? []);
396
+ seen.add(claudeSessionId);
397
+ state.seenClaudeSessionIds = [...seen].sort();
398
+ }
399
+ }
400
+ const envelope = { recordedAt: Date.now(), payload };
401
+ appendFileSync(join(bridgeDir, HOOKS_FILE), `${JSON.stringify(envelope)}\n`);
402
+ writeJsonFileAtomic(join(bridgeDir, STATE_FILE), state);
304
403
  }
305
404
  /** Read complete newline-terminated JSON records appended after `byteOffset`. */
306
405
  export function readJsonlFrom(path, byteOffset) {
406
+ const result = readJsonlEntriesFrom(path, byteOffset);
407
+ return { records: result.records, nextOffset: result.nextOffset };
408
+ }
409
+ /** Read complete records with their stable byte/line source positions. */
410
+ export function readJsonlEntriesFrom(path, byteOffset, startLineCursor = 0) {
307
411
  let info;
308
412
  try {
309
413
  info = statSync(path);
310
414
  }
311
415
  catch {
312
- return { records: [], nextOffset: byteOffset };
416
+ return {
417
+ records: [],
418
+ entries: [],
419
+ nextOffset: byteOffset,
420
+ nextLineCursor: startLineCursor,
421
+ };
422
+ }
423
+ if (info.size <= byteOffset) {
424
+ return {
425
+ records: [],
426
+ entries: [],
427
+ nextOffset: byteOffset,
428
+ nextLineCursor: startLineCursor,
429
+ };
313
430
  }
314
- if (info.size <= byteOffset)
315
- return { records: [], nextOffset: byteOffset };
316
431
  const fd = openSync(path, "r");
317
432
  try {
318
433
  const length = info.size - byteOffset;
@@ -323,41 +438,245 @@ export function readJsonlFrom(path, byteOffset) {
323
438
  const trailing = lines.pop() ?? ""; // partial (no trailing newline yet)
324
439
  const consumed = length - Buffer.byteLength(trailing, "utf8");
325
440
  const records = [];
441
+ const entries = [];
442
+ let recordOffset = byteOffset;
443
+ let lineNumber = startLineCursor;
326
444
  for (const line of lines) {
445
+ const lineOffset = recordOffset;
446
+ recordOffset += Buffer.byteLength(`${line}\n`, "utf8");
447
+ lineNumber += 1;
327
448
  const trimmed = line.trim();
328
449
  if (!trimmed)
329
450
  continue;
330
451
  try {
331
- records.push(JSON.parse(trimmed));
452
+ const record = JSON.parse(trimmed);
453
+ records.push(record);
454
+ entries.push({ record, byteOffset: lineOffset, lineNumber });
332
455
  }
333
456
  catch {
334
457
  // Skip a malformed complete line; the offset still advances past it.
335
458
  }
336
459
  }
337
- return { records, nextOffset: byteOffset + consumed };
460
+ return {
461
+ records,
462
+ entries,
463
+ nextOffset: byteOffset + consumed,
464
+ nextLineCursor: lineNumber,
465
+ };
338
466
  }
339
467
  finally {
340
468
  closeSync(fd);
341
469
  }
342
470
  }
343
471
  /** Tail `hooks.jsonl` from a byte offset into parsed {@link HookEvent}s. */
344
- export function readHookEventsFrom(bridgeDir, byteOffset) {
345
- const { records, nextOffset } = readJsonlFrom(join(bridgeDir, HOOKS_FILE), byteOffset);
472
+ export function readHookEventsFrom(bridgeDir, byteOffset, startEventCursor = 0) {
473
+ const path = join(bridgeDir, HOOKS_FILE);
474
+ let info;
475
+ try {
476
+ info = statSync(path);
477
+ }
478
+ catch {
479
+ return { events: [], nextOffset: byteOffset };
480
+ }
481
+ if (info.size <= byteOffset)
482
+ return { events: [], nextOffset: byteOffset };
483
+ const fd = openSync(path, "r");
346
484
  const events = [];
347
- for (const rec of records) {
348
- const payload = rec.payload;
349
- if (!payload || typeof payload !== "object")
350
- continue;
351
- events.push({
352
- eventName: asString(payload.hook_event_name),
353
- transcriptPath: asString(payload.transcript_path),
354
- sessionId: asString(payload.session_id),
355
- source: asString(payload.source),
356
- payload,
357
- });
485
+ let nextOffset = byteOffset;
486
+ let eventCursor = startEventCursor;
487
+ try {
488
+ const length = info.size - byteOffset;
489
+ const buf = Buffer.alloc(length);
490
+ readSync(fd, buf, 0, length, byteOffset);
491
+ const text = buf.toString("utf8");
492
+ const lines = text.split("\n");
493
+ lines.pop(); // retain a partial trailing record for the next poll
494
+ for (const line of lines) {
495
+ const lineBytes = Buffer.byteLength(`${line}\n`, "utf8");
496
+ nextOffset += lineBytes;
497
+ if (!line.trim())
498
+ continue;
499
+ eventCursor += 1;
500
+ let rec;
501
+ try {
502
+ rec = JSON.parse(line);
503
+ }
504
+ catch {
505
+ events.push({ eventCursor, byteOffset: nextOffset, payload: {} });
506
+ continue;
507
+ }
508
+ const payload = rec.payload;
509
+ if (!payload || typeof payload !== "object") {
510
+ events.push({ eventCursor, byteOffset: nextOffset, payload: {} });
511
+ continue;
512
+ }
513
+ const liveBackgroundTasks = backgroundTaskCount(payload);
514
+ events.push({
515
+ eventCursor,
516
+ byteOffset: nextOffset,
517
+ ...(typeof rec.recordedAt === "number" && Number.isFinite(rec.recordedAt)
518
+ ? { recordedAt: rec.recordedAt }
519
+ : {}),
520
+ eventName: asString(payload.hook_event_name),
521
+ transcriptPath: asString(payload.transcript_path),
522
+ sessionId: asString(payload.session_id),
523
+ source: asString(payload.source),
524
+ ...(liveBackgroundTasks === undefined
525
+ ? {}
526
+ : { backgroundTaskCount: liveBackgroundTasks }),
527
+ payload,
528
+ });
529
+ }
530
+ }
531
+ finally {
532
+ closeSync(fd);
358
533
  }
359
534
  return { events, nextOffset };
360
535
  }
536
+ /** Read and validate the durable hook cursor. A replaced/truncated hook stream
537
+ * restarts at zero; event handlers provide their own stable deduplication. */
538
+ export function readHookForwardState(bridgeDir) {
539
+ const raw = readJsonFile(join(bridgeDir, HOOK_FORWARDER_STATE_FILE));
540
+ if (!raw || typeof raw !== "object")
541
+ return undefined;
542
+ const state = raw;
543
+ const eventCursor = asNumber(state.eventCursor);
544
+ const byteOffset = asNumber(state.byteOffset);
545
+ const cursorFingerprint = asString(state.cursorFingerprint);
546
+ if (eventCursor === undefined || eventCursor < 0 || !Number.isInteger(eventCursor) ||
547
+ byteOffset === undefined || byteOffset < 0 || !Number.isInteger(byteOffset))
548
+ return undefined;
549
+ const current = jsonlCursorFingerprint(join(bridgeDir, HOOKS_FILE), byteOffset);
550
+ if (!cursorFingerprint || current !== cursorFingerprint) {
551
+ return {
552
+ eventCursor: 0,
553
+ byteOffset: 0,
554
+ ...(jsonlCursorFingerprint(join(bridgeDir, HOOKS_FILE), 0)
555
+ ? { cursorFingerprint: jsonlCursorFingerprint(join(bridgeDir, HOOKS_FILE), 0) }
556
+ : {}),
557
+ };
558
+ }
559
+ return { eventCursor, byteOffset, cursorFingerprint };
560
+ }
561
+ /** Persist one consumed hook record before another poll can observe it. */
562
+ export function writeHookForwardState(bridgeDir, state) {
563
+ mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
564
+ const tmp = join(bridgeDir, `${HOOK_FORWARDER_STATE_FILE}.tmp`);
565
+ writeFileSync(tmp, JSON.stringify(state));
566
+ renameSync(tmp, join(bridgeDir, HOOK_FORWARDER_STATE_FILE));
567
+ }
568
+ export function readCompactionForwardState(bridgeDir, generation) {
569
+ let raw;
570
+ try {
571
+ raw = JSON.parse(readFileSync(join(bridgeDir, COMPACTION_FORWARDER_STATE_FILE), "utf8"));
572
+ }
573
+ catch {
574
+ return {
575
+ generation,
576
+ lastSequence: 0,
577
+ persistedSequences: [],
578
+ lastPrecompactCursor: 0,
579
+ };
580
+ }
581
+ if (!raw || typeof raw !== "object") {
582
+ return {
583
+ generation,
584
+ lastSequence: 0,
585
+ persistedSequences: [],
586
+ lastPrecompactCursor: 0,
587
+ };
588
+ }
589
+ const value = raw;
590
+ if (asString(value.generation) !== generation) {
591
+ return {
592
+ generation,
593
+ lastSequence: 0,
594
+ persistedSequences: [],
595
+ lastPrecompactCursor: 0,
596
+ };
597
+ }
598
+ const lastSequence = asNumber(value.lastSequence);
599
+ const lastPrecompactCursor = asNumber(value.lastPrecompactCursor);
600
+ const persistedSequences = Array.isArray(value.persistedSequences)
601
+ ? value.persistedSequences.filter((entry) => typeof entry === "number" && Number.isInteger(entry) && entry > 0).slice(-16)
602
+ : [];
603
+ const pendingRaw = value.pending;
604
+ let pending;
605
+ if (pendingRaw && typeof pendingRaw === "object") {
606
+ const candidate = pendingRaw;
607
+ const sequence = asNumber(candidate.sequence);
608
+ if (sequence !== undefined && Number.isInteger(sequence) && sequence > 0) {
609
+ pending = {
610
+ sequence,
611
+ ...(asString(candidate.claudeSessionId)
612
+ ? { claudeSessionId: asString(candidate.claudeSessionId) }
613
+ : {}),
614
+ ...(asString(candidate.transcriptPath)
615
+ ? { transcriptPath: asString(candidate.transcriptPath) }
616
+ : {}),
617
+ };
618
+ }
619
+ }
620
+ const expectCompletionAckSequence = asNumber(value.expectCompletionAckSequence);
621
+ return {
622
+ generation,
623
+ lastSequence: lastSequence !== undefined && Number.isInteger(lastSequence) && lastSequence >= 0
624
+ ? lastSequence
625
+ : 0,
626
+ persistedSequences,
627
+ lastPrecompactCursor: lastPrecompactCursor !== undefined && Number.isInteger(lastPrecompactCursor) &&
628
+ lastPrecompactCursor >= 0
629
+ ? lastPrecompactCursor
630
+ : 0,
631
+ ...(pending ? { pending } : {}),
632
+ ...(expectCompletionAckSequence !== undefined &&
633
+ Number.isInteger(expectCompletionAckSequence) && expectCompletionAckSequence > 0
634
+ ? { expectCompletionAckSequence }
635
+ : {}),
636
+ };
637
+ }
638
+ export function writeCompactionForwardState(bridgeDir, state) {
639
+ mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
640
+ const tmp = join(bridgeDir, `${COMPACTION_FORWARDER_STATE_FILE}.tmp`);
641
+ writeFileSync(tmp, JSON.stringify({
642
+ ...state,
643
+ persistedSequences: state.persistedSequences.slice(-16),
644
+ }));
645
+ renameSync(tmp, join(bridgeDir, COMPACTION_FORWARDER_STATE_FILE));
646
+ }
647
+ /** Read the process-wide MessageDisplay cursor. It deliberately survives
648
+ * logical Session rotation; if the append log was truncated by a fresh Claude
649
+ * process, restart at zero. */
650
+ export function readDeltaForwardState(bridgeDir) {
651
+ let raw;
652
+ try {
653
+ raw = JSON.parse(readFileSync(join(bridgeDir, DELTA_FORWARDER_STATE_FILE), "utf8"));
654
+ }
655
+ catch {
656
+ return { byteOffset: 0 };
657
+ }
658
+ const byteOffset = raw && typeof raw === "object"
659
+ ? asNumber(raw.byteOffset)
660
+ : undefined;
661
+ if (byteOffset === undefined || byteOffset < 0 || !Number.isInteger(byteOffset)) {
662
+ return { byteOffset: 0 };
663
+ }
664
+ try {
665
+ if (statSync(join(bridgeDir, DELTAS_FILE)).size < byteOffset)
666
+ return { byteOffset: 0 };
667
+ }
668
+ catch {
669
+ if (byteOffset > 0)
670
+ return { byteOffset: 0 };
671
+ }
672
+ return { byteOffset };
673
+ }
674
+ export function writeDeltaForwardState(bridgeDir, state) {
675
+ mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
676
+ const tmp = join(bridgeDir, `${DELTA_FORWARDER_STATE_FILE}.tmp`);
677
+ writeFileSync(tmp, JSON.stringify({ byteOffset: state.byteOffset }));
678
+ renameSync(tmp, join(bridgeDir, DELTA_FORWARDER_STATE_FILE));
679
+ }
361
680
  /** Tail `message_deltas.jsonl` from a byte offset into {@link MessageDelta}s. */
362
681
  export function readMessageDeltasFrom(bridgeDir, byteOffset) {
363
682
  const { records, nextOffset } = readJsonlFrom(join(bridgeDir, DELTAS_FILE), byteOffset);
@@ -451,7 +770,36 @@ export function readForwardState(bridgeDir) {
451
770
  ? s.seenSourceIds.filter((x) => typeof x === "string")
452
771
  : [];
453
772
  const cursorFingerprint = asString(s.cursorFingerprint);
454
- return { transcriptPath, byteOffset, seenSourceIds, ...(cursorFingerprint ? { cursorFingerprint } : {}) };
773
+ const lineCursor = asNumber(s.lineCursor);
774
+ const currentTurnId = asString(s.currentTurnId);
775
+ const currentResponseId = asString(s.currentResponseId);
776
+ const activeTerminalCommandRaw = s.activeTerminalCommand;
777
+ const activeTerminalCommandRecord = activeTerminalCommandRaw &&
778
+ typeof activeTerminalCommandRaw === "object" &&
779
+ !Array.isArray(activeTerminalCommandRaw)
780
+ ? activeTerminalCommandRaw
781
+ : undefined;
782
+ const activeTerminalCommandText = asString(activeTerminalCommandRecord?.command);
783
+ const activeTerminalCommandTurnId = asString(activeTerminalCommandRecord?.turnId);
784
+ const activeTerminalCommand = activeTerminalCommandText
785
+ ? {
786
+ command: activeTerminalCommandText,
787
+ ...(activeTerminalCommandTurnId ? { turnId: activeTerminalCommandTurnId } : {}),
788
+ }
789
+ : undefined;
790
+ return {
791
+ transcriptPath,
792
+ byteOffset,
793
+ seenSourceIds,
794
+ ...(lineCursor !== undefined && Number.isInteger(lineCursor) && lineCursor >= 0
795
+ ? { lineCursor }
796
+ : {}),
797
+ ...(cursorFingerprint ? { cursorFingerprint } : {}),
798
+ ...(currentTurnId ? { currentTurnId } : {}),
799
+ ...(currentResponseId ? { currentResponseId } : {}),
800
+ ...(s.turnOpen === true ? { turnOpen: true } : {}),
801
+ ...(activeTerminalCommand ? { activeTerminalCommand } : {}),
802
+ };
455
803
  }
456
804
  /** Drop the forwarder cursor (a `/clear` starts fresh). */
457
805
  export function resetForwardState(bridgeDir) {
@@ -462,6 +810,88 @@ export function resetForwardState(bridgeDir) {
462
810
  // absent — fine
463
811
  }
464
812
  }
813
+ /** Parent Task/tool ids already consumed by the main transcript cursor. Kept
814
+ * independently of concrete child agents because Claude may create the child
815
+ * meta file only after the parent record was durably acknowledged. */
816
+ export function readSubagentParentResponses(bridgeDir, parentTranscriptPath) {
817
+ const raw = readJsonFile(join(bridgeDir, SUBAGENT_FORWARDER_STATE_FILE));
818
+ if (!raw || typeof raw !== "object")
819
+ return new Map();
820
+ const file = raw;
821
+ if (file.parentTranscriptPath !== parentTranscriptPath)
822
+ return new Map();
823
+ const responses = file.parentResponses;
824
+ if (!responses || typeof responses !== "object" || Array.isArray(responses))
825
+ return new Map();
826
+ const result = new Map();
827
+ for (const [parentToolCallId, responseId] of Object.entries(responses)) {
828
+ if (!parentToolCallId || typeof responseId !== "string" || !responseId)
829
+ continue;
830
+ result.set(parentToolCallId, responseId);
831
+ }
832
+ return result;
833
+ }
834
+ /** Read independent Claude native sub-agent cursors for one parent transcript. */
835
+ export function readSubagentForwardStates(bridgeDir, parentTranscriptPath) {
836
+ const raw = readJsonFile(join(bridgeDir, SUBAGENT_FORWARDER_STATE_FILE));
837
+ if (!raw || typeof raw !== "object")
838
+ return [];
839
+ const file = raw;
840
+ if (file.parentTranscriptPath !== parentTranscriptPath || !Array.isArray(file.agents)) {
841
+ return [];
842
+ }
843
+ const states = [];
844
+ for (const value of file.agents) {
845
+ if (!value || typeof value !== "object" || Array.isArray(value))
846
+ continue;
847
+ const state = value;
848
+ const agentId = asString(state.agentId);
849
+ const parentToolCallId = asString(state.parentToolCallId);
850
+ const parentResponseId = asString(state.parentResponseId);
851
+ const transcriptPath = asString(state.transcriptPath);
852
+ const byteOffset = asNumber(state.byteOffset);
853
+ const lineCursor = asNumber(state.lineCursor);
854
+ const terminalCommand = asString(state.terminalCommand);
855
+ if (!agentId || !parentToolCallId || !transcriptPath || byteOffset === undefined)
856
+ continue;
857
+ states.push({
858
+ agentId,
859
+ parentToolCallId,
860
+ ...(parentResponseId ? { parentResponseId } : {}),
861
+ transcriptPath,
862
+ byteOffset,
863
+ ...(lineCursor !== undefined && Number.isInteger(lineCursor) && lineCursor >= 0
864
+ ? { lineCursor }
865
+ : {}),
866
+ ...(terminalCommand ? { terminalCommand } : {}),
867
+ seenSourceIds: Array.isArray(state.seenSourceIds)
868
+ ? state.seenSourceIds
869
+ .filter((id) => typeof id === "string")
870
+ .slice(-MAX_SEEN_SOURCE_IDS)
871
+ : [],
872
+ });
873
+ }
874
+ return states;
875
+ }
876
+ /** Atomically persist every native sub-agent's independent delivery cursor. */
877
+ export function writeSubagentForwardStates(bridgeDir, parentTranscriptPath, states, parentResponses = new Map()) {
878
+ mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
879
+ const file = {
880
+ parentTranscriptPath,
881
+ parentResponses: Object.fromEntries([...parentResponses].slice(-MAX_SEEN_SOURCE_IDS)),
882
+ agents: states.map((state) => ({
883
+ ...state,
884
+ seenSourceIds: state.seenSourceIds.slice(-MAX_SEEN_SOURCE_IDS),
885
+ })),
886
+ };
887
+ const target = join(bridgeDir, SUBAGENT_FORWARDER_STATE_FILE);
888
+ const tmp = `${target}.tmp`;
889
+ writeFileSync(tmp, JSON.stringify(file));
890
+ renameSync(tmp, target);
891
+ }
892
+ export function resetSubagentForwardStates(bridgeDir) {
893
+ rmSync(join(bridgeDir, SUBAGENT_FORWARDER_STATE_FILE), { force: true });
894
+ }
465
895
  /** Read the latest `status.json` snapshot, or undefined if absent/malformed. */
466
896
  export function readClaudeStatus(bridgeDir) {
467
897
  const raw = readJsonFile(join(bridgeDir, STATUS_FILE));