@scotthuang/agent-knock-knock 0.11.5 → 0.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +3 -3
  3. package/dist/src/claude-lifecycle-compatibility.d.ts +16 -0
  4. package/dist/src/claude-lifecycle-compatibility.js +77 -0
  5. package/dist/src/claude-lifecycle-compatibility.js.map +1 -0
  6. package/dist/src/claude-local-transcript-provider.js +37 -17
  7. package/dist/src/claude-local-transcript-provider.js.map +1 -1
  8. package/dist/src/claude-terminal-agent-adapter.d.ts +4 -4
  9. package/dist/src/claude-terminal-agent-adapter.js +42 -35
  10. package/dist/src/claude-terminal-agent-adapter.js.map +1 -1
  11. package/dist/src/cli.js +604 -405
  12. package/dist/src/cli.js.map +1 -1
  13. package/dist/src/codex-lifecycle-compatibility.js +2 -1
  14. package/dist/src/codex-lifecycle-compatibility.js.map +1 -1
  15. package/dist/src/codex-terminal-agent-adapter.d.ts +1 -0
  16. package/dist/src/codex-terminal-agent-adapter.js +3 -1
  17. package/dist/src/codex-terminal-agent-adapter.js.map +1 -1
  18. package/dist/src/managed-session.d.ts +15 -0
  19. package/dist/src/managed-session.js +105 -4
  20. package/dist/src/managed-session.js.map +1 -1
  21. package/dist/src/native-thread-resume-snapshot.d.ts +5 -2
  22. package/dist/src/native-thread-resume-snapshot.js +65 -15
  23. package/dist/src/native-thread-resume-snapshot.js.map +1 -1
  24. package/dist/src/openclaw-plugin.js +2 -2
  25. package/dist/src/openclaw-plugin.js.map +1 -1
  26. package/dist/src/session-store.js +5 -0
  27. package/dist/src/session-store.js.map +1 -1
  28. package/dist/src/terminal-agent-adapter.d.ts +4 -14
  29. package/dist/src/terminal-agent-adapter.js.map +1 -1
  30. package/dist/src/terminal-agent-bridge.d.ts +8 -2
  31. package/dist/src/terminal-agent-bridge.js +250 -95
  32. package/dist/src/terminal-agent-bridge.js.map +1 -1
  33. package/dist/src/terminal-control-provider.d.ts +49 -21
  34. package/dist/src/terminal-control-provider.js +328 -30
  35. package/dist/src/terminal-control-provider.js.map +1 -1
  36. package/dist/src/terminal-control-ref.d.ts +119 -0
  37. package/dist/src/terminal-control-ref.js +410 -0
  38. package/dist/src/terminal-control-ref.js.map +1 -0
  39. package/docs/quickstart-tmux.md +1 -1
  40. package/package.json +1 -1
  41. package/templates/openclaw-skills/agent-knock-knock/SKILL.md +1 -1
package/dist/src/cli.js CHANGED
@@ -16,12 +16,13 @@ import { EXECUTOR_KINDS, executorDefinitionForKind, isExecutorKind } from "./exe
16
16
  import { redactString, writeRuntimeLog } from "./runtime-log.js";
17
17
  import { formatTranscript, readNdjsonLog } from "./transcript.js";
18
18
  import { appendEvent, assertStoreWriterCompatible, defaultStoreDir, ensureDir, ensureStoreWritable, inspectStoreCompatibility, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, StoreLockTimeoutError, statePathForConversationId, withStoreWriterLease, withStoreWriterLeaseAsync } from "./store.js";
19
- import { createManagedSessionId, createNativeThreadTransitionId, isExactNativeThreadId, managedSessionBindingToken, nativeThreadCommandFingerprint, terminalBindingFrom, unmanagedTerminalBindingToken } from "./managed-session.js";
19
+ import { createManagedSessionId, createNativeThreadTransitionId, isExactNativeThreadId, legacyManagedSessionBindingToken, legacyUnmanagedTerminalBindingToken, managedSessionBindingToken, nativeThreadCommandFingerprint, terminalBindingFrom, unmanagedTerminalBindingToken } from "./managed-session.js";
20
20
  import { classifyCodexLifecyclePostcondition, evaluateResumeCandidateAvailability, hasStrongCodexLifecycleIdentity, isFreshCodexPostProbeScreen } from "./native-thread-lifecycle-policy.js";
21
21
  import { createNativeThreadResumeSnapshot, nativeThreadCandidateSnapshotFingerprint, nativeThreadResumeSnapshotRowsMatchCandidates, resolveNativeThreadResumeSelection, saveNativeThreadResumeSnapshot, sortNativeThreadCandidates, terminalActionFingerprint, verifiedPreviousResumeCandidate } from "./native-thread-resume-snapshot.js";
22
22
  import { listManagedSessions, loadManagedSession, loadNativeThreadTransition, nativeThreadTransitionsDir, saveManagedSession, saveNativeThreadTransition, tryLoadManagedSession } from "./session-store.js";
23
- import { StaticTerminalControlProvider, TmuxTerminalControlProvider, terminalPaneContainsProcess } from "./terminal-control-provider.js";
23
+ import { createTerminalControlProviderRegistry as createProviderRegistry, StaticTerminalControlProvider, TmuxTerminalControlProvider } from "./terminal-control-provider.js";
24
24
  import { parseTerminalConversationId } from "./terminal-agent-adapter.js";
25
+ import { associateTerminalEndpointEvidence, hasCanonicalTerminalEndpoint, sameTerminalControlEvidenceIncarnation, sameTerminalControlIncarnation, terminalControlEvidence, terminalControlEvidenceMatches, terminalEndpointFromControlRef, terminalEndpointIdentityFromEvidence, terminalEndpointIdentityKey, terminalLegacyRuntimeRoute, sameTerminalEndpointIdentity } from "./terminal-control-ref.js";
25
26
  import { createProductionTerminalAgentRegistry } from "./terminal-agent-registry.js";
26
27
  import { parseProcessElapsedSeconds, StaticTerminalProcessSource, SystemTerminalProcessSource } from "./terminal-process-source.js";
27
28
  import { isExactClaudeNativeInspectionIdleComposer, NativeInspectionSubmissionError, TerminalAgentBridge, TerminalInputNotStartedError } from "./terminal-agent-bridge.js";
@@ -624,14 +625,22 @@ async function listActiveSessionsWithTerminalControl(provider, options, terminal
624
625
  : [];
625
626
  return createTerminalAgentBridge(options, terminalProvider).attachProcesses(provider.agent, activeSessions, { processTree });
626
627
  }
