@scotthuang/agent-knock-knock 0.12.3 → 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,7 +20,7 @@ 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";
@@ -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,
@@ -2999,9 +3302,7 @@ 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));
3305
+ const terminalHasBlockingTurn = terminalIncarnationBlockingTurns(storeDirFromOptions(options), terminalControl).length > 0;
3005
3306
  let automatedInputComposerReady = nativeInspectionComposerEmpty(session.agent, terminalState.screen_excerpt);
3006
3307
  if (session.agent === "codex" &&
3007
3308
  (terminalState.activity_state === "idle" ||
@@ -3078,7 +3379,7 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
3078
3379
  }
3079
3380
  : {}),
3080
3381
  commands: {
3081
- send: true,
3382
+ send: !terminalHasBlockingTurn,
3082
3383
  approve: terminalControl.capabilities.includes("terminal_approval") &&
3083
3384
  terminalState.approval_state.approvable === true,
3084
3385
  status: true,
@@ -3086,7 +3387,8 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
3086
3387
  close: orphanedDispatch !== undefined,
3087
3388
  new_thread: lifecycleCapability.status === "supported" &&
3088
3389
  lifecycleCapability.newThread === true &&
3089
- codexLifecycleIncarnationAvailable,
3390
+ codexLifecycleIncarnationAvailable &&
3391
+ !terminalHasBlockingTurn,
3090
3392
  list_resumable_threads: lifecycleCapability.status === "supported" &&
3091
3393
  lifecycleCapability.resumeExact === true &&
3092
3394
  codexLifecycleIncarnationAvailable,
@@ -3101,7 +3403,7 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
3101
3403
  : Boolean(nativeAgentIdentity?.sessionId &&
3102
3404
  nativeAgentIdentity.processUuid)) &&
3103
3405
  orphanedDispatch === undefined &&
3104
- !nativeInspectionHasBlockingTurn
3406
+ !terminalHasBlockingTurn
3105
3407
  }
3106
3408
  };
3107
3409
  const availableActions = availableListActions(entry);
@@ -3283,6 +3585,13 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3283
3585
  })
3284
3586
  : undefined;
3285
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)));
3286
3595
  const nativeIdentityObservation = isRecord(terminal.native_agent_identity_observation)
3287
3596
  ? terminal.native_agent_identity_observation
3288
3597
  : undefined;
@@ -3327,6 +3636,7 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3327
3636
  !(isRecord(terminal.approval_state) &&
3328
3637
  terminal.approval_state.blocked === true) &&
3329
3638
  blockingHandoffTurns.length === 0 &&
3639
+ terminalBlockingTurns.length === 0 &&
3330
3640
  !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
3331
3641
  !verifiedEmptySourceActiveElsewhere &&
3332
3642
  terminal.orphaned_terminal_dispatch === undefined &&
@@ -3361,6 +3671,7 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3361
3671
  !(isRecord(terminal.approval_state) &&
3362
3672
  terminal.approval_state.blocked === true) &&
3363
3673
  !conflictingSessionTurns.some((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status)) &&
3674
+ terminalBlockingTurns.length === 0 &&
3364
3675
  !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session)
3365
3676
  ? {
3366
3677
  tool: "agent_knock_knock_reconcile_binding",
@@ -3383,6 +3694,7 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3383
3694
  !(isRecord(terminal.approval_state) &&
3384
3695
  terminal.approval_state.blocked === true) &&
3385
3696
  !conflictingSessionTurns.some((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status)) &&
3697
+ terminalBlockingTurns.length === 0 &&
3386
3698
  !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
3387
3699
  externalHandoffTarget?.status === "eligible" &&
3388
3700
  isRecord(rawActions.send) &&
@@ -3394,7 +3706,8 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3394
3706
  !(isRecord(terminal.approval_state) &&
3395
3707
  terminal.approval_state.blocked === true) &&
3396
3708
  !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
3397
- blockingHandoffTurns.length === 1
3709
+ blockingHandoffTurns.length === 1 &&
3710
+ terminalBlockingTurns.every((turn) => turn.conversation_id === blockingHandoffTurns[0].conversation_id)
3398
3711
  ? blockingHandoffTurns[0]
3399
3712
  : undefined;
3400
3713
  const handoffDecisionToken = handoffDecisionTurn &&
@@ -3434,6 +3747,14 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3434
3747
  }
