@scotthuang/agent-knock-knock 0.12.1 → 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,11 +9247,26 @@ 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
  });
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
+ });
8553
9270
  if (!managedSession && currentNativeIdentity) {
8554
9271
  managedSession = await reattachManagedSessionForNativeIdentity({
8555
9272
  options,
@@ -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
  }
@@ -10276,6 +11015,27 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10276
11015
  try {
10277
11016
  await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
10278
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,
10279
11039
  async onTransportStage(event) {
10280
11040
  const stageAt = cliNow().toISOString();
10281
11041
  if (event.stage === "text_injected") {
@@ -10316,6 +11076,14 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10316
11076
  previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
10317
11077
  stringValue(previousDispatchLedger?.message_id)
10318
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
+ }
10319
11087
  try {
10320
11088
  appendEvent(logPath, {
10321
11089
  ts: stageAt,
@@ -13263,9 +14031,185 @@ async function runTerminalControlCancel({ options, statePath, logPath, agent, te
13263
14031
  }
13264
14032
  }
13265
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
+ }
13266
14207
  async function runClose(options) {
13267
14208
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
13268
14209
  if (terminalConversation) {
14210
+ if (stringValue(options.expectedHandoffToken)) {
14211
+ throw new Error("--expected-handoff-token cannot be used with raw terminal close");
14212
+ }
13269
14213
  await runTerminalDispatchClose({
13270
14214
  options,
13271
14215
  terminalConversation
@@ -13273,6 +14217,15 @@ async function runClose(options) {
13273
14217
  return;
13274
14218
  }
13275
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
+ }
13276
14229
  const { statePath, logPath } = loaded;
13277
14230
  const nativeTakeover = isRecord(loaded.conversation.native_session_takeover)
13278
14231
  ? loaded.conversation.native_session_takeover
@@ -13286,11 +14239,18 @@ async function runClose(options) {
13286
14239
  releaseStateLock = acquireFileLock(`${statePath}.lock`);
13287
14240
  const conversation = loadState(statePath);
13288
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;
13289
14246
  const closed = {
13290
14247
  ...conversation,
13291
14248
  status: "closed",
13292
14249
  closed_at: now,
13293
- close_reason: options.reason ?? "closed by request",
14250
+ close_reason: closeReason,
14251
+ ...(handoffDisposition
14252
+ ? { disposition: handoffDisposition }
14253
+ : {}),
13294
14254
  updated_at: now
13295
14255
  };
13296
14256
  saveState(statePath, closed);
@@ -15330,6 +16290,7 @@ function terminalDispatchLedgerLooksLifecycle(ledger) {
15330
16290
  ledger.transition_id !== undefined ||
15331
16291
  ledger.operation === "new_thread" ||
15332
16292
  ledger.operation === "resume_thread" ||
16293
+ ledger.operation === "adopt_external_thread" ||
15333
16294
  ledger.adapter_version !== undefined ||
15334
16295
  ledger.command_fingerprint !== undefined ||
15335
16296
  ledger.target_session_id !== undefined ||
@@ -15345,6 +16306,8 @@ function lifecycleLedgerFields(transition, storeDir) {
15345
16306
  generation_id: transition.transition_id,
15346
16307
  transition_id: transition.transition_id,
15347
16308
  operation: transition.operation,
16309
+ origin: transition.origin,
16310
+ terminal_input_sent: transition.terminal_input_sent,
15348
16311
  terminal_id: transition.terminal_id,
15349
16312
  agent: transition.agent,
15350
16313
  workspace: transition.workspace,
@@ -15778,6 +16741,7 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
15778
16741
  const dispatcherPid = Number(ledger.dispatcher_pid);
15779
16742
  if (Number.isSafeInteger(dispatcherPid) &&
15780
16743
  dispatcherPid > 1 &&
16744
+ dispatcherPid !== cliPid() &&
15781
16745
  isProcessAlive(dispatcherPid)) {
15782
16746
  return ledger;
15783
16747
  }
@@ -16115,9 +17079,16 @@ function terminalSubmissionProofRank(status, lastProven) {
16115
17079
  return 0;
16116
17080
  }
16117
17081
  async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
16118
- const ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
17082
+ let ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
16119
17083
  if (!ledger || ledger.status === "resolved") {
16120
- return;
17084
+ ledger = rebuildObservedHandoffLedgerFromTransition({
17085
+ options,
17086
+ terminal,
17087
+ previousLedger: ledger
17088
+ });
17089
+ if (!ledger) {
17090
+ return;
17091
+ }
16121
17092
  }
16122
17093
  if (!terminalDispatchLedgerLooksLifecycle(ledger)) {
16123
17094
  return;
@@ -16136,6 +17107,175 @@ async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
16136
17107
  "expected-transition-id recovery action");
16137
17108
  }
16138
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
+ }
16139
17279
  async function reconcileLifecycleDispatchLedger(options, terminal, ledger, authority = {
16140
17280
  kind: "automatic"
16141
17281
  }) {
@@ -16179,6 +17319,34 @@ async function reconcileLifecycleDispatchLedger(options, terminal, ledger, autho
16179
17319
  transition,
16180
17320
  storeDir
16181
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
+ }
16182
17350
  if (transition.status === "prepared" && ledger.status === "prepared") {
16183
17351
  await assertNativeThreadHasExclusiveOwnership({
16184
17352
  options,
@@ -16317,9 +17485,9 @@ async function reconcileLifecycleDispatchLedger(options, terminal, ledger, autho
16317
17485
  }
16318
17486
  }
16319
17487
  function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, transition, storeDir }) {
16320
- const operationTarget = transition.operation === "resume_thread"
16321
- ? transition.target_native_thread_id
16322
- : undefined;
17488
+ const operationTarget = transition.operation === "new_thread"
17489
+ ? undefined
17490
+ : transition.target_native_thread_id;
16323
17491
  const ledgerControl = isRecord(ledger.terminal_control)
16324
17492
  ? ledger.terminal_control
16325
17493
  : undefined;
@@ -16328,8 +17496,12 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
16328
17496
  dispatching: ["prepared", "dispatching", "uncertain"],
16329
17497
  submitted: ["dispatching", "submitted", "uncertain"],
16330
17498
  uncertain: ["dispatching", "submitted", "uncertain"],
16331
- verified: ["submitted", "verified", "uncertain"],
16332
- 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"],
16333
17505
  aborted: transition.reconciled_outcome === "before"
16334
17506
  ? ["dispatching", "submitted", "uncertain"]
16335
17507
  : ["prepared", "dispatching"]
@@ -16338,6 +17510,8 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
16338
17510
  stringValue(ledger.transition_id) !== transition.transition_id ||
16339
17511
  stringValue(ledger.generation_id) !== transition.transition_id ||
16340
17512
  stringValue(ledger.operation) !== transition.operation ||
17513
+ stringValue(ledger.origin) !== transition.origin ||
17514
+ ledger.terminal_input_sent !== transition.terminal_input_sent ||
16341
17515
  stringValue(ledger.terminal_id) !== transition.terminal_id ||
16342
17516
  stringValue(ledger.agent) !== transition.agent ||
16343
17517
  stringValue(ledger.workspace) !== transition.workspace ||
@@ -16383,6 +17557,16 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
16383
17557
  const adapter = createRuntimeTerminalAgentRegistry(options)
16384
17558
  .require(terminal.agent);
16385
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
+ }
16386
17570
  const operation = transition.operation === "resume_thread"
16387
17571
  ? {
16388
17572
  kind: "resume_thread",
@@ -16407,6 +17591,49 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
16407
17591
  !terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.conversationId, terminal.terminalControl)) {
16408
17592
  throw new Error("verified after_binding no longer matches the terminal or pid");
16409
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
+ }
16410
17637
  const bridge = createTerminalAgentBridge(options);
16411
17638
  const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
16412
17639
  if (!status.reachable ||
@@ -16481,6 +17708,9 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
16481
17708
  }
16482
17709
  }
16483
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
+ }
16484
17714
  const live = terminal.agent === "claude"
16485
17715
  ? await probeManualClaudeLifecycleRecoveryIdentity({
16486
17716
  options,
@@ -16523,15 +17753,18 @@ async function reconcileUncertainLifecycleTransition({ options, terminal, transi
16523
17753
  }, { expectedTransitionId: transition.transition_id });
16524
17754
  return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
16525
17755
  }
16526
- if (transition.operation !== "resume_thread" ||
17756
+ if (!["resume_thread", "adopt_external_thread"].includes(transition.operation) ||
16527
17757
  live.sessionId !== transition.target_native_thread_id) {
16528
17758
  throw new Error("fresh native thread identity matches neither the recorded before identity " +
16529
17759
  "nor a durably known exact after identity");
16530
17760
  }
16531
- if (terminal.agent === "codex" && !live.rollout) {
17761
+ if (transition.operation === "resume_thread" &&
17762
+ terminal.agent === "codex" &&
17763
+ !live.rollout) {
16532
17764
  throw new Error("Codex resume recovery requires an exact live rollout incarnation");
16533
17765
  }
16534
- if (terminal.agent === "codex") {
17766
+ if (transition.operation === "resume_thread" &&
17767
+ terminal.agent === "codex") {
16535
17768
  assertResumedCodexRolloutMatchesCandidate(live, transition.target_candidate_file_identity);
16536
17769
  }
16537
17770
  const targetAtPrepare = tryLoadManagedSession(storeDir, transition.target_session_id);
@@ -16819,7 +18052,9 @@ async function probeManualCodexLifecycleRecoveryIdentity({ options, terminal, tr
16819
18052
  ? "no_rollout"
16820
18053
  : "invalid"
16821
18054
  : classifyCodexLifecyclePostcondition({
16822
- operation: transition.operation,
18055
+ operation: transition.operation === "resume_thread"
18056
+ ? "resume_thread"
18057
+ : "new_thread",
16823
18058
  parsedNativeThreadId: observed.nativeThreadId,
16824
18059
  observationSucceeded: true,
16825
18060
  observedIdentity: resolved,
@@ -17118,7 +18353,9 @@ function commitVerifiedLifecycleTransition(storeDir, transition, now) {
17118
18353
  status: "bound",
17119
18354
  binding: afterBinding,
17120
18355
  lineage: targetExisting?.lineage ?? {
17121
- created_by: transition.operation,
18356
+ created_by: transition.operation === "adopt_external_thread"
18357
+ ? "human_observed"
18358
+ : transition.operation,
17122
18359
  previous_session_id: transition.source_session_id,
17123
18360
  resumed_from_native_thread_id: transition.operation === "resume_thread"
17124
18361
  ? transition.target_native_thread_id
@@ -20368,7 +21605,7 @@ function usage() {
20368
21605
  agent-knock-knock delegate --request <text> [--agent ${agentList}] [--workspace <path>] [--store-dir <dir>]
20369
21606
  agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all] [--reconcile] [--no-approval-scan] [--terminal-debug]
20370
21607
  agent-knock-knock status [--turn <turn-id|selector>] [--conversation <selector>] [--store-dir <dir>] [--reconcile] [--trace]
20371
- 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>]
20372
21609
  agent-knock-knock new-thread --terminal <exact-terminal-id> --expected-binding-token <token>
20373
21610
  agent-knock-knock clear-thread --terminal <exact-terminal-id> --expected-binding-token <token>
20374
21611
  agent-knock-knock list-resumable-threads --terminal <exact-terminal-id> [--selection-scope <opaque-scope>]
@@ -20381,7 +21618,7 @@ function usage() {
20381
21618
  agent-knock-knock cancel [--turn <turn-id|selector>] [--conversation <selector>]
20382
21619
  agent-knock-knock renew [--turn <turn-id|selector>] [--conversation <selector>]
20383
21620
  agent-knock-knock retry-callback [--turn <turn-id|selector>] [--conversation <selector>]
20384
- 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>]
20385
21622
  agent-knock-knock install-openclaw [--verify] [--openclaw-bin <path>] [--skill-path <path>] [--skill-only] [--no-restart]
20386
21623
  agent-knock-knock doctor [--openclaw-bin <path>] [--tmux-bin <path>] [--herdr-bin <path>]
20387
21624
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]