@scotthuang/agent-knock-knock 0.12.2 → 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.
@@ -26,7 +26,7 @@ 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;
@@ -3269,6 +3283,71 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3269
3283
  })
3270
3284
  : undefined;
3271
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;
3272
3351
  const reconcileBindingAction = mutationsAllowed &&
3273
3352
  discoveredOwnership.state === "none" &&
3274
3353
  unresolvedSessionClaims.length === 0 &&
@@ -3300,6 +3379,7 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3300
3379
  unresolvedSessionClaims.length === 0 &&
3301
3380
  soleBindingConflict?.kind === "live_external_thread_change" &&
3302
3381
  terminal.activity_state === "idle" &&
3382
+ automatedInputComposerReady === true &&
3303
3383
  !(isRecord(terminal.approval_state) &&
3304
3384
  terminal.approval_state.blocked === true) &&
3305
3385
  !conflictingSessionTurns.some((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status)) &&
@@ -3455,6 +3535,19 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3455
3535
  }
3456
3536
  }
3457
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
+ : {}),
3458
3551
  ...(reconcileBindingAction
3459
3552
  ? { reconcile_binding: reconcileBindingAction }
3460
3553
  : {})
@@ -3482,8 +3575,19 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3482
3575
  lifecycle_binding_token: managedSessionBindingToken(authoritativeSession)
3483
3576
  }
3484
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;
3485
3589
  return {
3486
- ...terminal,
3590
+ ...publicTerminal,
3487
3591
  ...authoritativeIdentity,
3488
3592
  management_state: ownership.state === "conflict"
3489
3593
  ? "conflict"
@@ -3491,15 +3595,23 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3491
3595
  ? "managed"
3492
3596
  : "unmanaged",
3493
3597
  ...(ownership.state === "conflict"
3494
- ? { management_conflict: ownership.conflict }
3598
+ ? { management_conflict: publicManagementConflict }
3495
3599
  : {}),
3496
3600
  ...(externalHandoffDetected
3497
3601
  ? {
3498
3602
  handoff_state: externalHandoffAdoptable
3499
3603
  ? "external_handoff_adoptable"
3500
- : "external_handoff_blocked"
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
+ : {})
3501
3610
  }
3502
3611
  : {}),
3612
+ ...(verifiedEmptyCodexSnapshotToken
3613
+ ? { handoff_state: "verified_empty_native_session_adoptable" }
3614
+ : {}),
3503
3615
  ...(handoffDecision ? { handoff_decision: handoffDecision } : {}),
3504
3616
  managed: management,
3505
3617
  available_actions: availableActions
@@ -4199,13 +4311,13 @@ function managedListApprovalState(conversation) {
4199
4311
  }
