@scotthuang/agent-knock-knock 0.12.1 → 0.12.3

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,17 +16,17 @@ 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";
26
26
  import { associateTerminalEndpointEvidence, hasCanonicalTerminalEndpoint, sameTerminalControlEvidenceIncarnation, sameTerminalControlIncarnation, terminalControlEvidence, terminalControlEvidenceMatches, terminalEndpointFromControlRef, terminalEndpointIdentityFromEvidence, terminalEndpointIdentityKey, terminalLegacyRuntimeRoute, sameTerminalEndpointIdentity } from "./terminal-control-ref.js";
27
27
  import { createProductionTerminalAgentRegistry } from "./terminal-agent-registry.js";
28
28
  import { parseProcessElapsedSeconds, StaticTerminalProcessSource, SystemTerminalProcessSource } from "./terminal-process-source.js";
29
- import { isExactClaudeNativeInspectionIdleComposer, NativeInspectionSubmissionError, TerminalAgentBridge, TerminalInputNotStartedError } from "./terminal-agent-bridge.js";
29
+ import { isExactClaudeIdleComposer, isExactClaudeNativeInspectionIdleComposer, NativeInspectionSubmissionError, TerminalAgentBridge, TerminalInputNotStartedError } from "./terminal-agent-bridge.js";
30
30
  import { evaluateApprovalPolicy } from "./approval-policy.js";
31
31
  import { evaluateDoctorCapabilities, runDoctorCapabilityProbes } from "./doctor-capabilities.js";
32
32
  import { runOpenClawChainDiagnostics } from "./openclaw-doctor.js";
@@ -2916,24 +2916,24 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
2916
2916
  });
2917
2917
  }
2918
2918
  }
