@scotthuang/agent-knock-knock 0.12.7 → 0.12.8

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.
@@ -1425,7 +1425,7 @@ function isTerminalControlCapability(value) {
1425
1425
  }
1426
1426
  function assertSafeAbortedTerminalRetryBinding({ owner, receipt, storeDir, terminalControl, messageId }) {
1427
1427
  if (!(receipt.status === "aborted" && receipt.safe_to_retry === true)) {
1428
- return;
1428
+ return undefined;
1429
1429
  }
1430
1430
  const sessionId = sessionIdForConversation(owner);
1431
1431
  const managedSession = tryLoadManagedSession(storeDir, sessionId);
@@ -1441,26 +1441,192 @@ function assertSafeAbortedTerminalRetryBinding({ owner, receipt, storeDir, termi
1441
1441
  stringValue(ownerTakeover?.terminal_agent_session_id) ??
1442
1442
  stringValue(ownerTakeover?.terminal_agent_expected_session_id);
1443
1443
  const ownerAgentPid = Number(ownerTakeover?.terminal_agent_pid);
1444
- if (!managedSession ||
1445
- managedSession.status !== "bound" ||
1446
- !binding ||
1447
- !receiptBindingId ||
1448
- !Number.isSafeInteger(receiptBindingGeneration) ||
1449
- !receiptNativeThreadId ||
1450
- receiptBindingId !== stringValue(owner.terminal_binding_id) ||
1451
- receiptBindingGeneration !== Number(owner.terminal_binding_generation) ||
1452
- receiptNativeThreadId !== ownerNativeThreadId ||
1453
- binding.binding_id !== receiptBindingId ||
1454
- binding.generation !== receiptBindingGeneration ||
1455
- binding.native_thread_id !== receiptNativeThreadId ||
1456
- !Number.isSafeInteger(ownerAgentPid) ||
1457
- binding.native_process.pid !== ownerAgentPid ||
1458
- !ownerControl ||
1459
- !terminalControlsShareIncarnation(ownerControl, terminalControl) ||
1460
- !terminalControlsShareIncarnation(binding.terminal_control, terminalControl)) {
1444
+ if (managedSession &&
1445
+ managedSession.status === "bound" &&
1446
+ binding &&
1447
+ receiptBindingId &&
1448
+ Number.isSafeInteger(receiptBindingGeneration) &&
1449
+ receiptNativeThreadId &&
1450
+ receiptBindingId === stringValue(owner.terminal_binding_id) &&
1451
+ receiptBindingGeneration === Number(owner.terminal_binding_generation) &&
1452
+ receiptNativeThreadId === ownerNativeThreadId &&
1453
+ binding.binding_id === receiptBindingId &&
1454
+ binding.generation === receiptBindingGeneration &&
1455
+ binding.native_thread_id === receiptNativeThreadId &&
1456
+ Number.isSafeInteger(ownerAgentPid) &&
1457
+ binding.native_process.pid === ownerAgentPid &&
1458
+ ownerControl &&
1459
+ terminalControlsShareIncarnation(ownerControl, terminalControl) &&
1460
+ terminalControlsShareIncarnation(binding.terminal_control, terminalControl)) {
1461
+ return managedSession;
1462
+ }
1463
+ const recoveredSource = safeAbortedDeferredRetrySourceSession({
1464
+ owner,
1465
+ receipt,
1466
+ storeDir,
1467
+ terminalControl,
1468
+ messageId
1469
+ });
1470
+ if (!recoveredSource) {
1461
1471
  throw new Error(`terminal idempotency key ${messageId} belongs to a safe-aborted Turn ` +
1462
1472
  "whose Session binding is no longer current; no terminal input was sent");
1463
1473
  }
1474
+ return recoveredSource;
1475
+ }
1476
+ function safeAbortedDeferredRetrySourceSession({ owner, receipt, storeDir, terminalControl, messageId }) {
1477
+ const takeover = isRecord(owner.native_session_takeover)
1478
+ ? owner.native_session_takeover
1479
+ : undefined;
1480
+ const transferId = stringValue(takeover?.deferred_foreground_transfer_id);
1481
+ const ownerControl = terminalControlFromTakeover(takeover);
1482
+ if (!transferId || !takeover || !ownerControl) {
1483
+ return undefined;
1484
+ }
1485
+ const transfer = loadDeferredForegroundTransfer(storeDir, transferId);
1486
+ const target = tryLoadManagedSession(storeDir, transfer.target_session_id);
1487
+ const source = tryLoadManagedSession(storeDir, transfer.source_session_id);
1488
+ const submission = terminalBridgeSubmission(owner);
1489
+ const matchingReceipts = terminalBridgeSubmissionReceipts(owner).filter((candidate) => stringValue(candidate.message_id) === messageId);
1490
+ const canonical = pathsForConversation(owner.conversation_id, storeDir);
1491
+ const targetBinding = transfer.abort_target_after_binding;
1492
+ const sourceBinding = transfer.abort_source_after_binding;
1493
+ const transferDispatchStartedAt = stringValue(transfer.dispatch_started_at);
1494
+ const terminalInputNotStartedAt = stringValue(transfer.terminal_input_not_started_at);
1495
+ const abortedBeforeDispatchIntent = transfer.input_stage === "none" &&
1496
+ transferDispatchStartedAt === undefined &&
1497
+ terminalInputNotStartedAt === undefined;
1498
+ const dispatchIntentProvedNotStarted = transfer.input_stage === "dispatch_started" &&
1499
+ transferDispatchStartedAt !== undefined &&
1500
+ terminalInputNotStartedAt !== undefined &&
1501
+ validTimestampMs(transferDispatchStartedAt) &&
1502
+ validTimestampMs(terminalInputNotStartedAt) &&
1503
+ Date.parse(terminalInputNotStartedAt) >=
1504
+ Date.parse(transferDispatchStartedAt);
1505
+ const forbiddenInputEvidence = [
1506
+ "text_injected_at",
1507
+ "enter_dispatched_at",
1508
+ "submitted_at",
1509
+ "agent_accepted_at",
1510
+ "not_accepted_at",
1511
+ "uncertain_at",
1512
+ "acceptance_evidence"
1513
+ ];
1514
+ if (transfer.version !== 2 ||
1515
+ transfer.status !== "abort_resolved" ||
1516
+ (!abortedBeforeDispatchIntent && !dispatchIntentProvedNotStarted) ||
1517
+ transfer.text_injected_at !== undefined ||
1518
+ transfer.enter_dispatched_at !== undefined ||
1519
+ transfer.agent_accepted_at !== undefined ||
1520
+ transfer.target_session_id !== sessionIdForConversation(owner) ||
1521
+ transfer.turn_id !== turnIdForConversation(owner) ||
1522
+ transfer.turn_id !== owner.conversation_id ||
1523
+ transfer.message_id !== messageId ||
1524
+ transfer.terminal_id !== stringValue(takeover.native_session_id) ||
1525
+ transfer.process_pid !== Number(takeover.terminal_agent_pid) ||
1526
+ transfer.process_uuid !== stringValue(takeover.terminal_agent_process_uuid) ||
1527
+ transfer.process_birth !== stringValue(takeover.terminal_agent_process_birth) ||
1528
+ path.resolve(transfer.workspace) !== path.resolve(owner.workspace) ||
1529
+ !terminalControlsShareIncarnation(ownerControl, terminalControl) ||
1530
+ !terminalControlEvidenceMatches(transfer.terminal_endpoint, terminalControl) ||
1531
+ owner.status !== "failed" ||
1532
+ isRecord(owner.callback_delivery) ||
1533
+ isRecord(owner.terminal_bridge_completion_claim) ||
1534
+ isRecord(takeover.terminal_bridge_completion_claim) ||
1535
+ stringValue(takeover.terminal_bridge_message_id) !== messageId ||
1536
+ stringValue(takeover.terminal_bridge_request_hash) !==
1537
+ transfer.request_hash ||
1538
+ stringValue(takeover.terminal_bridge_request_hash) !==
1539
+ stringValue(receipt.request_hash) ||
1540
+ stringValue(takeover.deferred_foreground_transfer_id) !==
1541
+ transfer.transfer_id ||
1542
+ path.resolve(stringValue(owner.state_path) ?? "") !==
1543
+ path.resolve(canonical.statePath) ||
1544
+ path.resolve(stringValue(owner.event_log_path) ?? "") !==
1545
+ path.resolve(canonical.logPath) ||
1546
+ path.resolve(managedSessionStoreDirForConversation(owner) ?? "") !==
1547
+ path.resolve(storeDir) ||
1548
+ !submission ||
1549
+ matchingReceipts.length !== 1 ||
1550
+ canonicalJson(matchingReceipts[0]) !== canonicalJson(submission) ||
1551
+ canonicalJson(submission) !== canonicalJson(receipt) ||
1552
+ submission.status !== "aborted" ||
1553
+ submission.safe_to_retry !== true ||
1554
+ stringValue(submission.last_proven_stage) !== "prepared" ||
1555
+ !validTimestampMs(submission.prepared_at) ||
1556
+ !validTimestampMs(submission.aborted_at) ||
1557
+ Date.parse(String(submission.aborted_at)) <
1558
+ Date.parse(String(submission.prepared_at)) ||
1559
+ (dispatchIntentProvedNotStarted &&
1560
+ stringValue(submission.aborted_at) !== terminalInputNotStartedAt) ||
1561
+ forbiddenInputEvidence.some((field) => submission[field] !== undefined) ||
1562
+ stringValue(submission.session_id) !== transfer.target_session_id ||
1563
+ stringValue(submission.turn_id) !== transfer.turn_id ||
1564
+ stringValue(submission.message_id) !== transfer.message_id ||
1565
+ stringValue(submission.request_hash) !== transfer.request_hash ||
1566
+ stringValue(submission.binding_id) !==
1567
+ transfer.target_before_binding?.binding_id ||
1568
+ Number(submission.binding_generation) !==
1569
+ transfer.target_before_binding?.generation ||
1570
+ stringValue(submission.native_thread_id) !== undefined ||
1571
+ !target ||
1572
+ target.status !== "detached" ||
1573
+ target.last_transition_id !== transfer.transfer_id ||
1574
+ target.lineage.transition_id !== transfer.transfer_id ||
1575
+ target.lineage.previous_session_id !== transfer.source_session_id ||
1576
+ transfer.abort_target_after_status !== "detached" ||
1577
+ !targetBinding ||
1578
+ managedSessionRevision(target) !== transfer.abort_target_after_revision ||
1579
+ managedSessionBindingToken(target) !==
1580
+ transfer.abort_target_after_binding_token ||
1581
+ JSON.stringify(target.binding) !== JSON.stringify(targetBinding) ||
1582
+ !source ||
1583
+ source.status !== "bound" ||
1584
+ source.last_transition_id !== transfer.source_previous_last_transition_id ||
1585
+ transfer.abort_source_after_status !== "bound" ||
1586
+ !sourceBinding ||
1587
+ managedSessionRevision(source) !== transfer.abort_source_after_revision ||
1588
+ managedSessionBindingToken(source) !==
1589
+ transfer.abort_source_after_binding_token ||
1590
+ JSON.stringify(source.binding) !== JSON.stringify(sourceBinding) ||
1591
+ JSON.stringify(sourceBinding) !==
1592
+ JSON.stringify(transfer.source_before_binding)) {
1593
+ return undefined;
1594
+ }
1595
+ const ledger = loadTerminalBridgeDispatchLedger(terminalControl);
1596
+ if (!ledger) {
1597
+ return undefined;
1598
+ }
1599
+ assertDeferredForegroundResolvedZeroInputLedger({
1600
+ storeDir,
1601
+ terminal: { terminalControl },
1602
+ transfer,
1603
+ ledger,
1604
+ statePath: canonical.statePath
1605
+ });
1606
+ if (dispatchIntentProvedNotStarted &&
1607
+ stringValue(ledger.aborted_at) !== terminalInputNotStartedAt) {
1608
+ return undefined;
1609
+ }
1610
+ return source;
1611
+ }
1612
+ function exactSafeAbortedRecoveredSessionMatches({ owner, receipt, storeDir, terminalControl, messageId, expectedSessionId }) {
1613
+ const exactReceipt = receipt ?? (() => {
1614
+ const matches = terminalBridgeSubmissionReceipts(owner).filter((candidate) => stringValue(candidate.message_id) === messageId);
1615
+ return matches.length === 1 ? matches[0] : undefined;
1616
+ })();
1617
+ if (!exactReceipt ||
1618
+ exactReceipt.status !== "aborted" ||
1619
+ exactReceipt.safe_to_retry !== true) {
1620
+ return false;
1621
+ }
1622
+ const recoveredSession = assertSafeAbortedTerminalRetryBinding({
1623
+ owner,
1624
+ receipt: exactReceipt,
1625
+ storeDir,
1626
+ terminalControl,
1627
+ messageId
1628
+ });
1629
+ return recoveredSession?.session_id === expectedSessionId;
1464
1630
  }
1465
1631
  function stableDelegateTerminalRoute({ options, request, workspace, requestedAgent }) {
1466
1632
  const messageId = stringValue(options.messageId);
@@ -1548,23 +1714,38 @@ function stableDelegateTerminalRoute({ options, request, workspace, requestedAge
1548
1714
  }
1549
1715
  if (selected.receipt.status === "aborted" &&
1550
1716
  selected.receipt.safe_to_retry === true) {
1551
- const sessionId = sessionIdForConversation(selected.owner);
1552
1717
  const ownerControl = terminalControlFromTakeover(isRecord(selected.owner.native_session_takeover)
1553
1718
  ? selected.owner.native_session_takeover
1554
1719
  : undefined);
1555
1720
  if (!ownerControl) {
1556
1721
  throw new Error(`terminal idempotency key ${messageId} has no durable terminal route`);
1557
1722
  }
1558
- assertSafeAbortedTerminalRetryBinding({
1723
+ const retrySession = assertSafeAbortedTerminalRetryBinding({
1559
1724
  owner: selected.owner,
1560
1725
  receipt: selected.receipt,
1561
1726
  storeDir,
1562
1727
  terminalControl: ownerControl,
1563
1728
  messageId
1564
1729
  });
1730
+ if (!retrySession) {
1731
+ throw new Error(`terminal idempotency key ${messageId} has no restored retry Session`);
1732
+ }
1733
+ if (retrySession.agent === "codex" &&
1734
+ isCompleteNativeRollout(retrySession.binding?.native_process.rollout)) {
1735
+ // A safe-aborted retry has proved that the original binding is unchanged,
1736
+ // but one open Codex rollout still does not prove the TUI foreground.
1737
+ // Preserve the stable terminal route so runSend captures fresh implicit
1738
+ // candidate authority and retries through the v3 transfer instead of the
1739
+ // forbidden strict Session path.
1740
+ return {
1741
+ kind: "terminal",
1742
+ conversationId: selected.conversationId,
1743
+ workspace: selected.workspace
1744
+ };
1745
+ }
1565
1746
  return {
1566
1747
  kind: "session",
1567
- sessionId,
1748
+ sessionId: retrySession.session_id,
1568
1749
  workspace: selected.workspace
1569
1750
  };
1570
1751
  }
@@ -2103,6 +2284,18 @@ function replayExactActiveTerminalSubmission({ options, terminalControl, request
2103
2284
  const loadedLedgerOwner = loadedLedger && loadedLedgerMessageId === messageId
2104
2285
  ? loadTerminalDispatchLedgerOwner(loadedLedger)
2105
2286
  : undefined;
2287
+ const loadedLedgerSessionId = stringValue(loadedLedger?.session_id);
2288
+ const loadedLedgerMatchesRecoveredSession = Boolean(expectedSessionId &&
2289
+ loadedLedgerSessionId !== undefined &&
2290
+ loadedLedgerSessionId !== expectedSessionId &&
2291
+ loadedLedgerOwner &&
2292
+ exactSafeAbortedRecoveredSessionMatches({
2293
+ owner: loadedLedgerOwner,
2294
+ storeDir: expectedStoreDir,
2295
+ terminalControl,
2296
+ messageId,
2297
+ expectedSessionId
2298
+ }));
2106
2299
  const expectedMessageBodyHash = createHash("sha256")
2107
2300
  .update(requestText)
2108
2301
  .digest("hex");
@@ -2136,8 +2329,9 @@ function replayExactActiveTerminalSubmission({ options, terminalControl, request
2136
2329
  (!terminalDispatchRecordMatchesControl(loadedLedger, terminalControl) ||
2137
2330
  (loadedLedgerStoreDir !== undefined &&
2138
2331
  path.resolve(loadedLedgerStoreDir) !== path.resolve(expectedStoreDir)) ||
2139
- (expectedSessionId && stringValue(loadedLedger.session_id) !== undefined &&
2140
- stringValue(loadedLedger.session_id) !== expectedSessionId) ||
2332
+ (expectedSessionId && loadedLedgerSessionId !== undefined &&
2333
+ loadedLedgerSessionId !== expectedSessionId &&
2334
+ !loadedLedgerMatchesRecoveredSession) ||
2141
2335
  (expectedTurnId && stringValue(loadedLedger.turn_id) !== undefined &&
2142
2336
  stringValue(loadedLedger.turn_id) !== expectedTurnId) ||
2143
2337
  (expectedStatePath && loadedLedgerStatePath !== undefined &&
@@ -2169,6 +2363,7 @@ function replayExactActiveTerminalSubmission({ options, terminalControl, request
2169
2363
  return true;
2170
2364
  }
2171
2365
  if (ledgerReceipt &&
2366
+ !(ledgerReceipt.status === "aborted" && ledgerReceipt.safe_to_retry === true) &&
2172
2367
  (loadedLedgerMessageId !== messageId ||
2173
2368
  !["submitted", "enter_dispatched", "agent_accepted"].includes(String(ledgerReceipt.status)) ||
2174
2369
  !["submitted", "enter_dispatched", "agent_accepted"].includes(String(loadedLedger?.status)))) {
@@ -2326,6 +2521,16 @@ function validateStoredTerminalSubmissionMatch({ owner, receipt, options, termin
2326
2521
  const receiptStoreDir = stringValue(receipt.store_dir) ?? ownerStoreDir;
2327
2522
  const receiptSessionId = stringValue(receipt.session_id) ??
2328
2523
  sessionIdForConversation(owner);
2524
+ const receiptMatchesRecoveredSession = Boolean(expectedSessionId &&
2525
+ receiptSessionId !== expectedSessionId &&
2526
+ exactSafeAbortedRecoveredSessionMatches({
2527
+ owner,
2528
+ receipt,
2529
+ storeDir: expectedStoreDir,
2530
+ terminalControl,
2531
+ messageId,
2532
+ expectedSessionId
2533
+ }));
2329
2534
  const receiptTurnId = stringValue(receipt.turn_id) ??
2330
2535
  turnIdForConversation(owner);
2331
2536
  const receiptOpenClawSession = stringValue(receipt.openclaw_session) ??
@@ -2352,7 +2557,8 @@ function validateStoredTerminalSubmissionMatch({ owner, receipt, options, termin
2352
2557
  !terminalControlEvidenceMatches(receiptTerminalEvidence, terminalControl) ||
2353
2558
  receiptSessionId !== sessionIdForConversation(owner) ||
2354
2559
  receiptTurnId !== turnIdForConversation(owner) ||
2355
- (expectedSessionId && receiptSessionId !== expectedSessionId) ||
2560
+ (expectedSessionId && receiptSessionId !== expectedSessionId &&
2561
+ !receiptMatchesRecoveredSession) ||
2356
2562
  (expectedTurnId && receiptTurnId !== expectedTurnId) ||
2357
2563
  (expectedStatePath && !sameCanonicalStatePath(owner.state_path, expectedStatePath)) ||
2358
2564
  (requestedOpenClawSession && receiptOpenClawSession !== requestedOpenClawSession) ||
@@ -2427,13 +2633,28 @@ function replayExactStoredTerminalSubmission({ options, terminalControl, request
2427
2633
  const match = matches[0];
2428
2634
  if (!match) {
2429
2635
  for (const { owner, receipt } of validatedMatches) {
2430
- assertSafeAbortedTerminalRetryBinding({
2431
- owner,
2432
- receipt,
2433
- storeDir: expectedStoreDir,
2434
- terminalControl,
2435
- messageId
2436
- });
2636
+ if (expectedSessionId) {
2637
+ if (!exactSafeAbortedRecoveredSessionMatches({
2638
+ owner,
2639
+ receipt,
2640
+ storeDir: expectedStoreDir,
2641
+ terminalControl,
2642
+ messageId,
2643
+ expectedSessionId
2644
+ })) {
2645
+ throw new Error(`terminal idempotency key ${messageId} does not match its ` +
2646
+ "restored retry Session; no terminal input was sent");
2647
+ }
2648
+ }
2649
+ else {
2650
+ assertSafeAbortedTerminalRetryBinding({
2651
+ owner,
2652
+ receipt,
2653
+ storeDir: expectedStoreDir,
2654
+ terminalControl,
2655
+ messageId
2656
+ });
2657
+ }
2437
2658
  }
2438
2659
  // A prepared-stage failure with a restored ledger proves that tmux was
2439
2660
  // untouched. The same exact id may therefore start a fresh attempt; all
@@ -3771,6 +3992,17 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3771
3992
  stringValue(codexLatentClearResumeValue.source_native_thread_id)?.toLowerCase() === deferredCodexSourceNativeThreadId
3772
3993
  ? stringValue(codexLatentClearResumeValue.fingerprint)
3773
3994
  : undefined;
3995
+ if (deferredCodexLatentClearResumeFingerprint) {
3996
+ // The resume hint is useful operational context, but it is not durable
3997
+ // foreground authority: it can scroll away while the latent thread is
3998
+ // still current. Candidate routing and its token rely on the complete
3999
+ // rollout inventory and Store authority below instead.
4000
+ runtimeLog("info", "terminal_codex_latent_clear_hint_observed", {
4001
+ terminal_id: String(terminal.id),
4002
+ source_session_id: deferredCodexSource?.session_id,
4003
+ source_native_thread_id: deferredCodexSourceNativeThreadId
4004
+ });
4005
+ }
3774
4006
  const deferredCodexSourceActiveElsewhere = Boolean(deferredCodexSourceNativeThreadId &&
3775
4007
  terminals.some((candidate) => candidate !== terminal &&
3776
4008
  candidate.agent === "codex" &&
@@ -3848,11 +4080,12 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3848
4080
  "explicitly_abandoned_predecessor"
3849
4081
  }) &&
3850
4082
  candidateSourceTransitionHistoryIsTerminal(storeDir, deferredCodexSource));
3851
- const deferredCodexCandidateRouteNeeded = Boolean(deferredCodexCandidateInventory &&
3852
- (deferredCodexCandidateInventory.status === "unbound" ||
3853
- nativeIdentityObservation?.status === "unavailable" ||
3854
- deferredCodexSource?.binding?.native_process.rollout === undefined ||
3855
- deferredCodexLatentClearResumeFingerprint !== undefined));
4083
+ // A complete one-root inventory proves only which rollout files are
4084
+ // materialized, not which logical thread the Codex TUI currently owns.
4085
+ // `/clear` can select a new thread before its rollout exists, so every
4086
+ // otherwise eligible nonempty inventory uses the same post-submission
4087
+ // candidate-set attribution as a multi-root inventory.
4088
+ const deferredCodexCandidateRouteNeeded = deferredCodexCandidateInventory !== undefined;
3856
4089
  const deferredCodexForegroundEligible = Boolean(mutationsAllowed &&
3857
4090
  !terminalHasNonterminalDeferredTransfer &&
3858
4091
  deferredCodexSource &&
@@ -3899,7 +4132,6 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3899
4132
  sourceSession: deferredCodexSource,
3900
4133
  dispatchSnapshot: deferredCodexDispatchSnapshot,
3901
4134
  sourceTurnHistory: deferredCodexCandidateSourceTurnHistory,
3902
- candidateContextFingerprint: deferredCodexLatentClearResumeFingerprint,
3903
4135
  sourceRolloutAuthority: deferredCodexSourceRolloutAuthority,
3904
4136
  sourceAbandonmentFingerprint: deferredCodexSourceAbandonmentFingerprint,
3905
4137
  ...(deferredCodexCandidateInventory
@@ -4066,7 +4298,11 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
4066
4298
  });
4067
4299
  }
4068
4300
  }
4069
- const terminalCanAcceptSend = ownership.state === "none" && isRecord(sessionAwareRawActions.send);
4301
+ const rolloutBackedCodexSession = Boolean(authoritativeSession?.agent === "codex" &&
4302
+ isCompleteNativeRollout(authoritativeSession.binding?.native_process.rollout));
4303
+ const terminalCanAcceptSend = ownership.state === "none" &&
4304
+ Boolean(deferredCodexForegroundToken ||
4305
+ (!rolloutBackedCodexSession && isRecord(sessionAwareRawActions.send)));
4070
4306
  if (ownership.state === "current" &&
4071
4307
  !allRelated.some((conversation) => conversation.conversation_id === ownership.conversation.conversation_id)) {
4072
4308
  allRelated.push(ownership.conversation);
@@ -4111,7 +4347,8 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
4111
4347
  ? withoutGenericHandoffSourceClose(currentTurnProjection, blockingHandoffTurnIds)
4112
4348
  : undefined;
4113
4349
  const nonOwnerRawActions = authoritativeSession
4114
- ? Object.fromEntries(Object.entries(sessionAwareRawActions).filter(([actionName]) => actionName !== "approve"))
4350
+ ? Object.fromEntries(Object.entries(sessionAwareRawActions).filter(([actionName]) => actionName !== "approve" &&
4351
+ !(rolloutBackedCodexSession && actionName === "send")))
4115
4352
  : sessionAwareRawActions;
4116
4353
  const sortedDisplayed = [...sessionDisplayedRelated]
4117
4354
  .filter((conversation) => conversation.conversation_id !== currentTurn?.conversation_id)
@@ -4229,6 +4466,7 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
4229
4466
  }
4230
4467
  }
4231
4468
  : managedSessionId &&
4469
+ !rolloutBackedCodexSession &&
4232
4470
  sessionBindingMatchesLiveTerminal &&
4233
4471
  isRecord(sessionAwareRawActions.send)
4234
4472
  ? {
@@ -5615,13 +5853,14 @@ function managedListApprovalState(conversation) {
5615
5853
  }
5616
5854
  function listActionContracts() {
5617
5855
  return {
5618
- version: 14,
5856
+ version: 15,
5619
5857
  instructions: [
5620
5858
  "Treat terminals[] as the primary resource and use only actions present in available_actions, except the snapshot-bound terminals[].handoff_decision.choices.take_over_current.action and an exact terminals[].blocking_turns[].recovery_action. Either nested action requires explicit user confirmation; after it succeeds, refresh list before any follow-current send.",
5621
- "An existing managed session's ordinary send targets session_id and creates a new turn. A turn id is never an ordinary send target.",
5859
+ "The session_exact scope uses session_id only when it is prefilled by the listed send action. A rollout-backed managed Codex pane instead uses the terminal_follow_current scope with its exact selector plus expected_terminal_token because even one materialized rollout does not prove the current TUI foreground thread. A turn id is never an ordinary send target.",
5860
+ "A user-explicit raw terminal selector, or a uniquely delegated raw send with no selector, may omit expected_terminal_token for convenience. If that terminal already has one rollout-backed managed Codex source, AKK captures an equivalent fresh candidate authority under the terminal and Store locks and still uses the same v3 follow-current transfer; it never degrades to sole-root strict continuation. Unmanaged first attach retains its existing behavior.",
5622
5861
  "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.",
5623
5862
  "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.",
5624
- "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. A status-card-only zero-rollout source, a quiescent rollout-backed source whose complete pinned open-rollout inventory cannot identify one foreground candidate, or a supported Codex /clear foreground hint observed before its new rollout materializes may also expose this exact action. AKK freezes any released predecessor Turn history, submits the ordinary task once, and binds a separate provisional Session only after one post-anchor rollout uniquely accepts that exact request. The accepted UUID may equal or differ from the predecessor without merging their Session lineages, and narrow panes do not require /status. Until that promotion commits, strict session_id send, respond, approve, cancel, native lifecycle, and native_inspect remain unavailable, and the provisional binding has no callback authority. If dispatch, acceptance, or post-submit binding is uncertain, do not retry automatically. An explicitly closed uncertain Turn may authorize only a future candidate send when its exact resolved close ledger, append-only uncertain receipt, frozen Turn history, and complete current rollout inventory prove that the old bound rollout is absent and every candidate is unclaimed; close never forges the lost callback, and uncertain submissions cannot be renewed. Other binding conflicts remain fail-closed and may expose only exact low-level reconcile_binding recovery.",
5863
+ "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. A status-card-only zero-rollout source or any otherwise eligible quiescent rollout-backed source with a complete nonempty pinned open-rollout inventory may also expose this exact action. One materialized rollout does not prove the current Codex TUI foreground thread, and a /clear resume hint is diagnostic only. AKK freezes any released predecessor Turn history, submits the ordinary task once, and binds a separate provisional Session only after one post-anchor rollout uniquely accepts that exact request. The accepted UUID may equal or differ from the predecessor without merging their Session lineages, and narrow panes do not require /status. Until that promotion commits, strict session_id send, respond, approve, cancel, native lifecycle, and native_inspect remain unavailable, and the provisional binding has no callback authority. If dispatch, acceptance, or post-submit binding is uncertain, do not retry automatically. An explicitly closed uncertain Turn may authorize only a future candidate send when its exact resolved close ledger, append-only uncertain receipt, frozen predecessor history, and complete current rollout inventory prove that the old bound rollout is absent and every candidate is unclaimed; close never forges the lost callback, and uncertain submissions cannot be renewed. Other binding conflicts remain fail-closed and may expose only exact low-level reconcile_binding recovery.",
5625
5864
  "List resumable threads before resume; use only a complete native_thread_id and the action returned for that candidate.",
5626
5865
  "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.",
5627
5866
  "Use respond only for an in-flight turn that is explicitly waiting for OpenClaw.",
@@ -5646,7 +5885,7 @@ function listActionContracts() {
5646
5885
  managed: {
5647
5886
  current_turn: "the authoritative dispatch-ledger owner, never inferred from history",
5648
5887
  recent_turn: "the latest visible non-owning turn in the current managed session",
5649
- session_id: "the continuing agent context and authoritative ordinary-send target",
5888
+ session_id: "the continuing agent context; it is an ordinary-send target only when the listed send action explicitly prefills it, and rollout-backed Codex uses selector/token instead",
5650
5889
  binding_id: "the immutable terminal-binding generation currently authorized for this Session",
5651
5890
  binding_token: "an optimistic concurrency token required by native-thread mutations",
5652
5891
  history: "older turns, present only with --all"
@@ -5684,7 +5923,17 @@ function listActionContracts() {
5684
5923
  tool: "agent_knock_knock_send",
5685
5924
  target_argument: "session_id",
5686
5925
  initial_attach_target_argument: "selector",
5687
- initial_attach_scope: "A discovery selector explicitly named by the user, or the selector prefilled by an unmanaged raw-terminal row's available send action; never infer, guess, or reuse one.",
5926
+ managed_scopes: {
5927
+ session_exact: {
5928
+ target_arguments: ["session_id"],
5929
+ follows_current_terminal: false
5930
+ },
5931
+ terminal_follow_current: {
5932
+ target_arguments: ["selector", "expected_terminal_token"],
5933
+ follows_current_terminal: true
5934
+ }
5935
+ },
5936
+ initial_attach_scope: "A discovery selector explicitly named by the user, the selector prefilled by an unmanaged raw-terminal row's available send action, or an omitted selector delegated only when exactly one eligible raw pane exists; never infer, guess, or reuse a selector or token. For an already managed rollout-backed Codex pane, an explicit or unique raw delegation without a token internally captures fresh v3 candidate authority under lock; it never becomes a strict Session continuation.",
5688
5937
  required: ["request"],
5689
5938
  optional: [
5690
5939
  "selector",
@@ -5696,8 +5945,8 @@ function listActionContracts() {
5696
5945
  ],
5697
5946
  unsupported: ["timeoutSeconds"],
5698
5947
  status_card_only_deferred_scope: "A zero-Turn Codex status-card binding has no rollout; only its listed selector/token send creates an isolated provisional Session and binds it after exact request acceptance. Until promotion commits, strict managed controls, native lifecycle, native_inspect, and callback authority remain unavailable; an uncertain dispatch, acceptance, or post-submit binding must not be retried automatically.",
5699
- candidate_rollout_deferred_scope: "A quiescent rollout-backed Codex source may use a listed selector/token send when AKK pins the complete exact candidate inventory but cannot prove one foreground UUID, or when a supported /clear foreground hint signals that the sole old rollout is no longer sufficient authority. Released predecessor Turn history stays read-only while a separate provisional Session sends once and waits for one unique post-anchor request acceptance. Same-UUID and different-UUID results keep separate Session lineages; zero, multiple, drifted, or uncertain acceptance is never retried blindly. Explicit close can abandon an uncertain receipt for future-send liveness only while the exact resolved close ledger, append-only receipt, frozen history, absent old rollout, and unclaimed candidate set remain authoritative; it never synthesizes callback delivery.",
5700
- 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, detach a verified-empty Codex source, or replace an eligible status-card/candidate-rollout source after the submitted request proves its unique exact rollout; an explicit session_id never follows the pane."
5948
+ candidate_rollout_deferred_scope: "A quiescent rollout-backed Codex source uses a listed selector/token send whenever AKK can pin a complete nonempty candidate inventory. Inventory status resolved means only that one rollout is materialized; it does not prove the current TUI foreground thread. Released predecessor Turn history stays read-only while a separate provisional Session sends once and waits for one unique post-anchor request acceptance. A /clear resume hint is diagnostic only and is not token or routing authority. Same-UUID and different-UUID results keep separate Session lineages; zero, multiple, drifted, or uncertain acceptance is never retried blindly. Explicit close can abandon an uncertain receipt for future-send liveness only while the exact resolved close ledger, append-only receipt, frozen history, absent old rollout, and unclaimed candidate set remain authoritative; it never synthesizes callback delivery.",
5949
+ ordinary_use: "Create a new managed Turn through the exact action listed for the pane. An explicit session_id never follows the pane and is unavailable for rollout-backed Codex Sessions; their listed selector/token action binds only the unique exact rollout that accepts the submitted request. A user-explicit or uniquely delegated raw send without a token receives the same fresh under-lock candidate authority when it resolves to an already managed rollout-backed Codex source. A live terminal selector can also attach an unmanaged pane, adopt one verified human-selected native context, detach a verified-empty Codex source, or replace an eligible status-card/candidate-rollout source."
5701
5950
  },
5702
5951
  new_thread: {
5703
5952
  tool: "agent_knock_knock_new_thread",
@@ -7196,6 +7445,91 @@ function codexCandidateInventoryHasNoOtherManagedClaim({ storeDir, inventory, so
7196
7445
  isExactNativeThreadId(session.binding?.native_thread_id) &&
7197
7446
  candidateIds.has(session.binding.native_thread_id.toLowerCase()));
7198
7447
  }
7448
+ function exactReleasedSafeAbortedCandidateTurn({ storeDir, session, turn }) {
7449
+ const binding = session.binding;
7450
+ const takeover = isRecord(turn.native_session_takeover)
7451
+ ? turn.native_session_takeover
7452
+ : undefined;
7453
+ const submission = terminalBridgeSubmission(turn);
7454
+ const terminalControl = terminalControlFromTakeover(takeover);
7455
+ const messageId = stringValue(submission?.message_id);
7456
+ const preparedAt = stringValue(submission?.prepared_at);
7457
+ const abortedAt = stringValue(submission?.aborted_at);
7458
+ if (session.status !== "bound" ||
7459
+ session.agent !== "codex" ||
7460
+ !binding ||
7461
+ !isExactNativeThreadId(binding.native_thread_id) ||
7462
+ !TERMINAL_DISPATCH_RELEASE_STATUSES.has(turn.status) ||
7463
+ managedTurnNeedsAttention(turn) ||
7464
+ isRecord(turn.callback_delivery) ||
7465
+ isRecord(turn.terminal_bridge_completion_claim) ||
7466
+ !takeover ||
7467
+ isRecord(takeover.terminal_bridge_completion_claim) ||
7468
+ !submission ||
7469
+ !terminalControl ||
7470
+ !messageId ||
7471
+ submission.status !== "aborted" ||
7472
+ submission.safe_to_retry !== true ||
7473
+ stringValue(submission.last_proven_stage) !== "prepared" ||
7474
+ !preparedAt ||
7475
+ !abortedAt ||
7476
+ !validTimestampMs(preparedAt) ||
7477
+ !validTimestampMs(abortedAt) ||
7478
+ Date.parse(abortedAt) < Date.parse(preparedAt) ||
7479
+ [
7480
+ "text_injected_at",
7481
+ "enter_dispatched_at",
7482
+ "submitted_at",
7483
+ "agent_accepted_at",
7484
+ "not_accepted_at",
7485
+ "uncertain_at",
7486
+ "acceptance_evidence"
7487
+ ].some((field) => submission[field] !== undefined) ||
7488
+ stringValue(takeover.terminal_bridge_message_id) !== messageId ||
7489
+ stringValue(takeover.terminal_bridge_request_hash) !==
7490
+ stringValue(submission.request_hash) ||
7491
+ terminalBridgeRequestFingerprint(stringValue(takeover.terminal_bridge_request_text) ?? "") !== stringValue(submission.request_hash) ||
7492
+ executorForConversation(turn).kind !== "codex" ||
7493
+ sessionIdForConversation(turn) !== session.session_id ||
7494
+ stringValue(submission.session_id) !== session.session_id ||
7495
+ stringValue(submission.turn_id) !== turnIdForConversation(turn) ||
7496
+ stringValue(submission.executor_kind) !== "codex" ||
7497
+ stringValue(submission.openclaw_session) !== turn.openclaw_session ||
7498
+ stringValue(turn.terminal_binding_id) !== binding.binding_id ||
7499
+ Number(turn.terminal_binding_generation) !== binding.generation ||
7500
+ stringValue(takeover.terminal_binding_id) !== binding.binding_id ||
7501
+ Number(takeover.terminal_binding_generation) !== binding.generation ||
7502
+ stringValue(submission.binding_id) !== binding.binding_id ||
7503
+ Number(submission.binding_generation) !== binding.generation ||
7504
+ stringValue(turn.native_thread_id) !== binding.native_thread_id ||
7505
+ stringValue(takeover.terminal_agent_session_id) !==
7506
+ binding.native_thread_id ||
7507
+ stringValue(submission.native_thread_id) !== binding.native_thread_id ||
7508
+ Number(takeover.terminal_agent_pid) !== binding.native_process.pid ||
7509
+ stringValue(takeover.terminal_agent_process_uuid) !==
7510
+ binding.native_process.process_uuid ||
7511
+ stringValue(takeover.terminal_agent_process_birth) !==
7512
+ binding.native_process.process_birth ||
7513
+ !terminalControlsShareIncarnation(terminalControl, binding.terminal_control) ||
7514
+ !terminalControlEvidenceMatches(submission.terminal_endpoint ?? terminalControl, binding.terminal_control) ||
7515
+ path.resolve(turn.workspace) !== path.resolve(session.workspace)) {
7516
+ return false;
7517
+ }
7518
+ const canonical = pathsForConversation(turn.conversation_id, storeDir);
7519
+ if (path.resolve(stringValue(turn.state_path) ?? "") !==
7520
+ path.resolve(canonical.statePath) ||
7521
+ path.resolve(stringValue(turn.event_log_path) ?? "") !==
7522
+ path.resolve(canonical.logPath) ||
7523
+ path.resolve(managedSessionStoreDirForConversation(turn) ?? "") !==
7524
+ path.resolve(storeDir) ||
7525
+ path.resolve(stringValue(submission.store_dir) ?? "") !==
7526
+ path.resolve(storeDir)) {
7527
+ return false;
7528
+ }
7529
+ const matchingReceipts = terminalBridgeSubmissionReceipts(turn).filter((receipt) => stringValue(receipt.message_id) === messageId);
7530
+ return matchingReceipts.length === 1 &&
7531
+ canonicalJson(matchingReceipts[0]) === canonicalJson(submission);
7532
+ }
7199
7533
  function deferredCandidateSourceTurnHistory(storeDir, session) {
7200
7534
  const binding = session.binding;
7201
7535
  if (!binding?.native_thread_id) {
@@ -7210,13 +7544,19 @@ function deferredCandidateSourceTurnHistory(storeDir, session) {
7210
7544
  session,
7211
7545
  turn
7212
7546
  });
7547
+ const safelyAbortedBeforeInput = exactReleasedSafeAbortedCandidateTurn({
7548
+ storeDir,
7549
+ session,
7550
+ turn
7551
+ });
7213
7552
  return (!TERMINAL_DISPATCH_RELEASE_STATUSES.has(turn.status) ||
7214
7553
  managedTurnNeedsAttention(turn) ||
7215
7554
  (isRecord(turn.callback_delivery) &&
7216
7555
  !callbackDelivered) ||
7217
7556
  (Boolean(turn.gateway_method) &&
7218
7557
  !callbackDelivered &&
7219
- !explicitlyAbandonedUncertain) ||
7558
+ !explicitlyAbandonedUncertain &&
7559
+ !safelyAbortedBeforeInput) ||
7220
7560
  stringValue(turn.terminal_binding_id) !== binding.binding_id ||
7221
7561
  Number(turn.terminal_binding_generation) !== binding.generation ||
7222
7562
  (stringValue(turn.native_thread_id) ??
@@ -7497,7 +7837,7 @@ function candidateSourceTransitionHistoryIsTerminal(storeDir, session) {
7497
7837
  return false;
7498
7838
  }
7499
7839
  }
7500
- function deferredCodexForegroundBindingToken({ terminalId, terminalControl, pid, workspace, processUuid, processBirth, sourceSession, dispatchSnapshot, candidateInventory, sourceTurnHistory, candidateContextFingerprint, sourceRolloutAuthority = "present", sourceAbandonmentFingerprint }) {
7840
+ function deferredCodexForegroundBindingToken({ terminalId, terminalControl, pid, workspace, processUuid, processBirth, sourceSession, dispatchSnapshot, candidateInventory, sourceTurnHistory, sourceRolloutAuthority = "present", sourceAbandonmentFingerprint }) {
7501
7841
  const terminalToken = unmanagedTerminalBindingToken({
7502
7842
  terminalId,
7503
7843
  terminalControl,
@@ -7530,17 +7870,12 @@ function deferredCodexForegroundBindingToken({ terminalId, terminalControl, pid,
7530
7870
  source_revision: managedSessionRevision(sourceSession),
7531
7871
  source_binding_token: managedSessionBindingToken(sourceSession),
7532
7872
  terminal_dispatch_snapshot: dispatchSnapshot,
7533
- observation: candidateContextFingerprint
7534
- ? "latent_codex_thread_reset"
7535
- : candidateAuthority
7536
- ? "exact_open_root_inventory"
7537
- : "verified_absent",
7873
+ observation: candidateAuthority
7874
+ ? "exact_open_root_inventory"
7875
+ : "verified_absent",
7538
7876
  ...(sourceTurnHistoryFingerprint
7539
7877
  ? { source_turn_history_fingerprint: sourceTurnHistoryFingerprint }
7540
7878
  : {}),
7541
- ...(candidateContextFingerprint
7542
- ? { candidate_context_fingerprint: candidateContextFingerprint }
7543
- : {}),
7544
7879
  ...(candidateAuthority
7545
7880
  ? { source_rollout_authority: sourceRolloutAuthority }
7546
7881
  : {}),
@@ -7978,16 +8313,6 @@ async function assertDeferredCodexForegroundBindingBoundary({ options, boundary,
7978
8313
  physicalOnly: boundary.candidateAcceptanceAnchor !== undefined
7979
8314
  })
7980
8315
  });
7981
- if (requireEmptyComposer && boundary.candidateContextFingerprint) {
7982
- const currentContextFingerprint = codexLatentClearResumeFingerprint({
7983
- screen: status.screen.excerpt,
7984
- sourceNativeThreadId: source.binding?.native_thread_id,
7985
- agentVersion: agentVersionForRunningProcess("codex", boundary.terminal.pid, options)
7986
- });
7987
- if (currentContextFingerprint !== boundary.candidateContextFingerprint) {
7988
- throw new Error("the Codex /clear foreground hint changed; refresh AKK list before sending");
7989
- }
7990
- }
7991
8316
  if (status.reachable !== true ||
7992
8317
  status.approval_state.blocked === true ||
7993
8318
  !["idle", "unknown"].includes(status.activity_state)) {
@@ -8003,15 +8328,22 @@ async function assertDeferredCodexForegroundBindingBoundary({ options, boundary,
8003
8328
  }
8004
8329
  return source;
8005
8330
  }
8006
- async function prepareDeferredCodexForegroundBinding({ options, terminal, sourceSession, observation, candidateInventory, requestText }) {
8331
+ async function prepareDeferredCodexForegroundBinding({ options, terminal, sourceSession, observation, candidateInventory, requestText, allowImplicitFreshAuthority = false }) {
8007
8332
  const expectedToken = stringValue(options.expectedTerminalToken);
8008
8333
  const candidateMode = Boolean(candidateInventory?.roots.length);
8009
8334
  if (terminal.agent !== "codex" ||
8010
8335
  !sourceSession?.binding ||
8011
8336
  (observation.status !== "verified_absent" && !candidateMode) ||
8012
- !expectedToken) {
8337
+ (!expectedToken && !allowImplicitFreshAuthority)) {
8013
8338
  return undefined;
8014
8339
  }
8340
+ if (allowImplicitFreshAuthority &&
8341
+ (expectedToken !== undefined ||
8342
+ !candidateMode ||
8343
+ !isCompleteNativeRollout(sourceSession.binding.native_process.rollout))) {
8344
+ throw new Error("implicit Codex candidate authority requires one fresh complete " +
8345
+ "nonempty rollout inventory for a rollout-backed source");
8346
+ }
8015
8347
  const processUuid = sourceSession.binding.native_process.process_uuid;
8016
8348
  const processBirth = sourceSession.binding.native_process.process_birth;
8017
8349
  const workspace = terminal.terminalControl.currentPath;
@@ -8045,14 +8377,6 @@ async function prepareDeferredCodexForegroundBinding({ options, terminal, source
8045
8377
  dispatchSnapshot
8046
8378
  })
8047
8379
  : undefined;
8048
- const candidateContextFingerprint = candidateMode &&
8049
- sourceKind === "candidate_rollout_quiescent"
8050
- ? await observeCodexLatentClearResumeFingerprint({
8051
- options,
8052
- terminal,
8053
- sourceNativeThreadId: sourceSession.binding.native_thread_id
8054
- })
8055
- : undefined;
8056
8380
  const exactSource = !workspace
8057
8381
  ? false
8058
8382
  : sourceKind === "candidate_rollout_quiescent"
@@ -8103,15 +8427,26 @@ async function prepareDeferredCodexForegroundBinding({ options, terminal, source
8103
8427
  sourceSession,
8104
8428
  dispatchSnapshot,
8105
8429
  sourceTurnHistory: candidateSourceTurnHistory,
8106
- candidateContextFingerprint,
8107
8430
  sourceRolloutAuthority,
8108
8431
  sourceAbandonmentFingerprint,
8109
8432
  ...(candidateMode ? { candidateInventory } : {})
8110
8433
  });
8111
- if (expectedToken !== token) {
8434
+ if (!allowImplicitFreshAuthority && expectedToken !== token) {
8112
8435
  throw new Error("deferred Codex foreground binding requires the fresh exact terminal " +
8113
8436
  "token advertised by AKK list");
8114
8437
  }
8438
+ if (allowImplicitFreshAuthority) {
8439
+ runtimeLog("info", "deferred_codex_implicit_candidate_authority", {
8440
+ terminal_id: terminal.conversationId,
8441
+ terminal_target: terminal.terminalControl.target,
8442
+ source_session_id: sourceSession.session_id,
8443
+ inventory_status: candidateInventory?.status,
8444
+ inventory_fingerprint: candidateInventory?.inventoryFingerprint,
8445
+ candidate_count: candidateInventory?.roots.length,
8446
+ authority_scope: "terminal_follow_current",
8447
+ terminal_input_sent: false
8448
+ });
8449
+ }
8115
8450
  const targetSessionId = createManagedSessionId();
8116
8451
  const transferId = createDeferredForegroundTransferId();
8117
8452
  const candidateAcceptanceAnchor = candidateMode
@@ -8135,9 +8470,6 @@ async function prepareDeferredCodexForegroundBinding({ options, terminal, source
8135
8470
  ...(sourceAbandonmentFingerprint
8136
8471
  ? { sourceAbandonmentFingerprint }
8137
8472
  : {}),
8138
- ...(candidateContextFingerprint
8139
- ? { candidateContextFingerprint }
8140
- : {}),
8141
8473
  ...(sourceSession.last_transition_id
8142
8474
  ? { sourcePreviousLastTransitionId: sourceSession.last_transition_id }
8143
8475
  : {}),
@@ -8162,13 +8494,14 @@ async function prepareDeferredCodexForegroundBinding({ options, terminal, source
8162
8494
  terminalControl: terminal.terminalControl,
8163
8495
  excludedManagedSessionId: sourceSession.session_id
8164
8496
  });
8165
- if (sourceRolloutAuthority === "explicitly_abandoned_predecessor" &&
8497
+ if (candidateInventory &&
8166
8498
  !codexCandidateInventoryHasNoOtherManagedClaim({
8167
8499
  storeDir,
8168
- inventory: required(candidateInventory, "Codex candidate inventory is unavailable"),
8169
- sourceSessionId: sourceSession.session_id
8500
+ inventory: candidateInventory,
8501
+ sourceSessionId: sourceSession.session_id,
8502
+ includeDetached: sourceRolloutAuthority === "explicitly_abandoned_predecessor"
8170
8503
  })) {
8171
- throw new Error("the post-/clear Codex rollout candidate is already claimed by another Session");
8504
+ throw new Error("a Codex rollout candidate is already claimed by another Session");
8172
8505
  }
8173
8506
  if (candidateInventory &&
8174
8507
  sourceRolloutAuthority === "explicitly_abandoned_predecessor") {
@@ -12569,6 +12902,11 @@ async function runSend(options) {
12569
12902
  }
12570
12903
  assertTerminalIncarnationCanStartTurn(rawStoreDir, terminalConversation.terminalControl);
12571
12904
  let claimedSession = soleBoundManagedSessionClaimForTerminal(rawStoreDir, terminalConversation);
12905
+ const suppliedExpectedTerminalToken = stringValue(options.expectedTerminalToken);
12906
+ const implicitCodexCandidateAuthority = Boolean(terminalConversation.agent === "codex" &&
12907
+ suppliedExpectedTerminalToken === undefined &&
12908
+ claimedSession?.status === "bound" &&
12909
+ isCompleteNativeRollout(claimedSession.binding?.native_process.rollout));
12572
12910
  let knownCodexCompanions = claimedSession
12573
12911
  ? codexAllowedCompanionSetForManagedSession({
12574
12912
  storeDir: rawStoreDir,
@@ -12588,7 +12926,7 @@ async function runSend(options) {
12588
12926
  });
12589
12927
  let deferredCodexCandidateInventory;
12590
12928
  if (terminalConversation.agent === "codex" &&
12591
- stringValue(options.expectedTerminalToken)) {
12929
+ (suppliedExpectedTerminalToken || implicitCodexCandidateAuthority)) {
12592
12930
  try {
12593
12931
  const inventory = await inspectCodexOpenRootRolloutInventory({
12594
12932
  options,
@@ -12600,12 +12938,18 @@ async function runSend(options) {
12600
12938
  }
12601
12939
  }
12602
12940
  catch (error) {
12603
- if (nativeIdentityObservation.status === "unavailable") {
12604
- throw new Error(`native Codex identity observation is unavailable and its exact ` +
12605
- `open-root inventory could not be proven: ${error instanceof Error ? error.message : String(error)}`);
12941
+ if (implicitCodexCandidateAuthority ||
12942
+ nativeIdentityObservation.status === "unavailable") {
12943
+ throw new Error(`native Codex foreground attribution requires a fresh complete ` +
12944
+ `open-root inventory: ${error instanceof Error ? error.message : String(error)}`);
12606
12945
  }
12607
12946
  }
12608
12947
  }
12948
+ if (implicitCodexCandidateAuthority &&
12949
+ !deferredCodexCandidateInventory) {
12950
+ throw new Error("rollout-backed Codex terminal send requires a fresh complete " +
12951
+ "nonempty open-root inventory; refresh AKK list before sending");
12952
+ }
12609
12953
  if (nativeIdentityObservation.status === "unavailable" &&
12610
12954
  !deferredCodexCandidateInventory) {
12611
12955
  throw new Error(`native ${terminalConversation.agent} identity observation is ` +
@@ -12614,12 +12958,17 @@ async function runSend(options) {
12614
12958
  let currentNativeIdentity = nativeIdentityObservation.status === "resolved"
12615
12959
  ? nativeIdentityObservation.identity
12616
12960
  : undefined;
12617
- const verifiedEmptyHandoff = await maybeDetachVerifiedEmptyCodexSource({
12618
- options,
12619
- terminal: terminalConversation,
12620
- sourceSession: claimedSession,
12621
- observation: nativeIdentityObservation
12622
- });
12961
+ // A fresh nonempty inventory is the stronger physical authority for an
12962
+ // implicit candidate send. Do not let an earlier verified-absent
12963
+ // observation divert this path into the token-only empty handoff.
12964
+ const verifiedEmptyHandoff = implicitCodexCandidateAuthority
12965
+ ? undefined
12966
+ : await maybeDetachVerifiedEmptyCodexSource({
12967
+ options,
12968
+ terminal: terminalConversation,
12969
+ sourceSession: claimedSession,
12970
+ observation: nativeIdentityObservation
12971
+ });
12623
12972
  if (verifiedEmptyHandoff) {
12624
12973
  // The old rollout is conclusively closed. Never carry it forward as
12625
12974
  // a pre-materialization companion for the new virgin Session.
@@ -12631,7 +12980,13 @@ async function runSend(options) {
12631
12980
  let handoff = await maybeAdoptObservedExternalThread({
12632
12981
  options,
12633
12982
  terminal: terminalConversation,
12634
- sourceSession: claimedSession,
12983
+ // A no-token raw send to an already managed rollout-backed Codex pane
12984
+ // is an internal follow-current delegation, never a sole-root strict
12985
+ // continuation or an external-handoff adoption. The dedicated v3
12986
+ // transfer below owns attribution and lineage.
12987
+ sourceSession: implicitCodexCandidateAuthority
12988
+ ? undefined
12989
+ : claimedSession,
12635
12990
  resolvedIdentity: currentNativeIdentity,
12636
12991
  storeDir: rawStoreDir
12637
12992
  });
@@ -12648,7 +13003,8 @@ async function runSend(options) {
12648
13003
  sourceSession: claimedSession,
12649
13004
  observation: nativeIdentityObservation,
12650
13005
  candidateInventory: deferredCodexCandidateInventory,
12651
- requestText: String(messageBody)
13006
+ requestText: String(messageBody),
13007
+ allowImplicitFreshAuthority: implicitCodexCandidateAuthority
12652
13008
  })
12653
13009
  : undefined;
12654
13010
  if (deferredCodexForegroundBinding) {
@@ -12657,7 +13013,7 @@ async function runSend(options) {
12657
13013
  currentNativeIdentity = undefined;
12658
13014
  handoff = { identity: undefined, adopted: false };
12659
13015
  }
12660
- else if (stringValue(options.expectedTerminalToken) &&
13016
+ else if ((suppliedExpectedTerminalToken || implicitCodexCandidateAuthority) &&
12661
13017
  !verifiedEmptyHandoff &&
12662
13018
  !handoff.adopted) {
12663
13019
  throw new Error("the expected terminal token no longer authorizes the current " +
@@ -12925,6 +13281,17 @@ async function runSend(options) {
12925
13281
  const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDir, resolvedTerminal.terminalControl, { timeoutMs: 30000 });
12926
13282
  try {
12927
13283
  await withStoreWriterLeaseAsync(storeDir, async () => {
13284
+ const lockedStrictSession = tryLoadManagedSession(storeDir, sessionId);
13285
+ if (lockedStrictSession?.agent === "codex" &&
13286
+ lockedStrictSession.session_id === sessionId &&
13287
+ lockedStrictSession.status === "bound" &&
13288
+ isCompleteNativeRollout(lockedStrictSession.binding?.native_process.rollout)) {
13289
+ throw new Error(`Codex rollout-backed managed Session ${sessionId} cannot use a ` +
13290
+ "strict session_id send because an open rollout does not prove the " +
13291
+ "current TUI foreground thread. Refresh AKK list and use its exact " +
13292
+ "selector plus expected_terminal_token. No Turn was created and no " +
13293
+ "terminal input was sent.");
13294
+ }
12928
13295
  await recoverLifecycleFenceBeforeMutation({
12929
13296
  options,
12930
13297
  terminal: resolvedTerminal
@@ -12995,11 +13362,6 @@ async function runSend(options) {
12995
13362
  if (!bindingMatchesLiveTerminal(currentSession, resolvedTerminal, lockedNativeIdentity, storeDir)) {
12996
13363
  throw new Error("managed session identity changed while waiting to send; refresh list and retry");
12997
13364
  }
12998
- await assertStrictCodexSessionHasNoLatentClear({
12999
- options,
13000
- terminal: resolvedTerminal,
13001
- session: currentSession
13002
- });
13003
13365
  const logicalLockedNativeIdentity = logicalIdentityForManagedSession({
13004
13366
  storeDir,
13005
13367
  session: currentSession,
@@ -20851,6 +21213,32 @@ function terminalLedgerReceiptCandidate(ledger) {
20851
21213
  }
20852
21214
  function mergeTerminalLedgerReceipt(previous, next) {
20853
21215
  const messageId = required(stringValue(previous.message_id), "terminal dispatch receipt message id is required");
21216
+ const safeAbortRetryGeneration = Boolean(previous.status === "aborted" &&
21217
+ previous.safe_to_retry === true &&
21218
+ stringValue(next.message_id) === messageId &&
21219
+ stringValue(next.previous_generation_id) === messageId &&
21220
+ [
21221
+ "text_injected",
21222
+ "enter_dispatched",
21223
+ "submitted",
21224
+ "agent_accepted",
21225
+ "not_accepted",
21226
+ "uncertain",
21227
+ "aborted"
21228
+ ].includes(String(next.status)) &&
21229
+ validTimestampMs(previous.aborted_at) !== undefined &&
21230
+ validTimestampMs(next.prepared_at) !== undefined &&
21231
+ Date.parse(String(next.prepared_at)) >=
21232
+ Date.parse(String(previous.aborted_at)));
21233
+ if (safeAbortRetryGeneration) {
21234
+ // A proved zero-input abort intentionally allows the same OpenClaw
21235
+ // idempotency key to start one fresh Turn/Session generation. The old
21236
+ // Turn and, for v3, its abort_resolved transfer retain that immutable
21237
+ // proof; the terminal-wide singleton receipt advances to the retry so its
21238
+ // different binding/Turn identity cannot collide with the abandoned
21239
+ // zero-input generation.
21240
+ return next;
21241
+ }
20854
21242
  for (const field of TERMINAL_LEDGER_RECEIPT_IMMUTABLE_FIELDS) {
20855
21243
  const previousValue = previous[field];
20856
21244
  const nextValue = next[field];
@@ -23745,47 +24133,6 @@ function codexLatentClearResumeObservation({ screen, agentVersion }) {
23745
24133
  })
23746
24134
  };
23747
24135
  }
23748
- function codexLatentClearResumeFingerprint({ screen, sourceNativeThreadId, agentVersion }) {
23749
- if (!isExactNativeThreadId(sourceNativeThreadId)) {
23750
- return undefined;
23751
- }
23752
- const observation = codexLatentClearResumeObservation({
23753
- screen,
23754
- agentVersion
23755
- });
23756
- return observation?.sourceNativeThreadId === sourceNativeThreadId.toLowerCase()
23757
- ? observation.fingerprint
23758
- : undefined;
23759
- }
23760
- async function observeCodexLatentClearResumeFingerprint({ options, terminal, sourceNativeThreadId }) {
23761
- if (terminal.agent !== "codex" || !isExactNativeThreadId(sourceNativeThreadId)) {
23762
- return undefined;
23763
- }
23764
- const agentVersion = agentVersionForRunningProcess("codex", terminal.pid, options);
23765
- if (!codexLifecycleBehaviorProfile(agentVersion)) {
23766
- return undefined;
23767
- }
23768
- const status = await createTerminalAgentBridge(options).status("codex", terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
23769
- return codexLatentClearResumeFingerprint({
23770
- screen: status.screen.excerpt,
23771
- sourceNativeThreadId,
23772
- agentVersion
23773
- });
23774
- }
23775
- async function assertStrictCodexSessionHasNoLatentClear({ options, terminal, session }) {
23776
- if (session.agent !== "codex") {
23777
- return;
23778
- }
23779
- const fingerprint = await observeCodexLatentClearResumeFingerprint({
23780
- options,
23781
- terminal,
23782
- sourceNativeThreadId: session.binding?.native_thread_id
23783
- });
23784
- if (fingerprint) {
23785
- throw new Error("Codex /clear changed the foreground logical thread; refresh AKK list " +
23786
- "and use its snapshot-bound follow-current send. No task input was sent.");
23787
- }
23788
- }
23789
24136
  function nativeInspectionComposerEmpty(agent, screen) {
23790
24137
  return agent === "codex"
23791
24138
  ? codexComposerEmpty(screen)