4200
4312
  function listActionContracts() {
4201
4313
  return {
4202
- version: 8,
4314
+ version: 9,
4203
4315
  instructions: [
4204
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.",
4205
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.",
4206
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.",
4207
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.",
4208
- "A verified, idle human native-thread switch may expose a terminal-scoped send with expected_terminal_token; that action atomically adopts the live context before creating its Turn. Other binding conflicts remain fail-closed and may expose only exact low-level reconcile_binding recovery.",
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.",
4209
4321
  "List resumable threads before resume; use only a complete native_thread_id and the action returned for that candidate.",
4210
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.",
4211
4323
  "Use respond only for an in-flight turn that is explicitly waiting for OpenClaw.",
@@ -4239,6 +4351,17 @@ function listActionContracts() {
4239
4351
  meaning: "currently_safe_actions",
4240
4352
  authoritative_for_tool_calls: true
4241
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
+ },
4242
4365
  handoff_decision: {
4243
4366
  meaning: "an explicit human choice required before superseding an active source Turn",
4244
4367
  authoritative_action_path: "terminals[].handoff_decision.choices.take_over_current.action",
@@ -4262,7 +4385,7 @@ function listActionContracts() {
4262
4385
  "agentHardTimeoutMinutes"
4263
4386
  ],
4264
4387
  unsupported: ["timeoutSeconds"],
4265
- ordinary_use: "Create a new managed turn in the exact Session. A live terminal selector can attach an unmanaged pane or adopt one verified human-selected native context; an explicit session_id never follows the pane."
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."
4266
4389
  },
4267
4390
  new_thread: {
4268
4391
  tool: "agent_knock_knock_new_thread",
@@ -5652,6 +5775,218 @@ async function reattachManagedSessionForNativeIdentity({ options, terminal, iden
5652
5775
  }, { expectedRevision: managedSessionRevision(existing) });
5653
5776
  }
5654
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
+ }
5655
5990
  function observedHandoffAuthorityToken({ terminal, identity, sourceSession, target }) {
5656
5991
  const exact = exactLifecycleProcessIdentity(terminal, identity);
5657
5992
  const terminalToken = unmanagedTerminalBindingToken({
@@ -9229,14 +9564,14 @@ async function runSend(options) {
9229
9564
  })) {
9230
9565
  return;
9231
9566
  }
9232
- const claimedSession = soleBoundManagedSessionClaimForTerminal(rawStoreDir, terminalConversation);
9233
- const knownCodexCompanions = claimedSession
9567
+ let claimedSession = soleBoundManagedSessionClaimForTerminal(rawStoreDir, terminalConversation);
9568
+ let knownCodexCompanions = claimedSession
9234
9569
  ? codexAllowedCompanionSetForManagedSession({
9235
9570
  storeDir: rawStoreDir,
9236
9571
  session: claimedSession
9237
9572
  })
9238
9573
  : { additional: [] };
9239
- let currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
9574
+ const nativeIdentityObservation = await observeCurrentNativeAgentSessionIdentity({
9240
9575
  options,
9241
9576
  agent: terminalConversation.agent,
9242
9577
  pid: terminalConversation.pid,
@@ -9247,6 +9582,26 @@ async function runSend(options) {
9247
9582
  allowedCompanionIdentity: knownCodexCompanions.primary,
9248
9583
  allowedAdditionalIdentities: knownCodexCompanions.additional
9249
9584
  });
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
+ }
9250
9605
  const physicalNativeIdentityBeforeHandoff = currentNativeIdentity;
9251
9606
  const handoff = await maybeAdoptObservedExternalThread({
9252
9607
  options,
@@ -9415,7 +9770,8 @@ async function runSend(options) {
9415
9770
  terminal: terminalConversation,
9416
9771
  transition: handoff.transition
9417
9772
  }
9418
- : undefined
9773
+ : undefined,
9774
+ verifiedEmptyCodexHandoff: verifiedEmptyHandoff?.boundary
9419
9775
  });
9420
9776
  }
9421
9777
  finally {
@@ -10374,7 +10730,7 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
10374
10730
  releaseTerminalLock();
10375
10731
  }
10376
10732
  }
10377
- async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, terminalSendLockHeld = false, terminalStateLockHeld = false, storeWriterLeaseHeld = false, recordMessageAfterSend = false, recordRawAttachmentAfterSend = false, onTerminalPreflightVerified = undefined, allowedPreMaterializationIdentity = undefined, allowedAdditionalIdentities = [], observedHandoff = undefined, continuingTurnResponse = false }) {
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 }) {
10378
10734
  const bridge = terminalBridgeEnabled(conversation);
10379
10735
  if (!terminalSendLockHeld) {
10380
10736
  const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
@@ -10397,6 +10753,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10397
10753
  allowedPreMaterializationIdentity,
10398
10754
  allowedAdditionalIdentities,
10399
10755
  observedHandoff,
10756
+ verifiedEmptyCodexHandoff,
10400
10757
  continuingTurnResponse
10401
10758
  });
10402
10759
  }
@@ -10437,6 +10794,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10437
10794
  allowedPreMaterializationIdentity,
10438
10795
  allowedAdditionalIdentities,
10439
10796
  observedHandoff,
10797
+ verifiedEmptyCodexHandoff,
10440
10798
  continuingTurnResponse
10441
10799
  });
10442
10800
  }
@@ -10464,6 +10822,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10464
10822
  allowedPreMaterializationIdentity,
10465
10823
  allowedAdditionalIdentities,
10466
10824
  observedHandoff,
10825
+ verifiedEmptyCodexHandoff,
10467
10826
  continuingTurnResponse
10468
10827
  }));
10469
10828
  }
@@ -10645,7 +11004,18 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10645
11004
  scrollbackLines: Number(options.scrollbackLines ?? 120),
