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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/claude/executor.d.ts +19 -5
  2. package/dist/claude/executor.js +56 -12
  3. package/dist/claude/models.d.ts +0 -5
  4. package/dist/claude/models.js +1 -7
  5. package/dist/claude/native-bridge.d.ts +103 -1
  6. package/dist/claude/native-bridge.js +445 -30
  7. package/dist/claude/native-hook-main.js +81 -1
  8. package/dist/claude/native-hooks.js +7 -0
  9. package/dist/claude/native-integration.d.ts +178 -26
  10. package/dist/claude/native-integration.js +1528 -170
  11. package/dist/claude/session-status.d.ts +39 -0
  12. package/dist/claude/session-status.js +163 -0
  13. package/dist/claude/transcript-clone.d.ts +18 -0
  14. package/dist/claude/transcript-clone.js +497 -0
  15. package/dist/claude/transcript.d.ts +27 -4
  16. package/dist/claude/transcript.js +158 -47
  17. package/dist/codex-app-server/client.d.ts +10 -6
  18. package/dist/codex-app-server/client.js +67 -15
  19. package/dist/codex-app-server/forwarder.d.ts +92 -3
  20. package/dist/codex-app-server/forwarder.js +532 -57
  21. package/dist/codex-app-server/mapping.d.ts +3 -6
  22. package/dist/codex-app-server/mapping.js +206 -36
  23. package/dist/codex-app-server/mcp-startup.d.ts +13 -0
  24. package/dist/codex-app-server/mcp-startup.js +63 -0
  25. package/dist/codex-app-server/process-registry.d.ts +36 -0
  26. package/dist/codex-app-server/process-registry.js +320 -0
  27. package/dist/codex-app-server/protocol.d.ts +64 -7
  28. package/dist/codex-app-server/ws-channel.d.ts +7 -0
  29. package/dist/codex-app-server/ws-channel.js +104 -28
  30. package/dist/codex-home.d.ts +35 -3
  31. package/dist/codex-home.js +323 -18
  32. package/dist/codex-session-store.d.ts +23 -0
  33. package/dist/codex-session-store.js +21 -0
  34. package/dist/host.d.ts +103 -46
  35. package/dist/host.js +1988 -634
  36. package/dist/index.d.ts +3 -3
  37. package/dist/index.js +1 -1
  38. package/dist/input-resources.d.ts +4 -0
  39. package/dist/input-resources.js +21 -5
  40. package/dist/models-catalog.d.ts +2 -1
  41. package/dist/models-catalog.js +94 -6
  42. package/dist/runner/child.d.ts +97 -28
  43. package/dist/runner/child.js +1486 -100
  44. package/dist/runner/manager.d.ts +110 -29
  45. package/dist/runner/manager.js +1481 -246
  46. package/dist/runner/protocol.d.ts +212 -24
  47. package/dist/runner/protocol.js +5 -0
  48. package/dist/runner/startup-policy.d.ts +7 -0
  49. package/dist/runner/startup-policy.js +10 -0
  50. package/dist/runner/transport.d.ts +18 -2
  51. package/dist/runner/transport.js +82 -3
  52. package/dist/runner-main.js +8 -3
  53. package/dist/terminal/claude-tui.d.ts +3 -1
  54. package/dist/terminal/claude-tui.js +3 -1
  55. package/dist/terminal/codex-tui.d.ts +4 -0
  56. package/dist/terminal/codex-tui.js +5 -0
  57. package/dist/terminal/control-parser.d.ts +39 -0
  58. package/dist/terminal/control-parser.js +172 -0
  59. package/dist/terminal/registry.d.ts +18 -15
  60. package/dist/terminal/registry.js +44 -23
  61. package/dist/terminal/spool.d.ts +47 -0
  62. package/dist/terminal/spool.js +231 -0
  63. package/dist/terminal/tmux.d.ts +126 -74
  64. package/dist/terminal/tmux.js +807 -211
  65. package/package.json +4 -4
