@scotthuang/agent-knock-knock 0.12.0 → 0.12.2

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.
@@ -16,10 +16,10 @@ import { EXECUTOR_KINDS, executorDefinitionForKind, isExecutorKind } from "./exe
16
16
  import { redactString, writeRuntimeLog } from "./runtime-log.js";
17
17
  import { formatTranscript, readNdjsonLog } from "./transcript.js";
18
18
  import { appendEvent, assertStoreWriterCompatible, defaultStoreDir, ensureDir, ensureStoreWritable, inspectStoreCompatibility, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, StoreLockTimeoutError, statePathForConversationId, withStoreWriterLease, withStoreWriterLeaseAsync } from "./store.js";
19
- import { createManagedSessionId, createNativeThreadTransitionId, isExactNativeThreadId, legacyManagedSessionBindingToken, legacyUnmanagedTerminalBindingToken, managedSessionBindingToken, nativeThreadCommandFingerprint, terminalBindingFrom, unmanagedTerminalBindingToken } from "./managed-session.js";
19
+ import { createManagedSessionId, createNativeThreadTransitionId, humanObservedHandoffBindingToken, isExactNativeThreadId, legacyManagedSessionBindingToken, legacyUnmanagedTerminalBindingToken, managedSessionBindingToken, nativeThreadCommandFingerprint, terminalBindingFrom, unmanagedTerminalBindingToken } from "./managed-session.js";
20
20
  import { classifyCodexLifecyclePostcondition, evaluateResumeCandidateAvailability, hasStrongCodexLifecycleIdentity, isFreshCodexPostProbeScreen } from "./native-thread-lifecycle-policy.js";
21
21
  import { createNativeThreadResumeSnapshot, nativeThreadCandidateSnapshotFingerprint, nativeThreadResumeSnapshotRowsMatchCandidates, resolveNativeThreadResumeSelection, saveNativeThreadResumeSnapshot, sortNativeThreadCandidates, terminalActionFingerprint, verifiedPreviousResumeCandidate } from "./native-thread-resume-snapshot.js";
