@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.
package/dist/host.js CHANGED
@@ -4,7 +4,7 @@ import { cp, mkdir, mkdtemp, realpath, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import path from "node:path";
6
6
  import readline from "node:readline";
7
- import { buildAgentSkillEnvironment, hashPluginPackageTree, scanSkillsDir, SessionNormalizer, } from "@rynx-ai/core";
7
+ import { buildAgentSkillEnvironment, hashPluginPackageTree, scanSkillsDir, SessionNormalizer, newSessionItemId, } from "@rynx-ai/core";
8
8
  import { getRuntimeProfile, } from "@rynx-ai/core";
9
9
  import { resolveRuntimeBinary, } from "@rynx-ai/core";
10
10
  import { createCodexChildEnv } from "./codex-child-env.js";
@@ -24,13 +24,34 @@ import { buildClaudeTuiArgs } from "./terminal/claude-tui.js";
24
24
  import { providerAdditionalDirs, threadWorkspaceParams, turnWorkspaceParams, } from "./provider-workspace.js";
25
25
  import { ensureProjectTrusted } from "./claude/trust.js";
26
26
  import { claudeAttachmentToken, claudeInputText, runtimeUserContent, } from "./input-resources.js";
27
- import { claudeBridgeDir, prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
27
+ import { prepareClaudeBridgeDir, removeManagedClaudeSettings, writeManagedClaudeSettings, } from "./claude/native-bridge.js";
28
28
  import { ClaudeLiveSession, injectViaTerminal, } from "./claude/native-integration.js";
29
29
  import { buildClaudeHookSettings } from "./claude/native-hooks.js";
30
30
  import { buildManagedClaudeSettings } from "./claude/settings.js";
31
31
  import { FileCodexSessionStore, resolveCodexSessionStorePath, } from "./codex-session-store.js";
32
32
  import { AgentRuntimeError as CodexRuntimeError } from "@rynx-ai/core";
33
33
  import { newSessionId as makeSessionId } from "@rynx-ai/core";
34
+ function isDurableSessionEvent(event) {
35
+ return event.type === "response.output_item.done" ||
36
+ event.type === "session.input.consumed" ||
37
+ event.type === "session.interaction.requested" ||
38
+ event.type === "session.interaction.resolved" ||
39
+ event.type === "session.interaction.cancelled";
40
+ }
41
+ /**
42
+ * Events produced while a native Provider is crossing a logical Session
43
+ * rotation are normalized before the new Rynx id is known. Re-stamp both the
44
+ * envelope and any durable item before releasing that buffer into the target.
45
+ */
46
+ function retargetSessionEvent(event, sessionId) {
47
+ const retargeted = structuredClone(event);
48
+ if (typeof retargeted.sessionId === "string")
49
+ retargeted.sessionId = sessionId;
50
+ if (retargeted.item && typeof retargeted.item.sessionId === "string") {
51
+ retargeted.item.sessionId = sessionId;
52
+ }
53
+ return retargeted;
54
+ }
34
55
  import { resolveAllowedRoots, } from "@rynx-ai/core";
35
56
  export { FileCodexSessionStore, resolveCodexSessionStorePath, };
36
57
  // The runtime error type lives in @rynx-ai/core (as AgentRuntimeError) so channels
@@ -1364,6 +1385,9 @@ export class LocalAgentHost {
1364
1385
  ...(previous?.runtimeHomeOwnerSessionId
1365
1386
  ? { runtimeHomeOwnerSessionId: previous.runtimeHomeOwnerSessionId }
1366
1387
  : {}),
1388
+ ...(previous?.bridgeOwnerSessionId
1389
+ ? { bridgeOwnerSessionId: previous.bridgeOwnerSessionId }
1390
+ : {}),
1367
1391
  updatedAt: new Date().toISOString(),
1368
1392
  }))
1369
1393
  .then(() => {
@@ -1528,6 +1552,9 @@ export class LocalAgentHost {
1528
1552
  ...(existing?.runtimeHomeOwnerSessionId
1529
1553
  ? { runtimeHomeOwnerSessionId: existing.runtimeHomeOwnerSessionId }
1530
1554
  : {}),
1555
+ ...(existing?.bridgeOwnerSessionId
1556
+ ? { bridgeOwnerSessionId: existing.bridgeOwnerSessionId }
1557
+ : {}),
1531
1558
  updatedAt: new Date().toISOString(),
1532
1559
  });
1533
1560
  })