627
- function createTerminalControlProvider(options) {
628
- if (options.terminalsJson || options.terminalScreensJson || options.processesJson) {
629
- return new StaticTerminalControlProvider({
630
- panes: options.terminalsJson ? parseJsonOption(options.terminalsJson, "--terminals-json") : [],
631
- screens: options.terminalScreensJson ? parseJsonOption(options.terminalScreensJson, "--terminal-screens-json") : {}
632
- });
633
- }
634
- return new TmuxTerminalControlProvider();
628
+ function createRuntimeTerminalControlProviderRegistry(options) {
629
+ return createProviderRegistry([
630
+ options.terminalsJson || options.terminalScreensJson || options.processesJson
631
+ ? new StaticTerminalControlProvider({
632
+ panes: options.terminalsJson
633
+ ? parseJsonOption(options.terminalsJson, "--terminals-json")
634
+ : [],
635
+ screens: options.terminalScreensJson
636
+ ? parseJsonOption(options.terminalScreensJson, "--terminal-screens-json")
637
+ : {}
638
+ })
639
+ : new TmuxTerminalControlProvider()
640
+ ]);
641
+ }
642
+ function createTerminalControlProvider(options, registry = createRuntimeTerminalControlProviderRegistry(options)) {
643
+ return registry.require("tmux");
635
644
  }
636
645
  function createTerminalProcessSource(options) {
637
646
  if (options.processesJson) {
@@ -884,7 +893,10 @@ function createTerminalAgentBridge(options, terminalProvider = createTerminalCon
884
893
  terminalProvider,
885
894
  async verifyIdentity({ agent, pid, terminalControl, runtime }) {
886
895
  const adapter = registry.require(agent);
887
- const expectedWorkspace = options.workspace ?? terminalControl.currentPath;
896
+ const requestedTerminal = terminalProvider.endpoint(terminalControl);
897
+ const resolvedTerminal = await terminalProvider.resolve(requestedTerminal);
898
+ const expectedWorkspace = options.workspace ?? resolvedTerminal.route.currentPath ??
899
+ terminalControl.currentPath;
888
900
  if (!expectedWorkspace) {
889
901
  throw new Error(`refusing terminal access to ${terminalControl.target}; its workspace is unavailable`);
890
902
  }
@@ -896,15 +908,11 @@ function createTerminalAgentBridge(options, terminalProvider = createTerminalCon
896
908
  if (!snapshot || !adapter.classifyProcess(snapshot)) {
897
909
  throw new Error(`terminal conversation agent ${agent} with pid ${pid} is no longer active`);
898
910
  }
899
- const panes = await terminalProvider.listPanes();
900
- const pane = panes.find((candidate) => candidate.kind === terminalControl.kind &&
901
- candidate.target === terminalControl.target &&
902
- candidate.panePid === terminalControl.panePid);
903
- if (!pane || !terminalPaneContainsProcess(snapshot, pane, snapshots)) {
911
+ if (!terminalProvider.containsProcess(resolvedTerminal, snapshot, snapshots)) {
904
912
  throw new Error(`terminal conversation agent ${agent} with pid ${pid} no longer belongs to pane ${terminalControl.target}`);
905
913
  }
906
914
  assertConfiguredWorkspace(expectedWorkspace, snapshot.cwd, `terminal access to ${terminalControl.target} by agent process ${pid}`);
907
- assertConfiguredWorkspace(expectedWorkspace, pane.currentPath, `terminal access to ${terminalControl.target} by tmux pane`);
915
+ assertConfiguredWorkspace(expectedWorkspace, resolvedTerminal.route.currentPath, `terminal access to ${terminalControl.target} by terminal endpoint`);
908
916
  const requiresNativeIdentity = Boolean(runtime?.nativeSessionId ||
909
917
  runtime?.nativeProcessUuid ||
910
918
  runtime?.nativeProcessBirth ||
@@ -923,7 +931,7 @@ function createTerminalAgentBridge(options, terminalProvider = createTerminalCon
923
931
  options,
924
932
  agent,
925
933
  pid,
926
- cwd: snapshot.cwd ?? pane.currentPath,
934
+ cwd: snapshot.cwd ?? resolvedTerminal.route.currentPath,
927
935
  preferredSessionId: runtime?.allowedPreMaterializationNativeIdentity &&
928
936
  (runtime.expectedNativeSessionId ?? runtime.nativeSessionId) &&
929
937
  runtime.allowedPreMaterializationNativeIdentity.sessionId !==
@@ -954,7 +962,7 @@ function createTerminalAgentBridge(options, terminalProvider = createTerminalCon
954
962
  phase: "before",
955
963
  pid,
956
964
  processStartedAt: runtime.nativeProcessStartedAt,
957
- cwd: snapshot.cwd ?? pane.currentPath,
965
+ cwd: snapshot.cwd ?? resolvedTerminal.route.currentPath,
958
966
  agentRows
959
967
  });
960
968
  const exactRows = agentRows.filter((row) => row.pid === pid);
@@ -973,13 +981,7 @@ function createTerminalAgentBridge(options, terminalProvider = createTerminalCon
973
981
  }
974
982
  }
975
983
  return {
976
- terminalControl: {
977
- ...terminalControl,
978
- socketPath: pane.socketPath,
979
- panePid: pane.panePid,
980
- currentCommand: pane.currentCommand,
981
- currentPath: pane.currentPath
982
- }
984
+ terminalControl: terminalProvider.toControlRef(resolvedTerminal, terminalControl.capabilities)
983
985
  };
984
986
  }
985
987
  });
@@ -1003,7 +1005,7 @@ function terminalControlFromTakeover(nativeTakeover) {
1003
1005
  const storedCapabilities = Array.isArray(terminalControl.capabilities)
1004
1006
  ? terminalControl.capabilities.filter(isTerminalControlCapability)
1005
1007
  : [];
1006
- return {
1008
+ const control = {
1007
1009
  kind: "tmux",
1008
1010
  target,
1009
1011
  session,
@@ -1025,6 +1027,59 @@ function terminalControlFromTakeover(nativeTakeover) {
1025
1027
  "terminal_cancel"
1026
1028
  ]
1027
1029
  };
1030
+ const endpointEvidence = nativeTakeover["terminal_endpoint"];
1031
+ if (endpointEvidence !== undefined) {
1032
+ try {
1033
+ associateTerminalEndpointEvidence(control, endpointEvidence);
1034
+ }
1035
+ catch {
1036
+ return undefined;
1037
+ }
1038
+ }
1039
+ return control;
1040
+ }
1041
+ function terminalEndpointTakeoverFields(terminalControl) {
1042
+ return {
1043
+ terminal_control: terminalControl,
1044
+ ...(hasCanonicalTerminalEndpoint(terminalControl)
1045
+ ? { terminal_endpoint: terminalControlEvidence(terminalControl) }
1046
+ : {})
1047
+ };
1048
+ }
1049
+ /**
1050
+ * Add canonical endpoint evidence to a verified v0.11.x Turn without changing
1051
+ * its public route-shaped terminal id or provider-owned control payload.
1052
+ * Callers must already hold the terminal, Store-writer, and Turn state locks.
1053
+ */
1054
+ function refineTerminalTurnEndpoint({ conversation, statePath, terminalControl }) {
1055
+ const takeover = isRecord(conversation.native_session_takeover)
1056
+ ? conversation.native_session_takeover
1057
+ : undefined;
1058
+ const storedControl = terminalControlFromTakeover(takeover);
1059
+ if (!takeover ||
1060
+ !storedControl ||
1061
+ takeover.terminal_endpoint !== undefined ||
1062
+ !hasCanonicalTerminalEndpoint(terminalControl)) {
1063
+ return conversation;
1064
+ }
1065
+ if (!terminalControlsShareIncarnation(storedControl, terminalControl)) {
1066
+ throw new Error(`cannot refine Turn ${turnIdForConversation(conversation)} terminal ` +
1067
+ "endpoint after its terminal incarnation changed");
1068
+ }
1069
+ const terminalEndpoint = terminalControlEvidence(terminalControl);
1070
+ // Validate that the additive evidence agrees with the exact legacy route
1071
+ // before making it durable. This also restores the in-memory association on
1072
+ // the old provider-owned control object.
1073
+ associateTerminalEndpointEvidence(storedControl, terminalEndpoint);
1074
+ const refined = {
1075
+ ...conversation,
1076
+ native_session_takeover: {
1077
+ ...takeover,
1078
+ terminal_endpoint: terminalEndpoint
1079
+ }
1080
+ };
1081
+ saveState(statePath, refined);
1082
+ return refined;
1028
1083
  }
1029
1084
  function terminalRuntimeIdentityForConversation(conversation, terminalControl) {
1030
1085
  const nativeTakeover = isRecord(conversation?.native_session_takeover)
@@ -1091,8 +1146,7 @@ function terminalRuntimeIdentityForConversation(conversation, terminalControl) {
1091
1146
  binding.generation !== Number(conversation.terminal_binding_generation) ||
1092
1147
  binding.native_thread_id !== expectedThreadId ||
1093
1148
  binding.native_process.pid !== runtime.pid ||
1094
- terminalControlSelectorKey(binding.terminal_control) !==
1095
- terminalControlSelectorKey(terminalControl)) {
1149
+ !terminalControlsShareIncarnation(binding.terminal_control, terminalControl)) {
1096
1150
  return runtime;
1097
1151
  }
1098
1152
  return withCodexCompanionFences(runtime, codexAllowedCompanionSetForManagedSession({
@@ -1143,19 +1197,14 @@ async function migrateLegacyTerminalAgentIdentity({ conversation, statePath, log
1143
1197
  const registry = createRuntimeTerminalAgentRegistry(options);
1144
1198
  const adapter = registry.require("codex");
1145
1199
  const snapshots = await createTerminalProcessSource(options).listProcessSnapshots((snapshot) => adapter.classifyProcess(snapshot) !== undefined, { includeAncestors: true });
1146
- const panes = await createTerminalControlProvider(options).listPanes();
1147
- const matchingPanes = panes.filter((pane) => pane.kind === terminalControl.kind &&
1148
- pane.target === terminalControl.target &&
1149
- pane.panePid === terminalControl.panePid);
1150
- if (matchingPanes.length !== 1) {
1151
- return conversation;
1152
- }
1200
+ const terminalProvider = createTerminalControlProvider(options);
1201
+ const resolvedTerminal = await terminalProvider.resolve(terminalProvider.endpoint(terminalControl));
1153
1202
  const candidates = snapshots.flatMap((snapshot) => {
1154
1203
  const classified = adapter.classifyProcess(snapshot);
1155
1204
  return classified ? [{ ...classified, agent: "codex" }] : [];
1156
1205
  });
1157
1206
  const matches = candidates.filter((candidate) => candidate.sessionId === nativeSessionId &&
1158
- terminalPaneContainsProcess(candidate, matchingPanes[0], snapshots));
1207
+ terminalProvider.containsProcess(resolvedTerminal, candidate, snapshots));
1159
1208
  if (matches.length !== 1) {
1160
1209
  return conversation;
1161
1210
  }
@@ -1189,9 +1238,7 @@ async function migrateLegacyTerminalAgentIdentity({ conversation, statePath, log
1189
1238
  return current;
1190
1239
  }
1191
1240
  if (currentTakeover.native_session_id !== nativeSessionId ||
1192
- currentControl.target !== terminalControl.target ||
1193
- currentControl.socketPath !== terminalControl.socketPath ||
1194
- currentControl.panePid !== terminalControl.panePid) {
1241
+ !terminalControlsShareIncarnation(currentControl, terminalControl)) {
1195
1242
  return current;
1196
1243
  }
1197
1244
  const migratedAt = new Date().toISOString();
@@ -1271,10 +1318,8 @@ function assertSafeAbortedTerminalRetryBinding({ owner, receipt, storeDir, termi
1271
1318
  !Number.isSafeInteger(ownerAgentPid) ||
1272
1319
  binding.native_process.pid !== ownerAgentPid ||
1273
1320
  !ownerControl ||
1274
- terminalControlSelectorKey(ownerControl) !==
1275
- terminalControlSelectorKey(terminalControl) ||
1276
- terminalControlSelectorKey(binding.terminal_control) !==
1277
- terminalControlSelectorKey(terminalControl)) {
1321
+ !terminalControlsShareIncarnation(ownerControl, terminalControl) ||
1322
+ !terminalControlsShareIncarnation(binding.terminal_control, terminalControl)) {
1278
1323
  throw new Error(`terminal idempotency key ${messageId} belongs to a safe-aborted Turn ` +
1279
1324
  "whose Session binding is no longer current; no terminal input was sent");
1280
1325
  }
@@ -1346,16 +1391,17 @@ function stableDelegateTerminalRoute({ options, request, workspace, requestedAge
1346
1391
  receipt,
1347
1392
  conversationId,
1348
1393
  workspace: ownerWorkspace,
1349
- terminalKey: terminalControlSelectorKey(terminalControl),
1350
- panePid: terminalControl.panePid
1394
+ terminalControl
1351
1395
  };
1352
1396
  });
1353
1397
  const authoritative = routed.filter(({ receipt }) => !(receipt.status === "aborted" && receipt.safe_to_retry === true));
1354
1398
  if (authoritative.length > 1) {
1355
1399
  throw new Error(`terminal idempotency key ${messageId} has multiple durable delegate receipts`);
1356
1400
  }
1357
- const terminalKeys = new Set(routed.map((entry) => `${entry.conversationId}\0${entry.terminalKey}\0${entry.panePid}`));
1358
- if (terminalKeys.size !== 1) {
1401
+ const firstRoute = routed[0];
1402
+ if (!firstRoute ||
1403
+ routed.some((entry) => entry.conversationId !== firstRoute.conversationId ||
1404
+ !terminalControlsShareIncarnation(entry.terminalControl, firstRoute.terminalControl))) {
1359
1405
  throw new Error(`terminal idempotency key ${messageId} has conflicting terminal routes`);
1360
1406
  }
1361
1407
  const selected = authoritative[0] ?? routed.at(-1);
@@ -1816,7 +1862,10 @@ function withTerminalBridgeSubmission({ conversation, messageId, messageType, me
1816
1862
  stringValue(nativeTakeover.terminal_agent_expected_session_id),
1817
1863
  terminal_target: storedControl?.target,
1818
1864
  terminal_socket_path: storedControl?.socketPath ?? null,
1819
- terminal_pane_pid: storedControl?.panePid
1865
+ terminal_pane_pid: storedControl?.panePid,
1866
+ terminal_endpoint: storedControl && hasCanonicalTerminalEndpoint(storedControl)
1867
+ ? terminalControlEvidence(storedControl)
1868
+ : previousReceipt?.terminal_endpoint
1820
1869
  };
1821
1870
  const immutableReceiptFields = Object.fromEntries(Object.entries(candidateImmutableReceiptFields).map(([key, value]) => [
1822
1871
  key,
@@ -1828,7 +1877,7 @@ function withTerminalBridgeSubmission({ conversation, messageId, messageType, me
1828
1877
  for (const [key, value] of Object.entries(candidateImmutableReceiptFields)) {
1829
1878
  if (previousReceipt[key] !== undefined &&
1830
1879
  value !== undefined &&
1831
- String(previousReceipt[key]) !== String(value)) {
1880
+ canonicalJson(previousReceipt[key]) !== canonicalJson(value)) {
1832
1881
  throw new Error(`terminal submission receipt ${messageId} changed immutable ${key}`);
1833
1882
  }
1834
1883
  }
@@ -1909,27 +1958,18 @@ function replayExactActiveTerminalSubmission({ options, terminalControl, request
1909
1958
  throw new Error(`terminal idempotency key ${messageId} has multiple terminal-ledger receipts`);
1910
1959
  }
1911
1960
  const ledgerReceipt = ledgerReceiptMatches[0];
1912
- const loadedLedgerControl = isRecord(loadedLedger?.terminal_control)
1913
- ? loadedLedger.terminal_control
1914
- : undefined;
1915
1961
  const loadedLedgerMessageId = stringValue(loadedLedger?.message_id);
1916
1962
  const loadedLedgerStoreDir = stringValue(loadedLedger?.store_dir);
1917
1963
  const loadedLedgerStatePath = stringValue(loadedLedger?.state_path);
1918
1964
  const loadedLedgerOwner = loadedLedger && loadedLedgerMessageId === messageId
1919
1965
  ? loadTerminalDispatchLedgerOwner(loadedLedger)
1920
1966
  : undefined;
1921
- const ledgerReceiptControl = isRecord(ledgerReceipt?.terminal_control)
1922
- ? ledgerReceipt.terminal_control
1923
- : undefined;
1924
1967
  const expectedMessageBodyHash = createHash("sha256")
1925
1968
  .update(requestText)
1926
1969
  .digest("hex");
1927
1970
  if (ledgerReceipt &&
1928
1971
  !(ledgerReceipt.status === "aborted" && ledgerReceipt.safe_to_retry === true) &&
1929
- (stringValue(ledgerReceiptControl?.target) !== terminalControl.target ||
1930
- (stringValue(ledgerReceiptControl?.socket_path) ?? undefined) !==
1931
- terminalControl.socketPath ||
1932
- Number(ledgerReceiptControl?.pane_pid) !== Number(terminalControl.panePid) ||
1972
+ (!terminalDispatchRecordMatchesControl(ledgerReceipt, terminalControl) ||
1933
1973
  (stringValue(ledgerReceipt.store_dir) !== undefined &&
1934
1974
  path.resolve(String(ledgerReceipt.store_dir)) !==
1935
1975
  path.resolve(expectedStoreDir)) ||
@@ -1954,10 +1994,7 @@ function replayExactActiveTerminalSubmission({ options, terminalControl, request
1954
1994
  }
1955
1995
  if (loadedLedger &&
1956
1996
  loadedLedgerMessageId === messageId &&
1957
- (stringValue(loadedLedgerControl?.target) !== terminalControl.target ||
1958
- (stringValue(loadedLedgerControl?.socket_path) ?? undefined) !==
1959
- terminalControl.socketPath ||
1960
- Number(loadedLedgerControl?.pane_pid) !== Number(terminalControl.panePid) ||
1997
+ (!terminalDispatchRecordMatchesControl(loadedLedger, terminalControl) ||
1961
1998
  (loadedLedgerStoreDir !== undefined &&
1962
1999
  path.resolve(loadedLedgerStoreDir) !== path.resolve(expectedStoreDir)) ||
1963
2000
  (expectedSessionId && stringValue(loadedLedger.session_id) !== undefined &&
@@ -2002,15 +2039,9 @@ function replayExactActiveTerminalSubmission({ options, terminalControl, request
2002
2039
  }
2003
2040
  const incarnationLedger = resolveTerminalDispatchLedgerPaneIncarnation(terminalControl, loadedLedger);
2004
2041
  const ledger = reconcilePreparedTerminalDispatchLedger(terminalControl, incarnationLedger);
2005
- const ledgerControl = isRecord(ledger?.terminal_control)
2006
- ? ledger.terminal_control
2007
- : undefined;
2008
2042
  if (!ledger ||
2009
2043
  !["submitted", "enter_dispatched", "agent_accepted"].includes(String(ledger.status)) ||
2010
- stringValue(ledgerControl?.target) !== terminalControl.target ||
2011
- (stringValue(ledgerControl?.socket_path) ?? undefined) !==
2012
- terminalControl.socketPath ||
2013
- Number(ledgerControl?.pane_pid) !== Number(terminalControl.panePid) ||
2044
+ !terminalDispatchRecordMatchesControl(ledger, terminalControl) ||
2014
2045
  stringValue(ledger.message_id) !== messageId ||
2015
2046
  (stringValue(ledger.message_type) !== undefined &&
2016
2047
  stringValue(ledger.message_type) !== expectedMessageType) ||
@@ -2160,21 +2191,26 @@ function validateStoredTerminalSubmissionMatch({ owner, receipt, options, termin
2160
2191
  turnIdForConversation(owner);
2161
2192
  const receiptOpenClawSession = stringValue(receipt.openclaw_session) ??
2162
2193
  owner.openclaw_session;
2163
- const receiptTarget = stringValue(receipt.terminal_target) ??
2164
- storedControl?.target;
2165
- const receiptSocketPath = receipt.terminal_socket_path === null
2166
- ? undefined
2167
- : stringValue(receipt.terminal_socket_path) ?? storedControl?.socketPath;
2168
- const receiptPanePid = Number(receipt.terminal_pane_pid ?? storedControl?.panePid);
2194
+ const receiptTerminalEvidence = receipt.terminal_endpoint !== undefined
2195
+ ? receipt.terminal_endpoint
2196
+ : {
2197
+ kind: "tmux",
2198
+ target: stringValue(receipt.terminal_target) ?? storedControl?.target,
2199
+ socket_path: receipt.terminal_socket_path === null
2200
+ ? null
2201
+ : stringValue(receipt.terminal_socket_path) ??
2202
+ storedControl?.socketPath ??
2203
+ null,
2204
+ pane_pid: Number(receipt.terminal_pane_pid ?? storedControl?.panePid)
2205
+ };
2169
2206
  const requestedOpenClawSession = stringValue(options.openclawSession);
2170
2207
  if (!ownerStoreDir ||
2171
2208
  !receiptStoreDir ||
2172
2209
  path.resolve(ownerStoreDir) !== path.resolve(expectedStoreDir) ||
2173
2210
  path.resolve(receiptStoreDir) !== path.resolve(expectedStoreDir) ||
2174
2211
  !storedControl ||
2175
- receiptTarget !== terminalControl.target ||
2176
- receiptSocketPath !== terminalControl.socketPath ||
2177
- receiptPanePid !== Number(terminalControl.panePid) ||
2212
+ !terminalControlsShareIncarnation(storedControl, terminalControl) ||
2213
+ !terminalControlEvidenceMatches(receiptTerminalEvidence, terminalControl) ||
2178
2214
  receiptSessionId !== sessionIdForConversation(owner) ||
2179
2215
  receiptTurnId !== turnIdForConversation(owner) ||
2180
2216
  (expectedSessionId && receiptSessionId !== expectedSessionId) ||
@@ -2422,11 +2458,7 @@ function unresolvedTerminalBridgeSubmission(conversation) {
2422
2458
  : undefined;
2423
2459
  }
2424
2460
  function assertNoUnresolvedTerminalBridgeSubmission(storeDir, terminalControl, currentConversationId, requestText) {
2425
- const targetKey = terminalControlSelectorKey(terminalControl);
2426
2461
  const requestHash = terminalBridgeRequestFingerprint(requestText);
2427
- if (!targetKey) {
2428
- return;
2429
- }
2430
2462
  for (const candidate of listConversations(storeDir)) {
2431
2463
  const submission = terminalBridgeSubmission(candidate);
2432
2464
  if (candidate.conversation_id === currentConversationId ||
@@ -2450,7 +2482,7 @@ function assertNoUnresolvedTerminalBridgeSubmission(storeDir, terminalControl, c
2450
2482
  const nativeTakeover = isRecord(candidate.native_session_takeover)
2451
2483
  ? candidate.native_session_takeover
2452
2484
  : undefined;
2453
- if (terminalControlSelectorKey(terminalControlFromTakeover(nativeTakeover)) === targetKey) {
2485
+ if (terminalControlsShareIncarnation(terminalControlFromTakeover(nativeTakeover), terminalControl)) {
2454
2486
  throw new Error(`terminal ${terminalControl.target} has a conflicting ${String(submission.status)} ` +
2455
2487
  `AKK submission in ${candidate.conversation_id}; inspect that conversation and pane, ` +
2456
2488
  "then close it before retrying");
@@ -2458,10 +2490,6 @@ function assertNoUnresolvedTerminalBridgeSubmission(storeDir, terminalControl, c
2458
2490
  }
2459
2491
  }
2460
2492
  function stallOtherTerminalBridgeConversationsForUncertainDispatch({ storeDir, terminalControl, currentConversationId, uncertainMessageId }) {
2461
- const targetKey = terminalControlSelectorKey(terminalControl);
2462
- if (!targetKey) {
2463
- return [];
2464
- }
2465
2493
  const stalledConversationIds = [];
2466
2494
  for (const listed of listConversations(storeDir)) {
2467
2495
  if (listed.conversation_id === currentConversationId ||
@@ -2472,7 +2500,7 @@ function stallOtherTerminalBridgeConversationsForUncertainDispatch({ storeDir, t
2472
2500
  ? listed.native_session_takeover
2473
2501
  : undefined;
2474
2502
  if (listedTakeover?.terminal_bridge !== true ||
2475
- terminalControlSelectorKey(terminalControlFromTakeover(listedTakeover)) !== targetKey) {
2503
+ !terminalControlsShareIncarnation(terminalControlFromTakeover(listedTakeover), terminalControl)) {
2476
2504
  continue;
2477
2505
  }
2478
2506
  const listedStatePath = stringValue(listed.state_path);
@@ -2487,7 +2515,7 @@ function stallOtherTerminalBridgeConversationsForUncertainDispatch({ storeDir, t
2487
2515
  : undefined;
2488
2516
  if (!isActiveStatus(current.status) ||
2489
2517
  currentTakeover?.terminal_bridge !== true ||
2490
- terminalControlSelectorKey(terminalControlFromTakeover(currentTakeover)) !== targetKey) {
2518
+ !terminalControlsShareIncarnation(terminalControlFromTakeover(currentTakeover), terminalControl)) {
2491
2519
  continue;
2492
2520
  }
2493
2521
  const stalledAt = new Date().toISOString();
@@ -2686,13 +2714,7 @@ async function buildTerminalListGroup({ options, agentFilter, statusFilter }) {
2686
2714
  };
2687
2715
  }
2688
2716
  async function terminalControlDiagnostics(provider) {
2689
- if (provider instanceof TmuxTerminalControlProvider) {
2690
- return provider.diagnose();
2691
- }
2692
- return {
2693
- provider: "static",
2694
- paneCount: (await provider.listPanes()).length
2695
- };
2717
+ return provider.diagnostics();
2696
2718
  }
2697
2719
  function managedTurnListEntry(task, { terminalBridge = false, approvalState, conversation } = {}) {
2698
2720
  const sessionId = stringValue(task.session_id) ??
@@ -2844,9 +2866,28 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
2844
2866
  const codexLifecycleIncarnationAvailable = session.agent !== "codex" ||
2845
2867
  Boolean(nativeProcessUuid && nativeProcessBirth);
2846
2868
  const nativeInspectionHasBlockingTurn = listConversations(storeDirFromOptions(options)).some((turn) => isDiscoverableTmuxConversation(turn) &&
2847
- terminalKeyForManagedConversation(turn) ===
2848
- terminalControlSelectorKey(terminalControl) &&
2869
+ terminalControlsShareIncarnation(terminalControlForManagedConversation(turn), terminalControl) &&
2849
2870
  SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
2871
+ let nativeInspectionComposerReady = nativeInspectionComposerEmpty(session.agent, terminalState.screen_excerpt);
2872
+ if (session.agent === "codex" &&
2873
+ terminalState.activity_state === "idle" &&
2874
+ terminalState.approval_state.blocked !== true &&
2875
+ nativeInspectionCapability.status === "supported" &&
2876
+ nativeInspectionCapability.statusInspection === true &&
2877
+ terminalControl.capabilities.includes("send_keys") &&
2878
+ terminalControl.capabilities.includes("screen_status")) {
2879
+ try {
2880
+ const provider = createTerminalControlProvider(options);
2881
+ const resolvedTerminal = await provider.resolve(provider.endpoint(terminalControl));
2882
+ const styledScreen = await provider.capture(resolvedTerminal, { scrollbackLines: 40, preserveEscapes: true });
2883
+ nativeInspectionComposerReady = codexStyledComposerEmpty(styledScreen);
2884
+ }
2885
+ catch {
2886
+ // Advertising an input action is optional. The action itself repeats the
2887
+ // same styled composer proof under the terminal lock before any input.
2888
+ nativeInspectionComposerReady = false;
2889
+ }
2890
+ }
2850
2891
  const entry = {
2851
2892
  id: bridge.terminalConversationId(session),
2852
2893
  short_ref: sessionShortRef(bridge.terminalConversationId(session)),
@@ -2905,7 +2946,7 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
2905
2946
  codexLifecycleIncarnationAvailable,
2906
2947
  native_inspect: nativeInspectionCapability.status === "supported" &&
2907
2948
  nativeInspectionCapability.statusInspection === true &&
2908
- nativeInspectionComposerEmpty(session.agent, terminalState.screen_excerpt) &&
2949
+ nativeInspectionComposerReady &&
2909
2950
  terminalControl.capabilities.includes("send_keys") &&
2910
2951
  terminalControl.capabilities.includes("screen_status") &&
2911
2952
  (session.agent === "codex"
@@ -2924,26 +2965,24 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
2924
2965
  };
2925
2966
  }
2926
2967
  function terminalFirstListProjection({ storeDir, terminals, managedSessions, sessionAuthorityRequired, allConversations, displayedConversations, includeAll, managedOnly, statusFilter, mutationsAllowed }) {
2927
- const allByTerminal = managedConversationsByTerminal(allConversations);
2928
- const displayedByTerminal = managedConversationsByTerminal(displayedConversations);
2929
- const sessionsByTerminal = managedSessionsByTerminal(managedSessions);
2930
- const discoveredTerminalKeys = new Set();
2968
+ const discoveredTerminalControls = terminals.flatMap((terminal) => {
2969
+ const control = isRecord(terminal.terminal_control)
2970
+ ? terminal.terminal_control
2971
+ : undefined;
2972
+ return control ? [control] : [];
2973
+ });
2931
2974
  const projectedTerminals = terminals.map((terminal) => {
2932
2975
  const terminalControl = isRecord(terminal.terminal_control)
2933
2976
  ? terminal.terminal_control
2934
2977
  : undefined;
2935
- const terminalKey = terminalControlSelectorKey(terminalControl);
2936
- if (terminalKey) {
2937
- discoveredTerminalKeys.add(terminalKey);
2938
- }
2939
- const allRelated = terminalKey
2940
- ? [...(allByTerminal.get(terminalKey) ?? [])]
2978
+ const allRelated = terminalControl
2979
+ ? allConversations.filter((conversation) => terminalControlsShareIncarnation(terminalControlForManagedConversation(conversation), terminalControl))
2941
2980
  : [];
2942
- const displayedRelated = terminalKey
2943
- ? [...(displayedByTerminal.get(terminalKey) ?? [])]
2981
+ const displayedRelated = terminalControl
2982
+ ? displayedConversations.filter((conversation) => terminalControlsShareIncarnation(terminalControlForManagedConversation(conversation), terminalControl))
2944
2983
  : [];
2945
- const relatedSessions = terminalKey
2946
- ? [...(sessionsByTerminal.get(terminalKey) ?? [])]
2984
+ const relatedSessions = terminalControl
2985
+ ? managedSessions.filter((session) => terminalControlsShareIncarnation(session.binding?.terminal_control, terminalControl))
2947
2986
  : [];
2948
2987
  const matchingSessions = relatedSessions.filter((session) => managedSessionMatchesLiveTerminalEntry(session, terminal, storeDir));
2949
2988
  const conflictingBoundSessionClaims = relatedSessions.flatMap((session) => {
@@ -3210,8 +3249,8 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3210
3249
  });
3211
3250
  const unavailableManagedTurns = displayedConversations
3212
3251
  .filter((conversation) => {
3213
- const terminalKey = terminalKeyForManagedConversation(conversation);
3214
- if (terminalKey && discoveredTerminalKeys.has(terminalKey)) {
3252
+ const managedControl = terminalControlForManagedConversation(conversation);
3253
+ if (discoveredTerminalControls.some((control) => terminalControlsShareIncarnation(managedControl, control))) {
3215
3254
  return false;
3216
3255
  }
3217
3256
  return (includeAll ||
@@ -3248,35 +3287,6 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3248
3287
  unavailableManagedTurns
3249
3288
  };
3250
3289
  }
3251
- function managedConversationsByTerminal(conversations) {
3252
- const groups = new Map();
3253
- for (const conversation of conversations) {
3254
- const key = terminalKeyForManagedConversation(conversation);
3255
- if (!key) {
3256
- continue;
3257
- }
3258
- const group = groups.get(key) ?? [];
3259
- group.push(conversation);
3260
- groups.set(key, group);
3261
- }
3262
- return groups;
3263
- }
3264
- function managedSessionsByTerminal(sessions) {
3265
- const groups = new Map();
3266
- for (const session of sessions) {
3267
- if (!session.binding) {
3268
- continue;
3269
- }
3270
- const key = terminalControlSelectorKey(session.binding.terminal_control);
3271
- if (!key) {
3272
- continue;
3273
- }
3274
- const group = groups.get(key) ?? [];
3275
- group.push(session);
3276
- groups.set(key, group);
3277
- }
3278
- return groups;
3279
- }
3280
3290
  function managedSessionMatchesLiveTerminalEntry(session, terminal, storeDir) {
3281
3291
  const binding = session.binding;
3282
3292
  const liveControl = isRecord(terminal.terminal_control)
@@ -3285,10 +3295,8 @@ function managedSessionMatchesLiveTerminalEntry(session, terminal, storeDir) {
3285
3295
  if (session.status !== "bound" ||
3286
3296
  !binding ||
3287
3297
  session.agent !== terminal.agent ||
3288
- binding.terminal_id !== stringValue(terminal.id) ||
3289
3298
  binding.native_process.pid !== Number(terminal.pid) ||
3290
- terminalControlSelectorKey(binding.terminal_control) !==
3291
- terminalControlSelectorKey(liveControl) ||
3299
+ !terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.id, liveControl) ||
3292
3300
  !matchesConfiguredWorkspace(session.workspace, terminal.workspace ?? terminal.cwd)) {
3293
3301
  return false;
3294
3302
  }
@@ -3359,8 +3367,7 @@ function managedSessionClaimsLiveTerminalEntry(session, terminal) {
3359
3367
  return Boolean(binding &&
3360
3368
  session.agent === terminal.agent &&
3361
3369
  binding.native_process.pid === Number(terminal.pid) &&
3362
- terminalControlSelectorKey(binding.terminal_control) ===
3363
- terminalControlSelectorKey(liveControl));
3370
+ terminalControlsShareIncarnation(binding.terminal_control, liveControl));
3364
3371
  }
3365
3372
  function listedTerminalProcessIncarnation(terminal) {
3366
3373
  const processUuid = stringValue(terminal.native_agent_process_uuid);
@@ -3403,7 +3410,9 @@ function managedBindingConflictKindForLiveTerminalEntry({ storeDir, session, ter
3403
3410
  if (relationship === "different") {
3404
3411
  return "stale_process_incarnation";
3405
3412
  }
3406
- if (binding.terminal_id !== stringValue(terminal.id) ||
3413
+ if (!terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.id, isRecord(terminal.terminal_control)
3414
+ ? terminal.terminal_control
3415
+ : undefined) ||
3407
3416
  !matchesConfiguredWorkspace(session.workspace, terminal.workspace ?? terminal.cwd)) {
3408
3417
  return "unverifiable";
3409
3418
  }
@@ -3469,10 +3478,10 @@ function managedSessionHasUnresolvedNativeTransition(storeDir, session) {
3469
3478
  }
3470
3479
  return false;
3471
3480
  }
3472
- function terminalKeyForManagedConversation(conversation) {
3473
- return terminalControlSelectorKey(terminalControlFromTakeover(isRecord(conversation.native_session_takeover)
3481
+ function terminalControlForManagedConversation(conversation) {
3482
+ return terminalControlFromTakeover(isRecord(conversation.native_session_takeover)
3474
3483
  ? conversation.native_session_takeover
3475
- : undefined));
3484
+ : undefined);
3476
3485
  }
3477
3486
  function compareManagedConversationRecency(left, right) {
3478
3487
  const leftTime = Date.parse(String(left.updated_at ?? left.created_at ?? ""));
@@ -3520,16 +3529,10 @@ function terminalDispatchOwnership(terminalControl) {
3520
3529
  if (!ledger || ledger.status === "resolved") {
3521
3530
  return { state: "none" };
3522
3531
  }
3523
- const ledgerControl = isRecord(ledger.terminal_control)
3524
- ? ledger.terminal_control
3525
- : undefined;
3526
- const ledgerPanePid = Number(ledgerControl?.pane_pid);
3527
- const currentPanePid = Number(terminalControl.panePid);
3528
- if (Number.isSafeInteger(ledgerPanePid) &&
3529
- ledgerPanePid > 0 &&
3530
- Number.isSafeInteger(currentPanePid) &&
3531
- currentPanePid > 0 &&
3532
- ledgerPanePid !== currentPanePid) {
3532
+ if (terminalDispatchRecordMatchesControl(ledger, terminalControl, {
3533
+ requireProcessAnchor: false
3534
+ }) &&
3535
+ !terminalDispatchRecordMatchesControl(ledger, terminalControl)) {
3533
3536
  return { state: "none" };
3534
3537
  }
3535
3538
  if (![
@@ -3554,9 +3557,8 @@ function terminalDispatchOwnership(terminalControl) {
3554
3557
  if (TERMINAL_DISPATCH_RELEASE_STATUSES.has(owner.status)) {
3555
3558
  return { state: "none" };
3556
3559
  }
3557
- const ownerTerminalKey = terminalKeyForManagedConversation(owner);
3558
- const currentTerminalKey = terminalControlSelectorKey(terminalControl);
3559
- if (!ownerTerminalKey || ownerTerminalKey !== currentTerminalKey) {
3560
+ const ownerTerminalControl = terminalControlForManagedConversation(owner);
3561
+ if (!terminalControlsShareIncarnation(ownerTerminalControl, terminalControl)) {
3560
3562
  return {
3561
3563
  state: "conflict",
3562
3564
  conflict: terminalDispatchConflict(ledger, "dispatch owner does not reference this tmux pane incarnation")
@@ -3729,9 +3731,8 @@ function managedTurnMatchesLiveTerminal(conversation, terminal) {
3729
3731
  !Number.isSafeInteger(livePid) ||
3730
3732
  livePid <= 1 ||
3731
3733
  storedPid !== livePid ||
3732
- stringValue(takeover?.native_session_id) !== stringValue(terminal.id) ||
3733
- terminalControlSelectorKey(storedControl) !==
3734
- terminalControlSelectorKey(liveControl)) {
3734
+ !terminalControlAliasMatches(stringValue(takeover?.native_session_id), storedControl, stringValue(terminal.id), liveControl) ||
3735
+ !terminalControlsShareIncarnation(storedControl, liveControl)) {
3735
3736
  return false;
3736
3737
  }
3737
3738
  const liveSessionId = stringValue(terminal.native_agent_session_id);
@@ -3876,18 +3877,25 @@ async function listStateForTerminal(agent, terminalControl, options, bridge = cr
3876
3877
  function rootActiveProcesses(processes) {
3877
3878
  const pids = new Set(processes.map((process) => `${process.agent}:${process.pid}`));
3878
3879
  const roots = processes.filter((process) => !process.ppid || !pids.has(`${process.agent}:${process.ppid}`));
3879
- const seenTerminalTargets = new Set();
3880
+ const seenTerminalIncarnations = new Set();
3880
3881
  return roots.filter((process) => {
3881
- const terminalTarget = process.terminalControl?.target
3882
- ? `${process.agent}:${process.terminalControl.target}:${process.terminalControl.panePid}`
3882
+ const endpoint = process.terminalControl
3883
+ ? terminalEndpointFromControlRef(process.terminalControl)
3884
+ : undefined;
3885
+ const terminalIncarnation = endpoint
3886
+ ? JSON.stringify({
3887
+ agent: process.agent,
3888
+ identity: terminalEndpointIdentityKey(endpoint),
3889
+ process_anchor_pid: endpoint.processAnchorPid ?? null
3890
+ })
3883
3891
  : undefined;
3884
- if (!terminalTarget) {
3892
+ if (!terminalIncarnation) {
3885
3893
  return true;
3886
3894
  }
3887
- if (seenTerminalTargets.has(terminalTarget)) {
3895
+ if (seenTerminalIncarnations.has(terminalIncarnation)) {
3888
3896
  return false;
3889
3897
  }
3890
- seenTerminalTargets.add(terminalTarget);
3898
+ seenTerminalIncarnations.add(terminalIncarnation);
3891
3899
  return true;
3892
3900
  });
3893
3901
  }
@@ -4508,17 +4516,43 @@ function terminalControlSelectorKey(value) {
4508
4516
  if (!isRecord(value)) {
4509
4517
  return undefined;
4510
4518
  }
4511
- const target = stringValue(value.target);
4512
- const panePid = Number(value.panePid);
4513
- if (!target || !Number.isSafeInteger(panePid) || panePid <= 1) {
4519
+ let endpoint;
4520
+ try {
4521
+ endpoint = terminalEndpointFromControlRef(value);
4522
+ }
4523
+ catch {
4524
+ return undefined;
4525
+ }
4526
+ const processAnchorPid = Number(endpoint.processAnchorPid);
4527
+ if (!Number.isSafeInteger(processAnchorPid) || processAnchorPid <= 1) {
4514
4528
  return undefined;
4515
4529
  }
4516
4530
  return JSON.stringify({
4517
- target,
4518
- pane_pid: panePid,
4519
- socket_path: stringValue(value.socketPath) ?? null
4531
+ identity: terminalEndpointIdentityKey(endpoint),
4532
+ process_anchor_pid: processAnchorPid
4520
4533
  });
4521
4534
  }
4535
+ function terminalControlsShareIncarnation(left, right) {
4536
+ if (!terminalControlSelectorKey(left) || !terminalControlSelectorKey(right)) {
4537
+ return false;
4538
+ }
4539
+ return sameTerminalControlIncarnation(left, right);
4540
+ }
4541
+ /**
4542
+ * `terminal:v2` is a route-shaped compatibility alias, not terminal
4543
+ * ownership. Once both sides carry canonical endpoint evidence, a route
4544
+ * rename must not detach or duplicate the stable resource. Legacy records
4545
+ * continue to require the exact historical alias and route tuple.
4546
+ */
4547
+ function terminalControlAliasMatches(storedTerminalId, storedControl, currentTerminalId, currentControl) {
4548
+ if (!terminalControlsShareIncarnation(storedControl, currentControl)) {
4549
+ return false;
4550
+ }
4551
+ const stored = storedControl;
4552
+ const current = currentControl;
4553
+ return (hasCanonicalTerminalEndpoint(stored) &&
4554
+ hasCanonicalTerminalEndpoint(current)) || stringValue(storedTerminalId) === stringValue(currentTerminalId);
4555
+ }
4522
4556
  function sessionEntryRecency(entry, observedAtMs) {
4523
4557
  const timestamp = Date.parse(String(entry.updated_at ?? entry.created_at ?? ""));
4524
4558
  if (Number.isFinite(timestamp)) {
@@ -4812,14 +4846,12 @@ function codexAllowedCompanionSetForManagedSession({ storeDir, session }) {
4812
4846
  candidate.agent !== "codex" ||
4813
4847
  candidate.status !== "detached" ||
4814
4848
  !candidateBinding?.native_thread_id ||
4815
- candidateBinding.terminal_id !== binding.terminal_id ||
4816
4849
  candidateBinding.native_process.pid !== binding.native_process.pid ||
4817
4850
  candidateBinding.native_process.process_uuid !==
4818
4851
  binding.native_process.process_uuid ||
4819
4852
  candidateBinding.native_process.process_birth !==
4820
4853
  binding.native_process.process_birth ||
4821
- terminalControlSelectorKey(candidateBinding.terminal_control) !==
4822
- terminalControlSelectorKey(binding.terminal_control) ||
4854
+ !terminalControlAliasMatches(candidateBinding.terminal_id, candidateBinding.terminal_control, binding.terminal_id, binding.terminal_control) ||
4823
4855
  path.resolve(candidate.workspace) !== path.resolve(session.workspace) ||
4824
4856
  !candidateBinding.native_process.process_uuid ||
4825
4857
  !candidateBinding.native_process.process_birth ||
@@ -4907,8 +4939,8 @@ function codexKnownRootSetForLifecycleTransition({ storeDir, terminal, transitio
4907
4939
  if (after?.native_thread_id &&
4908
4940
  after.native_process.process_uuid &&
4909
4941
  after.native_process.process_birth &&
4910
- after.terminal_id === terminal.conversationId &&
4911
4942
  after.native_process.pid === terminal.pid &&
4943
+ terminalControlAliasMatches(after.terminal_id, after.terminal_control, terminal.conversationId, terminal.terminalControl) &&
4912
4944
  after.native_process.process_uuid === transition.before_process_uuid &&
4913
4945
  after.native_process.process_birth === transition.before_process_birth &&
4914
4946
  isCompleteNativeRollout(after.native_process.rollout)) {
@@ -4926,12 +4958,10 @@ function codexKnownRootSetForLifecycleTransition({ storeDir, terminal, transitio
4926
4958
  !binding?.native_thread_id ||
4927
4959
  !binding.native_process.process_uuid ||
4928
4960
  !binding.native_process.process_birth ||
4929
- binding.terminal_id !== terminal.conversationId ||
4930
4961
  binding.native_process.pid !== terminal.pid ||
4931
4962
  binding.native_process.process_uuid !== transition.before_process_uuid ||
4932
4963
  binding.native_process.process_birth !== transition.before_process_birth ||
4933
- terminalControlSelectorKey(binding.terminal_control) !==
4934
- terminalControlSelectorKey(terminal.terminalControl) ||
4964
+ !terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.conversationId, terminal.terminalControl) ||
4935
4965
  !matchesConfiguredWorkspace(transition.workspace, session.workspace) ||
4936
4966
  !isCompleteNativeRollout(binding.native_process.rollout)) {
4937
4967
  continue;
@@ -4994,11 +5024,9 @@ function nativeIdentityMatchesCodexPreMaterialization(identity, expected) {
4994
5024
  identity.rollout?.path === expected.rollout.path);
4995
5025
  }
4996
5026
  async function assertCodexComposerReadyForAutomatedInput({ options, terminalControl }) {
4997
- const styledScreen = await createTerminalControlProvider(options).capture(terminalControl.target, {
4998
- scrollbackLines: 40,
4999
- socketPath: terminalControl.socketPath,
5000
- preserveEscapes: true
5001
- });
5027
+ const provider = createTerminalControlProvider(options);
5028
+ const resolvedTerminal = await provider.resolve(provider.endpoint(terminalControl));
5029
+ const styledScreen = await provider.capture(resolvedTerminal, { scrollbackLines: 40, preserveEscapes: true });
5002
5030
  if (!codexStyledComposerEmpty(styledScreen)) {
5003
5031
  throw new Error("Codex composer contains non-placeholder input; refusing automated terminal input");
5004
5032
  }
@@ -5086,10 +5114,8 @@ function bindingMatchesLiveTerminal(session, terminal, identity, storeDir) {
5086
5114
  return false;
5087
5115
  }
5088
5116
  if (session.agent !== terminal.agent ||
5089
- binding.terminal_id !== terminal.conversationId ||
5090
5117
  binding.native_process.pid !== terminal.pid ||
5091
- terminalControlSelectorKey(binding.terminal_control) !==
5092
- terminalControlSelectorKey(terminal.terminalControl) ||
5118
+ !terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.conversationId, terminal.terminalControl) ||
5093
5119
  !matchesConfiguredWorkspace(session.workspace, terminal.terminalControl.currentPath)) {
5094
5120
  return false;
5095
5121
  }
@@ -5131,9 +5157,7 @@ function boundManagedSessionForTerminal({ storeDir, terminal, identity }) {
5131
5157
  const unresolved = sessions.filter((session) => ["transitioning", "quarantined"].includes(session.status) &&
5132
5158
  session.binding &&
5133
5159
  session.agent === terminal.agent &&
5134
- session.binding.terminal_id === terminal.conversationId &&
5135
- terminalControlSelectorKey(session.binding.terminal_control) ===
5136
- terminalControlSelectorKey(terminal.terminalControl));
5160
+ terminalControlAliasMatches(session.binding.terminal_id, session.binding.terminal_control, terminal.conversationId, terminal.terminalControl));
5137
5161
  if (unresolved.length > 0) {
5138
5162
  throw new Error(`terminal ${terminal.terminalControl.target} is still claimed by ` +
5139
5163
  `${unresolved[0].status} managed Session ${unresolved[0].session_id}` +
@@ -5149,8 +5173,7 @@ function boundManagedSessionForTerminal({ storeDir, terminal, identity }) {
5149
5173
  const conflicting = sessions.filter((session) => session.status === "bound" &&
5150
5174
  session.binding &&
5151
5175
  session.agent === terminal.agent &&
5152
- terminalControlSelectorKey(session.binding.terminal_control) ===
5153
- terminalControlSelectorKey(terminal.terminalControl) &&
5176
+ terminalControlsShareIncarnation(session.binding.terminal_control, terminal.terminalControl) &&
5154
5177
  session.binding.native_process.pid === terminal.pid &&
5155
5178
  !managedSessionOwnerIsConclusivelyInactive({
5156
5179
  session,
@@ -5169,10 +5192,8 @@ function managedBindingConflictKindForResolvedTerminal({ storeDir, session, term
5169
5192
  if (session.status !== "bound" ||
5170
5193
  !binding ||
5171
5194
  session.agent !== terminal.agent ||
5172
- binding.terminal_id !== terminal.conversationId ||
5173
5195
  binding.native_process.pid !== terminal.pid ||
5174
- terminalControlSelectorKey(binding.terminal_control) !==
5175
- terminalControlSelectorKey(terminal.terminalControl) ||
5196
+ !terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.conversationId, terminal.terminalControl) ||
5176
5197
  !matchesConfiguredWorkspace(session.workspace, terminal.terminalControl.currentPath) ||
5177
5198
  bindingMatchesLiveTerminal(session, terminal, identity, storeDir)) {
5178
5199
  return undefined;
@@ -5211,10 +5232,8 @@ function soleBoundManagedSessionClaimForTerminal(storeDir, terminal) {
5211
5232
  const claims = listManagedSessions(storeDir).filter((session) => session.status === "bound" &&
5212
5233
  session.binding &&
5213
5234
  session.agent === terminal.agent &&
5214
- session.binding.terminal_id === terminal.conversationId &&
5215
5235
  session.binding.native_process.pid === terminal.pid &&
5216
- terminalControlSelectorKey(session.binding.terminal_control) ===
5217
- terminalControlSelectorKey(terminal.terminalControl) &&
5236
+ terminalControlAliasMatches(session.binding.terminal_id, session.binding.terminal_control, terminal.conversationId, terminal.terminalControl) &&
5218
5237
  matchesConfiguredWorkspace(session.workspace, terminal.terminalControl.currentPath) &&
5219
5238
  !managedSessionOwnerIsConclusivelyInactive({
5220
5239
  session,
@@ -5368,6 +5387,32 @@ function lifecycleBindingToken({ session, terminal, identity }) {
5368
5387
  rollout: identity?.rollout
5369
5388
  });
5370
5389
  }
5390
+ function managedSessionBindingTokens(session) {
5391
+ return [...new Set([
5392
+ managedSessionBindingToken(session),
5393
+ legacyManagedSessionBindingToken(session)
5394
+ ])];
5395
+ }
5396
+ function lifecycleBindingTokens({ session, terminal, identity }) {
5397
+ const current = lifecycleBindingToken({ session, terminal, identity });
5398
+ const codexIncarnation = terminal.agent === "codex" && !identity
5399
+ ? codexProcessIncarnationForPid(terminal.pid)
5400
+ : undefined;
5401
+ const legacy = session
5402
+ ? legacyManagedSessionBindingToken(session)
5403
+ : legacyUnmanagedTerminalBindingToken({
5404
+ terminalId: terminal.conversationId,
5405
+ terminalControl: terminal.terminalControl,
5406
+ agent: terminal.agent,
5407
+ pid: terminal.pid,
5408
+ workspace: terminal.terminalControl.currentPath ?? process.cwd(),
5409
+ nativeThreadId: identity?.sessionId,
5410
+ processUuid: identity?.processUuid ?? codexIncarnation?.processUuid,
5411
+ processBirth: identity?.processBirth ?? codexIncarnation?.processBirth,
5412
+ rollout: identity?.rollout
5413
+ });
5414
+ return [...new Set([current, legacy])];
5415
+ }
5371
5416
  function agentVersionForRunningProcess(agent, pid, options) {
5372
5417
  const fixture = options.agentVersionsJson
5373
5418
  ? parseJsonOption(options.agentVersionsJson, "--agent-versions-json")
@@ -5408,8 +5453,7 @@ function assertTerminalLifecycleReady({ options, terminal, terminalStatus }) {
5408
5453
  }
5409
5454
  const blockers = listConversations(storeDirFromOptions(options))
5410
5455
  .filter(isDiscoverableTmuxConversation)
5411
- .filter((turn) => terminalKeyForManagedConversation(turn) ===
5412
- terminalControlSelectorKey(terminal.terminalControl) &&
5456
+ .filter((turn) => terminalControlsShareIncarnation(terminalControlForManagedConversation(turn), terminal.terminalControl) &&
5413
5457
  SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
5414
5458
  if (blockers.length > 0) {
5415
5459
  throw new Error(`terminal ${terminal.terminalControl.target} still has unresolved Turn ` +
@@ -5611,10 +5655,8 @@ async function assertLifecycleTargetHasExclusiveOwnership({ options, terminal, t
5611
5655
  if (!afterBinding ||
5612
5656
  !nativeThreadId ||
5613
5657
  !isExactNativeThreadId(nativeThreadId) ||
5614
- afterBinding.terminal_id !== terminal.conversationId ||
5615
5658
  afterBinding.native_process.pid !== terminal.pid ||
5616
- terminalControlSelectorKey(afterBinding.terminal_control) !==
5617
- terminalControlSelectorKey(terminal.terminalControl)) {
5659
+ !terminalControlAliasMatches(afterBinding.terminal_id, afterBinding.terminal_control, terminal.conversationId, terminal.terminalControl)) {
5618
5660
  throw new Error("cannot exclude the current lifecycle process without an exact after_binding");
5619
5661
  }
5620
5662
  await assertNativeThreadHasExclusiveOwnership({
@@ -5907,7 +5949,7 @@ async function currentLifecycleSnapshot(options, terminal, { materialize = false
5907
5949
  allowedCompanionIdentity: claimedCodexCompanions.primary,
5908
5950
  allowedAdditionalIdentities: claimedCodexCompanions.additional
5909
5951
  });
5910
- const session = materialize
5952
+ let session = materialize
5911
5953
  ? materializeCurrentManagedSession({
5912
5954
  options,
5913
5955
  terminal,
@@ -5918,13 +5960,26 @@ async function currentLifecycleSnapshot(options, terminal, { materialize = false
5918
5960
  terminal,
5919
5961
  identity: observedIdentity
5920
5962
  });
5921
- const identity = session
5963
+ let identity = session
5922
5964
  ? logicalIdentityForManagedSession({
5923
5965
  storeDir,
5924
5966
  session,
5925
5967
  observedIdentity
5926
5968
  })
5927
5969
  : observedIdentity;
5970
+ if (materialize && session) {
5971
+ session = refineManagedSessionNativeIdentity({
5972
+ storeDir,
5973
+ session,
5974
+ terminalControl: terminal.terminalControl,
5975
+ identity
5976
+ });
5977
+ identity = logicalIdentityForManagedSession({
5978
+ storeDir,
5979
+ session,
5980
+ observedIdentity
5981
+ });
5982
+ }
5928
5983
  const codexCompanions = terminal.agent === "codex" && session
5929
5984
  ? codexAllowedCompanionSetForManagedSession({ storeDir, session })
5930
5985
  : claimedCodexCompanions;
@@ -5949,6 +6004,7 @@ async function currentLifecycleSnapshot(options, terminal, { materialize = false
5949
6004
  resumeExact: false,
5950
6005
  reason: `${adapter.displayName} has no native-thread lifecycle adapter`
5951
6006
  };
6007
+ const bindingTokens = lifecycleBindingTokens({ session, terminal, identity });
5952
6008
  return {
5953
6009
  identity,
5954
6010
  runtimeIdentity: observedIdentity,
@@ -5957,7 +6013,8 @@ async function currentLifecycleSnapshot(options, terminal, { materialize = false
5957
6013
  version,
5958
6014
  adapter,
5959
6015
  capabilities,
5960
- bindingToken: lifecycleBindingToken({ session, terminal, identity })
6016
+ bindingToken: bindingTokens[0],
6017
+ bindingTokens
5961
6018
  };
5962
6019
  }
5963
6020
  async function runListResumableThreads(options) {
@@ -6062,11 +6119,9 @@ async function runListResumableThreads(options) {
6062
6119
  function assertSameNativeInspectionTerminal(expected, actual, stage) {
6063
6120
  const expectedPath = expected.terminalControl.currentPath;
6064
6121
  const actualPath = actual.terminalControl.currentPath;
6065
- if (actual.conversationId !== expected.conversationId ||
6066
- actual.agent !== expected.agent ||
6122
+ if (actual.agent !== expected.agent ||
6067
6123
  actual.pid !== expected.pid ||
6068
- terminalControlSelectorKey(actual.terminalControl) !==
6069
- terminalControlSelectorKey(expected.terminalControl) ||
6124
+ !terminalControlAliasMatches(expected.conversationId, expected.terminalControl, actual.conversationId, actual.terminalControl) ||
6070
6125
  !expectedPath ||
6071
6126
  !actualPath ||
6072
6127
  path.resolve(actualPath) !== path.resolve(expectedPath)) {
@@ -6083,8 +6138,7 @@ function assertTerminalNativeInspectionReady({ options, terminal, terminalStatus
6083
6138
  }
6084
6139
  const blocker = listConversations(storeDirFromOptions(options))
6085
6140
  .filter(isDiscoverableTmuxConversation)
6086
- .find((turn) => terminalKeyForManagedConversation(turn) ===
6087
- terminalControlSelectorKey(terminal.terminalControl) &&
6141
+ .find((turn) => terminalControlsShareIncarnation(terminalControlForManagedConversation(turn), terminal.terminalControl) &&
6088
6142
  SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
6089
6143
  if (blocker) {
6090
6144
  throw new Error(`terminal ${terminal.terminalControl.target} still has unresolved Turn ` +
@@ -6148,7 +6202,7 @@ function nativeInspectionRuntime({ terminal, snapshot }) {
6148
6202
  }
6149
6203
  function assertNativeInspectionSnapshotUnchanged({ options, expectedTerminal, actualTerminal, expectedBindingToken, expectedVersion, actualSnapshot, stage, expectedClaudeState = "idle" }) {
6150
6204
  assertSameNativeInspectionTerminal(expectedTerminal, actualTerminal, stage);
6151
- if (actualSnapshot.bindingToken !== expectedBindingToken) {
6205
+ if (!actualSnapshot.bindingTokens.includes(expectedBindingToken)) {
6152
6206
  throw new Error(`terminal binding changed ${stage}; refresh AKK list`);
6153
6207
  }
6154
6208
  if (actualSnapshot.version !== expectedVersion) {
@@ -6203,7 +6257,7 @@ function assertNativeInspectionAgentIdentity({ options, terminal, snapshot, stag
6203
6257
  }
6204
6258
  async function assertNativeInspectionExclusiveOwnership({ options, terminal, snapshot }) {
6205
6259
  // Claude's exact `claude agents` mapping makes global active ownership part
6206
- // of the 2.1.218 inspection profile. Keep the existing Codex #112 path
6260
+ // of the supported exact Claude inspection profiles. Keep the existing Codex #112 path
6207
6261
  // unchanged: an unmanaged Codex pane may be inspected before a rollout
6208
6262
  // identity exists, and its status card is the bounded identity evidence.
6209
6263
  if (terminal.agent !== "claude") {
@@ -6239,12 +6293,13 @@ async function runNativeInspect(options) {
6239
6293
  throw new Error("native inspection requires a compatible AKK Store so binding authority can be verified");
6240
6294
  }
6241
6295
  const initiallyResolved = await resolveLifecycleTerminal(options);
6242
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDir, initiallyResolved.terminalControl), { timeoutMs: 30000 });
6296
+ const bridge = createTerminalAgentBridge(options);
6297
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDir, initiallyResolved.terminalControl, { timeoutMs: 30000 });
6243
6298
  try {
6244
- const terminal = await resolveLifecycleTerminal(options);
6299
+ const terminal = await bridge.resolveStoredTerminal(initiallyResolved.agent, initiallyResolved.pid, initiallyResolved.terminalControl, { pid: initiallyResolved.pid });
6245
6300
  assertSameNativeInspectionTerminal(initiallyResolved, terminal, "while waiting for native-inspection control");
6246
6301
  const snapshot = await currentLifecycleSnapshot(options, terminal);
6247
- if (snapshot.bindingToken !== expectedBindingToken) {
6302
+ if (!snapshot.bindingTokens.includes(expectedBindingToken)) {
6248
6303
  throw new Error("terminal binding changed after it was listed; refresh AKK list and retry");
6249
6304
  }
6250
6305
  const capability = snapshot.adapter.probeNativeInspection?.(snapshot.version);
@@ -6272,7 +6327,6 @@ async function runNativeInspect(options) {
6272
6327
  snapshot
6273
6328
  });
6274
6329
  const runtime = nativeInspectionRuntime({ terminal, snapshot });
6275
- const bridge = createTerminalAgentBridge(options);
6276
6330
  const initialStatus = await bridge.status(terminal.agent, terminal.terminalControl, { runtime });
6277
6331
  assertTerminalNativeInspectionReady({
6278
6332
  options,
@@ -6289,7 +6343,7 @@ async function runNativeInspect(options) {
6289
6343
  submission = await bridge.submitNativeInspection(terminal.agent, terminal.terminalControl, plan, {
6290
6344
  runtime,
6291
6345
  beforeEnter: async () => {
6292
- const finalTerminal = await resolveLifecycleTerminal(options);
6346
+ const finalTerminal = await bridge.resolveStoredTerminal(terminal.agent, terminal.pid, terminal.terminalControl, runtime);
6293
6347
  const finalSnapshot = await currentLifecycleSnapshot(options, finalTerminal);
6294
6348
  assertNativeInspectionSnapshotUnchanged({
6295
6349
  options,
@@ -6388,7 +6442,7 @@ async function runNativeInspect(options) {
6388
6442
  runtime: postEnterRuntime,
6389
6443
  scrollbackLines: 240,
6390
6444
  beforeDismiss: async () => {
6391
- const dismissTerminal = await resolveLifecycleTerminal(options);
6445
+ const dismissTerminal = await bridge.resolveStoredTerminal(terminal.agent, terminal.pid, terminal.terminalControl, postEnterRuntime);
6392
6446
  const dismissSnapshot = await currentLifecycleSnapshot(options, dismissTerminal);
6393
6447
  assertNativeInspectionSnapshotUnchanged({
6394
6448
  options,
@@ -6434,7 +6488,7 @@ async function runNativeInspect(options) {
6434
6488
  throw new Error("Claude Status panel dismissal was dispatched exactly once, but the original idle composer was not restored; do not retry automatically");
6435
6489
  }
6436
6490
  }
6437
- const finalTerminal = await resolveLifecycleTerminal(options);
6491
+ const finalTerminal = await bridge.resolveStoredTerminal(terminal.agent, terminal.pid, terminal.terminalControl, runtime);
6438
6492
  const finalSnapshot = await currentLifecycleSnapshot(options, finalTerminal);
6439
6493
  assertNativeInspectionSnapshotUnchanged({
6440
6494
  options,
@@ -6873,14 +6927,13 @@ async function runReconcileBinding(options) {
6873
6927
  }
6874
6928
  const expectedBindingToken = required(stringValue(options.expectedBindingToken), "--expected-binding-token is required");
6875
6929
  const expectedTerminalToken = required(stringValue(options.expectedTerminalToken), "--expected-terminal-token is required");
6876
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDir, initiallyResolved.terminalControl), { timeoutMs: 30000 });
6930
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDir, initiallyResolved.terminalControl, { timeoutMs: 30000 });
6877
6931
  try {
6878
6932
  return await withStoreWriterLeaseAsync(storeDir, async () => {
6879
6933
  const terminal = await resolveLifecycleTerminal(options);
6880
6934
  if (terminal.pid !== initiallyResolved.pid ||
6881
6935
  terminal.conversationId !== initiallyResolved.conversationId ||
6882
- terminalControlSelectorKey(terminal.terminalControl) !==
6883
- terminalControlSelectorKey(initiallyResolved.terminalControl)) {
6936
+ !terminalControlsShareIncarnation(terminal.terminalControl, initiallyResolved.terminalControl)) {
6884
6937
  throw new Error("terminal identity changed while waiting to reconcile its binding; refresh AKK list");
6885
6938
  }
6886
6939
  await recoverLifecycleFenceBeforeMutation({ options, terminal });
@@ -6890,17 +6943,15 @@ async function runReconcileBinding(options) {
6890
6943
  }
6891
6944
  const session = loadManagedSession(storeDir, conflictingSessionId);
6892
6945
  if (session.revision !== expectedSessionRevision ||
6893
- managedSessionBindingToken(session) !== expectedBindingToken) {
6946
+ !managedSessionBindingTokens(session).includes(expectedBindingToken)) {
6894
6947
  throw new Error("managed Session binding changed after it was listed; refresh AKK list");
6895
6948
  }
6896
6949
  const binding = session.binding;
6897
6950
  if (session.status !== "bound" ||
6898
6951
  !binding ||
6899
6952
  session.agent !== terminal.agent ||
6900
- binding.terminal_id !== terminal.conversationId ||
6901
6953
  binding.native_process.pid !== terminal.pid ||
6902
- terminalControlSelectorKey(binding.terminal_control) !==
6903
- terminalControlSelectorKey(terminal.terminalControl) ||
6954
+ !terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.conversationId, terminal.terminalControl) ||
6904
6955
  !matchesConfiguredWorkspace(session.workspace, terminal.terminalControl.currentPath)) {
6905
6956
  throw new Error("the listed managed Session no longer claims this exact terminal");
6906
6957
  }
@@ -6910,11 +6961,11 @@ async function runReconcileBinding(options) {
6910
6961
  pid: terminal.pid,
6911
6962
  cwd: terminal.terminalControl.currentPath
6912
6963
  });
6913
- const terminalToken = lifecycleBindingToken({
6964
+ const terminalTokens = lifecycleBindingTokens({
6914
6965
  terminal,
6915
6966
  identity
6916
6967
  });
6917
- if (terminalToken !== expectedTerminalToken) {
6968
+ if (!terminalTokens.includes(expectedTerminalToken)) {
6918
6969
  throw new Error("live terminal identity changed after the conflict was listed; refresh AKK list");
6919
6970
  }
6920
6971
  const bridge = createTerminalAgentBridge(options);
@@ -6929,8 +6980,7 @@ async function runReconcileBinding(options) {
6929
6980
  const finalTerminal = await resolveLifecycleTerminal(options);
6930
6981
  if (finalTerminal.pid !== terminal.pid ||
6931
6982
  finalTerminal.conversationId !== terminal.conversationId ||
6932
- terminalControlSelectorKey(finalTerminal.terminalControl) !==
6933
- terminalControlSelectorKey(terminal.terminalControl)) {
6983
+ !terminalControlsShareIncarnation(finalTerminal.terminalControl, terminal.terminalControl)) {
6934
6984
  throw new Error("terminal identity changed during binding reconciliation; refresh AKK list");
6935
6985
  }
6936
6986
  const finalStatus = await bridge.status(finalTerminal.agent, finalTerminal.terminalControl, {
@@ -6950,15 +7000,15 @@ async function runReconcileBinding(options) {
6950
7000
  pid: finalTerminal.pid,
6951
7001
  cwd: finalTerminal.terminalControl.currentPath
6952
7002
  });
6953
- if (lifecycleBindingToken({
7003
+ if (!lifecycleBindingTokens({
6954
7004
  terminal: finalTerminal,
6955
7005
  identity: finalIdentity
6956
- }) !== expectedTerminalToken) {
7006
+ }).includes(expectedTerminalToken)) {
6957
7007
  throw new Error("live terminal identity changed during binding reconciliation; refresh AKK list");
6958
7008
  }
6959
7009
  const finalSession = loadManagedSession(storeDir, conflictingSessionId);
6960
7010
  if (finalSession.revision !== expectedSessionRevision ||
6961
- managedSessionBindingToken(finalSession) !== expectedBindingToken) {
7011
+ !managedSessionBindingTokens(finalSession).includes(expectedBindingToken)) {
6962
7012
  throw new Error("managed Session binding changed during reconciliation; refresh AKK list");
6963
7013
  }
6964
7014
  const conflictKind = managedBindingConflictKindForResolvedTerminal({
@@ -7016,12 +7066,14 @@ async function runReconcileBinding(options) {
7016
7066
  }
7017
7067
  function assertResumeSnapshotMatchesTerminal(snapshot, terminal) {
7018
7068
  const workspace = path.resolve(terminal.terminalControl.currentPath ?? process.cwd());
7019
- if (snapshot.terminal_id !== terminal.conversationId ||
7069
+ const terminalMatches = snapshot.version === 2
7070
+ ? terminalControlEvidenceMatches(snapshot.terminal_endpoint, terminal.terminalControl)
7071
+ : terminalControlEvidenceMatches(snapshot.terminal_control, terminal.terminalControl);
7072
+ if ((snapshot.version === 1 &&
7073
+ snapshot.terminal_id !== terminal.conversationId) ||
7020
7074
  snapshot.agent !== terminal.agent ||
7021
7075
  path.resolve(snapshot.workspace) !== workspace ||
7022
- snapshot.terminal_control.target !== terminal.terminalControl.target ||
7023
- snapshot.terminal_control.socket_path !== terminal.terminalControl.socketPath ||
7024
- snapshot.terminal_control.pane_pid !== terminal.terminalControl.panePid) {
7076
+ !terminalMatches) {
7025
7077
  throw new Error("resume selection terminal, process, or workspace changed; run /akk threads again");
7026
7078
  }
7027
7079
  }
@@ -7048,13 +7100,12 @@ function assertResumeSnapshotNotExpired(snapshot) {
7048
7100
  async function runNativeThreadTransition(options, operation) {
7049
7101
  const initiallyResolved = await resolveLifecycleTerminal(options);
7050
7102
  const storeDir = storeDirFromOptions(options);
7051
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDir, initiallyResolved.terminalControl), { timeoutMs: 30000 });
7103
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDir, initiallyResolved.terminalControl, { timeoutMs: 30000 });
7052
7104
  try {
7053
7105
  return await withStoreWriterLeaseAsync(storeDir, async () => {
7054
7106
  const terminal = await resolveLifecycleTerminal(options);
7055
7107
  if (terminal.pid !== initiallyResolved.pid ||
7056
- terminalControlSelectorKey(terminal.terminalControl) !==
7057
- terminalControlSelectorKey(initiallyResolved.terminalControl)) {
7108
+ !terminalControlsShareIncarnation(terminal.terminalControl, initiallyResolved.terminalControl)) {
7058
7109
  throw new Error("terminal identity changed while waiting for lifecycle control; refresh list");
7059
7110
  }
7060
7111
  if (operation.kind === "resume_thread" && operation.selectionSnapshot) {
@@ -7071,7 +7122,7 @@ async function runNativeThreadTransition(options, operation) {
7071
7122
  }
7072
7123
  const snapshot = await currentLifecycleSnapshot(options, terminal, { materialize: true });
7073
7124
  const expectedToken = required(stringValue(options.expectedBindingToken), "--expected-binding-token is required");
7074
- if (expectedToken !== snapshot.bindingToken) {
7125
+ if (!snapshot.bindingTokens.includes(expectedToken)) {
7075
7126
  throw new Error("terminal binding changed after it was listed; refresh AKK list and retry");
7076
7127
  }
7077
7128
  if (snapshot.capabilities.status !== "supported") {
@@ -7417,16 +7468,27 @@ async function runNativeThreadTransition(options, operation) {
7417
7468
  // Candidate discovery and ownership revalidation can take long
7418
7469
  // enough for a human to start typing after the /status probe. Keep
7419
7470
  // this check immediately adjacent to the terminal mutation.
7420
- await assertCodexComposerReadyForAutomatedInput({
7421
- options,
7422
- terminalControl: terminal.terminalControl
7423
- });
7471
+ try {
7472
+ await assertCodexComposerReadyForAutomatedInput({
7473
+ options,
7474
+ terminalControl: terminal.terminalControl
7475
+ });
7476
+ }
7477
+ catch (error) {
7478
+ throw new TerminalInputNotStartedError(error instanceof Error ? error.message : String(error), { cause: error });
7479
+ }
7424
7480
  }
7425
7481
  if (operation.kind === "resume_thread" && operation.selectionSnapshot) {
7426
7482
  assertResumeSnapshotNotExpired(operation.selectionSnapshot);
7427
7483
  }
7428
- inputStarted = true;
7429
- await bridge.send(terminal.agent, terminal.terminalControl, transitionStep.command, { runtime: beforeRuntime });
7484
+ await bridge.send(terminal.agent, terminal.terminalControl, transitionStep.command, {
7485
+ runtime: beforeRuntime,
7486
+ onTransportStage(event) {
7487
+ if (event.stage === "text_injected") {
7488
+ inputStarted = true;
7489
+ }
7490
+ }
7491
+ });
7430
7492
  const submittedAt = new Date().toISOString();
7431
7493
  transition = {
7432
7494
  ...transition,
@@ -7615,7 +7677,8 @@ async function runNativeThreadTransition(options, operation) {
7615
7677
  });
7616
7678
  return;
7617
7679
  }
7618
- if (!inputStarted) {
7680
+ if (!inputStarted &&
7681
+ error instanceof TerminalInputNotStartedError) {
7619
7682
  transition = {
7620
7683
  ...durableTransition,
7621
7684
  status: "aborted",
@@ -7864,10 +7927,16 @@ function terminalBridgeApprovalFingerprint({ terminalControl, terminalStatus })
7864
7927
  if (adapterFingerprint) {
7865
7928
  return adapterFingerprint;
7866
7929
  }
7930
+ const endpoint = terminalEndpointFromControlRef(terminalControl);
7931
+ const processAnchorPid = Number(endpoint.processAnchorPid);
7932
+ if (!Number.isSafeInteger(processAnchorPid) || processAnchorPid <= 1) {
7933
+ throw new Error("terminal approval fingerprint requires a stable process anchor");
7934
+ }
7867
7935
  const screen = isRecord(terminalStatus?.screen) ? terminalStatus.screen : {};
7868
7936
  return createHash("sha256")
7869
7937
  .update(JSON.stringify({
7870
- target: terminalControl.target,
7938
+ terminal_identity: terminalEndpointIdentityKey(endpoint),
7939
+ process_anchor_pid: processAnchorPid,
7871
7940
  keys: approval.keys ?? (approval.key ? [approval.key] : undefined),
7872
7941
  label: approval.label,
7873
7942
  prompt_kind: approval.prompt_kind,
@@ -7990,10 +8059,7 @@ function recordTerminalBridgeApprovalNotification({ statePath, logPath, terminal
7990
8059
  stringValue(currentNativeTakeover.terminal_bridge_message_id) !==
7991
8060
  expectedConversation.messageId ||
7992
8061
  !currentTerminalControl ||
7993
- currentTerminalControl.kind !== terminalControl.kind ||
7994
- currentTerminalControl.target !== terminalControl.target ||
7995
- currentTerminalControl.socketPath !== terminalControl.socketPath ||
7996
- currentTerminalControl.panePid !== terminalControl.panePid) {
8062
+ !terminalControlsShareIncarnation(currentTerminalControl, terminalControl)) {
7997
8063
  return {
7998
8064
  conversation,
7999
8065
  duplicate: false,
@@ -8301,7 +8367,7 @@ async function runSend(options) {
8301
8367
  throw new Error("raw tmux terminal sends require --background so AKK can persist and monitor the submission safely");
8302
8368
  }
8303
8369
  const rawStoreDir = storeDirFromOptions(options);
8304
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(rawStoreDir, terminalConversation.terminalControl), { timeoutMs: 30000 });
8370
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(rawStoreDir, terminalConversation.terminalControl, { timeoutMs: 30000 });
8305
8371
  try {
8306
8372
  await withStoreWriterLeaseAsync(rawStoreDir, async () => {
8307
8373
  await recoverLifecycleFenceBeforeMutation({
@@ -8519,12 +8585,23 @@ async function runSend(options) {
8519
8585
  if (!rawTerminalId) {
8520
8586
  throw new Error(`managed Session ${sessionId} has no authoritative terminal binding`);
8521
8587
  }
8522
- const resolvedTerminal = await createTerminalAgentBridge(options)
8523
- .resolveConversationId(rawTerminalId);
8588
+ const storedTerminalControl = initialSession?.binding?.terminal_control ??
8589
+ terminalControlFromTakeover(legacyTakeover);
8590
+ const storedAgent = initialSession?.agent ??
8591
+ (legacyBindingTurn
8592
+ ? executorForConversation(legacyBindingTurn).kind
8593
+ : undefined);
8594
+ const storedPid = initialSession?.binding?.native_process.pid ??
8595
+ Number(legacyTakeover?.terminal_agent_pid);
8596
+ const terminalBridge = createTerminalAgentBridge(options);
8597
+ const resolvedTerminal = storedTerminalControl && storedAgent &&
8598
+ Number.isSafeInteger(storedPid) && storedPid > 1
8599
+ ? await terminalBridge.resolveStoredTerminal(storedAgent, storedPid, storedTerminalControl, { pid: storedPid })
8600
+ : await terminalBridge.resolveConversationId(rawTerminalId);
8524
8601
  if (!resolvedTerminal) {
8525
8602
  throw new Error(`session ${sessionId} is not attached to a live tmux terminal`);
8526
8603
  }
8527
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDir, resolvedTerminal.terminalControl), { timeoutMs: 30000 });
8604
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDir, resolvedTerminal.terminalControl, { timeoutMs: 30000 });
8528
8605
  try {
8529
8606
  await withStoreWriterLeaseAsync(storeDir, async () => {
8530
8607
  await recoverLifecycleFenceBeforeMutation({
@@ -8692,18 +8769,19 @@ async function runTurnResponse({ options, messageBody }) {
8692
8769
  if (!storedTerminalControl || !nativeTerminalId) {
8693
8770
  throw new Error(`turn ${turnIdForConversation(initialConversation)} is not attached to a live tmux terminal`);
8694
8771
  }
8695
- const liveTerminal = await createTerminalAgentBridge(options)
8696
- .resolveConversationId(nativeTerminalId);
8772
+ const storedPid = Number(nativeTakeover?.terminal_agent_pid);
8773
+ const storedAgent = executorForConversation(initialConversation).kind;
8774
+ const liveTerminal = Number.isSafeInteger(storedPid) && storedPid > 1
8775
+ ? await createTerminalAgentBridge(options).resolveStoredTerminal(storedAgent, storedPid, storedTerminalControl, terminalRuntimeIdentityForConversation(initialConversation, storedTerminalControl))
8776
+ : undefined;
8697
8777
  if (!liveTerminal ||
8698
8778
  liveTerminal.agent !== executorForConversation(initialConversation).kind ||
8699
- liveTerminal.terminalControl.target !== storedTerminalControl.target ||
8700
- liveTerminal.terminalControl.socketPath !== storedTerminalControl.socketPath ||
8701
- liveTerminal.terminalControl.panePid !== storedTerminalControl.panePid) {
8779
+ !terminalControlsShareIncarnation(liveTerminal.terminalControl, storedTerminalControl)) {
8702
8780
  throw new Error(`turn ${turnIdForConversation(initialConversation)} is not attached to its expected live tmux terminal`);
8703
8781
  }
8704
8782
  const terminalControl = liveTerminal.terminalControl;
8705
8783
  const responseStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
8706
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(responseStoreDir, terminalControl), { timeoutMs: 30000 });
8784
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(responseStoreDir, terminalControl, { timeoutMs: 30000 });
8707
8785
  try {
8708
8786
  await withStoreWriterLeaseAsync(responseStoreDir, async () => {
8709
8787
  // Legacy identity migration may write the Turn, so keep it inside the
@@ -8711,17 +8789,15 @@ async function runTurnResponse({ options, messageBody }) {
8711
8789
  await migrateLegacyTerminalAgentIdentity({ ...loaded, options });
8712
8790
  const releaseStateLock = acquireFileLock(`${statePath}.lock`);
8713
8791
  try {
8714
- const lockedConversation = loadState(statePath);
8715
- const lockedTakeover = isRecord(lockedConversation.native_session_takeover)
8792
+ let lockedConversation = loadState(statePath);
8793
+ let lockedTakeover = isRecord(lockedConversation.native_session_takeover)
8716
8794
  ? lockedConversation.native_session_takeover
8717
8795
  : undefined;
8718
8796
  const lockedControl = terminalControlFromTakeover(lockedTakeover);
8719
8797
  if (stringValue(lockedTakeover?.native_session_id) !== nativeTerminalId ||
8720
8798
  executorForConversation(lockedConversation).kind !== liveTerminal.agent ||
8721
8799
  !lockedControl ||
8722
- lockedControl.target !== terminalControl.target ||
8723
- lockedControl.socketPath !== terminalControl.socketPath ||
8724
- lockedControl.panePid !== terminalControl.panePid) {
8800
+ !terminalControlsShareIncarnation(lockedControl, terminalControl)) {
8725
8801
  throw new Error("terminal control changed while waiting to respond; refresh status and retry");
8726
8802
  }
8727
8803
  if (requestedOpenClawSession &&
@@ -8729,6 +8805,14 @@ async function runTurnResponse({ options, messageBody }) {
8729
8805
  throw new Error(`turn ${turnIdForConversation(lockedConversation)} belongs to a ` +
8730
8806
  "different OpenClaw session; no terminal input was sent");
8731
8807
  }
8808
+ lockedConversation = refineTerminalTurnEndpoint({
8809
+ conversation: lockedConversation,
8810
+ statePath,
8811
+ terminalControl
8812
+ });
8813
+ lockedTakeover = isRecord(lockedConversation.native_session_takeover)
8814
+ ? lockedConversation.native_session_takeover
8815
+ : undefined;
8732
8816
  if (replayExactActiveTerminalSubmission({
8733
8817
  options,
8734
8818
  terminalControl,
@@ -8757,7 +8841,7 @@ async function runTurnResponse({ options, messageBody }) {
8757
8841
  ? prepared.conversation.native_session_takeover
8758
8842
  : undefined;
8759
8843
  const terminalAgentPid = Number(preparedTakeover?.terminal_agent_pid);
8760
- const responseManagedSession = tryLoadManagedSession(responseStoreDir, sessionIdForConversation(prepared.conversation));
8844
+ let responseManagedSession = tryLoadManagedSession(responseStoreDir, sessionIdForConversation(prepared.conversation));
8761
8845
  const responseCodexCompanions = prepared.executor.kind === "codex" && responseManagedSession
8762
8846
  ? codexAllowedCompanionSetForManagedSession({
8763
8847
  storeDir: responseStoreDir,
@@ -8780,11 +8864,22 @@ async function runTurnResponse({ options, messageBody }) {
8780
8864
  currentIdentity: currentNativeIdentity,
8781
8865
  operation: "respond to"
8782
8866
  });
8867
+ if (responseManagedSession) {
8868
+ const logicalResponseIdentity = logicalIdentityForManagedSession({
8869
+ storeDir: responseStoreDir,
8870
+ session: responseManagedSession,
8871
+ observedIdentity: currentNativeIdentity
8872
+ });
8873
+ responseManagedSession = refineManagedSessionNativeIdentity({
8874
+ storeDir: responseStoreDir,
8875
+ session: responseManagedSession,
8876
+ terminalControl,
8877
+ identity: logicalResponseIdentity
8878
+ });
8879
+ }
8783
8880
  const currentTerminalControl = terminalControlFromTakeover(prepared.nativeTakeoverForSend);
8784
8881
  if (!currentTerminalControl ||
8785
- currentTerminalControl.target !== terminalControl.target ||
8786
- currentTerminalControl.socketPath !== terminalControl.socketPath ||
8787
- currentTerminalControl.panePid !== terminalControl.panePid) {
8882
+ !terminalControlsShareIncarnation(currentTerminalControl, terminalControl)) {
8788
8883
  throw new Error("terminal control changed while waiting to respond; refresh status and retry");
8789
8884
  }
8790
8885
  const responseOptions = {
@@ -8988,7 +9083,7 @@ async function runApprove(options) {
8988
9083
  : {})
8989
9084
  };
8990
9085
  };
8991
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
9086
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
8992
9087
  let terminalLockReleased = false;
8993
9088
  const releaseApprovalTerminalLock = () => {
8994
9089
  if (!terminalLockReleased) {
@@ -9021,8 +9116,8 @@ async function runApprove(options) {
9021
9116
  : undefined;
9022
9117
  if (currentConversation.status !== conversation.status ||
9023
9118
  currentTakeover?.terminal_bridge_message_id !== nativeTakeover?.terminal_bridge_message_id ||
9024
- currentControl?.target !== terminalControl.target ||
9025
- currentControl?.socketPath !== terminalControl.socketPath ||
9119
+ !currentControl ||
9120
+ !terminalControlsShareIncarnation(currentControl, terminalControl) ||
9026
9121
  (claudeScreenApproval &&
9027
9122
  currentApproval?.fingerprint !== monitoredApproval?.fingerprint)) {
9028
9123
  throw new Error("approval state changed while waiting for terminal control; refresh status and retry");
@@ -9147,8 +9242,7 @@ async function runApprove(options) {
9147
9242
  latestNotifiedAt === undefined ||
9148
9243
  Date.now() - latestNotifiedAt > CLAUDE_SCREEN_APPROVAL_TTL_MS ||
9149
9244
  expectedFingerprint !== fingerprint ||
9150
- latestControl?.target !== dispatchControl.target ||
9151
- latestControl?.socketPath !== dispatchControl.socketPath ||
9245
+ !terminalControlsShareIncarnation(latestControl, dispatchControl) ||
9152
9246
  (autoApproved &&
9153
9247
  (latestPolicyEvidence?.source !== "claude_transcript" ||
9154
9248
  latestPolicyEvidence.evidence_fingerprint !==
@@ -9342,7 +9436,7 @@ async function runApprove(options) {
9342
9436
  }
9343
9437
  }
9344
9438
  async function runTerminalConversationApprove({ options, conversationId, agent, terminalControl, pid }) {
9345
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
9439
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
9346
9440
  try {
9347
9441
  if (agent === "claude") {
9348
9442
  printJson({
@@ -9407,7 +9501,7 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
9407
9501
  async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, terminalSendLockHeld = false, terminalStateLockHeld = false, storeWriterLeaseHeld = false, recordMessageAfterSend = false, recordRawAttachmentAfterSend = false, onTerminalPreflightVerified = undefined, allowedPreMaterializationIdentity = undefined, allowedAdditionalIdentities = [], continuingTurnResponse = false }) {
9408
9502
  const bridge = terminalBridgeEnabled(conversation);
9409
9503
  if (!terminalSendLockHeld) {
9410
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
9504
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
9411
9505
  try {
9412
9506
  return await runTerminalControlSend({
9413
9507
  options,
@@ -9445,8 +9539,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9445
9539
  currentConversation.updated_at !== nextConversation.updated_at ||
9446
9540
  currentConversation.status !== nextConversation.status ||
9447
9541
  currentConversation.response_rounds_used !== nextConversation.response_rounds_used ||
9448
- currentControl?.target !== terminalControl.target ||
9449
- currentControl?.socketPath !== terminalControl.socketPath) {
9542
+ !terminalControlsShareIncarnation(currentControl, terminalControl)) {
9450
9543
  throw new Error("conversation changed while waiting to send to the terminal; refresh status and retry");
9451
9544
  }
9452
9545
  return await runTerminalControlSend({
@@ -11469,9 +11562,8 @@ function migratedTerminalTurnMatchesSessionBinding({ conversation, takeover, ter
11469
11562
  session.agent !== agent ||
11470
11563
  path.resolve(session.workspace) !== path.resolve(conversation.workspace) ||
11471
11564
  !terminalId ||
11472
- binding.terminal_id !== terminalId ||
11473
- terminalControlSelectorKey(binding.terminal_control) !==
11474
- terminalControlSelectorKey(terminalControl) ||
11565
+ !terminalControlAliasMatches(terminalId, terminalControl, binding.terminal_id, binding.terminal_control) ||
11566
+ !terminalControlsShareIncarnation(binding.terminal_control, terminalControl) ||
11475
11567
  !Number.isSafeInteger(pid) ||
11476
11568
  binding.native_process.pid !== pid ||
11477
11569
  !turnNativeThreadId ||
@@ -11556,24 +11648,28 @@ function nativeThreadTransitionRevision(transition) {
11556
11648
  }
11557
11649
  function refineManagedSessionNativeIdentity({ storeDir, session, terminalControl, identity }) {
11558
11650
  const binding = session.binding;
11559
- if (!identity || session.status !== "bound" || !binding) {
11651
+ if (session.status !== "bound" || !binding) {
11560
11652
  return session;
11561
11653
  }
11562
- if (terminalControlSelectorKey(binding.terminal_control) !==
11563
- terminalControlSelectorKey(terminalControl) ||
11564
- (binding.native_thread_id &&
11654
+ if (!terminalControlsShareIncarnation(binding.terminal_control, terminalControl) ||
11655
+ (identity && binding.native_thread_id &&
11565
11656
  binding.native_thread_id !== identity.sessionId)) {
11566
11657
  throw new Error(`managed Session ${session.session_id} changed native identity before send`);
11567
11658
  }
11568
- const refinedNativeThreadId = binding.native_thread_id ?? identity.sessionId;
11569
- const refinedProcessUuid = binding.native_process.process_uuid ?? identity.processUuid;
11570
- const refinedProcessBirth = binding.native_process.process_birth ?? identity.processBirth;
11571
- const refinedRollout = binding.native_process.rollout ?? identity.rollout;
11659
+ const refinedNativeThreadId = binding.native_thread_id ?? identity?.sessionId;
11660
+ const refinedProcessUuid = binding.native_process.process_uuid ?? identity?.processUuid;
11661
+ const refinedProcessBirth = binding.native_process.process_birth ?? identity?.processBirth;
11662
+ const refinedRollout = binding.native_process.rollout ?? identity?.rollout;
11663
+ const refinedTerminalEndpoint = binding.terminal_endpoint ??
11664
+ (hasCanonicalTerminalEndpoint(terminalControl)
11665
+ ? terminalControlEvidence(terminalControl)
11666
+ : undefined);
11572
11667
  const changed = refinedNativeThreadId !== binding.native_thread_id ||
11573
11668
  refinedProcessUuid !== binding.native_process.process_uuid ||
11574
11669
  refinedProcessBirth !== binding.native_process.process_birth ||
11575
11670
  JSON.stringify(refinedRollout) !==
11576
- JSON.stringify(binding.native_process.rollout);
11671
+ JSON.stringify(binding.native_process.rollout) ||
11672
+ refinedTerminalEndpoint !== binding.terminal_endpoint;
11577
11673
  if (!changed) {
11578
11674
  return session;
11579
11675
  }
@@ -11582,13 +11678,16 @@ function refineManagedSessionNativeIdentity({ storeDir, session, terminalControl
11582
11678
  ...session,
11583
11679
  binding: {
11584
11680
  ...binding,
11681
+ ...(refinedTerminalEndpoint
11682
+ ? { terminal_endpoint: refinedTerminalEndpoint }
11683
+ : {}),
11585
11684
  native_thread_id: refinedNativeThreadId,
11586
11685
  native_process: {
11587
11686
  ...binding.native_process,
11588
11687
  process_uuid: refinedProcessUuid,
11589
11688
  process_birth: refinedProcessBirth,
11590
11689
  rollout: refinedRollout,
11591
- evidence: identity.evidence
11690
+ evidence: identity?.evidence ?? binding.native_process.evidence
11592
11691
  },
11593
11692
  last_verified_at: now
11594
11693
  },
@@ -11611,8 +11710,7 @@ function persistManagedSessionNativeIdentity({ conversation, terminalControl, id
11611
11710
  !current.binding ||
11612
11711
  current.binding.binding_id !== bindingId ||
11613
11712
  current.binding.generation !== bindingGeneration ||
11614
- terminalControlSelectorKey(current.binding.terminal_control) !==
11615
- terminalControlSelectorKey(terminalControl)) {
11713
+ !terminalControlsShareIncarnation(current.binding.terminal_control, terminalControl)) {
11616
11714
  throw new Error(`managed Session ${sessionId} binding changed before native identity commit`);
11617
11715
  }
11618
11716
  const expectedNativeThreadId = current.binding.native_thread_id;
@@ -11760,12 +11858,10 @@ function managedTurnMatchesResolvedTerminal(conversation, terminalConversation,
11760
11858
  currentTerminalIdentity &&
11761
11859
  executorForConversation(conversation).kind === terminalConversation.agent &&
11762
11860
  storedTerminalIdentity.agent === currentTerminalIdentity.agent &&
11763
- storedTerminalIdentity.target === currentTerminalIdentity.target &&
11764
11861
  storedTerminalIdentity.pid === currentTerminalIdentity.pid &&
11765
11862
  Number(takeover?.terminal_agent_pid) === terminalConversation.pid &&
11766
11863
  nativeAgentIdentityMatchesTurn(conversation, currentNativeIdentity) &&
11767
- terminalControlSelectorKey(storedControl) ===
11768
- terminalControlSelectorKey(terminalConversation.terminalControl) &&
11864
+ terminalControlsShareIncarnation(storedControl, terminalConversation.terminalControl) &&
11769
11865
  matchesConfiguredWorkspace(conversation.workspace, terminalConversation.terminalControl.currentPath));
11770
11866
  }
11771
11867
  function createManagedTerminalTurn({ options, conversationId, agent, pid, messageBody, terminalControl, previousTurn, managedSession, nativeAgentIdentity }) {
@@ -11834,7 +11930,7 @@ function createManagedTerminalTurn({ options, conversationId, agent, pid, messag
11834
11930
  takeover_match_kind: previousTurn
11835
11931
  ? "managed_session_send"
11836
11932
  : "raw_terminal_send",
11837
- terminal_control: terminalControl,
11933
+ ...terminalEndpointTakeoverFields(terminalControl),
11838
11934
  needs_bootstrap: false,
11839
11935
  terminal_bridge: true
11840
11936
  }
@@ -11898,10 +11994,11 @@ async function runRenew(options) {
11898
11994
  if (!terminalControl || nativeTakeover?.["terminal_bridge"] !== true) {
11899
11995
  throw new Error(`cannot renew ${conversation.conversation_id}; conversation is not a terminal bridge task`);
11900
11996
  }
11901
- const panes = await createTerminalControlProvider(options).listPanes();
11902
- const terminalExists = panes.some((pane) => pane.target === terminalControl.target &&
11903
- (terminalControl.socketPath === undefined || pane.socketPath === terminalControl.socketPath));
11904
- if (!terminalExists) {
11997
+ const terminalProvider = createTerminalControlProvider(options);
11998
+ try {
11999
+ await terminalProvider.resolve(terminalProvider.endpoint(terminalControl));
12000
+ }
12001
+ catch {
11905
12002
  throw new Error(`cannot renew ${conversation.conversation_id}; terminal ${terminalControl.target} is no longer available`);
11906
12003
  }
11907
12004
  const expectedMessageId = stringValue(nativeTakeover?.terminal_bridge_message_id);
@@ -11924,9 +12021,7 @@ async function runRenew(options) {
11924
12021
  throw new Error(`cannot renew ${current.conversation_id}; conversation is not a terminal bridge task`);
11925
12022
  }
11926
12023
  if (current.conversation_id !== conversation.conversation_id ||
11927
- currentControl.target !== terminalControl.target ||
11928
- currentControl.socketPath !== terminalControl.socketPath ||
11929
- currentControl.panePid !== terminalControl.panePid ||
12024
+ !terminalControlsShareIncarnation(currentControl, terminalControl) ||
11930
12025
  stringValue(currentTakeover.terminal_bridge_message_id) !== expectedMessageId ||
11931
12026
  stringValue(currentTakeover.terminal_bridge_started_at) !== expectedStartedAt) {
11932
12027
  throw new Error("conversation changed while waiting to renew; refresh status and retry");
@@ -12601,7 +12696,7 @@ async function runCancel(options) {
12601
12696
  throw new Error(`conversation ${conversation.conversation_id} is not attached to a live tmux terminal`);
12602
12697
  }
12603
12698
  async function runTerminalConversationCancel({ options, conversationId, agent, terminalControl, pid }) {
12604
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
12699
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
12605
12700
  try {
12606
12701
  const cancellation = await createTerminalAgentBridge(options).cancel(agent, terminalControl, {
12607
12702
  runtime: {
@@ -12639,7 +12734,7 @@ async function runTerminalConversationCancel({ options, conversationId, agent, t
12639
12734
  }
12640
12735
  }
12641
12736
  async function runTerminalControlCancel({ options, statePath, logPath, agent, terminalControl }) {
12642
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
12737
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
12643
12738
  let releaseStateLock;
12644
12739
  try {
12645
12740
  releaseStateLock = acquireFileLock(`${statePath}.lock`);
@@ -12654,8 +12749,7 @@ async function runTerminalControlCancel({ options, statePath, logPath, agent, te
12654
12749
  : undefined;
12655
12750
  const currentControl = terminalControlFromTakeover(currentTakeover);
12656
12751
  if (!currentControl ||
12657
- currentControl.target !== terminalControl.target ||
12658
- currentControl.socketPath !== terminalControl.socketPath) {
12752
+ !terminalControlsShareIncarnation(currentControl, terminalControl)) {
12659
12753
  throw new Error("terminal control changed while waiting to cancel; refresh status and retry");
12660
12754
  }
12661
12755
  assertManagedTerminalDispatchOwner({
@@ -12742,7 +12836,7 @@ async function runClose(options) {
12742
12836
  : undefined;
12743
12837
  const terminalControl = terminalControlFromTakeover(nativeTakeover);
12744
12838
  const releaseTerminalLock = terminalControl
12745
- ? acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 })
12839
+ ? acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 })
12746
12840
  : () => { };
12747
12841
  let releaseStateLock;
12748
12842
  try {
@@ -12816,7 +12910,7 @@ async function runClose(options) {
12816
12910
  async function runTerminalDispatchClose({ options, terminalConversation }) {
12817
12911
  const terminalControl = terminalConversation.terminalControl;
12818
12912
  const storeDir = storeDirFromOptions(options);
12819
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDir, terminalControl), { timeoutMs: 30000 });
12913
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDir, terminalControl, { timeoutMs: 30000 });
12820
12914
  try {
12821
12915
  return await withStoreWriterLeaseAsync(storeDir, async () => {
12822
12916
  let ledger = resolveTerminalDispatchLedgerPaneIncarnation(terminalControl, loadTerminalBridgeDispatchLedger(terminalControl));
@@ -13484,7 +13578,7 @@ async function runTerminalBridgeMonitorWithLock(options, lifecycle, expectedTerm
13484
13578
  });
13485
13579
  return;
13486
13580
  }
13487
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
13581
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
13488
13582
  try {
13489
13583
  const reconciliation = await reconcileTerminalAcceptanceInMonitor({
13490
13584
  options,
@@ -13548,7 +13642,7 @@ async function runTerminalBridgeMonitorWithLock(options, lifecycle, expectedTerm
13548
13642
  sleepSync(pollIntervalMs);
13549
13643
  continue;
13550
13644
  }
13551
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
13645
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
13552
13646
  try {
13553
13647
  const writerStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
13554
13648
  await withStoreWriterLeaseAsync(writerStoreDir, async () => {
@@ -13828,7 +13922,7 @@ async function runTerminalBridgeMonitorWithLock(options, lifecycle, expectedTerm
13828
13922
  let poll;
13829
13923
  const expectedUpdatedAt = conversation.updated_at;
13830
13924
  const expectedStatus = conversation.status;
13831
- const releaseTerminalPollLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
13925
+ const releaseTerminalPollLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
13832
13926
  try {
13833
13927
  let dispatchLedger = reconcilePreparedTerminalDispatchLedger(terminalControl, loadTerminalBridgeDispatchLedger(terminalControl));
13834
13928
  const durableSubmission = terminalBridgeSubmission(conversation);
@@ -13894,8 +13988,7 @@ async function runTerminalBridgeMonitorWithLock(options, lifecycle, expectedTerm
13894
13988
  stringValue(lockedTakeover?.terminal_bridge_message_id) !==
13895
13989
  currentMessageId ||
13896
13990
  !lockedControl ||
13897
- terminalControlSelectorKey(lockedControl) !==
13898
- terminalControlSelectorKey(terminalControl)) {
13991
+ !terminalControlsShareIncarnation(lockedControl, terminalControl)) {
13899
13992
  conversation = lockedConversation;
13900
13993
  continue;
13901
13994
  }
@@ -14579,6 +14672,39 @@ function terminalBridgeSendLockPath(_storeDir, terminalControl) {
14579
14672
  const terminalKey = terminalBridgeRuntimeKey(terminalControl);
14580
14673
  return path.join(lockDir, `terminal-bridge-send-${terminalKey}.lock`);
14581
14674
  }
14675
+ function legacyTerminalBridgeSendLockPath(_storeDir, terminalControl) {
14676
+ const lockDir = terminalBridgeRuntimeLockDir();
14677
+ ensureDir(lockDir);
14678
+ return path.join(lockDir, `terminal-bridge-send-${legacyTerminalBridgeRuntimeKey(terminalControl)}.lock`);
14679
+ }
14680
+ function acquireTerminalBridgeSendLock(storeDir, terminalControl, options = {}) {
14681
+ const lockPaths = [...new Set([
14682
+ terminalBridgeSendLockPath(storeDir, terminalControl),
14683
+ legacyTerminalBridgeSendLockPath(storeDir, terminalControl)
14684
+ ])].sort();
14685
+ const releases = [];
14686
+ try {
14687
+ for (const lockPath of lockPaths) {
14688
+ releases.push(acquireFileLock(lockPath, options));
14689
+ }
14690
+ }
14691
+ catch (error) {
14692
+ for (const release of releases.reverse()) {
14693
+ release();
14694
+ }
14695
+ throw error;
14696
+ }
14697
+ let released = false;
14698
+ return () => {
14699
+ if (released) {
14700
+ return;
14701
+ }
14702
+ released = true;
14703
+ for (const release of [...releases].reverse()) {
14704
+ release();
14705
+ }
14706
+ };
14707
+ }
14582
14708
  function terminalBridgeRuntimeLockDir() {
14583
14709
  return path.join(terminalBridgeRuntimeDir(), "terminal-locks");
14584
14710
  }
@@ -14589,46 +14715,101 @@ function terminalBridgeRuntimeDir() {
14589
14715
  : path.join(path.dirname(defaultStoreDir()), "runtime-v2");
14590
14716
  }
14591
14717
  function terminalBridgeRuntimeKey(terminalControl) {
14718
+ if (hasCanonicalTerminalEndpoint(terminalControl)) {
14719
+ return createHash("sha256")
14720
+ .update(terminalEndpointIdentityKey(terminalControl))
14721
+ .digest("hex")
14722
+ .slice(0, 20);
14723
+ }
14724
+ return legacyTerminalBridgeRuntimeKey(terminalControl);
14725
+ }
14726
+ function legacyTerminalBridgeRuntimeKey(terminalControl) {
14592
14727
  return createHash("sha256")
14593
- .update(JSON.stringify({
14594
- target: terminalControl.target,
14595
- socket_path: terminalControl.socketPath ?? null
14596
- }))
14728
+ .update(JSON.stringify(terminalLegacyRuntimeRoute(terminalControl)))
14597
14729
  .digest("hex")
14598
14730
  .slice(0, 20);
14599
14731
  }
14600
- function terminalBridgeDispatchLedgerPath(terminalControl) {
14732
+ function terminalBridgeDispatchLedgerPath(terminalControl, options = {}) {
14601
14733
  const ledgerDir = path.join(terminalBridgeRuntimeDir(), "terminal-dispatch");
14602
- return path.join(ledgerDir, `terminal-dispatch-${terminalBridgeRuntimeKey(terminalControl)}.json`);
14734
+ return path.join(ledgerDir, `terminal-dispatch-${options.legacy
14735
+ ? legacyTerminalBridgeRuntimeKey(terminalControl)
14736
+ : terminalBridgeRuntimeKey(terminalControl)}.json`);
14737
+ }
14738
+ function terminalBridgeDispatchLedgerPaths(terminalControl) {
14739
+ return [...new Set([
14740
+ terminalBridgeDispatchLedgerPath(terminalControl),
14741
+ terminalBridgeDispatchLedgerPath(terminalControl, { legacy: true })
14742
+ ])];
14603
14743
  }
14604
14744
  function loadTerminalBridgeDispatchLedger(terminalControl) {
14605
- const ledgerPath = terminalBridgeDispatchLedgerPath(terminalControl);
14606
- if (!fs.existsSync(ledgerPath)) {
14745
+ const ledgerPaths = terminalBridgeDispatchLedgerPaths(terminalControl)
14746
+ .filter((candidate) => fs.existsSync(candidate));
14747
+ if (ledgerPaths.length === 0) {
14607
14748
  return undefined;
14608
14749
  }
14750
+ if (ledgerPaths.length > 1) {
14751
+ throw new Error(`terminal dispatch ledger has conflicting canonical and legacy owners: ` +
14752
+ ledgerPaths.join(", "));
14753
+ }
14754
+ const ledgerPath = ledgerPaths[0];
14609
14755
  const stat = fs.lstatSync(ledgerPath);
14610
14756
  if (stat.isSymbolicLink() || !stat.isFile()) {
14611
14757
  throw new Error(`terminal dispatch ledger is not a regular file: ${ledgerPath}`);
14612
14758
  }
14613
14759
  const parsed = JSON.parse(fs.readFileSync(ledgerPath, "utf8"));
14614
14760
  if (!isRecord(parsed) ||
14615
- parsed.version !== 1 ||
14616
- stringValue(parsed.terminal_key) !== terminalBridgeRuntimeKey(terminalControl)) {
14761
+ !(parsed.version === 1 || parsed.version === 2)) {
14617
14762
  throw new Error(`terminal dispatch ledger is invalid: ${ledgerPath}`);
14618
14763
  }
14764
+ if (parsed.version === 1) {
14765
+ const control = isRecord(parsed.terminal_control)
14766
+ ? parsed.terminal_control
14767
+ : undefined;
14768
+ if (stringValue(parsed.terminal_key) !==
14769
+ legacyTerminalBridgeRuntimeKey(terminalControl) ||
14770
+ stringValue(control?.target) !== terminalControl.target ||
14771
+ (stringValue(control?.socket_path) ?? undefined) !==
14772
+ terminalControl.socketPath) {
14773
+ throw new Error(`terminal dispatch ledger is invalid: ${ledgerPath}`);
14774
+ }
14775
+ }
14776
+ else {
14777
+ const identity = terminalEndpointIdentityFromEvidence(parsed.terminal_endpoint);
14778
+ if (!identity ||
14779
+ stringValue(parsed.terminal_key) !==
14780
+ terminalBridgeRuntimeKey(terminalControl) ||
14781
+ !sameTerminalEndpointIdentity(identity, terminalControl)) {
14782
+ throw new Error(`terminal dispatch ledger is invalid: ${ledgerPath}`);
14783
+ }
14784
+ }
14619
14785
  return parsed;
14620
14786
  }
14787
+ function terminalDispatchRecordMatchesControl(record, terminalControl, options = {}) {
14788
+ if (!record) {
14789
+ return false;
14790
+ }
14791
+ const evidence = record.terminal_endpoint !== undefined
14792
+ ? record.terminal_endpoint
14793
+ : record.terminal_control;
14794
+ return terminalControlEvidenceMatches(evidence, terminalControl, options);
14795
+ }
14796
+ function terminalDispatchRecordProcessAnchor(record) {
14797
+ const evidence = isRecord(record.terminal_endpoint)
14798
+ ? record.terminal_endpoint
14799
+ : isRecord(record.terminal_control)
14800
+ ? record.terminal_control
14801
+ : undefined;
14802
+ const panePid = Number(evidence?.process_anchor_pid ?? evidence?.pane_pid ?? evidence?.panePid);
14803
+ return Number.isSafeInteger(panePid) && panePid > 0 ? panePid : undefined;
14804
+ }
14621
14805
  function assertTerminalNativeThreadStoreAuthority({ terminalControl, nativeThreadId, storeDir }) {
14622
14806
  const ledger = resolveTerminalDispatchLedgerPaneIncarnation(terminalControl, loadTerminalBridgeDispatchLedger(terminalControl));
14623
14807
  if (!ledger) {
14624
14808
  return;
14625
14809
  }
14626
- const ledgerControl = isRecord(ledger.terminal_control)
14627
- ? ledger.terminal_control
14628
- : undefined;
14629
- if (stringValue(ledgerControl?.target) !== terminalControl.target ||
14630
- (stringValue(ledgerControl?.socket_path) ?? undefined) !==
14631
- terminalControl.socketPath) {
14810
+ if (!terminalDispatchRecordMatchesControl(ledger, terminalControl, {
14811
+ requireProcessAnchor: false
14812
+ })) {
14632
14813
  throw new Error(`terminal ${terminalControl.target} dispatch ledger selector is invalid`);
14633
14814
  }
14634
14815
  const binding = isRecord(ledger.binding) ? ledger.binding : undefined;
@@ -14672,11 +14853,6 @@ function orphanedTerminalDispatchForRecovery(terminalControl) {
14672
14853
  const recoveryIdentity = lifecycle
14673
14854
  ? stringValue(ledger?.transition_id)
14674
14855
  : stringValue(ledger?.message_id);
14675
- const ledgerControl = isRecord(ledger?.terminal_control)
14676
- ? ledger.terminal_control
14677
- : undefined;
14678
- const ledgerPanePid = Number(ledgerControl?.pane_pid);
14679
- const currentPanePid = Number(terminalControl.panePid);
14680
14856
  if (!ledger ||
14681
14857
  ![
14682
14858
  "prepared",
@@ -14692,11 +14868,10 @@ function orphanedTerminalDispatchForRecovery(terminalControl) {
14692
14868
  .includes(String(ledger.status)) ||
14693
14869
  !recoveryIdentity ||
14694
14870
  (!lifecycle &&
14695
- Number.isSafeInteger(ledgerPanePid) &&
14696
- ledgerPanePid > 0 &&
14697
- Number.isSafeInteger(currentPanePid) &&
14698
- currentPanePid > 0 &&
14699
- ledgerPanePid !== currentPanePid) ||
14871
+ terminalDispatchRecordMatchesControl(ledger, terminalControl, {
14872
+ requireProcessAnchor: false
14873
+ }) &&
14874
+ !terminalDispatchRecordMatchesControl(ledger, terminalControl)) ||
14700
14875
  (!lifecycle && loadTerminalDispatchLedgerOwner(ledger))) {
14701
14876
  return undefined;
14702
14877
  }
@@ -14865,12 +15040,11 @@ function mergeTerminalLedgerReceipt(previous, next) {
14865
15040
  const nextControl = isRecord(next.terminal_control)
14866
15041
  ? next.terminal_control
14867
15042
  : undefined;
14868
- if (previousControl &&
14869
- nextControl &&
14870
- (stringValue(previousControl.target) !== stringValue(nextControl.target) ||
14871
- (stringValue(previousControl.socket_path) ?? undefined) !==
14872
- (stringValue(nextControl.socket_path) ?? undefined) ||
14873
- Number(previousControl.pane_pid) !== Number(nextControl.pane_pid))) {
15043
+ const previousEvidence = previous.terminal_endpoint ?? previousControl;
15044
+ const nextEvidence = next.terminal_endpoint ?? nextControl;
15045
+ if (previousEvidence !== undefined &&
15046
+ nextEvidence !== undefined &&
15047
+ !sameTerminalControlEvidenceIncarnation(previousEvidence, nextEvidence)) {
14874
15048
  throw new Error(`terminal dispatch receipt ${messageId} changed immutable terminal_control`);
14875
15049
  }
14876
15050
  if (previous.status === "agent_accepted" &&
@@ -14899,17 +15073,40 @@ function mergeTerminalLedgerReceipt(previous, next) {
14899
15073
  if (!merged.terminal_control && previousControl) {
14900
15074
  merged.terminal_control = previousControl;
14901
15075
  }
15076
+ if (!merged.terminal_endpoint && previous.terminal_endpoint) {
15077
+ merged.terminal_endpoint = previous.terminal_endpoint;
15078
+ }
14902
15079
  return merged;
14903
15080
  }
14904
15081
  function saveTerminalBridgeDispatchLedger(terminalControl, ledger) {
14905
- const ledgerPath = terminalBridgeDispatchLedgerPath(terminalControl);
15082
+ const previousLedger = loadTerminalBridgeDispatchLedger(terminalControl);
15083
+ const existingPaths = terminalBridgeDispatchLedgerPaths(terminalControl)
15084
+ .filter((candidate) => fs.existsSync(candidate));
15085
+ let ledgerPath = existingPaths[0] ??
15086
+ terminalBridgeDispatchLedgerPath(terminalControl);
15087
+ const canonicalLedgerPath = terminalBridgeDispatchLedgerPath(terminalControl);
15088
+ const legacyLedgerPath = terminalBridgeDispatchLedgerPath(terminalControl, { legacy: true });
15089
+ ensureDir(path.dirname(canonicalLedgerPath));
15090
+ if (hasCanonicalTerminalEndpoint(terminalControl) &&
15091
+ ledgerPath === legacyLedgerPath &&
15092
+ legacyLedgerPath !== canonicalLedgerPath) {
15093
+ // Promote a validated v0.11.x route-keyed ledger before writing the next
15094
+ // generation. rename() removes the duplicate-owner window: after a crash,
15095
+ // the canonical path may still contain v1 JSON, which the compatibility
15096
+ // reader accepts and a later writer can finish upgrading to v2.
15097
+ if (fs.existsSync(canonicalLedgerPath)) {
15098
+ throw new Error(`terminal dispatch ledger has conflicting canonical and legacy owners: ` +
15099
+ `${canonicalLedgerPath}, ${legacyLedgerPath}`);
15100
+ }
15101
+ fs.renameSync(legacyLedgerPath, canonicalLedgerPath);
15102
+ fsyncTerminalBridgeDirectory(path.dirname(canonicalLedgerPath));
15103
+ ledgerPath = canonicalLedgerPath;
15104
+ }
15105
+ const preserveLegacyFormat = !hasCanonicalTerminalEndpoint(terminalControl);
14906
15106
  ensureDir(path.dirname(ledgerPath));
14907
15107
  if (fs.existsSync(ledgerPath) && fs.lstatSync(ledgerPath).isSymbolicLink()) {
14908
15108
  throw new Error(`terminal dispatch ledger is a symlink: ${ledgerPath}`);
14909
15109
  }
14910
- const previousLedger = fs.existsSync(ledgerPath)
14911
- ? loadTerminalBridgeDispatchLedger(terminalControl)
14912
- : undefined;
14913
15110
  let baseReceiptHistory = terminalLedgerReceiptHistory(previousLedger);
14914
15111
  for (const incomingReceipt of terminalLedgerReceiptHistory(ledger)) {
14915
15112
  const incomingId = String(incomingReceipt.message_id);
@@ -14921,18 +15118,25 @@ function saveTerminalBridgeDispatchLedger(terminalControl, ledger) {
14921
15118
  ? baseReceiptHistory.map((receipt) => stringValue(receipt.message_id) === incomingId ? merged : receipt)
14922
15119
  : [...baseReceiptHistory, merged];
14923
15120
  }
14924
- const { terminal_submission_receipts: _incomingReceiptHistory, ...ledgerWithoutReceiptHistory } = ledger;
15121
+ const { terminal_submission_receipts: _incomingReceiptHistory, terminal_endpoint: incomingTerminalEndpoint, ...ledgerWithoutReceiptHistory } = ledger;
14925
15122
  const nextWithoutHistory = {
14926
15123
  ...ledgerWithoutReceiptHistory,
14927
- version: 1,
14928
- terminal_key: terminalBridgeRuntimeKey(terminalControl),
15124
+ version: hasCanonicalTerminalEndpoint(terminalControl) && !preserveLegacyFormat
15125
+ ? 2
15126
+ : 1,
15127
+ terminal_key: preserveLegacyFormat
15128
+ ? legacyTerminalBridgeRuntimeKey(terminalControl)
15129
+ : terminalBridgeRuntimeKey(terminalControl),
14929
15130
  terminal_control: {
14930
15131
  kind: "tmux",
14931
15132
  target: terminalControl.target,
14932
15133
  socket_path: terminalControl.socketPath ?? null,
14933
15134
  pane_pid: terminalControl.panePid ?? null,
14934
15135
  current_path: terminalControl.currentPath ?? null
14935
- }
15136
+ },
15137
+ ...(hasCanonicalTerminalEndpoint(terminalControl) && !preserveLegacyFormat
15138
+ ? { terminal_endpoint: terminalControlEvidence(terminalControl) }
15139
+ : {})
14936
15140
  };
14937
15141
  // The top-level ledger follows the current tmux pane incarnation, while an
14938
15142
  // append-only receipt must retain the pane that actually accepted its
@@ -14940,10 +15144,16 @@ function saveTerminalBridgeDispatchLedger(terminalControl, ledger) {
14940
15144
  // pane with the same selector, so derive that receipt from the caller's
14941
15145
  // original control record instead of silently rebinding its proof.
14942
15146
  const receiptCandidateLedger = isRecord(ledgerWithoutReceiptHistory.terminal_control)
14943
- ? {
14944
- ...nextWithoutHistory,
14945
- terminal_control: ledgerWithoutReceiptHistory.terminal_control
14946
- }
15147
+ ? (() => {
15148
+ const { terminal_endpoint: _derivedTerminalEndpoint, ...nextWithoutDerivedTerminalEndpoint } = nextWithoutHistory;
15149
+ return {
15150
+ ...nextWithoutDerivedTerminalEndpoint,
15151
+ terminal_control: ledgerWithoutReceiptHistory.terminal_control,
15152
+ ...(incomingTerminalEndpoint && !preserveLegacyFormat
15153
+ ? { terminal_endpoint: incomingTerminalEndpoint }
15154
+ : {})
15155
+ };
15156
+ })()
14947
15157
  : nextWithoutHistory;
14948
15158
  const nextCandidate = terminalLedgerReceiptCandidate(receiptCandidateLedger);
14949
15159
  let nextReceiptHistory = baseReceiptHistory;
@@ -15018,23 +15228,20 @@ function resolveTerminalDispatchLedgerPaneIncarnation(terminalControl, ledger) {
15018
15228
  // released by the generic Turn-ledger cleanup path.
15019
15229
  return ledger;
15020
15230
  }
15021
- const ledgerControl = isRecord(ledger.terminal_control)
15022
- ? ledger.terminal_control
15023
- : undefined;
15024
- const ledgerPanePid = Number(ledgerControl?.pane_pid);
15025
- const currentPanePid = Number(terminalControl.panePid);
15026
- if (!Number.isSafeInteger(ledgerPanePid) ||
15027
- ledgerPanePid <= 0 ||
15028
- !Number.isSafeInteger(currentPanePid) ||
15029
- currentPanePid <= 0 ||
15030
- ledgerPanePid === currentPanePid) {
15231
+ if (!terminalDispatchRecordMatchesControl(ledger, terminalControl, {
15232
+ requireProcessAnchor: false
15233
+ }) ||
15234
+ terminalDispatchRecordMatchesControl(ledger, terminalControl)) {
15031
15235
  return ledger;
15032
15236
  }
15237
+ const ledgerProcessAnchor = terminalDispatchRecordProcessAnchor(ledger);
15238
+ const currentProcessAnchor = terminalEndpointFromControlRef(terminalControl).processAnchorPid;
15033
15239
  saveTerminalBridgeDispatchLedger(terminalControl, {
15034
15240
  ...ledger,
15035
15241
  status: "resolved",
15036
15242
  resolved_at: new Date().toISOString(),
15037
- reason: `tmux pane incarnation changed from pid ${ledgerPanePid} to ${currentPanePid}`
15243
+ reason: "terminal process incarnation changed from anchor " +
15244
+ `${ledgerProcessAnchor} to ${currentProcessAnchor ?? "unknown"}`
15038
15245
  });
15039
15246
  return loadTerminalBridgeDispatchLedger(terminalControl);
15040
15247
  }
@@ -15154,8 +15361,7 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
15154
15361
  if (conversation.conversation_id !==
15155
15362
  stringValue(ledger.conversation_id) ||
15156
15363
  !storedControl ||
15157
- terminalBridgeRuntimeKey(storedControl) !==
15158
- terminalBridgeRuntimeKey(terminalControl)) {
15364
+ !sameTerminalControlIncarnation(storedControl, terminalControl)) {
15159
15365
  return ledger;
15160
15366
  }
15161
15367
  if (storedMessageId === messageId &&
@@ -15280,8 +15486,7 @@ function reconcileLaggingTerminalDispatchLedger(terminalControl, ledger) {
15280
15486
  stringValue(nativeTakeover?.terminal_bridge_message_id) !== messageId ||
15281
15487
  stringValue(submission?.message_id) !== messageId ||
15282
15488
  !storedControl ||
15283
- terminalBridgeRuntimeKey(storedControl) !==
15284
- terminalBridgeRuntimeKey(terminalControl)) {
15489
+ !sameTerminalControlIncarnation(storedControl, terminalControl)) {
15285
15490
  return ledger;
15286
15491
  }
15287
15492
  const requestText = String(nativeTakeover?.terminal_bridge_request_text ?? conversation.user_request ?? "");
@@ -15709,16 +15914,13 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
15709
15914
  stringValue(ledger.adapter_version) !== transition.adapter_version ||
15710
15915
  stringValue(ledger.command_fingerprint) !== transition.command_fingerprint ||
15711
15916
  path.resolve(storeDir) !== path.resolve(storeDirFromOptions(options)) ||
15712
- terminal.conversationId !== transition.terminal_id ||
15713
15917
  terminal.agent !== transition.agent ||
15714
- Number(ledgerControl?.pane_pid) !== terminal.terminalControl.panePid ||
15715
- stringValue(ledgerControl?.target) !== terminal.terminalControl.target ||
15716
- (stringValue(ledgerControl?.socket_path) ?? undefined) !==
15717
- terminal.terminalControl.socketPath ||
15918
+ !terminalDispatchRecordMatchesControl(ledger, terminal.terminalControl) ||
15718
15919
  terminal.pid !== transition.before_binding?.native_process.pid &&
15719
15920
  transition.before_binding !== undefined ||
15720
- terminalControlSelectorKey(terminal.terminalControl) !==
15721
- terminalControlSelectorKey(transition.before_binding?.terminal_control ?? terminal.terminalControl) ||
15921
+ !terminalControlsShareIncarnation(terminal.terminalControl, transition.before_binding?.terminal_control ?? terminal.terminalControl) ||
15922
+ (transition.before_binding !== undefined &&
15923
+ !terminalControlAliasMatches(transition.terminal_id, transition.before_binding.terminal_control, terminal.conversationId, terminal.terminalControl)) ||
15722
15924
  !matchesConfiguredWorkspace(transition.workspace, terminal.terminalControl.currentPath) ||
15723
15925
  JSON.stringify(ledger.binding ?? null) !==
15724
15926
  JSON.stringify(transition.before_binding ?? null)) {
@@ -15748,10 +15950,8 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
15748
15950
  const binding = transition.after_binding;
15749
15951
  if (!binding ||
15750
15952
  !binding.native_thread_id ||
15751
- binding.terminal_id !== terminal.conversationId ||
15752
15953
  binding.native_process.pid !== terminal.pid ||
15753
- terminalControlSelectorKey(binding.terminal_control) !==
15754
- terminalControlSelectorKey(terminal.terminalControl)) {
15954
+ !terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.conversationId, terminal.terminalControl)) {
15755
15955
  throw new Error("verified after_binding no longer matches the terminal or pid");
15756
15956
  }
15757
15957
  const bridge = createTerminalAgentBridge(options);
@@ -16255,10 +16455,9 @@ async function assertNativeInspectionComposerReadyForAutomatedInput({ options, t
16255
16455
  });
16256
16456
  return;
16257
16457
  }
16258
- const screen = await createTerminalControlProvider(options).capture(terminal.terminalControl.target, {
16259
- scrollbackLines: 40,
16260
- socketPath: terminal.terminalControl.socketPath
16261
- });
16458
+ const provider = createTerminalControlProvider(options);
16459
+ const resolvedTerminal = await provider.resolve(provider.endpoint(terminal.terminalControl));
16460
+ const screen = await provider.capture(resolvedTerminal, { scrollbackLines: 40 });
16262
16461
  if (!isExactClaudeNativeInspectionIdleComposer(screen)) {
16263
16462
  throw new Error("Claude composer contains input or is not at the exact idle frame; refusing automated terminal input");
16264
16463
  }
@@ -16677,7 +16876,7 @@ function prepareTerminalBridgeCompletionCallback({ options, statePath, logPath,
16677
16876
  });
16678
16877
  claim.release();
16679
16878
  claimReleased = true;
16680
- const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
16879
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
16681
16880
  try {
16682
16881
  resolveTerminalBridgeDispatchLedger({
16683
16882
  terminalControl,