2919
- let nativeAgentIdentity;
2920
- try {
2921
- nativeAgentIdentity = await resolveCurrentNativeAgentSessionIdentity({
2922
- options,
2923
- agent: session.agent,
2924
- pid: session.pid,
2925
- cwd: session.cwd ?? terminalControl.currentPath,
2926
- preferredSessionId: codexIdentityContext?.preferredSessionId,
2927
- allowedCompanionIdentity: codexIdentityContext?.companions.primary,
2928
- allowedAdditionalIdentities: codexIdentityContext?.companions.additional
2929
- });
2930
- }
2931
- catch (error) {
2919
+ const nativeIdentityObservation = await observeCurrentNativeAgentSessionIdentity({
2920
+ options,
2921
+ agent: session.agent,
2922
+ pid: session.pid,
2923
+ cwd: session.cwd ?? terminalControl.currentPath,
2924
+ preferredSessionId: codexIdentityContext?.preferredSessionId,
2925
+ allowedCompanionIdentity: codexIdentityContext?.companions.primary,
2926
+ allowedAdditionalIdentities: codexIdentityContext?.companions.additional
2927
+ });
2928
+ const nativeAgentIdentity = nativeIdentityObservation.status === "resolved"
2929
+ ? nativeIdentityObservation.identity
2930
+ : undefined;
2931
+ if (nativeIdentityObservation.status === "unavailable") {
2932
2932
  runtimeLog("warn", "terminal_native_session_identity_unavailable", {
2933
2933
  agent: session.agent,
2934
2934
  terminal_target: terminalControl.target,
2935
2935
  pid: session.pid,
2936
- error: error instanceof Error ? error.message : String(error)
2936
+ error: nativeIdentityObservation.reason
2937
2937
  });
2938
2938
  }
2939
2939
  let nativeProcessUuid = nativeAgentIdentity?.processUuid;
@@ -3002,24 +3002,24 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
3002
3002
  const nativeInspectionHasBlockingTurn = listConversations(storeDirFromOptions(options)).some((turn) => isDiscoverableTmuxConversation(turn) &&
3003
3003
  terminalControlsShareIncarnation(terminalControlForManagedConversation(turn), terminalControl) &&
3004
3004
  SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
3005
- let nativeInspectionComposerReady = nativeInspectionComposerEmpty(session.agent, terminalState.screen_excerpt);
3005
+ let automatedInputComposerReady = nativeInspectionComposerEmpty(session.agent, terminalState.screen_excerpt);
3006
3006
  if (session.agent === "codex" &&
3007
- terminalState.activity_state === "idle" &&
3007
+ (terminalState.activity_state === "idle" ||
3008
+ (terminalState.activity_state === "unknown" &&
3009
+ nativeIdentityObservation.status === "verified_absent")) &&
3008
3010
  terminalState.approval_state.blocked !== true &&
3009
- nativeInspectionCapability.status === "supported" &&
3010
- nativeInspectionCapability.statusInspection === true &&
3011
3011
  terminalControl.capabilities.includes("send_keys") &&
3012
3012
  terminalControl.capabilities.includes("screen_status")) {
3013
3013
  try {
3014
3014
  const provider = createTerminalControlProvider(options);
3015
3015
  const resolvedTerminal = await provider.resolve(provider.endpoint(terminalControl));
3016
3016
  const styledScreen = await provider.capture(resolvedTerminal, { scrollbackLines: 40, preserveEscapes: true });
3017
- nativeInspectionComposerReady = codexStyledComposerEmpty(styledScreen);
3017
+ automatedInputComposerReady = codexStyledComposerEmpty(styledScreen);
3018
3018
  }
3019
3019
  catch {
3020
3020
  // Advertising an input action is optional. The action itself repeats the
3021
3021
  // same styled composer proof under the terminal lock before any input.
3022
- nativeInspectionComposerReady = false;
3022
+ automatedInputComposerReady = false;
3023
3023
  }
3024
3024
  }
3025
3025
  const entry = {
@@ -3040,6 +3040,14 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
3040
3040
  native_agent_process_birth: nativeProcessBirth,
3041
3041
  native_agent_rollout: nativeAgentIdentity?.rollout,
3042
3042
  native_agent_identity_evidence: nativeProcessEvidence,
3043
+ native_agent_identity_observation: {
3044
+ status: nativeIdentityObservation.status,
3045
+ ...(nativeIdentityObservation.status === "unavailable"
3046
+ ? { reason: nativeIdentityObservation.reason }
3047
+ : nativeIdentityObservation.status === "verified_absent"
3048
+ ? { evidence: nativeIdentityObservation.evidence }
3049
+ : {})
3050
+ },
3043
3051
  agent_version: agentVersion,
3044
3052
  native_thread_lifecycle: lifecycleCapability,
3045
3053
  native_inspection: nativeInspectionCapability,
@@ -3050,6 +3058,10 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
3050
3058
  approval_state: terminalState.approval_state,
3051
3059
  activity_state: terminalState.activity_state,
3052
3060
  activity_reason: terminalState.activity_reason,
3061
+ // Internal action-projection evidence. terminalFirstListProjection strips
3062
+ // this field after gating every automated-input action that can follow a
3063
+ // human native-thread switch.
3064
+ _automated_input_composer_ready: automatedInputComposerReady,
3053
3065
  ...(orphanedDispatch
3054
3066
  ? {
3055
3067
  orphaned_terminal_dispatch: {
@@ -3080,7 +3092,8 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
3080
3092
  codexLifecycleIncarnationAvailable,
3081
3093
  native_inspect: nativeInspectionCapability.status === "supported" &&
3082
3094
  nativeInspectionCapability.statusInspection === true &&
3083
- nativeInspectionComposerReady &&
3095
+ terminalState.activity_state === "idle" &&
3096
+ automatedInputComposerReady &&
3084
3097
  terminalControl.capabilities.includes("send_keys") &&
3085
3098
  terminalControl.capabilities.includes("screen_status") &&
3086
3099
  (session.agent === "codex"
@@ -3106,6 +3119,7 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3106
3119
  return control ? [control] : [];
3107
3120
  });
3108
3121
  const projectedTerminals = terminals.map((terminal) => {
3122
+ const { _automated_input_composer_ready: automatedInputComposerReady, ...publicTerminal } = terminal;
3109
3123
  const terminalControl = isRecord(terminal.terminal_control)
3110
3124
  ? terminal.terminal_control
3111
3125
  : undefined;
@@ -3220,11 +3234,120 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3220
3234
  const soleBindingConflict = conflictingBoundSessionClaims.length === 1
3221
3235
  ? conflictingBoundSessionClaims[0]
3222
3236
  : undefined;
3237
+ const externalHandoffDetected = conflictingBoundSessionClaims.some(({ kind }) => kind === "live_external_thread_change");
3223
3238
  const conflictingSessionRevision = Number(soleBindingConflict?.session.revision);
3224
3239
  const conflictingSessionTurns = soleBindingConflict
3225
3240
  ? managedTurnsForSession(storeDir, soleBindingConflict.session.session_id)
3226
3241
  : [];
3227
3242
  const expectedTerminalToken = stringValue(terminal.lifecycle_binding_token);
3243
+ const externalHandoffNativeThreadId = stringValue(terminal.native_agent_status_card_session_id) ?? stringValue(terminal.native_agent_session_id);
3244
+ const resolvedNativeThreadId = stringValue(terminal.native_agent_session_id);
3245
+ const externalHandoffTerminalToken = terminalControl &&
3246
+ externalHandoffNativeThreadId &&
3247
+ isExactNativeThreadId(externalHandoffNativeThreadId)
3248
+ ? unmanagedTerminalBindingToken({
3249
+ terminalId: stringValue(terminal.id),
3250
+ terminalControl,
3251
+ agent: terminal.agent,
3252
+ pid: Number(terminal.pid),
3253
+ workspace: terminal.workspace ?? terminal.cwd ?? cliCwd(),
3254
+ nativeThreadId: externalHandoffNativeThreadId,
3255
+ processUuid: stringValue(terminal.native_agent_process_uuid),
3256
+ processBirth: stringValue(terminal.native_agent_process_birth),
3257
+ rollout: resolvedNativeThreadId === externalHandoffNativeThreadId &&
3258
+ isRecord(terminal.native_agent_rollout)
3259
+ ? terminal.native_agent_rollout
3260
+ : undefined
3261
+ })
3262
+ : undefined;
3263
+ const externalHandoffTarget = soleBindingConflict?.kind === "live_external_thread_change" &&
3264
+ externalHandoffNativeThreadId &&
3265
+ isExactNativeThreadId(externalHandoffNativeThreadId)
3266
+ ? observedHandoffTargetResolution({
3267
+ storeDir,
3268
+ agent: terminal.agent,
3269
+ workspace: terminal.workspace ?? terminal.cwd ?? cliCwd(),
3270
+ nativeThreadId: externalHandoffNativeThreadId.toLowerCase(),
3271
+ sourceSessionId: soleBindingConflict.session.session_id
3272
+ })
3273
+ : undefined;
3274
+ const externalHandoffSnapshotToken = externalHandoffTerminalToken &&
3275
+ soleBindingConflict?.kind === "live_external_thread_change" &&
3276
+ externalHandoffTarget?.status === "eligible"
3277
+ ? humanObservedHandoffBindingToken({
3278
+ terminal_token: externalHandoffTerminalToken,
3279
+ source_session_id: soleBindingConflict.session.session_id,
3280
+ source_revision: managedSessionRevision(soleBindingConflict.session),
3281
+ source_binding_token: managedSessionBindingToken(soleBindingConflict.session),
3282
+ target: externalHandoffTarget.snapshot
3283
+ })
3284
+ : undefined;
3285
+ const blockingHandoffTurns = conflictingSessionTurns.filter((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
3286
+ const nativeIdentityObservation = isRecord(terminal.native_agent_identity_observation)
3287
+ ? terminal.native_agent_identity_observation
3288
+ : undefined;
3289
+ const verifiedEmptyProcessUuid = stringValue(terminal.native_agent_process_uuid);
3290
+ const verifiedEmptyProcessBirth = stringValue(terminal.native_agent_process_birth);
3291
+ const verifiedEmptyWorkspace = stringValue(terminal.workspace ?? terminal.cwd);
3292
+ const verifiedEmptySourceNativeThreadId = stringValue(soleBindingConflict?.session.binding?.native_thread_id)?.toLowerCase();
3293
+ const verifiedEmptySourceActiveElsewhere = Boolean(verifiedEmptySourceNativeThreadId &&
3294
+ terminals.some((candidate) => candidate !== terminal &&
3295
+ candidate.agent === "codex" &&
3296
+ stringValue(candidate.native_agent_session_id)?.toLowerCase() ===
3297
+ verifiedEmptySourceNativeThreadId));
3298
+ const verifiedEmptyRawSendAction = isRecord(rawActions.send)
3299
+ ? rawActions.send
3300
+ : {
3301
+ tool: "agent_knock_knock_send",
3302
+ arguments: { selector: stringValue(terminal.id) },
3303
+ missing_required: ["request"]
3304
+ };
3305
+ const verifiedEmptyCodexHandoffEligible = Boolean(mutationsAllowed &&
3306
+ terminal.agent === "codex" &&
3307
+ terminalControl &&
3308
+ discoveredOwnership.state === "none" &&
3309
+ unresolvedSessionClaims.length === 0 &&
3310
+ matchingSessions.length === 0 &&
3311
+ soleBindingConflict?.kind === "unverifiable" &&
3312
+ nativeIdentityObservation?.status === "verified_absent" &&
3313
+ verifiedEmptyProcessUuid &&
3314
+ verifiedEmptyProcessBirth &&
3315
+ verifiedEmptyWorkspace &&
3316
+ exactBoundCodexSourceForVerifiedEmptyHandoff({
3317
+ session: soleBindingConflict.session,
3318
+ terminalId: String(terminal.id),
3319
+ terminalControl,
3320
+ pid: Number(terminal.pid),
3321
+ workspace: verifiedEmptyWorkspace,
3322
+ processUuid: verifiedEmptyProcessUuid,
3323
+ processBirth: verifiedEmptyProcessBirth
3324
+ }) &&
3325
+ ["idle", "unknown"].includes(String(terminal.activity_state)) &&
3326
+ automatedInputComposerReady === true &&
3327
+ !(isRecord(terminal.approval_state) &&
3328
+ terminal.approval_state.blocked === true) &&
3329
+ blockingHandoffTurns.length === 0 &&
3330
+ !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
3331
+ !verifiedEmptySourceActiveElsewhere &&
3332
+ terminal.orphaned_terminal_dispatch === undefined &&
3333
+ terminalControl?.capabilities.includes("send_keys") &&
3334
+ terminalControl.capabilities.includes("screen_status"));
3335
+ const verifiedEmptyCodexSnapshotToken = verifiedEmptyCodexHandoffEligible &&
3336
+ terminalControl &&
3337
+ soleBindingConflict &&
3338
+ verifiedEmptyProcessUuid &&
3339
+ verifiedEmptyProcessBirth &&
3340
+ verifiedEmptyWorkspace
3341
+ ? verifiedEmptyCodexHandoffToken({
3342
+ terminalId: String(terminal.id),
3343
+ terminalControl,
3344
+ pid: Number(terminal.pid),
3345
+ workspace: verifiedEmptyWorkspace,
3346
+ processUuid: verifiedEmptyProcessUuid,
3347
+ processBirth: verifiedEmptyProcessBirth,
3348
+ sourceSession: soleBindingConflict.session
3349
+ })
3350
+ : undefined;
3228
3351
  const reconcileBindingAction = mutationsAllowed &&
3229
3352
  discoveredOwnership.state === "none" &&
3230
3353
  unresolvedSessionClaims.length === 0 &&
@@ -3251,6 +3374,66 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3251
3374
  requires_user_intent: true
3252
3375
  }
3253
3376
  : undefined;
3377
+ const externalHandoffAdoptable = Boolean(mutationsAllowed &&
3378
+ discoveredOwnership.state === "none" &&
3379
+ unresolvedSessionClaims.length === 0 &&
3380
+ soleBindingConflict?.kind === "live_external_thread_change" &&
3381
+ terminal.activity_state === "idle" &&
3382
+ automatedInputComposerReady === true &&
3383
+ !(isRecord(terminal.approval_state) &&
3384
+ terminal.approval_state.blocked === true) &&
3385
+ !conflictingSessionTurns.some((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status)) &&
3386
+ !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
3387
+ externalHandoffTarget?.status === "eligible" &&
3388
+ isRecord(rawActions.send) &&
3389
+ Boolean(externalHandoffSnapshotToken));
3390
+ const handoffDecisionTurn = mutationsAllowed &&
3391
+ soleBindingConflict?.kind === "live_external_thread_change" &&
3392
+ externalHandoffTarget?.status === "eligible" &&
3393
+ terminal.activity_state === "idle" &&
3394
+ !(isRecord(terminal.approval_state) &&
3395
+ terminal.approval_state.blocked === true) &&
3396
+ !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
3397
+ blockingHandoffTurns.length === 1
3398
+ ? blockingHandoffTurns[0]
3399
+ : undefined;
3400
+ const handoffDecisionToken = handoffDecisionTurn &&
3401
+ externalHandoffSnapshotToken &&
3402
+ terminalControl
3403
+ ? activeTurnHandoffDecisionToken({
3404
+ handoffToken: externalHandoffSnapshotToken,
3405
+ turn: handoffDecisionTurn,
3406
+ ledger: loadTerminalBridgeDispatchLedger(terminalControl)
3407
+ })
3408
+ : undefined;
3409
+ const handoffDecision = handoffDecisionTurn &&
3410
+ handoffDecisionToken &&
3411
+ externalHandoffNativeThreadId
3412
+ ? {
3413
+ kind: "active_turn_requires_decision",
3414
+ source_session_id: soleBindingConflict?.session.session_id,
3415
+ source_turn_id: turnIdForConversation(handoffDecisionTurn),
3416
+ live_native_thread_id: externalHandoffNativeThreadId,
3417
+ choices: {
3418
+ take_over_current: {
3419
+ action: {
3420
+ tool: "agent_knock_knock_close",
3421
+ arguments: {
3422
+ turn_id: turnIdForConversation(handoffDecisionTurn),
3423
+ reason: "superseded_by_human_context_switch",
3424
+ expected_handoff_token: handoffDecisionToken
3425
+ },
3426
+ requires_explicit_user_confirmation: true
3427
+ },
3428
+ after: "refresh list and use its follow-current send"
3429
+ },
3430
+ keep_source: {
3431
+ effect: "no Store or terminal mutation",
3432
+ after: "restore source native thread in the Codex/Claude TUI, then refresh list"
3433
+ }
3434
+ }
3435
+ }
3436
+ : undefined;
3254
3437
  const terminalCanAcceptSend = ownership.state === "none" && isRecord(sessionAwareRawActions.send);
3255
3438
  if (ownership.state === "current" &&
3256
3439
  !allRelated.some((conversation) => conversation.conversation_id === ownership.conversation.conversation_id)) {
@@ -3339,6 +3522,32 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3339
3522
  : ownership.state === "conflict"
3340
3523
  ? {
3341
3524
  ...safeTerminalActionsDuringConflict(sessionAwareRawActions),
3525
+ ...(externalHandoffAdoptable
3526
+ ? {
3527
+ send: {
3528
+ ...rawActions.send,
3529
+ arguments: {
3530
+ ...(isRecord(rawActions.send.arguments)
3531
+ ? rawActions.send.arguments
3532
+ : {}),
3533
+ expected_terminal_token: externalHandoffSnapshotToken
3534
+ }
3535
+ }
3536
+ }
3537
+ : {}),
3538
+ ...(verifiedEmptyCodexSnapshotToken
3539
+ ? {
3540
+ send: {
3541
+ ...verifiedEmptyRawSendAction,
3542
+ arguments: {
3543
+ ...(isRecord(verifiedEmptyRawSendAction.arguments)
3544
+ ? verifiedEmptyRawSendAction.arguments
3545
+ : {}),
3546
+ expected_terminal_token: verifiedEmptyCodexSnapshotToken
3547
+ }
3548
+ }
3549
+ }
3550
+ : {}),
3342
3551
  ...(reconcileBindingAction
3343
3552
  ? { reconcile_binding: reconcileBindingAction }
3344
3553
  : {})
@@ -3366,8 +3575,19 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3366
3575
  lifecycle_binding_token: managedSessionBindingToken(authoritativeSession)
3367
3576
  }
3368
3577
  : {};
3578
+ const publicManagementConflict = ownership.state === "conflict" && verifiedEmptyCodexSnapshotToken
3579
+ ? {
3580
+ ...ownership.conflict,
3581
+ kind: "verified_empty_native_session",
3582
+ reason: "the previously bound Codex rollout is conclusively closed " +
3583
+ "while the same terminal process is at an exact empty prompt",
3584
+ recovery: "use only the exact snapshot-bound send action listed for this terminal"
3585
+ }
3586
+ : ownership.state === "conflict"
3587
+ ? ownership.conflict
3588
+ : undefined;
3369
3589
  return {
3370
- ...terminal,
3590
+ ...publicTerminal,
3371
3591
  ...authoritativeIdentity,
3372
3592
  management_state: ownership.state === "conflict"
3373
3593
  ? "conflict"
@@ -3375,8 +3595,24 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3375
3595
  ? "managed"
3376
3596
  : "unmanaged",
3377
3597
  ...(ownership.state === "conflict"
3378
- ? { management_conflict: ownership.conflict }
3598
+ ? { management_conflict: publicManagementConflict }
3599
+ : {}),
3600
+ ...(externalHandoffDetected
3601
+ ? {
3602
+ handoff_state: externalHandoffAdoptable
3603
+ ? "external_handoff_adoptable"
3604
+ : "external_handoff_blocked",
3605
+ ...(automatedInputComposerReady !== true
3606
+ ? {
3607
+ handoff_blocked_reason: "the current terminal composer is not an exact empty idle frame"
3608
+ }
3609
+ : {})
3610
+ }
3611
+ : {}),
3612
+ ...(verifiedEmptyCodexSnapshotToken
3613
+ ? { handoff_state: "verified_empty_native_session_adoptable" }
3379
3614
  : {}),
3615
+ ...(handoffDecision ? { handoff_decision: handoffDecision } : {}),
3380
3616
  managed: management,
3381
3617
  available_actions: availableActions
3382
3618
  };
@@ -3558,8 +3794,10 @@ function managedBindingConflictKindForLiveTerminalEntry({ storeDir, session, ter
3558
3794
  statusCardThreadId.toLowerCase()) {
3559
3795
  return relationship === "same" &&
3560
3796
  isExactNativeThreadId(liveNativeThreadId) &&
3561
- liveNativeThreadId.toLowerCase() ===
3562
- statusCardThreadId.toLowerCase()
3797
+ (liveNativeThreadId.toLowerCase() ===
3798
+ statusCardThreadId.toLowerCase() ||
3799
+ liveNativeThreadId.toLowerCase() ===
3800
+ binding.native_thread_id.toLowerCase())
3563
3801
  ? "live_external_thread_change"
3564
3802
  : "unverifiable";
3565
3803
  }
@@ -4073,15 +4311,15 @@ function managedListApprovalState(conversation) {
4073
4311
  }
4074
4312
  function listActionContracts() {
4075
4313
  return {
4076
- version: 7,
4314
+ version: 9,
4077
4315
  instructions: [
4078
- "Treat terminals[] as the primary resource and use only actions present in available_actions.",
4316
+ "Treat terminals[] as the primary resource and use only actions present in available_actions, except the snapshot-bound terminals[].handoff_decision.choices.take_over_current.action. That nested action requires explicit user confirmation; after it succeeds, refresh list before any follow-current send.",
4079
4317
  "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
4318
  "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
4319
  "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.",
4320
+ "A verified, idle human native-thread switch may expose a terminal-scoped send with expected_terminal_token; that action atomically adopts the live context before creating its Turn. A conclusively ended Codex rollout may expose the same snapshot-bound send only after AKK proves zero current rollout and an exact empty composer; it detaches the ended Session and creates an isolated virgin Session. Other binding conflicts remain fail-closed and may expose only exact low-level reconcile_binding recovery.",
4083
4321
  "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.",
4322
+ "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
4323
  "Use respond only for an in-flight turn that is explicitly waiting for OpenClaw.",
4086
4324
  "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
4325
  "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 +4350,23 @@ function listActionContracts() {
4112
4350
  available_actions: {
4113
4351
  meaning: "currently_safe_actions",
4114
4352
  authoritative_for_tool_calls: true
4353
+ },
4354
+ native_agent_identity_observation: {
4355
+ meaning: "the latest bounded native identity probe; verified_absent is distinct from an unavailable resolver",
4356
+ authoritative_for_tool_calls: false
4357
+ },
4358
+ management_conflict: {
4359
+ verified_empty_native_session: "a previously bound Codex rollout is conclusively closed; only the exact snapshot-bound send in available_actions may detach it and create an isolated virgin Session"
4360
+ },
4361
+ handoff_state: {
4362
+ meaning: "why a conflict-scoped follow-current send is available or blocked",
4363
+ verified_empty_native_session_adoptable: "the exact current terminal snapshot permits the listed conflict send; availability is revalidated before terminal input"
4364
+ },
4365
+ handoff_decision: {
4366
+ meaning: "an explicit human choice required before superseding an active source Turn",
4367
+ authoritative_action_path: "terminals[].handoff_decision.choices.take_over_current.action",
4368
+ requires_explicit_user_confirmation: true,
4369
+ after_success: "refresh list before using a follow-current send action"
4115
4370
  }
4116
4371
  },
4117
4372
  actions: {
@@ -4123,13 +4378,14 @@ function listActionContracts() {
4123
4378
  required: ["request"],
4124
4379
  optional: [
4125
4380
  "selector",
4381
+ "expected_terminal_token",
4126
4382
  "type",
4127
4383
  "idleTimeoutMinutes",
4128
4384
  "agentTimeoutMinutes",
4129
4385
  "agentHardTimeoutMinutes"
4130
4386
  ],
4131
4387
  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."
4388
+ ordinary_use: "Create a new managed turn in the exact Session. A live terminal selector can attach an unmanaged pane, adopt one verified human-selected native context, or use the exact listed conflict send to detach a verified-empty Codex source before an isolated virgin attach; an explicit session_id never follows the pane."
4133
4389
  },
4134
4390
  new_thread: {
4135
4391
  tool: "agent_knock_knock_new_thread",
@@ -4240,9 +4496,11 @@ function listActionContracts() {
4240
4496
  optional: [
4241
4497
  "reason",
4242
4498
  "expected_message_id",
4243
- "expected_transition_id"
4499
+ "expected_transition_id",
4500
+ "expected_handoff_token"
4244
4501
  ],
4245
- requires_explicit_user_confirmation: true
4502
+ requires_explicit_user_confirmation: true,
4503
+ 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
4504
  }
4247
4505
  }
4248
4506
  };
@@ -4413,11 +4671,18 @@ function availableListActions(entry, { conversation } = {}) {
4413
4671
  return actions;
4414
4672
  }
4415
4673
  async function resolveConversationSelectorOption(commandName, options) {
4674
+ const sendOperation = commandName === "send";
4675
+ if (sendOperation && stringValue(options.expectedTerminalToken)) {
4676
+ // Selector resolution may replace an alias (or an omitted selector) with a
4677
+ // discovered full terminal id. Preserve the caller's actual authority so
4678
+ // the handoff token fence cannot mistake that convenience resolution for
4679
+ // an exact selector supplied by the caller.
4680
+ originalExpectedTerminalSelector.set(options, stringValue(options.session ?? options.conversation ?? options.conversationId)?.trim());
4681
+ }
4416
4682
  if (!SESSION_SELECTOR_COMMANDS.has(String(commandName ?? "")) ||
4417
4683
  options.state) {
4418
4684
  return;
4419
4685
  }
4420
- const sendOperation = commandName === "send";
4421
4686
  const supplied = stringValue(sendOperation
4422
4687
  ? options.session ?? options.conversation ?? options.conversationId
4423
4688
  : options.turn ?? options.conversation ?? options.conversationId)?.trim();
@@ -4859,7 +5124,10 @@ function codexKnownBeforeIdentityForManagedSession({ storeDir, session, requireN
4859
5124
  }
4860
5125
  const after = transition.after_binding;
4861
5126
  if (transition.status !== "committed" ||
4862
- (requireNewThread && transition.operation !== "new_thread") ||
5127
+ (requireNewThread && ![
5128
+ "new_thread",
5129
+ "adopt_external_thread"
5130
+ ].includes(transition.operation)) ||
4863
5131
  transition.target_session_id !== session.session_id ||
4864
5132
  !after ||
4865
5133
  after.binding_id !== binding.binding_id ||
@@ -4911,7 +5179,7 @@ function codexLingeringBeforeIdentityMatchesSession({ storeDir, session, identit
4911
5179
  }
4912
5180
  const after = transition.after_binding;
4913
5181
  if (transition.status !== "committed" ||
4914
- transition.operation !== "new_thread" ||
5182
+ !["new_thread", "adopt_external_thread"].includes(transition.operation) ||
4915
5183
  transition.target_session_id !== session.session_id ||
4916
5184
  !after ||
4917
5185
  after.binding_id !== binding.binding_id ||
@@ -5506,6 +5774,764 @@ async function reattachManagedSessionForNativeIdentity({ options, terminal, iden
5506
5774
  updated_at: now.toISOString()
5507
5775
  }, { expectedRevision: managedSessionRevision(existing) });
5508
5776
  }
5777
+ const HUMAN_OBSERVED_HANDOFF_FINGERPRINT = nativeThreadCommandFingerprint("adopt_external_thread:human_observed:no_terminal_input:v1");
5778
+ function exactBoundCodexSourceForVerifiedEmptyHandoff({ session, terminalId, terminalControl, pid, workspace, processUuid, processBirth }) {
5779
+ const binding = session.binding;
5780
+ return Boolean(session.agent === "codex" &&
5781
+ session.status === "bound" &&
5782
+ binding &&
5783
+ isExactNativeThreadId(binding.native_thread_id) &&
5784
+ isCompleteNativeRollout(binding.native_process.rollout) &&
5785
+ binding.native_process.process_uuid &&
5786
+ binding.native_process.process_birth &&
5787
+ binding.native_process.pid === pid &&
5788
+ terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminalId, terminalControl) &&
5789
+ workspace &&
5790
+ path.resolve(session.workspace) === path.resolve(workspace) &&
5791
+ processIncarnationRelationship({
5792
+ binding,
5793
+ livePid: pid,
5794
+ liveProcessUuid: processUuid,
5795
+ liveProcessBirth: processBirth
5796
+ }) === "same");
5797
+ }
5798
+ function verifiedEmptyCodexHandoffToken({ terminalId, terminalControl, pid, workspace, processUuid, processBirth, sourceSession }) {
5799
+ const terminalToken = unmanagedTerminalBindingToken({
5800
+ terminalId,
5801
+ terminalControl,
5802
+ agent: "codex",
5803
+ pid,
5804
+ workspace,
5805
+ processUuid,
5806
+ processBirth
5807
+ });
5808
+ return createHash("sha256")
5809
+ .update(JSON.stringify({
5810
+ version: 1,
5811
+ kind: "verified_empty_codex_handoff",
5812
+ terminal_token: terminalToken,
5813
+ source_session_id: sourceSession.session_id,
5814
+ source_revision: managedSessionRevision(sourceSession),
5815
+ source_binding_token: managedSessionBindingToken(sourceSession),
5816
+ observation: "verified_absent"
5817
+ }))
5818
+ .digest("hex");
5819
+ }
5820
+ async function assertVerifiedEmptyCodexHandoffBoundary({ options, terminal, sourceSession, expectedSourceStatus, requireNoDispatch, requireEmptyComposer = true }) {
5821
+ const currentSource = loadManagedSession(storeDirFromOptions(options), sourceSession.session_id);
5822
+ if (currentSource.status !== expectedSourceStatus ||
5823
+ currentSource.revision !== sourceSession.revision ||
5824
+ managedSessionBindingToken(currentSource) !==
5825
+ managedSessionBindingToken(sourceSession)) {
5826
+ throw new Error("the verified-empty source Session changed; refresh AKK list");
5827
+ }
5828
+ const liveIncarnation = codexProcessIncarnationForPid(terminal.pid);
5829
+ if (!exactBoundCodexSourceForVerifiedEmptyHandoff({
5830
+ session: {
5831
+ ...currentSource,
5832
+ // The exact source binding remains authoritative after the monotonic
5833
+ // detach; only the Session status changes.
5834
+ status: "bound"
5835
+ },
5836
+ terminalId: terminal.conversationId,
5837
+ terminalControl: terminal.terminalControl,
5838
+ pid: terminal.pid,
5839
+ workspace: terminal.terminalControl.currentPath,
5840
+ processUuid: liveIncarnation.processUuid,
5841
+ processBirth: liveIncarnation.processBirth
5842
+ })) {
5843
+ throw new Error("the verified-empty source binding no longer matches the terminal");
5844
+ }
5845
+ const observation = await observeCurrentNativeAgentSessionIdentity({
5846
+ options,
5847
+ agent: "codex",
5848
+ pid: terminal.pid,
5849
+ cwd: terminal.terminalControl.currentPath
5850
+ });
5851
+ if (observation.status === "unavailable") {
5852
+ throw new Error(`Codex native identity observation is unavailable: ${observation.reason}`);
5853
+ }
5854
+ if (observation.status !== "verified_absent") {
5855
+ throw new Error(`Codex materialized native thread ${observation.identity.sessionId}; ` +
5856
+ "refresh AKK list before sending");
5857
+ }
5858
+ const status = await createTerminalAgentBridge(options).status("codex", terminal.terminalControl, {
5859
+ runtime: terminalRuntimeForLiveIdentity({
5860
+ terminal,
5861
+ expectedEmptyNativeSession: true
5862
+ })
5863
+ });
5864
+ if (status.reachable !== true ||
5865
+ status.approval_state.blocked === true ||
5866
+ !["idle", "unknown"].includes(status.activity_state)) {
5867
+ throw new Error(`terminal ${terminal.terminalControl.target} is not at a verified ` +
5868
+ `empty Codex prompt (${status.activity_state}: ${status.activity_reason})`);
5869
+ }
5870
+ if (requireNoDispatch) {
5871
+ assertTerminalLifecycleReady({
5872
+ options,
5873
+ terminal,
5874
+ // A fully styled empty/placeholder composer below is stronger prompt
5875
+ // evidence than the plain-screen parser's conservative `unknown`.
5876
+ terminalStatus: { ...status, activity_state: "idle" }
5877
+ });
5878
+ }
5879
+ if (managedSessionHasUnresolvedNativeTransition(storeDirFromOptions(options), currentSource)) {
5880
+ throw new Error(`managed Session ${currentSource.session_id} has an unresolved native-thread transition`);
5881
+ }
5882
+ if (requireEmptyComposer) {
5883
+ await assertCodexComposerReadyForAutomatedInput({
5884
+ options,
5885
+ terminalControl: terminal.terminalControl
5886
+ });
5887
+ }
5888
+ }
5889
+ async function maybeDetachVerifiedEmptyCodexSource({ options, terminal, sourceSession, observation }) {
5890
+ if (terminal.agent !== "codex" ||
5891
+ !sourceSession?.binding ||
5892
+ observation.status !== "verified_absent") {
5893
+ return undefined;
5894
+ }
5895
+ const processUuid = sourceSession.binding.native_process.process_uuid;
5896
+ const processBirth = sourceSession.binding.native_process.process_birth;
5897
+ const workspace = terminal.terminalControl.currentPath;
5898
+ const liveIncarnation = codexProcessIncarnationForPid(terminal.pid);
5899
+ if (!processUuid ||
5900
+ !processBirth ||
5901
+ !workspace ||
5902
+ !exactBoundCodexSourceForVerifiedEmptyHandoff({
5903
+ session: sourceSession,
5904
+ terminalId: terminal.conversationId,
5905
+ terminalControl: terminal.terminalControl,
5906
+ pid: terminal.pid,
5907
+ workspace,
5908
+ processUuid: liveIncarnation.processUuid,
5909
+ processBirth: liveIncarnation.processBirth
5910
+ })) {
5911
+ return undefined;
5912
+ }
5913
+ const expectedToken = verifiedEmptyCodexHandoffToken({
5914
+ terminalId: terminal.conversationId,
5915
+ terminalControl: terminal.terminalControl,
5916
+ pid: terminal.pid,
5917
+ workspace,
5918
+ processUuid,
5919
+ processBirth,
5920
+ sourceSession
5921
+ });
5922
+ if (stringValue(options.expectedTerminalToken) !== expectedToken) {
5923
+ throw new Error("verified-empty Codex handoff requires the fresh exact terminal token " +
5924
+ "advertised by AKK list");
5925
+ }
5926
+ await assertVerifiedEmptyCodexHandoffBoundary({
5927
+ options,
5928
+ terminal,
5929
+ sourceSession,
5930
+ expectedSourceStatus: "bound",
5931
+ requireNoDispatch: true
5932
+ });
5933
+ await assertNativeThreadHasExclusiveOwnership({
5934
+ options,
5935
+ agent: "codex",
5936
+ currentPid: terminal.pid,
5937
+ nativeThreadId: sourceSession.binding.native_thread_id,
5938
+ storeDir: storeDirFromOptions(options),
5939
+ terminalControl: terminal.terminalControl,
5940
+ excludedManagedSessionId: sourceSession.session_id
5941
+ });
5942
+ const detachedAt = cliNow().toISOString();
5943
+ const detached = saveManagedSession(storeDirFromOptions(options), {
5944
+ ...sourceSession,
5945
+ status: "detached",
5946
+ detached_at: detachedAt,
5947
+ updated_at: detachedAt
5948
+ }, {
5949
+ expectedRevision: managedSessionRevision(sourceSession)
5950
+ });
5951
+ runtimeLog("info", "verified_empty_codex_source_detached", {
5952
+ terminal_id: terminal.conversationId,
5953
+ source_session_id: sourceSession.session_id,
5954
+ native_thread_id: sourceSession.binding.native_thread_id,
5955
+ process_uuid: processUuid,
5956
+ process_birth: processBirth,
5957
+ terminal_input_sent: false
5958
+ });
5959
+ return {
5960
+ detached,
5961
+ boundary: {
5962
+ terminal,
5963
+ detachedSourceSessionId: detached.session_id,
5964
+ detachedSourceRevision: managedSessionRevision(detached),
5965
+ detachedSourceBindingToken: managedSessionBindingToken(detached),
5966
+ processUuid,
5967
+ processBirth
5968
+ }
5969
+ };
5970
+ }
5971
+ async function assertVerifiedEmptyCodexTransportBoundary({ options, boundary, requireEmptyComposer }) {
5972
+ const source = loadManagedSession(storeDirFromOptions(options), boundary.detachedSourceSessionId);
5973
+ if (source.status !== "detached" ||
5974
+ managedSessionRevision(source) !== boundary.detachedSourceRevision ||
5975
+ managedSessionBindingToken(source) !==
5976
+ boundary.detachedSourceBindingToken ||
5977
+ source.binding?.native_process.process_uuid !== boundary.processUuid ||
5978
+ source.binding.native_process.process_birth !== boundary.processBirth) {
5979
+ throw new Error("verified-empty Codex source authority changed before text injection");
5980
+ }
5981
+ await assertVerifiedEmptyCodexHandoffBoundary({
5982
+ options,
5983
+ terminal: boundary.terminal,
5984
+ sourceSession: source,
5985
+ expectedSourceStatus: "detached",
5986
+ requireNoDispatch: false,
5987
+ requireEmptyComposer
5988
+ });
5989
+ }
5990
+ function observedHandoffAuthorityToken({ terminal, identity, sourceSession, target }) {
5991
+ const exact = exactLifecycleProcessIdentity(terminal, identity);
5992
+ const terminalToken = unmanagedTerminalBindingToken({
5993
+ terminalId: terminal.conversationId,
5994
+ terminalControl: terminal.terminalControl,
5995
+ agent: terminal.agent,
5996
+ pid: terminal.pid,
5997
+ workspace: terminal.terminalControl.currentPath ?? cliCwd(),
5998
+ nativeThreadId: exact.sessionId,
5999
+ processUuid: exact.processUuid,
6000
+ processBirth: exact.processBirth,
6001
+ rollout: exact.rollout
6002
+ });
6003
+ return humanObservedHandoffBindingToken({
6004
+ terminal_token: terminalToken,
6005
+ source_session_id: sourceSession.session_id,
6006
+ source_revision: managedSessionRevision(sourceSession),
6007
+ source_binding_token: managedSessionBindingToken(sourceSession),
6008
+ target
6009
+ });
6010
+ }
6011
+ function activeTurnHandoffDecisionToken({ handoffToken, turn, ledger }) {
6012
+ const takeover = isRecord(turn.native_session_takeover)
6013
+ ? turn.native_session_takeover
6014
+ : undefined;
6015
+ const submission = terminalBridgeSubmission(turn);
6016
+ return createHash("sha256")
6017
+ .update(JSON.stringify({
6018
+ version: 1,
6019
+ kind: "active_turn_human_handoff",
6020
+ handoff_token: handoffToken,
6021
+ session_id: sessionIdForConversation(turn),
6022
+ turn_id: turnIdForConversation(turn),
6023
+ turn_status: turn.status,
6024
+ turn_updated_at: turn.updated_at ?? null,
6025
+ current_message_id: stringValue(takeover?.terminal_bridge_message_id) ??
6026
+ stringValue(submission?.message_id) ??
6027
+ null,
6028
+ ledger_generation_id: stringValue(ledger?.generation_id) ?? null,
6029
+ ledger_message_id: stringValue(ledger?.message_id) ?? null,
6030
+ ledger_status: stringValue(ledger?.status) ?? null
6031
+ }))
6032
+ .digest("hex");
6033
+ }
6034
+ const originalExpectedTerminalSelector = new WeakMap();
6035
+ function assertExpectedHandoffTokenUsesExactTerminalSelector({ options, terminal }) {
6036
+ if (!stringValue(options.expectedTerminalToken)) {
6037
+ return;
6038
+ }
6039
+ const supplied = originalExpectedTerminalSelector.has(options)
6040
+ ? originalExpectedTerminalSelector.get(options)
6041
+ : stringValue(options.session ?? options.conversation ?? options.conversationId)?.trim();
6042
+ if (supplied !== terminal.conversationId) {
6043
+ throw new Error("--expected-terminal-token is valid only with the exact full terminal " +
6044
+ "conversation selector advertised by AKK list");
6045
+ }
6046
+ }
6047
+ async function observedExternalHandoffIdentity({ options, terminal, sourceSession, resolvedIdentity }) {
6048
+ const bridge = createTerminalAgentBridge(options);
6049
+ const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
6050
+ assertSafeTerminalSend(terminal.agent, status);
6051
+ if (terminal.agent !== "codex") {
6052
+ return { identity: resolvedIdentity, status };
6053
+ }
6054
+ const sourceBinding = sourceSession.binding;
6055
+ const statusCard = terminal.adapter.observeThreadLifecycle?.({
6056
+ operation: { kind: "new_thread" },
6057
+ phase: "before",
6058
+ screen: status.screen.excerpt ?? ""
6059
+ });
6060
+ const statusCardId = statusCard?.status === "observed" &&
6061
+ isExactNativeThreadId(statusCard.nativeThreadId)
6062
+ ? statusCard.nativeThreadId.toLowerCase()
6063
+ : undefined;
6064
+ const sourceId = sourceBinding?.native_thread_id?.toLowerCase();
6065
+ if (statusCardId && sourceId && statusCardId !== sourceId) {
6066
+ const processUuid = sourceBinding?.native_process.process_uuid;
6067
+ const processBirth = sourceBinding?.native_process.process_birth;
6068
+ if (!processUuid || !processBirth) {
6069
+ return { identity: undefined, status };
6070
+ }
6071
+ const resolvedMatchesStatus = resolvedIdentity?.sessionId.toLowerCase() === statusCardId;
6072
+ return {
6073
+ identity: {
6074
+ sessionId: statusCardId,
6075
+ processUuid,
6076
+ processBirth,
6077
+ rollout: resolvedMatchesStatus ? resolvedIdentity?.rollout : undefined,
6078
+ evidence: resolvedMatchesStatus
6079
+ ? `${resolvedIdentity?.evidence ?? "native_thread_boundary"}+codex_status_card`
6080
+ : statusCard?.evidence ?? "codex_status_card"
6081
+ },
6082
+ status
6083
+ };
6084
+ }
6085
+ return { identity: resolvedIdentity, status };
6086
+ }
6087
+ function observedHandoffTargetResolution({ storeDir, agent, workspace, nativeThreadId, sourceSessionId }) {
6088
+ const matches = listManagedSessions(storeDir).filter((session) => session.session_id !== sourceSessionId &&
6089
+ session.agent === agent &&
6090
+ session.binding?.native_thread_id?.toLowerCase() === nativeThreadId &&
6091
+ path.resolve(session.workspace) === path.resolve(workspace));
6092
+ if (matches.length > 1) {
6093
+ return {
6094
+ status: "blocked",
6095
+ reason: `native thread ${nativeThreadId} is claimed by multiple managed Sessions`
6096
+ };
6097
+ }
6098
+ const target = matches[0];
6099
+ if (!target) {
6100
+ return { status: "eligible", snapshot: { state: "absent" } };
6101
+ }
6102
+ if (!target.binding ||
6103
+ target.status !== "detached" ||
6104
+ managedSessionHasUnresolvedNativeTransition(storeDir, target)) {
6105
+ return {
6106
+ status: "blocked",
6107
+ reason: `managed Session ${target.session_id} cannot be adopted from ` +
6108
+ `${target.status} state or while its lifecycle is unresolved`
6109
+ };
6110
+ }
6111
+ try {
6112
+ assertManagedSessionCanStartTurn(managedTurnsForSession(storeDir, target.session_id));
6113
+ }
6114
+ catch (error) {
6115
+ return {
6116
+ status: "blocked",
6117
+ reason: `managed Session ${target.session_id} has unresolved work: ` +
6118
+ `${error instanceof Error ? error.message : String(error)}`
6119
+ };
6120
+ }
6121
+ return {
6122
+ status: "eligible",
6123
+ session: target,
6124
+ snapshot: {
6125
+ state: "detached",
6126
+ session_id: target.session_id,
6127
+ revision: managedSessionRevision(target),
6128
+ status: "detached",
6129
+ binding_token: managedSessionBindingToken(target)
6130
+ }
6131
+ };
6132
+ }
6133
+ async function maybeAdoptObservedExternalThread({ options, terminal, sourceSession, resolvedIdentity, storeDir }) {
6134
+ if (!sourceSession?.binding) {
6135
+ return { identity: resolvedIdentity, adopted: false };
6136
+ }
6137
+ const observed = await observedExternalHandoffIdentity({
6138
+ options,
6139
+ terminal,
6140
+ sourceSession,
6141
+ resolvedIdentity
6142
+ });
6143
+ const identity = observed.identity;
6144
+ const conflictKind = managedBindingConflictKindForResolvedTerminal({
6145
+ storeDir,
6146
+ session: sourceSession,
6147
+ terminal,
6148
+ identity
6149
+ });
6150
+ if (conflictKind !== "live_external_thread_change") {
6151
+ return { identity, adopted: false };
6152
+ }
6153
+ assertTerminalLifecycleReady({
6154
+ options,
6155
+ terminal,
6156
+ terminalStatus: observed.status
6157
+ });
6158
+ if (!identity || !isExactNativeThreadId(identity.sessionId)) {
6159
+ throw new Error("the externally selected native thread has no exact supported identity");
6160
+ }
6161
+ if (managedSessionHasUnresolvedNativeTransition(storeDir, sourceSession)) {
6162
+ throw new Error(`managed Session ${sourceSession.session_id} has an unresolved native-thread transition`);
6163
+ }
6164
+ assertManagedSessionCanStartTurn(managedTurnsForSession(storeDir, sourceSession.session_id));
6165
+ if (terminal.agent === "codex"
6166
+ ? !codexComposerEmpty(observed.status.screen.excerpt)
6167
+ : !claudeComposerEmpty(observed.status.screen.excerpt)) {
6168
+ throw new Error("external handoff adoption requires an exact empty idle composer");
6169
+ }
6170
+ if (terminal.agent === "codex") {
6171
+ await assertCodexComposerReadyForAutomatedInput({
6172
+ options,
6173
+ terminalControl: terminal.terminalControl
6174
+ });
6175
+ }
6176
+ const targetNativeThreadId = identity.sessionId.toLowerCase();
6177
+ const targetResolution = observedHandoffTargetResolution({
6178
+ storeDir,
6179
+ agent: terminal.agent,
6180
+ workspace: terminal.terminalControl.currentPath ?? cliCwd(),
6181
+ nativeThreadId: targetNativeThreadId,
6182
+ sourceSessionId: sourceSession.session_id
6183
+ });
6184
+ const expectedTerminalToken = stringValue(options.expectedTerminalToken);
6185
+ if (targetResolution.status === "blocked") {
6186
+ if (expectedTerminalToken) {
6187
+ throw new Error("live source or target Session snapshot changed after the handoff was " +
6188
+ "listed; refresh AKK list");
6189
+ }
6190
+ throw new Error(targetResolution.reason);
6191
+ }
6192
+ const freshHandoffToken = observedHandoffAuthorityToken({
6193
+ terminal,
6194
+ identity,
6195
+ sourceSession,
6196
+ target: targetResolution.snapshot
6197
+ });
6198
+ if (expectedTerminalToken &&
6199
+ expectedTerminalToken !== freshHandoffToken) {
6200
+ throw new Error("live source, target, or terminal identity changed after the handoff " +
6201
+ "was listed; refresh AKK list");
6202
+ }
6203
+ const targetSession = targetResolution.session;
6204
+ await assertNativeThreadHasExclusiveOwnership({
6205
+ options,
6206
+ agent: terminal.agent,
6207
+ currentPid: terminal.pid,
6208
+ nativeThreadId: targetNativeThreadId,
6209
+ storeDir,
6210
+ terminalControl: terminal.terminalControl,
6211
+ excludedManagedSessionId: targetSession?.session_id
6212
+ });
6213
+ const adapterVersion = required(stringValue(agentVersionForRunningProcess(terminal.agent, terminal.pid, options)), "external handoff adoption requires the exact running agent version");
6214
+ const capability = terminal.adapter.probeThreadLifecycle?.(adapterVersion);
6215
+ if (capability?.status !== "supported") {
6216
+ throw new Error(capability?.reason ?? "external handoff adoption is unsupported for this agent version");
6217
+ }
6218
+ const now = cliNow();
6219
+ const sourceBinding = sourceSession.binding;
6220
+ const targetSessionId = targetSession?.session_id ?? createManagedSessionId(now);
6221
+ const transitionId = createNativeThreadTransitionId();
6222
+ const exactIdentity = exactLifecycleProcessIdentity(terminal, identity);
6223
+ const nextBinding = terminalBindingFrom({
6224
+ terminalId: terminal.conversationId,
6225
+ terminalControl: terminal.terminalControl,
6226
+ pid: terminal.pid,
6227
+ nativeThreadId: targetNativeThreadId,
6228
+ processUuid: exactIdentity.processUuid,
6229
+ processBirth: exactIdentity.processBirth,
6230
+ rollout: exactIdentity.rollout,
6231
+ evidence: `${exactIdentity.evidence}+human_observed`,
6232
+ generation: (targetSession?.binding?.generation ?? 0) + 1,
6233
+ now
6234
+ });
6235
+ const previousLedger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
6236
+ let transition = {
6237
+ schema: "agent-knock-knock/native-thread-transition",
6238
+ version: 1,
6239
+ transition_id: transitionId,
6240
+ operation: "adopt_external_thread",
6241
+ origin: "human_observed",
6242
+ terminal_input_sent: false,
6243
+ status: "prepared",
6244
+ terminal_id: terminal.conversationId,
6245
+ agent: terminal.agent,
6246
+ workspace: terminal.terminalControl.currentPath ?? cliCwd(),
6247
+ source_session_id: sourceSession.session_id,
6248
+ source_expected_revision: managedSessionRevision(sourceSession),
6249
+ source_previous_last_transition_id: sourceSession.last_transition_id,
6250
+ target_session_id: targetSessionId,
6251
+ target_expected_revision: targetSession
6252
+ ? managedSessionRevision(targetSession)
6253
+ : null,
6254
+ target_native_thread_id: targetNativeThreadId,
6255
+ before_native_thread_id: sourceBinding.native_thread_id,
6256
+ before_process_uuid: sourceBinding.native_process.process_uuid,
6257
+ before_process_started_at: exactIdentity.processStartedAt,
6258
+ before_process_birth: sourceBinding.native_process.process_birth,
6259
+ before_process_rollout: sourceBinding.native_process.rollout,
6260
+ before_binding: sourceBinding,
6261
+ adapter_version: adapterVersion,
6262
+ command_fingerprint: HUMAN_OBSERVED_HANDOFF_FINGERPRINT,
6263
+ dispatcher_pid: cliPid(),
6264
+ prepared_at: now.toISOString()
6265
+ };
6266
+ transition = saveNativeThreadTransition(storeDir, transition, {
6267
+ expectedRevision: null
6268
+ });
6269
+ if (cliEnv().AKK_TEST_EXIT_AFTER_HANDOFF_TRANSITION_BEFORE_LEDGER === "1") {
6270
+ cliExit(88);
6271
+ }
6272
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
6273
+ ...lifecycleLedgerFields(transition, storeDir),
6274
+ status: "prepared",
6275
+ target_native_thread_id: targetNativeThreadId,
6276
+ previous_generation_id: stringValue(previousLedger?.generation_id) ??
6277
+ stringValue(previousLedger?.message_id)
6278
+ }, { expectedTransitionId: null });
6279
+ if (cliEnv().AKK_TEST_EXIT_AFTER_LIFECYCLE_PREPARED === "1") {
6280
+ cliExit(86);
6281
+ }
6282
+ const sourceTransitioning = saveManagedSession(storeDir, {
6283
+ ...sourceSession,
6284
+ status: "transitioning",
6285
+ last_transition_id: transitionId,
6286
+ updated_at: now.toISOString()
6287
+ }, { expectedRevision: managedSessionRevision(sourceSession) });
6288
+ try {
6289
+ const reObserved = await observedExternalHandoffIdentity({
6290
+ options,
6291
+ terminal,
6292
+ sourceSession: sourceTransitioning,
6293
+ resolvedIdentity: await resolveCurrentNativeAgentSessionIdentity({
6294
+ options,
6295
+ agent: terminal.agent,
6296
+ pid: terminal.pid,
6297
+ cwd: terminal.terminalControl.currentPath,
6298
+ preferredSessionId: targetNativeThreadId,
6299
+ allowedCompanionIdentity: codexIdentityFence({
6300
+ sessionId: sourceBinding.native_thread_id,
6301
+ processUuid: sourceBinding.native_process.process_uuid,
6302
+ processBirth: sourceBinding.native_process.process_birth,
6303
+ rollout: sourceBinding.native_process.rollout,
6304
+ evidence: sourceBinding.native_process.evidence
6305
+ }),
6306
+ allowedAdditionalIdentities: []
6307
+ })
6308
+ });
6309
+ const reObservedExact = reObserved.identity
6310
+ ? exactLifecycleProcessIdentity(terminal, reObserved.identity)
6311
+ : undefined;
6312
+ if (reObservedExact?.sessionId.toLowerCase() !== targetNativeThreadId ||
6313
+ reObservedExact.processUuid !== nextBinding.native_process.process_uuid ||
6314
+ reObservedExact.processBirth !== nextBinding.native_process.process_birth ||
6315
+ JSON.stringify(reObservedExact.rollout ?? null) !==
6316
+ JSON.stringify(nextBinding.native_process.rollout ?? null)) {
6317
+ throw new Error("live native thread changed during external handoff adoption");
6318
+ }
6319
+ await assertNativeThreadHasExclusiveOwnership({
6320
+ options,
6321
+ agent: terminal.agent,
6322
+ currentPid: terminal.pid,
6323
+ nativeThreadId: targetNativeThreadId,
6324
+ storeDir,
6325
+ terminalControl: terminal.terminalControl,
6326
+ excludedManagedSessionId: targetSession?.session_id
6327
+ });
6328
+ transition = saveNativeThreadTransition(storeDir, {
6329
+ ...transition,
6330
+ status: "verified",
6331
+ after_binding: nextBinding,
6332
+ verified_at: cliNow().toISOString()
6333
+ }, { expectedRevision: nativeThreadTransitionRevision(transition) });
6334
+ if (cliEnv().AKK_TEST_EXIT_AFTER_HANDOFF_VERIFIED_TRANSITION_BEFORE_LEDGER ===
6335
+ "1") {
6336
+ cliExit(89);
6337
+ }
6338
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
6339
+ ...lifecycleLedgerFields(transition, storeDir),
6340
+ status: "verified",
6341
+ binding: sourceBinding
6342
+ }, {
6343
+ expectedTransitionId: transitionId,
6344
+ expectedStatus: "prepared"
6345
+ });
6346
+ if (cliEnv().AKK_TEST_EXIT_AFTER_LIFECYCLE_VERIFIED === "1") {
6347
+ cliExit(87);
6348
+ }
6349
+ const committedTarget = commitVerifiedLifecycleTransition(storeDir, transition, cliNow().toISOString());
6350
+ transition = saveNativeThreadTransition(storeDir, {
6351
+ ...transition,
6352
+ status: "committed",
6353
+ committed_at: cliNow().toISOString()
6354
+ }, { expectedRevision: nativeThreadTransitionRevision(transition) });
6355
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
6356
+ ...lifecycleLedgerFields(transition, storeDir),
6357
+ status: "resolved",
6358
+ resolved_at: cliNow().toISOString(),
6359
+ binding: committedTarget.binding,
6360
+ reason: "verified human-observed native thread handoff committed"
6361
+ }, {
6362
+ expectedTransitionId: transitionId,
6363
+ expectedStatus: "verified"
6364
+ });
6365
+ runtimeLog("info", "human_observed_handoff_adopted", {
6366
+ transition_id: transitionId,
6367
+ terminal_id: terminal.conversationId,
6368
+ source_session_id: sourceSession.session_id,
6369
+ target_session_id: committedTarget.session_id,
6370
+ native_thread_id: targetNativeThreadId,
6371
+ terminal_input_sent: false
6372
+ });
6373
+ return {
6374
+ session: committedTarget,
6375
+ identity: exactIdentity,
6376
+ transition,
6377
+ adopted: true
6378
+ };
6379
+ }
6380
+ catch (error) {
6381
+ const failedAt = cliNow().toISOString();
6382
+ const durable = loadNativeThreadTransition(storeDir, transitionId);
6383
+ if (durable.status === "verified" || durable.status === "committed") {
6384
+ throw error;
6385
+ }
6386
+ const uncertain = saveNativeThreadTransition(storeDir, {
6387
+ ...durable,
6388
+ status: "uncertain",
6389
+ uncertain_at: failedAt,
6390
+ error: error instanceof Error ? error.message : String(error),
6391
+ do_not_retry: true
6392
+ }, { expectedRevision: nativeThreadTransitionRevision(durable) });
6393
+ saveManagedSession(storeDir, {
6394
+ ...sourceTransitioning,
6395
+ status: "quarantined",
6396
+ quarantine_reason: "human-observed handoff could not be revalidated",
6397
+ updated_at: failedAt
6398
+ }, { expectedRevision: managedSessionRevision(sourceTransitioning) });
6399
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
6400
+ ...lifecycleLedgerFields(uncertain, storeDir),
6401
+ status: "uncertain",
6402
+ uncertain_at: failedAt,
6403
+ reason: "human-observed handoff could not be revalidated"
6404
+ }, { expectedTransitionId: transitionId });
6405
+ throw error;
6406
+ }
6407
+ }
6408
+ async function assertObservedHandoffTransportBoundary({ options, terminal, transition, requireEmptyComposer }) {
6409
+ const storeDir = storeDirFromOptions(options);
6410
+ const durable = loadNativeThreadTransition(storeDir, transition.transition_id);
6411
+ if (durable.operation !== "adopt_external_thread" ||
6412
+ durable.origin !== "human_observed" ||
6413
+ durable.terminal_input_sent !== false ||
6414
+ durable.status !== "committed" ||
6415
+ !durable.source_session_id ||
6416
+ !durable.before_binding ||
6417
+ !durable.after_binding ||
6418
+ JSON.stringify(durable.after_binding) !==
6419
+ JSON.stringify(transition.after_binding)) {
6420
+ throw new Error("human-observed handoff changed before terminal transport");
6421
+ }
6422
+ const source = loadManagedSession(storeDir, durable.source_session_id);
6423
+ const target = loadManagedSession(storeDir, durable.target_session_id);
6424
+ if (source.status !== "detached" ||
6425
+ source.last_transition_id !== durable.transition_id ||
6426
+ JSON.stringify(source.binding) !== JSON.stringify(durable.before_binding) ||
6427
+ target.status !== "bound" ||
6428
+ target.last_transition_id !== durable.transition_id ||
6429
+ JSON.stringify(target.binding) !== JSON.stringify(durable.after_binding)) {
6430
+ throw new Error("human-observed handoff Session authority changed before send");
6431
+ }
6432
+ const targetId = durable.after_binding.native_thread_id;
6433
+ if (!targetId) {
6434
+ throw new Error("human-observed handoff target identity is incomplete");
6435
+ }
6436
+ const resolved = await resolveCurrentNativeAgentSessionIdentity({
6437
+ options,
6438
+ agent: terminal.agent,
6439
+ pid: terminal.pid,
6440
+ cwd: terminal.terminalControl.currentPath,
6441
+ preferredSessionId: targetId,
6442
+ allowedCompanionIdentity: codexIdentityFence({
6443
+ sessionId: durable.before_native_thread_id,
6444
+ processUuid: durable.before_process_uuid,
6445
+ processBirth: durable.before_process_birth,
6446
+ rollout: durable.before_process_rollout,
6447
+ evidence: durable.before_binding.native_process.evidence
6448
+ }),
6449
+ allowedAdditionalIdentities: []
6450
+ });
6451
+ const bridge = createTerminalAgentBridge(options);
6452
+ const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
6453
+ if (requireEmptyComposer) {
6454
+ assertSafeTerminalSend(terminal.agent, status);
6455
+ }
6456
+ else {
6457
+ const displayName = executorDefinitionForKind(terminal.agent).displayName;
6458
+ const approval = isRecord(status?.approval_state)
6459
+ ? status.approval_state
6460
+ : undefined;
6461
+ if (status?.reachable !== true) {
6462
+ throw new Error(`${displayName} terminal status is unavailable`);
6463
+ }
6464
+ if (approval?.blocked === true) {
6465
+ throw new Error(stringValue(approval.reason) ??
6466
+ `${displayName} is waiting at a permission dialog`);
6467
+ }
6468
+ // With an exact draft in the composer, native TUIs can classify the screen
6469
+ // as `unknown` instead of `idle`. Exact draft materialization is proven by
6470
+ // the bridge before Enter, so only a positively busy state is unsafe here.
6471
+ if (status.activity_state !== "idle" &&
6472
+ status.activity_state !== "unknown") {
6473
+ throw new Error(`${displayName} terminal became ${stringValue(status.activity_state) ?? "unknown"} before handoff submission`);
6474
+ }
6475
+ }
6476
+ let liveIdentity = resolved;
6477
+ if (terminal.agent === "codex") {
6478
+ const foreground = terminal.adapter.observeThreadLifecycle?.({
6479
+ operation: { kind: "new_thread" },
6480
+ phase: "before",
6481
+ screen: status.screen.excerpt ?? ""
6482
+ });
6483
+ const foregroundId = foreground?.status === "observed" &&
6484
+ isExactNativeThreadId(foreground.nativeThreadId)
6485
+ ? foreground.nativeThreadId.toLowerCase()
6486
+ : undefined;
6487
+ if (foregroundId && foregroundId !== targetId.toLowerCase()) {
6488
+ throw new Error("Codex foreground native thread changed after handoff adoption");
6489
+ }
6490
+ if (!durable.after_binding.native_process.rollout) {
6491
+ if (requireEmptyComposer &&
6492
+ foregroundId !== targetId.toLowerCase()) {
6493
+ throw new Error("status-card-only Codex handoff lost its exact foreground identity");
6494
+ }
6495
+ liveIdentity = resolved?.sessionId.toLowerCase() === targetId.toLowerCase()
6496
+ ? resolved
6497
+ : {
6498
+ sessionId: targetId,
6499
+ processUuid: durable.after_binding.native_process.process_uuid,
6500
+ processBirth: durable.after_binding.native_process.process_birth,
6501
+ evidence: foreground?.evidence ?? "codex_status_card"
6502
+ };
6503
+ }
6504
+ else if (!liveIdentity ||
6505
+ liveIdentity.sessionId.toLowerCase() !== targetId.toLowerCase()) {
6506
+ throw new Error("Codex handoff rollout identity changed before terminal transport");
6507
+ }
6508
+ }
6509
+ if (!liveIdentity) {
6510
+ throw new Error("human-observed handoff identity is unavailable before send");
6511
+ }
6512
+ const exact = exactLifecycleProcessIdentity(terminal, liveIdentity);
6513
+ if (exact.sessionId.toLowerCase() !== targetId.toLowerCase() ||
6514
+ exact.processUuid !== durable.after_binding.native_process.process_uuid ||
6515
+ exact.processBirth !== durable.after_binding.native_process.process_birth ||
6516
+ JSON.stringify(exact.rollout ?? null) !==
6517
+ JSON.stringify(durable.after_binding.native_process.rollout ?? null)) {
6518
+ throw new Error("human-observed handoff identity changed before send");
6519
+ }
6520
+ if (requireEmptyComposer) {
6521
+ const empty = terminal.agent === "codex"
6522
+ ? codexComposerEmpty(status.screen.excerpt)
6523
+ : claudeComposerEmpty(status.screen.excerpt);
6524
+ if (!empty) {
6525
+ throw new Error("human-observed handoff composer changed before text injection");
6526
+ }
6527
+ if (terminal.agent === "codex") {
6528
+ await assertCodexComposerReadyForAutomatedInput({
6529
+ options,
6530
+ terminalControl: terminal.terminalControl
6531
+ });
6532
+ }
6533
+ }
6534
+ }
5509
6535
  function lifecycleBindingToken({ session, terminal, identity }) {
5510
6536
  if (session) {
5511
6537
  return managedSessionBindingToken(session);
@@ -7914,6 +8940,10 @@ async function runStatus(options) {
7914
8940
  };
7915
8941
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
7916
8942
  if (terminalConversation) {
8943
+ assertExpectedHandoffTokenUsesExactTerminalSelector({
8944
+ options,
8945
+ terminal: terminalConversation
8946
+ });
7917
8947
  const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
7918
8948
  pid: terminalConversation.pid,
7919
8949
  cwd: terminalConversation.terminalControl.currentPath,
@@ -8503,6 +9533,13 @@ async function runSend(options) {
8503
9533
  }
8504
9534
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
8505
9535
  if (terminalConversation) {
9536
+ // A token copied from list is authority for exactly the advertised full
9537
+ // terminal selector. Reject aliases and implicit/no-selector resolution
9538
+ // before taking locks or touching Store state.
9539
+ assertExpectedHandoffTokenUsesExactTerminalSelector({
9540
+ options,
9541
+ terminal: terminalConversation
9542
+ });
8506
9543
  if (!options.background) {
8507
9544
  throw new Error("raw terminal sends require --background so AKK can persist and monitor the submission safely");
8508
9545
  }
@@ -8527,14 +9564,14 @@ async function runSend(options) {
8527
9564
  })) {
8528
9565
  return;
8529
9566
  }
8530
- const claimedSession = soleBoundManagedSessionClaimForTerminal(rawStoreDir, terminalConversation);
8531
- const knownCodexCompanions = claimedSession
9567
+ let claimedSession = soleBoundManagedSessionClaimForTerminal(rawStoreDir, terminalConversation);
9568
+ let knownCodexCompanions = claimedSession
8532
9569
  ? codexAllowedCompanionSetForManagedSession({
8533
9570
  storeDir: rawStoreDir,
8534
9571
  session: claimedSession
8535
9572
  })
8536
9573
  : { additional: [] };
8537
- const currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
9574
+ const nativeIdentityObservation = await observeCurrentNativeAgentSessionIdentity({
8538
9575
  options,
8539
9576
  agent: terminalConversation.agent,
8540
9577
  pid: terminalConversation.pid,
@@ -8545,11 +9582,46 @@ async function runSend(options) {
8545
9582
  allowedCompanionIdentity: knownCodexCompanions.primary,
8546
9583
  allowedAdditionalIdentities: knownCodexCompanions.additional
8547
9584
  });
8548
- let managedSession = materializeCurrentManagedSession({
9585
+ if (nativeIdentityObservation.status === "unavailable") {
9586
+ throw new Error(`native ${terminalConversation.agent} identity observation is ` +
9587
+ `unavailable: ${nativeIdentityObservation.reason}`);
9588
+ }
9589
+ let currentNativeIdentity = nativeIdentityObservation.status === "resolved"
9590
+ ? nativeIdentityObservation.identity
9591
+ : undefined;
9592
+ const verifiedEmptyHandoff = await maybeDetachVerifiedEmptyCodexSource({
9593
+ options,
9594
+ terminal: terminalConversation,
9595
+ sourceSession: claimedSession,
9596
+ observation: nativeIdentityObservation
9597
+ });
9598
+ if (verifiedEmptyHandoff) {
9599
+ // The old rollout is conclusively closed. Never carry it forward as
9600
+ // a pre-materialization companion for the new virgin Session.
9601
+ claimedSession = undefined;
9602
+ knownCodexCompanions = { additional: [] };
9603
+ currentNativeIdentity = undefined;
9604
+ }
9605
+ const physicalNativeIdentityBeforeHandoff = currentNativeIdentity;
9606
+ const handoff = await maybeAdoptObservedExternalThread({
8549
9607
  options,
8550
9608
  terminal: terminalConversation,
8551
- identity: currentNativeIdentity
9609
+ sourceSession: claimedSession,
9610
+ resolvedIdentity: currentNativeIdentity,
9611
+ storeDir: rawStoreDir
8552
9612
  });
9613
+ currentNativeIdentity =
9614
+ handoff.adopted &&
9615
+ terminalConversation.agent === "codex" &&
9616
+ !handoff.session?.binding?.native_process.rollout
9617
+ ? physicalNativeIdentityBeforeHandoff
9618
+ : handoff.identity;
9619
+ let managedSession = handoff.session ??
9620
+ materializeCurrentManagedSession({
9621
+ options,
9622
+ terminal: terminalConversation,
9623
+ identity: currentNativeIdentity
9624
+ });
8553
9625
  if (!managedSession && currentNativeIdentity) {
8554
9626
  managedSession = await reattachManagedSessionForNativeIdentity({
8555
9627
  options,
@@ -8593,19 +9665,28 @@ async function runSend(options) {
8593
9665
  storeDir: rawStoreDir,
8594
9666
  session: managedSession,
8595
9667
  observedIdentity: currentNativeIdentity
8596
- }) ?? knownCodexCompanions.primary;
8597
- const allowedAdditionalIdentities = knownCodexCompanions.additional;
9668
+ }) ?? (currentNativeIdentity === undefined ||
9669
+ nativeIdentityMatchesCodexPreMaterialization(currentNativeIdentity, knownCodexCompanions.primary)
9670
+ ? knownCodexCompanions.primary
9671
+ : undefined);
9672
+ const allowedAdditionalIdentities = allowedPreMaterializationIdentity
9673
+ ? knownCodexCompanions.additional
9674
+ : [];
8598
9675
  const materializedNativeIdentity = currentNativeIdentity?.sessionId === logicalNativeIdentity?.sessionId
8599
9676
  ? currentNativeIdentity
8600
9677
  : undefined;
8601
- await verifyCodexPendingManagedSendStatus({
8602
- options,
8603
- terminal: terminalConversation,
8604
- session: managedSession,
8605
- logicalIdentity: logicalNativeIdentity,
8606
- allowedPreMaterializationIdentity,
8607
- allowedAdditionalIdentities
8608
- });
9678
+ if (!(handoff.adopted &&
9679
+ terminalConversation.agent === "codex" &&
9680
+ !managedSession.binding?.native_process.rollout)) {
9681
+ await verifyCodexPendingManagedSendStatus({
9682
+ options,
9683
+ terminal: terminalConversation,
9684
+ session: managedSession,
9685
+ logicalIdentity: logicalNativeIdentity,
9686
+ allowedPreMaterializationIdentity,
9687
+ allowedAdditionalIdentities
9688
+ });
9689
+ }
8609
9690
  const managedNativeThreadId = logicalNativeIdentity?.sessionId ??
8610
9691
  managedSession.binding?.native_thread_id;
8611
9692
  if (managedNativeThreadId) {
@@ -8683,7 +9764,14 @@ async function runSend(options) {
8683
9764
  }
8684
9765
  : undefined,
8685
9766
  allowedPreMaterializationIdentity,
8686
- allowedAdditionalIdentities
9767
+ allowedAdditionalIdentities,
9768
+ observedHandoff: handoff.adopted && handoff.transition
9769
+ ? {
9770
+ terminal: terminalConversation,
9771
+ transition: handoff.transition
9772
+ }
9773
+ : undefined,
9774
+ verifiedEmptyCodexHandoff: verifiedEmptyHandoff?.boundary
8687
9775
  });
8688
9776
  }
8689
9777
  finally {
@@ -8696,6 +9784,10 @@ async function runSend(options) {
8696
9784
  }
8697
9785
  return;
8698
9786
  }
9787
+ if (stringValue(options.expectedTerminalToken)) {
9788
+ throw new Error("--expected-terminal-token cannot be used with a managed Session; " +
9789
+ "use the exact full terminal selector advertised by AKK list");
9790
+ }
8699
9791
  const sessionId = required(stringValue(options.session ?? options.conversation ?? options.conversationId), "--session is required for an ordinary managed send");
8700
9792
  const storeDir = storeDirFromOptions(options);
8701
9793
  // Read only enough legacy/Session authority to identify the physical
@@ -9638,7 +10730,7 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
9638
10730
  releaseTerminalLock();
9639
10731
  }
9640
10732
  }
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 }) {
10733
+ async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, terminalSendLockHeld = false, terminalStateLockHeld = false, storeWriterLeaseHeld = false, recordMessageAfterSend = false, recordRawAttachmentAfterSend = false, onTerminalPreflightVerified = undefined, allowedPreMaterializationIdentity = undefined, allowedAdditionalIdentities = [], observedHandoff = undefined, verifiedEmptyCodexHandoff = undefined, continuingTurnResponse = false }) {
9642
10734
  const bridge = terminalBridgeEnabled(conversation);
9643
10735
  if (!terminalSendLockHeld) {
9644
10736
  const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
@@ -9660,6 +10752,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9660
10752
  onTerminalPreflightVerified,
9661
10753
  allowedPreMaterializationIdentity,
9662
10754
  allowedAdditionalIdentities,
10755
+ observedHandoff,
10756
+ verifiedEmptyCodexHandoff,
9663
10757
  continuingTurnResponse
9664
10758
  });
9665
10759
  }
@@ -9699,6 +10793,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9699
10793
  onTerminalPreflightVerified,
9700
10794
  allowedPreMaterializationIdentity,
9701
10795
  allowedAdditionalIdentities,
10796
+ observedHandoff,
10797
+ verifiedEmptyCodexHandoff,
9702
10798
  continuingTurnResponse
9703
10799
  });
9704
10800
  }
@@ -9725,6 +10821,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9725
10821
  onTerminalPreflightVerified,
9726
10822
  allowedPreMaterializationIdentity,
9727
10823
  allowedAdditionalIdentities,
10824
+ observedHandoff,
10825
+ verifiedEmptyCodexHandoff,
9728
10826
  continuingTurnResponse
9729
10827
  }));