@@ -2168,13 +2195,13 @@ export class LocalAgentHost {
2168
2195
  const effectiveSettings = applyClaudePermissionModeSnapshot(inheritedSettings, live.permissionMode);
2169
2196
  const hookPermissionMode = live.permissionMode;
2170
2197
  const hookSettings = buildClaudeHookSettings({
2171
- bridgeDir: claudeBridgeDir(localThreadId),
2198
+ bridgeDir: live.bridgeDir,
2172
2199
  ...(hookPermissionMode ? { permissionMode: hookPermissionMode } : {}),
2173
2200
  messageDisplay: true,
2174
2201
  statusLine: true,
2175
2202
  });
2176
2203
  const settings = { ...effectiveSettings, ...hookSettings };
2177
- const settingsPath = writeManagedClaudeSettings(claudeBridgeDir(localThreadId), settings);
2204
+ const settingsPath = writeManagedClaudeSettings(live.bridgeDir, settings);
2178
2205
  // The live session carries the daemon-owned execution state used by Chat.
2179
2206
  const args = buildClaudeTuiArgs({
2180
2207
  settingsJson: settingsPath,
@@ -2192,11 +2219,9 @@ export class LocalAgentHost {
2192
2219
  : {}),
2193
2220
  additionalDirs: providerAdditionalDirs(live.workspace),
2194
2221
  ...(live.forkIntent
2195
- ? {
2196
- resume: live.forkIntent.sourceClaudeSessionId,
2197
- forkSession: true,
2198
- sessionId: live.forkIntent.targetClaudeSessionId,
2199
- }
2222
+ ? live.forkTranscriptPrepared
2223
+ ? { resume: live.forkIntent.targetClaudeSessionId }
2224
+ : { sessionId: live.forkIntent.targetClaudeSessionId }
2200
2225
  : record?.codexSessionId
2201
2226
  ? { resume: record.codexSessionId }
2202
2227
  : {}),
@@ -2312,19 +2337,96 @@ export class LocalAgentHost {
2312
2337
  throw error;
2313
2338
  }
2314
2339
  }
2340
+ /** Repair the only cross-process crash window in native rotation. A target
2341
+ * started through the Session registry proves that logical publication is
2342
+ * durable; a published target in turn proves its source must no longer own
2343
+ * the shared live bridge. */
2344
+ async reconcileClaudeNativeRotation(localThreadId, record) {
2345
+ const records = await this.sessionStore.listAll();
2346
+ let current = record;
2347
+ const ownTarget = current?.nativeRotationSourceSessionId
2348
+ ? current
2349
+ : undefined;
2350
+ let promotedOwnTarget = false;
2351
+ const ownTargetSharedBridgeOwner = ownTarget?.bridgeOwnerSessionId;
2352
+ if (ownTarget && !ownTarget.nativeRotationPublished) {
2353
+ promotedOwnTarget = true;
2354
+ current = {
2355
+ ...ownTarget,
2356
+ // Recovery cannot resume the interrupted physical handoff. Give the
2357
+ // visible target its own bridge before publishing it, so a source that
2358
+ // was reopened first can never share hooks/cursors with this process.
2359
+ // The persisted initial transcript offset remains the replay boundary.
2360
+ bridgeOwnerSessionId: ownTarget.localThreadId,
2361
+ nativeRotationPublished: true,
2362
+ updatedAt: new Date().toISOString(),
2363
+ };
2364
+ await this.sessionStore.set(current);
2365
+ }
2366
+ const linkedTarget = current?.nativeRotationTargetSessionId
2367
+ ? records.find((candidate) => candidate.localThreadId === current?.nativeRotationTargetSessionId &&
2368
+ candidate.nativeRotationPublished &&
2369
+ candidate.nativeRotationSourceSessionId === localThreadId)
2370
+ : undefined;
2371
+ const legacyLatestTarget = records
2372
+ .filter((candidate) => candidate.nativeRotationPublished &&
2373
+ candidate.nativeRotationSourceSessionId === localThreadId)
2374
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) ||
2375
+ b.localThreadId.localeCompare(a.localThreadId))[0];
2376
+ // A Session can be both an inbound target (A→B) and later an outbound
2377
+ // source (B→C). Its latest outbound edge is authoritative for reopening B;
2378
+ // choosing B's older inbound marker would reopen the bridge now owned by C.
2379
+ const publishedTarget = linkedTarget ?? legacyLatestTarget ??
2380
+ (current?.nativeRotationSourceSessionId && current.nativeRotationPublished
2381
+ ? current
2382
+ : undefined);
2383
+ if (publishedTarget) {
2384
+ const source = await this.sessionStore.get(publishedTarget.nativeRotationSourceSessionId);
2385
+ const kind = publishedTarget.nativeRotationKind ?? "clear";
2386
+ const retiredBridgeOwner = `${source?.localThreadId ?? localThreadId}-${kind}-retired-${publishedTarget.localThreadId}`;
2387
+ // A source can rotate more than once after its old conversation is
2388
+ // reopened. Never let opening an historical target roll the source's
2389
+ // authoritative outbound link back from S->T2 to the older S->T1 edge.
2390
+ // Records written before nativeRotationTargetSessionId existed are only
2391
+ // repairable when source and target still prove they share one bridge.
2392
+ const sharedBridgeProof = promotedOwnTarget && publishedTarget.localThreadId === current?.localThreadId
2393
+ ? ownTargetSharedBridgeOwner
2394
+ : publishedTarget.bridgeOwnerSessionId;
2395
+ const isAuthoritativeTarget = source && (source.nativeRotationTargetSessionId === publishedTarget.localThreadId ||
2396
+ (!source.nativeRotationTargetSessionId &&
2397
+ source.bridgeOwnerSessionId === sharedBridgeProof));
2398
+ if (isAuthoritativeTarget && source.bridgeOwnerSessionId !== retiredBridgeOwner) {
2399
+ const retired = {
2400
+ ...source,
2401
+ bridgeOwnerSessionId: retiredBridgeOwner,
2402
+ nativeRotationTargetSessionId: publishedTarget.localThreadId,
2403
+ updatedAt: new Date().toISOString(),
2404
+ };
2405
+ await this.sessionStore.set(retired);
2406
+ if (source.localThreadId === localThreadId)
2407
+ current = retired;
2408
+ }
2409
+ }
2410
+ return current;
2411
+ }
2315
2412
  async startLiveClaudeSession(localThreadId, emit, record, opts) {
2316
2413
  const retargetMirror = opts.retargetMirror;
2414
+ record = await this.reconcileClaudeNativeRotation(localThreadId, record);
2317
2415
  const forkIntent = await this.sessionStore.getClaudeForkIntent?.(localThreadId) ?? null;
2318
2416
  if (forkIntent && forkIntent.targetSessionId !== localThreadId) {
2319
2417
  throw new CodexRuntimeError("Claude fork intent targets a different Session", 409, "invalid_fork_intent");
2320
2418
  }
2321
- const bridgeDir = prepareClaudeBridgeDir(localThreadId);
2419
+ const runtimeHomeOwnerSessionId = record?.runtimeHomeOwnerSessionId ?? localThreadId;
2420
+ const bridgeOwnerSessionId = record?.bridgeOwnerSessionId ?? runtimeHomeOwnerSessionId;
2421
+ const bridgeDir = prepareClaudeBridgeDir(bridgeOwnerSessionId);
2322
2422
  const workspace = structuredClone(opts.workspace);
2323
2423
  const execution = structuredClone(opts.execution);
2324
2424
  if (execution.provider !== "claude") {
2325
2425
  throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
2326
2426
  }
2327
2427
  const cwd = workspace.cwd;
2428
+ const forkResumePrefixBytes = forkIntent?.forkTranscriptPrefixBytes;
2429
+ const forkTranscriptPrepared = Boolean(forkIntent?.forkTranscriptPath);
2328
2430
  const hasSkillSnapshot = execution.skills.length > 0 || execution.pluginSkills.length > 0;
2329
2431
  let skillPlugin = hasSkillSnapshot
2330
2432
  ? await reuseClaudePlugin(localThreadId)
@@ -2356,13 +2458,65 @@ export class LocalAgentHost {
2356
2458
  let currentSessionId = localThreadId;
2357
2459
  let normalizer = null;
2358
2460
  let currentResponseId;
2461
+ const subagentParentResponseIds = new Map();
2462
+ const lateSubagentNormalizers = new Map();
2359
2463
  let pendingRotationEvents = null;
2360
- const emitCurrent = (event) => {
2464
+ let rotationPublication = null;
2465
+ let transcriptDelivery = null;
2466
+ const emitRotationAware = (event, delivery) => {
2361
2467
  if (pendingRotationEvents) {
2362
- pendingRotationEvents.push(event);
2468
+ pendingRotationEvents.push({ event, ...(delivery ? { delivery } : {}) });
2363
2469
  return;
2364
2470
  }
2365
- emit(event);
2471
+ return emit(event, delivery);
2472
+ };
2473
+ const publishForwarderFailure = (reason, responseId) => {
2474
+ void Promise.resolve().then(() => emitRotationAware({
2475
+ type: "session.status",
2476
+ sessionId: currentSessionId,
2477
+ ...(responseId ? { responseId } : {}),
2478
+ status: "failed",
2479
+ backgroundTaskCount: 0,
2480
+ note: reason,
2481
+ }, { policy: "best-effort", lane: "claude-forwarder-failure" })).catch((error) => {
2482
+ console.error("[claude-forwarder] failed to publish poison-item status:", error);
2483
+ });
2484
+ };
2485
+ const emitCurrent = (event, delivery) => {
2486
+ if (pendingRotationEvents) {
2487
+ pendingRotationEvents.push({ event, ...(delivery ? { delivery } : {}) });
2488
+ return;
2489
+ }
2490
+ const source = transcriptDelivery;
2491
+ if (source && isDurableSessionEvent(event)) {
2492
+ const sourceId = `${source.sourceBase}:${source.nextItemIndex}:${event.item.type}`;
2493
+ source.nextItemIndex += 1;
2494
+ source.sourceIds.push(sourceId);
2495
+ if (source.isHandled(sourceId))
2496
+ return;
2497
+ const policy = delivery?.policy ?? "ordinary";
2498
+ const pending = Promise.resolve(emit(event, {
2499
+ policy,
2500
+ sourceId,
2501
+ lane: delivery?.lane ?? source.lane,
2502
+ ...(policy === "ordinary"
2503
+ ? { deadLetterPath: delivery?.deadLetterPath ?? source.deadLetterPath }
2504
+ : {}),
2505
+ onOutcome: (outcome) => {
2506
+ delivery?.onOutcome?.(outcome);
2507
+ if (policy === "ordinary" &&
2508
+ outcome === "dropped" &&
2509
+ !source.lane.startsWith("claude-subagent:")) {
2510
+ publishForwarderFailure(`transcript item ${sourceId} rejected`, event.item.responseId);
2511
+ }
2512
+ },
2513
+ })).then(() => {
2514
+ source.onHandled?.(sourceId);
2515
+ });
2516
+ source.pending.push(pending);
2517
+ return pending;
2518
+ }
2519
+ return emit(event, delivery ?? { policy: "best-effort" });
2366
2520
  };
2367
2521
  const startNormalizer = (turnId) => {
2368
2522
  // turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
@@ -2377,6 +2531,45 @@ export class LocalAgentHost {
2377
2531
  });
2378
2532
  return normalizer;
2379
2533
  };
2534
+ const rememberSubagentParentResponse = (parentToolCallId, responseId) => {
2535
+ if (subagentParentResponseIds.has(parentToolCallId))
2536
+ return;
2537
+ subagentParentResponseIds.set(parentToolCallId, responseId);
2538
+ while (subagentParentResponseIds.size > 2_000) {
2539
+ const oldest = subagentParentResponseIds.keys().next().value;
2540
+ if (!oldest)
2541
+ break;
2542
+ subagentParentResponseIds.delete(oldest);
2543
+ lateSubagentNormalizers.delete(oldest);
2544
+ }
2545
+ };
2546
+ const subagentNormalizer = (parentToolCallId) => {
2547
+ let value = lateSubagentNormalizers.get(parentToolCallId);
2548
+ if (!value) {
2549
+ const responseId = subagentParentResponseIds.get(parentToolCallId) ??
2550
+ `resp_claude_parent_${createHash("sha256")
2551
+ .update(parentToolCallId)
2552
+ .digest("hex")
2553
+ .slice(0, 20)}`;
2554
+ value = new SessionNormalizer({
2555
+ sessionId: currentSessionId,
2556
+ responseId,
2557
+ model: model || "claude",
2558
+ });
2559
+ lateSubagentNormalizers.set(parentToolCallId, value);
2560
+ }
2561
+ return value;
2562
+ };
2563
+ const emitSubagentEvents = (events) => {
2564
+ for (const event of events) {
2565
+ // Rynx keeps native child items nested in the parent Session, but its
2566
+ // child normalizer remains
2567
+ // lifecycle-isolated and must never open/reopen a parent Response.
2568
+ if ((event.type === "response.created" || event.type === "session.status"))
2569
+ continue;
2570
+ emitCurrent(event);
2571
+ }
2572
+ };
2380
2573
  const forwardInteraction = (event) => {
2381
2574
  const n = normalizer ?? startNormalizer(event.turnId);
2382
2575
  const agentEvent = event.type === "requested"
@@ -2392,8 +2585,20 @@ export class LocalAgentHost {
2392
2585
  interactionId: event.interactionId,
2393
2586
  ...(event.reason ? { reason: event.reason } : {}),
2394
2587
  };
2395
- for (const se of n.next(agentEvent))
2396
- emitCurrent(se);
2588
+ // Permission/question elicitation stays on a separate live hook
2589
+ // side-channel rather than the durable transcript forwarder. Rynx stores
2590
+ // a delivered card for UI replay, but intentionally does not make this
2591
+ // bridge spool advance or block the transcript cursor.
2592
+ for (const se of n.next(agentEvent)) {
2593
+ try {
2594
+ void Promise.resolve(emitRotationAware(se, { policy: "best-effort", lane: "claude-interaction" })).catch((error) => {
2595
+ console.error("[claude-forwarder] interaction mirror failed:", error);
2596
+ });
2597
+ }
2598
+ catch (error) {
2599
+ console.error("[claude-forwarder] interaction mirror failed:", error);
2600
+ }
2601
+ }
2397
2602
  };
2398
2603
  let markReady;
2399
2604
  const ready = new Promise((resolve) => {
@@ -2408,7 +2613,10 @@ export class LocalAgentHost {
2408
2613
  workspace,
2409
2614
  execution,
2410
2615
  bridgeDir,
2616
+ runtimeHomeOwnerSessionId,
2617
+ bridgeOwnerSessionId,
2411
2618
  injectLock: Promise.resolve(),
2619
+ rotationPending: false,
2412
2620
  pendingImageInputs: new Map(),
2413
2621
  pendingInjectedInputs: [],
2414
2622
  currentResponseId: () => currentResponseId,
@@ -2422,6 +2630,8 @@ export class LocalAgentHost {
2422
2630
  launchExtraArgs,
2423
2631
  permissionMode,
2424
2632
  ...(forkIntent ? { forkIntent } : {}),
2633
+ ...(forkResumePrefixBytes !== undefined ? { forkResumePrefixBytes } : {}),
2634
+ ...(forkTranscriptPrepared ? { forkTranscriptPrepared: true } : {}),
2425
2635
  };
2426
2636
  live.publishInterrupted = (responseId) => {
2427
2637
  if (live.interruptedResponseId === responseId)
@@ -2433,27 +2643,34 @@ export class LocalAgentHost {
2433
2643
  responseId,
2434
2644
  });
2435
2645
  };
2436
- const settleClaudeTurn = (interrupted, usage, backgroundTaskCount) => {
2646
+ const settleClaudeTurn = (interrupted, usage, backgroundTaskCount, reason) => {
2437
2647
  if (!normalizer)
2438
2648
  return;
2649
+ const settlementDelivery = reason === "rotation"
2650
+ ? { policy: "best-effort", lane: "control" }
2651
+ : undefined;
2652
+ const publishSettlement = (event) => {
2653
+ void emitCurrent(event, settlementDelivery);
2654
+ };
2439
2655
  const rid = currentResponseId;
2440
2656
  if (interrupted) {
2441
2657
  for (const se of normalizer.interrupt()) {
2442
2658
  if (se.type === "session.interrupted" &&
2443
2659
  live.interruptedResponseId === rid)
2444
2660
  continue;
2445
- emitCurrent(se);
2661
+ publishSettlement(se);
2446
2662
  }
2447
2663
  live.interruptedResponseId = undefined;
2448
2664
  }
2449
2665
  else {
2450
2666
  // statusLine usage (context/cost) rides the turn's response.completed.
2451
2667
  if (usage) {
2452
- for (const se of normalizer.next({ type: "turn_completed", usage }))
2453
- emitCurrent(se);
2668
+ for (const se of normalizer.next({ type: "turn_completed", usage })) {
2669
+ publishSettlement(se);
2670
+ }
2454
2671
  }
2455
2672
  for (const se of normalizer.next({ type: "done" })) {
2456
- emitCurrent(se.type === "session.status" && backgroundTaskCount !== undefined
2673
+ publishSettlement(se.type === "session.status" && backgroundTaskCount !== undefined
2457
2674
  ? { ...se, backgroundTaskCount }
2458
2675
  : se);
2459
2676
  }
@@ -2466,7 +2683,7 @@ export class LocalAgentHost {
2466
2683
  const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
2467
2684
  if (!interrupted && rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
2468
2685
  live.contextWarned = true;
2469
- emitCurrent({
2686
+ publishSettlement({
2470
2687
  type: "session.status",
2471
2688
  sessionId: currentSessionId,
2472
2689
  responseId: rid,
@@ -2476,6 +2693,30 @@ export class LocalAgentHost {
2476
2693
  }
2477
2694
  };
2478
2695
  const sink = {
2696
+ onTranscriptRecordStart: (sourceBase, isHandled, delivery) => {
2697
+ if (transcriptDelivery) {
2698
+ throw new Error("Claude transcript delivery record overlapped");
2699
+ }
2700
+ transcriptDelivery = {
2701
+ sourceBase,
2702
+ nextItemIndex: 0,
2703
+ isHandled,
2704
+ sourceIds: [],
2705
+ pending: [],
2706
+ lane: delivery?.lane ?? "claude-main",
2707
+ deadLetterPath: delivery?.deadLetterPath ??
2708
+ path.join(bridgeDir, "dead_letter.jsonl"),
2709
+ ...(delivery?.onHandled ? { onHandled: delivery.onHandled } : {}),
2710
+ };
2711
+ },
2712
+ onTranscriptRecordEnd: async () => {
2713
+ const source = transcriptDelivery;
2714
+ if (!source)
2715
+ return [];
2716
+ transcriptDelivery = null;
2717
+ await Promise.all(source.pending);
2718
+ return source.sourceIds;
2719
+ },
2479
2720
  onTurnStart: (turnId) => startNormalizer(turnId),
2480
2721
  onUserMessage: (text) => {
2481
2722
  const n = normalizer ?? startNormalizer();
@@ -2495,14 +2736,112 @@ export class LocalAgentHost {
2495
2736
  for (const se of n.userInput(normalizedContent))
2496
2737
  emitCurrent(se);
2497
2738
  },
2739
+ onMetaUserMessage: (text, sourceKey, parentToolCallId) => {
2740
+ const responseId = parentToolCallId
2741
+ ? subagentParentResponseIds.get(parentToolCallId) ??
2742
+ `resp_claude_parent_${createHash("sha256")
2743
+ .update(parentToolCallId)
2744
+ .digest("hex")
2745
+ .slice(0, 20)}`
2746
+ : `resp_claude_meta_${createHash("sha256")
2747
+ .update(sourceKey ?? text)
2748
+ .digest("hex")
2749
+ .slice(0, 20)}`;
2750
+ const metaNormalizer = new SessionNormalizer({
2751
+ sessionId: currentSessionId,
2752
+ responseId,
2753
+ model: model || "claude",
2754
+ });
2755
+ for (const event of metaNormalizer.userInput(text, parentToolCallId)) {
2756
+ if (event.type !== "session.input.consumed" || event.item.type !== "message")
2757
+ continue;
2758
+ emitCurrent({
2759
+ ...event,
2760
+ item: {
2761
+ ...event.item,
2762
+ data: { ...event.item.data, isMeta: true },
2763
+ },
2764
+ });
2765
+ }
2766
+ },
2767
+ onSubagentUserMessage: (text, parentToolCallId) => {
2768
+ const nested = subagentNormalizer(parentToolCallId);
2769
+ emitSubagentEvents(nested.userInput(text, parentToolCallId));
2770
+ },
2771
+ onSubagentTerminalCommand: (command, parentToolCallId) => {
2772
+ const nested = subagentNormalizer(parentToolCallId);
2773
+ emitSubagentEvents(nested.terminalCommand(command, parentToolCallId));
2774
+ },
2775
+ resolveSubagentParentResponseId: (parentToolCallId) => subagentParentResponseIds.get(parentToolCallId),
2776
+ onSubagentParentResponse: (parentToolCallId, responseId) => {
2777
+ rememberSubagentParentResponse(parentToolCallId, responseId);
2778
+ },
2498
2779
  onTerminalCommand: (cmd) => {
2499
2780
  const n = normalizer ?? startNormalizer();
2500
2781
  for (const se of n.terminalCommand(cmd))
2501
2782
  emitCurrent(se);
2502
2783
  },
2503
2784
  onTodos: (todos) => emitCurrent({ type: "session.todos", sessionId: currentSessionId, todos }),
2785
+ onCompactionStatus: (status) => {
2786
+ void emitCurrent({
2787
+ type: `response.compaction.${status}`,
2788
+ sessionId: currentSessionId,
2789
+ ...(currentResponseId ? { responseId: currentResponseId } : {}),
2790
+ }, { policy: "best-effort", lane: "claude-compaction" });
2791
+ },
2792
+ onCompactionBoundary: (summary, sequence, source) => {
2793
+ let outcome;
2794
+ const responseId = currentResponseId ?? "resp_claude_native";
2795
+ const delivery = emitCurrent({
2796
+ type: "response.output_item.done",
2797
+ responseId,
2798
+ item: {
2799
+ id: newSessionItemId("compaction"),
2800
+ sessionId: currentSessionId,
2801
+ position: 0,
2802
+ responseId,
2803
+ status: "completed",
2804
+ createdAt: Date.now(),
2805
+ type: "compaction",
2806
+ data: { summary, sequence },
2807
+ },
2808
+ }, {
2809
+ policy: source === "transcript" ? "compaction" : "compaction-hook",
2810
+ // A transcript compaction is still one ordered transcript record.
2811
+ // Only the independent hook boundary gets its own delivery lane.
2812
+ lane: source === "transcript" ? "claude-main" : "claude-compaction",
2813
+ onOutcome: (value) => { outcome = value; },
2814
+ });
2815
+ return Promise.resolve(delivery).then(() => outcome === undefined || outcome === "confirmed" || outcome === "ambiguous");
2816
+ },
2504
2817
  onEvent: (event) => {
2818
+ const parentToolCallId = "parentToolUseId" in event &&
2819
+ typeof event.parentToolUseId === "string"
2820
+ ? event.parentToolUseId
2821
+ : undefined;
2822
+ if (parentToolCallId) {
2823
+ const nested = subagentNormalizer(parentToolCallId);
2824
+ emitSubagentEvents(nested.next(event));
2825
+ return;
2826
+ }
2505
2827
  const n = normalizer ?? startNormalizer();
2828
+ if (currentResponseId &&
2829
+ event.type === "tool" &&
2830
+ event.event === "on_tool_start") {
2831
+ const input = typeof event.input === "object" && event.input !== null
2832
+ ? event.input
2833
+ : {};
2834
+ const data = typeof event.data === "object" && event.data !== null
2835
+ ? event.data
2836
+ : {};
2837
+ const parentId = typeof input.id === "string"
2838
+ ? input.id
2839
+ : typeof data.id === "string"
2840
+ ? data.id
2841
+ : undefined;
2842
+ if (parentId)
2843
+ rememberSubagentParentResponse(parentId, currentResponseId);
2844
+ }
2506
2845
  for (const se of n.next(event))
2507
2846
  emitCurrent(se);
2508
2847
  },
@@ -2518,114 +2857,229 @@ export class LocalAgentHost {
2518
2857
  ...(blockedOn ? { note: blockedOn } : {}),
2519
2858
  });
2520
2859
  },
2521
- onTurnEnd: (usage, backgroundTaskCount) => settleClaudeTurn(false, usage, backgroundTaskCount),
2860
+ onTurnEnd: (usage, backgroundTaskCount, reason) => settleClaudeTurn(false, usage, backgroundTaskCount, reason),
2522
2861
  onTurnInterrupted: (usage) => settleClaudeTurn(true, usage),
2523
2862
  onTurnInterruptRequested: () => {
2524
2863
  const responseId = currentResponseId;
2525
2864
  if (responseId)
2526
2865
  live.publishInterrupted(responseId);
2527
2866
  },
2528
- onIdle: (backgroundTaskCount) => {
2867
+ onIdle: (backgroundTaskCount, sourceId) => {
2529
2868
  // Surface Stop even after its Turn has already closed: a later
2530
2869
  // authoritative zero is what clears a sticky background-shell tally.
2531
2870
  // When the Turn is still open, retain its identity without finalizing it
2532
2871
  // so a late assistant record continues to join the same Response.
2533
- emitCurrent({
2872
+ return emitCurrent({
2534
2873
  type: "session.status",
2535
2874
  sessionId: currentSessionId,
2536
2875
  ...(currentResponseId ? { responseId: currentResponseId } : {}),
2537
2876
  status: "idle",
2538
2877
  ...(backgroundTaskCount === undefined ? {} : { backgroundTaskCount }),
2878
+ }, {
2879
+ policy: "terminal-status",
2880
+ lane: "claude-status",
2881
+ ...(sourceId ? { sourceId } : {}),
2882
+ onOutcome: (outcome) => {
2883
+ if (outcome === "dropped") {
2884
+ publishForwarderFailure(`hook status idle rejected`, currentResponseId);
2885
+ }
2886
+ },
2539
2887
  });
2540
2888
  },
2541
- onTurnError: (error) => {
2889
+ onTurnError: (error, sourceId) => {
2542
2890
  const message = error.message || "Agent turn failed";
2543
2891
  if (!normalizer) {
2544
2892
  // Native pane/forwarder death is Session-level even when Claude is
2545
2893
  // between Turns. Emit a bare failed edge; the
2546
2894
  // explicit zero also retires any sticky background-shell tally.
2547
- emitCurrent({
2895
+ return emitCurrent({
2548
2896
  type: "session.status",
2549
2897
  sessionId: currentSessionId,
2550
2898
  status: "failed",
2551
2899
  backgroundTaskCount: 0,
2552
2900
  note: message,
2901
+ }, {
2902
+ policy: "terminal-status",
2903
+ lane: "claude-status",
2904
+ ...(sourceId ? { sourceId } : {}),
2553
2905
  });
2554
- return;
2555
2906
  }
2556
2907
  const rid = currentResponseId;
2908
+ const deliveries = [];
2909
+ let itemIndex = 0;
2557
2910
  for (const se of normalizer.fail({
2558
2911
  code: "agent_error",
2559
2912
  message,
2560
2913
  source: "execution",
2561
2914
  })) {
2562
- emitCurrent(se.type === "session.status"
2915
+ const event = se.type === "session.status"
2563
2916
  ? { ...se, status: "failed", note: message }
2564
- : se);
2917
+ : se;
2918
+ const durable = isDurableSessionEvent(event);
2919
+ const eventSourceId = sourceId
2920
+ ? `${sourceId}:${itemIndex++}:${durable && "item" in event ? event.item.type : event.type}`
2921
+ : undefined;
2922
+ const delivered = emitCurrent(event, {
2923
+ policy: durable ? "ordinary" : "terminal-status",
2924
+ lane: "claude-status",
2925
+ ...(eventSourceId ? { sourceId: eventSourceId } : {}),
2926
+ ...(durable
2927
+ ? { deadLetterPath: path.join(bridgeDir, "dead_letter.jsonl") }
2928
+ : {}),
2929
+ });
2930
+ if (delivered)
2931
+ deliveries.push(Promise.resolve(delivered));
2565
2932
  }
2566
2933
  live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
2567
2934
  normalizer = null;
2568
2935
  currentResponseId = undefined;
2936
+ return Promise.all(deliveries).then(() => undefined);
2569
2937
  },
2570
2938
  onSessionDiscovered: (claudeSessionId) => this.onClaudeDiscovered(live, localThreadId, claudeSessionId),
2571
2939
  onSessionResumeError: (error) => {
2572
2940
  live.error = error.message;
2573
2941
  live.markFailed();
2574
2942
  },
2575
- onSessionRotated: (kind, claudeSessionId) => {
2576
- if (pendingRotationEvents) {
2577
- live.error = "Claude reported another Session rotation before publication completed";
2578
- live.markFailed();
2579
- return;
2943
+ onSessionRotated: (kind, claudeSessionId, _transcriptPath, initialTranscriptOffset) => {
2944
+ let publication = rotationPublication?.claudeSessionId === claudeSessionId &&
2945
+ rotationPublication.kind === kind
2946
+ ? rotationPublication
2947
+ : null;
2948
+ if (publication?.operation)
2949
+ return publication.operation;
2950
+ if (publication?.completed)
2951
+ return Promise.resolve();
2952
+ if (!publication && pendingRotationEvents) {
2953
+ return Promise.reject(new Error("Claude reported another Session rotation before publication completed"));
2580
2954
  }
2581
- // The forwarder already closed the old turn (onTurnEnd) + re-pointed to the
2582
- // new transcript. Persist the Provider binding first, then announce on
2583
- // the OLD session and re-target the mirror. Events produced during the
2584
- // file-store commit are buffered so none can land under the old identity.
2585
- const previousSessionId = currentSessionId;
2586
- const newSessionId = makeSessionId();
2587
- currentSessionId = newSessionId;
2588
- normalizer = null;
2589
- currentResponseId = undefined;
2590
- live.contextWarned = false;
2591
- pendingRotationEvents = [];
2592
- this.liveClaudeSessions.set(newSessionId, live);
2593
- this.liveClaudeSessions.delete(previousSessionId);
2594
- void this.sessionStore
2595
- .set({
2596
- localThreadId: newSessionId,
2597
- codexSessionId: claudeSessionId,
2598
- ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
2599
- updatedAt: new Date().toISOString(),
2600
- })
2601
- .then(() => {
2602
- emit({
2603
- type: "session.rotated",
2604
- sessionId: previousSessionId,
2605
- newSessionId,
2955
+ // Persist the Provider binding, then wait for the runner manager to
2956
+ // publish and ACK the new alias. The parent announces the rotation on
2957
+ // the OLD Session only after that target publication succeeds.
2958
+ // The forwarder does not consume the SessionStart hook or switch source
2959
+ // state until this operation resolves.
2960
+ if (!publication) {
2961
+ // Claude has already crossed its physical clear/fork boundary by the
2962
+ // time this hook arrives. Fence injection synchronously, before the
2963
+ // first asynchronous target-store operation or IPC publication.
2964
+ live.rotationPending = true;
2965
+ publication = {
2966
+ claudeSessionId,
2606
2967
  kind,
2968
+ initialTranscriptOffset,
2969
+ previousSessionId: currentSessionId,
2970
+ newSessionId: makeSessionId(),
2971
+ };
2972
+ rotationPublication = publication;
2973
+ }
2974
+ const { previousSessionId } = publication;
2975
+ // An exact publication retry keeps events buffered during the failed
2976
+ // attempt; they already belong to this same post-rotation target.
2977
+ pendingRotationEvents ??= [];
2978
+ const operation = this.sessionStore
2979
+ .findByCodexSessionId(claudeSessionId)
2980
+ .then(async (existingTarget) => {
2981
+ if (existingTarget &&
2982
+ existingTarget.localThreadId !== previousSessionId) {
2983
+ publication.newSessionId = existingTarget.localThreadId;
2984
+ }
2985
+ if (existingTarget?.nativeRotationInitialTranscriptOffset !== undefined) {
2986
+ publication.initialTranscriptOffset =
2987
+ existingTarget.nativeRotationInitialTranscriptOffset;
2988
+ }
2989
+ await this.sessionStore.set({
2990
+ localThreadId: publication.newSessionId,
2991
+ codexSessionId: claudeSessionId,
2992
+ ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
2993
+ runtimeHomeOwnerSessionId: live.runtimeHomeOwnerSessionId,
2994
+ bridgeOwnerSessionId: live.bridgeOwnerSessionId,
2995
+ nativeRotationSourceSessionId: previousSessionId,
2996
+ nativeRotationKind: kind,
2997
+ nativeRotationInitialTranscriptOffset: publication.initialTranscriptOffset,
2998
+ ...(existingTarget?.nativeRotationPublished
2999
+ ? { nativeRotationPublished: true }
3000
+ : {}),
3001
+ updatedAt: new Date().toISOString(),
2607
3002
  });
2608
- retargetMirror?.(newSessionId, {
3003
+ })
3004
+ .then(async () => {
3005
+ const applyHostTarget = async () => {
3006
+ if (publication.hostApplied)
3007
+ return;
3008
+ const newSessionId = publication.newSessionId;
3009
+ const targetBinding = await this.sessionStore.get(newSessionId);
3010
+ if (targetBinding && !targetBinding.nativeRotationPublished) {
3011
+ await this.sessionStore.set({
3012
+ ...targetBinding,
3013
+ nativeRotationPublished: true,
3014
+ updatedAt: new Date().toISOString(),
3015
+ });
3016
+ }
3017
+ const sourceBinding = await this.sessionStore.get(previousSessionId);
3018
+ const retiredBridgeOwner = `${previousSessionId}-${kind}-retired-${newSessionId}`;
3019
+ if (sourceBinding &&
3020
+ sourceBinding.bridgeOwnerSessionId !== retiredBridgeOwner) {
3021
+ await this.sessionStore.set({
3022
+ ...sourceBinding,
3023
+ bridgeOwnerSessionId: retiredBridgeOwner,
3024
+ nativeRotationTargetSessionId: newSessionId,
3025
+ updatedAt: new Date().toISOString(),
3026
+ });
3027
+ }
3028
+ currentSessionId = newSessionId;
3029
+ normalizer = null;
3030
+ currentResponseId = undefined;
3031
+ live.contextWarned = false;
3032
+ this.liveClaudeSessions.set(newSessionId, live);
3033
+ this.liveClaudeSessions.delete(previousSessionId);
3034
+ const queued = pendingRotationEvents ?? [];
3035
+ pendingRotationEvents = null;
3036
+ live.rotationPending = false;
3037
+ publication.hostApplied = true;
3038
+ for (const entry of queued) {
3039
+ emit(retargetSessionEvent(entry.event, newSessionId), entry.delivery);
3040
+ }
3041
+ };
3042
+ await Promise.resolve(retargetMirror?.(publication.newSessionId, {
2609
3043
  kind,
2610
3044
  workspace: structuredClone(live.workspace),
2611
3045
  execution: structuredClone(live.execution),
2612
3046
  ...(kind === "fork" ? { parentSessionId: previousSessionId } : {}),
2613
- });
2614
- const queued = pendingRotationEvents ?? [];
2615
- pendingRotationEvents = null;
2616
- for (const event of queued)
2617
- emit(event);
3047
+ beforeApplied: applyHostTarget,
3048
+ }));
3049
+ if (!publication.hostApplied) {
3050
+ throw new Error("Claude retarget completed without applying the host target");
3051
+ }
3052
+ publication.completed = true;
2618
3053
  })
2619
3054
  .catch((error) => {
2620
- pendingRotationEvents = null;
3055
+ // Keep the fence and buffered events across an exact publication
3056
+ // retry. Re-opening either here would leak post-clear work back to
3057
+ // the retired logical Session.
2621
3058
  live.error = error instanceof Error ? error.message : String(error);
2622
3059
  live.markFailed();
3060
+ if (typeof error === "object" &&
3061
+ error !== null &&
3062
+ "classification" in error &&
3063
+ error.classification === "permanent") {
3064
+ // A deterministic target-publication rejection cannot recover by
3065
+ // polling the same hook forever. Keep injection fenced and stop
3066
+ // the forwarder so restart/operator action is explicit.
3067
+ queueMicrotask(() => live.forwarder.stop());
3068
+ }
3069
+ throw error;
3070
+ })
3071
+ .finally(() => {
3072
+ if (rotationPublication === publication) {
3073
+ delete publication.operation;
3074
+ }
2623
3075
  });
3076
+ publication.operation = operation;
3077
+ return operation;
2624
3078
  },
2625
3079
  };
2626
- // Claude itself resolves `--resume <id>`. The SessionStart hook is the
2627
- // authoritative source for the actual transcript path; do not infer or
2628
- // generate Claude's private on-disk format here.
3080
+ // Claude itself resolves `--resume <id>`. A managed fork pre-writes and
3081
+ // measures its target prefix, but SessionStart remains authoritative for
3082
+ // the transcript path the forwarder actually tails.
2629
3083
  live.forwarder = new ClaudeLiveSession({
2630
3084
  bridgeDir,
2631
3085
  sink,
@@ -2634,7 +3088,16 @@ export class LocalAgentHost {
2634
3088
  : record?.codexSessionId
2635
3089
  ? { claudeSessionId: record.codexSessionId }
2636
3090
  : {}),
2637
- ...((forkIntent || record?.codexSessionId) ? { resumeAtEndOnDiscovery: true } : {}),
3091
+ ...((record?.codexSessionId || forkTranscriptPrepared)
3092
+ ? { resumeAtEndOnDiscovery: true }
3093
+ : {}),
3094
+ ...(forkResumePrefixBytes !== undefined
3095
+ ? { initialTranscriptOffsetOnDiscovery: forkResumePrefixBytes }
3096
+ : record?.nativeRotationInitialTranscriptOffset !== undefined
3097
+ ? {
3098
+ initialTranscriptOffsetOnDiscovery: record.nativeRotationInitialTranscriptOffset,
3099
+ }
3100
+ : {}),
2638
3101
  });
2639
3102
  this.liveClaudeSessions.set(localThreadId, live);
2640
3103
  live.forwarder.start();
@@ -2654,12 +3117,16 @@ export class LocalAgentHost {
2654
3117
  }
2655
3118
  live.discoveredReady = true;
2656
3119
  void this.sessionStore
2657
- .set({
3120
+ .get(localThreadId)
3121
+ .then((existing) => this.sessionStore.set({
3122
+ ...existing,
2658
3123
  localThreadId,
2659
3124
  codexSessionId: claudeSessionId,
2660
3125
  ...(forkIntent ? { parentSessionId: forkIntent.sourceSessionId } : {}),
3126
+ runtimeHomeOwnerSessionId: live.runtimeHomeOwnerSessionId,
3127
+ bridgeOwnerSessionId: live.bridgeOwnerSessionId,
2661
3128
  updatedAt: new Date().toISOString(),
2662
- })
3129
+ }))
2663
3130
  .then(() => this.sessionStore.deleteClaudeForkIntent?.(localThreadId))
2664
3131
  .then(() => live.markReady())
2665
3132
  .catch((error) => {
@@ -2673,15 +3140,24 @@ export class LocalAgentHost {
2673
3140
  * appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
2674
3141
  * fall-through-to-run signal. Returns {@link InjectResult}. */
2675
3142
  injectClaude(live, localThreadId, text, pendingInput) {
3143
+ if (live.rotationPending || live.stopped) {
3144
+ return Promise.resolve({ outcome: "notReady" });
3145
+ }
2676
3146
  const run = live.injectLock.then(async () => {
3147
+ if (live.rotationPending || live.stopped)
3148
+ return { outcome: "notReady" };
2677
3149
  const ready = await this.waitLiveReady(localThreadId, 60_000);
2678
3150
  if (!ready)
2679
3151
  return { outcome: "notReady" };
3152
+ if (live.rotationPending || live.stopped)
3153
+ return { outcome: "notReady" };
2680
3154
  // Pane may have just relaunched — park until its injector re-attaches
2681
3155
  // (attachTerminalInjector resets it) instead of returning false → fallback.
2682
3156
  const injector = await this.waitInjector(live, 60_000);
2683
3157
  if (!injector)
2684
3158
  return { outcome: "notReady" };
3159
+ if (live.rotationPending || live.stopped)
3160
+ return { outcome: "notReady" };
2685
3161
  const steered = live.forwarder.isTurnOpen();
2686
3162
  // Abortable: the web Stop button cancels an in-flight paste/submit (before
2687
3163
  // the message reaches claude) via interruptLive → injectAbort.abort().
@@ -2907,6 +3383,9 @@ export class LocalAgentHost {
2907
3383
  codexSessionId: forkedThreadId,
2908
3384
  parentSessionId: currentLocalThreadId,
2909
3385
  runtimeHomeOwnerSessionId: sourceHomeOwner,
3386
+ ...(record.bridgeOwnerSessionId
3387
+ ? { bridgeOwnerSessionId: record.bridgeOwnerSessionId }
3388
+ : {}),
2910
3389
  updatedAt: new Date().toISOString(),
2911
3390
  });
2912
3391
  return { ok: true, data: undefined };