@rynx-ai/runtime 0.1.11-beta.22 → 0.1.11-beta.24

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
@@ -9,7 +9,7 @@ import { getRuntimeProfile, } from "@rynx-ai/core";
9
9
  import { resolveRuntimeBinary, } from "@rynx-ai/core";
10
10
  import { createCodexChildEnv } from "./codex-child-env.js";
11
11
  import { prepareRuntimeHome, populateCodexSkills, runtimeHomePath, } from "./codex-home.js";
12
- import { materializeSkillPlugin } from "./claude/executor.js";
12
+ import { materializeClaudePlugin, reuseClaudePlugin, } from "./claude/executor.js";
13
13
  import { listClaudeModels } from "./claude/models.js";
14
14
  import { CodexAppServerClient, buildRuntimeUserInput, } from "./codex-app-server/client.js";
15
15
  import { buildAppServerBaseArgs, CodexTransportError, } from "./codex-app-server/transport.js";
@@ -529,16 +529,21 @@ export class LocalAgentHost {
529
529
  return sameSessionSnapshots(existingClaude.workspace, existingClaude.execution, snapshotOpts);
530
530
  }
531
531
  // Dedupe concurrent triggers so only one forwarder is ever created per
532
- // session (two would double-mirror every turn). Check this before the live
533
- // map: startLiveCodexSession publishes its partially initialized session
534
- // before thread/start completes, so a concurrent Terminal start must await
535
- // the thread id instead of launching a bare `codex --remote` pane.
532
+ // session (two would double-mirror every turn). startLiveCodexSession
533
+ // publishes its partially initialized session before thread/start completes,
534
+ // so wait for that startup and then adopt any newer mutable Turn settings.
536
535
  const inflight = this.liveEnsuring.get(localThreadId);
537
- if (inflight)
538
- return inflight;
536
+ if (inflight) {
537
+ if (!await inflight)
538
+ return false;
539
+ const startedLive = this.liveSessions.get(localThreadId);
540
+ return startedLive
541
+ ? updateLiveCodexTurnSettings(startedLive, snapshotOpts)
542
+ : false;
543
+ }
539
544
  const live = this.liveSessions.get(localThreadId);
540
545
  if (live)
541
- return sameSessionSnapshots(live.workspace, live.execution, snapshotOpts);
546
+ return updateLiveCodexTurnSettings(live, snapshotOpts);
542
547
  const started = this.startLiveCodexSession(localThreadId, emit, snapshotOpts);
543
548
  this.liveEnsuring.set(localThreadId, started);