@@ -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";
@@ -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");
@@ -47,6 +77,7 @@ export function prepareClaudeBridgeDir(sessionId) {
47
77
  INTERACTIONS_FILE,
48
78
  INTERACTION_ACKS_FILE,
49
79
  MANAGED_SETTINGS_FILE,
80
+ HOOK_FORWARDER_STATE_FILE,
50
81
  ]) {
51
82
  try {
52
83
  rmSync(join(dir, file));
@@ -267,17 +298,59 @@ function readJsonFile(path) {
267
298
  function asString(value) {
268
299
  return typeof value === "string" && value ? value : undefined;
269
300
  }
301
+ const TERMINAL_BACKGROUND_TASK_STATUSES = new Set([
302
+ "completed",
303
+ "failed",
304
+ "stopped",
305
+ "killed",
306
+ ]);
307
+ function backgroundTaskCount(payload) {
308
+ if (payload.hook_event_name !== "Stop")
309
+ return undefined;
310
+ const tasks = payload.background_tasks;
311
+ if (!Array.isArray(tasks))
312
+ return 0;
313
+ return tasks.filter((task) => {
314
+ if (!task || typeof task !== "object" || Array.isArray(task))
315
+ return true;
316
+ const status = task.status;
317
+ return typeof status !== "string" || !TERMINAL_BACKGROUND_TASK_STATUSES.has(status);
318
+ }).length;
319
+ }
270
320
  export function readClaudeState(bridgeDir) {
271
321
  const raw = readJsonFile(join(bridgeDir, STATE_FILE));
272
322
  if (!raw || typeof raw !== "object")
273
323
  return {};
274
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);
275
331
  return {
276
332
  transcriptPath: asString(s.transcriptPath),
277
- claudeSessionId: asString(s.claudeSessionId),
333
+ claudeSessionId: current,
334
+ ...(seen.length > 0 ? { seenClaudeSessionIds: seen } : {}),
278
335
  lastHookEventName: asString(s.lastHookEventName),
279
336
  };
280
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
+ }
281
354
  /**
282
355
  * Append one Claude hook payload to `hooks.jsonl` and fold its key fields into
283
356
  * `state.json` (transcript path + claude session id + last event). The payload
@@ -288,31 +361,58 @@ export function readClaudeState(bridgeDir) {
288
361
  */
289
362
  export function recordHookEvent(bridgeDir, payload) {
290
363
  mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
291
- const envelope = { recordedAt: Date.now(), payload };
292
- appendFileSync(join(bridgeDir, HOOKS_FILE), `${JSON.stringify(envelope)}\n`);
364
+ if (payload.rynx_claude_session_was_seen === undefined) {
365
+ annotateClaudeResumeContext(bridgeDir, payload);
366
+ }
293
367
  const state = readClaudeState(bridgeDir);
294
368
  const eventName = asString(payload.hook_event_name);
295
369
  if (eventName)
296
370
  state.lastHookEventName = eventName;
297
- const transcriptPath = asString(payload.transcript_path);
298
- if (transcriptPath)
299
- state.transcriptPath = transcriptPath;
300
371
  const claudeSessionId = asString(payload.session_id);
301
- if (claudeSessionId)
302
- state.claudeSessionId = claudeSessionId;
303
- 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);
304
388
  }
305
389
  /** Read complete newline-terminated JSON records appended after `byteOffset`. */
306
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) {
307
396
  let info;
308
397
  try {
309
398
  info = statSync(path);
310
399
  }
311
400
  catch {
312
- 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
+ };
313
415
  }
314
- if (info.size <= byteOffset)
315
- return { records: [], nextOffset: byteOffset };
316
416
  const fd = openSync(path, "r");
317
417
  try {
318
418
  const length = info.size - byteOffset;
@@ -323,41 +423,245 @@ export function readJsonlFrom(path, byteOffset) {
323
423
  const trailing = lines.pop() ?? ""; // partial (no trailing newline yet)
324
424
  const consumed = length - Buffer.byteLength(trailing, "utf8");
325
425
  const records = [];
426
+ const entries = [];
427
+ let recordOffset = byteOffset;
428
+ let lineNumber = startLineCursor;
326
429
  for (const line of lines) {
430
+ const lineOffset = recordOffset;
431
+ recordOffset += Buffer.byteLength(`${line}\n`, "utf8");
432
+ lineNumber += 1;
327
433
  const trimmed = line.trim();
328
434
  if (!trimmed)
329
435
  continue;
330
436
  try {
331
- records.push(JSON.parse(trimmed));
437
+ const record = JSON.parse(trimmed);
438
+ records.push(record);
439
+ entries.push({ record, byteOffset: lineOffset, lineNumber });
332
440
  }
333
441
  catch {
334
442
  // Skip a malformed complete line; the offset still advances past it.
335
443
  }
336
444
  }
337
- return { records, nextOffset: byteOffset + consumed };
445
+ return {
446
+ records,
447
+ entries,
448
+ nextOffset: byteOffset + consumed,
449
+ nextLineCursor: lineNumber,
450
+ };
338
451
  }
339
452
  finally {
340
453
  closeSync(fd);
341
454
  }
342
455
  }