3435
3748
  }
3436
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));
3437
3758
  const terminalCanAcceptSend = ownership.state === "none" && isRecord(sessionAwareRawActions.send);
3438
3759
  if (ownership.state === "current" &&
3439
3760
  !allRelated.some((conversation) => conversation.conversation_id === ownership.conversation.conversation_id)) {
@@ -3470,9 +3791,12 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3470
3791
  const currentTurnValue = ownership.state === "current"
3471
3792
  ? currentManagedTurnForTerminal(ownership.conversation, terminal, sessionAwareRawActions)
3472
3793
  : undefined;
3473
- const currentTurn = currentTurnValue && !mutationsAllowed
3794
+ const currentTurnProjection = currentTurnValue && !mutationsAllowed
3474
3795
  ? readOnlyManagedTurn(currentTurnValue)
3475
3796
  : currentTurnValue;
3797
+ const currentTurn = currentTurnProjection
3798
+ ? withoutGenericHandoffSourceClose(currentTurnProjection, blockingHandoffTurnIds)
3799
+ : undefined;
3476
3800
  const sortedDisplayed = [...sessionDisplayedRelated]
3477
3801
  .filter((conversation) => conversation.conversation_id !== currentTurn?.conversation_id)
3478
3802
  .sort(compareManagedConversationRecency);
@@ -3480,15 +3804,18 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3480
3804
  const recentTurnValue = recentConversation
3481
3805
  ? historicalManagedTurnForTerminal(recentConversation, terminalCanAcceptSend, terminal)
3482
3806
  : undefined;
3483
- const recentTurn = recentTurnValue && !mutationsAllowed
3807
+ const recentTurnProjection = recentTurnValue && !mutationsAllowed
3484
3808
  ? readOnlyManagedTurn(recentTurnValue)
3485
3809
  : recentTurnValue;
3810
+ const recentTurn = recentTurnProjection
3811
+ ? withoutGenericHandoffSourceClose(recentTurnProjection, blockingHandoffTurnIds)
3812
+ : undefined;
3486
3813
  const historyConversations = includeAll
3487
3814
  ? sortedDisplayed.filter((conversation) => conversation.conversation_id !== recentConversation?.conversation_id)
3488
3815
  : [];
3489
3816
  const history = historyConversations.map((conversation) => {
3490
3817
  const turn = historicalManagedTurnForTerminal(conversation, terminalCanAcceptSend, terminal);
3491
- return mutationsAllowed ? turn : readOnlyManagedTurn(turn);
3818
+ return withoutGenericHandoffSourceClose(mutationsAllowed ? turn : readOnlyManagedTurn(turn), blockingHandoffTurnIds);
3492
3819
  });
3493
3820
  const visibleTurnIds = new Set([currentTurn, recentTurn, ...history]
3494
3821
  .map((turn) => stringValue(turn?.conversation_id))
@@ -3602,17 +3929,45 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3602
3929
  handoff_state: externalHandoffAdoptable
3603
3930
  ? "external_handoff_adoptable"
3604
3931
  : "external_handoff_blocked",
3605
- ...(automatedInputComposerReady !== true
3932
+ ...(terminalRecoveryBlockingTurns.length > 0
3606
3933
  ? {
3607
- handoff_blocked_reason: "the current terminal composer is not an exact empty idle frame"
3934
+ handoff_blocked_reason: "the terminal has unresolved managed Turn state; use only a listed blocking_turns recovery action"
3608
3935
  }
3609
- : {})
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
+ : {})
3610
3949
  }
3611
3950
  : {}),
3612
3951
  ...(verifiedEmptyCodexSnapshotToken
3613
3952
  ? { handoff_state: "verified_empty_native_session_adoptable" }
3614
3953
  : {}),