22
- import { listManagedSessions, loadManagedSession, loadNativeThreadTransition, nativeThreadTransitionsDir, saveManagedSession, saveNativeThreadTransition, tryLoadManagedSession } from "./session-store.js";
22
+ import { listNativeThreadTransitions, listManagedSessions, loadManagedSession, loadNativeThreadTransition, nativeThreadTransitionsDir, saveManagedSession, saveNativeThreadTransition, tryLoadManagedSession } from "./session-store.js";
23
23
  import { createTerminalControlProviderRegistry as createProviderRegistry, StaticTerminalControlProvider, 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";
@@ -3220,11 +3220,55 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3220
3220
  const soleBindingConflict = conflictingBoundSessionClaims.length === 1
3221
3221
  ? conflictingBoundSessionClaims[0]
3222
3222
  : undefined;
3223
+ const externalHandoffDetected = conflictingBoundSessionClaims.some(({ kind }) => kind === "live_external_thread_change");
3223
3224
  const conflictingSessionRevision = Number(soleBindingConflict?.session.revision);
3224
3225
  const conflictingSessionTurns = soleBindingConflict
3225
3226
  ? managedTurnsForSession(storeDir, soleBindingConflict.session.session_id)
3226
3227
  : [];
3227
3228
  const expectedTerminalToken = stringValue(terminal.lifecycle_binding_token);
3229
+ const externalHandoffNativeThreadId = stringValue(terminal.native_agent_status_card_session_id) ?? stringValue(terminal.native_agent_session_id);
3230
+ const resolvedNativeThreadId = stringValue(terminal.native_agent_session_id);
3231
+ const externalHandoffTerminalToken = terminalControl &&
3232
+ externalHandoffNativeThreadId &&
3233
+ isExactNativeThreadId(externalHandoffNativeThreadId)
3234
+ ? unmanagedTerminalBindingToken({
3235
+ terminalId: stringValue(terminal.id),
3236
+ terminalControl,
3237
+ agent: terminal.agent,
3238
+ pid: Number(terminal.pid),
3239
+ workspace: terminal.workspace ?? terminal.cwd ?? cliCwd(),
3240
+ nativeThreadId: externalHandoffNativeThreadId,
3241
+ processUuid: stringValue(terminal.native_agent_process_uuid),
3242
+ processBirth: stringValue(terminal.native_agent_process_birth),
3243
+ rollout: resolvedNativeThreadId === externalHandoffNativeThreadId &&
3244
+ isRecord(terminal.native_agent_rollout)
3245
+ ? terminal.native_agent_rollout
3246
+ : undefined
3247
+ })
3248
+ : undefined;
3249
+ const externalHandoffTarget = soleBindingConflict?.kind === "live_external_thread_change" &&
3250
+ externalHandoffNativeThreadId &&
3251
+ isExactNativeThreadId(externalHandoffNativeThreadId)
3252
+ ? observedHandoffTargetResolution({
3253
+ storeDir,
3254
+ agent: terminal.agent,
3255
+ workspace: terminal.workspace ?? terminal.cwd ?? cliCwd(),
3256
+ nativeThreadId: externalHandoffNativeThreadId.toLowerCase(),
3257
+ sourceSessionId: soleBindingConflict.session.session_id
3258
+ })
3259
+ : undefined;
3260
+ const externalHandoffSnapshotToken = externalHandoffTerminalToken &&
3261
+ soleBindingConflict?.kind === "live_external_thread_change" &&
3262
+ externalHandoffTarget?.status === "eligible"
3263
+ ? humanObservedHandoffBindingToken({
3264
+ terminal_token: externalHandoffTerminalToken,
3265
+ source_session_id: soleBindingConflict.session.session_id,
3266
+ source_revision: managedSessionRevision(soleBindingConflict.session),
3267
+ source_binding_token: managedSessionBindingToken(soleBindingConflict.session),
3268
+ target: externalHandoffTarget.snapshot
3269
+ })
3270
+ : undefined;
3271
+ const blockingHandoffTurns = conflictingSessionTurns.filter((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
3228
3272
  const reconcileBindingAction = mutationsAllowed &&
3229
3273
  discoveredOwnership.state === "none" &&
3230
3274
  unresolvedSessionClaims.length === 0 &&
@@ -3251,6 +3295,65 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3251
3295
  requires_user_intent: true
3252
3296
  }
3253
3297
  : undefined;
3298
+ const externalHandoffAdoptable = Boolean(mutationsAllowed &&
3299
+ discoveredOwnership.state === "none" &&
3300
+ unresolvedSessionClaims.length === 0 &&
3301
+ soleBindingConflict?.kind === "live_external_thread_change" &&
3302
+ terminal.activity_state === "idle" &&
3303
+ !(isRecord(terminal.approval_state) &&
3304
+ terminal.approval_state.blocked === true) &&
3305
+ !conflictingSessionTurns.some((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status)) &&
3306
+ !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
3307
+ externalHandoffTarget?.status === "eligible" &&
3308
+ isRecord(rawActions.send) &&
3309
+ Boolean(externalHandoffSnapshotToken));
3310
+ const handoffDecisionTurn = mutationsAllowed &&
3311
+ soleBindingConflict?.kind === "live_external_thread_change" &&
3312
+ externalHandoffTarget?.status === "eligible" &&
3313
+ terminal.activity_state === "idle" &&
3314
+ !(isRecord(terminal.approval_state) &&
3315
+ terminal.approval_state.blocked === true) &&
3316
+ !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
3317
+ blockingHandoffTurns.length === 1
3318
+ ? blockingHandoffTurns[0]
3319
+ : undefined;
3320
+ const handoffDecisionToken = handoffDecisionTurn &&
3321
+ externalHandoffSnapshotToken &&
3322
+ terminalControl
3323
+ ? activeTurnHandoffDecisionToken({
3324
+ handoffToken: externalHandoffSnapshotToken,
3325
+ turn: handoffDecisionTurn,
3326
+ ledger: loadTerminalBridgeDispatchLedger(terminalControl)
3327
+ })
3328
+ : undefined;
3329
+ const handoffDecision = handoffDecisionTurn &&
3330
+ handoffDecisionToken &&
3331
+ externalHandoffNativeThreadId
3332
+ ? {
3333
+ kind: "active_turn_requires_decision",
3334
+ source_session_id: soleBindingConflict?.session.session_id,
3335
+ source_turn_id: turnIdForConversation(handoffDecisionTurn),
3336
+ live_native_thread_id: externalHandoffNativeThreadId,
3337
+ choices: {
3338
+ take_over_current: {
3339
+ action: {
3340
+ tool: "agent_knock_knock_close",
3341
+ arguments: {
3342
+ turn_id: turnIdForConversation(handoffDecisionTurn),
3343
+ reason: "superseded_by_human_context_switch",
3344
+ expected_handoff_token: handoffDecisionToken
3345
+ },
3346
+ requires_explicit_user_confirmation: true
3347
+ },
3348
+ after: "refresh list and use its follow-current send"
3349
+ },
3350
+ keep_source: {
3351
+ effect: "no Store or terminal mutation",
3352
+ after: "restore source native thread in the Codex/Claude TUI, then refresh list"
3353
+ }
3354
+ }
3355
+ }
3356
+ : undefined;
3254
3357
  const terminalCanAcceptSend = ownership.state === "none" && isRecord(sessionAwareRawActions.send);
3255
3358
  if (ownership.state === "current" &&
3256
3359
  !allRelated.some((conversation) => conversation.conversation_id === ownership.conversation.conversation_id)) {
@@ -3339,6 +3442,19 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3339
3442
  : ownership.state === "conflict"
3340
3443
  ? {
3341
3444
  ...safeTerminalActionsDuringConflict(sessionAwareRawActions),
3445
+ ...(externalHandoffAdoptable
3446
+ ? {
3447
+ send: {
3448
+ ...rawActions.send,
3449
+ arguments: {
3450
+ ...(isRecord(rawActions.send.arguments)
3451
+ ? rawActions.send.arguments
3452
+ : {}),
3453
+ expected_terminal_token: externalHandoffSnapshotToken
3454
+ }
3455
+ }
3456
+ }
3457
+ : {}),
3342
3458
  ...(reconcileBindingAction
3343
3459
  ? { reconcile_binding: reconcileBindingAction }
3344
3460
  : {})
@@ -3377,6 +3493,14 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3377
3493
  ...(ownership.state === "conflict"
3378
3494
  ? { management_conflict: ownership.conflict }
3379
3495
  : {}),
3496
+ ...(externalHandoffDetected
3497
+ ? {
3498
+ handoff_state: externalHandoffAdoptable
3499
+ ? "external_handoff_adoptable"
3500
+ : "external_handoff_blocked"
3501
+ }
3502
+ : {}),
3503
+ ...(handoffDecision ? { handoff_decision: handoffDecision } : {}),
3380
3504
  managed: management,
3381
3505
  available_actions: availableActions
3382
3506
  };
@@ -3558,8 +3682,10 @@ function managedBindingConflictKindForLiveTerminalEntry({ storeDir, session, ter
3558
3682
  statusCardThreadId.toLowerCase()) {
3559
3683
  return relationship === "same" &&
3560
3684
  isExactNativeThreadId(liveNativeThreadId) &&
3561
- liveNativeThreadId.toLowerCase() ===
3562
- statusCardThreadId.toLowerCase()
3685
+ (liveNativeThreadId.toLowerCase() ===
3686
+ statusCardThreadId.toLowerCase() ||
3687
+ liveNativeThreadId.toLowerCase() ===
3688
+ binding.native_thread_id.toLowerCase())
3563
3689
  ? "live_external_thread_change"
3564
3690
  : "unverifiable";
3565
3691
  }
@@ -4073,15 +4199,15 @@ function managedListApprovalState(conversation) {
4073
4199
  }
4074
4200
  function listActionContracts() {
4075
4201
  return {
4076
- version: 7,
4202
+ version: 8,
4077
4203
  instructions: [
4078
- "Treat terminals[] as the primary resource and use only actions present in available_actions.",
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.",
4079
4205
  "An existing managed session's ordinary send targets session_id and creates a new turn. A turn id is never an ordinary send target.",
4080
4206
  "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.",
4081
4207
  "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.",
4082
- "A binding conflict may be detached only through its exact reconcile_binding action, which is snapshot-bound to the Session revision, binding, and live terminal identity and never adopts a replacement native thread.",
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.",
4083
4209
  "List resumable threads before resume; use only a complete native_thread_id and the action returned for that candidate.",
4084
- "For first attach only, use a discovery selector explicitly named by the user or the selector prefilled by that unmanaged raw-terminal row's available send action; never infer, guess, or reuse one.",
4210
+ "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.",
4085
4211
  "Use respond only for an in-flight turn that is explicitly waiting for OpenClaw.",
4086
4212
  "Managed controls target turn_id. A raw terminal may be controlled only through its own list-prefilled conversation_id action; never construct, guess, or reuse that compatibility selector.",
4087
4213
  "Start with the action's prefilled arguments, supply every missing_required field, and consult the top-level action's optional fields only when needed.",
@@ -4112,6 +4238,12 @@ function listActionContracts() {
4112
4238
  available_actions: {
4113
4239
  meaning: "currently_safe_actions",
4114
4240
  authoritative_for_tool_calls: true
4241
+ },
4242
+ handoff_decision: {
4243
+ meaning: "an explicit human choice required before superseding an active source Turn",
4244
+ authoritative_action_path: "terminals[].handoff_decision.choices.take_over_current.action",
4245
+ requires_explicit_user_confirmation: true,
4246
+ after_success: "refresh list before using a follow-current send action"
4115
4247
  }
4116
4248
  },
4117
4249
  actions: {
@@ -4123,13 +4255,14 @@ function listActionContracts() {
4123
4255
  required: ["request"],
4124
4256
  optional: [
4125
4257
  "selector",
4258
+ "expected_terminal_token",
4126
4259
  "type",
4127
4260
  "idleTimeoutMinutes",
4128
4261
  "agentTimeoutMinutes",
4129
4262
  "agentHardTimeoutMinutes"
4130
4263
  ],
4131
4264
  unsupported: ["timeoutSeconds"],
4132
- ordinary_use: "Create a new managed turn in the selected session. A live terminal selector is accepted only for initial attach/discovery compatibility."
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."
4133
4266
  },
4134
4267
  new_thread: {
4135
4268
  tool: "agent_knock_knock_new_thread",
@@ -4240,9 +4373,11 @@ function listActionContracts() {
4240
4373
  optional: [
4241
4374
  "reason",
4242
4375
  "expected_message_id",
4243
- "expected_transition_id"
4376
+ "expected_transition_id",
4377
+ "expected_handoff_token"
4244
4378
  ],
4245
- requires_explicit_user_confirmation: true
4379
+ requires_explicit_user_confirmation: true,
4380
+ handoff_scope: "expected_handoff_token is valid only by copying the complete nested action from terminals[].handoff_decision.choices.take_over_current; never construct, guess, or reuse it."
4246
4381
  }
4247
4382
  }
4248
4383
  };
@@ -4413,11 +4548,18 @@ function availableListActions(entry, { conversation } = {}) {
4413
4548
  return actions;
4414
4549
  }
4415
4550
  async function resolveConversationSelectorOption(commandName, options) {
4551
+ const sendOperation = commandName === "send";
4552
+ if (sendOperation && stringValue(options.expectedTerminalToken)) {
4553
+ // Selector resolution may replace an alias (or an omitted selector) with a
4554
+ // discovered full terminal id. Preserve the caller's actual authority so
4555
+ // the handoff token fence cannot mistake that convenience resolution for
4556
+ // an exact selector supplied by the caller.
4557
+ originalExpectedTerminalSelector.set(options, stringValue(options.session ?? options.conversation ?? options.conversationId)?.trim());
4558
+ }
4416
4559
  if (!SESSION_SELECTOR_COMMANDS.has(String(commandName ?? "")) ||
4417
4560
  options.state) {
4418
4561
  return;
4419
4562
  }
4420
- const sendOperation = commandName === "send";
4421
4563
  const supplied = stringValue(sendOperation
4422
4564
  ? options.session ?? options.conversation ?? options.conversationId
4423
4565
  : options.turn ?? options.conversation ?? options.conversationId)?.trim();
@@ -4859,7 +5001,10 @@ function codexKnownBeforeIdentityForManagedSession({ storeDir, session, requireN
4859
5001
  }
4860
5002
  const after = transition.after_binding;
4861
5003
  if (transition.status !== "committed" ||
4862
- (requireNewThread && transition.operation !== "new_thread") ||
5004
+ (requireNewThread && ![
5005
+ "new_thread",
5006
+ "adopt_external_thread"
5007
+ ].includes(transition.operation)) ||
4863
5008
  transition.target_session_id !== session.session_id ||
4864
5009
  !after ||
4865
5010
  after.binding_id !== binding.binding_id ||
@@ -4911,7 +5056,7 @@ function codexLingeringBeforeIdentityMatchesSession({ storeDir, session, identit
4911
5056
  }
4912
5057
  const after = transition.after_binding;
4913
5058
  if (transition.status !== "committed" ||
4914
- transition.operation !== "new_thread" ||
5059
+ !["new_thread", "adopt_external_thread"].includes(transition.operation) ||
4915
5060
  transition.target_session_id !== session.session_id ||
4916
5061
  !after ||
4917
5062
  after.binding_id !== binding.binding_id ||
@@ -5506,6 +5651,552 @@ async function reattachManagedSessionForNativeIdentity({ options, terminal, iden
5506
5651
  updated_at: now.toISOString()
5507
5652
  }, { expectedRevision: managedSessionRevision(existing) });
5508
5653
  }
5654
+ const HUMAN_OBSERVED_HANDOFF_FINGERPRINT = nativeThreadCommandFingerprint("adopt_external_thread:human_observed:no_terminal_input:v1");
5655
+ function observedHandoffAuthorityToken({ terminal, identity, sourceSession, target }) {
5656
+ const exact = exactLifecycleProcessIdentity(terminal, identity);
5657
+ const terminalToken = unmanagedTerminalBindingToken({
5658
+ terminalId: terminal.conversationId,
5659
+ terminalControl: terminal.terminalControl,
5660
+ agent: terminal.agent,
5661
+ pid: terminal.pid,
5662
+ workspace: terminal.terminalControl.currentPath ?? cliCwd(),
5663
+ nativeThreadId: exact.sessionId,
5664
+ processUuid: exact.processUuid,
5665
+ processBirth: exact.processBirth,
5666
+ rollout: exact.rollout
5667
+ });
5668
+ return humanObservedHandoffBindingToken({
5669
+ terminal_token: terminalToken,
5670
+ source_session_id: sourceSession.session_id,
5671
+ source_revision: managedSessionRevision(sourceSession),
5672
+ source_binding_token: managedSessionBindingToken(sourceSession),
5673
+ target
5674
+ });
5675
+ }
5676
+ function activeTurnHandoffDecisionToken({ handoffToken, turn, ledger }) {
5677
+ const takeover = isRecord(turn.native_session_takeover)
5678
+ ? turn.native_session_takeover
5679
+ : undefined;
5680
+ const submission = terminalBridgeSubmission(turn);
5681
+ return createHash("sha256")
5682
+ .update(JSON.stringify({
5683
+ version: 1,
5684
+ kind: "active_turn_human_handoff",
5685
+ handoff_token: handoffToken,
5686
+ session_id: sessionIdForConversation(turn),
5687
+ turn_id: turnIdForConversation(turn),
5688
+ turn_status: turn.status,
5689
+ turn_updated_at: turn.updated_at ?? null,
5690
+ current_message_id: stringValue(takeover?.terminal_bridge_message_id) ??
5691
+ stringValue(submission?.message_id) ??
5692
+ null,
5693
+ ledger_generation_id: stringValue(ledger?.generation_id) ?? null,
5694
+ ledger_message_id: stringValue(ledger?.message_id) ?? null,
5695
+ ledger_status: stringValue(ledger?.status) ?? null
5696
+ }))
5697
+ .digest("hex");
5698
+ }
5699
+ const originalExpectedTerminalSelector = new WeakMap();
5700
+ function assertExpectedHandoffTokenUsesExactTerminalSelector({ options, terminal }) {
5701
+ if (!stringValue(options.expectedTerminalToken)) {
5702
+ return;
5703
+ }
5704
+ const supplied = originalExpectedTerminalSelector.has(options)
5705
+ ? originalExpectedTerminalSelector.get(options)
5706
+ : stringValue(options.session ?? options.conversation ?? options.conversationId)?.trim();
5707
+ if (supplied !== terminal.conversationId) {
5708
+ throw new Error("--expected-terminal-token is valid only with the exact full terminal " +
5709
+ "conversation selector advertised by AKK list");
5710
+ }
5711
+ }
5712
+ async function observedExternalHandoffIdentity({ options, terminal, sourceSession, resolvedIdentity }) {
5713
+ const bridge = createTerminalAgentBridge(options);
5714
+ const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
5715
+ assertSafeTerminalSend(terminal.agent, status);
5716
+ if (terminal.agent !== "codex") {
5717
+ return { identity: resolvedIdentity, status };
5718
+ }
5719
+ const sourceBinding = sourceSession.binding;
5720
+ const statusCard = terminal.adapter.observeThreadLifecycle?.({
5721
+ operation: { kind: "new_thread" },
5722
+ phase: "before",
5723
+ screen: status.screen.excerpt ?? ""
5724
+ });
5725
+ const statusCardId = statusCard?.status === "observed" &&
5726
+ isExactNativeThreadId(statusCard.nativeThreadId)
5727
+ ? statusCard.nativeThreadId.toLowerCase()
5728
+ : undefined;
5729
+ const sourceId = sourceBinding?.native_thread_id?.toLowerCase();
5730
+ if (statusCardId && sourceId && statusCardId !== sourceId) {
5731
+ const processUuid = sourceBinding?.native_process.process_uuid;
5732
+ const processBirth = sourceBinding?.native_process.process_birth;
5733
+ if (!processUuid || !processBirth) {
5734
+ return { identity: undefined, status };
5735
+ }
5736
+ const resolvedMatchesStatus = resolvedIdentity?.sessionId.toLowerCase() === statusCardId;
5737
+ return {
5738
+ identity: {
5739
+ sessionId: statusCardId,
5740
+ processUuid,
5741
+ processBirth,
5742
+ rollout: resolvedMatchesStatus ? resolvedIdentity?.rollout : undefined,
5743
+ evidence: resolvedMatchesStatus
5744
+ ? `${resolvedIdentity?.evidence ?? "native_thread_boundary"}+codex_status_card`
5745
+ : statusCard?.evidence ?? "codex_status_card"
5746
+ },
5747
+ status
5748
+ };
5749
+ }
5750
+ return { identity: resolvedIdentity, status };
5751
+ }
5752
+ function observedHandoffTargetResolution({ storeDir, agent, workspace, nativeThreadId, sourceSessionId }) {
5753
+ const matches = listManagedSessions(storeDir).filter((session) => session.session_id !== sourceSessionId &&
5754
+ session.agent === agent &&
5755
+ session.binding?.native_thread_id?.toLowerCase() === nativeThreadId &&
5756
+ path.resolve(session.workspace) === path.resolve(workspace));
5757
+ if (matches.length > 1) {
5758
+ return {
5759
+ status: "blocked",
5760
+ reason: `native thread ${nativeThreadId} is claimed by multiple managed Sessions`
5761
+ };
5762
+ }
5763
+ const target = matches[0];
5764
+ if (!target) {
5765
+ return { status: "eligible", snapshot: { state: "absent" } };
5766
+ }
5767
+ if (!target.binding ||
5768
+ target.status !== "detached" ||
5769
+ managedSessionHasUnresolvedNativeTransition(storeDir, target)) {
5770
+ return {
5771
+ status: "blocked",
5772
+ reason: `managed Session ${target.session_id} cannot be adopted from ` +
5773
+ `${target.status} state or while its lifecycle is unresolved`
5774
+ };
5775
+ }
5776
+ try {
5777
+ assertManagedSessionCanStartTurn(managedTurnsForSession(storeDir, target.session_id));
5778
+ }
5779
+ catch (error) {
5780
+ return {
5781
+ status: "blocked",
5782
+ reason: `managed Session ${target.session_id} has unresolved work: ` +
5783
+ `${error instanceof Error ? error.message : String(error)}`
5784
+ };
5785
+ }
5786
+ return {
5787
+ status: "eligible",
5788
+ session: target,
5789
+ snapshot: {
5790
+ state: "detached",
5791
+ session_id: target.session_id,
5792
+ revision: managedSessionRevision(target),
5793
+ status: "detached",
5794
+ binding_token: managedSessionBindingToken(target)
5795
+ }
5796
+ };
5797
+ }
5798
+ async function maybeAdoptObservedExternalThread({ options, terminal, sourceSession, resolvedIdentity, storeDir }) {
5799
+ if (!sourceSession?.binding) {
5800
+ return { identity: resolvedIdentity, adopted: false };
5801
+ }
5802
+ const observed = await observedExternalHandoffIdentity({
5803
+ options,
5804
+ terminal,
5805
+ sourceSession,
5806
+ resolvedIdentity
5807
+ });
5808
+ const identity = observed.identity;
5809
+ const conflictKind = managedBindingConflictKindForResolvedTerminal({
5810
+ storeDir,
5811
+ session: sourceSession,
5812
+ terminal,
5813
+ identity
5814
+ });
5815
+ if (conflictKind !== "live_external_thread_change") {
5816
+ return { identity, adopted: false };
5817
+ }
5818
+ assertTerminalLifecycleReady({
5819
+ options,
5820
+ terminal,
5821
+ terminalStatus: observed.status
5822
+ });
5823
+ if (!identity || !isExactNativeThreadId(identity.sessionId)) {
5824
+ throw new Error("the externally selected native thread has no exact supported identity");
5825
+ }
5826
+ if (managedSessionHasUnresolvedNativeTransition(storeDir, sourceSession)) {
5827
+ throw new Error(`managed Session ${sourceSession.session_id} has an unresolved native-thread transition`);
5828
+ }
5829
+ assertManagedSessionCanStartTurn(managedTurnsForSession(storeDir, sourceSession.session_id));
5830
+ if (terminal.agent === "codex"
5831
+ ? !codexComposerEmpty(observed.status.screen.excerpt)
5832
+ : !claudeComposerEmpty(observed.status.screen.excerpt)) {
5833
+ throw new Error("external handoff adoption requires an exact empty idle composer");
5834
+ }
5835
+ if (terminal.agent === "codex") {
5836
+ await assertCodexComposerReadyForAutomatedInput({
5837
+ options,
5838
+ terminalControl: terminal.terminalControl
5839
+ });
5840
+ }
5841
+ const targetNativeThreadId = identity.sessionId.toLowerCase();
5842
+ const targetResolution = observedHandoffTargetResolution({
5843
+ storeDir,
5844
+ agent: terminal.agent,
5845
+ workspace: terminal.terminalControl.currentPath ?? cliCwd(),
5846
+ nativeThreadId: targetNativeThreadId,
5847
+ sourceSessionId: sourceSession.session_id
5848
+ });
5849
+ const expectedTerminalToken = stringValue(options.expectedTerminalToken);
5850
+ if (targetResolution.status === "blocked") {
5851
+ if (expectedTerminalToken) {
5852
+ throw new Error("live source or target Session snapshot changed after the handoff was " +
5853
+ "listed; refresh AKK list");
5854
+ }
5855
+ throw new Error(targetResolution.reason);
5856
+ }
5857
+ const freshHandoffToken = observedHandoffAuthorityToken({
5858
+ terminal,
5859
+ identity,
5860
+ sourceSession,
5861
+ target: targetResolution.snapshot
5862
+ });
5863
+ if (expectedTerminalToken &&
5864
+ expectedTerminalToken !== freshHandoffToken) {
5865
+ throw new Error("live source, target, or terminal identity changed after the handoff " +
5866
+ "was listed; refresh AKK list");
5867
+ }
5868
+ const targetSession = targetResolution.session;
5869
+ await assertNativeThreadHasExclusiveOwnership({
5870
+ options,
5871
+ agent: terminal.agent,
5872
+ currentPid: terminal.pid,
5873
+ nativeThreadId: targetNativeThreadId,
5874
+ storeDir,
5875
+ terminalControl: terminal.terminalControl,
5876
+ excludedManagedSessionId: targetSession?.session_id
5877
+ });
5878
+ const adapterVersion = required(stringValue(agentVersionForRunningProcess(terminal.agent, terminal.pid, options)), "external handoff adoption requires the exact running agent version");
5879
+ const capability = terminal.adapter.probeThreadLifecycle?.(adapterVersion);
5880
+ if (capability?.status !== "supported") {
5881
+ throw new Error(capability?.reason ?? "external handoff adoption is unsupported for this agent version");
5882
+ }
5883
+ const now = cliNow();
5884
+ const sourceBinding = sourceSession.binding;
5885
+ const targetSessionId = targetSession?.session_id ?? createManagedSessionId(now);
5886
+ const transitionId = createNativeThreadTransitionId();
5887
+ const exactIdentity = exactLifecycleProcessIdentity(terminal, identity);
5888
+ const nextBinding = terminalBindingFrom({
5889
+ terminalId: terminal.conversationId,
5890
+ terminalControl: terminal.terminalControl,
5891
+ pid: terminal.pid,
5892
+ nativeThreadId: targetNativeThreadId,
5893
+ processUuid: exactIdentity.processUuid,
5894
+ processBirth: exactIdentity.processBirth,
5895
+ rollout: exactIdentity.rollout,
5896
+ evidence: `${exactIdentity.evidence}+human_observed`,
5897
+ generation: (targetSession?.binding?.generation ?? 0) + 1,
5898
+ now
5899
+ });
5900
+ const previousLedger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
5901
+ let transition = {
5902
+ schema: "agent-knock-knock/native-thread-transition",
5903
+ version: 1,
5904
+ transition_id: transitionId,
5905
+ operation: "adopt_external_thread",
5906
+ origin: "human_observed",
5907
+ terminal_input_sent: false,
5908
+ status: "prepared",
5909
+ terminal_id: terminal.conversationId,
5910
+ agent: terminal.agent,
5911
+ workspace: terminal.terminalControl.currentPath ?? cliCwd(),
5912
+ source_session_id: sourceSession.session_id,
5913
+ source_expected_revision: managedSessionRevision(sourceSession),
5914
+ source_previous_last_transition_id: sourceSession.last_transition_id,
5915
+ target_session_id: targetSessionId,
5916
+ target_expected_revision: targetSession
5917
+ ? managedSessionRevision(targetSession)
5918
+ : null,
5919
+ target_native_thread_id: targetNativeThreadId,
5920
+ before_native_thread_id: sourceBinding.native_thread_id,
5921
+ before_process_uuid: sourceBinding.native_process.process_uuid,
5922
+ before_process_started_at: exactIdentity.processStartedAt,
5923
+ before_process_birth: sourceBinding.native_process.process_birth,
5924
+ before_process_rollout: sourceBinding.native_process.rollout,
5925
+ before_binding: sourceBinding,
5926
+ adapter_version: adapterVersion,
5927
+ command_fingerprint: HUMAN_OBSERVED_HANDOFF_FINGERPRINT,
5928
+ dispatcher_pid: cliPid(),
5929
+ prepared_at: now.toISOString()
5930
+ };
5931
+ transition = saveNativeThreadTransition(storeDir, transition, {
5932
+ expectedRevision: null
5933
+ });
5934
+ if (cliEnv().AKK_TEST_EXIT_AFTER_HANDOFF_TRANSITION_BEFORE_LEDGER === "1") {
5935
+ cliExit(88);
5936
+ }
5937
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
5938
+ ...lifecycleLedgerFields(transition, storeDir),
5939
+ status: "prepared",
5940
+ target_native_thread_id: targetNativeThreadId,
5941
+ previous_generation_id: stringValue(previousLedger?.generation_id) ??
5942
+ stringValue(previousLedger?.message_id)
5943
+ }, { expectedTransitionId: null });
5944
+ if (cliEnv().AKK_TEST_EXIT_AFTER_LIFECYCLE_PREPARED === "1") {
5945
+ cliExit(86);
5946
+ }
5947
+ const sourceTransitioning = saveManagedSession(storeDir, {
5948
+ ...sourceSession,
5949
+ status: "transitioning",
5950
+ last_transition_id: transitionId,
5951
+ updated_at: now.toISOString()
5952
+ }, { expectedRevision: managedSessionRevision(sourceSession) });
5953
+ try {
5954
+ const reObserved = await observedExternalHandoffIdentity({
5955
+ options,
5956
+ terminal,
5957
+ sourceSession: sourceTransitioning,
5958
+ resolvedIdentity: await resolveCurrentNativeAgentSessionIdentity({
5959
+ options,
5960
+ agent: terminal.agent,
5961
+ pid: terminal.pid,
5962
+ cwd: terminal.terminalControl.currentPath,
5963
+ preferredSessionId: targetNativeThreadId,
5964
+ allowedCompanionIdentity: codexIdentityFence({
5965
+ sessionId: sourceBinding.native_thread_id,
5966
+ processUuid: sourceBinding.native_process.process_uuid,
5967
+ processBirth: sourceBinding.native_process.process_birth,
5968
+ rollout: sourceBinding.native_process.rollout,
5969
+ evidence: sourceBinding.native_process.evidence
5970
+ }),
5971
+ allowedAdditionalIdentities: []
5972
+ })
5973
+ });
5974
+ const reObservedExact = reObserved.identity
5975
+ ? exactLifecycleProcessIdentity(terminal, reObserved.identity)
5976
+ : undefined;
5977
+ if (reObservedExact?.sessionId.toLowerCase() !== targetNativeThreadId ||
5978
+ reObservedExact.processUuid !== nextBinding.native_process.process_uuid ||
5979
+ reObservedExact.processBirth !== nextBinding.native_process.process_birth ||
5980
+ JSON.stringify(reObservedExact.rollout ?? null) !==
5981
+ JSON.stringify(nextBinding.native_process.rollout ?? null)) {
5982
+ throw new Error("live native thread changed during external handoff adoption");
5983
+ }
5984
+ await assertNativeThreadHasExclusiveOwnership({
5985
+ options,
5986
+ agent: terminal.agent,
5987
+ currentPid: terminal.pid,
5988
+ nativeThreadId: targetNativeThreadId,
5989
+ storeDir,
5990
+ terminalControl: terminal.terminalControl,
5991
+ excludedManagedSessionId: targetSession?.session_id
5992
+ });
5993
+ transition = saveNativeThreadTransition(storeDir, {
5994
+ ...transition,
5995
+ status: "verified",
5996
+ after_binding: nextBinding,
5997
+ verified_at: cliNow().toISOString()
5998
+ }, { expectedRevision: nativeThreadTransitionRevision(transition) });
5999
+ if (cliEnv().AKK_TEST_EXIT_AFTER_HANDOFF_VERIFIED_TRANSITION_BEFORE_LEDGER ===
6000
+ "1") {
6001
+ cliExit(89);
6002
+ }
6003
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
6004
+ ...lifecycleLedgerFields(transition, storeDir),
6005
+ status: "verified",
6006
+ binding: sourceBinding
6007
+ }, {
6008
+ expectedTransitionId: transitionId,
6009
+ expectedStatus: "prepared"
6010
+ });
6011
+ if (cliEnv().AKK_TEST_EXIT_AFTER_LIFECYCLE_VERIFIED === "1") {
6012
+ cliExit(87);
6013
+ }
6014
+ const committedTarget = commitVerifiedLifecycleTransition(storeDir, transition, cliNow().toISOString());
6015
+ transition = saveNativeThreadTransition(storeDir, {
6016
+ ...transition,
6017
+ status: "committed",
6018
+ committed_at: cliNow().toISOString()
6019
+ }, { expectedRevision: nativeThreadTransitionRevision(transition) });
6020
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
6021
+ ...lifecycleLedgerFields(transition, storeDir),
6022
+ status: "resolved",
6023
+ resolved_at: cliNow().toISOString(),
6024
+ binding: committedTarget.binding,
6025
+ reason: "verified human-observed native thread handoff committed"
6026
+ }, {
6027
+ expectedTransitionId: transitionId,
6028
+ expectedStatus: "verified"
6029
+ });
6030
+ runtimeLog("info", "human_observed_handoff_adopted", {
6031
+ transition_id: transitionId,
6032
+ terminal_id: terminal.conversationId,
6033
+ source_session_id: sourceSession.session_id,
6034
+ target_session_id: committedTarget.session_id,
6035
+ native_thread_id: targetNativeThreadId,
6036
+ terminal_input_sent: false
6037
+ });
6038
+ return {
6039
+ session: committedTarget,
6040
+ identity: exactIdentity,
6041
+ transition,
6042
+ adopted: true
6043
+ };
6044
+ }
6045
+ catch (error) {
6046
+ const failedAt = cliNow().toISOString();
6047
+ const durable = loadNativeThreadTransition(storeDir, transitionId);
6048
+ if (durable.status === "verified" || durable.status === "committed") {
6049
+ throw error;
6050
+ }
6051
+ const uncertain = saveNativeThreadTransition(storeDir, {
6052
+ ...durable,
6053
+ status: "uncertain",
6054
+ uncertain_at: failedAt,
6055
+ error: error instanceof Error ? error.message : String(error),
6056
+ do_not_retry: true
6057
+ }, { expectedRevision: nativeThreadTransitionRevision(durable) });
6058
+ saveManagedSession(storeDir, {
6059
+ ...sourceTransitioning,
6060
+ status: "quarantined",
6061
+ quarantine_reason: "human-observed handoff could not be revalidated",
6062
+ updated_at: failedAt
6063
+ }, { expectedRevision: managedSessionRevision(sourceTransitioning) });
6064
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
6065
+ ...lifecycleLedgerFields(uncertain, storeDir),
6066
+ status: "uncertain",
6067
+ uncertain_at: failedAt,
6068
+ reason: "human-observed handoff could not be revalidated"
6069
+ }, { expectedTransitionId: transitionId });
6070
+ throw error;
6071
+ }
6072
+ }
6073
+ async function assertObservedHandoffTransportBoundary({ options, terminal, transition, requireEmptyComposer }) {
6074
+ const storeDir = storeDirFromOptions(options);
6075
+ const durable = loadNativeThreadTransition(storeDir, transition.transition_id);
6076
+ if (durable.operation !== "adopt_external_thread" ||
6077
+ durable.origin !== "human_observed" ||
6078
+ durable.terminal_input_sent !== false ||
6079
+ durable.status !== "committed" ||
6080
+ !durable.source_session_id ||
6081
+ !durable.before_binding ||
6082
+ !durable.after_binding ||
6083
+ JSON.stringify(durable.after_binding) !==
6084
+ JSON.stringify(transition.after_binding)) {
6085
+ throw new Error("human-observed handoff changed before terminal transport");
6086
+ }
6087
+ const source = loadManagedSession(storeDir, durable.source_session_id);
6088
+ const target = loadManagedSession(storeDir, durable.target_session_id);
6089
+ if (source.status !== "detached" ||
6090
+ source.last_transition_id !== durable.transition_id ||
6091
+ JSON.stringify(source.binding) !== JSON.stringify(durable.before_binding) ||
6092
+ target.status !== "bound" ||
6093
+ target.last_transition_id !== durable.transition_id ||
6094
+ JSON.stringify(target.binding) !== JSON.stringify(durable.after_binding)) {
6095
+ throw new Error("human-observed handoff Session authority changed before send");
6096
+ }
6097
+ const targetId = durable.after_binding.native_thread_id;
6098
+ if (!targetId) {
6099
+ throw new Error("human-observed handoff target identity is incomplete");
6100
+ }
6101
+ const resolved = await resolveCurrentNativeAgentSessionIdentity({
6102
+ options,
6103
+ agent: terminal.agent,
6104
+ pid: terminal.pid,
6105
+ cwd: terminal.terminalControl.currentPath,
6106
+ preferredSessionId: targetId,
6107
+ allowedCompanionIdentity: codexIdentityFence({
6108
+ sessionId: durable.before_native_thread_id,
6109
+ processUuid: durable.before_process_uuid,
6110
+ processBirth: durable.before_process_birth,
6111
+ rollout: durable.before_process_rollout,
6112
+ evidence: durable.before_binding.native_process.evidence
6113
+ }),
6114
+ allowedAdditionalIdentities: []
6115
+ });
6116
+ const bridge = createTerminalAgentBridge(options);
6117
+ const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
6118
+ if (requireEmptyComposer) {
6119
+ assertSafeTerminalSend(terminal.agent, status);
6120
+ }
6121
+ else {
6122
+ const displayName = executorDefinitionForKind(terminal.agent).displayName;
6123
+ const approval = isRecord(status?.approval_state)
6124
+ ? status.approval_state
6125
+ : undefined;
6126
+ if (status?.reachable !== true) {
6127
+ throw new Error(`${displayName} terminal status is unavailable`);
6128
+ }
6129
+ if (approval?.blocked === true) {
6130
+ throw new Error(stringValue(approval.reason) ??
6131
+ `${displayName} is waiting at a permission dialog`);
6132
+ }
6133
+ // With an exact draft in the composer, native TUIs can classify the screen
6134
+ // as `unknown` instead of `idle`. Exact draft materialization is proven by
6135
+ // the bridge before Enter, so only a positively busy state is unsafe here.
6136
+ if (status.activity_state !== "idle" &&
6137
+ status.activity_state !== "unknown") {
6138
+ throw new Error(`${displayName} terminal became ${stringValue(status.activity_state) ?? "unknown"} before handoff submission`);
6139
+ }
6140
+ }
6141
+ let liveIdentity = resolved;
6142
+ if (terminal.agent === "codex") {
6143
+ const foreground = terminal.adapter.observeThreadLifecycle?.({
6144
+ operation: { kind: "new_thread" },
6145
+ phase: "before",
6146
+ screen: status.screen.excerpt ?? ""
6147
+ });
6148
+ const foregroundId = foreground?.status === "observed" &&
6149
+ isExactNativeThreadId(foreground.nativeThreadId)
6150
+ ? foreground.nativeThreadId.toLowerCase()
6151
+ : undefined;
6152
+ if (foregroundId && foregroundId !== targetId.toLowerCase()) {
6153
+ throw new Error("Codex foreground native thread changed after handoff adoption");
6154
+ }
6155
+ if (!durable.after_binding.native_process.rollout) {
6156
+ if (requireEmptyComposer &&
6157
+ foregroundId !== targetId.toLowerCase()) {
6158
+ throw new Error("status-card-only Codex handoff lost its exact foreground identity");
6159
+ }
6160
+ liveIdentity = resolved?.sessionId.toLowerCase() === targetId.toLowerCase()
6161
+ ? resolved
6162
+ : {
6163
+ sessionId: targetId,
6164
+ processUuid: durable.after_binding.native_process.process_uuid,
6165
+ processBirth: durable.after_binding.native_process.process_birth,
6166
+ evidence: foreground?.evidence ?? "codex_status_card"
6167
+ };
6168
+ }
6169
+ else if (!liveIdentity ||
6170
+ liveIdentity.sessionId.toLowerCase() !== targetId.toLowerCase()) {
6171
+ throw new Error("Codex handoff rollout identity changed before terminal transport");
6172
+ }
6173
+ }
6174
+ if (!liveIdentity) {
6175
+ throw new Error("human-observed handoff identity is unavailable before send");
6176
+ }
6177
+ const exact = exactLifecycleProcessIdentity(terminal, liveIdentity);
6178
+ if (exact.sessionId.toLowerCase() !== targetId.toLowerCase() ||
6179
+ exact.processUuid !== durable.after_binding.native_process.process_uuid ||
6180
+ exact.processBirth !== durable.after_binding.native_process.process_birth ||
6181
+ JSON.stringify(exact.rollout ?? null) !==
6182
+ JSON.stringify(durable.after_binding.native_process.rollout ?? null)) {
6183
+ throw new Error("human-observed handoff identity changed before send");
6184
+ }
6185
+ if (requireEmptyComposer) {
6186
+ const empty = terminal.agent === "codex"
6187
+ ? codexComposerEmpty(status.screen.excerpt)
6188
+ : claudeComposerEmpty(status.screen.excerpt);
6189
+ if (!empty) {
6190
+ throw new Error("human-observed handoff composer changed before text injection");
6191
+ }
6192
+ if (terminal.agent === "codex") {
6193
+ await assertCodexComposerReadyForAutomatedInput({
6194
+ options,
6195
+ terminalControl: terminal.terminalControl
6196
+ });
6197
+ }
6198
+ }
6199
+ }
5509
6200
  function lifecycleBindingToken({ session, terminal, identity }) {
5510
6201
  if (session) {
5511
6202
  return managedSessionBindingToken(session);
@@ -7914,6 +8605,10 @@ async function runStatus(options) {
7914
8605
  };
7915
8606
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
7916
8607
  if (terminalConversation) {
8608
+ assertExpectedHandoffTokenUsesExactTerminalSelector({
8609
+ options,
8610
+ terminal: terminalConversation
8611
+ });
7917
8612
  const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
7918
8613
  pid: terminalConversation.pid,
7919
8614
  cwd: terminalConversation.terminalControl.currentPath,
@@ -8503,6 +9198,13 @@ async function runSend(options) {
8503
9198
  }
8504
9199
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
8505
9200
  if (terminalConversation) {
9201
+ // A token copied from list is authority for exactly the advertised full
9202
+ // terminal selector. Reject aliases and implicit/no-selector resolution
9203
+ // before taking locks or touching Store state.
9204
+ assertExpectedHandoffTokenUsesExactTerminalSelector({
9205
+ options,
9206
+ terminal: terminalConversation
9207
+ });
8506
9208
  if (!options.background) {
8507
9209
  throw new Error("raw terminal sends require --background so AKK can persist and monitor the submission safely");
8508
9210
  }
@@ -8534,7 +9236,7 @@ async function runSend(options) {
8534
9236
  session: claimedSession
8535
9237
  })
8536
9238
  : { additional: [] };
8537
- const currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
9239
+ let currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
8538
9240
  options,
8539
9241
  agent: terminalConversation.agent,
8540
9242
  pid: terminalConversation.pid,
@@ -8545,12 +9247,27 @@ async function runSend(options) {
8545
9247
  allowedCompanionIdentity: knownCodexCompanions.primary,
8546
9248
  allowedAdditionalIdentities: knownCodexCompanions.additional
8547
9249
  });
8548
- let managedSession = materializeCurrentManagedSession({
9250
+ const physicalNativeIdentityBeforeHandoff = currentNativeIdentity;
9251
+ const handoff = await maybeAdoptObservedExternalThread({
8549
9252
  options,
8550
9253
  terminal: terminalConversation,
8551
- identity: currentNativeIdentity
9254
+ sourceSession: claimedSession,
9255
+ resolvedIdentity: currentNativeIdentity,
9256
+ storeDir: rawStoreDir
8552
9257
  });
8553
- if (!managedSession && currentNativeIdentity) {
9258
+ currentNativeIdentity =
9259
+ handoff.adopted &&
9260
+ terminalConversation.agent === "codex" &&
9261
+ !handoff.session?.binding?.native_process.rollout
9262
+ ? physicalNativeIdentityBeforeHandoff
9263
+ : handoff.identity;
9264
+ let managedSession = handoff.session ??
9265
+ materializeCurrentManagedSession({
9266
+ options,
9267
+ terminal: terminalConversation,
9268
+ identity: currentNativeIdentity
9269
+ });
9270
+ if (!managedSession && currentNativeIdentity) {
8554
9271
  managedSession = await reattachManagedSessionForNativeIdentity({
8555
9272
  options,
8556
9273
  terminal: terminalConversation,
@@ -8593,19 +9310,28 @@ async function runSend(options) {
8593
9310
  storeDir: rawStoreDir,
8594
9311
  session: managedSession,
8595
9312
  observedIdentity: currentNativeIdentity
8596
- }) ?? knownCodexCompanions.primary;
8597
- const allowedAdditionalIdentities = knownCodexCompanions.additional;
9313
+ }) ?? (currentNativeIdentity === undefined ||
9314
+ nativeIdentityMatchesCodexPreMaterialization(currentNativeIdentity, knownCodexCompanions.primary)
9315
+ ? knownCodexCompanions.primary
9316
+ : undefined);
9317
+ const allowedAdditionalIdentities = allowedPreMaterializationIdentity
9318
+ ? knownCodexCompanions.additional
9319
+ : [];
8598
9320
  const materializedNativeIdentity = currentNativeIdentity?.sessionId === logicalNativeIdentity?.sessionId
8599
9321
  ? currentNativeIdentity
8600
9322
  : undefined;
8601
- await verifyCodexPendingManagedSendStatus({
8602
- options,
8603
- terminal: terminalConversation,
8604
- session: managedSession,
8605
- logicalIdentity: logicalNativeIdentity,
8606
- allowedPreMaterializationIdentity,
8607
- allowedAdditionalIdentities
8608
- });
9323
+ if (!(handoff.adopted &&
9324
+ terminalConversation.agent === "codex" &&
9325
+ !managedSession.binding?.native_process.rollout)) {
9326
+ await verifyCodexPendingManagedSendStatus({
9327
+ options,
9328
+ terminal: terminalConversation,
9329
+ session: managedSession,
9330
+ logicalIdentity: logicalNativeIdentity,
9331
+ allowedPreMaterializationIdentity,
9332
+ allowedAdditionalIdentities
9333
+ });
9334
+ }
8609
9335
  const managedNativeThreadId = logicalNativeIdentity?.sessionId ??
8610
9336
  managedSession.binding?.native_thread_id;
8611
9337
  if (managedNativeThreadId) {
@@ -8683,7 +9409,13 @@ async function runSend(options) {
8683
9409
  }
8684
9410
  : undefined,
8685
9411
  allowedPreMaterializationIdentity,
8686
- allowedAdditionalIdentities
9412
+ allowedAdditionalIdentities,
9413
+ observedHandoff: handoff.adopted && handoff.transition
9414
+ ? {
9415
+ terminal: terminalConversation,
9416
+ transition: handoff.transition
9417
+ }
9418
+ : undefined
8687
9419
  });
8688
9420
  }
8689
9421
  finally {
@@ -8696,6 +9428,10 @@ async function runSend(options) {
8696
9428
  }
8697
9429
  return;
8698
9430
  }
9431
+ if (stringValue(options.expectedTerminalToken)) {
9432
+ throw new Error("--expected-terminal-token cannot be used with a managed Session; " +
9433
+ "use the exact full terminal selector advertised by AKK list");
9434
+ }
8699
9435
  const sessionId = required(stringValue(options.session ?? options.conversation ?? options.conversationId), "--session is required for an ordinary managed send");
8700
9436
  const storeDir = storeDirFromOptions(options);
8701
9437
  // Read only enough legacy/Session authority to identify the physical
@@ -9638,7 +10374,7 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
9638
10374
  releaseTerminalLock();
9639
10375
  }
9640
10376
  }
9641
- async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, terminalSendLockHeld = false, terminalStateLockHeld = false, storeWriterLeaseHeld = false, recordMessageAfterSend = false, recordRawAttachmentAfterSend = false, onTerminalPreflightVerified = undefined, allowedPreMaterializationIdentity = undefined, allowedAdditionalIdentities = [], continuingTurnResponse = false }) {
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 }) {
9642
10378
  const bridge = terminalBridgeEnabled(conversation);
9643
10379
  if (!terminalSendLockHeld) {
9644
10380
  const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
@@ -9660,6 +10396,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9660
10396
  onTerminalPreflightVerified,
9661
10397
  allowedPreMaterializationIdentity,
9662
10398
  allowedAdditionalIdentities,
10399
+ observedHandoff,
9663
10400
  continuingTurnResponse
9664
10401
  });
9665
10402
  }
