@scotthuang/agent-knock-knock 0.12.2 → 0.12.4

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.
@@ -20,13 +20,13 @@ import { createManagedSessionId, createNativeThreadTransitionId, humanObservedHa
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 { listNativeThreadTransitions, listManagedSessions, loadManagedSession, loadNativeThreadTransition, nativeThreadTransitionsDir, saveManagedSession, saveNativeThreadTransition, tryLoadManagedSession } from "./session-store.js";
23
- import { createTerminalControlProviderRegistry as createProviderRegistry, StaticTerminalControlProvider, TmuxTerminalControlProvider } from "./terminal-control-provider.js";
23
+ import { createTerminalControlProviderRegistry as createProviderRegistry, StaticTerminalControlProvider, TerminalControlUnavailableError, TmuxTerminalControlProvider } from "./terminal-control-provider.js";
24
24
  import { HerdrTerminalControlProvider } from "./herdr-terminal-control-provider.js";
25
25
  import { parseTerminalConversationId } from "./terminal-agent-adapter.js";
26
26
  import { associateTerminalEndpointEvidence, hasCanonicalTerminalEndpoint, sameTerminalControlEvidenceIncarnation, sameTerminalControlIncarnation, terminalControlEvidence, terminalControlEvidenceMatches, terminalEndpointFromControlRef, terminalEndpointIdentityFromEvidence, terminalEndpointIdentityKey, terminalLegacyRuntimeRoute, sameTerminalEndpointIdentity } from "./terminal-control-ref.js";
27
27
  import { createProductionTerminalAgentRegistry } from "./terminal-agent-registry.js";
28
28
  import { parseProcessElapsedSeconds, StaticTerminalProcessSource, SystemTerminalProcessSource } from "./terminal-process-source.js";
29
- import { isExactClaudeNativeInspectionIdleComposer, NativeInspectionSubmissionError, TerminalAgentBridge, TerminalInputNotStartedError } from "./terminal-agent-bridge.js";
29
+ import { isExactClaudeIdleComposer, isExactClaudeNativeInspectionIdleComposer, NativeInspectionSubmissionError, TerminalAgentBridge, TerminalInputNotStartedError } from "./terminal-agent-bridge.js";
30
30
  import { evaluateApprovalPolicy } from "./approval-policy.js";
31
31
  import { evaluateDoctorCapabilities, runDoctorCapabilityProbes } from "./doctor-capabilities.js";
32
32
  import { runOpenClawChainDiagnostics } from "./openclaw-doctor.js";
@@ -96,6 +96,7 @@ const SESSION_SEND_BLOCKING_STATUSES = new Set([
96
96
  "callback_failed",
97
97
  "cancelling"
98
98
  ]);
99
+ const TERMINAL_BRIDGE_UNCERTAIN_COLLATERAL_STALL_REASON = "a newer terminal submission has an uncertain outcome; inspect the shared terminal pane before continuing";
99
100
  const TERMINAL_BRIDGE_MONITOR_LOCK_VERSION = 1;
100
101
  const MINIMUM_NODE_VERSION = "22.19.0";
101
102
  const PRIVATE_LOCK_FILE_MODE = 0o600;
@@ -2627,7 +2628,7 @@ function stallOtherTerminalBridgeConversationsForUncertainDispatch({ storeDir, t
2627
2628
  const stalledConversationIds = [];
2628
2629
  for (const listed of listConversations(storeDir)) {
2629
2630
  if (listed.conversation_id === currentConversationId ||
2630
- !isActiveStatus(listed.status)) {
2631
+ !SESSION_SEND_BLOCKING_STATUSES.has(listed.status)) {
2631
2632
  continue;
2632
2633
  }
2633
2634
  const listedTakeover = isRecord(listed.native_session_takeover)
@@ -2647,7 +2648,7 @@ function stallOtherTerminalBridgeConversationsForUncertainDispatch({ storeDir, t
2647
2648
  const currentTakeover = isRecord(current.native_session_takeover)
2648
2649
  ? current.native_session_takeover
2649
2650
  : undefined;
2650
- if (!isActiveStatus(current.status) ||
2651
+ if (!SESSION_SEND_BLOCKING_STATUSES.has(current.status) ||
2651
2652
  currentTakeover?.terminal_bridge !== true ||
2652
2653
  !terminalControlsShareIncarnation(terminalControlFromTakeover(currentTakeover), terminalControl)) {
2653
2654
  continue;
@@ -2657,12 +2658,13 @@ function stallOtherTerminalBridgeConversationsForUncertainDispatch({ storeDir, t
2657
2658
  ...current,
2658
2659
  status: "stalled",
2659
2660
  stalled_at: stalledAt,
2660
- stalled_reason: "a newer terminal submission has an uncertain outcome; inspect the shared terminal pane before continuing",
2661
+ stalled_reason: TERMINAL_BRIDGE_UNCERTAIN_COLLATERAL_STALL_REASON,
2661
2662
  native_session_takeover: {
2662
2663
  ...currentTakeover,
2663
2664
  terminal_bridge_uncertain_dispatch_fence: {
2664
2665
  message_id: uncertainMessageId,
2665
- observed_at: stalledAt
2666
+ observed_at: stalledAt,
2667
+ previous_status: current.status
2666
2668
  }
2667
2669
  },
2668
2670
  updated_at: stalledAt
@@ -2688,6 +2690,303 @@ function stallOtherTerminalBridgeConversationsForUncertainDispatch({ storeDir, t
2688
2690
  }
2689
2691
  return stalledConversationIds;
2690
2692
  }
2693
+ function exactTerminalBridgeCollateralStallRepairEvidence({ conversation, storeDir }) {
2694
+ if (conversation.status !== "stalled" ||
2695
+ conversation.stalled_reason !==
2696
+ TERMINAL_BRIDGE_UNCERTAIN_COLLATERAL_STALL_REASON) {
2697
+ return undefined;
2698
+ }
2699
+ const takeover = isRecord(conversation.native_session_takeover)
2700
+ ? conversation.native_session_takeover
2701
+ : undefined;
2702
+ const fence = isRecord(takeover?.terminal_bridge_uncertain_dispatch_fence)
2703
+ ? takeover.terminal_bridge_uncertain_dispatch_fence
2704
+ : undefined;
2705
+ const uncertainMessageId = stringValue(fence?.message_id);
2706
+ const fenceObservedAt = stringValue(fence?.observed_at);
2707
+ const previousStatus = stringValue(fence?.previous_status);
2708
+ const ownMessageId = stringValue(takeover?.terminal_bridge_message_id);
2709
+ const ownSubmission = terminalBridgeSubmission(conversation);
2710
+ const completionClaim = isRecord(takeover?.terminal_bridge_completion_claim)
2711
+ ? takeover.terminal_bridge_completion_claim
2712
+ : undefined;
2713
+ const callbackDelivery = isRecord(conversation.callback_delivery)
2714
+ ? conversation.callback_delivery
2715
+ : undefined;
2716
+ const callbackMessage = isRecord(callbackDelivery?.message)
2717
+ ? callbackDelivery.message
2718
+ : undefined;
2719
+ const idleSince = stringValue(conversation.idle_since);
2720
+ const deliveredAt = stringValue(callbackDelivery?.delivered_at);
2721
+ const claimedAt = stringValue(completionClaim?.claimed_at);
2722
+ const fenceAtMs = validTimestampMs(fenceObservedAt);
2723
+ const idleAtMs = validTimestampMs(idleSince);
2724
+ const deliveredAtMs = validTimestampMs(deliveredAt);
2725
+ const claimedAtMs = validTimestampMs(claimedAt);
2726
+ if (takeover?.terminal_bridge !== true ||
2727
+ !uncertainMessageId ||
2728
+ !fenceObservedAt ||
2729
+ fenceAtMs === undefined ||
2730
+ uncertainMessageId === ownMessageId ||
2731
+ (previousStatus !== undefined && previousStatus !== "idle") ||
2732
+ conversation.stalled_at !== fenceObservedAt ||
2733
+ conversation.updated_at !== fenceObservedAt ||
2734
+ !idleSince ||
2735
+ idleAtMs === undefined ||
2736
+ idleAtMs > fenceAtMs ||
2737
+ !ownMessageId ||
2738
+ ownSubmission?.status !== "agent_accepted" ||
2739
+ stringValue(ownSubmission.message_id) !== ownMessageId ||
2740
+ stringValue(ownSubmission.session_id) !==
2741
+ sessionIdForConversation(conversation) ||
2742
+ stringValue(ownSubmission.turn_id) !== turnIdForConversation(conversation) ||
2743
+ !completionClaim ||
2744
+ stringValue(completionClaim.terminal_bridge_message_id) !== ownMessageId ||
2745
+ completionClaim.outcome !== "success" ||
2746
+ !claimedAt ||
2747
+ claimedAtMs === undefined ||
2748
+ claimedAtMs > fenceAtMs ||
2749
+ callbackDelivery?.status !== "delivered" ||
2750
+ callbackDelivery.final_status !== "idle" ||
2751
+ callbackDelivery.preserve_conversation_status !== true ||
2752
+ !callbackMessage ||
2753
+ callbackMessage.type !== "done" ||
2754
+ callbackMessage.requires_response !== false ||
2755
+ stringValue(callbackMessage.id) !==
2756
+ stringValue(completionClaim.callback_message_id) ||
2757
+ stringValue(callbackMessage.conversation_id) !==
2758
+ conversation.conversation_id ||
2759
+ stringValue(callbackMessage.session_id) !==
2760
+ sessionIdForConversation(conversation) ||
2761
+ stringValue(callbackMessage.turn_id) !== turnIdForConversation(conversation) ||
2762
+ stringValue(isRecord(callbackMessage.metadata)
2763
+ ? callbackMessage.metadata.terminal_bridge_message_id
2764
+ : undefined) !== ownMessageId ||
2765
+ !deliveredAt ||
2766
+ deliveredAtMs === undefined ||
2767
+ deliveredAtMs > fenceAtMs) {
2768
+ return undefined;
2769
+ }
2770
+ const control = terminalControlForManagedConversation(conversation);
2771
+ if (!control) {
2772
+ return undefined;
2773
+ }
2774
+ // The target state lock is already held by the caller. Rescan owner state
2775
+ // now instead of trusting the unlocked discovery snapshot used to find the
2776
+ // target candidate.
2777
+ const ownerCandidates = listConversations(storeDir).filter((candidate) => {
2778
+ if (candidate.conversation_id === conversation.conversation_id) {
2779
+ return false;
2780
+ }
2781
+ const candidateTakeover = isRecord(candidate.native_session_takeover)
2782
+ ? candidate.native_session_takeover
2783
+ : undefined;
2784
+ const candidateSubmission = terminalBridgeSubmission(candidate);
2785
+ return candidateTakeover?.terminal_bridge === true &&
2786
+ stringValue(candidateTakeover.terminal_bridge_message_id) ===
2787
+ uncertainMessageId &&
2788
+ stringValue(candidateSubmission?.message_id) === uncertainMessageId &&
2789
+ terminalControlsShareIncarnation(terminalControlForManagedConversation(candidate), control);
2790
+ });
2791
+ if (ownerCandidates.length !== 1) {
2792
+ return undefined;
2793
+ }
2794
+ const ownerListed = ownerCandidates[0];
2795
+ const ownerStatePath = stringValue(ownerListed.state_path);
2796
+ if (!ownerStatePath) {
2797
+ return undefined;
2798
+ }
2799
+ let owner;
2800
+ let ownerEvents;
2801
+ let events;
2802
+ try {
2803
+ owner = loadState(ownerStatePath);
2804
+ ownerEvents = readNdjsonLog(stringValue(owner.event_log_path) ?? logPathForStatePath(ownerStatePath));
2805
+ const statePath = stringValue(conversation.state_path);
2806
+ if (!statePath) {
2807
+ return undefined;
2808
+ }
2809
+ events = readNdjsonLog(stringValue(conversation.event_log_path) ??
2810
+ logPathForStatePath(statePath));
2811
+ }
2812
+ catch {
2813
+ return undefined;
2814
+ }
2815
+ const ownerTakeover = isRecord(owner.native_session_takeover)
2816
+ ? owner.native_session_takeover
2817
+ : undefined;
2818
+ const ownerSubmission = terminalBridgeSubmission(owner);
2819
+ const ownerClosedAt = stringValue(owner.closed_at);
2820
+ const ownerClosedAtMs = validTimestampMs(ownerClosedAt);
2821
+ if (owner.conversation_id !== ownerListed.conversation_id ||
2822
+ owner.status !== "closed" ||
2823
+ !ownerClosedAt ||
2824
+ ownerClosedAtMs === undefined ||
2825
+ ownerClosedAtMs < fenceAtMs ||
2826
+ !stringValue(owner.close_reason) ||
2827
+ owner.updated_at !== ownerClosedAt ||
2828
+ ownerTakeover?.terminal_bridge !== true ||
2829
+ stringValue(ownerTakeover.terminal_bridge_message_id) !==
2830
+ uncertainMessageId ||
2831
+ ownerSubmission?.status !== "uncertain" ||
2832
+ stringValue(ownerSubmission.message_id) !== uncertainMessageId ||
2833
+ stringValue(ownerSubmission.session_id) !== sessionIdForConversation(owner) ||
2834
+ stringValue(ownerSubmission.turn_id) !== turnIdForConversation(owner) ||
2835
+ isRecord(ownerTakeover.terminal_bridge_uncertain_dispatch_fence) ||
2836
+ !terminalControlsShareIncarnation(terminalControlForManagedConversation(owner), control)) {
2837
+ return undefined;
2838
+ }
2839
+ const callbackMessageId = stringValue(callbackMessage.id);
2840
+ const hasCompletionClaim = events.some((event) => event.event === "terminal_bridge_completion_claimed" &&
2841
+ event.conversation_id === conversation.conversation_id &&
2842
+ event.terminal_bridge_message_id === ownMessageId &&
2843
+ event.callback_message_id === callbackMessageId &&
2844
+ event.completion_fingerprint === completionClaim.completion_fingerprint &&
2845
+ event.completion_id === completionClaim.completion_id &&
2846
+ event.outcome === "success" &&
2847
+ event.ts === claimedAt);
2848
+ const hasCompletionDetected = events.some((event) => event.event === "terminal_bridge_completion_detected" &&
2849
+ event.conversation_id === conversation.conversation_id &&
2850
+ event.terminal_bridge_message_id === ownMessageId &&
2851
+ event.callback_message_id === callbackMessageId &&
2852
+ event.completion_id === completionClaim.completion_id &&
2853
+ event.completion_outcome === "success" &&
2854
+ validTimestampMs(event.ts) !== undefined &&
2855
+ validTimestampMs(event.ts) <= fenceAtMs);
2856
+ const hasDeliveredCallback = events.some((event) => event.event === "callback_delivery_succeeded" &&
2857
+ event.conversation_id === conversation.conversation_id &&
2858
+ event.message_id === callbackMessageId &&
2859
+ event.status === "idle" &&
2860
+ event.ts === deliveredAt);
2861
+ const hasExactFence = events.some((event) => event.event === "terminal_bridge_stalled_by_uncertain_dispatch" &&
2862
+ event.conversation_id === conversation.conversation_id &&
2863
+ event.uncertain_message_id === uncertainMessageId &&
2864
+ event.ts === fenceObservedAt);
2865
+ const hasExactOwnerClose = ownerEvents.some((event) => event.event === "conversation_closed" &&
2866
+ event.conversation_id === owner.conversation_id &&
2867
+ event.status === "closed" &&
2868
+ event.ts === ownerClosedAt &&
2869
+ event.reason === owner.close_reason);
2870
+ if (!hasCompletionClaim ||
2871
+ !hasCompletionDetected ||
2872
+ !hasDeliveredCallback ||
2873
+ !hasExactFence ||
2874
+ !hasExactOwnerClose) {
2875
+ return undefined;
2876
+ }
2877
+ return {
2878
+ uncertainMessageId,
2879
+ ownerConversationId: owner.conversation_id,
2880
+ restoredStatus: "idle"
2881
+ };
2882
+ }
2883
+ function reconcileTerminalBridgeCollateralStalls(storeDir, conversationId) {
2884
+ const candidates = listConversations(storeDir).filter((conversation) => {
2885
+ const takeover = isRecord(conversation.native_session_takeover)
2886
+ ? conversation.native_session_takeover
2887
+ : undefined;
2888
+ return (conversationId === undefined ||
2889
+ conversation.conversation_id === conversationId) &&
2890
+ conversation.status === "stalled" &&
2891
+ isRecord(takeover?.terminal_bridge_uncertain_dispatch_fence);
2892
+ });
2893
+ let repaired = 0;
2894
+ let skipped = 0;
2895
+ const errors = [];
2896
+ const items = [];
2897
+ for (const listed of candidates) {
2898
+ const statePath = stringValue(listed.state_path);
2899
+ if (!statePath) {
2900
+ skipped += 1;
2901
+ continue;
2902
+ }
2903
+ let releaseStateLock;
2904
+ try {
2905
+ releaseStateLock = acquireFileLock(`${statePath}.lock`);
2906
+ const current = loadState(statePath);
2907
+ const evidence = exactTerminalBridgeCollateralStallRepairEvidence({
2908
+ conversation: current,
2909
+ storeDir
2910
+ });
2911
+ if (!evidence) {
2912
+ skipped += 1;
2913
+ continue;
2914
+ }
2915
+ const takeover = {
2916
+ ...current.native_session_takeover
2917
+ };
2918
+ delete takeover.terminal_bridge_uncertain_dispatch_fence;
2919
+ const repairedAt = cliNow().toISOString();
2920
+ takeover.terminal_bridge_collateral_stall_repair = {
2921
+ repaired_at: repairedAt,
2922
+ uncertain_message_id: evidence.uncertainMessageId,
2923
+ uncertain_owner_conversation_id: evidence.ownerConversationId,
2924
+ restored_status: evidence.restoredStatus,
2925
+ evidence: "foreign_uncertain_fence+completion_claim+delivered_callback+closed_owner"
2926
+ };
2927
+ const repairedConversation = {
2928
+ ...current,
2929
+ status: evidence.restoredStatus,
2930
+ native_session_takeover: takeover,
2931
+ updated_at: repairedAt
2932
+ };
2933
+ delete repairedConversation.stalled_at;
2934
+ delete repairedConversation.stalled_reason;
2935
+ saveState(statePath, repairedConversation);
2936
+ let eventWarning;
2937
+ try {
2938
+ appendEvent(stringValue(current.event_log_path) ?? logPathForStatePath(statePath), {
2939
+ ts: repairedAt,
2940
+ conversation_id: current.conversation_id,
2941
+ event: "terminal_bridge_collateral_stall_repaired",
2942
+ uncertain_message_id: evidence.uncertainMessageId,
2943
+ uncertain_owner_conversation_id: evidence.ownerConversationId,
2944
+ previous_status: "stalled",
2945
+ restored_status: evidence.restoredStatus,
2946
+ evidence: "foreign_uncertain_fence+completion_claim+delivered_callback+closed_owner"
2947
+ });
2948
+ }
2949
+ catch (error) {
2950
+ eventWarning = error instanceof Error ? error.message : String(error);
2951
+ runtimeLog("warn", "terminal_bridge_collateral_stall_repair_event_failed", {
2952
+ conversation_id: current.conversation_id,
2953
+ uncertain_message_id: evidence.uncertainMessageId,
2954
+ error: eventWarning
2955
+ });
2956
+ }
2957
+ repaired += 1;
2958
+ items.push({
2959
+ conversation_id: current.conversation_id,
2960
+ status: "repaired",
2961
+ reason: "legacy_terminal_bridge_collateral_stall",
2962
+ uncertain_message_id: evidence.uncertainMessageId,
2963
+ uncertain_owner_conversation_id: evidence.ownerConversationId,
2964
+ restored_status: evidence.restoredStatus,
2965
+ ...(eventWarning ? { event_warning: eventWarning } : {})
2966
+ });
2967
+ }
2968
+ catch (error) {
2969
+ skipped += 1;
2970
+ const reason = error instanceof Error ? error.message : String(error);
2971
+ errors.push(`${listed.conversation_id}: ${reason}`);
2972
+ items.push({
2973
+ conversation_id: listed.conversation_id,
2974
+ status: "error",
2975
+ reason
2976
+ });
2977
+ }
2978
+ finally {
2979
+ releaseStateLock?.();
2980
+ }
2981
+ }
2982
+ return {
2983
+ checked: candidates.length,
2984
+ repaired,
2985
+ skipped,
2986
+ errors,
2987
+ items
2988
+ };
2989
+ }
2691
2990
  function environmentWithoutGatewayTokens() {
2692
2991
  const environment = { ...cliEnv() };
2693
2992
  delete environment.AKK_GATEWAY_TOKEN;
@@ -2768,17 +3067,21 @@ async function reconcileStoreForList(storeDir, options) {
2768
3067
  }
2769
3068
  throw error;
2770
3069
  }
2771
- const idle = reconcileIdleConversations(storeDir, options);
2772
3070
  const monitors = await reconcileMonitors(options, {
2773
3071
  includeCallbackRecovery: false,
2774
3072
  reason: "list_reconciliation",
2775
3073
  conversationId: undefined
2776
3074
  });
3075
+ const idle = reconcileIdleConversations(storeDir, options);
2777
3076
  return {
2778
3077
  status: "completed",
2779
3078
  checked: Math.max(idle.checked, monitors.checked),
2780
- changed: idle.closed + monitors.launched,
3079
+ changed: idle.closed + monitors.launched + monitors.repaired,
2781
3080
  closed: idle.closed,
3081
+ repaired: monitors.repaired,
3082
+ collateral_stalls_checked: monitors.collateral_stalls_checked,
3083
+ collateral_stalls_skipped: monitors.collateral_stalls_skipped,
3084
+ collateral_stall_repairs: monitors.items.filter((item) => item.status === "repaired"),
2782
3085
  monitors_launched: monitors.launched,
2783
3086
  monitors_already_running: monitors.already_running,
2784
3087
  skipped: idle.skipped + monitors.skipped,
@@ -2916,24 +3219,24 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
2916
3219
  });
2917
3220
  }
2918
3221
  }
2919
- let nativeAgentIdentity;
2920
- try {
2921
- nativeAgentIdentity = await resolveCurrentNativeAgentSessionIdentity({
2922
- options,
2923
- agent: session.agent,
2924
- pid: session.pid,
2925
- cwd: session.cwd ?? terminalControl.currentPath,
2926
- preferredSessionId: codexIdentityContext?.preferredSessionId,
2927
- allowedCompanionIdentity: codexIdentityContext?.companions.primary,
2928
- allowedAdditionalIdentities: codexIdentityContext?.companions.additional
2929
- });
2930
- }
2931
- catch (error) {
3222
+ const nativeIdentityObservation = await observeCurrentNativeAgentSessionIdentity({
3223
+ options,
3224
+ agent: session.agent,
3225
+ pid: session.pid,
3226
+ cwd: session.cwd ?? terminalControl.currentPath,
3227
+ preferredSessionId: codexIdentityContext?.preferredSessionId,
3228
+ allowedCompanionIdentity: codexIdentityContext?.companions.primary,
3229
+ allowedAdditionalIdentities: codexIdentityContext?.companions.additional
3230
+ });
3231
+ const nativeAgentIdentity = nativeIdentityObservation.status === "resolved"
3232
+ ? nativeIdentityObservation.identity
3233
+ : undefined;
3234
+ if (nativeIdentityObservation.status === "unavailable") {
2932
3235
  runtimeLog("warn", "terminal_native_session_identity_unavailable", {
2933
3236
  agent: session.agent,
2934
3237
  terminal_target: terminalControl.target,
2935
3238
  pid: session.pid,
2936
- error: error instanceof Error ? error.message : String(error)
3239
+ error: nativeIdentityObservation.reason
2937
3240
  });
2938
3241
  }
2939
3242
  let nativeProcessUuid = nativeAgentIdentity?.processUuid;
@@ -2999,27 +3302,25 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
2999
3302
  });
3000
3303
  const codexLifecycleIncarnationAvailable = session.agent !== "codex" ||
3001
3304
  Boolean(nativeProcessUuid && nativeProcessBirth);
3002
- const nativeInspectionHasBlockingTurn = listConversations(storeDirFromOptions(options)).some((turn) => isDiscoverableTmuxConversation(turn) &&
3003
- terminalControlsShareIncarnation(terminalControlForManagedConversation(turn), terminalControl) &&
3004
- SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
3005
- let nativeInspectionComposerReady = nativeInspectionComposerEmpty(session.agent, terminalState.screen_excerpt);
3305
+ const terminalHasBlockingTurn = terminalIncarnationBlockingTurns(storeDirFromOptions(options), terminalControl).length > 0;
3306
+ let automatedInputComposerReady = nativeInspectionComposerEmpty(session.agent, terminalState.screen_excerpt);
3006
3307
  if (session.agent === "codex" &&
3007
- terminalState.activity_state === "idle" &&
3308
+ (terminalState.activity_state === "idle" ||
3309
+ (terminalState.activity_state === "unknown" &&
3310
+ nativeIdentityObservation.status === "verified_absent")) &&
3008
3311
  terminalState.approval_state.blocked !== true &&
3009
- nativeInspectionCapability.status === "supported" &&
3010
- nativeInspectionCapability.statusInspection === true &&
3011
3312
  terminalControl.capabilities.includes("send_keys") &&
3012
3313
  terminalControl.capabilities.includes("screen_status")) {
3013
3314
  try {
3014
3315
  const provider = createTerminalControlProvider(options);
3015
3316
  const resolvedTerminal = await provider.resolve(provider.endpoint(terminalControl));
3016
3317
  const styledScreen = await provider.capture(resolvedTerminal, { scrollbackLines: 40, preserveEscapes: true });
3017
- nativeInspectionComposerReady = codexStyledComposerEmpty(styledScreen);
3318
+ automatedInputComposerReady = codexStyledComposerEmpty(styledScreen);
3018
3319
  }
3019
3320
  catch {
3020
3321
  // Advertising an input action is optional. The action itself repeats the
3021
3322
  // same styled composer proof under the terminal lock before any input.
3022
- nativeInspectionComposerReady = false;
3323
+ automatedInputComposerReady = false;
3023
3324
  }
3024
3325
  }
3025
3326
  const entry = {
@@ -3040,6 +3341,14 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
3040
3341
  native_agent_process_birth: nativeProcessBirth,
3041
3342
  native_agent_rollout: nativeAgentIdentity?.rollout,
3042
3343
  native_agent_identity_evidence: nativeProcessEvidence,
3344
+ native_agent_identity_observation: {
3345
+ status: nativeIdentityObservation.status,
3346
+ ...(nativeIdentityObservation.status === "unavailable"
3347
+ ? { reason: nativeIdentityObservation.reason }
3348
+ : nativeIdentityObservation.status === "verified_absent"
3349
+ ? { evidence: nativeIdentityObservation.evidence }
3350
+ : {})
3351
+ },
3043
3352
  agent_version: agentVersion,
3044
3353
  native_thread_lifecycle: lifecycleCapability,
3045
3354
  native_inspection: nativeInspectionCapability,
@@ -3050,6 +3359,10 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
3050
3359
  approval_state: terminalState.approval_state,
3051
3360
  activity_state: terminalState.activity_state,
3052
3361
  activity_reason: terminalState.activity_reason,
3362
+ // Internal action-projection evidence. terminalFirstListProjection strips
3363
+ // this field after gating every automated-input action that can follow a
3364
+ // human native-thread switch.
3365
+ _automated_input_composer_ready: automatedInputComposerReady,
3053
3366
  ...(orphanedDispatch
3054
3367
  ? {
3055
3368
  orphaned_terminal_dispatch: {
@@ -3066,7 +3379,7 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
3066
3379
  }
3067
3380
  : {}),
3068
3381
  commands: {
3069
- send: true,
3382
+ send: !terminalHasBlockingTurn,
3070
3383
  approve: terminalControl.capabilities.includes("terminal_approval") &&
3071
3384
  terminalState.approval_state.approvable === true,
3072
3385
  status: true,
@@ -3074,13 +3387,15 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
3074
3387
  close: orphanedDispatch !== undefined,
3075
3388
  new_thread: lifecycleCapability.status === "supported" &&
3076
3389
  lifecycleCapability.newThread === true &&
3077
- codexLifecycleIncarnationAvailable,
3390
+ codexLifecycleIncarnationAvailable &&
3391
+ !terminalHasBlockingTurn,
3078
3392
  list_resumable_threads: lifecycleCapability.status === "supported" &&
3079
3393
  lifecycleCapability.resumeExact === true &&
3080
3394
  codexLifecycleIncarnationAvailable,
3081
3395
  native_inspect: nativeInspectionCapability.status === "supported" &&
3082
3396
  nativeInspectionCapability.statusInspection === true &&
3083
- nativeInspectionComposerReady &&
3397
+ terminalState.activity_state === "idle" &&
3398
+ automatedInputComposerReady &&
3084
3399
  terminalControl.capabilities.includes("send_keys") &&
3085
3400
  terminalControl.capabilities.includes("screen_status") &&
3086
3401
  (session.agent === "codex"
@@ -3088,7 +3403,7 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
3088
3403
  : Boolean(nativeAgentIdentity?.sessionId &&
3089
3404
  nativeAgentIdentity.processUuid)) &&
3090
3405
  orphanedDispatch === undefined &&
3091
- !nativeInspectionHasBlockingTurn
3406
+ !terminalHasBlockingTurn
3092
3407
  }
3093
3408
  };
3094
3409
  const availableActions = availableListActions(entry);
@@ -3106,6 +3421,7 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3106
3421
  return control ? [control] : [];
3107
3422
  });
3108
3423
  const projectedTerminals = terminals.map((terminal) => {
3424
+ const { _automated_input_composer_ready: automatedInputComposerReady, ...publicTerminal } = terminal;
3109
3425
  const terminalControl = isRecord(terminal.terminal_control)
3110
3426
  ? terminal.terminal_control
3111
3427
  : undefined;
@@ -3269,6 +3585,79 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3269
3585
  })
3270
3586
  : undefined;
3271
3587
  const blockingHandoffTurns = conflictingSessionTurns.filter((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
3588
+ const terminalBlockingTurns = terminalControl
3589
+ ? terminalIncarnationBlockingTurns(storeDir, terminalControl, allRelated)
3590
+ : [];
3591
+ const externalHandoffSourceSessionIds = new Set(conflictingBoundSessionClaims
3592
+ .filter(({ kind }) => kind === "live_external_thread_change")
3593
+ .map(({ session }) => session.session_id));
3594
+ const handoffSourceBlockingTurns = terminalBlockingTurns.filter((turn) => externalHandoffSourceSessionIds.has(sessionIdForConversation(turn)));
3595
+ const nativeIdentityObservation = isRecord(terminal.native_agent_identity_observation)
3596
+ ? terminal.native_agent_identity_observation
3597
+ : undefined;
3598
+ const verifiedEmptyProcessUuid = stringValue(terminal.native_agent_process_uuid);
3599
+ const verifiedEmptyProcessBirth = stringValue(terminal.native_agent_process_birth);
3600
+ const verifiedEmptyWorkspace = stringValue(terminal.workspace ?? terminal.cwd);
3601
+ const verifiedEmptySourceNativeThreadId = stringValue(soleBindingConflict?.session.binding?.native_thread_id)?.toLowerCase();
3602
+ const verifiedEmptySourceActiveElsewhere = Boolean(verifiedEmptySourceNativeThreadId &&
3603
+ terminals.some((candidate) => candidate !== terminal &&
3604
+ candidate.agent === "codex" &&
3605
+ stringValue(candidate.native_agent_session_id)?.toLowerCase() ===
3606
+ verifiedEmptySourceNativeThreadId));
3607
+ const verifiedEmptyRawSendAction = isRecord(rawActions.send)
3608
+ ? rawActions.send
3609
+ : {
3610
+ tool: "agent_knock_knock_send",
3611
+ arguments: { selector: stringValue(terminal.id) },
3612
+ missing_required: ["request"]
3613
+ };
3614
+ const verifiedEmptyCodexHandoffEligible = Boolean(mutationsAllowed &&
3615
+ terminal.agent === "codex" &&
3616
+ terminalControl &&
3617
+ discoveredOwnership.state === "none" &&
3618
+ unresolvedSessionClaims.length === 0 &&
3619
+ matchingSessions.length === 0 &&
3620
+ soleBindingConflict?.kind === "unverifiable" &&
3621
+ nativeIdentityObservation?.status === "verified_absent" &&
3622
+ verifiedEmptyProcessUuid &&
3623
+ verifiedEmptyProcessBirth &&
3624
+ verifiedEmptyWorkspace &&
3625
+ exactBoundCodexSourceForVerifiedEmptyHandoff({
3626
+ session: soleBindingConflict.session,
3627
+ terminalId: String(terminal.id),
3628
+ terminalControl,
3629
+ pid: Number(terminal.pid),
3630
+ workspace: verifiedEmptyWorkspace,
3631
+ processUuid: verifiedEmptyProcessUuid,
3632
+ processBirth: verifiedEmptyProcessBirth
3633
+ }) &&
3634
+ ["idle", "unknown"].includes(String(terminal.activity_state)) &&
3635
+ automatedInputComposerReady === true &&
3636
+ !(isRecord(terminal.approval_state) &&
3637
+ terminal.approval_state.blocked === true) &&
3638
+ blockingHandoffTurns.length === 0 &&
3639
+ terminalBlockingTurns.length === 0 &&
3640
+ !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
3641
+ !verifiedEmptySourceActiveElsewhere &&
3642
+ terminal.orphaned_terminal_dispatch === undefined &&
3643
+ terminalControl?.capabilities.includes("send_keys") &&
3644
+ terminalControl.capabilities.includes("screen_status"));
3645
+ const verifiedEmptyCodexSnapshotToken = verifiedEmptyCodexHandoffEligible &&
3646
+ terminalControl &&
3647
+ soleBindingConflict &&
3648
+ verifiedEmptyProcessUuid &&
3649
+ verifiedEmptyProcessBirth &&
3650
+ verifiedEmptyWorkspace
3651
+ ? verifiedEmptyCodexHandoffToken({
3652
+ terminalId: String(terminal.id),
3653
+ terminalControl,
3654
+ pid: Number(terminal.pid),
3655
+ workspace: verifiedEmptyWorkspace,
3656
+ processUuid: verifiedEmptyProcessUuid,
3657
+ processBirth: verifiedEmptyProcessBirth,
3658
+ sourceSession: soleBindingConflict.session
3659
+ })
3660
+ : undefined;
3272
3661
  const reconcileBindingAction = mutationsAllowed &&
3273
3662
  discoveredOwnership.state === "none" &&
3274
3663
  unresolvedSessionClaims.length === 0 &&
@@ -3282,6 +3671,7 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3282
3671
  !(isRecord(terminal.approval_state) &&
3283
3672
  terminal.approval_state.blocked === true) &&
3284
3673
  !conflictingSessionTurns.some((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status)) &&
3674
+ terminalBlockingTurns.length === 0 &&
3285
3675
  !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session)
3286
3676
  ? {
3287
3677
  tool: "agent_knock_knock_reconcile_binding",
@@ -3300,9 +3690,11 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3300
3690
  unresolvedSessionClaims.length === 0 &&
3301
3691
  soleBindingConflict?.kind === "live_external_thread_change" &&
3302
3692
  terminal.activity_state === "idle" &&
3693
+ automatedInputComposerReady === true &&
3303
3694
  !(isRecord(terminal.approval_state) &&
3304
3695
  terminal.approval_state.blocked === true) &&
3305
3696
  !conflictingSessionTurns.some((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status)) &&
3697
+ terminalBlockingTurns.length === 0 &&
3306
3698
  !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
3307
3699
  externalHandoffTarget?.status === "eligible" &&
3308
3700
  isRecord(rawActions.send) &&
@@ -3314,7 +3706,8 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3314
3706
  !(isRecord(terminal.approval_state) &&
3315
3707
  terminal.approval_state.blocked === true) &&
3316
3708
  !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
3317
- blockingHandoffTurns.length === 1
3709
+ blockingHandoffTurns.length === 1 &&
3710
+ terminalBlockingTurns.every((turn) => turn.conversation_id === blockingHandoffTurns[0].conversation_id)
3318
3711
  ? blockingHandoffTurns[0]
3319
3712
  : undefined;
3320
3713
  const handoffDecisionToken = handoffDecisionTurn &&
@@ -3354,6 +3747,14 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3354
3747
  }
3355
3748
  }
3356
3749
  : undefined;
3750
+ const blockingHandoffTurnIds = new Set(handoffSourceBlockingTurns.map((turn) => turn.conversation_id));
3751
+ // An active source Turn may be superseded only through the snapshot-bound
3752
+ // handoff decision above. Never expose the generic Store-only close action
3753
+ // for that Turn, because doing so would bypass expected_handoff_token after
3754
+ // the live native thread or Turn generation changed. `blocking_turns` is
3755
+ // reserved for other same-incarnation blockers such as legacy collateral
3756
+ // stalls; clear those first, refresh, and only then decide the handoff.
3757
+ const terminalRecoveryBlockingTurns = terminalBlockingTurns.filter((turn) => !blockingHandoffTurnIds.has(turn.conversation_id));
3357
3758
  const terminalCanAcceptSend = ownership.state === "none" && isRecord(sessionAwareRawActions.send);
3358
3759
  if (ownership.state === "current" &&
3359
3760
  !allRelated.some((conversation) => conversation.conversation_id === ownership.conversation.conversation_id)) {
@@ -3390,9 +3791,12 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3390
3791
  const currentTurnValue = ownership.state === "current"
3391
3792
  ? currentManagedTurnForTerminal(ownership.conversation, terminal, sessionAwareRawActions)
3392
3793
  : undefined;
3393
- const currentTurn = currentTurnValue && !mutationsAllowed
3794
+ const currentTurnProjection = currentTurnValue && !mutationsAllowed
3394
3795
  ? readOnlyManagedTurn(currentTurnValue)
3395
3796
  : currentTurnValue;
3797
+ const currentTurn = currentTurnProjection
3798
+ ? withoutGenericHandoffSourceClose(currentTurnProjection, blockingHandoffTurnIds)
3799
+ : undefined;
3396
3800
  const sortedDisplayed = [...sessionDisplayedRelated]
3397
3801
  .filter((conversation) => conversation.conversation_id !== currentTurn?.conversation_id)
3398
3802
  .sort(compareManagedConversationRecency);
@@ -3400,15 +3804,18 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3400
3804
  const recentTurnValue = recentConversation
3401
3805
  ? historicalManagedTurnForTerminal(recentConversation, terminalCanAcceptSend, terminal)
3402
3806
  : undefined;
3403
- const recentTurn = recentTurnValue && !mutationsAllowed
3807
+ const recentTurnProjection = recentTurnValue && !mutationsAllowed
3404
3808
  ? readOnlyManagedTurn(recentTurnValue)
3405
3809
  : recentTurnValue;
3810
+ const recentTurn = recentTurnProjection
3811
+ ? withoutGenericHandoffSourceClose(recentTurnProjection, blockingHandoffTurnIds)
3812
+ : undefined;
3406
3813
  const historyConversations = includeAll
3407
3814
  ? sortedDisplayed.filter((conversation) => conversation.conversation_id !== recentConversation?.conversation_id)
3408
3815
  : [];
3409
3816
  const history = historyConversations.map((conversation) => {
3410
3817
  const turn = historicalManagedTurnForTerminal(conversation, terminalCanAcceptSend, terminal);
3411
- return mutationsAllowed ? turn : readOnlyManagedTurn(turn);
3818
+ return withoutGenericHandoffSourceClose(mutationsAllowed ? turn : readOnlyManagedTurn(turn), blockingHandoffTurnIds);
3412
3819
  });
3413
3820
  const visibleTurnIds = new Set([currentTurn, recentTurn, ...history]
3414
3821
  .map((turn) => stringValue(turn?.conversation_id))
@@ -3455,6 +3862,19 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3455
3862
  }
3456
3863
  }
3457
3864
  : {}),
3865
+ ...(verifiedEmptyCodexSnapshotToken
3866
+ ? {
3867
+ send: {
3868
+ ...verifiedEmptyRawSendAction,
3869
+ arguments: {
3870
+ ...(isRecord(verifiedEmptyRawSendAction.arguments)
3871
+ ? verifiedEmptyRawSendAction.arguments
3872
+ : {}),
3873
+ expected_terminal_token: verifiedEmptyCodexSnapshotToken
3874
+ }
3875
+ }
3876
+ }
3877
+ : {}),
3458
3878
  ...(reconcileBindingAction
3459
3879
  ? { reconcile_binding: reconcileBindingAction }
3460
3880
  : {})
@@ -3482,8 +3902,19 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3482
3902
  lifecycle_binding_token: managedSessionBindingToken(authoritativeSession)
3483
3903
  }
3484
3904
  : {};
3905
+ const publicManagementConflict = ownership.state === "conflict" && verifiedEmptyCodexSnapshotToken
3906
+ ? {
3907
+ ...ownership.conflict,
3908
+ kind: "verified_empty_native_session",
3909
+ reason: "the previously bound Codex rollout is conclusively closed " +
3910
+ "while the same terminal process is at an exact empty prompt",
3911
+ recovery: "use only the exact snapshot-bound send action listed for this terminal"
3912
+ }
3913
+ : ownership.state === "conflict"
3914
+ ? ownership.conflict
3915
+ : undefined;
3485
3916
  return {
3486
- ...terminal,
3917
+ ...publicTerminal,
3487
3918
  ...authoritativeIdentity,
3488
3919
  management_state: ownership.state === "conflict"
3489
3920
  ? "conflict"
@@ -3491,16 +3922,52 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3491
3922
  ? "managed"
3492
3923
  : "unmanaged",
3493
3924
  ...(ownership.state === "conflict"
3494
- ? { management_conflict: ownership.conflict }
3925
+ ? { management_conflict: publicManagementConflict }
3495
3926
  : {}),
3496
3927
  ...(externalHandoffDetected
3497
3928
  ? {
3498
3929
  handoff_state: externalHandoffAdoptable
3499
3930
  ? "external_handoff_adoptable"
3500
- : "external_handoff_blocked"
3931
+ : "external_handoff_blocked",
3932
+ ...(terminalRecoveryBlockingTurns.length > 0
3933
+ ? {
3934
+ handoff_blocked_reason: "the terminal has unresolved managed Turn state; use only a listed blocking_turns recovery action"
3935
+ }
3936
+ : handoffDecision
3937
+ ? {
3938
+ handoff_blocked_reason: "the source Session has one active Turn; use only the snapshot-bound handoff_decision after explicit confirmation"
3939
+ }
3940
+ : handoffSourceBlockingTurns.length > 0
3941
+ ? {
3942
+ handoff_blocked_reason: "the conflicting source Session has unresolved Turn state but no snapshot-bound handoff decision is currently safe; inspect the conflict and refresh"
3943
+ }
3944
+ : automatedInputComposerReady !== true
3945
+ ? {
3946
+ handoff_blocked_reason: "the current terminal composer is not an exact empty idle frame"
3947
+ }
3948
+ : {})
3501
3949
  }
3502
3950
  : {}),
3951
+ ...(verifiedEmptyCodexSnapshotToken
3952
+ ? { handoff_state: "verified_empty_native_session_adoptable" }
3953
+ : {}),
3503
3954
  ...(handoffDecision ? { handoff_decision: handoffDecision } : {}),
3955
+ ...(terminalRecoveryBlockingTurns.length > 0
3956
+ ? {
3957
+ blocking_turns: terminalRecoveryBlockingTurns.map((turn) => ({
3958
+ session_id: sessionIdForConversation(turn),
3959
+ turn_id: turnIdForConversation(turn),
3960
+ status: turn.status,
3961
+ recovery_action: {
3962
+ tool: "agent_knock_knock_close",
3963
+ arguments: {
3964
+ turn_id: turnIdForConversation(turn)
3965
+ },
3966
+ requires_explicit_user_confirmation: true
3967
+ }
3968
+ }))
3969
+ }
3970
+ : {}),
3504
3971
  managed: management,
3505
3972
  available_actions: availableActions
3506
3973
  };
@@ -3743,6 +4210,21 @@ function terminalControlForManagedConversation(conversation) {
3743
4210
  ? conversation.native_session_takeover
3744
4211
  : undefined);
3745
4212
  }
4213
+ function terminalIncarnationBlockingTurns(storeDir, terminalControl, conversations = listConversations(storeDir)) {
4214
+ return conversations
4215
+ .filter(isDiscoverableTmuxConversation)
4216
+ .filter((turn) => terminalControlsShareIncarnation(terminalControlForManagedConversation(turn), terminalControl) &&
4217
+ SESSION_SEND_BLOCKING_STATUSES.has(turn.status))
4218
+ .sort(compareManagedConversationRecency);
4219
+ }
4220
+ function assertTerminalIncarnationCanStartTurn(storeDir, terminalControl) {
4221
+ const blocker = terminalIncarnationBlockingTurns(storeDir, terminalControl)[0];
4222
+ if (!blocker) {
4223
+ return;
4224
+ }
4225
+ throw new Error(`terminal ${terminalControl.target} still has unresolved Turn ` +
4226
+ `${turnIdForConversation(blocker)} (${blocker.status})`);
4227
+ }
3746
4228
  function compareManagedConversationRecency(left, right) {
3747
4229
  const leftTime = Date.parse(String(left.updated_at ?? left.created_at ?? ""));
3748
4230
  const rightTime = Date.parse(String(right.updated_at ?? right.created_at ?? ""));
@@ -3962,6 +4444,20 @@ function readOnlyManagedTurn(managedTurn) {
3962
4444
  : {})
3963
4445
  };
3964
4446
  }
4447
+ function withoutGenericHandoffSourceClose(managedTurn, blockingHandoffTurnIds) {
4448
+ const conversationId = stringValue(managedTurn.conversation_id);
4449
+ if (!conversationId || !blockingHandoffTurnIds.has(conversationId)) {
4450
+ return managedTurn;
4451
+ }
4452
+ const availableActions = isRecord(managedTurn.available_actions)
4453
+ ? managedTurn.available_actions
4454
+ : {};
4455
+ const { close: _genericClose, ...safeActions } = availableActions;
4456
+ return {
4457
+ ...managedTurn,
4458
+ available_actions: safeActions
4459
+ };
4460
+ }
3965
4461
  function historicalManagedTurnForTerminal(conversation, terminalCanAcceptSend, terminal) {
3966
4462
  const managedTurn = managedTurnListEntry(summarizeConversation(conversation), {
3967
4463
  terminalBridge: terminalBridgeEnabled(conversation),
@@ -4199,13 +4695,13 @@ function managedListApprovalState(conversation) {
4199
4695
  }
4200
4696
  function listActionContracts() {
4201
4697
  return {
4202
- version: 8,
4698
+ version: 10,
4203
4699
  instructions: [
4204
- "Treat terminals[] as the primary resource and use only actions present in available_actions, except the snapshot-bound terminals[].handoff_decision.choices.take_over_current.action. That nested action requires explicit user confirmation; after it succeeds, refresh list before any follow-current send.",
4700
+ "Treat terminals[] as the primary resource and use only actions present in available_actions, except the snapshot-bound terminals[].handoff_decision.choices.take_over_current.action and an exact terminals[].blocking_turns[].recovery_action. Either nested action requires explicit user confirmation; after it succeeds, refresh list before any follow-current send.",
4205
4701
  "An existing managed session's ordinary send targets session_id and creates a new turn. A turn id is never an ordinary send target.",
4206
4702
  "Read-only native-thread listing targets an exact terminal_id. Native-thread new/resume mutations also use the listed expected_binding_token and never create a Turn.",
4207
4703
  "Native inspection is a separate terminal action: use only its closed inspection enum and current exact terminal_id/token; AKK status does not execute a native slash command.",
4208
- "A verified, idle human native-thread switch may expose a terminal-scoped send with expected_terminal_token; that action atomically adopts the live context before creating its Turn. Other binding conflicts remain fail-closed and may expose only exact low-level reconcile_binding recovery.",
4704
+ "A verified, idle human native-thread switch may expose a terminal-scoped send with expected_terminal_token; that action atomically adopts the live context before creating its Turn. A conclusively ended Codex rollout may expose the same snapshot-bound send only after AKK proves zero current rollout and an exact empty composer; it detaches the ended Session and creates an isolated virgin Session. Other binding conflicts remain fail-closed and may expose only exact low-level reconcile_binding recovery.",
4209
4705
  "List resumable threads before resume; use only a complete native_thread_id and the action returned for that candidate.",
4210
4706
  "Use a terminal selector only when explicitly named by the user or prefilled by that terminal row's send action. A handoff action also carries expected_terminal_token; never infer, guess, or reuse either value.",
4211
4707
  "Use respond only for an in-flight turn that is explicitly waiting for OpenClaw.",
@@ -4239,11 +4735,28 @@ function listActionContracts() {
4239
4735
  meaning: "currently_safe_actions",
4240
4736
  authoritative_for_tool_calls: true
4241
4737
  },
4738
+ native_agent_identity_observation: {
4739
+ meaning: "the latest bounded native identity probe; verified_absent is distinct from an unavailable resolver",
4740
+ authoritative_for_tool_calls: false
4741
+ },
4742
+ management_conflict: {
4743
+ verified_empty_native_session: "a previously bound Codex rollout is conclusively closed; only the exact snapshot-bound send in available_actions may detach it and create an isolated virgin Session"
4744
+ },
4745
+ handoff_state: {
4746
+ meaning: "why a conflict-scoped follow-current send is available or blocked",
4747
+ verified_empty_native_session_adoptable: "the exact current terminal snapshot permits the listed conflict send; availability is revalidated before terminal input"
4748
+ },
4242
4749
  handoff_decision: {
4243
4750
  meaning: "an explicit human choice required before superseding an active source Turn",
4244
4751
  authoritative_action_path: "terminals[].handoff_decision.choices.take_over_current.action",
4245
4752
  requires_explicit_user_confirmation: true,
4246
4753
  after_success: "refresh list before using a follow-current send action"
4754
+ },
4755
+ blocking_turns: {
4756
+ meaning: "terminal-incarnation-wide collateral unresolved managed Turns that suppress send, lifecycle, and native-inspection actions; an active human-handoff source Turn is never listed here and remains governed only by handoff_decision",
4757
+ authoritative_action_path: "terminals[].blocking_turns[].recovery_action",
4758
+ requires_explicit_user_confirmation: true,
4759
+ after_success: "refresh list before using any terminal action"
4247
4760
  }
4248
4761
  },
4249
4762
  actions: {
@@ -4262,7 +4775,7 @@ function listActionContracts() {
4262
4775
  "agentHardTimeoutMinutes"
4263
4776
  ],
4264
4777
  unsupported: ["timeoutSeconds"],
4265
- ordinary_use: "Create a new managed turn in the exact Session. A live terminal selector can attach an unmanaged pane or adopt one verified human-selected native context; an explicit session_id never follows the pane."
4778
+ ordinary_use: "Create a new managed turn in the exact Session. A live terminal selector can attach an unmanaged pane, adopt one verified human-selected native context, or use the exact listed conflict send to detach a verified-empty Codex source before an isolated virgin attach; an explicit session_id never follows the pane."
4266
4779
  },
4267
4780
  new_thread: {
4268
4781
  tool: "agent_knock_knock_new_thread",
@@ -5350,14 +5863,9 @@ async function verifyCodexPendingManagedSendStatus({ options, terminal, session,
5350
5863
  if (!codexComposerVisible(initialStatus.screen.excerpt)) {
5351
5864
  throw new Error("Codex status-card verification requires a visible idle composer");
5352
5865
  }
5353
- await assertCodexComposerReadyForAutomatedInput({
5354
- options,
5355
- terminalControl: terminal.terminalControl
5356
- });
5357
5866
  const observed = await probeCodexCurrentThread({
5358
5867
  options,
5359
5868
  terminal,
5360
- initialStatus,
5361
5869
  currentIdentity: logicalIdentity,
5362
5870
  runtimeOverride: runtime
5363
5871
  });
@@ -5652,6 +6160,218 @@ async function reattachManagedSessionForNativeIdentity({ options, terminal, iden
5652
6160
  }, { expectedRevision: managedSessionRevision(existing) });
5653
6161
  }
5654
6162
  const HUMAN_OBSERVED_HANDOFF_FINGERPRINT = nativeThreadCommandFingerprint("adopt_external_thread:human_observed:no_terminal_input:v1");
6163
+ function exactBoundCodexSourceForVerifiedEmptyHandoff({ session, terminalId, terminalControl, pid, workspace, processUuid, processBirth }) {
6164
+ const binding = session.binding;
6165
+ return Boolean(session.agent === "codex" &&
6166
+ session.status === "bound" &&
6167
+ binding &&
6168
+ isExactNativeThreadId(binding.native_thread_id) &&
6169
+ isCompleteNativeRollout(binding.native_process.rollout) &&
6170
+ binding.native_process.process_uuid &&
6171
+ binding.native_process.process_birth &&
6172
+ binding.native_process.pid === pid &&
6173
+ terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminalId, terminalControl) &&
6174
+ workspace &&
6175
+ path.resolve(session.workspace) === path.resolve(workspace) &&
6176
+ processIncarnationRelationship({
6177
+ binding,
6178
+ livePid: pid,
6179
+ liveProcessUuid: processUuid,
6180
+ liveProcessBirth: processBirth
6181
+ }) === "same");
6182
+ }
6183
+ function verifiedEmptyCodexHandoffToken({ terminalId, terminalControl, pid, workspace, processUuid, processBirth, sourceSession }) {
6184
+ const terminalToken = unmanagedTerminalBindingToken({
6185
+ terminalId,
6186
+ terminalControl,
6187
+ agent: "codex",
6188
+ pid,
6189
+ workspace,
6190
+ processUuid,
6191
+ processBirth
6192
+ });
6193
+ return createHash("sha256")
6194
+ .update(JSON.stringify({
6195
+ version: 1,
6196
+ kind: "verified_empty_codex_handoff",
6197
+ terminal_token: terminalToken,
6198
+ source_session_id: sourceSession.session_id,
6199
+ source_revision: managedSessionRevision(sourceSession),
6200
+ source_binding_token: managedSessionBindingToken(sourceSession),
6201
+ observation: "verified_absent"
6202
+ }))
6203
+ .digest("hex");
6204
+ }
6205
+ async function assertVerifiedEmptyCodexHandoffBoundary({ options, terminal, sourceSession, expectedSourceStatus, requireNoDispatch, requireEmptyComposer = true }) {
6206
+ const currentSource = loadManagedSession(storeDirFromOptions(options), sourceSession.session_id);
6207
+ if (currentSource.status !== expectedSourceStatus ||
6208
+ currentSource.revision !== sourceSession.revision ||
6209
+ managedSessionBindingToken(currentSource) !==
6210
+ managedSessionBindingToken(sourceSession)) {
6211
+ throw new Error("the verified-empty source Session changed; refresh AKK list");
6212
+ }
6213
+ const liveIncarnation = codexProcessIncarnationForPid(terminal.pid);
6214
+ if (!exactBoundCodexSourceForVerifiedEmptyHandoff({
6215
+ session: {
6216
+ ...currentSource,
6217
+ // The exact source binding remains authoritative after the monotonic
6218
+ // detach; only the Session status changes.
6219
+ status: "bound"
6220
+ },
6221
+ terminalId: terminal.conversationId,
6222
+ terminalControl: terminal.terminalControl,
6223
+ pid: terminal.pid,
6224
+ workspace: terminal.terminalControl.currentPath,
6225
+ processUuid: liveIncarnation.processUuid,
6226
+ processBirth: liveIncarnation.processBirth
6227
+ })) {
6228
+ throw new Error("the verified-empty source binding no longer matches the terminal");
6229
+ }
6230
+ const observation = await observeCurrentNativeAgentSessionIdentity({
6231
+ options,
6232
+ agent: "codex",
6233
+ pid: terminal.pid,
6234
+ cwd: terminal.terminalControl.currentPath
6235
+ });
6236
+ if (observation.status === "unavailable") {
6237
+ throw new Error(`Codex native identity observation is unavailable: ${observation.reason}`);
6238
+ }
6239
+ if (observation.status !== "verified_absent") {
6240
+ throw new Error(`Codex materialized native thread ${observation.identity.sessionId}; ` +
6241
+ "refresh AKK list before sending");
6242
+ }
6243
+ const status = await createTerminalAgentBridge(options).status("codex", terminal.terminalControl, {
6244
+ runtime: terminalRuntimeForLiveIdentity({
6245
+ terminal,
6246
+ expectedEmptyNativeSession: true
6247
+ })
6248
+ });
6249
+ if (status.reachable !== true ||
6250
+ status.approval_state.blocked === true ||
6251
+ !["idle", "unknown"].includes(status.activity_state)) {
6252
+ throw new Error(`terminal ${terminal.terminalControl.target} is not at a verified ` +
6253
+ `empty Codex prompt (${status.activity_state}: ${status.activity_reason})`);
6254
+ }
6255
+ if (requireNoDispatch) {
6256
+ assertTerminalLifecycleReady({
6257
+ options,
6258
+ terminal,
6259
+ // A fully styled empty/placeholder composer below is stronger prompt
6260
+ // evidence than the plain-screen parser's conservative `unknown`.
6261
+ terminalStatus: { ...status, activity_state: "idle" }
6262
+ });
6263
+ }
6264
+ if (managedSessionHasUnresolvedNativeTransition(storeDirFromOptions(options), currentSource)) {
6265
+ throw new Error(`managed Session ${currentSource.session_id} has an unresolved native-thread transition`);
6266
+ }
6267
+ if (requireEmptyComposer) {
6268
+ await assertCodexComposerReadyForAutomatedInput({
6269
+ options,
6270
+ terminalControl: terminal.terminalControl
6271
+ });
6272
+ }
6273
+ }
6274
+ async function maybeDetachVerifiedEmptyCodexSource({ options, terminal, sourceSession, observation }) {
6275
+ if (terminal.agent !== "codex" ||
6276
+ !sourceSession?.binding ||
6277
+ observation.status !== "verified_absent") {
6278
+ return undefined;
6279
+ }
6280
+ const processUuid = sourceSession.binding.native_process.process_uuid;
6281
+ const processBirth = sourceSession.binding.native_process.process_birth;
6282
+ const workspace = terminal.terminalControl.currentPath;
6283
+ const liveIncarnation = codexProcessIncarnationForPid(terminal.pid);
6284
+ if (!processUuid ||
6285
+ !processBirth ||
6286
+ !workspace ||
6287
+ !exactBoundCodexSourceForVerifiedEmptyHandoff({
6288
+ session: sourceSession,
6289
+ terminalId: terminal.conversationId,
6290
+ terminalControl: terminal.terminalControl,
6291
+ pid: terminal.pid,
6292
+ workspace,
6293
+ processUuid: liveIncarnation.processUuid,
6294
+ processBirth: liveIncarnation.processBirth
6295
+ })) {
6296
+ return undefined;
6297
+ }
6298
+ const expectedToken = verifiedEmptyCodexHandoffToken({
6299
+ terminalId: terminal.conversationId,
6300
+ terminalControl: terminal.terminalControl,
6301
+ pid: terminal.pid,
6302
+ workspace,
6303
+ processUuid,
6304
+ processBirth,
6305
+ sourceSession
6306
+ });
6307
+ if (stringValue(options.expectedTerminalToken) !== expectedToken) {
6308
+ throw new Error("verified-empty Codex handoff requires the fresh exact terminal token " +
6309
+ "advertised by AKK list");
6310
+ }
6311
+ await assertVerifiedEmptyCodexHandoffBoundary({
6312
+ options,
6313
+ terminal,
6314
+ sourceSession,
6315
+ expectedSourceStatus: "bound",
6316
+ requireNoDispatch: true
6317
+ });
6318
+ await assertNativeThreadHasExclusiveOwnership({
6319
+ options,
6320
+ agent: "codex",
6321
+ currentPid: terminal.pid,
6322
+ nativeThreadId: sourceSession.binding.native_thread_id,
6323
+ storeDir: storeDirFromOptions(options),
6324
+ terminalControl: terminal.terminalControl,
6325
+ excludedManagedSessionId: sourceSession.session_id
6326
+ });
6327
+ const detachedAt = cliNow().toISOString();
6328
+ const detached = saveManagedSession(storeDirFromOptions(options), {
6329
+ ...sourceSession,
6330
+ status: "detached",
6331
+ detached_at: detachedAt,
6332
+ updated_at: detachedAt
6333
+ }, {
6334
+ expectedRevision: managedSessionRevision(sourceSession)
6335
+ });
6336
+ runtimeLog("info", "verified_empty_codex_source_detached", {
6337
+ terminal_id: terminal.conversationId,
6338
+ source_session_id: sourceSession.session_id,
6339
+ native_thread_id: sourceSession.binding.native_thread_id,
6340
+ process_uuid: processUuid,
6341
+ process_birth: processBirth,
6342
+ terminal_input_sent: false
6343
+ });
6344
+ return {
6345
+ detached,
6346
+ boundary: {
6347
+ terminal,
6348
+ detachedSourceSessionId: detached.session_id,
6349
+ detachedSourceRevision: managedSessionRevision(detached),
6350
+ detachedSourceBindingToken: managedSessionBindingToken(detached),
6351
+ processUuid,
6352
+ processBirth
6353
+ }
6354
+ };
6355
+ }
6356
+ async function assertVerifiedEmptyCodexTransportBoundary({ options, boundary, requireEmptyComposer }) {
6357
+ const source = loadManagedSession(storeDirFromOptions(options), boundary.detachedSourceSessionId);
6358
+ if (source.status !== "detached" ||
6359
+ managedSessionRevision(source) !== boundary.detachedSourceRevision ||
6360
+ managedSessionBindingToken(source) !==
6361
+ boundary.detachedSourceBindingToken ||
6362
+ source.binding?.native_process.process_uuid !== boundary.processUuid ||
6363
+ source.binding.native_process.process_birth !== boundary.processBirth) {
6364
+ throw new Error("verified-empty Codex source authority changed before text injection");
6365
+ }
6366
+ await assertVerifiedEmptyCodexHandoffBoundary({
6367
+ options,
6368
+ terminal: boundary.terminal,
6369
+ sourceSession: source,
6370
+ expectedSourceStatus: "detached",
6371
+ requireNoDispatch: false,
6372
+ requireEmptyComposer
6373
+ });
6374
+ }
5655
6375
  function observedHandoffAuthorityToken({ terminal, identity, sourceSession, target }) {
5656
6376
  const exact = exactLifecycleProcessIdentity(terminal, identity);
5657
6377
  const terminalToken = unmanagedTerminalBindingToken({
@@ -5709,10 +6429,12 @@ function assertExpectedHandoffTokenUsesExactTerminalSelector({ options, terminal
5709
6429
  "conversation selector advertised by AKK list");
5710
6430
  }
5711
6431
  }
5712
- async function observedExternalHandoffIdentity({ options, terminal, sourceSession, resolvedIdentity }) {
6432
+ async function observedExternalHandoffIdentity({ options, terminal, sourceSession, resolvedIdentity, requireSafeTerminal = true }) {
5713
6433
  const bridge = createTerminalAgentBridge(options);
5714
6434
  const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
5715
- assertSafeTerminalSend(terminal.agent, status);
6435
+ if (requireSafeTerminal) {
6436
+ assertSafeTerminalSend(terminal.agent, status);
6437
+ }
5716
6438
  if (terminal.agent !== "codex") {
5717
6439
  return { identity: resolvedIdentity, status };
5718
6440
  }
@@ -6284,10 +7006,7 @@ function assertTerminalLifecycleReady({ options, terminal, terminalStatus }) {
6284
7006
  throw new Error(`terminal ${terminal.terminalControl.target} is not at a verified idle prompt ` +
6285
7007
  `(${terminalStatus.activity_state}: ${terminalStatus.activity_reason})`);
6286
7008
  }
6287
- const blockers = listConversations(storeDirFromOptions(options))
6288
- .filter(isDiscoverableTmuxConversation)
6289
- .filter((turn) => terminalControlsShareIncarnation(terminalControlForManagedConversation(turn), terminal.terminalControl) &&
6290
- SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
7009
+ const blockers = terminalIncarnationBlockingTurns(storeDirFromOptions(options), terminal.terminalControl);
6291
7010
  if (blockers.length > 0) {
6292
7011
  throw new Error(`terminal ${terminal.terminalControl.target} still has unresolved Turn ` +
6293
7012
  `${turnIdForConversation(blockers[0])} (${blockers[0].status})`);
@@ -6967,10 +7686,7 @@ function assertTerminalNativeInspectionReady({ options, terminal, terminalStatus
6967
7686
  throw new Error(`terminal ${terminal.terminalControl.target} is not at a verified idle prompt ` +
6968
7687
  `(${terminalStatus.activity_state}: ${terminalStatus.activity_reason})`);
6969
7688
  }
6970
- const blocker = listConversations(storeDirFromOptions(options))
6971
- .filter(isDiscoverableTmuxConversation)
6972
- .find((turn) => terminalControlsShareIncarnation(terminalControlForManagedConversation(turn), terminal.terminalControl) &&
6973
- SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
7689
+ const blocker = terminalIncarnationBlockingTurns(storeDirFromOptions(options), terminal.terminalControl)[0];
6974
7690
  if (blocker) {
6975
7691
  throw new Error(`terminal ${terminal.terminalControl.target} still has unresolved Turn ` +
6976
7692
  `${turnIdForConversation(blocker)} (${blocker.status})`);
@@ -7414,6 +8130,7 @@ async function waitForVerifiedThreadTransition({ options, terminal, beforeIdenti
7414
8130
  const bridge = createTerminalAgentBridge(options);
7415
8131
  let probeSent = false;
7416
8132
  let postProbeBaselineDigest;
8133
+ let postProbeObservationScrollbackLines;
7417
8134
  let stableIdentity;
7418
8135
  let stableCount = 0;
7419
8136
  const physicalBeforeFence = codexIdentityFence(physicalBeforeIdentity);
@@ -7481,22 +8198,37 @@ async function waitForVerifiedThreadTransition({ options, terminal, beforeIdenti
7481
8198
  }
7482
8199
  else {
7483
8200
  const afterProbe = plan.steps.find((step) => step.kind === "identity_probe_after");
7484
- if (!afterProbe || afterProbe.effect !== "read_only") {
8201
+ if (!afterProbe ||
8202
+ afterProbe.effect !== "read_only" ||
8203
+ afterProbe.command !== "/status") {
7485
8204
  throw new Error("the Codex lifecycle plan has no exact post-transition identity probe");
7486
8205
  }
8206
+ const agentVersion = agentVersionForRunningProcess(terminal.agent, terminal.pid, options);
8207
+ if (!agentVersion) {
8208
+ throw new Error("Codex lifecycle postcondition /status requires the exact running version before terminal input");
8209
+ }
7487
8210
  const physicalRuntime = terminalRuntimeForLiveIdentity({
7488
8211
  terminal,
7489
8212
  physicalOnly: true
7490
8213
  });
7491
- const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: physicalRuntime });
8214
+ const status = await bridge.status(terminal.agent, terminal.terminalControl, {
8215
+ runtime: physicalRuntime,
8216
+ ...(postProbeObservationScrollbackLines === undefined
8217
+ ? {}
8218
+ : {
8219
+ scrollbackLines: postProbeObservationScrollbackLines
8220
+ })
8221
+ });
7492
8222
  const screenDigest = stringValue(status.screen.digest);
7493
8223
  if (!probeSent &&
7494
8224
  status.reachable &&
7495
8225
  status.activity_state === "idle" &&
7496
8226
  screenDigest !== undefined &&
7497
8227
  screenDigest !== initialScreenDigest) {
7498
- postProbeBaselineDigest = screenDigest;
7499
- await bridge.send(terminal.agent, terminal.terminalControl, afterProbe.command, { runtime: physicalRuntime });
8228
+ const submission = await bridge.submitCodexStatusProbe(terminal.terminalControl, agentVersion, { runtime: physicalRuntime });
8229
+ postProbeBaselineDigest = submission.observationBaselineDigest;
8230
+ postProbeObservationScrollbackLines =
8231
+ submission.observationScrollbackLines;
7500
8232
  probeSent = true;
7501
8233
  }
7502
8234
  else if (status.reachable &&
@@ -7625,7 +8357,7 @@ function assertVerifiedClaudeLifecycleBeforeObservation({ options, terminal, ide
7625
8357
  `for the current process incarnation${observed?.reason ? `: ${observed.reason}` : ""}`);
7626
8358
  }
7627
8359
  }
7628
- async function probeCodexCurrentThread({ options, terminal, initialStatus, currentIdentity, runtimeIdentity, runtimeOverride }) {
8360
+ async function probeCodexCurrentThread({ options, terminal, currentIdentity, runtimeIdentity, runtimeOverride }) {
7629
8361
  if (terminal.agent !== "codex") {
7630
8362
  throw new Error("the current native thread identity is unavailable");
7631
8363
  }
@@ -7637,24 +8369,31 @@ async function probeCodexCurrentThread({ options, terminal, initialStatus, curre
7637
8369
  terminal,
7638
8370
  expectedEmptyNativeSession: true
7639
8371
  }));
7640
- // The plain activity parser intentionally recognizes Codex's composer even
7641
- // when a footer follows it, so "idle" alone is not authority to press Enter.
7642
- // Re-read the styled pane immediately before the slash probe and accept only
7643
- // an empty composer (or Codex's fully dim replace-on-type placeholder).
7644
- await assertCodexComposerReadyForAutomatedInput({
7645
- options,
7646
- terminalControl: terminal.terminalControl
7647
- });
7648
- await bridge.send(terminal.agent, terminal.terminalControl, "/status", { runtime: probeRuntime });
8372
+ const agentVersion = agentVersionForRunningProcess(terminal.agent, terminal.pid, options);
8373
+ if (!agentVersion) {
8374
+ throw new Error("Codex /status requires the exact running version before terminal input");
8375
+ }
8376
+ // Internal status probes use the same closed submission boundary as native
8377
+ // inspection: exact viewport and styled-empty composer proof, Codex's paste
8378
+ // suppression settle, identity revalidation, and exactly one Enter.
8379
+ const submission = await bridge.submitCodexStatusProbe(terminal.terminalControl, agentVersion, { runtime: probeRuntime });
8380
+ let lastObservationReason = "no fresh idle Codex /status card was captured after the one Enter dispatch";
8381
+ let sawIncompleteSessionUuid = false;
7649
8382
  for (let attempt = 0; attempt < 50; attempt += 1) {
7650
- const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: probeRuntime });
8383
+ const status = await bridge.status(terminal.agent, terminal.terminalControl, {
8384
+ runtime: probeRuntime,
8385
+ scrollbackLines: submission.observationScrollbackLines
8386
+ });
7651
8387
  if (status.reachable &&
7652
- status.screen.digest !== initialStatus.screen.digest) {
8388
+ status.screen.digest !== submission.observationBaselineDigest) {
7653
8389
  const observed = terminal.adapter.observeThreadLifecycle?.({
7654
8390
  operation: { kind: "new_thread" },
7655
8391
  phase: "before",
7656
8392
  screen: status.screen.excerpt ?? ""
7657
8393
  });
8394
+ lastObservationReason = observed?.reason ?? lastObservationReason;
8395
+ sawIncompleteSessionUuid ||= observed?.status === "missing" &&
8396
+ /complete Session UUID/iu.test(observed.reason ?? "");
7658
8397
  if (observed?.status === "observed" &&
7659
8398
  observed.nativeThreadId &&
7660
8399
  status.activity_state === "idle") {
@@ -7671,7 +8410,12 @@ async function probeCodexCurrentThread({ options, terminal, initialStatus, curre
7671
8410
  }
7672
8411
  await cliSleep(100);
7673
8412
  }
7674
- throw new Error("Codex /status did not expose a complete current Session UUID");
8413
+ throw new Error(sawIncompleteSessionUuid
8414
+ ? "Codex /status Enter was dispatched exactly once, but the status card " +
8415
+ "was truncated before the complete current Session UUID; widen or zoom " +
8416
+ "the pane, refresh AKK list, and do not retry the probe automatically"
8417
+ : "Codex /status Enter was dispatched exactly once, but no fresh exact " +
8418
+ `current Session UUID was proven; do not retry automatically: ${lastObservationReason}`);
7675
8419
  }
7676
8420
  async function runNewThread(options) {
7677
8421
  const requireRestorableOrigin = options.requireRestorableOrigin;
@@ -8028,7 +8772,6 @@ async function runNativeThreadTransition(options, operation) {
8028
8772
  const statusIdentity = await probeCodexCurrentThread({
8029
8773
  options,
8030
8774
  terminal,
8031
- initialStatus: terminalStatus,
8032
8775
  currentIdentity: beforeIdentity,
8033
8776
  runtimeIdentity: snapshot.runtimeIdentity,
8034
8777
  runtimeOverride: beforeRuntime
@@ -8692,17 +9435,21 @@ async function reconcileStoreForStatus(storeDir, options, conversationId) {
8692
9435
  }
8693
9436
  throw error;
8694
9437
  }
8695
- const idle = reconcileIdleConversations(storeDir, options, cliNow(), conversationId);
8696
9438
  const monitors = await reconcileMonitors(options, {
8697
9439
  includeCallbackRecovery: false,
8698
9440
  reason: "status_reconciliation",
8699
9441
  conversationId
8700
9442
  });
9443
+ const idle = reconcileIdleConversations(storeDir, options, cliNow(), conversationId);
8701
9444
  return {
8702
9445
  status: "completed",
8703
9446
  checked: Math.max(idle.checked, monitors.checked),
8704
- changed: idle.closed + monitors.launched,
9447
+ changed: idle.closed + monitors.launched + monitors.repaired,
8705
9448
  closed: idle.closed,
9449
+ repaired: monitors.repaired,
9450
+ collateral_stalls_checked: monitors.collateral_stalls_checked,
9451
+ collateral_stalls_skipped: monitors.collateral_stalls_skipped,
9452
+ collateral_stall_repairs: monitors.items.filter((item) => item.status === "repaired"),
8706
9453
  monitors_launched: monitors.launched,
8707
9454
  monitors_already_running: monitors.already_running,
8708
9455
  skipped: idle.skipped + monitors.skipped,
@@ -9229,14 +9976,15 @@ async function runSend(options) {
9229
9976
  })) {
9230
9977
  return;
9231
9978
  }
9232
- const claimedSession = soleBoundManagedSessionClaimForTerminal(rawStoreDir, terminalConversation);
9233
- const knownCodexCompanions = claimedSession
9979
+ assertTerminalIncarnationCanStartTurn(rawStoreDir, terminalConversation.terminalControl);
9980
+ let claimedSession = soleBoundManagedSessionClaimForTerminal(rawStoreDir, terminalConversation);
9981
+ let knownCodexCompanions = claimedSession
9234
9982
  ? codexAllowedCompanionSetForManagedSession({
9235
9983
  storeDir: rawStoreDir,
9236
9984
  session: claimedSession
9237
9985
  })
9238
9986
  : { additional: [] };
9239
- let currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
9987
+ const nativeIdentityObservation = await observeCurrentNativeAgentSessionIdentity({
9240
9988
  options,
9241
9989
  agent: terminalConversation.agent,
9242
9990
  pid: terminalConversation.pid,
@@ -9247,6 +9995,26 @@ async function runSend(options) {
9247
9995
  allowedCompanionIdentity: knownCodexCompanions.primary,
9248
9996
  allowedAdditionalIdentities: knownCodexCompanions.additional
9249
9997
  });
9998
+ if (nativeIdentityObservation.status === "unavailable") {
9999
+ throw new Error(`native ${terminalConversation.agent} identity observation is ` +
10000
+ `unavailable: ${nativeIdentityObservation.reason}`);
10001
+ }
10002
+ let currentNativeIdentity = nativeIdentityObservation.status === "resolved"
10003
+ ? nativeIdentityObservation.identity
10004
+ : undefined;
10005
+ const verifiedEmptyHandoff = await maybeDetachVerifiedEmptyCodexSource({
10006
+ options,
10007
+ terminal: terminalConversation,
10008
+ sourceSession: claimedSession,
10009
+ observation: nativeIdentityObservation
10010
+ });
10011
+ if (verifiedEmptyHandoff) {
10012
+ // The old rollout is conclusively closed. Never carry it forward as
10013
+ // a pre-materialization companion for the new virgin Session.
10014
+ claimedSession = undefined;
10015
+ knownCodexCompanions = { additional: [] };
10016
+ currentNativeIdentity = undefined;
10017
+ }
9250
10018
  const physicalNativeIdentityBeforeHandoff = currentNativeIdentity;
9251
10019
  const handoff = await maybeAdoptObservedExternalThread({
9252
10020
  options,
@@ -9415,7 +10183,8 @@ async function runSend(options) {
9415
10183
  terminal: terminalConversation,
9416
10184
  transition: handoff.transition
9417
10185
  }
9418
- : undefined
10186
+ : undefined,
10187
+ verifiedEmptyCodexHandoff: verifiedEmptyHandoff?.boundary
9419
10188
  });
9420
10189
  }
9421
10190
  finally {
@@ -9498,6 +10267,7 @@ async function runSend(options) {
9498
10267
  })) {
9499
10268
  return;
9500
10269
  }
10270
+ assertTerminalIncarnationCanStartTurn(storeDir, resolvedTerminal.terminalControl);
9501
10271
  let currentSession = tryLoadManagedSession(storeDir, sessionId);
9502
10272
  const knownCodexCompanions = currentSession
9503
10273
  ? codexAllowedCompanionSetForManagedSession({
@@ -10374,7 +11144,7 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
10374
11144
  releaseTerminalLock();
10375
11145
  }
10376
11146
  }
10377
- 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 = [], observedHandoff = undefined, continuingTurnResponse = false }) {
11147
+ 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 = [], observedHandoff = undefined, verifiedEmptyCodexHandoff = undefined, continuingTurnResponse = false }) {
10378
11148
  const bridge = terminalBridgeEnabled(conversation);
10379
11149
  if (!terminalSendLockHeld) {
10380
11150
  const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
@@ -10397,6 +11167,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10397
11167
  allowedPreMaterializationIdentity,
10398
11168
  allowedAdditionalIdentities,
10399
11169
  observedHandoff,
11170
+ verifiedEmptyCodexHandoff,
10400
11171
  continuingTurnResponse
10401
11172
  });
10402
11173
  }
@@ -10437,6 +11208,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10437
11208
  allowedPreMaterializationIdentity,
10438
11209
  allowedAdditionalIdentities,
10439
11210
  observedHandoff,
11211
+ verifiedEmptyCodexHandoff,
10440
11212
  continuingTurnResponse
10441
11213
  });
10442
11214
  }
@@ -10464,6 +11236,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10464
11236
  allowedPreMaterializationIdentity,
10465
11237
  allowedAdditionalIdentities,
10466
11238
  observedHandoff,
11239
+ verifiedEmptyCodexHandoff,
10467
11240
  continuingTurnResponse
10468
11241
  }));
10469
11242
  }
@@ -10645,7 +11418,18 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10645
11418
  scrollbackLines: Number(options.scrollbackLines ?? 120),
10646
11419
  runtime: preSendRuntime
10647
11420
  });
10648
- assertSafeTerminalSend(executor.kind, status);
11421
+ if (verifiedEmptyCodexHandoff) {
11422
+ if (executor.kind !== "codex" ||
11423
+ status.reachable !== true ||
11424
+ status.approval_state.blocked === true ||
11425
+ !["idle", "unknown"].includes(status.activity_state)) {
11426
+ throw new Error(`Codex verified-empty handoff is not at a safe prompt ` +
11427
+ `(${status.activity_state}: ${status.activity_reason})`);
11428
+ }
11429
+ }
11430
+ else {
11431
+ assertSafeTerminalSend(executor.kind, status);
11432
+ }
10649
11433
  if (executor.kind === "codex" && needsPostSendNativeBinding) {
10650
11434
  if (pendingManagedNativeBinding || allowedPreMaterializationIdentity) {
10651
11435
  const expectedForegroundId = stringValue(sendTakeover?.terminal_agent_expected_session_id);
@@ -11015,7 +11799,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
11015
11799
  try {
11016
11800
  await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
11017
11801
  runtime: preSendRuntime,
11018
- requireExactComposerBeforeEnter: observedHandoff !== undefined,
11802
+ requireExactComposerBeforeEnter: observedHandoff !== undefined ||
11803
+ verifiedEmptyCodexHandoff !== undefined,
11019
11804
  beforeText: observedHandoff
11020
11805
  ? async () => {
11021
11806
  await assertObservedHandoffTransportBoundary({
@@ -11025,7 +11810,15 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
11025
11810
  requireEmptyComposer: true
11026
11811
  });
11027
11812
  }
11028
- : undefined,
11813
+ : verifiedEmptyCodexHandoff
11814
+ ? async () => {
11815
+ await assertVerifiedEmptyCodexTransportBoundary({
11816
+ options,
11817
+ boundary: verifiedEmptyCodexHandoff,
11818
+ requireEmptyComposer: true
11819
+ });
11820
+ }
11821
+ : undefined,
11029
11822
  beforeEnter: observedHandoff
11030
11823
  ? async () => {
11031
11824
  await assertObservedHandoffTransportBoundary({
@@ -11035,7 +11828,15 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
11035
11828
  requireEmptyComposer: false
11036
11829
  });
11037
11830
  }
11038
- : undefined,
11831
+ : verifiedEmptyCodexHandoff
11832
+ ? async () => {
11833
+ await assertVerifiedEmptyCodexTransportBoundary({
11834
+ options,
11835
+ boundary: verifiedEmptyCodexHandoff,
11836
+ requireEmptyComposer: false
11837
+ });
11838
+ }
11839
+ : undefined,
11039
11840
  async onTransportStage(event) {
11040
11841
  const stageAt = cliNow().toISOString();
11041
11842
  if (event.stage === "text_injected") {
@@ -11084,6 +11885,13 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
11084
11885
  requireEmptyComposer: false
11085
11886
  });
11086
11887
  }
11888
+ if (event.stage === "text_injected" && verifiedEmptyCodexHandoff) {
11889
+ await assertVerifiedEmptyCodexTransportBoundary({
11890
+ options,
11891
+ boundary: verifiedEmptyCodexHandoff,
11892
+ requireEmptyComposer: false
11893
+ });
11894
+ }
11087
11895
  try {
11088
11896
  appendEvent(logPath, {
11089
11897
  ts: stageAt,
@@ -12523,6 +13331,29 @@ async function resolveCurrentNativeAgentSessionIdentity({ options, agent, pid, c
12523
13331
  evidence: "claude_agents_exact_pid"
12524
13332
  };
12525
13333
  }
13334
+ /**
13335
+ * Preserve the security-significant difference between a successful native
13336
+ * identity probe that found no current Session and a probe that could not be
13337
+ * completed. `undefined` from the Codex provider is authoritative only after
13338
+ * both the process-incarnation and open-rollout inspections succeeded.
13339
+ */
13340
+ async function observeCurrentNativeAgentSessionIdentity(request) {
13341
+ try {
13342
+ const identity = await resolveCurrentNativeAgentSessionIdentity(request);
13343
+ return identity
13344
+ ? { status: "resolved", identity }
13345
+ : {
13346
+ status: "verified_absent",
13347
+ evidence: "native_identity_resolver_verified_absent"
13348
+ };
13349
+ }
13350
+ catch (error) {
13351
+ return {
13352
+ status: "unavailable",
13353
+ reason: error instanceof Error ? error.message : String(error)
13354
+ };
13355
+ }
13356
+ }
12526
13357
  function nativeAgentIdentityMatchesTurn(conversation, currentIdentity) {
12527
13358
  const takeover = isRecord(conversation.native_session_takeover)
12528
13359
  ? conversation.native_session_takeover
@@ -13333,14 +14164,23 @@ async function runReconcileMonitors(options) {
13333
14164
  }
13334
14165
  async function reconcileMonitors(options, { includeCallbackRecovery, reason, conversationId }) {
13335
14166
  const storeDir = storeDirFromOptions(options);
14167
+ const collateralStalls = await withStoreWriterLeaseAsync(storeDir, async () => reconcileTerminalBridgeCollateralStalls(storeDir, conversationId));
13336
14168
  const conversations = listConversations(storeDir).filter((conversation) => conversationId === undefined || conversation.conversation_id === conversationId);
13337
- const items = [];
14169
+ const items = [...collateralStalls.items];
14170
+ const repairedConversationIds = new Set(collateralStalls.items
14171
+ .filter((item) => item.status === "repaired")
14172
+ .map((item) => stringValue(item.conversation_id))
14173
+ .filter((id) => id !== undefined));
13338
14174
  let ignored = 0;
13339
14175
  let launched = 0;
13340
14176
  let alreadyRunning = 0;
13341
14177
  let skipped = 0;
13342
- let errors = 0;
14178
+ let errors = collateralStalls.errors.length;
13343
14179
  for (const listedConversation of conversations) {
14180
+ if (repairedConversationIds.has(listedConversation.conversation_id)) {
14181
+ ignored += 1;
14182
+ continue;
14183
+ }
13344
14184
  if (!matchesConfiguredWorkspace(options.workspace, listedConversation.workspace)) {
13345
14185
  ignored += 1;
13346
14186
  continue;
@@ -13574,6 +14414,9 @@ async function reconcileMonitors(options, { includeCallbackRecovery, reason, con
13574
14414
  reconciled: true,
13575
14415
  store_dir: storeDir,
13576
14416
  checked: conversations.length,
14417
+ repaired: collateralStalls.repaired,
14418
+ collateral_stalls_checked: collateralStalls.checked,
14419
+ collateral_stalls_skipped: collateralStalls.skipped,
13577
14420
  ignored,
13578
14421
  launched,
13579
14422
  already_running: alreadyRunning,
@@ -14040,7 +14883,7 @@ async function runObservedHandoffClose({ options, statePath, logPath, initialCon
14040
14883
  if (stringValue(options.reason) !== "superseded_by_human_context_switch") {
14041
14884
  throw new Error("a handoff close requires reason superseded_by_human_context_switch");
14042
14885
  }
14043
- const storeDir = storeDirFromOptions(options);
14886
+ const storeDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
14044
14887
  const sourceSessionId = sessionIdForConversation(initialConversation);
14045
14888
  const initialSource = loadManagedSession(storeDir, sourceSessionId);
14046
14889
  if (!initialSource.binding || initialSource.status !== "bound") {
@@ -14226,89 +15069,179 @@ async function runClose(options) {
14226
15069
  });
14227
15070
  return;
14228
15071
  }
15072
+ if (stringValue(options.reason) === "superseded_by_human_context_switch") {
15073
+ throw new Error("reason superseded_by_human_context_switch requires the fresh " +
15074
+ "expected_handoff_token advertised by AKK list");
15075
+ }
14229
15076
  const { statePath, logPath } = loaded;
15077
+ const closeStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
14230
15078
  const nativeTakeover = isRecord(loaded.conversation.native_session_takeover)
14231
15079
  ? loaded.conversation.native_session_takeover
14232
15080
  : undefined;
14233
15081
  const terminalControl = terminalControlFromTakeover(nativeTakeover);
14234
15082
  const releaseTerminalLock = terminalControl
14235
- ? acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 })
15083
+ ? acquireTerminalBridgeSendLock(closeStoreDir, terminalControl, { timeoutMs: 30000 })
14236
15084
  : () => { };
14237
- let releaseStateLock;
14238
- try {
14239
- releaseStateLock = acquireFileLock(`${statePath}.lock`);
14240
- const conversation = loadState(statePath);
14241
- const now = cliNow().toISOString();
14242
- const closeReason = options.reason ?? "closed by request";
14243
- const handoffDisposition = closeReason === "superseded_by_human_context_switch"
14244
- ? closeReason
14245
- : undefined;
14246
- const closed = {
14247
- ...conversation,
14248
- status: "closed",
14249
- closed_at: now,
14250
- close_reason: closeReason,
14251
- ...(handoffDisposition
14252
- ? { disposition: handoffDisposition }
14253
- : {}),
14254
- updated_at: now
14255
- };
14256
- saveState(statePath, closed);
14257
- let dispatchLedgerResolved = false;
14258
- let dispatchLedgerWarning;
14259
- if (terminalControl) {
14260
- try {
14261
- dispatchLedgerResolved = resolveTerminalBridgeDispatchLedger({
14262
- terminalControl,
14263
- conversation: closed,
14264
- expectedMessageId: stringValue(nativeTakeover?.terminal_bridge_message_id),
14265
- reason: "conversation explicitly closed by request"
14266
- });
15085
+ const closeWithFreshState = async () => {
15086
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
15087
+ try {
15088
+ const conversation = loadState(statePath);
15089
+ const currentTakeover = isRecord(conversation.native_session_takeover)
15090
+ ? conversation.native_session_takeover
15091
+ : undefined;
15092
+ const currentTerminalControl = terminalControlFromTakeover(currentTakeover);
15093
+ if (terminalControl &&
15094
+ !terminalControlsShareIncarnation(currentTerminalControl, terminalControl)) {
15095
+ throw new Error("terminal control changed after the close action was listed; refresh AKK list");
14267
15096
  }
14268
- catch (error) {
14269
- dispatchLedgerWarning =
14270
- error instanceof Error ? error.message : String(error);
14271
- runtimeLog("error", "terminal_dispatch_ledger_resolve_failed", {
14272
- conversation_id: closed.conversation_id,
14273
- terminal_target: terminalControl.target,
14274
- error: dispatchLedgerWarning
15097
+ if (terminalControl && currentTerminalControl) {
15098
+ await assertGenericCloseDoesNotBypassObservedHandoff({
15099
+ options,
15100
+ storeDir: closeStoreDir,
15101
+ conversation,
15102
+ terminalControl: currentTerminalControl
14275
15103
  });
14276
15104
  }
14277
- }
14278
- appendEvent(logPath, {
14279
- ts: now,
14280
- conversation_id: conversation.conversation_id,
14281
- event: "conversation_closed",
14282
- status: "closed",
14283
- reason: closed.close_reason
14284
- });
14285
- runtimeLog("info", "conversation_closed", {
14286
- conversation_id: conversation.conversation_id,
14287
- status: "closed",
14288
- reason: closed.close_reason,
14289
- state_path: statePath,
14290
- event_log_path: logPath
14291
- });
14292
- printJson({
14293
- conversation: closed,
14294
- closed: true,
14295
- terminal_dispatch_resolved: dispatchLedgerResolved,
14296
- ...(dispatchLedgerWarning
14297
- ? {
14298
- terminal_dispatch_warning: textSummary(dispatchLedgerWarning),
14299
- do_not_retry: true
15105
+ const now = cliNow().toISOString();
15106
+ const closeReason = options.reason ?? "closed by request";
15107
+ const closed = {
15108
+ ...conversation,
15109
+ status: "closed",
15110
+ closed_at: now,
15111
+ close_reason: closeReason,
15112
+ updated_at: now
15113
+ };
15114
+ saveState(statePath, closed);
15115
+ let dispatchLedgerResolved = false;
15116
+ let dispatchLedgerWarning;
15117
+ if (currentTerminalControl) {
15118
+ try {
15119
+ dispatchLedgerResolved = resolveTerminalBridgeDispatchLedger({
15120
+ terminalControl: currentTerminalControl,
15121
+ conversation: closed,
15122
+ expectedMessageId: stringValue(currentTakeover?.terminal_bridge_message_id),
15123
+ reason: "conversation explicitly closed by request"
15124
+ });
14300
15125
  }
14301
- : {})
14302
- });
14303
- }
14304
- finally {
14305
- try {
14306
- releaseStateLock?.();
15126
+ catch (error) {
15127
+ dispatchLedgerWarning =
15128
+ error instanceof Error ? error.message : String(error);
15129
+ runtimeLog("error", "terminal_dispatch_ledger_resolve_failed", {
15130
+ conversation_id: closed.conversation_id,
15131
+ terminal_target: currentTerminalControl.target,
15132
+ error: dispatchLedgerWarning
15133
+ });
15134
+ }
15135
+ }
15136
+ appendEvent(logPath, {
15137
+ ts: now,
15138
+ conversation_id: conversation.conversation_id,
15139
+ event: "conversation_closed",
15140
+ status: "closed",
15141
+ reason: closed.close_reason
15142
+ });
15143
+ runtimeLog("info", "conversation_closed", {
15144
+ conversation_id: conversation.conversation_id,
15145
+ status: "closed",
15146
+ reason: closed.close_reason,
15147
+ state_path: statePath,
15148
+ event_log_path: logPath
15149
+ });
15150
+ printJson({
15151
+ conversation: closed,
15152
+ closed: true,
15153
+ terminal_dispatch_resolved: dispatchLedgerResolved,
15154
+ ...(dispatchLedgerWarning
15155
+ ? {
15156
+ terminal_dispatch_warning: textSummary(dispatchLedgerWarning),
15157
+ do_not_retry: true
15158
+ }
15159
+ : {})
15160
+ });
14307
15161
  }
14308
15162
  finally {
14309
- releaseTerminalLock();
15163
+ releaseStateLock();
15164
+ }
15165
+ };
15166
+ try {
15167
+ if (terminalControl) {
15168
+ await withStoreWriterLeaseAsync(closeStoreDir, closeWithFreshState);
15169
+ }
15170
+ else {
15171
+ await closeWithFreshState();
14310
15172
  }
14311
15173
  }
15174
+ finally {
15175
+ releaseTerminalLock();
15176
+ }
15177
+ }
15178
+ async function assertGenericCloseDoesNotBypassObservedHandoff({ options, storeDir, conversation, terminalControl }) {
15179
+ if (!SESSION_SEND_BLOCKING_STATUSES.has(conversation.status)) {
15180
+ return;
15181
+ }
15182
+ const sourceSession = tryLoadManagedSession(storeDir, sessionIdForConversation(conversation));
15183
+ if (sourceSession?.status !== "bound" ||
15184
+ !sourceSession.binding ||
15185
+ !terminalControlsShareIncarnation(sourceSession.binding.terminal_control, terminalControl)) {
15186
+ return;
15187
+ }
15188
+ const takeover = isRecord(conversation.native_session_takeover)
15189
+ ? conversation.native_session_takeover
15190
+ : undefined;
15191
+ const pid = Number(takeover?.terminal_agent_pid);
15192
+ if (!Number.isSafeInteger(pid) || pid <= 1) {
15193
+ return;
15194
+ }
15195
+ const bridge = createTerminalAgentBridge(options);
15196
+ let terminal;
15197
+ try {
15198
+ terminal = await bridge.resolveStoredTerminal(sourceSession.agent, pid, terminalControl, { pid });
15199
+ }
15200
+ catch (error) {
15201
+ if (!(error instanceof TerminalControlUnavailableError)) {
15202
+ throw error;
15203
+ }
15204
+ // An unavailable terminal cannot prove a live A -> B handoff. Preserve the
15205
+ // explicit Store-only close path used to retire unavailable Turn history.
15206
+ return;
15207
+ }
15208
+ const companions = sourceSession.agent === "codex"
15209
+ ? codexAllowedCompanionSetForManagedSession({
15210
+ storeDir,
15211
+ session: sourceSession
15212
+ })
15213
+ : { additional: [] };
15214
+ const identityObservation = await observeCurrentNativeAgentSessionIdentity({
15215
+ options,
15216
+ agent: terminal.agent,
15217
+ pid: terminal.pid,
15218
+ cwd: terminal.terminalControl.currentPath,
15219
+ preferredSessionId: companions.primary
15220
+ ? sourceSession.binding.native_thread_id
15221
+ : undefined,
15222
+ allowedCompanionIdentity: companions.primary,
15223
+ allowedAdditionalIdentities: companions.additional
15224
+ });
15225
+ const resolvedIdentity = identityObservation.status === "resolved"
15226
+ ? identityObservation.identity
15227
+ : undefined;
15228
+ const observed = await observedExternalHandoffIdentity({
15229
+ options,
15230
+ terminal,
15231
+ sourceSession,
15232
+ resolvedIdentity,
15233
+ requireSafeTerminal: false
15234
+ });
15235
+ if (managedBindingConflictKindForResolvedTerminal({
15236
+ storeDir,
15237
+ session: sourceSession,
15238
+ terminal,
15239
+ identity: observed.identity
15240
+ }) === "live_external_thread_change") {
15241
+ throw new Error(`terminal ${terminalControl.target} changed native thread after the ` +
15242
+ "generic close action was listed; refresh AKK list and use only its " +
15243
+ "fresh snapshot-bound handoff_decision");
15244
+ }
14312
15245
  }
14313
15246
  async function runTerminalDispatchClose({ options, terminalConversation }) {
14314
15247
  const terminalControl = terminalConversation.terminalControl;
@@ -17991,17 +18924,20 @@ async function probeManualCodexLifecycleRecoveryIdentity({ options, terminal, tr
17991
18924
  if (!cleared?.screen.digest) {
17992
18925
  throw new Error("Codex composer did not become empty after recovery clear-line");
17993
18926
  }
17994
- await assertCodexComposerReadyForAutomatedInput({
17995
- options,
17996
- terminalControl: terminal.terminalControl
17997
- });
17998
- const baselineDigest = cleared.screen.digest;
17999
- await bridge.send(terminal.agent, terminal.terminalControl, "/status", { runtime: recoveryRuntime });
18927
+ const agentVersion = agentVersionForRunningProcess(terminal.agent, terminal.pid, options);
18928
+ if (!agentVersion) {
18929
+ throw new Error("Codex lifecycle recovery /status requires the exact running version before terminal input");
18930
+ }
18931
+ const submission = await bridge.submitCodexStatusProbe(terminal.terminalControl, agentVersion, { runtime: recoveryRuntime });
18932
+ const baselineDigest = submission.observationBaselineDigest;
18000
18933
  let stable;
18001
18934
  let stableCount = 0;
18002
18935
  let lastObservationReason = "no fresh idle status screen was captured";
18003
18936
  for (let attempt = 0; attempt < 12; attempt += 1) {
18004
- const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: recoveryRuntime });
18937
+ const status = await bridge.status(terminal.agent, terminal.terminalControl, {
18938
+ runtime: recoveryRuntime,
18939
+ scrollbackLines: submission.observationScrollbackLines
18940
+ });
18005
18941
  if (status.reachable &&
18006
18942
  status.activity_state === "idle" &&
18007
18943
  !status.approval_state.blocked &&
@@ -18232,14 +19168,7 @@ function claudeComposerVisible(screen) {
18232
19168
  .some((line) => /^\s*❯(?:\s|$)/u.test(line));
18233
19169
  }
18234
19170
  function claudeComposerEmpty(screen) {
18235
- const lines = String(screen ?? "").split(/\r?\n/u);
18236
- while (lines.length > 0 && lines.at(-1)?.trim() === "") {
18237
- lines.pop();
18238
- }
18239
- const composers = lines
18240
- .slice(-8)
18241
- .filter((line) => /^\s*❯(?:\s|$)/u.test(line));
18242
- return composers.length === 1 && /^\s*❯\s*$/u.test(composers[0]);
19171
+ return isExactClaudeIdleComposer(String(screen ?? ""));
18243
19172
  }
18244
19173
  function assertManualLifecycleProcessIncarnation(transition, live) {
18245
19174
  if (live.processUuid !== transition.before_process_uuid ||