@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/claude/native-bridge.d.ts +85 -0
- package/dist/claude/native-bridge.js +335 -17
- package/dist/claude/native-hook-main.js +18 -1
- package/dist/claude/native-hooks.js +7 -0
- package/dist/claude/native-integration.d.ts +120 -18
- package/dist/claude/native-integration.js +1200 -161
- package/dist/claude/transcript-clone.d.ts +18 -0
- package/dist/claude/transcript-clone.js +497 -0
- package/dist/claude/transcript.d.ts +27 -4
- package/dist/claude/transcript.js +131 -30
- package/dist/codex-session-store.d.ts +23 -0
- package/dist/codex-session-store.js +21 -0
- package/dist/host.d.ts +31 -2
- package/dist/host.js +551 -72
- package/dist/runner/child.d.ts +29 -5
- package/dist/runner/child.js +635 -54
- package/dist/runner/manager.d.ts +25 -0
- package/dist/runner/manager.js +805 -115
- package/dist/runner/protocol.d.ts +76 -3
- package/dist/runner/transport.d.ts +9 -0
- package/dist/runner/transport.js +39 -12
- package/package.json +2 -2
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 {
|
|
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:
|
|
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(
|
|
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.
|
|
2197
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2396
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
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
|
-
//
|
|
2582
|
-
//
|
|
2583
|
-
// the OLD
|
|
2584
|
-
//
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
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
|
-
|
|
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>`.
|
|
2627
|
-
//
|
|
2628
|
-
//
|
|
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
|
-
...((
|
|
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
|
-
.
|
|
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 };
|