10646
11005
  runtime: preSendRuntime
10647
11006
  });
10648
- 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
+ }
10649
11019
  if (executor.kind === "codex" && needsPostSendNativeBinding) {
10650
11020
  if (pendingManagedNativeBinding || allowedPreMaterializationIdentity) {
10651
11021
  const expectedForegroundId = stringValue(sendTakeover?.terminal_agent_expected_session_id);
@@ -11015,7 +11385,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
11015
11385
  try {
11016
11386
  await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
11017
11387
  runtime: preSendRuntime,
11018
- requireExactComposerBeforeEnter: observedHandoff !== undefined,
11388
+ requireExactComposerBeforeEnter: observedHandoff !== undefined ||
11389
+ verifiedEmptyCodexHandoff !== undefined,
11019
11390
  beforeText: observedHandoff
11020
11391
  ? async () => {
11021
11392
  await assertObservedHandoffTransportBoundary({
@@ -11025,7 +11396,15 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
11025
11396
  requireEmptyComposer: true
11026
11397
  });
11027
11398
  }
11028
- : undefined,
11399
+ : verifiedEmptyCodexHandoff
11400
+ ? async () => {
11401
+ await assertVerifiedEmptyCodexTransportBoundary({
11402
+ options,
11403
+ boundary: verifiedEmptyCodexHandoff,
11404
+ requireEmptyComposer: true
11405
+ });
11406
+ }
11407
+ : undefined,
11029
11408
  beforeEnter: observedHandoff
11030
11409
  ? async () => {
11031
11410
  await assertObservedHandoffTransportBoundary({
@@ -11035,7 +11414,15 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
11035
11414
  requireEmptyComposer: false
11036
11415
  });
11037
11416
  }
11038
- : undefined,
11417
+ : verifiedEmptyCodexHandoff
11418
+ ? async () => {
11419
+ await assertVerifiedEmptyCodexTransportBoundary({
11420
+ options,
11421
+ boundary: verifiedEmptyCodexHandoff,
11422
+ requireEmptyComposer: false
11423
+ });
11424
+ }
11425
+ : undefined,
11039
11426
  async onTransportStage(event) {
11040
11427
  const stageAt = cliNow().toISOString();
11041
11428
  if (event.stage === "text_injected") {
@@ -11084,6 +11471,13 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
11084
11471
  requireEmptyComposer: false
11085
11472
  });
11086
11473
  }
11474
+ if (event.stage === "text_injected" && verifiedEmptyCodexHandoff) {
11475
+ await assertVerifiedEmptyCodexTransportBoundary({
11476
+ options,
11477
+ boundary: verifiedEmptyCodexHandoff,
11478
+ requireEmptyComposer: false
11479
+ });
11480
+ }
11087
11481
  try {
11088
11482
  appendEvent(logPath, {
11089
11483
  ts: stageAt,
@@ -12523,6 +12917,29 @@ async function resolveCurrentNativeAgentSessionIdentity({ options, agent, pid, c
12523
12917
  evidence: "claude_agents_exact_pid"
12524
12918
  };
12525
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
+ }
12526
12943
  function nativeAgentIdentityMatchesTurn(conversation, currentIdentity) {
12527
12944
  const takeover = isRecord(conversation.native_session_takeover)
12528
12945
  ? conversation.native_session_takeover
@@ -18232,14 +18649,7 @@ function claudeComposerVisible(screen) {
18232
18649
  .some((line) => /^\s*❯(?:\s|$)/u.test(line));
18233
18650
  }
18234
18651
  function claudeComposerEmpty(screen) {
18235
- const lines = String(screen ?? "").split(/\r?\n/u);
18236
- while (lines.length > 0 && lines.at(-1)?.trim() === "") {
18237
- lines.pop();
18238
- }
18239
- const composers = lines
18240
- .slice(-8)
18241
- .filter((line) => /^\s*❯(?:\s|$)/u.test(line));
18242
- return composers.length === 1 && /^\s*❯\s*$/u.test(composers[0]);
18652
+ return isExactClaudeIdleComposer(String(screen ?? ""));
18243
18653
  }
18244
18654
  function assertManualLifecycleProcessIncarnation(transition, live) {
18245
18655
  if (live.processUuid !== transition.before_process_uuid ||