544
549
  try {
@@ -670,6 +675,8 @@ export class LocalAgentHost {
670
675
  approvalPolicy,
671
676
  model,
672
677
  reasoningEffort,
678
+ appliedModel: model,
679
+ appliedReasoningEffort: reasoningEffort,
673
680
  ...(execution.instructions ? { instructions: execution.instructions } : {}),
674
681
  threadId: record?.codexSessionId ?? null,
675
682
  ready,
@@ -681,6 +688,10 @@ export class LocalAgentHost {
681
688
  injectLock: Promise.resolve(),
682
689
  pendingInjectedInputs: [],
683
690
  publishInjectedInput: () => undefined,
691
+ publishTurnAdmission: () => undefined,
692
+ publishTurnAdmissionFailure: () => undefined,
693
+ publishInterrupted: () => undefined,
694
+ interruptedResponseId: null,
684
695
  subscribing: false,
685
696
  rotationPending: false,
686
697
  stopped: false,
@@ -698,11 +709,20 @@ export class LocalAgentHost {
698
709
  }
699
710
  emit(event);
700
711
  };
712
+ live.publishInterrupted = (responseId) => {
713
+ if (live.interruptedResponseId === responseId)
714
+ return;
715
+ live.interruptedResponseId = responseId;
716
+ emitCurrent({
717
+ type: "session.interrupted",
718
+ sessionId: currentSessionId,
719
+ responseId,
720
+ });
721
+ };
701
722
  let normalizer = null;
702
723
  let currentResponseId = null;
703
724
  const startNormalizer = (turnId) => {
704
- const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
705
- (turnId ? `resp_codex_${turnId}` : "resp_codex_native");
725
+ const responseId = turnId ? `resp_codex_${turnId}` : "resp_codex_native";
706
726
  if (normalizer && currentResponseId === responseId)
707
727
  return normalizer;
708
728
  currentResponseId = responseId;
@@ -726,6 +746,21 @@ export class LocalAgentHost {
726
746
  for (const se of n.userInput(content))
727
747
  emitCurrent(se);
728
748
  };
749
+ live.publishTurnAdmission = () => {
750
+ emitCurrent({
751
+ type: "session.status",
752
+ sessionId: currentSessionId,
753
+ status: "running",
754
+ });
755
+ };
756
+ live.publishTurnAdmissionFailure = (message) => {
757
+ emitCurrent({
758
+ type: "session.status",
759
+ sessionId: currentSessionId,
760
+ status: "failed",
761
+ note: message,
762
+ });
763
+ };
729
764
  const clearPendingInputsForResponse = (responseId) => {
730
765
  if (!responseId)
731
766
  return;
@@ -842,6 +877,22 @@ export class LocalAgentHost {
842
877
  normalizer = null;
843
878
  currentResponseId = null;
844
879
  };
880
+ const interruptCurrentTurn = () => {
881
+ closeCanonicalInteractions();
882
+ if (!normalizer)
883
+ return;
884
+ const interruptedResponseId = currentResponseId;
885
+ for (const se of normalizer.interrupt()) {
886
+ if (se.type === "session.interrupted" &&
887
+ live.interruptedResponseId === interruptedResponseId)
888
+ continue;
889
+ emitCurrent(se);
890
+ }
891
+ live.interruptedResponseId = null;
892
+ clearPendingInputsForResponse(interruptedResponseId);
893
+ normalizer = null;
894
+ currentResponseId = null;
895
+ };
845
896
  const sink = {
846
897
  onTurnStart: (turnId) => startNormalizer(turnId),
847
898
  onTurnObserved: (turnId) => {
@@ -856,7 +907,7 @@ export class LocalAgentHost {
856
907
  : content;
857
908
  const signature = JSON.stringify(normalizedContent);
858
909
  const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
859
- if (pending?.state === "optimistic" || pending?.state === "prepublished") {
910
+ if (pending?.state === "optimistic") {
860
911
  live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
861
912
  return;
862
913
  }
@@ -887,6 +938,7 @@ export class LocalAgentHost {
887
938
  // response without publishing a false idle edge in a running→running
888
939
  // transition.
889
940
  onTurnEnd: completeCurrentTurn,
941
+ onTurnInterrupted: interruptCurrentTurn,
890
942
  onRecoveredTurnStatus: (status, turnId, error) => {
891
943
  const responseId = turnId ? `resp_codex_${turnId}` : undefined;
892
944
  if (normalizer && (!responseId || currentResponseId === responseId)) {
@@ -1132,12 +1184,7 @@ export class LocalAgentHost {
1132
1184
  return;
1133
1185
  live.startupError = error;
1134
1186
  this.liveStartupErrors.set(localThreadId, error);
1135
- live.forwarder.failOpenTurn(error);
1136
- const appServerOwner = live.appServerOwner;
1137
- this.stopLiveCodexSession(localThreadId);
1138
- void appServerOwner.stop().catch((stopError) => {
1139
- console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} app-server cleanup after observer failure failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
1140
- });
1187
+ this.teardownLiveCodexSession(localThreadId, error);
1141
1188
  }
1142
1189
  shouldIgnoreManagedForkThreadStarted(live, threadId, forkedFromId) {
1143
1190
  const pending = live.managedFork;
@@ -1292,7 +1339,7 @@ export class LocalAgentHost {
1292
1339
  * resumed from the persisted native id. Serialized per session so two
1293
1340
  * injects can't double-open a turn.
1294
1341
  *
1295
- * Returns an {@link InjectOutcome}: `notLive` when this session has no live
1342
+ * Returns an {@link InjectResult}: `notLive` when this session has no live
1296
1343
  * forwarder (caller may use the run path); `notReady`/`failed` are hard errors
1297
1344
  * the caller reports WITHOUT re-running (re-running double-writes alongside the
1298
1345
  * forwarder). Park-until-ready (~60s), aligning reference implementation's executor waiting for
@@ -1309,9 +1356,8 @@ export class LocalAgentHost {
1309
1356
  const pendingInput = {
1310
1357
  content,
1311
1358
  signature: JSON.stringify(content),
1312
- state: runtimeInput.responseId ? "prepublished" : "awaiting",
1359
+ state: "awaiting",
1313
1360
  observed: false,
1314
- ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1315
1361
  };
1316
1362
  claude.pendingInjectedInputs.push(pendingInput);
1317
1363
  const forgetPendingInput = () => {
@@ -1325,8 +1371,17 @@ export class LocalAgentHost {
1325
1371
  const expiry = setTimeout(() => claude.pendingImageInputs.delete(token), 5 * 60_000);
1326
1372
  expiry.unref?.();
1327
1373
  }
1328
- const outcome = await this.injectClaude(claude, localThreadId, text);
1329
- if (outcome !== "injected") {
1374
+ let result;
1375
+ try {
1376
+ result = await this.injectClaude(claude, localThreadId, text, pendingInput);
1377
+ }
1378
+ catch (error) {
1379
+ forgetPendingInput();
1380
+ if (token)
1381
+ claude.pendingImageInputs.delete(token);
1382
+ throw error;
1383
+ }
1384
+ if (result.outcome !== "injected" && result.outcome !== "steered") {
1330
1385
  forgetPendingInput();
1331
1386
  if (token)
1332
1387
  claude.pendingImageInputs.delete(token);
@@ -1334,31 +1389,37 @@ export class LocalAgentHost {
1334
1389
  else if (pendingInput.observed) {
1335
1390
  forgetPendingInput();
1336
1391
  }
1337
- return outcome;
1392
+ return result;
1338
1393
  }
1339
1394
  const live = this.liveSessions.get(localThreadId);
1340
1395
  if (!live)
1341
- return "notLive";
1396
+ return { outcome: "notLive" };
1342
1397
  const run = live.injectLock.then(async () => {
1343
1398
  if (live.rotationPending || live.stopped)
1344
- return "failed";
1399
+ return { outcome: "failed" };
1345
1400
  // Park until the thread binds (~60s, reference implementation codex_native_executor:177-186),
1346
1401
  // not a 20s race that returns false and lets the caller re-run on a 2nd path.
1347
1402
  const bound = await this.waitLiveReady(localThreadId, CODEX_BRIDGE_READY_TIMEOUT_MS);
1348
1403
  const threadId = live.threadId ?? live.forwarder.threadId();
1349
1404
  if (!bound || !threadId)
1350
- return "notReady";
1405
+ return { outcome: "notReady" };
1351
1406
  const nativeInput = buildRuntimeUserInput(runtimeInput);
1352
1407
  const content = runtimeUserContent(runtimeInput);
1353
1408
  const pendingInput = {
1354
1409
  content,
1355
1410
  signature: JSON.stringify(content),
1356
- state: runtimeInput.responseId ? "prepublished" : "awaiting",
1411
+ state: "awaiting",
1357
1412
  observed: false,
1358
- ...(runtimeInput.responseId ? { responseId: runtimeInput.responseId } : {}),
1359
1413
  };
1360
1414
  live.pendingInjectedInputs.push(pendingInput);
1361
1415
  let injectionMethod = "turn/start";
1416
+ let admissionPublished = false;
1417
+ const publishAdmission = () => {
1418
+ if (admissionPublished)
1419
+ return;
1420
+ admissionPublished = true;
1421
+ live.publishTurnAdmission();
1422
+ };
1362
1423
  const forgetPendingInput = () => {
1363
1424
  const index = live.pendingInjectedInputs.indexOf(pendingInput);
1364
1425
  if (index >= 0)
@@ -1366,6 +1427,12 @@ export class LocalAgentHost {
1366
1427
  };
1367
1428
  const injectionClient = this.injectionClientFactory(live.appServerUrl);
1368
1429
  try {
1430
+ // Omnigent publishes codex-native running only after the native Terminal
1431
+ // is ready and the runner has accepted the message, but before the
1432
+ // short-lived app-server client initializes and starts the Turn. Claude
1433
+ // deliberately has no matching synthesized edge.
1434
+ if (!live.forwarder.isTurnOpen())
1435
+ publishAdmission();
1369
1436
  await injectionClient.ensureInitialized();
1370
1437
  // Match Omnigent's executor: each message uses one initialized client
1371
1438
  // that closes as soon as turn/start or turn/steer is acknowledged.
@@ -1378,11 +1445,7 @@ export class LocalAgentHost {
1378
1445
  expectedTurnId: turnId,
1379
1446
  input: nativeInput,
1380
1447
  });
1381
- if (pendingInput.state === "prepublished") {
1382
- // The caller already persisted and published this user input
1383
- // before waiting for the native Terminal to become ready.
1384
- }
1385
- else if (pendingInput.observed) {
1448
+ if (pendingInput.observed) {
1386
1449
  forgetPendingInput();
1387
1450
  }
1388
1451
  else {
@@ -1390,24 +1453,39 @@ export class LocalAgentHost {
1390
1453
  pendingInput.state = "optimistic";
1391
1454
  }
1392
1455
  live.forwarder.noteTurnAccepted(steered.turnId);
1393
- return "steered";
1456
+ return { outcome: "steered", responseId: `resp_codex_${steered.turnId}` };
1394
1457
  }
1395
1458
  }
1396
- // Carry the agent-spec model on the turn so a web-injected turn runs the
1397
- // agent's model even if the TUI's config default differs.
1459
+ // Match Omnigent's turn boundary: change the native thread settings
1460
+ // under the same lock immediately before starting the next Turn. Never
1461
+ // put settings on turn/start or mutate a Turn that is already open.
1462
+ const desiredModel = live.model;
1463
+ const desiredReasoningEffort = live.reasoningEffort;
1464
+ const settings = {
1465
+ threadId,
1466
+ ...(desiredModel !== live.appliedModel
1467
+ ? { model: desiredModel || null }
1468
+ : {}),
1469
+ ...(desiredReasoningEffort !== live.appliedReasoningEffort
1470
+ ? { effort: desiredReasoningEffort ?? null }
1471
+ : {}),
1472
+ };
1473
+ if (Object.keys(settings).length > 1) {
1474
+ await injectionClient.threadSettingsUpdate(settings);
1475
+ // Record exactly what this RPC carried. A concurrent settings save may
1476
+ // already have advanced the desired fields and must remain pending for
1477
+ // the following new Turn.
1478
+ live.appliedModel = desiredModel;
1479
+ live.appliedReasoningEffort = desiredReasoningEffort;
1480
+ }
1481
+ publishAdmission();
1398
1482
  const started = await injectionClient.turnStart({
1399
1483
  threadId,
1400
1484
  input: nativeInput,
1401
1485
  ...turnWorkspaceParams(live.runtime, live.workspace, live.sandbox),
1402
1486
  approvalPolicy: live.approvalPolicy,
1403
- ...(live.model ? { model: live.model } : {}),
1404
- ...(live.reasoningEffort ? { effort: live.reasoningEffort } : {}),
1405
1487
  });
1406
- if (pendingInput.state === "prepublished") {
1407
- // The caller already persisted and published this user input before
1408
- // waiting for the native Terminal to become ready.
1409
- }
1410
- else if (pendingInput.observed) {
1488
+ if (pendingInput.observed) {
1411
1489
  forgetPendingInput();
1412
1490
  }
1413
1491
  else {
@@ -1418,7 +1496,7 @@ export class LocalAgentHost {
1418
1496
  // Do not wait for the independent observer connection's `turn/started`:
1419
1497
  // a second message accepted in that window must steer, not double-start.
1420
1498
  live.forwarder.noteTurnAccepted(started.turnId);
1421
- return "injected";
1499
+ return { outcome: "injected", responseId: `resp_codex_${started.turnId}` };
1422
1500
  }
1423
1501
  catch (error) {
1424
1502
  forgetPendingInput();
@@ -1427,6 +1505,13 @@ export class LocalAgentHost {
1427
1505
  const baseDetail = codexRpcError(error, injectionMethod);
1428
1506
  const startupDetail = live.forwarder.mcpStartupDetail();
1429
1507
  const detail = startupDetail ? `${baseDetail} (${startupDetail})` : baseDetail;
1508
+ // A turn may open on the observer while this short-lived client is
1509
+ // initializing, converting the submission to steer. A rejected steer
1510
+ // does not fail that already-running Turn; only a new-Turn admission
1511
+ // owns the terminal failed edge.
1512
+ if (admissionPublished && injectionMethod === "turn/start") {
1513
+ live.publishTurnAdmissionFailure(detail);
1514
+ }
1430
1515
  console.error(`[codex-live] session=${localThreadId} runtime=${live.runtime} injection failed: ${detail}`);
1431
1516
  throw nativeLiveFailure(live.runtime, "native_message_injection_failed", `message injection via ${injectionMethod} failed`, detail);
1432
1517
  }
@@ -1501,6 +1586,7 @@ export class LocalAgentHost {
1501
1586
  if (turnId) {
1502
1587
  try {
1503
1588
  await interruptClient.turnInterrupt({ threadId, turnId });
1589
+ live.publishInterrupted(`resp_codex_${turnId}`);
1504
1590
  handled = true;
1505
1591
  }
1506
1592
  catch {
@@ -1528,8 +1614,6 @@ export class LocalAgentHost {
1528
1614
  claude.forwarder.finalizeStop();
1529
1615
  }
1530
1616
  removeManagedClaudeSettings(claude.bridgeDir);
1531
- // Remove the throwaway skills --plugin-dir temp so it doesn't leak per launch.
1532
- void claude.skillCleanup?.();
1533
1617
  return;
1534
1618
  }
1535
1619
  const live = this.liveSessions.get(localThreadId);
@@ -1555,6 +1639,27 @@ export class LocalAgentHost {
1555
1639
  // Remove the session-scoped skills dir — the machine keeps zero task residue.
1556
1640
  void live.skillsCleanup?.();
1557
1641
  }
1642
+ /** Tear down one codex-lineage native runtime without deleting its durable
1643
+ * session-store binding. Omnigent couples its auxiliary Terminal, observer,
1644
+ * forwarder and per-session app-server as one disposable runtime envelope;
1645
+ * the next message recreates that envelope and cold-resumes the native id. */
1646
+ teardownLiveCodexSession(localThreadId, error) {
1647
+ const live = this.liveSessions.get(localThreadId);
1648
+ if (!live)
1649
+ return false;
1650
+ const turnFailed = error ? live.forwarder.failOpenTurn(error) : false;
1651
+ const backendKey = codexBackendKey(live.runtime, live.execution.budget ?? undefined);
1652
+ const backend = this.backends.get(backendKey);
1653
+ const appServerOwner = live.appServerOwner;
1654
+ this.stopLiveCodexSession(localThreadId);
1655
+ if (backend?.appServerClient === appServerOwner) {
1656
+ this.backends.delete(backendKey);
1657
+ }
1658
+ void appServerOwner.stop().catch((stopError) => {
1659
+ console.warn(`[codex-live] session=${localThreadId} runtime=${live.runtime} app-server teardown failed: ${stopError instanceof Error ? stopError.message : String(stopError)}`);
1660
+ });
1661
+ return turnFailed;
1662
+ }
1558
1663
  /** Complete the second shutdown phase after the runner has killed all native
1559
1664
  * terminals and hook subprocesses. Must run before the runner process exits. */
1560
1665
  finalizeStoppedLiveSessions() {
@@ -1745,17 +1850,27 @@ export class LocalAgentHost {
1745
1850
  throw new CodexRuntimeError(`cannot launch Claude from ${execution.provider} execution snapshot`, 422, "invalid_execution_snapshot");
1746
1851
  }
1747
1852
  const cwd = workspace.cwd;
1748
- let snapshotSkills;
1749
- try {
1750
- snapshotSkills = await this.prepareExecutionSkills(execution);
1751
- }
1752
- catch (err) {
1753
- console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
1754
- return false;
1853
+ const hasSkillSnapshot = execution.skills.length > 0 || execution.pluginSkills.length > 0;
1854
+ let skillPlugin = hasSkillSnapshot
1855
+ ? await reuseClaudePlugin(localThreadId)
1856
+ : null;
1857
+ if (!skillPlugin && hasSkillSnapshot) {
1858
+ let snapshotSkills;
1859
+ try {
1860
+ snapshotSkills = await this.prepareExecutionSkills(execution);
1861
+ }
1862
+ catch (err) {
1863
+ console.error(`[session-snapshot] session=${localThreadId} skill materialization failed: ${err instanceof Error ? err.message : String(err)}`);
1864
+ return false;
1865
+ }
1866
+ try {
1867
+ skillPlugin = await materializeClaudePlugin(localThreadId, snapshotSkills.selectedSkills);
1868
+ }
1869
+ finally {
1870
+ await snapshotSkills.skillsCleanup();
1871
+ }
1755
1872
  }
1756
1873
  const model = execution.model ?? "";
1757
- const skillPlugin = await materializeSkillPlugin(snapshotSkills.selectedSkills);
1758
- void snapshotSkills.skillsCleanup();
1759
1874
  const permissionMode = execution.permissionMode;
1760
1875
  const launchExtraArgs = [
1761
1876
  ...(skillPlugin?.pluginDir ? ["--plugin-dir", skillPlugin.pluginDir] : []),
@@ -1776,8 +1891,7 @@ export class LocalAgentHost {
1776
1891
  };
1777
1892
  const startNormalizer = (turnId) => {
1778
1893
  // turnId unknown → fixed literal (never random), aligning reference implementation `_response_id`.
1779
- const responseId = live.pendingInjectedInputs.find((entry) => entry.responseId)?.responseId ??
1780
- (turnId ? `resp_claude_${turnId}` : "resp_claude_native");
1894
+ const responseId = turnId ? `resp_claude_${turnId}` : "resp_claude_native";
1781
1895
  if (normalizer && currentResponseId === responseId)
1782
1896
  return normalizer;
1783
1897
  currentResponseId = responseId;
@@ -1822,6 +1936,8 @@ export class LocalAgentHost {
1822
1936
  injectLock: Promise.resolve(),
1823
1937
  pendingImageInputs: new Map(),
1824
1938
  pendingInjectedInputs: [],
1939
+ currentResponseId: () => currentResponseId,
1940
+ publishInterrupted: () => undefined,
1825
1941
  ready,
1826
1942
  markReady,
1827
1943
  failed,
@@ -1831,7 +1947,55 @@ export class LocalAgentHost {
1831
1947
  launchExtraArgs,
1832
1948
  permissionMode,
1833
1949
  ...(forkIntent ? { forkIntent } : {}),
1834
- ...(skillPlugin?.cleanup ? { skillCleanup: skillPlugin.cleanup } : {}),
1950
+ };
1951
+ live.publishInterrupted = (responseId) => {
1952
+ if (live.interruptedResponseId === responseId)
1953
+ return;
1954
+ live.interruptedResponseId = responseId;
1955
+ emitCurrent({
1956
+ type: "session.interrupted",
1957
+ sessionId: currentSessionId,
1958
+ responseId,
1959
+ });
1960
+ };
1961
+ const settleClaudeTurn = (interrupted, usage) => {
1962
+ if (!normalizer)
1963
+ return;
1964
+ const rid = currentResponseId;
1965
+ if (interrupted) {
1966
+ for (const se of normalizer.interrupt()) {
1967
+ if (se.type === "session.interrupted" &&
1968
+ live.interruptedResponseId === rid)
1969
+ continue;
1970
+ emitCurrent(se);
1971
+ }
1972
+ live.interruptedResponseId = undefined;
1973
+ }
1974
+ else {
1975
+ // statusLine usage (context/cost) rides the turn's response.completed.
1976
+ if (usage) {
1977
+ for (const se of normalizer.next({ type: "turn_completed", usage }))
1978
+ emitCurrent(se);
1979
+ }
1980
+ for (const se of normalizer.next({ type: "done" }))
1981
+ emitCurrent(se);
1982
+ }
1983
+ live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1984
+ normalizer = null;
1985
+ currentResponseId = undefined;
1986
+ // One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
1987
+ // cleared by the next turn's first item). statusLine pre-computes the %.
1988
+ const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
1989
+ if (!interrupted && rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
1990
+ live.contextWarned = true;
1991
+ emitCurrent({
1992
+ type: "session.status",
1993
+ sessionId: currentSessionId,
1994
+ responseId: rid,
1995
+ status: "idle",
1996
+ note: `context ${Math.round(pct)}% full — consider /compact`,
1997
+ });
1998
+ }
1835
1999
  };
1836
2000
  const sink = {
1837
2001
  onTurnStart: (turnId) => startNormalizer(turnId),
@@ -1844,12 +2008,13 @@ export class LocalAgentHost {
1844
2008
  const normalizedContent = content ?? [{ type: "input_text", text }];
1845
2009
  const signature = JSON.stringify(normalizedContent);
1846
2010
  const pending = live.pendingInjectedInputs.find((entry) => entry.signature === signature);
1847
- if (pending?.state === "prepublished" || pending?.state === "optimistic") {
2011
+ if (pending) {
2012
+ pending.responseId = currentResponseId;
1848
2013
  live.pendingInjectedInputs.splice(live.pendingInjectedInputs.indexOf(pending), 1);
1849
- return;
1850
- }
1851
- if (pending)
2014
+ if (pending.state === "optimistic")
2015
+ return;
1852
2016
  pending.observed = true;
2017
+ }
1853
2018
  for (const se of n.userInput(normalizedContent))
1854
2019
  emitCurrent(se);
1855
2020
  },
@@ -1876,33 +2041,12 @@ export class LocalAgentHost {
1876
2041
  ...(blockedOn ? { note: blockedOn } : {}),
1877
2042
  });
1878
2043
  },
1879
- onTurnEnd: (usage) => {
1880
- if (!normalizer)
1881
- return;
1882
- const rid = currentResponseId;
1883
- // statusLine usage (context/cost) rides the turn's response.completed.
1884
- if (usage) {
1885
- for (const se of normalizer.next({ type: "turn_completed", usage }))
1886
- emitCurrent(se);
1887
- }
1888
- for (const se of normalizer.next({ type: "done" }))
1889
- emitCurrent(se);
1890
- live.pendingInjectedInputs = live.pendingInjectedInputs.filter((entry) => entry.responseId !== rid);
1891
- normalizer = null;
1892
- currentResponseId = undefined;
1893
- // One-time context-window banner past CONTEXT_WARN_RATIO (a transient note,
1894
- // cleared by the next turn's first item). statusLine pre-computes the %.
1895
- const pct = usage && typeof usage.used_percentage === "number" ? usage.used_percentage : undefined;
1896
- if (rid && pct !== undefined && !live.contextWarned && pct >= CONTEXT_WARN_RATIO * 100) {
1897
- live.contextWarned = true;
1898
- emitCurrent({
1899
- type: "session.status",
1900
- sessionId: currentSessionId,
1901
- responseId: rid,
1902
- status: "idle",
1903
- note: `context ${Math.round(pct)}% full — consider /compact`,
1904
- });
1905
- }
2044
+ onTurnEnd: (usage) => settleClaudeTurn(false, usage),
2045
+ onTurnInterrupted: (usage) => settleClaudeTurn(true, usage),
2046
+ onTurnInterruptRequested: () => {
2047
+ const responseId = currentResponseId;
2048
+ if (responseId)
2049
+ live.publishInterrupted(responseId);
1906
2050
  },
1907
2051
  onIdle: () => {
1908
2052
  // Surface idle on the current turn WITHOUT finalizing it (see the sink's
@@ -2037,17 +2181,18 @@ export class LocalAgentHost {
2037
2181
  * serialized per session. Parks until the thread is ready AND the tmux injector
2038
2182
  * is (re)attached, then pastes; `injectViaTerminal` RAISES if the prompt never
2039
2183
  * appears (reference implementation RAISE), so a not-ready pane is a hard error — NOT a
2040
- * fall-through-to-run signal. Returns {@link InjectOutcome}. */
2041
- injectClaude(live, localThreadId, text) {
2184
+ * fall-through-to-run signal. Returns {@link InjectResult}. */
2185
+ injectClaude(live, localThreadId, text, pendingInput) {
2042
2186
  const run = live.injectLock.then(async () => {
2043
2187
  const ready = await this.waitLiveReady(localThreadId, 60_000);
2044
2188
  if (!ready)
2045
- return "notReady";
2189
+ return { outcome: "notReady" };
2046
2190
  // Pane may have just relaunched — park until its injector re-attaches
2047
2191
  // (attachTerminalInjector resets it) instead of returning false → fallback.
2048
2192
  const injector = await this.waitInjector(live, 60_000);
2049
2193
  if (!injector)
2050
- return "notReady";
2194
+ return { outcome: "notReady" };
2195
+ const steered = live.forwarder.isTurnOpen();
2051
2196
  // Abortable: the web Stop button cancels an in-flight paste/submit (before
2052
2197
  // the message reaches claude) via interruptLive → injectAbort.abort().
2053
2198
  const abort = new AbortController();
@@ -2058,11 +2203,18 @@ export class LocalAgentHost {
2058
2203
  signal: abort.signal,
2059
2204
  submissionObserved: () => live.forwarder.hasObservedSubmissionAfter(submissionCheckpoint, text),
2060
2205
  });
2061
- return ok ? "injected" : "failed";
2206
+ if (!ok)
2207
+ return { outcome: "failed" };
2208
+ const responseId = pendingInput.responseId ?? live.currentResponseId();
2209
+ if (!responseId) {
2210
+ live.error = "Claude accepted the message but did not publish its native Turn identity";
2211
+ return { outcome: "failed" };
2212
+ }
2213
+ return { outcome: steered ? "steered" : "injected", responseId };
2062
2214
  }
2063
2215
  catch (error) {
2064
2216
  live.error = error instanceof Error ? error.message : String(error);
2065
- return "failed";
2217
+ return { outcome: "failed" };
2066
2218
  }
2067
2219
  finally {
2068
2220
  if (live.injectAbort === abort)
@@ -2329,6 +2481,21 @@ function sameSessionSnapshots(workspace, execution, opts) {
2329
2481
  execution: opts.execution,
2330
2482
  }));
2331
2483
  }
2484
+ function sameImmutableSessionSnapshots(workspace, execution, opts) {
2485
+ const current = { ...execution, model: null, reasoningEffort: null };
2486
+ const requested = { ...opts.execution, model: null, reasoningEffort: null };
2487
+ return JSON.stringify(stableValue({ workspace, execution: current })) ===
2488
+ JSON.stringify(stableValue({ workspace: opts.workspace, execution: requested }));
2489
+ }
2490
+ function updateLiveCodexTurnSettings(live, opts) {
2491
+ if (!sameImmutableSessionSnapshots(live.workspace, live.execution, opts)) {
2492
+ return false;
2493
+ }
2494
+ live.execution = structuredClone(opts.execution);
2495
+ live.model = opts.execution.model ?? "";
2496
+ live.reasoningEffort = opts.execution.reasoningEffort ?? undefined;
2497
+ return true;
2498
+ }
2332
2499
  /** Freeze the Session's permission choice into the explicit managed settings.
2333
2500
  * Host/project settings may still contribute allow/deny rules, but changing
2334
2501
  * their defaultMode cannot silently mutate an existing Session on resume. */
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export type { CodexCapabilities, CapabilityResult, CodexRuntimeStatus, } from ".
9
9
  export type { ClaudeForkIntent, CodexSessionStore, CodexSessionRecord, } from "./codex-session-store.js";
10
10
  export { RunnerManager, TerminalOpenError } from "./runner/manager.js";
11
11
  export type { RunnerManagerOptions, RunnerSessionContext, RunnerSessionContextProvider, OpenTerminalOptions, ParentTerminal, } from "./runner/manager.js";
12
- export type { InjectOutcome, TerminalOpenErrorCode, TerminalRole } from "./runner/protocol.js";
12
+ export type { InjectOutcome, InjectResult, TerminalOpenErrorCode, TerminalRole } from "./runner/protocol.js";
13
13
  export type { ResolveInteractionResult, RuntimeInteractionEvent, RuntimeInteractionListener, } from "./interactions.js";
14
14
  export { probeRuntimeStatus } from "./runtime-status.js";
15
15
  export { listRuntimeModels } from "./models-catalog.js";
@@ -11,7 +11,7 @@ import { type AgentCapabilities, type LiveSessionFailure, type ResolvedExecution
11
11
  import type { SessionEvent } from "@rynx-ai/core";
12
12
  import type { TerminalInjector } from "../claude/native-integration.js";
13
13
  import type { ResolveInteractionResult } from "../interactions.js";
14
- import { type InjectOutcome } from "./protocol.js";
14
+ import { type InjectResult } from "./protocol.js";
15
15
  import type { ChildTransport } from "./transport.js";
16
16
  /** Re-target the mirror to a freshly minted rynx session (claude `/clear`·`/fork`)
17
17
  * + record the terminal transfer with the daemon. The child owns the transport,
@@ -50,7 +50,11 @@ interface LiveCodexProvider {
50
50
  liveSessionFailure?(localThreadId: string): LiveSessionFailure | undefined;
51
51
  failLiveStartup?(localThreadId: string, error: Error): boolean;
52
52
  failLiveSession?(localThreadId: string, error: Error): boolean;
53
- injectMessage?(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectOutcome>;
53
+ /** Codex/Traex auxiliary Terminal lifecycle: settle the current Turn, stop
54
+ * observer/forwarder, and evict the owned app-server while preserving the
55
+ * durable native-session binding for a later cold resume. */
56
+ teardownLiveCodexSession?(localThreadId: string, error?: Error): boolean;
57
+ injectMessage?(localThreadId: string, input: RuntimeUserInput | string): Promise<InjectResult>;
54
58
  interruptLive?(localThreadId: string): Promise<boolean>;
55
59
  stopLiveCodexSession?(localThreadId: string, opts?: {
56
60
  deferClaudeInteractionCleanup?: boolean;