9730
10828
  }
@@ -9906,7 +11004,18 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9906
11004
  scrollbackLines: Number(options.scrollbackLines ?? 120),
9907
11005
  runtime: preSendRuntime
9908
11006
  });
9909
- assertSafeTerminalSend(executor.kind, status);
11007
+ if (verifiedEmptyCodexHandoff) {
11008
+ if (executor.kind !== "codex" ||
11009
+ status.reachable !== true ||
11010
+ status.approval_state.blocked === true ||
11011
+ !["idle", "unknown"].includes(status.activity_state)) {
11012
+ throw new Error(`Codex verified-empty handoff is not at a safe prompt ` +
11013
+ `(${status.activity_state}: ${status.activity_reason})`);
11014
+ }
11015
+ }
11016
+ else {
11017
+ assertSafeTerminalSend(executor.kind, status);
11018
+ }
9910
11019
  if (executor.kind === "codex" && needsPostSendNativeBinding) {
9911
11020
  if (pendingManagedNativeBinding || allowedPreMaterializationIdentity) {
9912
11021
  const expectedForegroundId = stringValue(sendTakeover?.terminal_agent_expected_session_id);
@@ -10276,6 +11385,44 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10276
11385
  try {
10277
11386
  await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
10278
11387
  runtime: preSendRuntime,
11388
+ requireExactComposerBeforeEnter: observedHandoff !== undefined ||
11389
+ verifiedEmptyCodexHandoff !== undefined,
11390
+ beforeText: observedHandoff
11391
+ ? async () => {
11392
+ await assertObservedHandoffTransportBoundary({
11393
+ options,
11394
+ terminal: observedHandoff.terminal,
11395
+ transition: observedHandoff.transition,
11396
+ requireEmptyComposer: true
11397
+ });
11398
+ }
11399
+ : verifiedEmptyCodexHandoff
11400
+ ? async () => {
11401
+ await assertVerifiedEmptyCodexTransportBoundary({
11402
+ options,
11403
+ boundary: verifiedEmptyCodexHandoff,
11404
+ requireEmptyComposer: true
11405
+ });
11406
+ }
11407
+ : undefined,
11408
+ beforeEnter: observedHandoff
11409
+ ? async () => {
11410
+ await assertObservedHandoffTransportBoundary({
11411
+ options,
11412
+ terminal: observedHandoff.terminal,
11413
+ transition: observedHandoff.transition,
11414
+ requireEmptyComposer: false
11415
+ });
11416
+ }
11417
+ : verifiedEmptyCodexHandoff
11418
+ ? async () => {
11419
+ await assertVerifiedEmptyCodexTransportBoundary({
11420
+ options,
11421
+ boundary: verifiedEmptyCodexHandoff,
11422
+ requireEmptyComposer: false
11423
+ });
11424
+ }
11425
+ : undefined,
10279
11426
  async onTransportStage(event) {
10280
11427
  const stageAt = cliNow().toISOString();
10281
11428
  if (event.stage === "text_injected") {
@@ -10316,6 +11463,21 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10316
11463
  previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
10317
11464
  stringValue(previousDispatchLedger?.message_id)
10318
11465
  });
11466
+ if (event.stage === "text_injected" && observedHandoff) {
11467
+ await assertObservedHandoffTransportBoundary({
11468
+ options,
11469
+ terminal: observedHandoff.terminal,
11470
+ transition: observedHandoff.transition,
11471
+ requireEmptyComposer: false
11472
+ });
11473
+ }
11474
+ if (event.stage === "text_injected" && verifiedEmptyCodexHandoff) {
11475
+ await assertVerifiedEmptyCodexTransportBoundary({
11476
+ options,
11477
+ boundary: verifiedEmptyCodexHandoff,
11478
+ requireEmptyComposer: false
11479
+ });
11480
+ }
10319
11481
  try {
10320
11482
  appendEvent(logPath, {
10321
11483
  ts: stageAt,
@@ -11755,6 +12917,29 @@ async function resolveCurrentNativeAgentSessionIdentity({ options, agent, pid, c
11755
12917
  evidence: "claude_agents_exact_pid"
11756
12918
  };
11757
12919
  }
12920
+ /**
12921
+ * Preserve the security-significant difference between a successful native
12922
+ * identity probe that found no current Session and a probe that could not be
12923
+ * completed. `undefined` from the Codex provider is authoritative only after
12924
+ * both the process-incarnation and open-rollout inspections succeeded.
12925
+ */
12926
+ async function observeCurrentNativeAgentSessionIdentity(request) {
12927
+ try {
12928
+ const identity = await resolveCurrentNativeAgentSessionIdentity(request);
12929
+ return identity
12930
+ ? { status: "resolved", identity }
12931
+ : {
12932
+ status: "verified_absent",
12933
+ evidence: "native_identity_resolver_verified_absent"
12934
+ };
12935
+ }
12936
+ catch (error) {
12937
+ return {
12938
+ status: "unavailable",
12939
+ reason: error instanceof Error ? error.message : String(error)
12940
+ };
12941
+ }
12942
+ }
11758
12943
  function nativeAgentIdentityMatchesTurn(conversation, currentIdentity) {
11759
12944
  const takeover = isRecord(conversation.native_session_takeover)
11760
12945
  ? conversation.native_session_takeover
@@ -13263,9 +14448,185 @@ async function runTerminalControlCancel({ options, statePath, logPath, agent, te
13263
14448
  }
13264
14449
  }
13265
14450
  }
14451
+ async function runObservedHandoffClose({ options, statePath, logPath, initialConversation }) {
14452
+ const expectedToken = required(stringValue(options.expectedHandoffToken), "--expected-handoff-token is required");
14453
+ if (stringValue(options.expectedMessageId) ||
14454
+ stringValue(options.expectedTransitionId)) {
14455
+ throw new Error("--expected-handoff-token cannot be combined with dispatch or lifecycle recovery tokens");
14456
+ }
14457
+ if (stringValue(options.reason) !== "superseded_by_human_context_switch") {
14458
+ throw new Error("a handoff close requires reason superseded_by_human_context_switch");
14459
+ }
14460
+ const storeDir = storeDirFromOptions(options);
14461
+ const sourceSessionId = sessionIdForConversation(initialConversation);
14462
+ const initialSource = loadManagedSession(storeDir, sourceSessionId);
14463
+ if (!initialSource.binding || initialSource.status !== "bound") {
14464
+ throw new Error("handoff source Session is no longer bound; refresh list");
14465
+ }
14466
+ const terminal = await createTerminalAgentBridge(options).resolveStoredTerminal(initialSource.agent, initialSource.binding.native_process.pid, initialSource.binding.terminal_control, { pid: initialSource.binding.native_process.pid });
14467
+ const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDir, terminal.terminalControl, { timeoutMs: 30000 });
14468
+ try {
14469
+ await withStoreWriterLeaseAsync(storeDir, async () => {
14470
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
14471
+ try {
14472
+ const conversation = loadState(statePath);
14473
+ const turnId = turnIdForConversation(conversation);
14474
+ if (conversation.conversation_id !== initialConversation.conversation_id ||
14475
+ sessionIdForConversation(conversation) !== sourceSessionId ||
14476
+ !SESSION_SEND_BLOCKING_STATUSES.has(conversation.status)) {
14477
+ throw new Error("active handoff Turn changed after it was listed; refresh AKK list");
14478
+ }
14479
+ const source = loadManagedSession(storeDir, sourceSessionId);
14480
+ if (source.status !== "bound" ||
14481
+ !source.binding ||
14482
+ managedSessionHasUnresolvedNativeTransition(storeDir, source)) {
14483
+ throw new Error("handoff source Session changed after it was listed; refresh AKK list");
14484
+ }
14485
+ const companions = codexAllowedCompanionSetForManagedSession({
14486
+ storeDir,
14487
+ session: source
14488
+ });
14489
+ const resolved = await resolveCurrentNativeAgentSessionIdentity({
14490
+ options,
14491
+ agent: terminal.agent,
14492
+ pid: terminal.pid,
14493
+ cwd: terminal.terminalControl.currentPath,
14494
+ preferredSessionId: companions.primary
14495
+ ? source.binding.native_thread_id
14496
+ : undefined,
14497
+ allowedCompanionIdentity: companions.primary,
14498
+ allowedAdditionalIdentities: companions.additional
14499
+ });
14500
+ const observed = await observedExternalHandoffIdentity({
14501
+ options,
14502
+ terminal,
14503
+ sourceSession: source,
14504
+ resolvedIdentity: resolved
14505
+ });
14506
+ if (!observed.identity ||
14507
+ managedBindingConflictKindForResolvedTerminal({
14508
+ storeDir,
14509
+ session: source,
14510
+ terminal,
14511
+ identity: observed.identity
14512
+ }) !== "live_external_thread_change") {
14513
+ throw new Error("live native thread no longer matches the listed handoff; refresh AKK list");
14514
+ }
14515
+ const targetNativeThreadId = observed.identity.sessionId.toLowerCase();
14516
+ const target = observedHandoffTargetResolution({
14517
+ storeDir,
14518
+ agent: terminal.agent,
14519
+ workspace: terminal.terminalControl.currentPath ?? cliCwd(),
14520
+ nativeThreadId: targetNativeThreadId,
14521
+ sourceSessionId
14522
+ });
14523
+ if (target.status !== "eligible") {
14524
+ throw new Error("handoff target Session changed after it was listed; refresh AKK list");
14525
+ }
14526
+ await assertNativeThreadHasExclusiveOwnership({
14527
+ options,
14528
+ agent: terminal.agent,
14529
+ currentPid: terminal.pid,
14530
+ nativeThreadId: targetNativeThreadId,
14531
+ storeDir,
14532
+ terminalControl: terminal.terminalControl,
14533
+ excludedManagedSessionId: target.snapshot.state === "detached"
14534
+ ? target.snapshot.session_id
14535
+ : undefined
14536
+ });
14537
+ const handoffToken = observedHandoffAuthorityToken({
14538
+ terminal,
14539
+ identity: observed.identity,
14540
+ sourceSession: source,
14541
+ target: target.snapshot
14542
+ });
14543
+ const takeover = isRecord(conversation.native_session_takeover)
14544
+ ? conversation.native_session_takeover
14545
+ : undefined;
14546
+ const expectedMessageId = stringValue(takeover?.terminal_bridge_message_id) ?? stringValue(terminalBridgeSubmission(conversation)?.message_id);
14547
+ const ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
14548
+ const exactDispatchGeneration = Boolean(expectedMessageId &&
14549
+ ledger &&
14550
+ !terminalDispatchLedgerLooksLifecycle(ledger) &&
14551
+ stringValue(ledger.conversation_id) ===
14552
+ conversation.conversation_id &&
14553
+ stringValue(ledger.session_id) === sourceSessionId &&
14554
+ stringValue(ledger.turn_id) === turnId &&
14555
+ stringValue(ledger.message_id) === expectedMessageId);
14556
+ const exactNoLedgerGeneration = Boolean(!expectedMessageId &&
14557
+ (!ledger || ledger.status === "resolved"));
14558
+ if (!exactDispatchGeneration && !exactNoLedgerGeneration) {
14559
+ throw new Error("active handoff dispatch generation changed; refresh AKK list");
14560
+ }
14561
+ const freshToken = activeTurnHandoffDecisionToken({
14562
+ handoffToken,
14563
+ turn: conversation,
14564
+ ledger
14565
+ });
14566
+ if (freshToken !== expectedToken) {
14567
+ throw new Error("active handoff snapshot changed; refresh AKK list before closing");
14568
+ }
14569
+ const now = cliNow().toISOString();
14570
+ const closed = {
14571
+ ...conversation,
14572
+ status: "closed",
14573
+ closed_at: now,
14574
+ close_reason: "superseded_by_human_context_switch",
14575
+ disposition: "superseded_by_human_context_switch",
14576
+ updated_at: now
14577
+ };
14578
+ saveState(statePath, closed);
14579
+ const dispatchResolved = expectedMessageId
14580
+ ? resolveTerminalBridgeDispatchLedger({
14581
+ terminalControl: terminal.terminalControl,
14582
+ conversation: closed,
14583
+ expectedMessageId,
14584
+ reason: "Turn superseded by a verified human context switch"
14585
+ })
14586
+ : false;
14587
+ if (expectedMessageId && !dispatchResolved) {
14588
+ throw new Error("active handoff dispatch changed during close; inspect before retrying");
14589
+ }
14590
+ appendEvent(logPath, {
14591
+ ts: now,
14592
+ conversation_id: conversation.conversation_id,
14593
+ event: "conversation_closed",
14594
+ status: "closed",
14595
+ reason: "superseded_by_human_context_switch",
14596
+ disposition: "superseded_by_human_context_switch",
14597
+ handoff_native_thread_id: targetNativeThreadId
14598
+ });
14599
+ runtimeLog("info", "conversation_closed", {
14600
+ conversation_id: conversation.conversation_id,
14601
+ status: "closed",
14602
+ reason: "superseded_by_human_context_switch",
14603
+ disposition: "superseded_by_human_context_switch",
14604
+ state_path: statePath,
14605
+ event_log_path: logPath
14606
+ });
14607
+ printJson({
14608
+ conversation: closed,
14609
+ closed: true,
14610
+ terminal_dispatch_resolved: dispatchResolved,
14611
+ handoff_disposition: "superseded_by_human_context_switch",
14612
+ next_action: "refresh list and use its follow-current send"
14613
+ });
14614
+ }
14615
+ finally {
14616
+ releaseStateLock();
14617
+ }
14618
+ });
14619
+ }
14620
+ finally {
14621
+ releaseTerminalLock();
14622
+ }
14623
+ }
13266
14624
  async function runClose(options) {
13267
14625
  const terminalConversation = await resolveTerminalConversationFromOptions(options);
13268
14626
  if (terminalConversation) {
14627
+ if (stringValue(options.expectedHandoffToken)) {
14628
+ throw new Error("--expected-handoff-token cannot be used with raw terminal close");
14629
+ }
13269
14630
  await runTerminalDispatchClose({
13270
14631
  options,
13271
14632
  terminalConversation
@@ -13273,6 +14634,15 @@ async function runClose(options) {
13273
14634
  return;
13274
14635
  }
13275
14636
  const loaded = loadConversationFromOptions(options);
14637
+ if (stringValue(options.expectedHandoffToken)) {
14638
+ await runObservedHandoffClose({
14639
+ options,
14640
+ statePath: loaded.statePath,
14641
+ logPath: loaded.logPath,
14642
+ initialConversation: loaded.conversation
14643
+ });
14644
+ return;
14645
+ }
13276
14646
  const { statePath, logPath } = loaded;
13277
14647
  const nativeTakeover = isRecord(loaded.conversation.native_session_takeover)
13278
14648
  ? loaded.conversation.native_session_takeover
@@ -13286,11 +14656,18 @@ async function runClose(options) {
13286
14656
  releaseStateLock = acquireFileLock(`${statePath}.lock`);
13287
14657
  const conversation = loadState(statePath);
13288
14658
  const now = cliNow().toISOString();
14659
+ const closeReason = options.reason ?? "closed by request";
14660
+ const handoffDisposition = closeReason === "superseded_by_human_context_switch"
14661
+ ? closeReason
14662
+ : undefined;
13289
14663
  const closed = {
13290
14664
  ...conversation,
13291
14665
  status: "closed",
13292
14666
  closed_at: now,
13293
- close_reason: options.reason ?? "closed by request",
14667
+ close_reason: closeReason,
14668
+ ...(handoffDisposition
14669
+ ? { disposition: handoffDisposition }
14670
+ : {}),
13294
14671
  updated_at: now
13295
14672
  };
13296
14673
  saveState(statePath, closed);
@@ -15330,6 +16707,7 @@ function terminalDispatchLedgerLooksLifecycle(ledger) {
15330
16707
  ledger.transition_id !== undefined ||
15331
16708
  ledger.operation === "new_thread" ||
15332
16709
  ledger.operation === "resume_thread" ||
16710
+ ledger.operation === "adopt_external_thread" ||
15333
16711
  ledger.adapter_version !== undefined ||
15334
16712
  ledger.command_fingerprint !== undefined ||
15335
16713
  ledger.target_session_id !== undefined ||
@@ -15345,6 +16723,8 @@ function lifecycleLedgerFields(transition, storeDir) {
15345
16723
  generation_id: transition.transition_id,
15346
16724
  transition_id: transition.transition_id,
15347
16725
  operation: transition.operation,
16726
+ origin: transition.origin,
16727
+ terminal_input_sent: transition.terminal_input_sent,
15348
16728
  terminal_id: transition.terminal_id,
15349
16729
  agent: transition.agent,
15350
16730
  workspace: transition.workspace,
@@ -15778,6 +17158,7 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
15778
17158
  const dispatcherPid = Number(ledger.dispatcher_pid);
15779
17159
  if (Number.isSafeInteger(dispatcherPid) &&
15780
17160
  dispatcherPid > 1 &&
17161
+ dispatcherPid !== cliPid() &&
15781
17162
  isProcessAlive(dispatcherPid)) {
15782
17163
  return ledger;
15783
17164
  }
@@ -16115,9 +17496,16 @@ function terminalSubmissionProofRank(status, lastProven) {
16115
17496
  return 0;
16116
17497
  }
16117
17498
  async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
16118
- const ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
17499
+ let ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
16119
17500
  if (!ledger || ledger.status === "resolved") {
16120
- return;
17501
+ ledger = rebuildObservedHandoffLedgerFromTransition({
17502
+ options,
17503
+ terminal,
17504
+ previousLedger: ledger
17505
+ });
17506
+ if (!ledger) {
17507
+ return;
17508
+ }
16121
17509
  }
16122
17510
  if (!terminalDispatchLedgerLooksLifecycle(ledger)) {
16123
17511
  return;
@@ -16136,6 +17524,175 @@ async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
16136
17524
  "expected-transition-id recovery action");
16137
17525
  }
16138
17526
  }
17527
+ function rebuildObservedHandoffLedgerFromTransition({ options, terminal, previousLedger }) {
17528
+ const storeDir = storeDirFromOptions(options);
17529
+ const candidates = listNativeThreadTransitions(storeDir).filter((transition) => transition.operation === "adopt_external_thread" &&
17530
+ transition.origin === "human_observed" &&
17531
+ transition.terminal_input_sent === false &&
17532
+ ["prepared", "verified"].includes(transition.status) &&
17533
+ transition.agent === terminal.agent &&
17534
+ transition.before_binding?.native_process.pid === terminal.pid &&
17535
+ terminalControlAliasMatches(transition.terminal_id, transition.before_binding?.terminal_control, terminal.conversationId, terminal.terminalControl) &&
17536
+ matchesConfiguredWorkspace(transition.workspace, terminal.terminalControl.currentPath));
17537
+ if (candidates.length === 0) {
17538
+ return undefined;
17539
+ }
17540
+ if (candidates.length !== 1) {
17541
+ throw new Error(`terminal ${terminal.terminalControl.target} has multiple unresolved ` +
17542
+ "human-observed handoff transitions; refusing to infer one ledger owner");
17543
+ }
17544
+ const transition = candidates[0];
17545
+ if (previousLedger?.status === "resolved" &&
17546
+ stringValue(previousLedger.transition_id) === transition.transition_id) {
17547
+ throw new Error(`human-observed handoff transition ${transition.transition_id} is ` +
17548
+ "unresolved but its terminal ledger is already resolved");
17549
+ }
17550
+ const rebuilt = {
17551
+ ...lifecycleLedgerFields(transition, storeDir),
17552
+ status: transition.status,
17553
+ binding: transition.before_binding,
17554
+ terminal_control: {
17555
+ kind: terminal.terminalControl.kind,
17556
+ target: terminal.terminalControl.target,
17557
+ socket_path: terminal.terminalControl.socketPath ?? null,
17558
+ pane_pid: terminal.terminalControl.panePid ?? null,
17559
+ current_path: terminal.terminalControl.currentPath ?? null
17560
+ },
17561
+ ...(hasCanonicalTerminalEndpoint(terminal.terminalControl)
17562
+ ? {
17563
+ terminal_endpoint: terminalControlEvidence(terminal.terminalControl)
17564
+ }
17565
+ : {}),
17566
+ previous_generation_id: stringValue(previousLedger?.generation_id) ??
17567
+ stringValue(previousLedger?.message_id)
17568
+ };
17569
+ // Validate the complete transition/terminal/adapter relationship before
17570
+ // replacing a missing or older resolved ledger. Rebuilding a fence is a
17571
+ // Store-side recovery operation only and never sends terminal input.
17572
+ assertLifecycleLedgerMatchesTransition({
17573
+ options,
17574
+ terminal,
17575
+ ledger: rebuilt,
17576
+ transition,
17577
+ storeDir
17578
+ });
17579
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, rebuilt, {
17580
+ expectedTransitionId: null
17581
+ });
17582
+ return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
17583
+ }
17584
+ async function recoverPreparedObservedHandoff({ options, terminal, ledger, transition, storeDir, now }) {
17585
+ if (transition.operation !== "adopt_external_thread" ||
17586
+ transition.status !== "prepared" ||
17587
+ ledger.status !== "prepared" ||
17588
+ !transition.source_session_id ||
17589
+ transition.source_expected_revision === undefined ||
17590
+ !transition.before_binding ||
17591
+ !transition.target_native_thread_id) {
17592
+ return transition;
17593
+ }
17594
+ let source = loadManagedSession(storeDir, transition.source_session_id);
17595
+ if (source.status === "bound" &&
17596
+ source.revision === transition.source_expected_revision &&
17597
+ JSON.stringify(source.binding) === JSON.stringify(transition.before_binding)) {
17598
+ source = saveManagedSession(storeDir, {
17599
+ ...source,
17600
+ status: "transitioning",
17601
+ last_transition_id: transition.transition_id,
17602
+ updated_at: now
17603
+ }, { expectedRevision: managedSessionRevision(source) });
17604
+ }
17605
+ else if (source.status !== "transitioning" ||
17606
+ source.last_transition_id !== transition.transition_id ||
17607
+ source.revision !== transition.source_expected_revision + 1 ||
17608
+ JSON.stringify(source.binding) !== JSON.stringify(transition.before_binding)) {
17609
+ throw new Error("human-observed handoff source changed before recovery");
17610
+ }
17611
+ const before = transition.before_binding;
17612
+ const resolved = await resolveCurrentNativeAgentSessionIdentity({
17613
+ options,
17614
+ agent: terminal.agent,
17615
+ pid: terminal.pid,
17616
+ cwd: terminal.terminalControl.currentPath,
17617
+ preferredSessionId: transition.target_native_thread_id,
17618
+ allowedCompanionIdentity: codexIdentityFence({
17619
+ sessionId: transition.before_native_thread_id,
17620
+ processUuid: transition.before_process_uuid,
17621
+ processBirth: transition.before_process_birth,
17622
+ rollout: transition.before_process_rollout,
17623
+ evidence: before.native_process.evidence
17624
+ }),
17625
+ allowedAdditionalIdentities: []
17626
+ });
17627
+ const observed = await observedExternalHandoffIdentity({
17628
+ options,
17629
+ terminal,
17630
+ sourceSession: source,
17631
+ resolvedIdentity: resolved
17632
+ });
17633
+ const liveId = observed.identity?.sessionId.toLowerCase();
17634
+ if (liveId === transition.before_native_thread_id.toLowerCase()) {
17635
+ transition = saveNativeThreadTransition(storeDir, {
17636
+ ...transition,
17637
+ status: "aborted",
17638
+ aborted_at: now,
17639
+ error: "recovery observed the exact source native thread"
17640
+ }, { expectedRevision: nativeThreadTransitionRevision(transition) });
17641
+ restorePreparedLifecycleSource(storeDir, transition, now);
17642
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
17643
+ ...lifecycleLedgerFields(transition, storeDir),
17644
+ status: "resolved",
17645
+ resolved_at: now,
17646
+ reason: "human-observed handoff rolled back to its exact source"
17647
+ }, { expectedTransitionId: transition.transition_id });
17648
+ return transition;
17649
+ }
17650
+ if (!observed.identity ||
17651
+ liveId !== transition.target_native_thread_id.toLowerCase()) {
17652
+ throw new Error("human-observed handoff recovery found neither its exact source nor target");
17653
+ }
17654
+ const target = tryLoadManagedSession(storeDir, transition.target_session_id);
17655
+ if ((target?.revision ?? null) !== transition.target_expected_revision) {
17656
+ throw new Error("human-observed handoff target changed before recovery");
17657
+ }
17658
+ await assertNativeThreadHasExclusiveOwnership({
17659
+ options,
17660
+ agent: terminal.agent,
17661
+ currentPid: terminal.pid,
17662
+ nativeThreadId: transition.target_native_thread_id,
17663
+ storeDir,
17664
+ terminalControl: terminal.terminalControl,
17665
+ excludedManagedSessionId: target?.session_id
17666
+ });
17667
+ const exact = exactLifecycleProcessIdentity(terminal, observed.identity);
17668
+ const afterBinding = terminalBindingFrom({
17669
+ terminalId: terminal.conversationId,
17670
+ terminalControl: terminal.terminalControl,
17671
+ pid: terminal.pid,
17672
+ nativeThreadId: transition.target_native_thread_id,
17673
+ processUuid: exact.processUuid,
17674
+ processBirth: exact.processBirth,
17675
+ rollout: exact.rollout,
17676
+ evidence: `${exact.evidence}+human_observed_recovery`,
17677
+ generation: (target?.binding?.generation ?? 0) + 1,
17678
+ now: new Date(now)
17679
+ });
17680
+ transition = saveNativeThreadTransition(storeDir, {
17681
+ ...transition,
17682
+ status: "verified",
17683
+ verified_at: now,
17684
+ after_binding: afterBinding
17685
+ }, { expectedRevision: nativeThreadTransitionRevision(transition) });
17686
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
17687
+ ...lifecycleLedgerFields(transition, storeDir),
17688
+ status: "verified",
17689
+ binding: before
17690
+ }, {
17691
+ expectedTransitionId: transition.transition_id,
17692
+ expectedStatus: "prepared"
17693
+ });
17694
+ return transition;
17695
+ }
16139
17696
  async function reconcileLifecycleDispatchLedger(options, terminal, ledger, authority = {
16140
17697
  kind: "automatic"
16141
17698
  }) {
@@ -16179,6 +17736,34 @@ async function reconcileLifecycleDispatchLedger(options, terminal, ledger, autho
16179
17736
  transition,
16180
17737
  storeDir
16181
17738
  });
17739
+ if (transition.operation === "adopt_external_thread" &&
17740
+ transition.status === "verified" &&
17741
+ ledger.status === "prepared") {
17742
+ saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
17743
+ ...lifecycleLedgerFields(transition, storeDir),
17744
+ status: "verified",
17745
+ binding: transition.before_binding,
17746
+ previous_generation_id: stringValue(ledger.previous_generation_id)
17747
+ }, {
17748
+ expectedTransitionId: transition.transition_id,
17749
+ expectedStatus: "prepared"
17750
+ });
17751
+ ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
17752
+ }
17753
+ if (transition.operation === "adopt_external_thread" &&
17754
+ transition.status === "prepared") {
17755
+ transition = await recoverPreparedObservedHandoff({
17756
+ options,
17757
+ terminal,
17758
+ ledger,
17759
+ transition,
17760
+ storeDir,
17761
+ now
17762
+ });
17763
+ if (["aborted", "committed"].includes(transition.status)) {
17764
+ return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
17765
+ }
17766
+ }
16182
17767
  if (transition.status === "prepared" && ledger.status === "prepared") {
16183
17768
  await assertNativeThreadHasExclusiveOwnership({
16184
17769
  options,
@@ -16317,9 +17902,9 @@ async function reconcileLifecycleDispatchLedger(options, terminal, ledger, autho
16317
17902
  }
16318
17903
  }
16319
17904
  function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, transition, storeDir }) {
16320
- const operationTarget = transition.operation === "resume_thread"
16321
- ? transition.target_native_thread_id
16322
- : undefined;
17905
+ const operationTarget = transition.operation === "new_thread"
17906
+ ? undefined
17907
+ : transition.target_native_thread_id;
16323
17908
  const ledgerControl = isRecord(ledger.terminal_control)
16324
17909
  ? ledger.terminal_control
16325
17910
  : undefined;
@@ -16328,8 +17913,12 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
16328
17913
  dispatching: ["prepared", "dispatching", "uncertain"],
16329
17914
  submitted: ["dispatching", "submitted", "uncertain"],
16330
17915
  uncertain: ["dispatching", "submitted", "uncertain"],
16331
- verified: ["submitted", "verified", "uncertain"],
16332
- committed: ["submitted", "verified", "uncertain"],
17916
+ verified: transition.operation === "adopt_external_thread"
17917
+ ? ["prepared", "verified", "uncertain"]
17918
+ : ["submitted", "verified", "uncertain"],
17919
+ committed: transition.operation === "adopt_external_thread"
17920
+ ? ["verified", "uncertain"]
17921
+ : ["submitted", "verified", "uncertain"],
16333
17922
  aborted: transition.reconciled_outcome === "before"
16334
17923
  ? ["dispatching", "submitted", "uncertain"]
16335
17924
  : ["prepared", "dispatching"]
@@ -16338,6 +17927,8 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
16338
17927
  stringValue(ledger.transition_id) !== transition.transition_id ||
16339
17928
  stringValue(ledger.generation_id) !== transition.transition_id ||
16340
17929
  stringValue(ledger.operation) !== transition.operation ||
17930
+ stringValue(ledger.origin) !== transition.origin ||
17931
+ ledger.terminal_input_sent !== transition.terminal_input_sent ||
16341
17932
  stringValue(ledger.terminal_id) !== transition.terminal_id ||
16342
17933
  stringValue(ledger.agent) !== transition.agent ||
16343
17934
  stringValue(ledger.workspace) !== transition.workspace ||
@@ -16383,6 +17974,16 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
16383
17974
  const adapter = createRuntimeTerminalAgentRegistry(options)
16384
17975
  .require(terminal.agent);
16385
17976
  const capability = adapter.probeThreadLifecycle?.(currentVersion);
17977
+ if (transition.operation === "adopt_external_thread") {
17978
+ if (currentVersion !== transition.adapter_version ||
17979
+ capability?.status !== "supported" ||
17980
+ transition.origin !== "human_observed" ||
17981
+ transition.terminal_input_sent !== false ||
17982
+ transition.command_fingerprint !== HUMAN_OBSERVED_HANDOFF_FINGERPRINT) {
17983
+ throw new Error("human-observed handoff adapter profile changed during recovery");
17984
+ }
17985
+ return;
17986
+ }
16386
17987
  const operation = transition.operation === "resume_thread"
16387
17988
  ? {
16388
17989
  kind: "resume_thread",
@@ -16407,6 +18008,49 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
16407
18008
  !terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.conversationId, terminal.terminalControl)) {
16408
18009
  throw new Error("verified after_binding no longer matches the terminal or pid");
16409
18010
  }
18011
+ if (transition.operation === "adopt_external_thread") {
18012
+ if (transition.origin !== "human_observed" ||
18013
+ transition.terminal_input_sent !== false ||
18014
+ !transition.source_session_id ||
18015
+ !transition.before_binding) {
18016
+ throw new Error("human-observed handoff recovery evidence is incomplete");
18017
+ }
18018
+ const storeDir = storeDirFromOptions(options);
18019
+ const source = loadManagedSession(storeDir, transition.source_session_id);
18020
+ const resolved = await resolveCurrentNativeAgentSessionIdentity({
18021
+ options,
18022
+ agent: terminal.agent,
18023
+ pid: terminal.pid,
18024
+ cwd: terminal.terminalControl.currentPath,
18025
+ preferredSessionId: binding.native_thread_id,
18026
+ allowedCompanionIdentity: codexIdentityFence({
18027
+ sessionId: transition.before_native_thread_id,
18028
+ processUuid: transition.before_process_uuid,
18029
+ processBirth: transition.before_process_birth,
18030
+ rollout: transition.before_process_rollout,
18031
+ evidence: transition.before_binding.native_process.evidence
18032
+ }),
18033
+ allowedAdditionalIdentities: []
18034
+ });
18035
+ const observed = await observedExternalHandoffIdentity({
18036
+ options,
18037
+ terminal,
18038
+ sourceSession: source,
18039
+ resolvedIdentity: resolved
18040
+ });
18041
+ if (!observed.identity) {
18042
+ throw new Error("human-observed handoff target identity is unavailable during recovery");
18043
+ }
18044
+ const exact = exactLifecycleProcessIdentity(terminal, observed.identity);
18045
+ if (exact.sessionId.toLowerCase() !== binding.native_thread_id.toLowerCase() ||
18046
+ exact.processUuid !== binding.native_process.process_uuid ||
18047
+ exact.processBirth !== binding.native_process.process_birth ||
18048
+ JSON.stringify(exact.rollout ?? null) !==
18049
+ JSON.stringify(binding.native_process.rollout ?? null)) {
18050
+ throw new Error("human-observed handoff target identity changed during recovery");
18051
+ }
18052
+ return;
18053
+ }
16410
18054
  const bridge = createTerminalAgentBridge(options);
16411
18055
  const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
16412
18056
  if (!status.reachable ||
@@ -16481,6 +18125,9 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
16481
18125
  }
16482
18126
  }
16483
18127
  async function reconcileUncertainLifecycleTransition({ options, terminal, transition, storeDir, now }) {
18128
+ if (transition.operation === "adopt_external_thread") {
18129
+ throw new Error("uncertain human-observed handoff remains quarantined because recovery cannot send terminal input");
18130
+ }
16484
18131
  const live = terminal.agent === "claude"
16485
18132
  ? await probeManualClaudeLifecycleRecoveryIdentity({
16486
18133
  options,
@@ -16523,15 +18170,18 @@ async function reconcileUncertainLifecycleTransition({ options, terminal, transi
16523
18170
  }, { expectedTransitionId: transition.transition_id });
16524
18171
  return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
16525
18172
  }
16526
- if (transition.operation !== "resume_thread" ||
18173
+ if (!["resume_thread", "adopt_external_thread"].includes(transition.operation) ||
16527
18174
  live.sessionId !== transition.target_native_thread_id) {
16528
18175
  throw new Error("fresh native thread identity matches neither the recorded before identity " +
16529
18176
  "nor a durably known exact after identity");
16530
18177
  }
16531
- if (terminal.agent === "codex" && !live.rollout) {
18178
+ if (transition.operation === "resume_thread" &&
18179
+ terminal.agent === "codex" &&
18180
+ !live.rollout) {
16532
18181
  throw new Error("Codex resume recovery requires an exact live rollout incarnation");
16533
18182
  }
16534
- if (terminal.agent === "codex") {
18183
+ if (transition.operation === "resume_thread" &&
18184
+ terminal.agent === "codex") {
16535
18185
  assertResumedCodexRolloutMatchesCandidate(live, transition.target_candidate_file_identity);
16536
18186
  }
16537
18187
  const targetAtPrepare = tryLoadManagedSession(storeDir, transition.target_session_id);
@@ -16819,7 +18469,9 @@ async function probeManualCodexLifecycleRecoveryIdentity({ options, terminal, tr
16819
18469
  ? "no_rollout"
16820
18470
  : "invalid"
16821
18471
  : classifyCodexLifecyclePostcondition({
16822
- operation: transition.operation,
18472
+ operation: transition.operation === "resume_thread"
18473
+ ? "resume_thread"
18474
+ : "new_thread",
16823
18475
  parsedNativeThreadId: observed.nativeThreadId,
16824
18476
  observationSucceeded: true,
16825
18477
  observedIdentity: resolved,
@@ -16997,14 +18649,7 @@ function claudeComposerVisible(screen) {
16997
18649
  .some((line) => /^\s*❯(?:\s|$)/u.test(line));
16998
18650
  }
16999
18651
  function claudeComposerEmpty(screen) {
17000
- const lines = String(screen ?? "").split(/\r?\n/u);
17001
- while (lines.length > 0 && lines.at(-1)?.trim() === "") {
17002
- lines.pop();
17003
- }
17004
- const composers = lines
17005
- .slice(-8)
17006
- .filter((line) => /^\s*❯(?:\s|$)/u.test(line));
17007
- return composers.length === 1 && /^\s*❯\s*$/u.test(composers[0]);
18652
+ return isExactClaudeIdleComposer(String(screen ?? ""));
17008
18653
  }
17009
18654
  function assertManualLifecycleProcessIncarnation(transition, live) {
17010
18655
  if (live.processUuid !== transition.before_process_uuid ||
@@ -17118,7 +18763,9 @@ function commitVerifiedLifecycleTransition(storeDir, transition, now) {
17118
18763
  status: "bound",
17119
18764
  binding: afterBinding,
17120
18765
  lineage: targetExisting?.lineage ?? {
17121
- created_by: transition.operation,
18766
+ created_by: transition.operation === "adopt_external_thread"
18767
+ ? "human_observed"
18768
+ : transition.operation,
17122
18769
  previous_session_id: transition.source_session_id,
17123
18770
  resumed_from_native_thread_id: transition.operation === "resume_thread"
17124
18771
  ? transition.target_native_thread_id
@@ -20368,7 +22015,7 @@ function usage() {
20368
22015
  agent-knock-knock delegate --request <text> [--agent ${agentList}] [--workspace <path>] [--store-dir <dir>]
20369
22016
  agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all] [--reconcile] [--no-approval-scan] [--terminal-debug]
20370
22017
  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>]
22018
+ 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
22019
  agent-knock-knock new-thread --terminal <exact-terminal-id> --expected-binding-token <token>
20373
22020
  agent-knock-knock clear-thread --terminal <exact-terminal-id> --expected-binding-token <token>
20374
22021
  agent-knock-knock list-resumable-threads --terminal <exact-terminal-id> [--selection-scope <opaque-scope>]
@@ -20381,7 +22028,7 @@ function usage() {
20381
22028
  agent-knock-knock cancel [--turn <turn-id|selector>] [--conversation <selector>]
20382
22029
  agent-knock-knock renew [--turn <turn-id|selector>] [--conversation <selector>]
20383
22030
  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>]
22031
+ 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
22032
  agent-knock-knock install-openclaw [--verify] [--openclaw-bin <path>] [--skill-path <path>] [--skill-only] [--no-restart]
20386
22033
  agent-knock-knock doctor [--openclaw-bin <path>] [--tmux-bin <path>] [--herdr-bin <path>]
20387
22034
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]