@@ -9699,6 +10436,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9699
10436
  onTerminalPreflightVerified,
9700
10437
  allowedPreMaterializationIdentity,
9701
10438
  allowedAdditionalIdentities,
10439
+ observedHandoff,
9702
10440
  continuingTurnResponse
9703
10441
  });
9704
10442
  }
@@ -9725,6 +10463,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9725
10463
  onTerminalPreflightVerified,
9726
10464
  allowedPreMaterializationIdentity,
9727
10465
  allowedAdditionalIdentities,
10466
+ observedHandoff,
9728
10467
  continuingTurnResponse
9729
10468
  }));
9730
10469
  }
@@ -9907,21 +10646,22 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9907
10646
  runtime: preSendRuntime
9908
10647
  });
9909
10648
  assertSafeTerminalSend(executor.kind, status);
9910
- if (executor.kind === "codex" &&
9911
- (pendingManagedNativeBinding || allowedPreMaterializationIdentity)) {
9912
- const expectedForegroundId = stringValue(sendTakeover?.terminal_agent_expected_session_id);
9913
- const foreground = createRuntimeTerminalAgentRegistry(options)
9914
- .require("codex")
9915
- .observeThreadLifecycle?.({
9916
- operation: { kind: "new_thread" },
9917
- phase: "before",
9918
- screen: status.screen.excerpt ?? ""
9919
- });
9920
- if (!expectedForegroundId ||
9921
- foreground?.status !== "observed" ||
9922
- foreground.nativeThreadId !== expectedForegroundId) {
9923
- throw new Error("Codex foreground thread changed after the managed /status proof; " +
9924
- "refresh list before sending");
10649
+ if (executor.kind === "codex" && needsPostSendNativeBinding) {
10650
+ if (pendingManagedNativeBinding || allowedPreMaterializationIdentity) {
10651
+ const expectedForegroundId = stringValue(sendTakeover?.terminal_agent_expected_session_id);
10652
+ const foreground = createRuntimeTerminalAgentRegistry(options)
10653
+ .require("codex")
10654
+ .observeThreadLifecycle?.({
10655
+ operation: { kind: "new_thread" },
10656
+ phase: "before",
10657
+ screen: status.screen.excerpt ?? ""
10658
+ });
10659
+ if (!expectedForegroundId ||
10660
+ foreground?.status !== "observed" ||
10661
+ foreground.nativeThreadId !== expectedForegroundId) {
10662
+ throw new Error("Codex foreground thread changed after the managed /status proof; " +
10663
+ "refresh list before sending");
10664
+ }
9925
10665
  }
9926
10666
  await assertCodexComposerReadyForAutomatedInput({
9927
10667
  options,
@@ -9936,6 +10676,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9936
10676
  options,
9937
10677
  currentIdentity: currentNativeIdentity,
9938
10678
  expectedNativeThreadId: expectedManagedNativeThreadId,
10679
+ boundProcessUuid: stringValue(sendTakeover?.terminal_agent_process_uuid),
10680
+ boundProcessBirth: stringValue(sendTakeover?.terminal_agent_process_birth),
9939
10681
  allowedPreMaterializationIdentity,
9940
10682
  needsPostSendNativeBinding
9941
10683
  });
@@ -10273,6 +11015,27 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10273
11015
  try {
10274
11016
  await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
10275
11017
  runtime: preSendRuntime,
11018
+ requireExactComposerBeforeEnter: observedHandoff !== undefined,
11019
+ beforeText: observedHandoff
11020
+ ? async () => {
11021
+ await assertObservedHandoffTransportBoundary({
11022
+ options,
11023
+ terminal: observedHandoff.terminal,
11024
+ transition: observedHandoff.transition,
11025
+ requireEmptyComposer: true
11026
+ });
11027
+ }
11028
+ : undefined,
11029
+ beforeEnter: observedHandoff
11030
+ ? async () => {
11031
+ await assertObservedHandoffTransportBoundary({
11032
+ options,
11033
+ terminal: observedHandoff.terminal,
11034
+ transition: observedHandoff.transition,
11035
+ requireEmptyComposer: false
11036
+ });
11037
+ }
11038
+ : undefined,
10276
11039
  async onTransportStage(event) {
10277
11040
  const stageAt = cliNow().toISOString();
10278
11041
  if (event.stage === "text_injected") {
@@ -10313,6 +11076,14 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10313
11076
  previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
10314
11077
  stringValue(previousDispatchLedger?.message_id)
10315
11078
  });
11079
+ if (event.stage === "text_injected" && observedHandoff) {
11080
+ await assertObservedHandoffTransportBoundary({
11081
+ options,
11082
+ terminal: observedHandoff.terminal,
11083
+ transition: observedHandoff.transition,
11084
+ requireEmptyComposer: false
11085
+ });
11086
+ }
10316
11087
  try {
10317
11088
  appendEvent(logPath, {
10318
11089
  ts: stageAt,
@@ -10332,6 +11103,10 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10332
11103
  if (!enterDispatchedAt) {
10333
11104
  throw new Error("terminal bridge returned without an enter_dispatched receipt");
10334
11105
  }
11106
+ if (codexRolloutAcceptanceAnchor?.version === 2 &&
11107
+ cliEnv().AKK_TEST_EXIT_AFTER_VIRGIN_ENTER_DISPATCHED === "1") {
11108
+ cliExit(86);
11109
+ }
10335
11110
  let submittedBase = stagedConversation;
10336
11111
  if (needsPostSendNativeBinding) {
10337
11112
  let boundIdentity;
@@ -10353,6 +11128,14 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10353
11128
  }
10354
11129
  if (boundIdentity) {
10355
11130
  try {
11131
+ if (codexRolloutAcceptanceAnchor &&
11132
+ (boundIdentity.processUuid !==
11133
+ codexRolloutAcceptanceAnchor.process_uuid ||
11134
+ boundIdentity.processBirth !==
11135
+ codexRolloutAcceptanceAnchor.process_birth)) {
11136
+ throw new Error("Codex process incarnation changed while its native " +
11137
+ "thread materialized");
11138
+ }
10356
11139
  const expectedNativeThreadId = stringValue(sendTakeover?.terminal_agent_expected_session_id);
10357
11140
  if (expectedNativeThreadId &&
10358
11141
  boundIdentity.sessionId !== expectedNativeThreadId) {
@@ -10378,6 +11161,10 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10378
11161
  terminalControl,
10379
11162
  identity: boundIdentity
10380
11163
  });
11164
+ if (codexRolloutAcceptanceAnchor?.version === 2 &&
11165
+ cliEnv().AKK_TEST_EXIT_AFTER_VIRGIN_SESSION_BINDING === "1") {
11166
+ cliExit(86);
11167
+ }
10381
11168
  // A virgin terminal Turn and its Session start with the same
10382
11169
  // provisional binding id/generation before Codex has materialized a
10383
11170
  // native thread. Persist the observed identity as a monotonic
@@ -11050,7 +11837,7 @@ function syntheticTerminalAcceptanceEvidence({ options, executor, conversation,
11050
11837
  .digest("hex")
11051
11838
  };
11052
11839
  }
11053
- function captureCodexAcceptanceAnchorForSend({ options, currentIdentity, expectedNativeThreadId, allowedPreMaterializationIdentity, needsPostSendNativeBinding }) {
11840
+ function captureCodexAcceptanceAnchorForSend({ options, currentIdentity, expectedNativeThreadId, boundProcessUuid, boundProcessBirth, allowedPreMaterializationIdentity, needsPostSendNativeBinding }) {
11054
11841
  if (allowsSyntheticTerminalAcceptance(options)) {
11055
11842
  return undefined;
11056
11843
  }
@@ -11058,25 +11845,258 @@ function captureCodexAcceptanceAnchorForSend({ options, currentIdentity, expecte
11058
11845
  ? expectedNativeThreadId
11059
11846
  : currentIdentity?.sessionId;
11060
11847
  const processUuid = currentIdentity?.processUuid ??
11061
- allowedPreMaterializationIdentity?.processUuid;
11848
+ allowedPreMaterializationIdentity?.processUuid ??
11849
+ boundProcessUuid;
11062
11850
  const processBirth = currentIdentity?.processBirth ??
11063
- allowedPreMaterializationIdentity?.processBirth;
11064
- if (!nativeThreadId || !processUuid || !processBirth) {
11065
- throw new Error("Codex native acceptance anchor requires an exact thread and process incarnation");
11851
+ allowedPreMaterializationIdentity?.processBirth ??
11852
+ boundProcessBirth;
11853
+ if (!processUuid || !processBirth) {
11854
+ throw new Error("Codex native acceptance anchor requires an exact process incarnation");
11855
+ }
11856
+ if (!nativeThreadId && !needsPostSendNativeBinding) {
11857
+ throw new Error("Codex native acceptance anchor requires an exact thread");
11066
11858
  }
11067
- const base = { nativeThreadId, processUuid, processBirth };
11068
11859
  if (!needsPostSendNativeBinding && currentIdentity?.rollout) {
11069
11860
  return captureCodexRolloutAcceptanceAnchor({
11070
- ...base,
11861
+ nativeThreadId: nativeThreadId,
11862
+ processUuid,
11863
+ processBirth,
11864
+ now: cliNow(),
11071
11865
  mode: "existing",
11072
11866
  rollout: currentIdentity.rollout
11073
11867
  });
11074
11868
  }
11075
- return captureCodexRolloutAcceptanceAnchor({
11076
- ...base,
11077
- mode: "pre_materialization",
11078
- expectedEmptyNativeSession: true
11079
- });
11869
+ return nativeThreadId
11870
+ ? captureCodexRolloutAcceptanceAnchor({
11871
+ nativeThreadId,
11872
+ processUuid,
11873
+ processBirth,
11874
+ now: cliNow(),
11875
+ mode: "pre_materialization",
11876
+ expectedEmptyNativeSession: true
11877
+ })
11878
+ : captureCodexRolloutAcceptanceAnchor({
11879
+ processUuid,
11880
+ processBirth,
11881
+ now: cliNow(),
11882
+ mode: "pre_materialization",
11883
+ expectedEmptyNativeSession: true
11884
+ });
11885
+ }
11886
+ function exactNativeRolloutMatches(left, right) {
11887
+ return Boolean(isCompleteNativeRollout(left) &&
11888
+ right &&
11889
+ left.fd === right.fd &&
11890
+ left.device === right.device &&
11891
+ left.inode === right.inode &&
11892
+ left.path === right.path);
11893
+ }
11894
+ /**
11895
+ * Finish the one monotonic binding transaction that a process crash may split
11896
+ * after a virgin Codex Turn has durably dispatched Enter. This is deliberately
11897
+ * scoped to the v2 post-submission anchor: legacy/v1 Turns retain their normal
11898
+ * binding fences and never gain a new recovery path.
11899
+ */
11900
+ async function recoverVirginCodexPostSubmissionBinding({ options, conversation, statePath, logPath, terminalLockHeld = false }) {
11901
+ const initialTakeover = isRecord(conversation.native_session_takeover)
11902
+ ? conversation.native_session_takeover
11903
+ : undefined;
11904
+ const initialAnchor = isRecord(initialTakeover?.codex_rollout_acceptance_anchor)
11905
+ ? initialTakeover.codex_rollout_acceptance_anchor
11906
+ : undefined;
11907
+ const initialSubmission = terminalBridgeSubmission(conversation);
11908
+ const initialTerminalControl = terminalControlFromTakeover(initialTakeover);
11909
+ if (executorForConversation(conversation).kind !== "codex" ||
11910
+ initialAnchor?.version !== 2 ||
11911
+ initialAnchor?.native_thread_binding !== "post_submission" ||
11912
+ !initialTerminalControl ||
11913
+ !["enter_dispatched", "agent_accepted"].includes(String(initialSubmission?.status ?? ""))) {
11914
+ return { conversation, state: "not_applicable" };
11915
+ }
11916
+ const storeDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
11917
+ const releaseTerminalLock = terminalLockHeld
11918
+ ? () => { }
11919
+ : acquireTerminalBridgeSendLock(storeDir, initialTerminalControl, { timeoutMs: 30000 });
11920
+ try {
11921
+ return await withStoreWriterLeaseAsync(storeDir, async () => {
11922
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
11923
+ try {
11924
+ const current = loadState(statePath);
11925
+ const takeover = isRecord(current.native_session_takeover)
11926
+ ? current.native_session_takeover
11927
+ : undefined;
11928
+ const anchor = isRecord(takeover?.codex_rollout_acceptance_anchor)
11929
+ ? takeover.codex_rollout_acceptance_anchor
11930
+ : undefined;
11931
+ const submission = terminalBridgeSubmission(current);
11932
+ const terminalControl = terminalControlFromTakeover(takeover);
11933
+ const messageId = stringValue(submission?.message_id);
11934
+ const requestHash = stringValue(takeover?.terminal_bridge_request_hash);
11935
+ const requestText = String(takeover?.terminal_bridge_request_text ?? current.user_request ?? "");
11936
+ if (executorForConversation(current).kind !== "codex" ||
11937
+ anchor?.version !== 2 ||
11938
+ anchor?.native_thread_binding !== "post_submission" ||
11939
+ !terminalControl ||
11940
+ !terminalControlsShareIncarnation(terminalControl, initialTerminalControl) ||
11941
+ !messageId ||
11942
+ messageId !== stringValue(takeover?.terminal_bridge_message_id) ||
11943
+ !["enter_dispatched", "agent_accepted"].includes(String(submission?.status ?? "")) ||
11944
+ !requestHash ||
11945
+ requestHash !== terminalBridgeRequestFingerprint(requestText)) {
11946
+ return { conversation: current, state: "not_applicable" };
11947
+ }
11948
+ const bindingId = stringValue(current.terminal_binding_id);
11949
+ const bindingGeneration = Number(current.terminal_binding_generation);
11950
+ const session = loadManagedSession(storeDir, sessionIdForConversation(current));
11951
+ const binding = session.binding;
11952
+ const pid = Number(takeover?.terminal_agent_pid);
11953
+ if (!bindingId ||
11954
+ !Number.isSafeInteger(bindingGeneration) ||
11955
+ !Number.isSafeInteger(pid) ||
11956
+ pid <= 1 ||
11957
+ session.agent !== "codex" ||
11958
+ session.status !== "bound" ||
11959
+ !binding ||
11960
+ binding.binding_id !== bindingId ||
11961
+ binding.generation !== bindingGeneration ||
11962
+ binding.native_process.pid !== pid ||
11963
+ !terminalControlsShareIncarnation(binding.terminal_control, terminalControl)) {
11964
+ throw new Error("virgin Codex post-submission binding changed before recovery");
11965
+ }
11966
+ const anchorProcessUuid = stringValue(anchor.process_uuid);
11967
+ const anchorProcessBirth = stringValue(anchor.process_birth);
11968
+ const turnProcessUuid = stringValue(takeover?.terminal_agent_process_uuid);
11969
+ const turnProcessBirth = stringValue(takeover?.terminal_agent_process_birth);
11970
+ if (!anchorProcessUuid ||
11971
+ !anchorProcessBirth ||
11972
+ turnProcessUuid !== anchorProcessUuid ||
11973
+ turnProcessBirth !== anchorProcessBirth ||
11974
+ binding.native_process.process_uuid !== anchorProcessUuid ||
11975
+ binding.native_process.process_birth !== anchorProcessBirth) {
11976
+ throw new Error("virgin Codex process incarnation changed before binding recovery");
11977
+ }
11978
+ const turnNativeThreadId = stringValue(current.native_thread_id) ??
11979
+ stringValue(takeover?.terminal_agent_session_id);
11980
+ const sessionNativeThreadId = binding.native_thread_id;
11981
+ if (turnNativeThreadId &&
11982
+ sessionNativeThreadId &&
11983
+ turnNativeThreadId !== sessionNativeThreadId) {
11984
+ throw new Error("virgin Codex Session and Turn disagree before binding recovery");
11985
+ }
11986
+ if (turnNativeThreadId && !isCompleteNativeRollout(takeover?.terminal_agent_rollout)) {
11987
+ throw new Error("virgin Codex Turn has a partial recovered native identity");
11988
+ }
11989
+ if (sessionNativeThreadId && !binding.native_process.rollout) {
11990
+ throw new Error("virgin Codex Session has a partial recovered native identity");
11991
+ }
11992
+ if (turnNativeThreadId && sessionNativeThreadId) {
11993
+ assertTurnBindingCurrent(current, "recover virgin Codex binding for");
11994
+ return { conversation: current, state: "already_bound" };
11995
+ }
11996
+ const preferredSessionId = turnNativeThreadId ?? sessionNativeThreadId;
11997
+ const identity = await resolveCurrentNativeAgentSessionIdentity({
11998
+ options,
11999
+ agent: "codex",
12000
+ pid,
12001
+ cwd: terminalControl.currentPath,
12002
+ preferredSessionId
12003
+ });
12004
+ if (!identity) {
12005
+ return { conversation: current, state: "pending" };
12006
+ }
12007
+ if (!isExactNativeThreadId(identity.sessionId) ||
12008
+ identity.processUuid !== anchorProcessUuid ||
12009
+ identity.processBirth !== anchorProcessBirth ||
12010
+ !isCompleteNativeRollout(identity.rollout) ||
12011
+ (preferredSessionId && identity.sessionId !== preferredSessionId) ||
12012
+ (turnNativeThreadId &&
12013
+ !exactNativeRolloutMatches(takeover?.terminal_agent_rollout, identity.rollout)) ||
12014
+ (sessionNativeThreadId &&
12015
+ !exactNativeRolloutMatches(binding.native_process.rollout, identity.rollout))) {
12016
+ throw new Error("virgin Codex native identity changed before binding recovery");
12017
+ }
12018
+ // When neither side was committed, only the exact native acceptance
12019
+ // record may tell us that this newly materialized UUID belongs to the
12020
+ // dispatched request. If either side was already committed before the
12021
+ // crash, that durable CAS plus a fresh exact identity is sufficient to
12022
+ // finish the other side while acceptance is still being written.
12023
+ if (!turnNativeThreadId && !sessionNativeThreadId) {
12024
+ const acceptance = detectCodexRolloutAcceptance({
12025
+ anchor: anchor,
12026
+ currentIdentity: identity,
12027
+ requestHash
12028
+ });
12029
+ if (!acceptance) {
12030
+ return { conversation: current, state: "pending" };
12031
+ }
12032
+ }
12033
+ await assertNativeThreadHasExclusiveOwnership({
12034
+ options,
12035
+ agent: "codex",
12036
+ currentPid: pid,
12037
+ nativeThreadId: identity.sessionId,
12038
+ storeDir,
12039
+ terminalControl,
12040
+ excludedManagedSessionId: session.session_id
12041
+ });
12042
+ if (!sessionNativeThreadId) {
12043
+ const persistedSession = persistManagedSessionNativeIdentity({
12044
+ conversation: current,
12045
+ terminalControl,
12046
+ identity
12047
+ });
12048
+ if (persistedSession?.binding?.native_thread_id !==
12049
+ identity.sessionId ||
12050
+ persistedSession.binding.native_process.process_uuid !==
12051
+ identity.processUuid ||
12052
+ persistedSession.binding.native_process.process_birth !==
12053
+ identity.processBirth ||
12054
+ !exactNativeRolloutMatches(persistedSession.binding.native_process.rollout, identity.rollout)) {
12055
+ throw new Error("virgin Codex Session identity was not durably committed during recovery");
12056
+ }
12057
+ }
12058
+ const recoveredAt = cliNow().toISOString();
12059
+ const recoveredConversation = turnNativeThreadId
12060
+ ? current
12061
+ : {
12062
+ ...withNativeAgentSessionIdentity(current, identity),
12063
+ updated_at: recoveredAt
12064
+ };
12065
+ if (!turnNativeThreadId) {
12066
+ saveState(statePath, recoveredConversation);
12067
+ }
12068
+ assertNativeAgentIdentityForTurn({
12069
+ conversation: recoveredConversation,
12070
+ currentIdentity: identity,
12071
+ operation: "recover virgin Codex binding for"
12072
+ });
12073
+ try {
12074
+ appendEvent(logPath, {
12075
+ ts: recoveredAt,
12076
+ conversation_id: recoveredConversation.conversation_id,
12077
+ event: "virgin_codex_post_submission_binding_recovered",
12078
+ message_id: messageId,
12079
+ native_thread_id: identity.sessionId,
12080
+ terminal_control: terminalControl
12081
+ });
12082
+ }
12083
+ catch (error) {
12084
+ runtimeLog("warn", "virgin_codex_binding_recovery_event_failed", {
12085
+ conversation_id: recoveredConversation.conversation_id,
12086
+ terminal_target: terminalControl.target,
12087
+ error: error instanceof Error ? error.message : String(error)
12088
+ });
12089
+ }
12090
+ return { conversation: recoveredConversation, state: "recovered" };
12091
+ }
12092
+ finally {
12093
+ releaseStateLock();
12094
+ }
12095
+ });
12096
+ }
12097
+ finally {
12098
+ releaseTerminalLock();
12099
+ }
11080
12100
  }
11081
12101
  async function detectTerminalSubmissionAcceptance({ options, executor, conversation, terminalControl }) {
11082
12102
  const nativeTakeover = isRecord(conversation.native_session_takeover)
@@ -11187,12 +12207,57 @@ async function pollTerminalSubmissionAcceptance({ options, executor, conversatio
11187
12207
  return { outcome: "pending_acceptance" };
11188
12208
  }
11189
12209
  async function reconcileTerminalAcceptanceInMonitor({ options, conversation, statePath, logPath, terminalControl, executor, terminalBridge }) {
11190
- const evidence = await detectTerminalSubmissionAcceptance({
12210
+ let bindingRecovery = await recoverVirginCodexPostSubmissionBinding({
12211
+ options,
12212
+ conversation,
12213
+ statePath,
12214
+ logPath,
12215
+ terminalLockHeld: true
12216
+ });
12217
+ conversation = bindingRecovery.conversation;
12218
+ let evidence = await detectTerminalSubmissionAcceptance({
11191
12219
  options,
11192
12220
  executor: executor.kind,
11193
12221
  conversation,
11194
12222
  terminalControl
11195
12223
  });
12224
+ const recoveredAnchor = isRecord(conversation.native_session_takeover) &&
12225
+ isRecord(conversation.native_session_takeover.codex_rollout_acceptance_anchor)
12226
+ ? conversation.native_session_takeover.codex_rollout_acceptance_anchor
12227
+ : undefined;
12228
+ if (evidence &&
12229
+ recoveredAnchor?.version === 2 &&
12230
+ bindingRecovery.state === "not_applicable") {
12231
+ throw new Error("virgin Codex acceptance appeared without a recoverable Session/Turn binding");
12232
+ }
12233
+ if (evidence &&
12234
+ recoveredAnchor?.version === 2 &&
12235
+ bindingRecovery.state === "pending") {
12236
+ // The session_meta and exact user record can land between the first
12237
+ // recovery probe and this acceptance probe. Never persist agent_accepted
12238
+ // until the same evidence has also closed the Session/Turn binding CAS.
12239
+ bindingRecovery = await recoverVirginCodexPostSubmissionBinding({
12240
+ options,
12241
+ conversation,
12242
+ statePath,
12243
+ logPath,
12244
+ terminalLockHeld: true
12245
+ });
12246
+ if (bindingRecovery.state !== "recovered" &&
12247
+ bindingRecovery.state !== "already_bound") {
12248
+ return { outcome: "pending" };
12249
+ }
12250
+ conversation = bindingRecovery.conversation;
12251
+ evidence = await detectTerminalSubmissionAcceptance({
12252
+ options,
12253
+ executor: executor.kind,
12254
+ conversation,
12255
+ terminalControl
12256
+ });
12257
+ if (!evidence) {
12258
+ return { outcome: "pending" };
12259
+ }
12260
+ }
11196
12261
  const submission = terminalBridgeSubmission(conversation);
11197
12262
  const nativeTakeover = isRecord(conversation.native_session_takeover)
11198
12263
  ? conversation.native_session_takeover
@@ -12331,12 +13396,18 @@ async function reconcileMonitors(options, { includeCallbackRecovery, reason, con
12331
13396
  ignored += 1;
12332
13397
  continue;
12333
13398
  }
12334
- const initialConversation = await migrateLegacyTerminalAgentIdentity({
13399
+ let initialConversation = await migrateLegacyTerminalAgentIdentity({
12335
13400
  conversation: loadState(statePath),
12336
13401
  statePath,
12337
13402
  logPath,
12338
13403
  options
12339
13404
  });
13405
+ initialConversation = (await recoverVirginCodexPostSubmissionBinding({
13406
+ options,
13407
+ conversation: initialConversation,
13408
+ statePath,
13409
+ logPath
13410
+ })).conversation;
12340
13411
  assertTurnBindingCurrent(initialConversation, "reconcile monitor for");
12341
13412
  const initialEligibility = terminalBridgeReconciliationEligibility(initialConversation);
12342
13413
  if (!initialEligibility.eligible) {
@@ -12960,9 +14031,185 @@ async function runTerminalControlCancel({ options, statePath, logPath, agent, te
12960
14031
  }
12961
14032
  }
12962
14033
  }
14034
+ async function runObservedHandoffClose({ options, statePath, logPath, initialConversation }) {
14035
+ const expectedToken = required(stringValue(options.expectedHandoffToken), "--expected-handoff-token is required");
14036
+ if (stringValue(options.expectedMessageId) ||
14037
+ stringValue(options.expectedTransitionId)) {
14038
+ throw new Error("--expected-handoff-token cannot be combined with dispatch or lifecycle recovery tokens");
14039
+ }
14040
+ if (stringValue(options.reason) !== "superseded_by_human_context_switch") {
14041
+ throw new Error("a handoff close requires reason superseded_by_human_context_switch");
14042
+ }
14043
+ const storeDir = storeDirFromOptions(options);
14044
+ const sourceSessionId = sessionIdForConversation(initialConversation);
14045
+ const initialSource = loadManagedSession(storeDir, sourceSessionId);
14046
+ if (!initialSource.binding || initialSource.status !== "bound") {
14047
+ throw new Error("handoff source Session is no longer bound; refresh list");
14048
+ }
14049
+ const terminal = await createTerminalAgentBridge(options).resolveStoredTerminal(initialSource.agent, initialSource.binding.native_process.pid, initialSource.binding.terminal_control, { pid: initialSource.binding.native_process.pid });
14050
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDir, terminal.terminalControl, { timeoutMs: 30000 });
14051
+ try {
14052
+ await withStoreWriterLeaseAsync(storeDir, async () => {
14053
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
14054
+ try {
14055
+ const conversation = loadState(statePath);
14056
+ const turnId = turnIdForConversation(conversation);
14057
+ if (conversation.conversation_id !== initialConversation.conversation_id ||
14058
+ sessionIdForConversation(conversation) !== sourceSessionId ||
14059
+ !SESSION_SEND_BLOCKING_STATUSES.has(conversation.status)) {
14060
+ throw new Error("active handoff Turn changed after it was listed; refresh AKK list");
14061
+ }
14062
+ const source = loadManagedSession(storeDir, sourceSessionId);
14063
+ if (source.status !== "bound" ||
14064
+ !source.binding ||
14065
+ managedSessionHasUnresolvedNativeTransition(storeDir, source)) {
14066
+ throw new Error("handoff source Session changed after it was listed; refresh AKK list");
14067
+ }
14068
+ const companions = codexAllowedCompanionSetForManagedSession({
14069
+ storeDir,
14070
+ session: source
14071
+ });
14072
+ const resolved = await resolveCurrentNativeAgentSessionIdentity({
14073
+ options,
14074
+ agent: terminal.agent,
14075
+ pid: terminal.pid,
14076
+ cwd: terminal.terminalControl.currentPath,
14077
+ preferredSessionId: companions.primary
14078
+ ? source.binding.native_thread_id
14079
+ : undefined,
14080
+ allowedCompanionIdentity: companions.primary,
14081
+ allowedAdditionalIdentities: companions.additional
14082
+ });
14083
+ const observed = await observedExternalHandoffIdentity({
14084
+ options,
14085
+ terminal,
14086
+ sourceSession: source,
14087
+ resolvedIdentity: resolved
14088
+ });
14089
+ if (!observed.identity ||
14090
+ managedBindingConflictKindForResolvedTerminal({
14091
+ storeDir,
14092
+ session: source,
14093
+ terminal,
14094
+ identity: observed.identity
14095
+ }) !== "live_external_thread_change") {
14096
+ throw new Error("live native thread no longer matches the listed handoff; refresh AKK list");
14097
+ }
14098
+ const targetNativeThreadId = observed.identity.sessionId.toLowerCase();
14099
+ const target = observedHandoffTargetResolution({
14100
+ storeDir,
14101
+ agent: terminal.agent,
14102
+ workspace: terminal.terminalControl.currentPath ?? cliCwd(),
14103
+ nativeThreadId: targetNativeThreadId,
14104
+ sourceSessionId
14105
+ });
14106
+ if (target.status !== "eligible") {
14107
+ throw new Error("handoff target Session changed after it was listed; refresh AKK list");
14108
+ }
14109
+ await assertNativeThreadHasExclusiveOwnership({
14110
+ options,
14111
+ agent: terminal.agent,
14112
+ currentPid: terminal.pid,
14113
+ nativeThreadId: targetNativeThreadId,
14114
+ storeDir,
14115
+ terminalControl: terminal.terminalControl,
14116
+ excludedManagedSessionId: target.snapshot.state === "detached"
14117
+ ? target.snapshot.session_id
14118
+ : undefined
14119
+ });
14120
+ const handoffToken = observedHandoffAuthorityToken({
14121
+ terminal,
14122
+ identity: observed.identity,
14123
+ sourceSession: source,
14124
+ target: target.snapshot
14125
+ });
14126
+ const takeover = isRecord(conversation.native_session_takeover)
14127
+ ? conversation.native_session_takeover
14128
+ : undefined;
14129
+ const expectedMessageId = stringValue(takeover?.terminal_bridge_message_id) ?? stringValue(terminalBridgeSubmission(conversation)?.message_id);
14130
+ const ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
14131
+ const exactDispatchGeneration = Boolean(expectedMessageId &&
14132
+ ledger &&
14133
+ !terminalDispatchLedgerLooksLifecycle(ledger) &&
14134
+ stringValue(ledger.conversation_id) ===
14135
+ conversation.conversation_id &&
14136
+ stringValue(ledger.session_id) === sourceSessionId &&
14137
+ stringValue(ledger.turn_id) === turnId &&
14138
+ stringValue(ledger.message_id) === expectedMessageId);
14139
+ const exactNoLedgerGeneration = Boolean(!expectedMessageId &&
14140
+ (!ledger || ledger.status === "resolved"));
14141
+ if (!exactDispatchGeneration && !exactNoLedgerGeneration) {
14142
+ throw new Error("active handoff dispatch generation changed; refresh AKK list");
14143
+ }
14144
+ const freshToken = activeTurnHandoffDecisionToken({
14145
+ handoffToken,
14146
+ turn: conversation,
14147
+ ledger
14148
+ });
14149
+ if (freshToken !== expectedToken) {
14150
+ throw new Error("active handoff snapshot changed; refresh AKK list before closing");
14151
+ }
14152
+ const now = cliNow().toISOString();
14153
+ const closed = {
14154
+ ...conversation,
14155
+ status: "closed",
14156
+ closed_at: now,
14157
+ close_reason: "superseded_by_human_context_switch",
14158
+ disposition: "superseded_by_human_context_switch",
14159
+ updated_at: now
14160
+ };
14161
+ saveState(statePath, closed);
14162
+ const dispatchResolved = expectedMessageId
14163
+ ? resolveTerminalBridgeDispatchLedger({
14164
+ terminalControl: terminal.terminalControl,
14165
+ conversation: closed,
14166
+ expectedMessageId,
14167
+ reason: "Turn superseded by a verified human context switch"
14168
+ })
14169
+ : false;
14170
+ if (expectedMessageId && !dispatchResolved) {
14171
+ throw new Error("active handoff dispatch changed during close; inspect before retrying");
14172
+ }
14173
+ appendEvent(logPath, {
14174
+ ts: now,
14175
+ conversation_id: conversation.conversation_id,
14176
+ event: "conversation_closed",
14177
+ status: "closed",
14178
+ reason: "superseded_by_human_context_switch",
14179
+ disposition: "superseded_by_human_context_switch",
14180
+ handoff_native_thread_id: targetNativeThreadId
14181
+ });
14182
+ runtimeLog("info", "conversation_closed", {
14183
+ conversation_id: conversation.conversation_id,
14184
+ status: "closed",
14185
+ reason: "superseded_by_human_context_switch",
14186
+ disposition: "superseded_by_human_context_switch",
14187
+ state_path: statePath,
14188
+ event_log_path: logPath
14189
+ });
14190
+ printJson({
14191
+ conversation: closed,
14192
+ closed: true,
14193
+ terminal_dispatch_resolved: dispatchResolved,
14194
+ handoff_disposition: "superseded_by_human_context_switch",
14195
+ next_action: "refresh list and use its follow-current send"
14196
+ });
14197
+ }
14198
+ finally {
14199
+ releaseStateLock();
14200
+ }
14201
+ });
14202
+ }
14203
+ finally {
14204
+ releaseTerminalLock();
14205
+ }
14206
+ }
12963
14207
  async function runClose(options) {
12964
14208
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
12965
14209
  if (terminalConversation) {
14210
+ if (stringValue(options.expectedHandoffToken)) {
14211
+ throw new Error("--expected-handoff-token cannot be used with raw terminal close");
14212
+ }
12966
14213
  await runTerminalDispatchClose({
12967
14214
  options,
12968
14215
  terminalConversation
@@ -12970,6 +14217,15 @@ async function runClose(options) {
12970
14217
  return;
12971
14218
  }
12972
14219
  const loaded = loadConversationFromOptions(options);
14220
+ if (stringValue(options.expectedHandoffToken)) {
14221
+ await runObservedHandoffClose({
14222
+ options,
14223
+ statePath: loaded.statePath,
14224
+ logPath: loaded.logPath,
14225
+ initialConversation: loaded.conversation
14226
+ });
14227
+ return;
14228
+ }
12973
14229
  const { statePath, logPath } = loaded;
12974
14230
  const nativeTakeover = isRecord(loaded.conversation.native_session_takeover)
12975
14231
  ? loaded.conversation.native_session_takeover
@@ -12983,11 +14239,18 @@ async function runClose(options) {
12983
14239
  releaseStateLock = acquireFileLock(`${statePath}.lock`);
12984
14240
  const conversation = loadState(statePath);
12985
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;
12986
14246
  const closed = {
12987
14247
  ...conversation,
12988
14248
  status: "closed",
12989
14249
  closed_at: now,
12990
- close_reason: options.reason ?? "closed by request",
14250
+ close_reason: closeReason,
14251
+ ...(handoffDisposition
14252
+ ? { disposition: handoffDisposition }
14253
+ : {}),
12991
14254
  updated_at: now
12992
14255
  };
12993
14256
  saveState(statePath, closed);
@@ -15027,6 +16290,7 @@ function terminalDispatchLedgerLooksLifecycle(ledger) {
15027
16290
  ledger.transition_id !== undefined ||
15028
16291
  ledger.operation === "new_thread" ||
15029
16292
  ledger.operation === "resume_thread" ||
16293
+ ledger.operation === "adopt_external_thread" ||
15030
16294
  ledger.adapter_version !== undefined ||
15031
16295
  ledger.command_fingerprint !== undefined ||
15032
16296
  ledger.target_session_id !== undefined ||
@@ -15042,6 +16306,8 @@ function lifecycleLedgerFields(transition, storeDir) {
15042
16306
  generation_id: transition.transition_id,
15043
16307
  transition_id: transition.transition_id,
15044
16308
  operation: transition.operation,
16309
+ origin: transition.origin,
16310
+ terminal_input_sent: transition.terminal_input_sent,
15045
16311
  terminal_id: transition.terminal_id,
15046
16312
  agent: transition.agent,
15047
16313
  workspace: transition.workspace,
@@ -15475,6 +16741,7 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
15475
16741
  const dispatcherPid = Number(ledger.dispatcher_pid);
15476
16742
  if (Number.isSafeInteger(dispatcherPid) &&
15477
16743
  dispatcherPid > 1 &&
16744
+ dispatcherPid !== cliPid() &&
15478
16745
  isProcessAlive(dispatcherPid)) {
15479
16746
  return ledger;
15480
16747
  }
@@ -15812,9 +17079,16 @@ function terminalSubmissionProofRank(status, lastProven) {
15812
17079
  return 0;
15813
17080
  }
15814
17081
  async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
15815
- const ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
17082
+ let ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
15816
17083
  if (!ledger || ledger.status === "resolved") {
15817
- return;
17084
+ ledger = rebuildObservedHandoffLedgerFromTransition({
17085
+ options,
17086
+ terminal,
17087
+ previousLedger: ledger
17088
+ });
17089
+ if (!ledger) {
17090
+ return;
17091
+ }
15818
17092
  }
15819
17093
  if (!terminalDispatchLedgerLooksLifecycle(ledger)) {
15820
17094
  return;
@@ -15833,6 +17107,175 @@ async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
15833
17107
  "expected-transition-id recovery action");
15834
17108
  }
15835
17109
  }
17110
+ function rebuildObservedHandoffLedgerFromTransition({ options, terminal, previousLedger }) {
17111
+ const storeDir = storeDirFromOptions(options);
17112
+ const candidates = listNativeThreadTransitions(storeDir).filter((transition) => transition.operation === "adopt_external_thread" &&
17113
+ transition.origin === "human_observed" &&
17114
+ transition.terminal_input_sent === false &&
17115
+ ["prepared", "verified"].includes(transition.status) &&
17116
+ transition.agent === terminal.agent &&
17117
+ transition.before_binding?.native_process.pid === terminal.pid &&
17118
+ terminalControlAliasMatches(transition.terminal_id, transition.before_binding?.terminal_control, terminal.conversationId, terminal.terminalControl) &&
17119
+ matchesConfiguredWorkspace(transition.workspace, terminal.terminalControl.currentPath));
17120
+ if (candidates.length === 0) {
17121
+ return undefined;
17122
+ }
17123
+ if (candidates.length !== 1) {
17124
+ throw new Error(`terminal ${terminal.terminalControl.target} has multiple unresolved ` +
17125
+ "human-observed handoff transitions; refusing to infer one ledger owner");
17126
+ }
17127
+ const transition = candidates[0];
17128
+ if (previousLedger?.status === "resolved" &&
17129
+ stringValue(previousLedger.transition_id) === transition.transition_id) {
17130
+ throw new Error(`human-observed handoff transition ${transition.transition_id} is ` +
17131
+ "unresolved but its terminal ledger is already resolved");
17132
+ }
17133
+ const rebuilt = {
17134
+ ...lifecycleLedgerFields(transition, storeDir),
17135
+ status: transition.status,
17136
+ binding: transition.before_binding,
17137
+ terminal_control: {
17138
+ kind: terminal.terminalControl.kind,
17139
+ target: terminal.terminalControl.target,
17140
+ socket_path: terminal.terminalControl.socketPath ?? null,
17141
+ pane_pid: terminal.terminalControl.panePid ?? null,
17142
+ current_path: terminal.terminalControl.currentPath ?? null
17143
+ },
17144
+ ...(hasCanonicalTerminalEndpoint(terminal.terminalControl)
17145
+ ? {
17146
+ terminal_endpoint: terminalControlEvidence(terminal.terminalControl)
17147
+ }
17148
+ : {}),
17149
+ previous_generation_id: stringValue(previousLedger?.generation_id) ??
17150
+ stringValue(previousLedger?.message_id)
17151
+ };
17152
+ // Validate the complete transition/terminal/adapter relationship before
17153
+ // replacing a missing or older resolved ledger. Rebuilding a fence is a
17154
+ // Store-side recovery operation only and never sends terminal input.
17155
+ assertLifecycleLedgerMatchesTransition({
17156
+ options,
17157
+ terminal,
17158
+ ledger: rebuilt,
17159
+ transition,
17160
+ storeDir
17161
+ });
17162
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, rebuilt, {
17163
+ expectedTransitionId: null
17164
+ });
17165
+ return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
17166
+ }
17167
+ async function recoverPreparedObservedHandoff({ options, terminal, ledger, transition, storeDir, now }) {
17168
+ if (transition.operation !== "adopt_external_thread" ||
17169
+ transition.status !== "prepared" ||
17170
+ ledger.status !== "prepared" ||
17171
+ !transition.source_session_id ||
17172
+ transition.source_expected_revision === undefined ||
17173
+ !transition.before_binding ||
17174
+ !transition.target_native_thread_id) {
17175
+ return transition;
17176
+ }
17177
+ let source = loadManagedSession(storeDir, transition.source_session_id);
17178
+ if (source.status === "bound" &&
17179
+ source.revision === transition.source_expected_revision &&
17180
+ JSON.stringify(source.binding) === JSON.stringify(transition.before_binding)) {
17181
+ source = saveManagedSession(storeDir, {
17182
+ ...source,
17183
+ status: "transitioning",
17184
+ last_transition_id: transition.transition_id,
17185
+ updated_at: now
17186
+ }, { expectedRevision: managedSessionRevision(source) });
17187
+ }
17188
+ else if (source.status !== "transitioning" ||
17189
+ source.last_transition_id !== transition.transition_id ||
17190
+ source.revision !== transition.source_expected_revision + 1 ||
17191
+ JSON.stringify(source.binding) !== JSON.stringify(transition.before_binding)) {
17192
+ throw new Error("human-observed handoff source changed before recovery");
17193
+ }
17194
+ const before = transition.before_binding;
17195
+ const resolved = await resolveCurrentNativeAgentSessionIdentity({
17196
+ options,
17197
+ agent: terminal.agent,
17198
+ pid: terminal.pid,
17199
+ cwd: terminal.terminalControl.currentPath,
17200
+ preferredSessionId: transition.target_native_thread_id,
17201
+ allowedCompanionIdentity: codexIdentityFence({
17202
+ sessionId: transition.before_native_thread_id,
17203
+ processUuid: transition.before_process_uuid,
17204
+ processBirth: transition.before_process_birth,
17205
+ rollout: transition.before_process_rollout,
17206
+ evidence: before.native_process.evidence
17207
+ }),
17208
+ allowedAdditionalIdentities: []
17209
+ });
17210
+ const observed = await observedExternalHandoffIdentity({
17211
+ options,
17212
+ terminal,
17213
+ sourceSession: source,
17214
+ resolvedIdentity: resolved
17215
+ });
17216
+ const liveId = observed.identity?.sessionId.toLowerCase();
17217
+ if (liveId === transition.before_native_thread_id.toLowerCase()) {
17218
+ transition = saveNativeThreadTransition(storeDir, {
17219
+ ...transition,
17220
+ status: "aborted",
17221
+ aborted_at: now,
17222
+ error: "recovery observed the exact source native thread"
17223
+ }, { expectedRevision: nativeThreadTransitionRevision(transition) });
17224
+ restorePreparedLifecycleSource(storeDir, transition, now);
17225
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
17226
+ ...lifecycleLedgerFields(transition, storeDir),
17227
+ status: "resolved",
17228
+ resolved_at: now,
17229
+ reason: "human-observed handoff rolled back to its exact source"
17230
+ }, { expectedTransitionId: transition.transition_id });
17231
+ return transition;
17232
+ }
17233
+ if (!observed.identity ||
17234
+ liveId !== transition.target_native_thread_id.toLowerCase()) {
17235
+ throw new Error("human-observed handoff recovery found neither its exact source nor target");
17236
+ }
17237
+ const target = tryLoadManagedSession(storeDir, transition.target_session_id);
17238
+ if ((target?.revision ?? null) !== transition.target_expected_revision) {
17239
+ throw new Error("human-observed handoff target changed before recovery");
17240
+ }
17241
+ await assertNativeThreadHasExclusiveOwnership({
17242
+ options,
17243
+ agent: terminal.agent,
17244
+ currentPid: terminal.pid,
17245
+ nativeThreadId: transition.target_native_thread_id,
17246
+ storeDir,
17247
+ terminalControl: terminal.terminalControl,
17248
+ excludedManagedSessionId: target?.session_id
17249
+ });
17250
+ const exact = exactLifecycleProcessIdentity(terminal, observed.identity);
17251
+ const afterBinding = terminalBindingFrom({
17252
+ terminalId: terminal.conversationId,
17253
+ terminalControl: terminal.terminalControl,
17254
+ pid: terminal.pid,
17255
+ nativeThreadId: transition.target_native_thread_id,
17256
+ processUuid: exact.processUuid,
17257
+ processBirth: exact.processBirth,
17258
+ rollout: exact.rollout,
17259
+ evidence: `${exact.evidence}+human_observed_recovery`,
17260
+ generation: (target?.binding?.generation ?? 0) + 1,
17261
+ now: new Date(now)
17262
+ });
17263
+ transition = saveNativeThreadTransition(storeDir, {
17264
+ ...transition,
17265
+ status: "verified",
17266
+ verified_at: now,
17267
+ after_binding: afterBinding
17268
+ }, { expectedRevision: nativeThreadTransitionRevision(transition) });
17269
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
17270
+ ...lifecycleLedgerFields(transition, storeDir),
17271
+ status: "verified",
17272
+ binding: before
17273
+ }, {
17274
+ expectedTransitionId: transition.transition_id,
17275
+ expectedStatus: "prepared"
17276
+ });
17277
+ return transition;
17278
+ }
15836
17279
  async function reconcileLifecycleDispatchLedger(options, terminal, ledger, authority = {
15837
17280
  kind: "automatic"
15838
17281
  }) {
@@ -15876,6 +17319,34 @@ async function reconcileLifecycleDispatchLedger(options, terminal, ledger, autho
15876
17319
  transition,
15877
17320
  storeDir
15878
17321
  });
17322
+ if (transition.operation === "adopt_external_thread" &&
17323
+ transition.status === "verified" &&
17324
+ ledger.status === "prepared") {
17325
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
17326
+ ...lifecycleLedgerFields(transition, storeDir),
17327
+ status: "verified",
17328
+ binding: transition.before_binding,
17329
+ previous_generation_id: stringValue(ledger.previous_generation_id)
17330
+ }, {
17331
+ expectedTransitionId: transition.transition_id,
17332
+ expectedStatus: "prepared"
17333
+ });
17334
+ ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
17335
+ }
17336
+ if (transition.operation === "adopt_external_thread" &&
17337
+ transition.status === "prepared") {
17338
+ transition = await recoverPreparedObservedHandoff({
17339
+ options,
17340
+ terminal,
17341
+ ledger,
17342
+ transition,
17343
+ storeDir,
17344
+ now
17345
+ });
17346
+ if (["aborted", "committed"].includes(transition.status)) {
17347
+ return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
17348
+ }
17349
+ }
15879
17350
  if (transition.status === "prepared" && ledger.status === "prepared") {
15880
17351
  await assertNativeThreadHasExclusiveOwnership({
15881
17352
  options,
@@ -16014,9 +17485,9 @@ async function reconcileLifecycleDispatchLedger(options, terminal, ledger, autho
16014
17485
  }
16015
17486
  }
16016
17487
  function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, transition, storeDir }) {
16017
- const operationTarget = transition.operation === "resume_thread"
16018
- ? transition.target_native_thread_id
16019
- : undefined;
17488
+ const operationTarget = transition.operation === "new_thread"
17489
+ ? undefined
17490
+ : transition.target_native_thread_id;
16020
17491
  const ledgerControl = isRecord(ledger.terminal_control)
16021
17492
  ? ledger.terminal_control
16022
17493
  : undefined;
@@ -16025,8 +17496,12 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
16025
17496
  dispatching: ["prepared", "dispatching", "uncertain"],
16026
17497
  submitted: ["dispatching", "submitted", "uncertain"],
16027
17498
  uncertain: ["dispatching", "submitted", "uncertain"],
16028
- verified: ["submitted", "verified", "uncertain"],
16029
- committed: ["submitted", "verified", "uncertain"],
17499
+ verified: transition.operation === "adopt_external_thread"
17500
+ ? ["prepared", "verified", "uncertain"]
17501
+ : ["submitted", "verified", "uncertain"],
17502
+ committed: transition.operation === "adopt_external_thread"
17503
+ ? ["verified", "uncertain"]
17504
+ : ["submitted", "verified", "uncertain"],
16030
17505
  aborted: transition.reconciled_outcome === "before"
16031
17506
  ? ["dispatching", "submitted", "uncertain"]
16032
17507
  : ["prepared", "dispatching"]
@@ -16035,6 +17510,8 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
16035
17510
  stringValue(ledger.transition_id) !== transition.transition_id ||
16036
17511
  stringValue(ledger.generation_id) !== transition.transition_id ||
16037
17512
  stringValue(ledger.operation) !== transition.operation ||
17513
+ stringValue(ledger.origin) !== transition.origin ||
17514
+ ledger.terminal_input_sent !== transition.terminal_input_sent ||
16038
17515
  stringValue(ledger.terminal_id) !== transition.terminal_id ||
16039
17516
  stringValue(ledger.agent) !== transition.agent ||
16040
17517
  stringValue(ledger.workspace) !== transition.workspace ||
@@ -16080,6 +17557,16 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
16080
17557
  const adapter = createRuntimeTerminalAgentRegistry(options)
16081
17558
  .require(terminal.agent);
16082
17559
  const capability = adapter.probeThreadLifecycle?.(currentVersion);
17560
+ if (transition.operation === "adopt_external_thread") {
17561
+ if (currentVersion !== transition.adapter_version ||
17562
+ capability?.status !== "supported" ||
17563
+ transition.origin !== "human_observed" ||
17564
+ transition.terminal_input_sent !== false ||
17565
+ transition.command_fingerprint !== HUMAN_OBSERVED_HANDOFF_FINGERPRINT) {
17566
+ throw new Error("human-observed handoff adapter profile changed during recovery");
17567
+ }
17568
+ return;
17569
+ }
16083
17570
  const operation = transition.operation === "resume_thread"
16084
17571
  ? {
16085
17572
  kind: "resume_thread",
@@ -16104,6 +17591,49 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
16104
17591
  !terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.conversationId, terminal.terminalControl)) {
16105
17592
  throw new Error("verified after_binding no longer matches the terminal or pid");
16106
17593
  }
17594
+ if (transition.operation === "adopt_external_thread") {
17595
+ if (transition.origin !== "human_observed" ||
17596
+ transition.terminal_input_sent !== false ||
17597
+ !transition.source_session_id ||
17598
+ !transition.before_binding) {
17599
+ throw new Error("human-observed handoff recovery evidence is incomplete");
17600
+ }
17601
+ const storeDir = storeDirFromOptions(options);
17602
+ const source = loadManagedSession(storeDir, transition.source_session_id);
17603
+ const resolved = await resolveCurrentNativeAgentSessionIdentity({
17604
+ options,
17605
+ agent: terminal.agent,
17606
+ pid: terminal.pid,
17607
+ cwd: terminal.terminalControl.currentPath,
17608
+ preferredSessionId: binding.native_thread_id,
17609
+ allowedCompanionIdentity: codexIdentityFence({
17610
+ sessionId: transition.before_native_thread_id,
17611
+ processUuid: transition.before_process_uuid,
17612
+ processBirth: transition.before_process_birth,
17613
+ rollout: transition.before_process_rollout,
17614
+ evidence: transition.before_binding.native_process.evidence
17615
+ }),
17616
+ allowedAdditionalIdentities: []
17617
+ });
17618
+ const observed = await observedExternalHandoffIdentity({
17619
+ options,
17620
+ terminal,
17621
+ sourceSession: source,
17622
+ resolvedIdentity: resolved
17623
+ });
17624
+ if (!observed.identity) {
17625
+ throw new Error("human-observed handoff target identity is unavailable during recovery");
17626
+ }
17627
+ const exact = exactLifecycleProcessIdentity(terminal, observed.identity);
17628
+ if (exact.sessionId.toLowerCase() !== binding.native_thread_id.toLowerCase() ||
17629
+ exact.processUuid !== binding.native_process.process_uuid ||
17630
+ exact.processBirth !== binding.native_process.process_birth ||
17631
+ JSON.stringify(exact.rollout ?? null) !==
17632
+ JSON.stringify(binding.native_process.rollout ?? null)) {
17633
+ throw new Error("human-observed handoff target identity changed during recovery");
17634
+ }
17635
+ return;
17636
+ }
16107
17637
  const bridge = createTerminalAgentBridge(options);
16108
17638
  const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
16109
17639
  if (!status.reachable ||
@@ -16178,6 +17708,9 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
16178
17708
  }
16179
17709
  }
16180
17710
  async function reconcileUncertainLifecycleTransition({ options, terminal, transition, storeDir, now }) {
17711
+ if (transition.operation === "adopt_external_thread") {
17712
+ throw new Error("uncertain human-observed handoff remains quarantined because recovery cannot send terminal input");
17713
+ }
16181
17714
  const live = terminal.agent === "claude"
16182
17715
  ? await probeManualClaudeLifecycleRecoveryIdentity({
16183
17716
  options,
@@ -16220,15 +17753,18 @@ async function reconcileUncertainLifecycleTransition({ options, terminal, transi
16220
17753
  }, { expectedTransitionId: transition.transition_id });
16221
17754
  return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
16222
17755
  }
16223
- if (transition.operation !== "resume_thread" ||
17756
+ if (!["resume_thread", "adopt_external_thread"].includes(transition.operation) ||
16224
17757
  live.sessionId !== transition.target_native_thread_id) {
16225
17758
  throw new Error("fresh native thread identity matches neither the recorded before identity " +
16226
17759
  "nor a durably known exact after identity");
16227
17760
  }
16228
- if (terminal.agent === "codex" && !live.rollout) {
17761
+ if (transition.operation === "resume_thread" &&
17762
+ terminal.agent === "codex" &&
17763
+ !live.rollout) {
16229
17764
  throw new Error("Codex resume recovery requires an exact live rollout incarnation");
16230
17765
  }
16231
- if (terminal.agent === "codex") {
17766
+ if (transition.operation === "resume_thread" &&
17767
+ terminal.agent === "codex") {
16232
17768
  assertResumedCodexRolloutMatchesCandidate(live, transition.target_candidate_file_identity);
16233
17769
  }
16234
17770
  const targetAtPrepare = tryLoadManagedSession(storeDir, transition.target_session_id);
@@ -16516,7 +18052,9 @@ async function probeManualCodexLifecycleRecoveryIdentity({ options, terminal, tr
16516
18052
  ? "no_rollout"
16517
18053
  : "invalid"
16518
18054
  : classifyCodexLifecyclePostcondition({
16519
- operation: transition.operation,
18055
+ operation: transition.operation === "resume_thread"
18056
+ ? "resume_thread"
18057
+ : "new_thread",
16520
18058
  parsedNativeThreadId: observed.nativeThreadId,
16521
18059
  observationSucceeded: true,
16522
18060
  observedIdentity: resolved,
@@ -16815,7 +18353,9 @@ function commitVerifiedLifecycleTransition(storeDir, transition, now) {
16815
18353
  status: "bound",
16816
18354
  binding: afterBinding,
16817
18355
  lineage: targetExisting?.lineage ?? {
16818
- created_by: transition.operation,
18356
+ created_by: transition.operation === "adopt_external_thread"
18357
+ ? "human_observed"
18358
+ : transition.operation,
16819
18359
  previous_session_id: transition.source_session_id,
16820
18360
  resumed_from_native_thread_id: transition.operation === "resume_thread"
16821
18361
  ? transition.target_native_thread_id
@@ -20065,7 +21605,7 @@ function usage() {
20065
21605
  agent-knock-knock delegate --request <text> [--agent ${agentList}] [--workspace <path>] [--store-dir <dir>]
20066
21606
  agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all] [--reconcile] [--no-approval-scan] [--terminal-debug]
20067
21607
  agent-knock-knock status [--turn <turn-id|selector>] [--conversation <selector>] [--store-dir <dir>] [--reconcile] [--trace]
20068
- agent-knock-knock send [--session <session-id|selector>] [--conversation <selector>] --message <text> [--type task] [--agent-timeout-minutes <minutes>] [--agent-hard-timeout-minutes <minutes>]
21608
+ agent-knock-knock send [--session <session-id|selector>] [--conversation <selector>] --message <text> [--expected-terminal-token <token>] [--type task] [--agent-timeout-minutes <minutes>] [--agent-hard-timeout-minutes <minutes>]
20069
21609
  agent-knock-knock new-thread --terminal <exact-terminal-id> --expected-binding-token <token>
20070
21610
  agent-knock-knock clear-thread --terminal <exact-terminal-id> --expected-binding-token <token>
20071
21611
  agent-knock-knock list-resumable-threads --terminal <exact-terminal-id> [--selection-scope <opaque-scope>]
@@ -20078,7 +21618,7 @@ function usage() {
20078
21618
  agent-knock-knock cancel [--turn <turn-id|selector>] [--conversation <selector>]
20079
21619
  agent-knock-knock renew [--turn <turn-id|selector>] [--conversation <selector>]
20080
21620
  agent-knock-knock retry-callback [--turn <turn-id|selector>] [--conversation <selector>]
20081
- agent-knock-knock close [--turn <turn-id|selector>] [--conversation <selector>] [--reason <text>] [--expected-message-id <message-id> | --expected-transition-id <transition-id>]
21621
+ agent-knock-knock close [--turn <turn-id|selector>] [--conversation <selector>] [--reason <text>] [--expected-message-id <message-id> | --expected-transition-id <transition-id> | --expected-handoff-token <token>]
20082
21622
  agent-knock-knock install-openclaw [--verify] [--openclaw-bin <path>] [--skill-path <path>] [--skill-only] [--no-restart]
20083
21623
  agent-knock-knock doctor [--openclaw-bin <path>] [--tmux-bin <path>] [--herdr-bin <path>]
20084
21624
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]