3615
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
+ : {}),
3616
3971
  managed: management,
3617
3972
  available_actions: availableActions
3618
3973
  };
@@ -3855,6 +4210,21 @@ function terminalControlForManagedConversation(conversation) {
3855
4210
  ? conversation.native_session_takeover
3856
4211
  : undefined);
3857
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
+ }
3858
4228
  function compareManagedConversationRecency(left, right) {
3859
4229
  const leftTime = Date.parse(String(left.updated_at ?? left.created_at ?? ""));
3860
4230
  const rightTime = Date.parse(String(right.updated_at ?? right.created_at ?? ""));
@@ -4074,6 +4444,20 @@ function readOnlyManagedTurn(managedTurn) {
4074
4444
  : {})
4075
4445
  };
4076
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
+ }
4077
4461
  function historicalManagedTurnForTerminal(conversation, terminalCanAcceptSend, terminal) {
4078
4462
  const managedTurn = managedTurnListEntry(summarizeConversation(conversation), {
4079
4463
  terminalBridge: terminalBridgeEnabled(conversation),
@@ -4311,9 +4695,9 @@ function managedListApprovalState(conversation) {
4311
4695
  }
4312
4696
  function listActionContracts() {
4313
4697
  return {
4314
- version: 9,
4698
+ version: 10,
4315
4699
  instructions: [
4316
- "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.",
4317
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.",
4318
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.",
4319
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.",
@@ -4367,6 +4751,12 @@ function listActionContracts() {
4367
4751
  authoritative_action_path: "terminals[].handoff_decision.choices.take_over_current.action",
4368
4752
  requires_explicit_user_confirmation: true,
4369
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"
4370
4760
  }
4371
4761
  },
4372
4762
  actions: {
@@ -5473,14 +5863,9 @@ async function verifyCodexPendingManagedSendStatus({ options, terminal, session,
5473
5863
  if (!codexComposerVisible(initialStatus.screen.excerpt)) {
5474
5864
  throw new Error("Codex status-card verification requires a visible idle composer");
5475
5865
  }
5476
- await assertCodexComposerReadyForAutomatedInput({
5477
- options,
5478
- terminalControl: terminal.terminalControl
5479
- });
5480
5866
  const observed = await probeCodexCurrentThread({
5481
5867
  options,
5482
5868
  terminal,
5483
- initialStatus,
5484
5869
  currentIdentity: logicalIdentity,
5485
5870
  runtimeOverride: runtime
5486
5871
  });
@@ -6044,10 +6429,12 @@ function assertExpectedHandoffTokenUsesExactTerminalSelector({ options, terminal
6044
6429
  "conversation selector advertised by AKK list");
6045
6430
  }
6046
6431
  }
6047
- async function observedExternalHandoffIdentity({ options, terminal, sourceSession, resolvedIdentity }) {
6432
+ async function observedExternalHandoffIdentity({ options, terminal, sourceSession, resolvedIdentity, requireSafeTerminal = true }) {
6048
6433
  const bridge = createTerminalAgentBridge(options);
6049
6434
  const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
6050
- assertSafeTerminalSend(terminal.agent, status);
6435
+ if (requireSafeTerminal) {
6436
+ assertSafeTerminalSend(terminal.agent, status);
6437
+ }
6051
6438
  if (terminal.agent !== "codex") {
6052
6439
  return { identity: resolvedIdentity, status };
6053
6440
  }
@@ -6619,10 +7006,7 @@ function assertTerminalLifecycleReady({ options, terminal, terminalStatus }) {
6619
7006
  throw new Error(`terminal ${terminal.terminalControl.target} is not at a verified idle prompt ` +
6620
7007
  `(${terminalStatus.activity_state}: ${terminalStatus.activity_reason})`);
6621
7008
  }
6622
- const blockers = listConversations(storeDirFromOptions(options))
6623
- .filter(isDiscoverableTmuxConversation)
6624
- .filter((turn) => terminalControlsShareIncarnation(terminalControlForManagedConversation(turn), terminal.terminalControl) &&
6625
- SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
7009
+ const blockers = terminalIncarnationBlockingTurns(storeDirFromOptions(options), terminal.terminalControl);
6626
7010
  if (blockers.length > 0) {
6627
7011
  throw new Error(`terminal ${terminal.terminalControl.target} still has unresolved Turn ` +
6628
7012
  `${turnIdForConversation(blockers[0])} (${blockers[0].status})`);
@@ -7302,10 +7686,7 @@ function assertTerminalNativeInspectionReady({ options, terminal, terminalStatus
7302
7686
  throw new Error(`terminal ${terminal.terminalControl.target} is not at a verified idle prompt ` +
7303
7687
  `(${terminalStatus.activity_state}: ${terminalStatus.activity_reason})`);
7304
7688
  }
7305
- const blocker = listConversations(storeDirFromOptions(options))
7306
- .filter(isDiscoverableTmuxConversation)
7307
- .find((turn) => terminalControlsShareIncarnation(terminalControlForManagedConversation(turn), terminal.terminalControl) &&
7308
- SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
7689
+ const blocker = terminalIncarnationBlockingTurns(storeDirFromOptions(options), terminal.terminalControl)[0];
7309
7690
  if (blocker) {
7310
7691
  throw new Error(`terminal ${terminal.terminalControl.target} still has unresolved Turn ` +
7311
7692
  `${turnIdForConversation(blocker)} (${blocker.status})`);
@@ -7749,6 +8130,7 @@ async function waitForVerifiedThreadTransition({ options, terminal, beforeIdenti
7749
8130
  const bridge = createTerminalAgentBridge(options);
7750
8131
  let probeSent = false;
7751
8132
  let postProbeBaselineDigest;
8133
+ let postProbeObservationScrollbackLines;
7752
8134
  let stableIdentity;
7753
8135
  let stableCount = 0;
7754
8136
  const physicalBeforeFence = codexIdentityFence(physicalBeforeIdentity);
@@ -7816,22 +8198,37 @@ async function waitForVerifiedThreadTransition({ options, terminal, beforeIdenti
7816
8198
  }
7817
8199
  else {
7818
8200
  const afterProbe = plan.steps.find((step) => step.kind === "identity_probe_after");
7819
- if (!afterProbe || afterProbe.effect !== "read_only") {
8201
+ if (!afterProbe ||
8202
+ afterProbe.effect !== "read_only" ||
8203
+ afterProbe.command !== "/status") {
7820
8204
  throw new Error("the Codex lifecycle plan has no exact post-transition identity probe");
7821
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
+ }
7822
8210
  const physicalRuntime = terminalRuntimeForLiveIdentity({
7823
8211
  terminal,
7824
8212
  physicalOnly: true
7825
8213
  });
7826
- 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
+ });
7827
8222
  const screenDigest = stringValue(status.screen.digest);
7828
8223
  if (!probeSent &&
7829
8224
  status.reachable &&
7830
8225
  status.activity_state === "idle" &&
7831
8226
  screenDigest !== undefined &&
7832
8227
  screenDigest !== initialScreenDigest) {
7833
- postProbeBaselineDigest = screenDigest;
7834
- 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;
7835
8232
  probeSent = true;
7836
8233
  }
7837
8234
  else if (status.reachable &&
@@ -7960,7 +8357,7 @@ function assertVerifiedClaudeLifecycleBeforeObservation({ options, terminal, ide
7960
8357
  `for the current process incarnation${observed?.reason ? `: ${observed.reason}` : ""}`);
7961
8358
  }
7962
8359
  }
7963
- async function probeCodexCurrentThread({ options, terminal, initialStatus, currentIdentity, runtimeIdentity, runtimeOverride }) {
8360
+ async function probeCodexCurrentThread({ options, terminal, currentIdentity, runtimeIdentity, runtimeOverride }) {
7964
8361
  if (terminal.agent !== "codex") {
7965
8362
  throw new Error("the current native thread identity is unavailable");
7966
8363
  }
@@ -7972,24 +8369,31 @@ async function probeCodexCurrentThread({ options, terminal, initialStatus, curre
7972
8369
  terminal,
7973
8370
  expectedEmptyNativeSession: true
7974
8371
  }));
7975
- // The plain activity parser intentionally recognizes Codex's composer even
7976
- // when a footer follows it, so "idle" alone is not authority to press Enter.
7977
- // Re-read the styled pane immediately before the slash probe and accept only
7978
- // an empty composer (or Codex's fully dim replace-on-type placeholder).
7979
- await assertCodexComposerReadyForAutomatedInput({
7980
- options,
7981
- terminalControl: terminal.terminalControl
7982
- });
7983
- 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;
7984
8382
  for (let attempt = 0; attempt < 50; attempt += 1) {
7985
- 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
+ });
7986
8387
  if (status.reachable &&
7987
- status.screen.digest !== initialStatus.screen.digest) {
8388
+ status.screen.digest !== submission.observationBaselineDigest) {
7988
8389
  const observed = terminal.adapter.observeThreadLifecycle?.({
7989
8390
  operation: { kind: "new_thread" },
7990
8391
  phase: "before",
7991
8392
  screen: status.screen.excerpt ?? ""
7992
8393
  });
8394
+ lastObservationReason = observed?.reason ?? lastObservationReason;
8395
+ sawIncompleteSessionUuid ||= observed?.status === "missing" &&
8396
+ /complete Session UUID/iu.test(observed.reason ?? "");
7993
8397
  if (observed?.status === "observed" &&
7994
8398
  observed.nativeThreadId &&
7995
8399
  status.activity_state === "idle") {
@@ -8006,7 +8410,12 @@ async function probeCodexCurrentThread({ options, terminal, initialStatus, curre
8006
8410
  }
8007
8411
  await cliSleep(100);
8008
8412
  }
8009
- 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}`);
8010
8419
  }
8011
8420
  async function runNewThread(options) {
8012
8421
  const requireRestorableOrigin = options.requireRestorableOrigin;
@@ -8363,7 +8772,6 @@ async function runNativeThreadTransition(options, operation) {
8363
8772
  const statusIdentity = await probeCodexCurrentThread({
8364
8773
  options,
8365
8774
  terminal,
8366
- initialStatus: terminalStatus,
8367
8775
  currentIdentity: beforeIdentity,
8368
8776
  runtimeIdentity: snapshot.runtimeIdentity,
8369
8777
  runtimeOverride: beforeRuntime
@@ -9027,17 +9435,21 @@ async function reconcileStoreForStatus(storeDir, options, conversationId) {
9027
9435
  }
9028
9436
  throw error;
9029
9437
  }
9030
- const idle = reconcileIdleConversations(storeDir, options, cliNow(), conversationId);
9031
9438
  const monitors = await reconcileMonitors(options, {
9032
9439
  includeCallbackRecovery: false,
9033
9440
  reason: "status_reconciliation",
9034
9441
  conversationId
9035
9442
  });
9443
+ const idle = reconcileIdleConversations(storeDir, options, cliNow(), conversationId);
9036
9444
  return {
9037
9445
  status: "completed",
9038
9446
  checked: Math.max(idle.checked, monitors.checked),
9039
- changed: idle.closed + monitors.launched,
9447
+ changed: idle.closed + monitors.launched + monitors.repaired,
9040
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"),
9041
9453
  monitors_launched: monitors.launched,
9042
9454
  monitors_already_running: monitors.already_running,
9043
9455
  skipped: idle.skipped + monitors.skipped,
@@ -9564,6 +9976,7 @@ async function runSend(options) {
9564
9976
  })) {
9565
9977
  return;
9566
9978
  }
9979
+ assertTerminalIncarnationCanStartTurn(rawStoreDir, terminalConversation.terminalControl);
9567
9980
  let claimedSession = soleBoundManagedSessionClaimForTerminal(rawStoreDir, terminalConversation);
9568
9981
  let knownCodexCompanions = claimedSession
9569
9982
  ? codexAllowedCompanionSetForManagedSession({
@@ -9854,6 +10267,7 @@ async function runSend(options) {
9854
10267
  })) {
9855
10268
  return;
9856
10269
  }
10270
+ assertTerminalIncarnationCanStartTurn(storeDir, resolvedTerminal.terminalControl);
9857
10271
  let currentSession = tryLoadManagedSession(storeDir, sessionId);
9858
10272
  const knownCodexCompanions = currentSession
9859
10273
  ? codexAllowedCompanionSetForManagedSession({
@@ -13750,14 +14164,23 @@ async function runReconcileMonitors(options) {
13750
14164
  }
13751
14165
  async function reconcileMonitors(options, { includeCallbackRecovery, reason, conversationId }) {
13752
14166
  const storeDir = storeDirFromOptions(options);
14167
+ const collateralStalls = await withStoreWriterLeaseAsync(storeDir, async () => reconcileTerminalBridgeCollateralStalls(storeDir, conversationId));
13753
14168
  const conversations = listConversations(storeDir).filter((conversation) => conversationId === undefined || conversation.conversation_id === conversationId);
13754
- 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));
13755
14174
  let ignored = 0;
13756
14175
  let launched = 0;
13757
14176
  let alreadyRunning = 0;
13758
14177
  let skipped = 0;
13759
- let errors = 0;
14178
+ let errors = collateralStalls.errors.length;
13760
14179
  for (const listedConversation of conversations) {
14180
+ if (repairedConversationIds.has(listedConversation.conversation_id)) {
14181
+ ignored += 1;
14182
+ continue;
14183
+ }
13761
14184
  if (!matchesConfiguredWorkspace(options.workspace, listedConversation.workspace)) {
13762
14185
  ignored += 1;
13763
14186
  continue;
@@ -13991,6 +14414,9 @@ async function reconcileMonitors(options, { includeCallbackRecovery, reason, con
13991
14414
  reconciled: true,
13992
14415
  store_dir: storeDir,
13993
14416
  checked: conversations.length,
14417
+ repaired: collateralStalls.repaired,
14418
+ collateral_stalls_checked: collateralStalls.checked,
14419
+ collateral_stalls_skipped: collateralStalls.skipped,
13994
14420
  ignored,
13995
14421
  launched,
13996
14422
  already_running: alreadyRunning,
@@ -14457,7 +14883,7 @@ async function runObservedHandoffClose({ options, statePath, logPath, initialCon
14457
14883
  if (stringValue(options.reason) !== "superseded_by_human_context_switch") {
14458
14884
  throw new Error("a handoff close requires reason superseded_by_human_context_switch");
14459
14885
  }
14460
- const storeDir = storeDirFromOptions(options);
14886
+ const storeDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
14461
14887
  const sourceSessionId = sessionIdForConversation(initialConversation);
14462
14888
  const initialSource = loadManagedSession(storeDir, sourceSessionId);
14463
14889
  if (!initialSource.binding || initialSource.status !== "bound") {
@@ -14643,88 +15069,178 @@ async function runClose(options) {
14643
15069
  });
14644
15070
  return;
14645
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
+ }
14646
15076
  const { statePath, logPath } = loaded;
15077
+ const closeStoreDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
14647
15078
  const nativeTakeover = isRecord(loaded.conversation.native_session_takeover)
14648
15079
  ? loaded.conversation.native_session_takeover
14649
15080
  : undefined;
14650
15081
  const terminalControl = terminalControlFromTakeover(nativeTakeover);
14651
15082
  const releaseTerminalLock = terminalControl
14652
- ? acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 })
15083
+ ? acquireTerminalBridgeSendLock(closeStoreDir, terminalControl, { timeoutMs: 30000 })
14653
15084
  : () => { };
14654
- let releaseStateLock;
14655
- try {
14656
- releaseStateLock = acquireFileLock(`${statePath}.lock`);
14657
- const conversation = loadState(statePath);
14658
- const now = cliNow().toISOString();
14659
- const closeReason = options.reason ?? "closed by request";
14660
- const handoffDisposition = closeReason === "superseded_by_human_context_switch"
14661
- ? closeReason
14662
- : undefined;
14663
- const closed = {
14664
- ...conversation,
14665
- status: "closed",
14666
- closed_at: now,
14667
- close_reason: closeReason,
14668
- ...(handoffDisposition
14669
- ? { disposition: handoffDisposition }
14670
- : {}),
14671
- updated_at: now
14672
- };
14673
- saveState(statePath, closed);
14674
- let dispatchLedgerResolved = false;
14675
- let dispatchLedgerWarning;
14676
- if (terminalControl) {
14677
- try {
14678
- dispatchLedgerResolved = resolveTerminalBridgeDispatchLedger({
14679
- terminalControl,
14680
- conversation: closed,
14681
- expectedMessageId: stringValue(nativeTakeover?.terminal_bridge_message_id),
14682
- reason: "conversation explicitly closed by request"
14683
- });
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");
14684
15096
  }
14685
- catch (error) {
14686
- dispatchLedgerWarning =
14687
- error instanceof Error ? error.message : String(error);
14688
- runtimeLog("error", "terminal_dispatch_ledger_resolve_failed", {
14689
- conversation_id: closed.conversation_id,
14690
- terminal_target: terminalControl.target,
14691
- error: dispatchLedgerWarning
15097
+ if (terminalControl && currentTerminalControl) {
15098
+ await assertGenericCloseDoesNotBypassObservedHandoff({
15099
+ options,
15100
+ storeDir: closeStoreDir,
15101
+ conversation,
15102
+ terminalControl: currentTerminalControl
14692
15103
  });
14693
15104
  }
14694
- }
14695
- appendEvent(logPath, {
14696
- ts: now,
14697
- conversation_id: conversation.conversation_id,
14698
- event: "conversation_closed",
14699
- status: "closed",
14700
- reason: closed.close_reason
14701
- });
14702
- runtimeLog("info", "conversation_closed", {
14703
- conversation_id: conversation.conversation_id,
14704
- status: "closed",
14705
- reason: closed.close_reason,
14706
- state_path: statePath,
14707
- event_log_path: logPath
14708
- });
14709
- printJson({
14710
- conversation: closed,
14711
- closed: true,
14712
- terminal_dispatch_resolved: dispatchLedgerResolved,
14713
- ...(dispatchLedgerWarning
14714
- ? {
14715
- terminal_dispatch_warning: textSummary(dispatchLedgerWarning),
14716
- 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
+ });
14717
15125
  }
14718
- : {})
14719
- });
14720
- }
14721
- finally {
14722
- try {
14723
- 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
+ });
14724
15161
  }
14725
15162
  finally {
14726
- releaseTerminalLock();
15163
+ releaseStateLock();
15164
+ }
15165
+ };
15166
+ try {
15167
+ if (terminalControl) {
15168
+ await withStoreWriterLeaseAsync(closeStoreDir, closeWithFreshState);
15169
+ }
15170
+ else {
15171
+ await closeWithFreshState();
15172
+ }
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;
14727
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");
14728
15244
  }
14729
15245
  }
14730
15246
  async function runTerminalDispatchClose({ options, terminalConversation }) {
@@ -18408,17 +18924,20 @@ async function probeManualCodexLifecycleRecoveryIdentity({ options, terminal, tr
18408
18924
  if (!cleared?.screen.digest) {
18409
18925
  throw new Error("Codex composer did not become empty after recovery clear-line");
18410
18926
  }
18411
- await assertCodexComposerReadyForAutomatedInput({
18412
- options,
18413
- terminalControl: terminal.terminalControl
18414
- });
18415
- const baselineDigest = cleared.screen.digest;
18416
- 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;
18417
18933
  let stable;
18418
18934
  let stableCount = 0;
18419
18935
  let lastObservationReason = "no fresh idle status screen was captured";
18420
18936
  for (let attempt = 0; attempt < 12; attempt += 1) {
18421
- 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
+ });
18422
18941
  if (status.reachable &&
18423
18942
  status.activity_state === "idle" &&
18424
18943
  !status.approval_state.blocked &&