343
456
  /** 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);
457
+ export function readHookEventsFrom(bridgeDir, byteOffset, startEventCursor = 0) {
458
+ const path = join(bridgeDir, HOOKS_FILE);
459
+ let info;
460
+ try {
461
+ info = statSync(path);
462
+ }
463
+ catch {
464
+ return { events: [], nextOffset: byteOffset };
465
+ }
466
+ if (info.size <= byteOffset)
467
+ return { events: [], nextOffset: byteOffset };
468
+ const fd = openSync(path, "r");
346
469
  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
- });
470
+ let nextOffset = byteOffset;
471
+ let eventCursor = startEventCursor;
472
+ try {
473
+ const length = info.size - byteOffset;
474
+ const buf = Buffer.alloc(length);
475
+ readSync(fd, buf, 0, length, byteOffset);
476
+ const text = buf.toString("utf8");
477
+ const lines = text.split("\n");
478
+ lines.pop(); // retain a partial trailing record for the next poll
479
+ for (const line of lines) {
480
+ const lineBytes = Buffer.byteLength(`${line}\n`, "utf8");
481
+ nextOffset += lineBytes;
482
+ if (!line.trim())
483
+ continue;
484
+ eventCursor += 1;
485
+ let rec;
486
+ try {
487
+ rec = JSON.parse(line);
488
+ }
489
+ catch {
490
+ events.push({ eventCursor, byteOffset: nextOffset, payload: {} });
491
+ continue;
492
+ }
493
+ const payload = rec.payload;
494
+ if (!payload || typeof payload !== "object") {
495
+ events.push({ eventCursor, byteOffset: nextOffset, payload: {} });
496
+ continue;
497
+ }
498
+ const liveBackgroundTasks = backgroundTaskCount(payload);
499
+ events.push({
500
+ eventCursor,
501
+ byteOffset: nextOffset,
502
+ ...(typeof rec.recordedAt === "number" && Number.isFinite(rec.recordedAt)
503
+ ? { recordedAt: rec.recordedAt }
504
+ : {}),
505
+ eventName: asString(payload.hook_event_name),
506
+ transcriptPath: asString(payload.transcript_path),
507
+ sessionId: asString(payload.session_id),
508
+ source: asString(payload.source),
509
+ ...(liveBackgroundTasks === undefined
510
+ ? {}
511
+ : { backgroundTaskCount: liveBackgroundTasks }),
512
+ payload,
513
+ });
514
+ }
515
+ }
516
+ finally {
517
+ closeSync(fd);
358
518
  }
359
519
  return { events, nextOffset };
360
520
  }
521
+ /** Read and validate the durable hook cursor. A replaced/truncated hook stream
522
+ * restarts at zero; event handlers provide their own stable deduplication. */
523
+ export function readHookForwardState(bridgeDir) {
524
+ const raw = readJsonFile(join(bridgeDir, HOOK_FORWARDER_STATE_FILE));
525
+ if (!raw || typeof raw !== "object")
526
+ return undefined;
527
+ const state = raw;
528
+ const eventCursor = asNumber(state.eventCursor);
529
+ const byteOffset = asNumber(state.byteOffset);
530
+ const cursorFingerprint = asString(state.cursorFingerprint);
531
+ if (eventCursor === undefined || eventCursor < 0 || !Number.isInteger(eventCursor) ||
532
+ byteOffset === undefined || byteOffset < 0 || !Number.isInteger(byteOffset))
533
+ return undefined;
534
+ const current = jsonlCursorFingerprint(join(bridgeDir, HOOKS_FILE), byteOffset);
535
+ if (!cursorFingerprint || current !== cursorFingerprint) {
536
+ return {
537
+ eventCursor: 0,
538
+ byteOffset: 0,
539
+ ...(jsonlCursorFingerprint(join(bridgeDir, HOOKS_FILE), 0)
540
+ ? { cursorFingerprint: jsonlCursorFingerprint(join(bridgeDir, HOOKS_FILE), 0) }
541
+ : {}),
542
+ };
543
+ }
544
+ return { eventCursor, byteOffset, cursorFingerprint };
545
+ }
546
+ /** Persist one consumed hook record before another poll can observe it. */
547
+ export function writeHookForwardState(bridgeDir, state) {
548
+ mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
549
+ const tmp = join(bridgeDir, `${HOOK_FORWARDER_STATE_FILE}.tmp`);
550
+ writeFileSync(tmp, JSON.stringify(state));
551
+ renameSync(tmp, join(bridgeDir, HOOK_FORWARDER_STATE_FILE));
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
+ }
361
665
  /** Tail `message_deltas.jsonl` from a byte offset into {@link MessageDelta}s. */
362
666
  export function readMessageDeltasFrom(bridgeDir, byteOffset) {
363
667
  const { records, nextOffset } = readJsonlFrom(join(bridgeDir, DELTAS_FILE), byteOffset);
@@ -451,7 +755,36 @@ export function readForwardState(bridgeDir) {
451
755
  ? s.seenSourceIds.filter((x) => typeof x === "string")
452
756
  : [];
453
757
  const cursorFingerprint = asString(s.cursorFingerprint);
454
- 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
+ };
455
788
  }
456
789
  /** Drop the forwarder cursor (a `/clear` starts fresh). */
457
790
  export function resetForwardState(bridgeDir) {
@@ -462,6 +795,88 @@ export function resetForwardState(bridgeDir) {
462
795
  // absent — fine
463
796
  }
464
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
+ }
465
880
  /** Read the latest `status.json` snapshot, or undefined if absent/malformed. */
466
881
  export function readClaudeStatus(bridgeDir) {
467
882
  const raw = readJsonFile(join(bridgeDir, STATUS_FILE));