botmux 3.11.0 → 3.12.0

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.
Files changed (165) hide show
  1. package/dist/.runtime-build-id +1 -1
  2. package/dist/adapters/backend/riff-backend.d.ts.map +1 -1
  3. package/dist/adapters/backend/riff-backend.js +11 -2
  4. package/dist/adapters/backend/riff-backend.js.map +1 -1
  5. package/dist/adapters/cli/coco.js +3 -3
  6. package/dist/adapters/cli/coco.js.map +1 -1
  7. package/dist/adapters/cli/codex.js +2 -2
  8. package/dist/adapters/cli/codex.js.map +1 -1
  9. package/dist/cli/dashboard-command.d.ts +23 -0
  10. package/dist/cli/dashboard-command.d.ts.map +1 -0
  11. package/dist/cli/dashboard-command.js +61 -0
  12. package/dist/cli/dashboard-command.js.map +1 -0
  13. package/dist/cli/dashboard-endpoint.d.ts +3 -3
  14. package/dist/cli/dashboard-endpoint.d.ts.map +1 -1
  15. package/dist/cli/dashboard-endpoint.js +1 -1
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +36 -22
  18. package/dist/cli.js.map +1 -1
  19. package/dist/codex-rpc-engine.d.ts +33 -3
  20. package/dist/codex-rpc-engine.d.ts.map +1 -1
  21. package/dist/codex-rpc-engine.js +182 -20
  22. package/dist/codex-rpc-engine.js.map +1 -1
  23. package/dist/codex-rpc-lifecycle.d.ts +42 -7
  24. package/dist/codex-rpc-lifecycle.d.ts.map +1 -1
  25. package/dist/codex-rpc-lifecycle.js +73 -7
  26. package/dist/codex-rpc-lifecycle.js.map +1 -1
  27. package/dist/config.d.ts +1 -1
  28. package/dist/config.js +1 -1
  29. package/dist/core/command-handler.d.ts +3 -2
  30. package/dist/core/command-handler.d.ts.map +1 -1
  31. package/dist/core/command-handler.js +3 -2
  32. package/dist/core/command-handler.js.map +1 -1
  33. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  34. package/dist/core/dashboard-ipc-server.js +15 -10
  35. package/dist/core/dashboard-ipc-server.js.map +1 -1
  36. package/dist/core/dispatch.d.ts +9 -10
  37. package/dist/core/dispatch.d.ts.map +1 -1
  38. package/dist/core/dispatch.js +17 -10
  39. package/dist/core/dispatch.js.map +1 -1
  40. package/dist/core/role-resolver.d.ts +5 -2
  41. package/dist/core/role-resolver.d.ts.map +1 -1
  42. package/dist/core/role-resolver.js +55 -23
  43. package/dist/core/role-resolver.js.map +1 -1
  44. package/dist/core/session-manager.d.ts +2 -0
  45. package/dist/core/session-manager.d.ts.map +1 -1
  46. package/dist/core/session-manager.js +20 -18
  47. package/dist/core/session-manager.js.map +1 -1
  48. package/dist/core/terminal-write-auth.d.ts +4 -4
  49. package/dist/core/terminal-write-auth.d.ts.map +1 -1
  50. package/dist/core/terminal-write-auth.js +18 -6
  51. package/dist/core/terminal-write-auth.js.map +1 -1
  52. package/dist/daemon.d.ts +1 -0
  53. package/dist/daemon.d.ts.map +1 -1
  54. package/dist/daemon.js +101 -12
  55. package/dist/daemon.js.map +1 -1
  56. package/dist/dashboard/auth.d.ts +21 -11
  57. package/dist/dashboard/auth.d.ts.map +1 -1
  58. package/dist/dashboard/auth.js +39 -25
  59. package/dist/dashboard/auth.js.map +1 -1
  60. package/dist/dashboard/bot-payload.d.ts +0 -1
  61. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  62. package/dist/dashboard/bot-payload.js +0 -1
  63. package/dist/dashboard/bot-payload.js.map +1 -1
  64. package/dist/dashboard/dashboard-summary-endpoint.d.ts +39 -0
  65. package/dist/dashboard/dashboard-summary-endpoint.d.ts.map +1 -0
  66. package/dist/dashboard/dashboard-summary-endpoint.js +50 -0
  67. package/dist/dashboard/dashboard-summary-endpoint.js.map +1 -0
  68. package/dist/dashboard/dashboard-summary.d.ts +62 -0
  69. package/dist/dashboard/dashboard-summary.d.ts.map +1 -0
  70. package/dist/dashboard/dashboard-summary.js +106 -0
  71. package/dist/dashboard/dashboard-summary.js.map +1 -0
  72. package/dist/dashboard/web/feed-group-picker.d.ts +13 -0
  73. package/dist/dashboard/web/feed-group-picker.d.ts.map +1 -0
  74. package/dist/dashboard/web/feed-group-picker.js +45 -0
  75. package/dist/dashboard/web/feed-group-picker.js.map +1 -0
  76. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  77. package/dist/dashboard/web/groups-page.js +2 -5
  78. package/dist/dashboard/web/groups-page.js.map +1 -1
  79. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  80. package/dist/dashboard/web/i18n.js +8 -14
  81. package/dist/dashboard/web/i18n.js.map +1 -1
  82. package/dist/dashboard/web/roles-page.d.ts.map +1 -1
  83. package/dist/dashboard/web/roles-page.js +36 -4
  84. package/dist/dashboard/web/roles-page.js.map +1 -1
  85. package/dist/dashboard/web/roles.d.ts +3 -1
  86. package/dist/dashboard/web/roles.d.ts.map +1 -1
  87. package/dist/dashboard/web/roles.js +10 -2
  88. package/dist/dashboard/web/roles.js.map +1 -1
  89. package/dist/dashboard/web/schedules-page.d.ts.map +1 -1
  90. package/dist/dashboard/web/schedules-page.js +8 -2
  91. package/dist/dashboard/web/schedules-page.js.map +1 -1
  92. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  93. package/dist/dashboard/web/sessions-page.js +2 -2
  94. package/dist/dashboard/web/sessions-page.js.map +1 -1
  95. package/dist/dashboard-web/app.js +1 -1
  96. package/dist/dashboard-web/chunks/{bot-defaults-page-GED4FEMS.js → bot-defaults-page-USC6EZXP.js} +1 -1
  97. package/dist/dashboard-web/chunks/chunk-6ALR252E.js +1 -0
  98. package/dist/dashboard-web/chunks/{chunk-VBXJOJFH.js → chunk-A7VC4BXR.js} +1 -1
  99. package/dist/dashboard-web/chunks/{chunk-HK7NIBJG.js → chunk-KOPISPHN.js} +1 -1
  100. package/dist/dashboard-web/chunks/{chunk-6J5LS5AU.js → chunk-M6BHWQFO.js} +6 -6
  101. package/dist/dashboard-web/chunks/{chunk-KRHWZEXU.js → chunk-RXRJB5SO.js} +1 -1
  102. package/dist/dashboard-web/chunks/{connectors-page-EELM66G3.js → connectors-page-4Z76HWRH.js} +1 -1
  103. package/dist/dashboard-web/chunks/groups-page-C6NSDTIN.js +8 -0
  104. package/dist/dashboard-web/chunks/{insights-page-54UBPPOC.js → insights-page-UTOVYLLY.js} +1 -1
  105. package/dist/dashboard-web/chunks/{monitor-room-PORHVKRH.js → monitor-room-UKTJYTA2.js} +1 -1
  106. package/dist/dashboard-web/chunks/{monitoring-page-XGA7AXNL.js → monitoring-page-Q3CEZIVX.js} +1 -1
  107. package/dist/dashboard-web/chunks/{office-page-AO3AFG45.js → office-page-GYCZOOVJ.js} +1 -1
  108. package/dist/dashboard-web/chunks/{overview-page-F5BUTYR6.js → overview-page-W2VBJ6OH.js} +1 -1
  109. package/dist/dashboard-web/chunks/roles-page-U4SHER4K.js +1 -0
  110. package/dist/dashboard-web/chunks/schedules-page-TNPW3N3Q.js +1 -0
  111. package/dist/dashboard-web/chunks/{sessions-page-YFRPSTIL.js → sessions-page-YWWDDV57.js} +3 -3
  112. package/dist/dashboard-web/chunks/{settings-page-OJAZLZQT.js → settings-page-VKW5ZK5K.js} +1 -1
  113. package/dist/dashboard-web/chunks/{skills-page-EFZQPWV4.js → skills-page-ZCEDFGZZ.js} +1 -1
  114. package/dist/dashboard-web/chunks/{team-federation-page-ZJVAP7HW.js → team-federation-page-OMFGGBT2.js} +1 -1
  115. package/dist/dashboard-web/chunks/{v3-page-GYWTRGO2.js → v3-page-PLXWK4KO.js} +1 -1
  116. package/dist/dashboard-web/chunks/{whiteboards-page-MSDAG6P7.js → whiteboards-page-NJQOEO2P.js} +1 -1
  117. package/dist/dashboard-web/style.css +112 -0
  118. package/dist/dashboard.js +116 -31
  119. package/dist/dashboard.js.map +1 -1
  120. package/dist/desktop/main/ipc.js +1 -1
  121. package/dist/desktop/main/runtime-service.js +2 -2
  122. package/dist/desktop/main/runtime-service.js.map +1 -1
  123. package/dist/desktop/shared/types.d.ts +1 -1
  124. package/dist/i18n/en.d.ts.map +1 -1
  125. package/dist/i18n/en.js +3 -1
  126. package/dist/i18n/en.js.map +1 -1
  127. package/dist/i18n/zh.d.ts.map +1 -1
  128. package/dist/i18n/zh.js +3 -1
  129. package/dist/i18n/zh.js.map +1 -1
  130. package/dist/services/adopt-input-sequence.d.ts +52 -0
  131. package/dist/services/adopt-input-sequence.d.ts.map +1 -0
  132. package/dist/services/adopt-input-sequence.js +50 -0
  133. package/dist/services/adopt-input-sequence.js.map +1 -0
  134. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  135. package/dist/services/bridge-fallback-gate.js +34 -11
  136. package/dist/services/bridge-fallback-gate.js.map +1 -1
  137. package/dist/services/codex-bridge-queue.d.ts +103 -7
  138. package/dist/services/codex-bridge-queue.d.ts.map +1 -1
  139. package/dist/services/codex-bridge-queue.js +291 -17
  140. package/dist/services/codex-bridge-queue.js.map +1 -1
  141. package/dist/services/codex-transcript.d.ts +4 -3
  142. package/dist/services/codex-transcript.d.ts.map +1 -1
  143. package/dist/services/codex-transcript.js.map +1 -1
  144. package/dist/services/structured-bridge-clis.d.ts +8 -1
  145. package/dist/services/structured-bridge-clis.d.ts.map +1 -1
  146. package/dist/services/structured-bridge-clis.js +13 -0
  147. package/dist/services/structured-bridge-clis.js.map +1 -1
  148. package/dist/services/submit-confirmation.d.ts +50 -0
  149. package/dist/services/submit-confirmation.d.ts.map +1 -1
  150. package/dist/services/submit-confirmation.js +82 -0
  151. package/dist/services/submit-confirmation.js.map +1 -1
  152. package/dist/utils/async-serial-queue.d.ts +9 -0
  153. package/dist/utils/async-serial-queue.d.ts.map +1 -0
  154. package/dist/utils/async-serial-queue.js +13 -0
  155. package/dist/utils/async-serial-queue.js.map +1 -0
  156. package/dist/utils/runtime-screen-status.d.ts +52 -0
  157. package/dist/utils/runtime-screen-status.d.ts.map +1 -0
  158. package/dist/utils/runtime-screen-status.js +60 -0
  159. package/dist/utils/runtime-screen-status.js.map +1 -0
  160. package/dist/worker.js +1642 -452
  161. package/dist/worker.js.map +1 -1
  162. package/package.json +1 -1
  163. package/dist/dashboard-web/chunks/groups-page-WKUV6E42.js +0 -8
  164. package/dist/dashboard-web/chunks/roles-page-M5GIASJU.js +0 -1
  165. package/dist/dashboard-web/chunks/schedules-page-O232WQRR.js +0 -1
package/dist/worker.js CHANGED
@@ -36,7 +36,7 @@ import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirs
36
36
  import { decideRestartFollowup, settleDurableTurnForRestart } from './core/restart-followup-policy.js';
37
37
  import { stripAnsiForLog, tailChars } from './utils/crash-log.js';
38
38
  import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
39
- import { EffortConfirmDialogGuard, isEffortLevelCommand } from './utils/effort-confirm-dialog.js';
39
+ import { EffortConfirmDialogGuard } from './utils/effort-confirm-dialog.js';
40
40
  import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
41
41
  import { handoffQueuedDurableInputsOnBackendExit, mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, shouldArmSpawnArgvInitialPromptBusy, shouldTrackArgvBakedFirstPrompt, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
42
42
  import { riffWorkerShutdownInputBlocker } from './core/riff-worker-shutdown-readiness.js';
@@ -58,7 +58,7 @@ import { loadDashboardSecret, loadPersistedToken } from './dashboard/auth.js';
58
58
  import { InflightInputTracker } from './core/inflight-input-tracker.js';
59
59
  import { InputTurnDeduper } from './core/input-turn-dedupe.js';
60
60
  import { shouldRunQuietRotation, evaluatePidResolverPullback, decideFingerprintSwitch, shouldHealAbsentBaseline, sessionIdFromJsonlPath, SESSION_ID_FILENAME_RE, } from './services/bridge-rotation-policy.js';
61
- import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
61
+ import { CodexBridgeQueue, pruneExpiredPreStartHeadsAndEmit } from './services/codex-bridge-queue.js';
62
62
  import { detectCodexComposerState } from './services/codex-composer-state.js';
63
63
  import { generateCodexAppThreadTitle, readCodexAppThreadMetadata, setCodexAppThreadName, } from './services/codex-app-threads.js';
64
64
  import { buildBotmuxLarkNativeSessionTitle } from './core/session-title.js';
@@ -74,7 +74,7 @@ import { currentMtrSessionOffset, drainMtrSession, findLatestMtrSessionByDirecto
74
74
  import { drainPiTranscript } from './services/pi-transcript.js';
75
75
  import { drainGrokUpdates, findGrokSessionByPid, grokSessionIdFromPath, } from './services/grok-transcript.js';
76
76
  import { resolveFileBridgePath } from './services/file-bridge-path.js';
77
- import { isStructuredBridgeAdoptIdleCli, isStructuredBridgeAdoptInputCli, isStructuredBridgeFallbackActive, } from './services/structured-bridge-clis.js';
77
+ import { isStructuredBridgeAdoptIdleCli, isStructuredBridgeAdoptInputCli, isStructuredBridgeFallbackActive, isStructuredBridgeLifecycleBlockingCli, } from './services/structured-bridge-clis.js';
78
78
  import { drainCursorTranscript, findCursorChatIdByPid, findCursorTranscriptByChatId, findCursorTranscriptByPid } from './services/cursor-transcript.js';
79
79
  import { shouldObserveCursorChatId, shouldPersistObservedCursorChatId } from './services/cursor-resume-policy.js';
80
80
  import { extractKiroSessionIdFromOutput } from './services/kiro-session.js';
@@ -93,7 +93,7 @@ import { createCliAdapterSync, locateOnPath } from './adapters/cli/registry.js';
93
93
  import { buildWrappedLaunch, parseWrapperCli, isTtadkWrapper } from './setup/cli-selection.js';
94
94
  import { cliUnavailableMessage } from './setup/cli-availability.js';
95
95
  import { findLaunchedCliPid, scheduleWrapperRealCliPid, readComm, isBareShellComm, bareShellLaunchKind, settleLaunchComm, } from './core/session-discovery.js';
96
- import { codexRpcEligible, paneRunsRemoteTui, orchestrateCodexRpcInit, rolloutUserTurnMatches, decideStartupDialogAction, shouldQueueInitialPrompt, shouldPreMarkFirstTurn, killAndVerifyPersistentPane } from './codex-rpc-lifecycle.js';
96
+ import { CODEX_RPC_TERMINAL_HYDRATION_DELAYS_MS, RpcEngagementFence, codexRpcEligible, paneRunsRemoteTui, orchestrateCodexRpcInit, rolloutUserTurnMatches, decideStartupDialogAction, shouldQueueInitialPrompt, shouldPreMarkFirstTurn, killAndVerifyPersistentPane, rpcTranscriptIngestBlockedByAwaitingActivation } from './codex-rpc-lifecycle.js';
97
97
  import { delay } from './utils/timing.js';
98
98
  import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, syncClaudeResumeTargetToCwd, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js';
99
99
  import { sessionReadyHookCommand } from './adapters/hook-command.js';
@@ -129,7 +129,8 @@ import { parseWorkerRequestUrl } from './utils/worker-http.js';
129
129
  import { detectCliUsageLimit, usageLimitStateKey, structuredRateLimitState, isStructuredRateLimitAuthoritative } from './utils/cli-usage-limit.js';
130
130
  import { uploadImageBuffer } from './utils/lark-upload.js';
131
131
  import { redactChildEnv, scrubClaudeSessionMarkerEnv, scrubSessionCliHomeEnv } from './utils/child-env.js';
132
- import { decideSubmitConfirmationAction } from './services/submit-confirmation.js';
132
+ import { decideSubmitConfirmationAction, settleDeferredSubmitConfirmation, settleStaleWriteContinuation, } from './services/submit-confirmation.js';
133
+ import { runAdoptQueuedWriteSequence, runAdoptRawInputSequence, runAdoptSessionRenameSequence, } from './services/adopt-input-sequence.js';
133
134
  import { config, resolveChatBotDiscoveryConfig } from './config.js';
134
135
  import * as sessionStore from './services/session-store.js';
135
136
  import * as pty from 'node-pty';
@@ -142,7 +143,9 @@ import { resolveCodexAppFinalTurnIdentity } from './adapters/cli/codex-app-turn.
142
143
  import { RunnerControlDecoder } from './adapters/cli/runner-control-channel.js';
143
144
  import { normalizeCodexAppLifecycleEvent, normalizeFinalUsage, } from './services/codex-app-runner-protocol.js';
144
145
  import { hasMatchingManagedOriginCapability, ensureManagedOriginAttestationDirectory, ensureManagedOriginCapabilityLeafSafe, ensureManagedOriginDataRootProbe, ensureManagedOriginIsolationSentinel, ensureManagedOriginRootLocator, managedOriginLegacyIsolationProbeAccess, managedOriginDataRootProbeAccess, managedOriginIsolationSentinelAccess, managedOriginAttestationDirectory, managedOriginCapabilityPath, managedOriginRootLocatorPath, readManagedOriginAuthorityFile, RELAY_ORIGIN_CAPABILITY_BASENAME, replaceManagedOriginCapabilityFile, sweepManagedOriginAttestationProofs, } from './core/managed-origin-capability.js';
145
- import { CodexRpcEngine } from './codex-rpc-engine.js';
146
+ import { CodexRpcEngine, } from './codex-rpc-engine.js';
147
+ import { beginRuntimeWriteCycle, isCliBackendGenerationCurrent, PtyOutputGeneration, projectRuntimeScreenStatus, } from './utils/runtime-screen-status.js';
148
+ import { AsyncSerialQueue } from './utils/async-serial-queue.js';
146
149
  // A worker must never trust an INHERITED session-level CLI home pointer
147
150
  // (CLAUDE_CONFIG_DIR / CODEX_HOME): a stale pm2 dump can resurrect the daemon
148
151
  // with a sibling bot's home baked in (see the index-daemon.ts boot scrub —
@@ -216,16 +219,184 @@ function cleanupPiInitialPromptFiles() {
216
219
  piInitialPromptAdditionalArgs = [];
217
220
  piInitialPromptEnv = {};
218
221
  }
222
+ const rpcEngagementFence = new RpcEngagementFence();
223
+ const pendingRpcTurnTerminals = new Map();
224
+ const rpcTurnsAwaitingActivation = new Map();
225
+ const rpcTurnsAwaitingActivationIdentities = new Map();
226
+ /** Local send-start anchor for each exact awaiting owner. A predecessor's
227
+ * terminal hydration may ingest this successor's transcript before turn/start
228
+ * ACK returns. Reusing the send-start time when the bridge mark is installed
229
+ * keeps that already-buffered user/final pair inside its original bounded
230
+ * attribution window without widening replay for unrelated turns. */
231
+ const rpcTurnsAwaitingActivationReplayAnchors = new Map();
232
+ /** A bridge mark/activation failure after an accepted RPC submit must never
233
+ * degrade to ready. Keep a separate fail-closed gate until that exact native
234
+ * turn reaches terminal or the engine is torn down. */
235
+ const rpcLifecycleFailClosedOwners = new Map();
236
+ const rpcLifecycleFailClosedIdentities = new Map();
237
+ const rpcActiveOwners = new Map();
238
+ const rpcTerminalHydrationOwners = new Map();
239
+ const rpcTerminalHydrationTimers = new Map();
240
+ const rpcTerminalHydrationTerminals = new Map();
241
+ const settlingRpcTerminalOwners = new Map();
242
+ let deferredFreshRpcTurn;
243
+ function rpcTurnOwnerKey(identity) {
244
+ return `${identity.turnId}\0${identity.dispatchAttempt ?? ''}`;
245
+ }
246
+ function sameRpcGeneration(left, right) {
247
+ return left?.engine === right.engine
248
+ && left.cliGeneration === right.cliGeneration;
249
+ }
250
+ function installRpcLifecycleFailClosedOwner(identity, generation) {
251
+ const ownerKey = rpcTurnOwnerKey(identity);
252
+ rpcLifecycleFailClosedOwners.set(ownerKey, generation);
253
+ rpcLifecycleFailClosedIdentities.set(ownerKey, identity);
254
+ }
255
+ function installAwaitingRpcActivation(identity, generation) {
256
+ const ownerKey = rpcTurnOwnerKey(identity);
257
+ rpcTurnsAwaitingActivation.set(ownerKey, generation);
258
+ rpcTurnsAwaitingActivationIdentities.set(ownerKey, identity);
259
+ rpcTurnsAwaitingActivationReplayAnchors.set(ownerKey, Date.now());
260
+ }
261
+ function awaitingRpcActivationReplayAnchorMs(identity, generation) {
262
+ const ownerKey = rpcTurnOwnerKey(identity);
263
+ if (!sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
264
+ return undefined;
265
+ }
266
+ return rpcTurnsAwaitingActivationReplayAnchors.get(ownerKey);
267
+ }
268
+ function clearRpcLifecycleFailClosedOwner(identity, generation) {
269
+ const ownerKey = rpcTurnOwnerKey(identity);
270
+ if (!sameRpcGeneration(rpcLifecycleFailClosedOwners.get(ownerKey), generation)) {
271
+ return false;
272
+ }
273
+ rpcLifecycleFailClosedOwners.delete(ownerKey);
274
+ rpcLifecycleFailClosedIdentities.delete(ownerKey);
275
+ return true;
276
+ }
277
+ function clearAwaitingRpcActivation(identity, generation) {
278
+ const ownerKey = rpcTurnOwnerKey(identity);
279
+ if (sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
280
+ rpcTurnsAwaitingActivation.delete(ownerKey);
281
+ rpcTurnsAwaitingActivationIdentities.delete(ownerKey);
282
+ rpcTurnsAwaitingActivationReplayAnchors.delete(ownerKey);
283
+ }
284
+ const pending = pendingRpcTurnTerminals.get(ownerKey);
285
+ if (pending && sameRpcGeneration(pending.generation, generation)) {
286
+ pendingRpcTurnTerminals.delete(ownerKey);
287
+ }
288
+ }
289
+ function notifyRpcTeardownBeforeActivation(identity, terminalStatus) {
290
+ const completedBeforeActivation = terminalStatus === 'completed';
291
+ send({
292
+ type: 'user_notify',
293
+ message: completedBeforeActivation
294
+ ? 'Codex RPC 消息已执行完成,但会话在本地完成生命周期登记前重启,兜底输出可能未被捕获;原消息未自动重发,如未看到回复请先查看终端结果再人工跟进。'
295
+ : 'Codex RPC 消息已写出,但会话在取得 turn/start 归属前重启;为避免重复执行未自动重发,请按需人工确认结果。',
296
+ turnId: identity.turnId,
297
+ ...(identity.dispatchAttempt !== undefined
298
+ ? { dispatchAttempt: identity.dispatchAttempt }
299
+ : {}),
300
+ });
301
+ }
219
302
  function stopCodexRpcEngine() {
303
+ // Invalidate even when the engine has not yet been published: engageCodexRpc
304
+ // may be awaiting a local engine/thread/probe while another IPC handler starts
305
+ // a restart. That stale continuation must never republish the stopped engine.
306
+ rpcEngagementFence.invalidate();
220
307
  const engine = codexRpcEngine;
221
- codexRpcEngine = undefined;
222
- remoteWsUrl = undefined;
223
- remoteThreadId = undefined;
224
- clearRpcEnginePidMarker();
308
+ const ownedRpcTurns = new Set([
309
+ ...rpcTurnsAwaitingActivation.keys(),
310
+ ...rpcLifecycleFailClosedOwners.keys(),
311
+ ...rpcActiveOwners.keys(),
312
+ ...settlingRpcTerminalOwners.keys(),
313
+ ]);
314
+ // stop() emits exact 'stopped' terminals for every engine-owned native turn.
315
+ // Keep the engine identity installed until those synchronous callbacks have
316
+ // retired their matching rpcActive entries.
225
317
  try {
226
318
  engine?.stop();
227
319
  }
228
320
  catch { /* best effort */ }
321
+ // A native terminal can arrive synchronously from engine.stop() while its
322
+ // turn/start continuation is still awaiting activation. handleRpcTurnTerminal
323
+ // buffers that exact terminal; settle it now, before teardown clears the
324
+ // awaiting/pending maps, so an accepted RPC delivery never loses its only
325
+ // terminal at an operator or in-worker restart boundary.
326
+ for (const [ownerKey, pending] of [...pendingRpcTurnTerminals]) {
327
+ const awaiting = rpcTurnsAwaitingActivation.get(ownerKey);
328
+ if (!awaiting || !sameRpcGeneration(awaiting, pending.generation))
329
+ continue;
330
+ const identity = rpcTurnsAwaitingActivationIdentities.get(ownerKey);
331
+ const shouldNotify = !rpcLifecycleFailClosedOwners.has(ownerKey);
332
+ settleRpcTurnTerminal(pending.terminal, pending.generation);
333
+ if (identity && shouldNotify) {
334
+ notifyRpcTeardownBeforeActivation(identity, pending.terminal.status);
335
+ }
336
+ }
337
+ // A follow-up can be accepted by the socket but lose its turn/start response
338
+ // while an operator or liveness restart tears down the engine. RPC submissions
339
+ // intentionally bypass InflightInputTracker (replay would risk duplicate
340
+ // execution), so close every still-unbound logical owner explicitly instead
341
+ // of letting its stale continuation assume an ordinary carryover exists.
342
+ for (const [ownerKey, identity] of [...rpcTurnsAwaitingActivationIdentities]) {
343
+ const awaiting = rpcTurnsAwaitingActivation.get(ownerKey);
344
+ if (!awaiting)
345
+ continue;
346
+ clearAwaitingRpcActivation(identity, awaiting);
347
+ emitTurnTerminal(identity.turnId, 'ambiguous', 'rpc_engine_teardown_before_turn_start_ack', identity.dispatchAttempt);
348
+ if (!rpcLifecycleFailClosedOwners.has(ownerKey)) {
349
+ notifyRpcTeardownBeforeActivation(identity);
350
+ }
351
+ }
352
+ for (const pending of [...rpcTerminalHydrationTerminals.values()]) {
353
+ finalizeRpcTurnTerminal(pending.terminal, pending.generation, true);
354
+ }
355
+ // A dispatched delivery for which the app-server never yielded a native turn
356
+ // id cannot receive engine.stop()'s native terminal callback. Publish one
357
+ // exact logical terminal before clearing the owner so the daemon's durable
358
+ // ledger never waits forever on a teardown-only attempt.
359
+ for (const [ownerKey, identity] of rpcLifecycleFailClosedIdentities) {
360
+ if (!rpcLifecycleFailClosedOwners.has(ownerKey))
361
+ continue;
362
+ emitTurnTerminal(identity.turnId, 'ambiguous', 'rpc_engine_teardown_without_native_terminal', identity.dispatchAttempt);
363
+ }
364
+ codexRpcEngine = undefined;
365
+ remoteWsUrl = undefined;
366
+ remoteThreadId = undefined;
367
+ clearRpcEnginePidMarker();
368
+ // Defensive cleanup for a protocol/transport failure that never yielded a
369
+ // native id. No RPC turn may survive an engine teardown holding the lifecycle
370
+ // gate open.
371
+ for (const turn of codexBridgeQueue.peek()) {
372
+ if (turn.finalText === undefined
373
+ && (turn.rpcActive
374
+ || ownedRpcTurns.has(rpcTurnOwnerKey({
375
+ turnId: turn.turnId,
376
+ ...(turn.dispatchAttempt !== undefined
377
+ ? { dispatchAttempt: turn.dispatchAttempt }
378
+ : {}),
379
+ })))) {
380
+ codexBridgeQueue.stopRpcActive(turn.turnId, turn.dispatchAttempt);
381
+ codexBridgeQueue.dropPendingTurn(turn.turnId, turn.dispatchAttempt, true);
382
+ }
383
+ }
384
+ pendingRpcTurnTerminals.clear();
385
+ rpcTurnsAwaitingActivation.clear();
386
+ rpcTurnsAwaitingActivationIdentities.clear();
387
+ rpcTurnsAwaitingActivationReplayAnchors.clear();
388
+ rpcLifecycleFailClosedOwners.clear();
389
+ rpcLifecycleFailClosedIdentities.clear();
390
+ rpcActiveOwners.clear();
391
+ for (const timer of rpcTerminalHydrationTimers.values())
392
+ clearTimeout(timer);
393
+ rpcTerminalHydrationTimers.clear();
394
+ rpcTerminalHydrationTerminals.clear();
395
+ rpcTerminalHydrationOwners.clear();
396
+ settlingRpcTerminalOwners.clear();
397
+ deferredFreshRpcTurn = undefined;
398
+ stopStructuredStartGraceRecheck();
399
+ structuredRejectedReadyEvidenceGeneration = undefined;
229
400
  }
230
401
  /** Resolve the persistent pane name + liveness for a backend type, mirroring
231
402
  * spawnCli's willReattachPersistent probe (worker.ts) but callable from the init
@@ -566,8 +737,24 @@ async function engageCodexRpc(cfg) {
566
737
  return 'not-engaged';
567
738
  const wantResume = cfg.resume === true && !!cfg.cliSessionId;
568
739
  stopCodexRpcEngine();
740
+ const engagementLease = rpcEngagementFence.begin();
569
741
  let engine;
742
+ let enginePidMarker = null;
743
+ let freshDeliveryOwned = false;
744
+ const assertRpcEngagementCurrent = () => {
745
+ if (!rpcEngagementFence.isCurrent(engagementLease)) {
746
+ throw new CliSpawnSupersededError();
747
+ }
748
+ if (!rpcEngagementFence.isLive(engagementLease)) {
749
+ throw new CodexRpcEngineDiedDuringEngagementError();
750
+ }
751
+ };
570
752
  try {
753
+ // engage runs immediately before spawnCli, which advances the viewer
754
+ // generation once. Bind every callback from this app-server to that target
755
+ // generation so a late terminal from an old server can never retire a
756
+ // same-(turnId,attempt) entry installed by its replacement.
757
+ const engineCliGeneration = cliSpawnGeneration + 1;
571
758
  const cliBin = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride).resolvedBin;
572
759
  const engineEnv = { ...redactChildEnv(process.env) };
573
760
  engineEnv.PATH = prependBotmuxBin(resolveBotmuxWrapperBinDir(process.env), engineEnv.PATH);
@@ -609,7 +796,21 @@ async function engageCodexRpc(cfg) {
609
796
  onRequestUserInput: cfg.cliId === 'traex'
610
797
  ? (params) => bridgeTraexUserInput(cfg, params)
611
798
  : undefined,
799
+ onTurnTerminal: (terminal) => {
800
+ if (!engine)
801
+ return;
802
+ handleRpcTurnTerminal(terminal, {
803
+ engine,
804
+ cliGeneration: engineCliGeneration,
805
+ });
806
+ },
612
807
  onDead: () => {
808
+ // Death can race after the final awaited response but before the engine
809
+ // is published below. Record it against this exact engagement even when
810
+ // codexRpcEngine is still undefined; otherwise the continuation can
811
+ // publish a permanently dead/deadNotified engine whose later failures
812
+ // can no longer trigger onDead recovery.
813
+ rpcEngagementFence.markDead(engagementLease);
613
814
  if (codexRpcEngine === engine) {
614
815
  log('Codex RPC app-server died; replacing the tmux session and re-engaging the thread');
615
816
  // failAll() rejects the active sendTurn promise immediately after this
@@ -627,18 +828,37 @@ async function engageCodexRpc(cfg) {
627
828
  },
628
829
  });
629
830
  await engine.start();
831
+ assertRpcEngagementCurrent();
630
832
  // Shell tools run under the app-server process tree, not the viewer TUI.
631
833
  // Mark its pid before the first turn so `botmux send` resolves the current
632
834
  // per-turn identity instead of falling back to a stale/session-only env.
633
- registerRpcEnginePidMarker(engine.appServerPid);
835
+ enginePidMarker = registerRpcEnginePidMarker(engine.appServerPid);
634
836
  const threadId = wantResume ? await engine.resumeThread(cfg.cliSessionId) : await engine.startThread();
837
+ assertRpcEngagementCurrent();
635
838
  let outcome = wantResume ? 'resumed' : 'accepted';
636
839
  if (!wantResume && cfg.prompt) {
840
+ const firstIdentity = {
841
+ turnId: cfg.turnId ?? `codex-rpc-${randomBytes(8).toString('hex')}`,
842
+ ...(cfg.dispatchAttempt !== undefined
843
+ ? { dispatchAttempt: cfg.dispatchAttempt }
844
+ : {}),
845
+ };
846
+ const firstGeneration = {
847
+ engine,
848
+ cliGeneration: engineCliGeneration,
849
+ };
850
+ installAwaitingRpcActivation(firstIdentity, firstGeneration);
637
851
  // Three-state delivery (P1-1, exactly-once priority): 'accepted' (ack or
638
852
  // rollout evidence), 'not-sent' (frame never dispatched → safe paste), or
639
853
  // 'ambiguous' (dispatched, unconfirmed → engaged but NEVER resend).
640
- const first = await engine.sendFirstTurn(cfg.prompt, cfg.turnId, (tid) => codexRolloutProbe(cfg.cliId, tid, cfg.prompt, 12_000));
641
- if (first === 'not-sent') {
854
+ const first = await engine.sendFirstTurn(cfg.prompt, firstIdentity, (tid) => codexRolloutProbe(cfg.cliId, tid, cfg.prompt, 12_000));
855
+ freshDeliveryOwned = first.outcome !== 'not-sent';
856
+ // restart/close may have settled this exact attempt as ambiguous while the
857
+ // rollout probe was pending. Fence before ANY durable/bridge/global engine
858
+ // mutation; a superseded delivery is never pasted or re-queued.
859
+ assertRpcEngagementCurrent();
860
+ if (first.outcome === 'not-sent') {
861
+ clearAwaitingRpcActivation(firstIdentity, firstGeneration);
642
862
  // The turn/start frame never left → the turn cannot have run → tear the
643
863
  // engine down and fall back to paste. flushPending marks the bridge once
644
864
  // on the paste path — we must NOT pre-mark here or that would double-mark
@@ -651,16 +871,69 @@ async function engageCodexRpc(cfg) {
651
871
  catch { /* best effort */ }
652
872
  return 'not-engaged';
653
873
  }
874
+ // Fresh RPC delivery bypasses flushPending(), which is the normal owner
875
+ // of this durable head-of-line gate. Claim it here only after the frame is
876
+ // known dispatched (accepted or ambiguous); the not-sent branch above
877
+ // safely falls back to paste and lets flushPending claim it once.
878
+ if (firstIdentity.dispatchAttempt !== undefined) {
879
+ durableTurnInFlight = true;
880
+ }
654
881
  // Bridge mark ONLY for a confirmed-accepted turn — so the structured
655
882
  // fallback can attribute the reply even if the model skips `botmux send`.
656
883
  // Marked here (after the outcome, before persistCliSessionId/attach — late
657
- // attach from offset 0 still matches, timing-safe). 'ambiguous' does NOT
658
- // mark: there is no positive evidence the turn ran, and an unstarted head
659
- // would permanently block every later turn's drain the notify + Web
660
- // terminal are the authoritative recovery surface (Codex P1).
661
- if (shouldPreMarkFirstTurn(first))
662
- codexBridgeMarkPendingTurn(cfg.prompt, cfg.turnId);
663
- if (first === 'accepted' && cfg.queuedActivationToken) {
884
+ // attach from offset 0 still matches, timing-safe). An ambiguous outcome
885
+ // gets an attribution-only mark plus a separate fail-closed owner below:
886
+ // it cannot publish false idle or head-of-line a successor because no
887
+ // successor is admitted until terminal/teardown retires that owner.
888
+ if (shouldPreMarkFirstTurn(first.outcome)) {
889
+ if (!first.nativeTurnId) {
890
+ // Positive rollout evidence proves delivery, but without a native id
891
+ // no terminal can be associated precisely. Preserve exactly-once
892
+ // (never paste/requeue) and hold the explicit fail-closed lifecycle
893
+ // gate until a structured terminal or engine teardown instead of
894
+ // guessing a latest turn.
895
+ codexBridgeMarkPendingTurn(cfg.prompt, firstIdentity.turnId, firstIdentity.dispatchAttempt, awaitingRpcActivationReplayAnchorMs(firstIdentity, firstGeneration));
896
+ installRpcLifecycleFailClosedOwner(firstIdentity, firstGeneration);
897
+ deferredFreshRpcTurn = {
898
+ identity: firstIdentity,
899
+ generation: firstGeneration,
900
+ };
901
+ log(`Codex RPC fresh accepted turn has no native id; lifecycle failed closed for ${firstIdentity.turnId}`);
902
+ send({
903
+ type: 'user_notify',
904
+ message: 'Codex RPC 首条消息已确认落盘,但无法取得原生 turn id;为避免重复执行未重发,并保持忙碌直到检测到终态或会话重启。',
905
+ turnId: firstIdentity.turnId,
906
+ ...(firstIdentity.dispatchAttempt !== undefined
907
+ ? { dispatchAttempt: firstIdentity.dispatchAttempt }
908
+ : {}),
909
+ });
910
+ }
911
+ else {
912
+ // Keep terminal delivery deferred until spawnCli has attached and
913
+ // baselined the rollout; otherwise an ultra-fast first completion can
914
+ // retire the bridge mark before fallback output is harvested.
915
+ activateRpcTurnLifecycle(firstIdentity, cfg.prompt, false, firstGeneration, true);
916
+ deferredFreshRpcTurn = {
917
+ identity: firstIdentity,
918
+ generation: firstGeneration,
919
+ };
920
+ }
921
+ }
922
+ else {
923
+ // A dispatched-but-unconfirmed first turn is never re-sent. Keep an
924
+ // exact attribution mark plus an explicit fail-closed gate even without
925
+ // a native id. A structured transcript terminal can retire it; a later
926
+ // native terminal without an owner is intentionally ignored instead of
927
+ // guessed. If no structured terminal becomes visible, exact engine
928
+ // teardown is the expected fallback and drops this attempt's mark.
929
+ codexBridgeMarkPendingTurn(cfg.prompt, firstIdentity.turnId, firstIdentity.dispatchAttempt, awaitingRpcActivationReplayAnchorMs(firstIdentity, firstGeneration));
930
+ installRpcLifecycleFailClosedOwner(firstIdentity, firstGeneration);
931
+ deferredFreshRpcTurn = {
932
+ identity: firstIdentity,
933
+ generation: firstGeneration,
934
+ };
935
+ }
936
+ if (first.outcome === 'accepted' && cfg.queuedActivationToken) {
664
937
  // Fresh RPC input bypasses pendingMessages/flushPending entirely. The
665
938
  // app-server's accepted turn/start (or positive rollout proof) is the
666
939
  // durable submission boundary for the daemon's queued-opening journal.
@@ -670,7 +943,7 @@ async function engageCodexRpc(cfg) {
670
943
  activationToken: cfg.queuedActivationToken,
671
944
  });
672
945
  }
673
- outcome = first; // 'accepted' | 'ambiguous' — both stay engaged, prompt never re-queued
946
+ outcome = first.outcome; // accepted | ambiguous — both stay engaged, prompt never re-queued
674
947
  }
675
948
  codexRpcEngine = engine;
676
949
  remoteWsUrl = engine.wsUrl;
@@ -683,15 +956,43 @@ async function engageCodexRpc(cfg) {
683
956
  return outcome;
684
957
  }
685
958
  catch (err) {
959
+ if (err instanceof CliSpawnSupersededError
960
+ || !rpcEngagementFence.isCurrent(engagementLease)) {
961
+ log('Codex RPC engagement was superseded; stopping only its local app-server and preserving the replacement generation');
962
+ try {
963
+ engine?.stop();
964
+ }
965
+ catch { /* best effort */ }
966
+ if (enginePidMarker)
967
+ clearRpcEnginePidMarker(enginePidMarker);
968
+ throw err instanceof CliSpawnSupersededError
969
+ ? err
970
+ : new CliSpawnSupersededError();
971
+ }
972
+ if (err instanceof CodexRpcEngineDiedDuringEngagementError && freshDeliveryOwned) {
973
+ // The first frame may already have executed. Never turn this into
974
+ // `not-engaged`, because the init path would paste the same prompt into a
975
+ // native viewer and duplicate side effects. Tear down exact local state
976
+ // and fail the worker generation so durable recovery owns the next step.
977
+ log('Codex RPC app-server died after the fresh delivery boundary; aborting init without paste fallback');
978
+ clearRpcEnginePidMarker();
979
+ try {
980
+ engine?.stop();
981
+ }
982
+ catch { /* best effort */ }
983
+ stopCodexRpcEngine();
984
+ throw err;
985
+ }
686
986
  log(`Codex RPC input failed to start (${err?.message ?? err}); falling back to paste mode`);
687
987
  clearRpcEnginePidMarker();
688
988
  try {
689
989
  engine?.stop();
690
990
  }
691
991
  catch { /* best effort */ } // P1-3a: stop the LOCAL ref (codexRpcEngine may be unassigned)
692
- codexRpcEngine = undefined;
693
- remoteWsUrl = undefined;
694
- remoteThreadId = undefined;
992
+ // The local engine may not yet have been published into codexRpcEngine.
993
+ // Still clear any exact lifecycle state its callbacks installed before the
994
+ // failure; otherwise paste fallback could inherit a stale fail-closed mark.
995
+ stopCodexRpcEngine();
695
996
  return 'not-engaged';
696
997
  }
697
998
  }
@@ -754,6 +1055,9 @@ function armRpcStartupDialogDismiss() {
754
1055
  };
755
1056
  rpcDialogDismissTimer = setTimeout(tick, 2500);
756
1057
  }
1058
+ /** Monotonic identity for the owned/adopted CLI backend generation. Deferred
1059
+ * callbacks capture it and must re-check after every await before touching
1060
+ * transcript lifecycle or user-visible state. */
757
1061
  let cliSpawnGeneration = 0;
758
1062
  class CliSpawnSupersededError extends Error {
759
1063
  constructor() {
@@ -761,6 +1065,12 @@ class CliSpawnSupersededError extends Error {
761
1065
  this.name = 'CliSpawnSupersededError';
762
1066
  }
763
1067
  }
1068
+ class CodexRpcEngineDiedDuringEngagementError extends Error {
1069
+ constructor() {
1070
+ super('Codex RPC app-server died before engagement publication');
1071
+ this.name = 'CodexRpcEngineDiedDuringEngagementError';
1072
+ }
1073
+ }
764
1074
  let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
765
1075
  let seatbeltProfilePath = null; // per-session Seatbelt .sb profile to rm at exit (external-wrapper read isolation)
766
1076
  let sandboxStopWatcher = null; // stop fn for the sandbox outbox watcher
@@ -1045,6 +1355,10 @@ let activeRestartAttemptId;
1045
1355
  /** Distinguishes a replacement's synchronous ready signal (Riff) from a late
1046
1356
  * idle callback emitted by the backend being torn down. */
1047
1357
  let replacementSpawnInProgress = false;
1358
+ let structuredStartGraceRecheckTimer = null;
1359
+ let structuredRejectedReadyEvidenceGeneration;
1360
+ const ptyOutputGeneration = new PtyOutputGeneration();
1361
+ const adoptWriteQueue = new AsyncSerialQueue();
1048
1362
  /** The effectiveResume flag used by the most recent spawnCli call. Written
1049
1363
  * immediately after the two-tier fallback check so late-attach timers
1050
1364
  * (hermes, cursor, etc.) can read THE SAME semantics the spawn used,
@@ -1527,6 +1841,25 @@ function shortCorrelationId(value) {
1527
1841
  * an owned CLI restart was fenced. Normal raw_input commands are still
1528
1842
  * delivered immediately (including while busy). */
1529
1843
  const pendingRawInputs = freshnessInputQueue.raw;
1844
+ /** Adopt messages accepted while native /rename owns the TUI. They cannot use
1845
+ * pendingMessages because adopt writes need writeAdoptMessage's transcript
1846
+ * baseline + complete adapter lifecycle. flushPending drains them only after
1847
+ * deferred raw commands and the latest native rename have settled. */
1848
+ const pendingAdoptMessages = [];
1849
+ function captureAdoptWriteFence() {
1850
+ if (!backend || cliRestartInProgress || rawInputRestartGate)
1851
+ return undefined;
1852
+ return { generation: cliSpawnGeneration, backend };
1853
+ }
1854
+ /** A queued adopt task may outlive its CLI. Never let process-lifetime queue
1855
+ * work run against backend=null or a replacement backend generation. Tasks
1856
+ * that fail this check have not written anything and are safe to requeue. */
1857
+ function adoptWriteFenceIsCurrent(fence) {
1858
+ return cliSpawnGeneration === fence.generation
1859
+ && backend === fence.backend
1860
+ && !cliRestartInProgress
1861
+ && !rawInputRestartGate;
1862
+ }
1530
1863
  /** Latest requested canonical session title. Unlike a normal prompt this is an
1531
1864
  * administrative TUI command: never type-ahead while the agent is busy, never
1532
1865
  * open a model turn, and latest-wins if several renames arrive before idle. */
@@ -1546,32 +1879,45 @@ function stopNativeSessionTitleSync() {
1546
1879
  forceClose();
1547
1880
  nativeSessionTitleSyncForceClosers.clear();
1548
1881
  }
1549
- /** True after the rename command's Enter lands and until the TUI returns to its
1550
- * prompt. Blocks type-ahead user messages from racing into the command UI. */
1551
- let sessionRenameInFlight = false;
1882
+ let sessionRenamePhase = 'idle';
1552
1883
  const SESSION_RENAME_IDLE_TIMEOUT_MS = 5_000;
1553
1884
  let sessionRenameIdleTimer = null;
1554
- function clearSessionRenameInFlight() {
1885
+ function sessionRenameInFlight() {
1886
+ return sessionRenamePhase !== 'idle';
1887
+ }
1888
+ function forceClearSessionRenameInFlight() {
1555
1889
  if (sessionRenameIdleTimer) {
1556
1890
  clearTimeout(sessionRenameIdleTimer);
1557
1891
  sessionRenameIdleTimer = null;
1558
1892
  }
1559
- sessionRenameInFlight = false;
1893
+ sessionRenamePhase = 'idle';
1894
+ }
1895
+ /** A prompt can release rename only after the command Enter was actually sent.
1896
+ * A fast final from an older adopt write may arrive while rename is merely
1897
+ * reserved behind the queue; keep that reservation and let the queued helper
1898
+ * consume the newly-ready prompt. */
1899
+ function settleSessionRenameOnPrompt() {
1900
+ if (sessionRenamePhase === 'sent')
1901
+ forceClearSessionRenameInFlight();
1902
+ }
1903
+ function settleFailedSessionRenameWrite() {
1904
+ if (sessionRenamePhase === 'writing')
1905
+ sessionRenamePhase = 'sent';
1560
1906
  }
1561
1907
  /** Fail open if a CLI executes /rename without emitting enough redraw output
1562
1908
  * for IdleDetector to rediscover the prompt. The verified Codex/Claude paths
1563
1909
  * normally settle immediately; this cap prevents one administrative command
1564
1910
  * from wedging all later Lark messages forever. */
1565
1911
  function armSessionRenameIdleTimeout() {
1566
- if (!sessionRenameInFlight)
1912
+ if (sessionRenamePhase !== 'sent')
1567
1913
  return;
1568
1914
  if (sessionRenameIdleTimer)
1569
1915
  clearTimeout(sessionRenameIdleTimer);
1570
1916
  sessionRenameIdleTimer = setTimeout(() => {
1571
1917
  sessionRenameIdleTimer = null;
1572
- if (!sessionRenameInFlight)
1918
+ if (sessionRenamePhase !== 'sent')
1573
1919
  return;
1574
- sessionRenameInFlight = false;
1920
+ sessionRenamePhase = 'idle';
1575
1921
  // The timeout specifically means prompt redraw detection failed. Fail open
1576
1922
  // by restoring the gate explicitly; otherwise a deferred raw_input is the
1577
1923
  // only queued work and flushPending would keep waiting for the very signal
@@ -1587,96 +1933,147 @@ function armSessionRenameIdleTimeout() {
1587
1933
  * Commands received while /rename owns the TUI are deferred by the IPC handler
1588
1934
  * and come through this same function after the prompt returns. */
1589
1935
  async function deliverRawInput(msg) {
1590
- const targetBackend = backend;
1591
- if (!targetBackend)
1592
- return;
1593
- let sent = false;
1594
- let recoveryFailureReason;
1595
- try {
1596
- await sendRawCommandLineWithRecoveryFence(targetBackend, msg.content, () => {
1597
- renderer?.markNewTurn();
1598
- usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
1599
- if (tmuxScrolledHalfPages > 0)
1600
- exitTmuxScrollMode();
1601
- // Rotate (or revoke) attribution only after both submission locks are
1602
- // held and immediately before the literal command reaches the CLI.
1603
- // A queued passthrough must not steal output from the preceding turn.
1604
- currentBotmuxTurnId = msg.turnId;
1605
- currentBotmuxDispatchAttempt = undefined;
1606
- currentVcMeetingImTurnOrigin = undefined;
1607
- writeCliPidMarker();
1608
- publishSandboxRelayCapability();
1609
- });
1610
- sent = true;
1611
- // The accepted side effects (isPromptReady/idle reset/log) are owned by
1612
- // finalizeRawCommandDelivery.onAccepted below so the durable-activation
1613
- // ACK and follow-up enqueue stay in one ordered dispatcher.
1614
- // A mid-session `/effort <level>` opens a Yes/No "Change effort level?"
1615
- // confirm dialog; arm the guard so onPtyData can auto-accept it. Bare
1616
- // `/effort` opens a level picker (unknown target) and is left alone.
1617
- if (isEffortLevelCommand(msg.content))
1618
- armEffortConfirm();
1619
- }
1620
- catch (err) {
1621
- recoveryFailureReason = err instanceof SubmissionWriteError
1622
- ? err.recoveryFailureReason
1623
- : undefined;
1624
- // Do not send another queued command against a backend whose write failed.
1625
- isPromptReady = false;
1626
- log(`Passthrough slash command failed (${msg.content}): ${err?.message ?? err}`);
1627
- const failedTurnId = msg.followUpTurnId ?? msg.turnId;
1628
- if (failedTurnId && !recoveryFailureReason) {
1629
- emitTurnTerminal(failedTurnId, 'ambiguous', 'raw_input_write_failed');
1630
- }
1631
- const failureMessageKey = msg.followUpContent
1632
- ? (recoveryFailureReason
1633
- ? 'worker.raw_input_failed_recovery'
1634
- : 'worker.raw_input_failed')
1635
- : (recoveryFailureReason
1636
- ? 'worker.raw_input_failed_command_only_recovery'
1637
- : 'worker.raw_input_failed_command_only');
1638
- send({
1639
- type: 'user_notify',
1640
- ...(failedTurnId ? { turnId: failedTurnId } : {}),
1641
- message: t(failureMessageKey, {
1642
- cliName: cliName(),
1643
- reason: recoveryFailureReason ?? '',
1936
+ const writeRawInput = async (targetBackend, fence, onFollowUp) => {
1937
+ let sent = false;
1938
+ let recoveryFailureReason;
1939
+ try {
1940
+ await sendRawCommandLineWithRecoveryFence(targetBackend, msg.content, () => {
1941
+ renderer?.markNewTurn();
1942
+ usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
1943
+ if (tmuxScrolledHalfPages > 0)
1944
+ exitTmuxScrollMode();
1945
+ currentBotmuxTurnId = msg.turnId;
1946
+ currentBotmuxDispatchAttempt = undefined;
1947
+ currentVcMeetingImTurnOrigin = undefined;
1948
+ writeCliPidMarker();
1949
+ publishSandboxRelayCapability();
1950
+ });
1951
+ sent = true;
1952
+ if (fence && !adoptWriteFenceIsCurrent(fence)) {
1953
+ send({
1954
+ type: 'user_notify',
1955
+ message: `Passthrough command ${msg.content} became ambiguous because the adopted CLI backend changed while it was being written. Its dependent follow-up was withheld; please verify the terminal and retry the bundle explicitly.`,
1956
+ });
1957
+ sent = false;
1958
+ }
1959
+ }
1960
+ catch (err) {
1961
+ recoveryFailureReason = err instanceof SubmissionWriteError
1962
+ ? err.recoveryFailureReason
1963
+ : undefined;
1964
+ // Do not send a bundled follow-up or another queued command against a
1965
+ // backend whose literal command write failed.
1966
+ log(`Passthrough slash command failed (${msg.content}): ${err?.message ?? err}`);
1967
+ const failedTurnId = msg.followUpTurnId ?? msg.turnId;
1968
+ if (failedTurnId && !recoveryFailureReason) {
1969
+ emitTurnTerminal(failedTurnId, 'ambiguous', 'raw_input_write_failed');
1970
+ }
1971
+ send({
1972
+ type: 'user_notify',
1973
+ ...(failedTurnId ? { turnId: failedTurnId } : {}),
1974
+ message: t(msg.followUpContent
1975
+ ? (recoveryFailureReason ? 'worker.raw_input_failed_recovery' : 'worker.raw_input_failed')
1976
+ : (recoveryFailureReason ? 'worker.raw_input_failed_command_only_recovery' : 'worker.raw_input_failed_command_only'), {
1977
+ cliName: cliName(),
1978
+ reason: recoveryFailureReason ?? '',
1979
+ }),
1980
+ });
1981
+ }
1982
+ const accepted = finalizeRawCommandDelivery({
1983
+ accepted: sent,
1984
+ durableActivation: !!msg.queuedActivationToken,
1985
+ acknowledgeActivation: !!msg.queuedActivationToken,
1986
+ hasFollowUp: !!onFollowUp,
1987
+ onAccepted: () => {
1988
+ isPromptReady = false;
1989
+ idleDetector?.reset();
1990
+ log(`Passthrough slash command: ${msg.content}`);
1991
+ },
1992
+ // Non-adopt follows the normal busy queue. Adopt keeps the complete
1993
+ // adapter lifecycle in runAdoptRawInputSequence below.
1994
+ onFollowUp: () => onFollowUp?.(),
1995
+ // Pending-repo raw openings are durable too. ACK only after text + Enter.
1996
+ onActivationAck: () => send({
1997
+ type: 'queued_activation_submitted',
1998
+ sessionId,
1999
+ activationToken: msg.queuedActivationToken,
1644
2000
  }),
2001
+ onDurableFailure: () => {
2002
+ isPromptReady = false;
2003
+ log('Durable raw activation write rejected; retiring worker generation');
2004
+ void sendFatalWorkerErrorAndExit(new Error('durable raw activation was not accepted by the backend'), msg.turnId);
2005
+ },
2006
+ });
2007
+ if (!accepted && !msg.queuedActivationToken) {
2008
+ log(`Passthrough slash command was not accepted by the backend: ${msg.content}`);
2009
+ }
2010
+ return accepted;
2011
+ };
2012
+ if (lastInitConfig?.adoptMode) {
2013
+ const fence = captureAdoptWriteFence();
2014
+ if (!fence) {
2015
+ pendingRawInputs.push(msg);
2016
+ log(`Deferred adopt passthrough until a stable CLI generation is ready: ${msg.content}`);
2017
+ return;
2018
+ }
2019
+ const followUpContent = msg.followUpContent;
2020
+ // Adopt raw commands, their bundled follow-up and ordinary adopt messages
2021
+ // all share one queue. Keep the queue until writeAdoptMessage has completed
2022
+ // transcript marking, adapter/history verification and lifecycle settling.
2023
+ await runAdoptRawInputSequence({
2024
+ queue: adoptWriteQueue,
2025
+ isCurrent: () => adoptWriteFenceIsCurrent(fence),
2026
+ onStaleBeforeWrite: () => {
2027
+ pendingRawInputs.push(msg);
2028
+ log(`Re-queued stale adopt passthrough for the replacement CLI generation: ${msg.content}`);
2029
+ },
2030
+ onStaleBeforeFollowUp: () => {
2031
+ if (!followUpContent)
2032
+ return;
2033
+ // The raw command already landed on the previous backend. Replaying
2034
+ // only its dependent prompt into the replacement could run in the
2035
+ // wrong repo/session (for example after `/cd ...`). Hold the bundle
2036
+ // for an explicit user retry instead of splitting its atomic meaning.
2037
+ send({
2038
+ type: 'user_notify',
2039
+ message: `Passthrough command ${msg.content} completed on the previous CLI, but the backend changed before its dependent follow-up. The follow-up was withheld; verify the terminal and retry the bundle explicitly.`,
2040
+ });
2041
+ log(`Withheld bundled adopt follow-up after CLI generation changed (${followUpContent.length} chars)`);
2042
+ },
2043
+ writeRawInput: () => writeRawInput(fence.backend, fence),
2044
+ ...(followUpContent
2045
+ ? {
2046
+ writeFollowUp: async () => {
2047
+ const result = await writeAdoptMessage(followUpContent, msg.followUpTurnId, undefined, undefined, fence);
2048
+ if (result === 'stale-before-write') {
2049
+ send({
2050
+ type: 'user_notify',
2051
+ message: `The CLI backend changed after passthrough command ${msg.content} but before its dependent follow-up. The follow-up was withheld; verify the terminal and retry the bundle explicitly.`,
2052
+ });
2053
+ log(`Withheld stale bundled adopt follow-up after raw command (${followUpContent.length} chars)`);
2054
+ }
2055
+ else if (result === 'completed') {
2056
+ log(`Completed adopt follow-up after raw input (${followUpContent.length} chars)`);
2057
+ }
2058
+ },
2059
+ }
2060
+ : {}),
1645
2061
  });
1646
2062
  }
1647
- finalizeRawCommandDelivery({
1648
- accepted: sent,
1649
- durableActivation: !!msg.queuedActivationToken,
1650
- acknowledgeActivation: !!msg.queuedActivationToken,
1651
- hasFollowUp: !!msg.followUpContent,
1652
- onAccepted: () => {
1653
- isPromptReady = false;
1654
- idleDetector?.reset();
1655
- log(`Passthrough slash command: ${msg.content}`);
1656
- },
1657
- // Follow-up rides on the same IPC and is enqueued only after this command's
1658
- // Enter lands. sendToPty also observes commandLineWritesPending, so another
1659
- // raw command's text -> Enter window cannot be interrupted by the follow-up.
1660
- onFollowUp: () => {
1661
- sendToPty(msg.followUpContent, msg.followUpTurnId, {
1662
- codexAppInput: msg.followUpCodexAppInput,
1663
- });
1664
- log(`Enqueued follow-up after raw input (${msg.followUpContent.length} chars)`);
1665
- },
1666
- // Pending-repo raw openings are durable too. ACK only after text + Enter.
1667
- onActivationAck: () => send({
1668
- type: 'queued_activation_submitted',
1669
- sessionId,
1670
- activationToken: msg.queuedActivationToken,
1671
- }),
1672
- onDurableFailure: () => {
1673
- isPromptReady = false;
1674
- log(`Durable raw activation write rejected; retiring worker generation`);
1675
- void sendFatalWorkerErrorAndExit(new Error('durable raw activation was not accepted by the backend'), msg.turnId);
1676
- },
1677
- });
1678
- if (!sent && !msg.queuedActivationToken) {
1679
- log(`Passthrough slash command was not accepted by the backend: ${msg.content}`);
2063
+ else {
2064
+ const targetBackend = backend;
2065
+ if (!targetBackend) {
2066
+ pendingRawInputs.push(msg);
2067
+ return;
2068
+ }
2069
+ await writeRawInput(targetBackend, undefined, msg.followUpContent
2070
+ ? () => {
2071
+ sendToPty(msg.followUpContent, msg.followUpTurnId, {
2072
+ codexAppInput: msg.followUpCodexAppInput,
2073
+ });
2074
+ log(`Enqueued follow-up after raw input (${msg.followUpContent.length} chars)`);
2075
+ }
2076
+ : undefined);
1680
2077
  }
1681
2078
  // A pending /rename may have been held by the command-write mutex. It still
1682
2079
  // waits for a genuine prompt because isPromptReady was cleared above.
@@ -1860,7 +2257,9 @@ function authorizeManagedSend(claim) {
1860
2257
  }
1861
2258
  : { ok: false, error: `${decision.errorCode}: ${decision.error}` };
1862
2259
  }
1863
- function clearRpcEnginePidMarker() {
2260
+ function clearRpcEnginePidMarker(expectedMarker) {
2261
+ if (expectedMarker !== undefined && rpcEnginePidMarker !== expectedMarker)
2262
+ return;
1864
2263
  if (!rpcEnginePidMarker)
1865
2264
  return;
1866
2265
  try {
@@ -1872,17 +2271,19 @@ function clearRpcEnginePidMarker() {
1872
2271
  function registerRpcEnginePidMarker(pid) {
1873
2272
  clearRpcEnginePidMarker();
1874
2273
  if (!pid || !process.env.SESSION_DATA_DIR)
1875
- return;
2274
+ return null;
1876
2275
  try {
1877
2276
  const markersDir = join(process.env.SESSION_DATA_DIR, '.botmux-cli-pids');
1878
2277
  mkdirSync(markersDir, { recursive: true });
1879
2278
  rpcEnginePidMarker = join(markersDir, String(pid));
1880
2279
  writeCliPidMarker();
1881
2280
  log(`Codex RPC app-server PID marker written: ${pid}`);
2281
+ return rpcEnginePidMarker;
1882
2282
  }
1883
2283
  catch (err) {
1884
2284
  rpcEnginePidMarker = null;
1885
2285
  log(`Failed to write Codex RPC app-server PID marker: ${err?.message ?? err}`);
2286
+ return null;
1886
2287
  }
1887
2288
  }
1888
2289
  function writeCliPidMarker() {
@@ -4346,6 +4747,23 @@ function codexBridgeFallbackActive() {
4346
4747
  // services/structured-bridge-clis.ts (single source of the allowlist).
4347
4748
  return isStructuredBridgeFallbackActive(lastInitConfig?.cliId, lastInitConfig?.adoptMode === true);
4348
4749
  }
4750
+ /** Only drivers with a complete normal-final + interrupted-terminal contract
4751
+ * may let a transcript-started turn override the screen-ready heuristic. */
4752
+ function hasStructuredLifecycleBlock() {
4753
+ if (rpcTurnsAwaitingActivation.size > 0)
4754
+ return true;
4755
+ if (rpcLifecycleFailClosedOwners.size > 0)
4756
+ return true;
4757
+ if (rpcTerminalHydrationOwners.size > 0)
4758
+ return true;
4759
+ // rpcActive is an explicit native app-server ownership proof and is valid for
4760
+ // every RPC-capable Codex-family adapter (currently codex + traex), including
4761
+ // drivers intentionally excluded from transcript-only lifecycle blocking.
4762
+ if (codexBridgeQueue.peek().some(turn => turn.rpcActive === true && turn.finalText === undefined))
4763
+ return true;
4764
+ return isStructuredBridgeLifecycleBlockingCli(lastInitConfig?.cliId)
4765
+ && codexBridgeQueue.hasBlockingTurn();
4766
+ }
4349
4767
  function structuredBridgeIsCodex() {
4350
4768
  return lastInitConfig?.cliId === 'codex';
4351
4769
  }
@@ -4400,7 +4818,10 @@ function codexBridgeStartTimer() {
4400
4818
  // up. Emitting here when isPromptReady=true closes that window.
4401
4819
  // Codex's queue only releases turns on `assistant_final` (the model's
4402
4820
  // declared end-of-turn), so a tick-driven emit can't accidentally
4403
- // publish a half-streamed response.
4821
+ // publish a half-streamed response. The finally-path also expires stale
4822
+ // attribution heads after every tick even when no transcript exists yet or
4823
+ // its offset produced no events; otherwise an adapter returning undefined
4824
+ // before late-attach could leave a bare fingerprint at the head forever.
4404
4825
  codexBridgeTimer = setInterval(() => {
4405
4826
  try {
4406
4827
  if (structuredBridgeIsHermes()) {
@@ -4512,6 +4933,18 @@ function codexBridgeStartTimer() {
4512
4933
  catch (err) {
4513
4934
  log(`Codex bridge tick error: ${err.message}`);
4514
4935
  }
4936
+ finally {
4937
+ // All branch returns above still pass through here. Ingest always gets
4938
+ // first chance to claim a boundary-time user event; only afterwards do
4939
+ // we expire an unstarted head, replay buffered successors, and emit any
4940
+ // completion created by that replay in the same call stack.
4941
+ try {
4942
+ pruneExpiredStructuredHeadsAndEmit('structured bridge tick');
4943
+ }
4944
+ catch (err) {
4945
+ log(`Codex bridge tick expiry error: ${err.message}`);
4946
+ }
4947
+ }
4515
4948
  }, 1000);
4516
4949
  }
4517
4950
  function hermesBridgeAttach(mode) {
@@ -4547,7 +4980,8 @@ function hermesBridgeIngest() {
4547
4980
  if (filtered.events.length > 0)
4548
4981
  lastStructuredBridgeActivityAtMs = Date.now();
4549
4982
  codexBridgeQueue.ingest(filtered.events);
4550
- if (filtered.events.some(e => e.kind === 'assistant_final')) {
4983
+ pruneExpiredStructuredHeadsAndEmit('Hermes ingest');
4984
+ if (filtered.events.some(event => event.kind === 'assistant_final')) {
4551
4985
  idleDetector?.fireIdle();
4552
4986
  }
4553
4987
  }
@@ -4559,6 +4993,7 @@ function mtrBridgeAttach(source, mode) {
4559
4993
  const { history, live } = splitCodexEventsByCutoff(result.events, cutoff);
4560
4994
  codexBridgeQueue.absorb(history);
4561
4995
  codexBridgeQueue.ingest(live);
4996
+ pruneExpiredStructuredHeadsAndEmit('MTR split-live attach');
4562
4997
  mtrBridgeOffset = result.newOffset;
4563
4998
  mtrBridgeBaselineDone = true;
4564
4999
  log(`MTR bridge split-live: ${source.dbPath}#${source.sessionId} (history=${history.length}, live=${live.length}, cutoff=${cutoff}, offset=${mtrBridgeOffset})`);
@@ -4587,7 +5022,8 @@ function mtrBridgeIngest() {
4587
5022
  if (result.events.length > 0)
4588
5023
  lastStructuredBridgeActivityAtMs = Date.now();
4589
5024
  codexBridgeQueue.ingest(result.events);
4590
- if (result.events.some(e => e.kind === 'assistant_final')) {
5025
+ pruneExpiredStructuredHeadsAndEmit('MTR ingest');
5026
+ if (result.events.some(event => event.kind === 'assistant_final')) {
4591
5027
  idleDetector?.fireIdle();
4592
5028
  }
4593
5029
  }
@@ -4619,6 +5055,14 @@ function codexBridgeAttach(rolloutPath, mode) {
4619
5055
  const { history, live } = splitCodexEventsByCutoff(result.events, cutoff);
4620
5056
  codexBridgeQueue.absorb(history);
4621
5057
  codexBridgeQueue.ingest(live);
5058
+ pruneExpiredStructuredHeadsAndEmit('structured split-live attach');
5059
+ // Late attach can discover an already-completed live turn in the same
5060
+ // drain. Re-drive prompt readiness from that terminal event immediately;
5061
+ // otherwise a ready edge rejected by the lifecycle gate waits for the
5062
+ // 20-30s lease timer even though the transcript has already ended.
5063
+ if (live.some(event => event.kind === 'assistant_final')) {
5064
+ idleDetector?.fireIdle();
5065
+ }
4622
5066
  codexBridgeOffset = result.newOffset;
4623
5067
  codexBridgePendingTail = result.pendingTail;
4624
5068
  codexBridgeBaselineDone = true;
@@ -5107,6 +5551,13 @@ function maybeFollowTraexSessionRotationViaPid() {
5107
5551
  codexBridgeNotifyCliSessionId(observed.cliSessionId);
5108
5552
  }
5109
5553
  function codexBridgeIngest(opts = {}) {
5554
+ // Follow-up RPC turns install their exact bridge mark only after the
5555
+ // turn/start ACK passes the generation fence. Ordinary ingest must not
5556
+ // advance the rollout cursor in that window. Terminal hydration for an
5557
+ // older owner may continue: successor events reached by the same drain stay
5558
+ // buffered until the exact successor mark is installed.
5559
+ if (rpcTranscriptIngestBlockedByAwaitingActivation(rpcTurnsAwaitingActivation.keys(), opts.hydrationOwnerKey))
5560
+ return;
5110
5561
  if (structuredBridgeIsHermes()) {
5111
5562
  hermesBridgeIngest();
5112
5563
  return;
@@ -5139,13 +5590,14 @@ function codexBridgeIngest(opts = {}) {
5139
5590
  if (result.events.length > 0)
5140
5591
  lastStructuredBridgeActivityAtMs = Date.now();
5141
5592
  codexBridgeQueue.ingest(result.events);
5142
- // Transcript-driven idle: an `assistant_final` event is the CLI declaring
5143
- // end-of-turn, far more reliable than the screen-pattern heuristic
5593
+ pruneExpiredStructuredHeadsAndEmit('structured ingest');
5594
+ // Transcript-driven idle: a normal `assistant_final` or no-output
5595
+ // `turn_aborted` is Codex declaring end-of-turn, far more reliable than the screen-pattern heuristic
5144
5596
  // (CoCo's status bar varies by --yolo flag, version, theme; codex has
5145
5597
  // its own moving targets). Pushing idle here lets the bridge emit
5146
5598
  // immediately instead of waiting for readyPattern + quiescence to
5147
5599
  // converge. Idempotent — IdleDetector.fireIdle no-ops while already idle.
5148
- if (opts.signalIdle !== false && result.events.some(e => e.kind === 'assistant_final')) {
5600
+ if (opts.signalIdle !== false && result.events.some(event => event.kind === 'assistant_final')) {
5149
5601
  idleDetector?.fireIdle();
5150
5602
  }
5151
5603
  }
@@ -5172,11 +5624,309 @@ function maybeEmitCodexStructuredRateLimit(events) {
5172
5624
  /** Mark a pending Lark turn for Codex. Crucially this works even before a
5173
5625
  * rollout path is known — the queue is path-agnostic, and ingest after
5174
5626
  * late-attach picks up the user_message and matches the fingerprint. */
5175
- function codexBridgeMarkPendingTurn(messageText, preferredTurnId, dispatchAttempt) {
5627
+ function codexBridgeMarkPendingTurn(messageText, preferredTurnId, dispatchAttempt, markTimeMs = Date.now()) {
5176
5628
  if (!codexBridgeFallbackActive())
5177
- return false;
5629
+ return undefined;
5178
5630
  const turnId = preferredTurnId ?? `codex-${randomBytes(8).toString('hex')}`;
5179
- codexBridgeQueue.mark(turnId, messageText, Date.now(), dispatchAttempt);
5631
+ codexBridgeQueue.mark(turnId, messageText, markTimeMs, dispatchAttempt);
5632
+ return turnId;
5633
+ }
5634
+ function finalizeRpcTurnTerminal(terminal, generation, dropPending) {
5635
+ const { identity } = terminal;
5636
+ const ownerKey = rpcTurnOwnerKey(identity);
5637
+ if (!sameRpcGeneration(settlingRpcTerminalOwners.get(ownerKey), generation))
5638
+ return;
5639
+ const timer = rpcTerminalHydrationTimers.get(ownerKey);
5640
+ if (timer)
5641
+ clearTimeout(timer);
5642
+ rpcTerminalHydrationTimers.delete(ownerKey);
5643
+ if (sameRpcGeneration(rpcTerminalHydrationOwners.get(ownerKey), generation)) {
5644
+ rpcTerminalHydrationOwners.delete(ownerKey);
5645
+ rpcTerminalHydrationTerminals.delete(ownerKey);
5646
+ }
5647
+ settlingRpcTerminalOwners.delete(ownerKey);
5648
+ clearAwaitingRpcActivation(identity, generation);
5649
+ clearRpcLifecycleFailClosedOwner(identity, generation);
5650
+ if (sameRpcGeneration(rpcActiveOwners.get(ownerKey), generation)) {
5651
+ rpcActiveOwners.delete(ownerKey);
5652
+ }
5653
+ codexBridgeQueue.stopRpcActive(identity.turnId, identity.dispatchAttempt);
5654
+ if (dropPending) {
5655
+ codexBridgeQueue.dropPendingTurn(identity.turnId, identity.dispatchAttempt, true);
5656
+ }
5657
+ // RPC `aborted` means the turn had already started executing before it was
5658
+ // interrupted, so its side effects may have already run. Map it to
5659
+ // `ambiguous` (fail-closed, no auto-retry) to match the transcript path
5660
+ // (codex-transcript.ts turn_aborted -> ambiguous). Only a genuine app-server
5661
+ // `failed` is retryable. Otherwise durable delivery would auto-redispatch and
5662
+ // re-run already-executed side effects — exactly what this PR guards against.
5663
+ const status = terminal.status === 'completed' ? 'completed'
5664
+ : terminal.status === 'failed' ? 'failed'
5665
+ : 'ambiguous';
5666
+ const errorCode = terminal.errorCode
5667
+ ?? (terminal.status === 'engine-dead' ? 'rpc_engine_dead'
5668
+ : terminal.status === 'stopped' ? 'rpc_engine_stopped'
5669
+ : terminal.status === 'aborted' ? 'rpc_turn_aborted'
5670
+ : terminal.status === 'failed' ? 'rpc_turn_failed'
5671
+ : undefined);
5672
+ emitTurnTerminal(identity.turnId, status, errorCode, identity.dispatchAttempt);
5673
+ log(`Codex RPC terminal ${terminal.status}: native=${terminal.nativeTurnId.slice(0, 12)} `
5674
+ + `turn=${identity.turnId.slice(0, 12)} attempt=${identity.dispatchAttempt ?? '-'}`);
5675
+ redriveRejectedStructuredReady();
5676
+ idleDetector?.fireIdle();
5677
+ }
5678
+ /** turn/completed can race ahead of rollout fs visibility even though it is an
5679
+ * authoritative execution terminal. Release rpcActive immediately, but retain
5680
+ * a short non-ready hydration gate while polling the structured transcript.
5681
+ * This preserves fallback final output without allowing the stale fingerprint
5682
+ * to head-of-line block a new RPC turn indefinitely. */
5683
+ function hydrateCompletedRpcTurn(terminal, generation, attempt = 0) {
5684
+ const { identity } = terminal;
5685
+ const ownerKey = rpcTurnOwnerKey(identity);
5686
+ if (!sameRpcGeneration(settlingRpcTerminalOwners.get(ownerKey), generation))
5687
+ return;
5688
+ // restartCliProcess advances the generation before its asynchronous teardown.
5689
+ // Do not let an old hydration timer touch a bridge queue that may already be
5690
+ // owned by the replacement. stopCodexRpcEngine will synchronously retire the
5691
+ // old generation when teardown reaches the engine.
5692
+ if (cliSpawnGeneration !== generation.cliGeneration
5693
+ || codexRpcEngine !== generation.engine)
5694
+ return;
5695
+ try {
5696
+ codexBridgeDrainAndMaybeEmit({
5697
+ signalIdle: false,
5698
+ hydrationOwnerKey: ownerKey,
5699
+ });
5700
+ }
5701
+ catch { /* retry */ }
5702
+ if (!codexBridgeQueue.hasPendingTurn(identity.turnId, identity.dispatchAttempt)) {
5703
+ // Structured ingest already emitted final_output + terminal in the canonical
5704
+ // order. The explicit native terminal below is idempotently suppressed.
5705
+ finalizeRpcTurnTerminal(terminal, generation, false);
5706
+ return;
5707
+ }
5708
+ // The native terminal can beat rollout persistence by seconds on a cold or
5709
+ // busy filesystem. Keep this bounded (~11.6s), aligned with the fresh-turn
5710
+ // 12s positive-evidence probe, rather than dropping fallback output after the
5711
+ // previous 1.55s window.
5712
+ const delays = CODEX_RPC_TERMINAL_HYDRATION_DELAYS_MS;
5713
+ if (attempt >= delays.length) {
5714
+ finalizeRpcTurnTerminal(terminal, generation, true);
5715
+ return;
5716
+ }
5717
+ const timer = setTimeout(() => {
5718
+ rpcTerminalHydrationTimers.delete(ownerKey);
5719
+ hydrateCompletedRpcTurn(terminal, generation, attempt + 1);
5720
+ }, delays[attempt]);
5721
+ timer.unref?.();
5722
+ rpcTerminalHydrationTimers.set(ownerKey, timer);
5723
+ }
5724
+ function settleRpcTurnTerminal(terminal, generation) {
5725
+ const ownerKey = rpcTurnOwnerKey(terminal.identity);
5726
+ const existingSettlement = settlingRpcTerminalOwners.get(ownerKey);
5727
+ if (existingSettlement) {
5728
+ if (!sameRpcGeneration(existingSettlement, generation)) {
5729
+ log(`Ignored stale Codex RPC terminal settlement for ${terminal.identity.turnId}`);
5730
+ }
5731
+ return;
5732
+ }
5733
+ settlingRpcTerminalOwners.set(ownerKey, generation);
5734
+ clearAwaitingRpcActivation(terminal.identity, generation);
5735
+ clearRpcLifecycleFailClosedOwner(terminal.identity, generation);
5736
+ if (sameRpcGeneration(rpcActiveOwners.get(ownerKey), generation)) {
5737
+ rpcActiveOwners.delete(ownerKey);
5738
+ }
5739
+ codexBridgeQueue.stopRpcActive(terminal.identity.turnId, terminal.identity.dispatchAttempt);
5740
+ if (terminal.status === 'completed'
5741
+ && codexBridgeQueue.hasPendingTurn(terminal.identity.turnId, terminal.identity.dispatchAttempt)) {
5742
+ rpcTerminalHydrationOwners.set(ownerKey, generation);
5743
+ rpcTerminalHydrationTerminals.set(ownerKey, { terminal, generation });
5744
+ hydrateCompletedRpcTurn(terminal, generation);
5745
+ return;
5746
+ }
5747
+ finalizeRpcTurnTerminal(terminal, generation, true);
5748
+ }
5749
+ function handleRpcTurnTerminal(terminal, generation) {
5750
+ const ownerKey = rpcTurnOwnerKey(terminal.identity);
5751
+ const awaiting = rpcTurnsAwaitingActivation.get(ownerKey);
5752
+ if (awaiting && sameRpcGeneration(awaiting, generation)) {
5753
+ // Response + turn/completed can be decoded before the await continuation
5754
+ // installs rpcActive. The exact terminal is replayed immediately after that
5755
+ // activation, never against a guessed/latest queue entry.
5756
+ pendingRpcTurnTerminals.set(ownerKey, { terminal, generation });
5757
+ return;
5758
+ }
5759
+ if (awaiting && !sameRpcGeneration(awaiting, generation)) {
5760
+ log(`Ignored stale Codex RPC terminal from replaced engine for ${terminal.identity.turnId}`);
5761
+ return;
5762
+ }
5763
+ const activeGeneration = rpcActiveOwners.get(ownerKey);
5764
+ const failedClosedGeneration = rpcLifecycleFailClosedOwners.get(ownerKey);
5765
+ if (!sameRpcGeneration(activeGeneration, generation)
5766
+ && !sameRpcGeneration(failedClosedGeneration, generation)) {
5767
+ // The structured bridge may already have consumed the same turn and emitted
5768
+ // its terminal, or a replacement generation may own the same logical
5769
+ // delivery key. In either case this callback has no queue state to mutate.
5770
+ log(`Codex RPC terminal already retired: native=${terminal.nativeTurnId.slice(0, 12)} `
5771
+ + `turn=${terminal.identity.turnId.slice(0, 12)}`);
5772
+ return;
5773
+ }
5774
+ settleRpcTurnTerminal(terminal, generation);
5775
+ }
5776
+ /** Install the exact bridge mark + rpcActive hand-off after turn/start is known
5777
+ * accepted. Failure is fail-closed: a separate lifecycle gate remains asserted
5778
+ * for this owner until its native terminal/engine teardown, so a bookkeeping
5779
+ * bug cannot publish false idle. */
5780
+ function activateRpcTurnLifecycle(identity, messageText, alreadyMarked, generation, deferTerminal = false) {
5781
+ const ownerKey = rpcTurnOwnerKey(identity);
5782
+ if (!sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
5783
+ log(`Refused stale Codex RPC lifecycle activation for ${identity.turnId}`);
5784
+ return false;
5785
+ }
5786
+ const replayAnchorMs = awaitingRpcActivationReplayAnchorMs(identity, generation);
5787
+ let marked = alreadyMarked
5788
+ && (codexBridgeQueue.hasPendingTurn(identity.turnId, identity.dispatchAttempt)
5789
+ || codexBridgeQueue.hasTerminalTurn(identity.turnId, identity.dispatchAttempt));
5790
+ if (!alreadyMarked) {
5791
+ const bridgeTurnId = codexBridgeMarkPendingTurn(messageText, identity.turnId, identity.dispatchAttempt, replayAnchorMs);
5792
+ marked = bridgeTurnId === identity.turnId;
5793
+ }
5794
+ // An older turn's terminal hydration may have drained and buffered this
5795
+ // successor's complete user/final pair while turn/start ACK was still
5796
+ // pending. mark() replays that pair synchronously. Treat the exact transcript
5797
+ // terminal as an already-retired activation instead of failing closed merely
5798
+ // because markRpcActive correctly refuses a completed queue entry.
5799
+ const transcriptTerminalObserved = marked
5800
+ && codexBridgeQueue.hasTerminalTurn(identity.turnId, identity.dispatchAttempt);
5801
+ const activated = transcriptTerminalObserved
5802
+ || (marked && codexBridgeQueue.markRpcActive(identity.turnId, identity.dispatchAttempt));
5803
+ if (activated && !transcriptTerminalObserved) {
5804
+ rpcActiveOwners.set(ownerKey, generation);
5805
+ }
5806
+ if (transcriptTerminalObserved)
5807
+ emitReadyCodexTurns();
5808
+ if (!deferTerminal
5809
+ && sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
5810
+ rpcTurnsAwaitingActivation.delete(ownerKey);
5811
+ rpcTurnsAwaitingActivationIdentities.delete(ownerKey);
5812
+ rpcTurnsAwaitingActivationReplayAnchors.delete(ownerKey);
5813
+ }
5814
+ if (!activated) {
5815
+ if (marked)
5816
+ codexBridgeQueue.dropPendingTurn(identity.turnId, identity.dispatchAttempt, true);
5817
+ installRpcLifecycleFailClosedOwner(identity, generation);
5818
+ log(`Codex RPC lifecycle failed closed: mark=${marked} active=${activated} `
5819
+ + `turn=${identity.turnId.slice(0, 12)} attempt=${identity.dispatchAttempt ?? '-'}`);
5820
+ send({
5821
+ type: 'user_notify',
5822
+ message: 'Codex RPC 已接收消息,但本地生命周期登记失败;在原生终态到达前保持忙碌,未自动重发。',
5823
+ turnId: identity.turnId,
5824
+ ...(identity.dispatchAttempt !== undefined
5825
+ ? { dispatchAttempt: identity.dispatchAttempt }
5826
+ : {}),
5827
+ });
5828
+ }
5829
+ const pendingTerminal = pendingRpcTurnTerminals.get(ownerKey);
5830
+ if (pendingTerminal
5831
+ && sameRpcGeneration(pendingTerminal.generation, generation)
5832
+ && !deferTerminal) {
5833
+ settleRpcTurnTerminal(pendingTerminal.terminal, generation);
5834
+ }
5835
+ return activated;
5836
+ }
5837
+ function releaseRpcTurnTerminalDeferral(identity, generation) {
5838
+ const ownerKey = rpcTurnOwnerKey(identity);
5839
+ if (sameRpcGeneration(rpcTurnsAwaitingActivation.get(ownerKey), generation)) {
5840
+ rpcTurnsAwaitingActivation.delete(ownerKey);
5841
+ rpcTurnsAwaitingActivationIdentities.delete(ownerKey);
5842
+ rpcTurnsAwaitingActivationReplayAnchors.delete(ownerKey);
5843
+ }
5844
+ const pendingTerminal = pendingRpcTurnTerminals.get(ownerKey);
5845
+ if (pendingTerminal && sameRpcGeneration(pendingTerminal.generation, generation)) {
5846
+ settleRpcTurnTerminal(pendingTerminal.terminal, generation);
5847
+ }
5848
+ }
5849
+ function retireRpcLifecycleFromStructuredTerminal(identity) {
5850
+ const ownerKey = rpcTurnOwnerKey(identity);
5851
+ // Native completion hydration owns its own finalization after this canonical
5852
+ // drain returns. Do not tear down that generation's settlement underneath it.
5853
+ if (settlingRpcTerminalOwners.has(ownerKey))
5854
+ return;
5855
+ const generation = rpcActiveOwners.get(ownerKey)
5856
+ ?? rpcLifecycleFailClosedOwners.get(ownerKey)
5857
+ ?? rpcTurnsAwaitingActivation.get(ownerKey);
5858
+ if (!generation)
5859
+ return;
5860
+ clearAwaitingRpcActivation(identity, generation);
5861
+ if (sameRpcGeneration(rpcActiveOwners.get(ownerKey), generation)) {
5862
+ rpcActiveOwners.delete(ownerKey);
5863
+ }
5864
+ clearRpcLifecycleFailClosedOwner(identity, generation);
5865
+ codexBridgeQueue.stopRpcActive(identity.turnId, identity.dispatchAttempt);
5866
+ }
5867
+ /** Expire confirmed or attribution-only unstarted queue heads at an explicit worker
5868
+ * lifecycle boundary. Pruning can replay a buffered successor user+final and
5869
+ * thereby produce an immediately emittable completion, so query/projection
5870
+ * methods must never do this mutation invisibly: every removal funnels
5871
+ * through this helper and drains ready output in the same call stack. */
5872
+ function pruneExpiredStructuredHeadsAndEmit(source) {
5873
+ let fenceCurrentRpcEngine = false;
5874
+ const dropped = pruneExpiredPreStartHeadsAndEmit(codexBridgeQueue, emitReadyCodexTurns, undefined, turns => {
5875
+ // A bounded pre-start lease is also the terminal boundary for a durable
5876
+ // delivery attempt that the CLI accepted but never wrote to transcript.
5877
+ // Settle N before the prune replay drains successor N+1; ordinary IM
5878
+ // turns have no dispatchAttempt and remain log-only as before.
5879
+ for (const turn of turns) {
5880
+ const identity = {
5881
+ turnId: turn.turnId,
5882
+ ...(turn.dispatchAttempt !== undefined
5883
+ ? { dispatchAttempt: turn.dispatchAttempt }
5884
+ : {}),
5885
+ };
5886
+ const ownerKey = rpcTurnOwnerKey(identity);
5887
+ const failedClosedGeneration = rpcLifecycleFailClosedOwners.get(ownerKey);
5888
+ const retiredRpcOwner = failedClosedGeneration
5889
+ ? clearRpcLifecycleFailClosedOwner(identity, failedClosedGeneration)
5890
+ : false;
5891
+ let shouldFenceRpcEngine = false;
5892
+ if (retiredRpcOwner && failedClosedGeneration) {
5893
+ clearAwaitingRpcActivation(identity, failedClosedGeneration);
5894
+ if (deferredFreshRpcTurn
5895
+ && rpcTurnOwnerKey(deferredFreshRpcTurn.identity) === ownerKey
5896
+ && sameRpcGeneration(deferredFreshRpcTurn.generation, failedClosedGeneration)) {
5897
+ deferredFreshRpcTurn = undefined;
5898
+ }
5899
+ if (codexRpcEngine === failedClosedGeneration.engine
5900
+ && cliSpawnGeneration === failedClosedGeneration.cliGeneration
5901
+ && !cliRestartInProgress) {
5902
+ fenceCurrentRpcEngine = true;
5903
+ shouldFenceRpcEngine = true;
5904
+ }
5905
+ }
5906
+ if (retiredRpcOwner || turn.dispatchAttempt !== undefined) {
5907
+ emitTurnTerminal(turn.turnId, 'ambiguous', retiredRpcOwner
5908
+ ? 'rpc_delivery_ambiguous_timeout'
5909
+ : 'structured_start_timeout', turn.dispatchAttempt);
5910
+ }
5911
+ // Publish/retire N before a synchronous immediate restart can clear the
5912
+ // current exact identity in killCli(). The callback still installs the
5913
+ // restart fence before it returns, so the helper cannot emit buffered
5914
+ // successor N+1 against this ambiguous engine generation.
5915
+ if (shouldFenceRpcEngine) {
5916
+ void restartCliProcess('Codex RPC ambiguous delivery exceeded its bounded attribution lease', { immediate: true, preservePending: true });
5917
+ }
5918
+ }
5919
+ });
5920
+ if (dropped.length === 0)
5921
+ return false;
5922
+ log(`${source}: expired ${dropped.length} structured head(s) without transcript start (${dropped.map(turn => turn.turnId).join(', ')})`);
5923
+ if (fenceCurrentRpcEngine)
5924
+ log('Fenced ambiguous Codex RPC generation before structured successor replay');
5925
+ // A rejected prompt-ready signal may be waiting on this exact pre-start
5926
+ // lease. Re-evaluate it after both the queue mark and its separate RPC
5927
+ // fail-closed owner have retired; otherwise the projector can stay busy
5928
+ // forever even though there is no remaining terminal source.
5929
+ redriveRejectedStructuredReady();
5180
5930
  return true;
5181
5931
  }
5182
5932
  function codexBridgeDrainAndMaybeEmit(opts = {}) {
@@ -5309,6 +6059,12 @@ function emitReadyCodexTurns() {
5309
6059
  });
5310
6060
  }
5311
6061
  for (const turn of ready) {
6062
+ retireRpcLifecycleFromStructuredTerminal({
6063
+ turnId: turn.turnId,
6064
+ ...(turn.dispatchAttempt !== undefined
6065
+ ? { dispatchAttempt: turn.dispatchAttempt }
6066
+ : {}),
6067
+ });
5312
6068
  emitTurnTerminal(turn.turnId, turn.terminalStatus ?? 'completed', turn.terminalErrorCode, turn.dispatchAttempt);
5313
6069
  }
5314
6070
  }
@@ -5674,6 +6430,264 @@ function parkCrashDiagnosticTerminal(code, signal) {
5674
6430
  * the ask-hook / CoCo picker paths (driveCocoPicker, handleTuiKeys); cleared
5675
6431
  * when the prompt resolves or a Lark/terminal input overrides it. */
5676
6432
  let tuiPromptBlocking = false;
6433
+ /** One composed status projection shared by the immediate screen path,
6434
+ * periodic text updates, and screenshot uploads. */
6435
+ function projectedRuntimeScreenStatus() {
6436
+ return codexAppLivenessStatus(projectRuntimeScreenStatus({
6437
+ promptReady: isPromptReady,
6438
+ analyzing: false,
6439
+ structuredTurnBlocking: hasStructuredLifecycleBlock(),
6440
+ }));
6441
+ }
6442
+ /** Worker-local widening of the status snapshot helper. Codex App composes a
6443
+ * `stalled` state on top of the base screen/structured projection, while the
6444
+ * shared utility intentionally exposes only the base three-state union. */
6445
+ async function snapshotWithLatestRuntimeStatus(capture, projectStatus) {
6446
+ const snapshot = await capture();
6447
+ return { snapshot, status: projectStatus() };
6448
+ }
6449
+ /** Re-arm readiness before every individual CLI write. A whole flush can span
6450
+ * several type-ahead items, and adopt writeInput can await history polling;
6451
+ * either path may observe an assistant_final before the await returns. Reset
6452
+ * here (never after the await) so that final remains a usable ready edge. */
6453
+ function beginCliWriteCycle() {
6454
+ beginRuntimeWriteCycle({
6455
+ setPromptReady: ready => { isPromptReady = ready; },
6456
+ resetIdleDetector: () => { idleDetector?.reset(); },
6457
+ });
6458
+ }
6459
+ async function writeAdoptMessage(content, turnId, dispatchAttempt, vcMeetingImTurnOrigin, fence) {
6460
+ const executionFence = fence ?? captureAdoptWriteFence();
6461
+ if (!executionFence || !adoptWriteFenceIsCurrent(executionFence)) {
6462
+ return 'stale-before-write';
6463
+ }
6464
+ const adoptBackend = executionFence.backend;
6465
+ renderer?.markNewTurn();
6466
+ const turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
6467
+ currentBotmuxTurnId = turnId;
6468
+ currentBotmuxDispatchAttempt = dispatchAttempt;
6469
+ currentVcMeetingImTurnOrigin = vcMeetingImTurnOrigin;
6470
+ if (dispatchAttempt !== undefined)
6471
+ durableTurnInFlight = true;
6472
+ writeCliPidMarker();
6473
+ publishSandboxRelayCapability();
6474
+ let adoptStructuredBridgeTurnId;
6475
+ // Capture the transcript baseline immediately before the literal write, inside
6476
+ // the submission transaction (as its beforeWrite hook), so a ZMX composer-
6477
+ // recovery hold that refuses the write never leaves a bare, unwritten bridge
6478
+ // mark. prepareAdoptWrite is idempotent and also re-arms readiness before the
6479
+ // adapter can yield (an assistant_final may arrive while writeInput polls
6480
+ // history), so the re-arm stays BEFORE the write, never after.
6481
+ let adoptWritePrepared = false;
6482
+ const prepareAdoptWrite = () => {
6483
+ if (adoptWritePrepared)
6484
+ return;
6485
+ adoptWritePrepared = true;
6486
+ beginCliWriteCycle();
6487
+ if (bridgeJsonlPath) {
6488
+ try {
6489
+ bridgeIngest();
6490
+ }
6491
+ catch { /* best effort */ }
6492
+ bridgeMarkPendingTurn(content, turnId, dispatchAttempt);
6493
+ }
6494
+ else if (codexBridgeFallbackActive()) {
6495
+ if (codexBridgeIsCursor()) {
6496
+ // Cursor may append the current line before IPC handling; mark first so
6497
+ // the pre-existing line can fingerprint-match instead of becoming seen.
6498
+ adoptStructuredBridgeTurnId = codexBridgeMarkPendingTurn(content, turnId, dispatchAttempt);
6499
+ try {
6500
+ codexBridgeIngest();
6501
+ }
6502
+ catch { /* best effort */ }
6503
+ }
6504
+ else {
6505
+ try {
6506
+ codexBridgeIngest();
6507
+ }
6508
+ catch { /* best effort */ }
6509
+ adoptStructuredBridgeTurnId = codexBridgeMarkPendingTurn(content, turnId, dispatchAttempt);
6510
+ }
6511
+ if (adoptStructuredBridgeTurnId) {
6512
+ codexBridgeQueue.beginSubmitVerification(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
6513
+ }
6514
+ }
6515
+ };
6516
+ const settleStaleAfterWrite = (errorCode) => {
6517
+ if (adoptStructuredBridgeTurnId) {
6518
+ codexBridgeQueue.finishSubmitVerification(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
6519
+ }
6520
+ dropFailedBridgeMark(adoptStructuredBridgeTurnId, dispatchAttempt);
6521
+ if (turnId && dispatchAttempt !== undefined) {
6522
+ emitTurnTerminal(turnId, 'ambiguous', errorCode, dispatchAttempt);
6523
+ }
6524
+ else {
6525
+ send({
6526
+ type: 'user_notify',
6527
+ turnId,
6528
+ message: 'Adopt input could not be reconciled because the CLI backend changed while it was being written. Please verify the terminal before retrying.',
6529
+ });
6530
+ }
6531
+ return 'stale-after-write';
6532
+ };
6533
+ // Adopt mode write:
6534
+ // - Structured-bridge adopt-input CLIs (codex/traex/pi/grok/mtr) route
6535
+ // through cliAdapter.writeInput. The composer-conflict guard refuses input
6536
+ // when the adopted pane already holds an unsubmitted human draft, and the
6537
+ // shared submission transaction gives ZMX its capture→write→confirm/cancel
6538
+ // journal (a transparent pass-through on every non-ZMX backend).
6539
+ // - raw sendText+Enter runs inside the same transaction for identical
6540
+ // commit-journal atomicity.
6541
+ if (isStructuredBridgeAdoptInputCli(lastInitConfig?.cliId) && cliAdapter) {
6542
+ const submissionBackend = adoptBackend;
6543
+ let recoveryFailureReason;
6544
+ // Refuse adopt input while the local composer still holds an unsubmitted
6545
+ // human draft, BEFORE any bridge attribution or terminal write — otherwise
6546
+ // the Lark message would be appended onto the human's half-typed line.
6547
+ const composerConflict = codexAdoptComposerConflict(submissionBackend);
6548
+ if (composerConflict) {
6549
+ log('Refused Codex adopt input because the local composer contains an unsubmitted draft');
6550
+ scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, composerConflict, turnSeq, { turnId, dispatchAttempt }, 'failed');
6551
+ return 'completed';
6552
+ }
6553
+ try {
6554
+ const transaction = await runAmbiguousSubmissionTransaction(submissionBackend,
6555
+ // adapterInputHandle wraps a ZmxBackend in strictInputHandle, which turns
6556
+ // a `sendText/sendSpecialKeys === false` (ZMX write refusal) into a throw
6557
+ // so the transaction can cancel/poison the WAL. Passing the bare backend
6558
+ // would let a refused write look like success and clear the journal —
6559
+ // silent input loss (the exact failure the transaction guards against).
6560
+ () => cliAdapter.writeInput(adapterInputHandle(submissionBackend), content), settleVerifiableSubmissionForJournal, prepareAdoptWrite);
6561
+ const result = transaction.result;
6562
+ recoveryFailureReason = transaction.recoveryFailureReason;
6563
+ if (!adoptWriteFenceIsCurrent(executionFence)) {
6564
+ return settleStaleAfterWrite('adopt_generation_changed');
6565
+ }
6566
+ if (result?.cliSessionId) {
6567
+ persistCliSessionId(result.cliSessionId);
6568
+ codexBridgeNotifyCliSessionId(result.cliSessionId);
6569
+ }
6570
+ if (result?.submitted === true && adoptStructuredBridgeTurnId) {
6571
+ codexBridgeQueue.confirmPendingTurn(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
6572
+ }
6573
+ else if (adoptStructuredBridgeTurnId && !(result?.submitted === false && result.recheck && !result.failureReason)) {
6574
+ codexBridgeQueue.finishSubmitVerification(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
6575
+ }
6576
+ redriveRejectedStructuredReady();
6577
+ if (result?.submitted === false || recoveryFailureReason) {
6578
+ if (recoveryFailureReason) {
6579
+ notifyAmbiguousSubmissionRecovery(recoveryFailureReason, { turnId, dispatchAttempt });
6580
+ }
6581
+ else {
6582
+ scheduleSubmitFailureNotify(content, result?.recheck, 'submit history', adoptStructuredBridgeTurnId, result?.failureReason, turnSeq, { turnId, dispatchAttempt }, 'failed', true);
6583
+ }
6584
+ }
6585
+ }
6586
+ catch (err) {
6587
+ recoveryFailureReason = err instanceof SubmissionWriteError
6588
+ ? err.recoveryFailureReason
6589
+ : recoveryFailureReason;
6590
+ const blockedBeforeWrite = err instanceof SubmissionWriteError
6591
+ && !!err.recoveryFailureReason
6592
+ && !err.submissionStarted;
6593
+ log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`);
6594
+ if (!adoptWriteFenceIsCurrent(executionFence)) {
6595
+ return settleStaleAfterWrite('adopt_generation_changed');
6596
+ }
6597
+ if (adoptStructuredBridgeTurnId) {
6598
+ codexBridgeQueue.finishSubmitVerification(adoptStructuredBridgeTurnId, undefined, dispatchAttempt);
6599
+ }
6600
+ dropFailedBridgeMark(adoptStructuredBridgeTurnId, dispatchAttempt);
6601
+ if (turnId && dispatchAttempt !== undefined && blockedBeforeWrite) {
6602
+ // The ZMX recovery hold refused the write, so the input definitely did
6603
+ // NOT execute — a genuine retryable failure, not an ambiguous one.
6604
+ emitTurnTerminal(turnId, 'failed', 'zmx_recovery_blocked_before_write', dispatchAttempt);
6605
+ }
6606
+ else if (turnId && dispatchAttempt !== undefined && !recoveryFailureReason) {
6607
+ emitTurnTerminal(turnId, 'ambiguous', 'adopt_write_input_threw', dispatchAttempt);
6608
+ }
6609
+ if (recoveryFailureReason) {
6610
+ notifyAmbiguousSubmissionRecovery(recoveryFailureReason, { turnId, dispatchAttempt });
6611
+ }
6612
+ redriveRejectedStructuredReady();
6613
+ }
6614
+ }
6615
+ else if ('sendText' in adoptBackend && 'sendSpecialKeys' in adoptBackend) {
6616
+ const submissionBackend = adoptBackend;
6617
+ let recoveryFailureReason;
6618
+ try {
6619
+ const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, async () => {
6620
+ // Use adapterInputHandle so a ZMX write refusal (sendText/sendSpecialKeys
6621
+ // returning false) throws instead of silently succeeding — the raw path
6622
+ // needs the same strict-handle guard as the structured writeInput path.
6623
+ const input = adapterInputHandle(submissionBackend);
6624
+ input.sendText(content);
6625
+ // Beat between text and Enter so Ink-based TUIs register pasted text
6626
+ // before submit. The serial queue holds across this await.
6627
+ await new Promise(r => setTimeout(r, 200));
6628
+ if (!adoptWriteFenceIsCurrent(executionFence)) {
6629
+ throw new Error('backend changed before adopt raw Enter');
6630
+ }
6631
+ input.sendSpecialKeys('Enter');
6632
+ }, undefined, prepareAdoptWrite);
6633
+ recoveryFailureReason = transaction.recoveryFailureReason;
6634
+ if (!adoptWriteFenceIsCurrent(executionFence)) {
6635
+ return settleStaleAfterWrite('adopt_generation_changed_before_enter');
6636
+ }
6637
+ if (recoveryFailureReason) {
6638
+ throw new SubmissionWriteError(`backend could not commit the adopt submission journal; ${recoveryFailureReason}`, recoveryFailureReason);
6639
+ }
6640
+ }
6641
+ catch (err) {
6642
+ recoveryFailureReason = err instanceof SubmissionWriteError
6643
+ ? err.recoveryFailureReason
6644
+ : recoveryFailureReason;
6645
+ const blockedBeforeWrite = err instanceof SubmissionWriteError
6646
+ && !!err.recoveryFailureReason
6647
+ && !err.submissionStarted;
6648
+ log(`Adopt raw input error (${lastInitConfig?.cliId}): ${err.message}`);
6649
+ if (turnId && dispatchAttempt !== undefined && blockedBeforeWrite) {
6650
+ emitTurnTerminal(turnId, 'failed', 'zmx_recovery_blocked_before_write', dispatchAttempt);
6651
+ }
6652
+ else if (turnId && dispatchAttempt !== undefined && !recoveryFailureReason) {
6653
+ emitTurnTerminal(turnId, 'ambiguous', 'write_input_threw', dispatchAttempt);
6654
+ }
6655
+ if (recoveryFailureReason) {
6656
+ notifyAmbiguousSubmissionRecovery(recoveryFailureReason, { turnId, dispatchAttempt });
6657
+ }
6658
+ }
6659
+ }
6660
+ else {
6661
+ prepareAdoptWrite();
6662
+ adoptBackend.write(content + '\r');
6663
+ }
6664
+ return 'completed';
6665
+ }
6666
+ async function runAdoptMessageForCapturedGeneration(item, requeue) {
6667
+ const fence = captureAdoptWriteFence();
6668
+ if (!fence) {
6669
+ requeue();
6670
+ return 'stale-before-write';
6671
+ }
6672
+ let requeued = false;
6673
+ const requeueOnce = () => {
6674
+ if (requeued)
6675
+ return;
6676
+ requeued = true;
6677
+ requeue();
6678
+ };
6679
+ const queued = await runAdoptQueuedWriteSequence({
6680
+ queue: adoptWriteQueue,
6681
+ isCurrent: () => adoptWriteFenceIsCurrent(fence),
6682
+ onStale: requeueOnce,
6683
+ write: () => writeAdoptMessage(item.content, item.turnId, item.dispatchAttempt, item.vcMeetingImTurnOrigin, fence),
6684
+ });
6685
+ if (queued.status === 'stale-before-write')
6686
+ return 'stale-before-write';
6687
+ if (queued.value === 'stale-before-write')
6688
+ requeueOnce();
6689
+ return queued.value;
6690
+ }
5677
6691
  function isWorkflowWorker() {
5678
6692
  return process.env.BOTMUX_WORKFLOW === '1';
5679
6693
  }
@@ -5939,10 +6953,7 @@ async function captureAndUpload() {
5939
6953
  logError(`Screenshot upload failed: ${err?.message ?? err}`);
5940
6954
  return;
5941
6955
  }
5942
- let status = isPromptReady ? 'idle' : 'working';
5943
- // master removed the AI ScreenAnalyzer, so the `analyzing` projection is gone;
5944
- // PR #597's Codex App no-progress liveness projection still layers on top.
5945
- status = codexAppLivenessStatus(status);
6956
+ const status = projectedRuntimeScreenStatus();
5946
6957
  send({
5947
6958
  type: 'screenshot_uploaded',
5948
6959
  imageKey,
@@ -6168,7 +7179,7 @@ async function flushPendingInjections() {
6168
7179
  if (!canStartInjectionFlush({
6169
7180
  injectionFlushing,
6170
7181
  userFlushing: isFlushing,
6171
- sessionRenameInFlight,
7182
+ sessionRenameInFlight: sessionRenameInFlight(),
6172
7183
  commandLineWritesPending,
6173
7184
  bareShellLaunchBlocked,
6174
7185
  }))
@@ -6176,7 +7187,7 @@ async function flushPendingInjections() {
6176
7187
  injectionFlushing = true;
6177
7188
  try {
6178
7189
  while (pendingInjections.length > 0 && backend && isPromptReady && !bareShellLaunchBlocked
6179
- && !sessionRenameInFlight) {
7190
+ && !sessionRenameInFlight()) {
6180
7191
  // Mirror flushPending's one-shot launch-failure guard: when an injection is
6181
7192
  // the FIRST writer after a (re)spawn, flushPending may never have run
6182
7193
  // (pendingMessages empty ⇒ early return) so the bare-shell check must also
@@ -6881,7 +7892,15 @@ async function handleTrustedCodexAppMarker(kind, payload, control) {
6881
7892
  }
6882
7893
  if (deliverableContent && startedAtMs !== undefined) {
6883
7894
  const suppressMarkers = readSendMarkers();
6884
- const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: deliverableContent };
7895
+ // Pass the RAW finalContent (not the pre-stripped deliverableContent) as
7896
+ // finalText: shouldSuppressBridgeEmit needs to SEE the trailing sentinel to
7897
+ // apply the "already sent this turn + sentinel terminator → suppress
7898
+ // narration" branch. It strips internally for the length comparison, so a
7899
+ // prose+sentinel final is still length-matched on the stripped prose. If we
7900
+ // handed it the already-stripped text, the trailing-sentinel signal would
7901
+ // be invisible and a longer-than-send narration would leak (same class as
7902
+ // the transcript-path bug this fixes).
7903
+ const gateInput = { markTimeMs: startedAtMs, isLocal: false, finalText: finalContent };
6885
7904
  suppressDelivery = suppressDelivery || shouldSuppressBridgeEmit(gateInput, completedAtMs + 5_001, suppressMarkers, false);
6886
7905
  if (suppressDelivery) {
6887
7906
  log(`${cliName()} final_output suppressed (model already called botmux send)`);
@@ -7339,8 +8358,11 @@ function onPtyData(data) {
7339
8358
  return;
7340
8359
  }
7341
8360
  // Track last PTY output time for the ready-gate quiescence settle (see
7342
- // settleThenFlush) and delegate idle detection to IdleDetector.
8361
+ // settleThenFlush) and a monotonic generation for rejected-ready evidence.
8362
+ // Generation (not timestamp equality) distinguishes two redraw chunks that
8363
+ // happen inside the same millisecond.
7343
8364
  lastPtyOutputAtMs = Date.now();
8365
+ ptyOutputGeneration.observe();
7344
8366
  idleDetector?.feed(data);
7345
8367
  }
7346
8368
  /**
@@ -7449,23 +8471,12 @@ function markPromptReadyFromPty(observedBackend) {
7449
8471
  postSessionStartPromptEvidenceInFlight = false;
7450
8472
  }
7451
8473
  }
7452
- /**
7453
- * Push a coarse screen status to the daemon without waiting for the 2s sampler.
7454
- * Used so card-off reactions see working→idle even on short turns.
7455
- *
7456
- * @param force - When true, send `status` as-is and skip classifyScreenUsageLimit.
7457
- * Synthetic "working" seeds for card-off reactions MUST force: if the first
7458
- * screen already shows a rate-limit banner, classify would rewrite working→
7459
- * limited and the seed collapses to limited→limited (no working edge →
7460
- * finishTurnReactions never runs). Review: PR #633 third round.
7461
- */
8474
+ /** Push a coarse screen status without waiting for the periodic sampler. */
7462
8475
  function publishScreenStatus(status, opts) {
7463
8476
  if (!renderer)
7464
8477
  return;
7465
8478
  const { content } = renderer.snapshot();
7466
- const statusPayload = opts?.force
7467
- ? { status }
7468
- : classifyScreenUsageLimit(content, status);
8479
+ const statusPayload = opts?.force ? { status } : classifyScreenUsageLimit(content, status);
7469
8480
  send({
7470
8481
  type: 'screen_update',
7471
8482
  content,
@@ -7474,17 +8485,73 @@ function publishScreenStatus(status, opts) {
7474
8485
  dispatchAttempt: currentBotmuxDispatchAttempt,
7475
8486
  });
7476
8487
  }
8488
+ function stopStructuredStartGraceRecheck() {
8489
+ if (!structuredStartGraceRecheckTimer)
8490
+ return;
8491
+ clearTimeout(structuredStartGraceRecheckTimer);
8492
+ structuredStartGraceRecheckTimer = null;
8493
+ }
8494
+ function redriveRejectedStructuredReady() {
8495
+ const readyEvidenceGeneration = structuredRejectedReadyEvidenceGeneration;
8496
+ if (readyEvidenceGeneration === undefined)
8497
+ return;
8498
+ stopStructuredStartGraceRecheck();
8499
+ pruneExpiredStructuredHeadsAndEmit('structured pre-start gate');
8500
+ if (!backend || isPromptReady) {
8501
+ structuredRejectedReadyEvidenceGeneration = undefined;
8502
+ return;
8503
+ }
8504
+ if (hasStructuredLifecycleBlock()) {
8505
+ const nextRemainingMs = codexBridgeQueue.preStartLeaseRemainingMs();
8506
+ if (nextRemainingMs !== undefined)
8507
+ scheduleStructuredStartGraceRecheck(nextRemainingMs);
8508
+ return;
8509
+ }
8510
+ structuredRejectedReadyEvidenceGeneration = undefined;
8511
+ if (ptyOutputGeneration.isCurrent(readyEvidenceGeneration)) {
8512
+ log('Structured pre-start gate settled with no newer PTY output — re-driving prior prompt-ready signal');
8513
+ idleDetector?.fireIdle();
8514
+ return;
8515
+ }
8516
+ if (cliAdapter?.busyPattern && (backend.captureCurrentScreen || backend.captureViewport)) {
8517
+ probeBusyPatternIdle('structured pre-start gate', backend);
8518
+ return;
8519
+ }
8520
+ try {
8521
+ const currentScreen = captureBackendScreen(backend);
8522
+ if (currentScreen)
8523
+ idleDetector?.feed(currentScreen);
8524
+ }
8525
+ catch (err) {
8526
+ log(`Structured pre-start screen recheck failed: ${err.message}`);
8527
+ }
8528
+ }
8529
+ /** Re-drive a prompt signal rejected only because an explicitly confirmed
8530
+ * submit or its in-flight verification had not reached the structured
8531
+ * transcript yet. The bounded lease prevents permanent false-busy. */
8532
+ function scheduleStructuredStartGraceRecheck(remainingMs) {
8533
+ stopStructuredStartGraceRecheck();
8534
+ const backendAtSchedule = backend;
8535
+ const cliGenerationAtSchedule = cliSpawnGeneration;
8536
+ structuredStartGraceRecheckTimer = setTimeout(() => {
8537
+ structuredStartGraceRecheckTimer = null;
8538
+ if (!isCliBackendGenerationCurrent({ generation: cliGenerationAtSchedule, backend: backendAtSchedule }, { generation: cliSpawnGeneration, backend, restartInProgress: cliRestartInProgress })) {
8539
+ return;
8540
+ }
8541
+ redriveRejectedStructuredReady();
8542
+ }, Math.max(1, remainingMs + 1));
8543
+ structuredStartGraceRecheckTimer.unref?.();
8544
+ }
7477
8545
  function markPromptReady() {
7478
- // Only screen readiness plus a non-shell pane leaf may release a launch that
7479
- // was previously blocked. Transcript/task callbacks can arrive while the
7480
- // wrapper is still a shell; accepting one here would set isPromptReady and
7481
- // make the later real PTY callback return early, stranding queued input.
7482
8546
  if (bareShellLaunchBlocked) {
7483
8547
  log('Ignoring non-PTY prompt-ready while bare-shell launch block is active');
7484
8548
  return;
7485
8549
  }
7486
- if (isPromptReady)
8550
+ if (isPromptReady) {
8551
+ stopStructuredStartGraceRecheck();
7487
8552
  return; // guard against duplicate calls
8553
+ }
8554
+ stopStructuredStartGraceRecheck();
7488
8555
  if (lastInitConfig?.cliId === 'codex-app' && !codexAppControlProven) {
7489
8556
  if (!codexAppUnprovenPromptDeferred) {
7490
8557
  log('Ignoring Codex App prompt until this worker verifies a fresh Ed25519 challenge response');
@@ -7550,8 +8617,27 @@ function markPromptReady() {
7550
8617
  }
7551
8618
  if (freshnessAction === 'ignore')
7552
8619
  return;
8620
+ // Screen prompt/quiescence is only a UI heuristic. Structured transcript
8621
+ // bridges have the stronger lifecycle signal: a transcript-started turn
8622
+ // without assistant_final is still running even if the TUI redraw exposes a
8623
+ // prompt. An adapter-confirmed type-ahead submit also blocks during a bounded
8624
+ // hand-off lease while the CLI waits to write its transcript user event. A
8625
+ // bare mark is never authoritative, and the lease is explicitly re-driven,
8626
+ // so a dropped Enter cannot create permanent false-busy.
8627
+ // Reject the heuristic and re-arm IdleDetector so the later transcript-final
8628
+ // fireIdle() can drive the real ready edge.
8629
+ if (hasStructuredLifecycleBlock()) {
8630
+ const remainingMs = codexBridgeQueue.preStartLeaseRemainingMs();
8631
+ structuredRejectedReadyEvidenceGeneration = ptyOutputGeneration.snapshot();
8632
+ log('Ignoring prompt-ready heuristic while a structured turn is unfinished or submit verification/start is pending');
8633
+ idleDetector?.reset();
8634
+ if (remainingMs !== undefined)
8635
+ scheduleStructuredStartGraceRecheck(remainingMs);
8636
+ return;
8637
+ }
8638
+ structuredRejectedReadyEvidenceGeneration = undefined;
7553
8639
  isPromptReady = true;
7554
- clearSessionRenameInFlight();
8640
+ settleSessionRenameOnPrompt();
7555
8641
  // An old backend can still report idle while its async teardown is running.
7556
8642
  // Only a prompt observed after the general restart fence drops may release
7557
8643
  // slash commands to the replacement generation.
@@ -7588,6 +8674,21 @@ function markPromptReady() {
7588
8674
  send({ type: 'runner_build_ready', runnerBuildId: lastInitConfig.runnerBuildId });
7589
8675
  persistCodexRunnerBuildOnReady = false;
7590
8676
  }
8677
+ // Send immediate idle snapshot so Lark card reflects idle status.
8678
+ // BUT: skip when messages are pending — flushPending() will immediately
8679
+ // make the CLI busy, so the idle state is transient and shouldn't appear
8680
+ // in the card. This avoids a false "就绪" flash on daemon restart
8681
+ // (where the initial prompt is queued before the CLI becomes idle).
8682
+ if (renderer && pendingMessages.length === 0 && pendingAdoptMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null && !isFlushing) {
8683
+ const { content } = renderer.snapshot();
8684
+ send({
8685
+ type: 'screen_update',
8686
+ content,
8687
+ ...usageLimitTracker.classify(content, projectedRuntimeScreenStatus()),
8688
+ turnId: currentBotmuxTurnId,
8689
+ dispatchAttempt: currentBotmuxDispatchAttempt,
8690
+ });
8691
+ }
7591
8692
  if (activeRestartAttemptId) {
7592
8693
  // Defense in depth: only report a successful restart when a replacement
7593
8694
  // backend is actually installed. Every legitimate ready path assigns
@@ -7746,42 +8847,56 @@ function notifyAmbiguousSubmissionRecovery(recoveryReason, turnIdentity) {
7746
8847
  message: recoveryReason,
7747
8848
  });
7748
8849
  }
8850
+ /** Remove a transcript mark after the submit path has conclusively failed.
8851
+ * Both bridge implementations mark before writing so they cannot miss a
8852
+ * fast transcript append; a failed write must undo only an UNSTARTED mark.
8853
+ * Cleanup itself does not synthesize ready: an unverified mark never blocks,
8854
+ * and any verified pre-start lease is bounded and owns its expiry re-drive. */
8855
+ function dropFailedBridgeMark(bridgeTurnId, dispatchAttempt) {
8856
+ if (!bridgeTurnId)
8857
+ return;
8858
+ const dropped = bridgeQueue.dropPendingTurn(bridgeTurnId, dispatchAttempt);
8859
+ const droppedStructured = codexBridgeQueue.dropPendingTurn(bridgeTurnId, dispatchAttempt);
8860
+ if (dropped) {
8861
+ if (dropped.contentFingerprint)
8862
+ bridgeFingerprintScanLastMs.delete(dropped.contentFingerprint);
8863
+ log(`Bridge mark dropped after submit failure (turnId=${bridgeTurnId}) — rotation-fallback scan will stop spinning on this fingerprint.`);
8864
+ }
8865
+ if (droppedStructured) {
8866
+ log(`Structured bridge mark dropped after submit failure (turnId=${bridgeTurnId}) — later buffered turns were rechecked.`);
8867
+ // dropPendingTurn can replay a buffered successor user+final pair. Drain
8868
+ // it in the same explicit mutation path; leaving completion production to
8869
+ // a later transcript event can strand fallback output indefinitely.
8870
+ emitReadyCodexTurns();
8871
+ }
8872
+ }
7749
8873
  /** Worker-side handler for `submitted: false`. Defers the user-facing
7750
8874
  * warning and runs the adapter-supplied `recheck` closure first; if the
7751
8875
  * message has shown up in the transcript by then (slow path, hook delay),
7752
8876
  * suppresses the warning entirely. Adapters without a recheck still fall
7753
8877
  * through to the warning after the same delay so the UX is uniform.
7754
8878
  *
7755
- * `bridgeTurnId` is the BridgeTurnQueue mark created right before the
8879
+ * `bridgeTurnId` is the transcript queue mark created right before the
7756
8880
  * failing writeInput. When the deferred recheck conclusively fails (= no
7757
- * jsonl line will ever match this fingerprint), we drop that exact dispatch
7758
- * attempt's mark leaving it would keep `maybeSwitchBridgeJsonl` doing
7759
- * full-directory scans every poll tick for a fingerprint that's permanently
7760
- * dead, the 99% CPU bug this whole patch series is fixing. */
7761
- function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, failureReason, turnSeq = usageLimitTracker.currentTurn(), turnIdentity, durableTerminalStatus = 'failed') {
8881
+ * transcript event will ever match this fingerprint), we drop that exact
8882
+ * dispatch attempt's mark. Leaving a Claude mark would keep rotation-fallback
8883
+ * scans spinning; leaving a structured mark would block later fingerprint
8884
+ * attribution even after its bounded status lease expires. */
8885
+ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId, failureReason, turnSeq = usageLimitTracker.currentTurn(), turnIdentity, durableTerminalStatus = 'failed', structuredTarget = false) {
7762
8886
  const preview = buildSubmitMessagePreview(msg);
7763
8887
  const emitDurableTerminal = (errorCode) => {
7764
8888
  if (turnIdentity?.turnId && turnIdentity.dispatchAttempt !== undefined) {
7765
8889
  emitTurnTerminal(turnIdentity.turnId, durableTerminalStatus, errorCode, turnIdentity.dispatchAttempt);
7766
8890
  }
7767
8891
  };
7768
- const dropBridgeMark = () => {
7769
- if (!bridgeTurnId)
7770
- return;
7771
- const dropped = bridgeQueue.dropPendingTurn(bridgeTurnId, turnIdentity?.dispatchAttempt);
7772
- if (dropped) {
7773
- if (dropped.contentFingerprint)
7774
- bridgeFingerprintScanLastMs.delete(dropped.contentFingerprint);
7775
- log(`Bridge mark dropped after submit failure (turnId=${bridgeTurnId}, attempt=${turnIdentity?.dispatchAttempt ?? '-'}) — rotation-fallback scan will stop spinning on this fingerprint.`);
7776
- }
7777
- };
7778
8892
  if (failureReason) {
7779
8893
  const action = decideSubmitConfirmationAction({
7780
8894
  failureReason,
7781
8895
  recheckSubmitted: false,
7782
8896
  usageLimitDetected: false,
7783
8897
  });
7784
- dropBridgeMark();
8898
+ dropFailedBridgeMark(bridgeTurnId, turnIdentity?.dispatchAttempt);
8899
+ redriveRejectedStructuredReady();
7785
8900
  const reason = action.kind === 'notify-hard-failure' ? action.reason : failureReason;
7786
8901
  log(`writeInput: submit impossible — notifying user immediately. reason="${reason}" preview="${preview}"`);
7787
8902
  emitDurableTerminal(`submit_impossible:${reason}`);
@@ -7795,29 +8910,36 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
7795
8910
  return;
7796
8911
  }
7797
8912
  const activityBaselineMs = Date.now();
8913
+ const cliGenerationAtSchedule = cliSpawnGeneration;
8914
+ const backendAtSchedule = backend;
8915
+ const deferredAttemptIsCurrent = () => (cliSpawnGeneration === cliGenerationAtSchedule
8916
+ && backendAtSchedule !== null
8917
+ && backend === backendAtSchedule
8918
+ && !cliRestartInProgress);
7798
8919
  log(`writeInput: submit not confirmed after retries — deferred ${SUBMIT_DEFERRED_RECHECK_MS}ms recheck queued. preview="${preview}"`);
7799
8920
  setTimeout(async () => {
7800
- let recheckSubmitted = false;
7801
- let cliSessionId;
7802
- if (recheck) {
7803
- try {
7804
- const recheckResult = await recheck();
7805
- recheckSubmitted = typeof recheckResult === 'boolean'
7806
- ? recheckResult
7807
- : recheckResult.submitted === true;
7808
- cliSessionId = typeof recheckResult === 'object' && recheckResult && typeof recheckResult.cliSessionId === 'string'
7809
- ? recheckResult.cliSessionId
7810
- : undefined;
7811
- }
7812
- catch (err) {
7813
- log(`Deferred recheck threw (${err?.message ?? err}); falling through to warning.`);
7814
- }
7815
- }
7816
- const action = decideSubmitConfirmationAction({
7817
- recheckSubmitted,
7818
- usageLimitDetected: usageLimitTracker.detectedThisTurn(turnSeq),
7819
- activityEvidence: submitActivityEvidenceSince(activityBaselineMs),
8921
+ const settlement = await settleDeferredSubmitConfirmation(codexBridgeQueue, {
8922
+ turnId: bridgeTurnId,
8923
+ dispatchAttempt: turnIdentity?.dispatchAttempt,
8924
+ structuredTarget,
8925
+ recheck,
8926
+ usageLimitDetected: () => usageLimitTracker.detectedThisTurn(turnSeq),
8927
+ activityEvidence: () => submitActivityEvidenceSince(activityBaselineMs),
8928
+ isCurrent: deferredAttemptIsCurrent,
7820
8929
  });
8930
+ // Restart/exit or exact-attempt expiry can happen during either the 20s
8931
+ // delay or the awaited adapter recheck. A stale callback must perform no
8932
+ // side effects at all: no old cliSessionId persistence, ready redrive,
8933
+ // recursive timer, terminal, warning, or mutation of replay attempt N+1.
8934
+ if (settlement.stale) {
8935
+ log(`Discarded stale deferred submit recheck (${settlement.staleReason}) turn=${bridgeTurnId ?? '-'} attempt=${turnIdentity?.dispatchAttempt ?? '-'}`);
8936
+ return;
8937
+ }
8938
+ if (settlement.recheckError !== undefined) {
8939
+ const err = settlement.recheckError;
8940
+ log(`Deferred recheck threw (${err?.message ?? err}); falling through to warning.`);
8941
+ }
8942
+ const { action, cliSessionId } = settlement;
7821
8943
  switch (action.kind) {
7822
8944
  case 'suppress-confirmed':
7823
8945
  if (cliSessionId) {
@@ -7827,13 +8949,16 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
7827
8949
  void syncFreshCodexNativeSessionTitle(cliSessionId, codexRpcEngine);
7828
8950
  }
7829
8951
  log(`Deferred recheck found submit in ${transcriptLabel} — suppressing warning. preview="${preview}"`);
8952
+ redriveRejectedStructuredReady();
7830
8953
  return;
7831
8954
  case 'suppress-usage-limit':
7832
- dropBridgeMark();
8955
+ dropFailedBridgeMark(bridgeTurnId, turnIdentity?.dispatchAttempt);
8956
+ redriveRejectedStructuredReady();
7833
8957
  log(`Deferred recheck missing but usage limit was detected for this turn — suppressing submit warning. preview="${preview}"`);
7834
8958
  emitDurableTerminal('submit_usage_limit');
7835
8959
  return;
7836
8960
  case 'suppress-active':
8961
+ redriveRejectedStructuredReady();
7837
8962
  log(`Deferred recheck missing but later ${action.evidence} shows ${cliName()} is active — suppressing submit warning. preview="${preview}"`);
7838
8963
  // For a durable turn, activity is evidence of possible submission, not
7839
8964
  // a terminal result. Keep the arbiter closed and re-check until either
@@ -7848,7 +8973,8 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
7848
8973
  case 'notify-stuck':
7849
8974
  break;
7850
8975
  }
7851
- dropBridgeMark();
8976
+ dropFailedBridgeMark(bridgeTurnId, turnIdentity?.dispatchAttempt);
8977
+ redriveRejectedStructuredReady();
7852
8978
  log(`Deferred recheck still missing — notifying user. preview="${preview}"`);
7853
8979
  emitDurableTerminal('submit_unconfirmed');
7854
8980
  if (turnIdentity?.dispatchAttempt === undefined) {
@@ -8009,9 +9135,9 @@ async function flushPending() {
8009
9135
  }
8010
9136
  if (ambiguousSubmissionRecoveryHold?.backend === backend)
8011
9137
  return;
8012
- if (pendingMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null)
9138
+ if (pendingMessages.length === 0 && pendingAdoptMessages.length === 0 && pendingRawInputs.length === 0 && pendingSessionRename === null)
8013
9139
  return; // nothing to flush — keep isPromptReady
8014
- if (sessionRenameInFlight)
9140
+ if (sessionRenameInFlight())
8015
9141
  return; // wait for /rename to finish before any user input
8016
9142
  if (commandLineWritesPending > 0)
8017
9143
  return; // do not splice into text -> Enter
@@ -8034,6 +9160,16 @@ async function flushPending() {
8034
9160
  // the active attempt.
8035
9161
  if (!pendingInputMayFlush(durableTurnInFlight))
8036
9162
  return;
9163
+ // A no-native or locally-unregistered RPC delivery is deliberately
9164
+ // fail-closed: its exact execution state is unknown. Codex normally permits
9165
+ // type-ahead/steer, but admitting a successor here could merge it into the
9166
+ // unknown turn or overlap a durable replay. Keep every input class queued
9167
+ // until structured evidence, exact terminal, bounded generation restart, or
9168
+ // teardown retires the owner.
9169
+ if (rpcLifecycleFailClosedOwners.size > 0) {
9170
+ log(`Holding pending input behind ${rpcLifecycleFailClosedOwners.size} unresolved Codex RPC delivery owner(s)`);
9171
+ return;
9172
+ }
8037
9173
  // Ready-gate: hold the FIRST prompt until the SessionStart hook fires a true-
8038
9174
  // ready signal. A cjadk-style startup selector's ❯ falsely matches readyPattern
8039
9175
  // and would otherwise eat this message. releaseReadyGate() re-invokes us once
@@ -8084,12 +9220,13 @@ async function flushPending() {
8084
9220
  // pending messages can still drain while busy; the rename stays queued.
8085
9221
  const sessionRenameReady = isPromptReady && pendingSessionRename !== null;
8086
9222
  const rawInputReady = isPromptReady && pendingRawInputs.length > 0;
9223
+ const adoptInputReady = isPromptReady && lastInitConfig?.adoptMode === true && pendingAdoptMessages.length > 0;
8087
9224
  let supportedSessionRenameReady = sessionRenameReady;
8088
9225
  if (sessionRenameReady && (!cliAdapter.buildSessionRenameCommand || effectiveBackendType === 'riff')) {
8089
9226
  pendingSessionRename = null;
8090
9227
  supportedSessionRenameReady = false;
8091
9228
  log(`Ignoring native session rename — unsupported by ${cliName()}${effectiveBackendType === 'riff' ? ' on riff backend' : ''}`);
8092
- if (pendingMessages.length === 0 && pendingRawInputs.length === 0)
9229
+ if (pendingMessages.length === 0 && pendingAdoptMessages.length === 0 && pendingRawInputs.length === 0)
8093
9230
  return;
8094
9231
  }
8095
9232
  if (!isPromptReady && pendingMessages.length === 0)
@@ -8098,14 +9235,12 @@ async function flushPending() {
8098
9235
  return;
8099
9236
  isFlushing = true;
8100
9237
  const codexAppPromptReplay = new CodexAppFlushPromptReplay();
8101
- if (isPromptReady) {
8102
- isPromptReady = false;
8103
- idleDetector?.reset();
8104
- // Immediate working for card-off reaction settle (PR #633 P2): the screen
8105
- // sampler is 2s without this a short turn can complete before any
8106
- // working status is observed, leaving idle→idle and GoGoGo uncleared.
8107
- // force: same rate-limit rewrite trap as argv seed (review third round).
8108
- publishScreenStatus('working', { force: true });
9238
+ // Raw input and native rename own their explicit command-line/session gates;
9239
+ // pending adopt writes re-arm inside writeAdoptMessage. Clearing readiness
9240
+ // here would make an adopt rename's in-queue readiness recheck always see a
9241
+ // synthetic false. Normal prompt/startup flushes keep the original re-arm.
9242
+ if (!rawInputReady && !supportedSessionRenameReady && !adoptInputReady) {
9243
+ beginCliWriteCycle();
8109
9244
  }
8110
9245
  try {
8111
9246
  // Launch-failure guard, run ONCE per spawn on the first flush, BEFORE startup
@@ -8155,10 +9290,49 @@ async function flushPending() {
8155
9290
  if (supportedSessionRenameReady && pendingSessionRename !== null && backend && cliAdapter) {
8156
9291
  const title = pendingSessionRename;
8157
9292
  const buildRename = cliAdapter.buildSessionRenameCommand;
9293
+ const renameBackend = backend;
9294
+ const renameGeneration = cliSpawnGeneration;
8158
9295
  pendingSessionRename = null;
8159
- sessionRenameInFlight = true;
9296
+ sessionRenamePhase = 'reserved';
8160
9297
  try {
8161
- await sendRawCommandLineWithRecoveryFence(backend, buildRename(title));
9298
+ const writeRename = async () => {
9299
+ // Transition to busy inside the adopt queue, after the readiness
9300
+ // recheck. Keep `writing` through text→beat→Enter: a terminal from
9301
+ // the preceding turn in that window must not release the rename gate.
9302
+ beginCliWriteCycle();
9303
+ sessionRenamePhase = 'writing';
9304
+ await sendRawCommandLineWithRecoveryFence(renameBackend, buildRename(title));
9305
+ if (cliSpawnGeneration !== renameGeneration || backend !== renameBackend || cliRestartInProgress) {
9306
+ throw new Error('rename backend generation changed before Enter settlement');
9307
+ }
9308
+ sessionRenamePhase = 'sent';
9309
+ // A previous turn's terminal may have raised ready while the rename
9310
+ // was still writing. Re-arm after Enter so only rename's own prompt
9311
+ // can settle `sent` (with the bounded timeout as fail-open fallback).
9312
+ beginCliWriteCycle();
9313
+ };
9314
+ const sent = lastInitConfig?.adoptMode
9315
+ ? await runAdoptSessionRenameSequence({
9316
+ queue: adoptWriteQueue,
9317
+ // An adopt write queued immediately before rename can make the
9318
+ // outer readiness decision stale while this task waits.
9319
+ isPromptReady: () => isPromptReady
9320
+ && backend === renameBackend
9321
+ && cliSpawnGeneration === renameGeneration
9322
+ && !cliRestartInProgress
9323
+ && !rawInputRestartGate,
9324
+ writeRename,
9325
+ })
9326
+ : (await writeRename(), true);
9327
+ if (!sent) {
9328
+ // Keep a newer title if one arrived while this rename waited. Retry
9329
+ // this title only when it is still the latest requested canonical title.
9330
+ if (pendingSessionRename === null)
9331
+ pendingSessionRename = title;
9332
+ forceClearSessionRenameInFlight();
9333
+ log(`Deferred native session rename until the next prompt (${cliName()}): ${title}`);
9334
+ return;
9335
+ }
8162
9336
  armSessionRenameIdleTimeout();
8163
9337
  idleDetector?.reset();
8164
9338
  log(`Native session rename command sent (${cliName()}): ${title}`);
@@ -8169,17 +9343,43 @@ async function flushPending() {
8169
9343
  // Keep the rename gate for the same bounded fail-open window: the write
8170
9344
  // may have stopped after typing only part of the command, so immediately
8171
9345
  // appending a deferred raw_input could corrupt both commands.
8172
- armSessionRenameIdleTimeout();
9346
+ if (cliSpawnGeneration === renameGeneration && backend === renameBackend && !cliRestartInProgress) {
9347
+ settleFailedSessionRenameWrite();
9348
+ armSessionRenameIdleTimeout();
9349
+ }
9350
+ else {
9351
+ forceClearSessionRenameInFlight();
9352
+ }
8173
9353
  log(`Native session rename command failed (${cliName()}): ${err?.message ?? err}; waiting for prompt or fail-open timeout`);
8174
9354
  }
8175
9355
  // Wait for the command to finish and the TUI to become idle again before
8176
9356
  // sending queued user prompts; otherwise they can land in its picker.
8177
9357
  return;
8178
9358
  }
9359
+ if (adoptInputReady && pendingAdoptMessages.length > 0) {
9360
+ const item = pendingAdoptMessages.shift();
9361
+ await runAdoptMessageForCapturedGeneration(item, () => {
9362
+ pendingAdoptMessages.unshift(item);
9363
+ log(`Re-queued adopt message at queue head for the replacement CLI generation (${item.content.length} chars)`);
9364
+ });
9365
+ return;
9366
+ }
8179
9367
  while (pendingMessages.length > 0 && backend && cliAdapter) {
8180
9368
  const item = freshnessInputQueue.takeNormal();
8181
9369
  if (!item)
8182
9370
  break;
9371
+ // Type-ahead can drain several items in one flush. Each logical submit
9372
+ // starts its own readiness generation before transcript marking/writing.
9373
+ beginCliWriteCycle();
9374
+ const writeGeneration = cliSpawnGeneration;
9375
+ const writeBackend = backend;
9376
+ const writeAdapter = cliAdapter;
9377
+ const writeRpcEngine = codexRpcEngine;
9378
+ const writeContinuationIsCurrent = () => (cliSpawnGeneration === writeGeneration
9379
+ && backend === writeBackend
9380
+ && cliAdapter === writeAdapter
9381
+ && codexRpcEngine === writeRpcEngine
9382
+ && !cliRestartInProgress);
8183
9383
  const durableWrite = item.dispatchAttempt !== undefined;
8184
9384
  const msg = item.content;
8185
9385
  const logicalMsg = item.logicalContent ?? msg;
@@ -8206,7 +9406,7 @@ async function flushPending() {
8206
9406
  // in-flight and must not participate in restart replay. RPC mode is
8207
9407
  // excluded: the app-server owns an accepted turn independently of the
8208
9408
  // viewer pane, so replaying it after a pane restart could run it twice.
8209
- if (!codexRpcEngine) {
9409
+ if (!writeRpcEngine) {
8210
9410
  inflightInputs.onWrite(item);
8211
9411
  stuckDetector?.arm();
8212
9412
  }
@@ -8222,8 +9422,11 @@ async function flushPending() {
8222
9422
  catch { /* best-effort */ }
8223
9423
  bridgeTurnId = bridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
8224
9424
  }
8225
- else if (codexBridgeActive) {
8226
- codexBridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
9425
+ else if (codexBridgeActive && !writeRpcEngine) {
9426
+ bridgeTurnId = codexBridgeMarkPendingTurn(logicalMsg, item.turnId, item.dispatchAttempt);
9427
+ if (bridgeTurnId) {
9428
+ codexBridgeQueue.beginSubmitVerification(bridgeTurnId, undefined, item.dispatchAttempt);
9429
+ }
8227
9430
  }
8228
9431
  if (durableWrite
8229
9432
  && cliAdapter.reliableTurnTerminal === true
@@ -8290,42 +9493,89 @@ async function flushPending() {
8290
9493
  // backend regression). flushPending is invoked fire-and-forget, so an
8291
9494
  // escaping rejection would become an unhandledRejection and crash the
8292
9495
  // worker — exactly the failure mode this change is closing. Contain it.
8293
- let result;
8294
9496
  let submissionBackend = null;
8295
9497
  let recoveryFailureReason;
9498
+ const handleStaleWriteContinuation = (errorCode) => {
9499
+ const disposition = settleStaleWriteContinuation(item, errorCode, (turnId, code, dispatchAttempt) => {
9500
+ emitTurnTerminal(turnId, 'ambiguous', code, dispatchAttempt);
9501
+ });
9502
+ // Generation change has already handed ordinary input to
9503
+ // InflightInputTracker carryover. Do not touch global bridge queues:
9504
+ // replacement may already have marked the same turnId/undefined key.
9505
+ log(`Discarded stale writeInput continuation turn=${item.turnId ?? '-'} attempt=${item.dispatchAttempt ?? '-'} generation=${writeGeneration} disposition=${disposition}`);
9506
+ };
9507
+ let result;
9508
+ let rpcTurnIdentity;
9509
+ let rpcTurnGeneration;
8296
9510
  try {
8297
- if (codexRpcEngine) {
9511
+ if (writeRpcEngine) {
9512
+ rpcTurnIdentity = {
9513
+ turnId: item.turnId ?? `codex-rpc-${randomBytes(8).toString('hex')}`,
9514
+ ...(item.dispatchAttempt !== undefined
9515
+ ? { dispatchAttempt: item.dispatchAttempt }
9516
+ : {}),
9517
+ };
9518
+ rpcTurnGeneration = {
9519
+ engine: writeRpcEngine,
9520
+ cliGeneration: writeGeneration,
9521
+ };
9522
+ installAwaitingRpcActivation(rpcTurnIdentity, rpcTurnGeneration);
8298
9523
  // RPC input mode: deliver via JSON-RPC turn/start (its ack IS the
8299
9524
  // submit confirmation), which the attached `codex --remote` TUI
8300
9525
  // renders. No tmux paste → the history.jsonl verify/retry/recover
8301
9526
  // machinery is bypassed. A throw here falls into the catch below and
8302
9527
  // surfaces as a normal submit-failure notice.
8303
- const targetBackend = backend;
8304
- await runAfterAmbiguousSubmissionWrites(targetBackend, async () => {
9528
+ await runAfterAmbiguousSubmissionWrites(writeBackend, async () => {
8305
9529
  prepareNormalWrite();
8306
- await codexRpcEngine.sendTurn(msg, item.turnId);
9530
+ await writeRpcEngine.sendTurn(msg, rpcTurnIdentity);
8307
9531
  });
9532
+ // The await may overlap an engine/pane replacement. Fence the captured
9533
+ // generation BEFORE touching the global bridge queue; a stale ack must
9534
+ // never activate a mark owned by the replacement generation.
9535
+ if (!writeContinuationIsCurrent()) {
9536
+ // RPC writes bypass InflightInputTracker because replay could
9537
+ // duplicate an already-accepted server turn. Keep this exact owner
9538
+ // registered until stopCodexRpcEngine settles a native terminal or
9539
+ // publishes one ambiguous teardown terminal + notice.
9540
+ log(`Deferred stale Codex RPC continuation to engine teardown `
9541
+ + `turn=${rpcTurnIdentity.turnId} generation=${writeGeneration}`);
9542
+ break;
9543
+ }
8308
9544
  result = { submitted: true };
9545
+ // Only the ACKed, still-current generation may create bridge state.
9546
+ // While turn/start was pending, codexBridgeIngest left its cursor
9547
+ // untouched; marking now therefore still precedes replay of any
9548
+ // already-persisted user/final events.
9549
+ bridgeTurnId = rpcTurnIdentity.turnId;
9550
+ // The app-server ack confirms execution has begun, but no local
9551
+ // transcript event will follow to flip started. Mark the turn active
9552
+ // so the lifecycle gate stays asserted for the full server-side run
9553
+ // instead of relying on the bounded 20s confirmation lease (which
9554
+ // would expire mid-turn on a long-running or approval-pending turn).
9555
+ const activated = activateRpcTurnLifecycle(rpcTurnIdentity, msg, false, rpcTurnGeneration);
9556
+ if (activated) {
9557
+ codexBridgeDrainAndMaybeEmit({ signalIdle: false });
9558
+ }
8309
9559
  }
8310
- else if (item.codexAppInput && cliAdapter.writeStructuredInput) {
8311
- const targetBackend = backend;
8312
- const targetAdapter = cliAdapter;
8313
- submissionBackend = targetBackend;
8314
- const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeStructuredInput(adapterInputHandle(targetBackend), msg, item.codexAppInput, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
9560
+ else if (item.codexAppInput && writeAdapter.writeStructuredInput) {
9561
+ submissionBackend = writeBackend;
9562
+ const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeStructuredInput(adapterInputHandle(writeBackend), msg, item.codexAppInput, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
8315
9563
  result = transaction.result;
8316
9564
  recoveryFailureReason = transaction.recoveryFailureReason;
8317
9565
  }
8318
9566
  else {
8319
- const targetBackend = backend;
8320
- const targetAdapter = cliAdapter;
8321
- submissionBackend = targetBackend;
8322
- const transaction = await runAmbiguousSubmissionTransaction(targetBackend, () => targetAdapter.writeInput(adapterInputHandle(targetBackend), msg, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
9567
+ submissionBackend = writeBackend;
9568
+ const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeInput(adapterInputHandle(writeBackend), msg, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
8323
9569
  result = transaction.result;
8324
9570
  recoveryFailureReason = transaction.recoveryFailureReason;
8325
9571
  }
8326
9572
  if (recoveryFailureReason) {
8327
9573
  result = { ...result, submitted: false };
8328
9574
  }
9575
+ if (!writeContinuationIsCurrent()) {
9576
+ handleStaleWriteContinuation('write_generation_changed');
9577
+ break;
9578
+ }
8329
9579
  // Transcript-backed CLIs (Grok/Codex/… reliableTurnTerminal) own idle via
8330
9580
  // assistant_final → fireIdle. Their busyPattern is often missing for
8331
9581
  // several seconds after submit (or never matches the current TUI chrome),
@@ -8346,6 +9596,42 @@ async function flushPending() {
8346
9596
  && !err.submissionStarted;
8347
9597
  // Roll back this turn's Codex App liveness slot regardless of the failure
8348
9598
  // shape; the slot was opened before the fenced write.
9599
+ // A replacement generation may already have installed an equal
9600
+ // turn/attempt key. Fence before touching any process-global queue or
9601
+ // liveness state owned by that successor.
9602
+ if (!writeContinuationIsCurrent()) {
9603
+ if (rpcTurnIdentity && rpcTurnGeneration) {
9604
+ // The frame may have crossed the socket before the error. Never hand
9605
+ // RPC input to ordinary carryover/replay; exact teardown owns the
9606
+ // terminal and user notice for this still-awaiting identity.
9607
+ log(`Deferred stale failed Codex RPC continuation to engine teardown `
9608
+ + `turn=${rpcTurnIdentity.turnId} generation=${writeGeneration}`);
9609
+ }
9610
+ else {
9611
+ handleStaleWriteContinuation('write_generation_changed_after_error');
9612
+ }
9613
+ break;
9614
+ }
9615
+ if (rpcTurnIdentity && rpcTurnGeneration && writeRpcEngine) {
9616
+ const replayAnchorMs = awaitingRpcActivationReplayAnchorMs(rpcTurnIdentity, rpcTurnGeneration);
9617
+ clearAwaitingRpcActivation(rpcTurnIdentity, rpcTurnGeneration);
9618
+ bridgeTurnId = codexBridgeMarkPendingTurn(msg, rpcTurnIdentity.turnId, rpcTurnIdentity.dispatchAttempt, replayAnchorMs);
9619
+ installRpcLifecycleFailClosedOwner(rpcTurnIdentity, rpcTurnGeneration);
9620
+ log(`Codex RPC turn/start became ambiguous (${err?.message ?? err}); `
9621
+ + `held exact owner ${rpcTurnIdentity.turnId} and blocked successors`);
9622
+ send({
9623
+ type: 'user_notify',
9624
+ message: 'Codex RPC 消息已发出但未取得可验证的 turn/start 响应;为避免重复执行,未自动重发,并暂时阻塞后续消息直到结构化终态、边界重启或会话重启。',
9625
+ turnId: rpcTurnIdentity.turnId,
9626
+ ...(rpcTurnIdentity.dispatchAttempt !== undefined
9627
+ ? { dispatchAttempt: rpcTurnIdentity.dispatchAttempt }
9628
+ : {}),
9629
+ });
9630
+ if (bridgeTurnId !== rpcTurnIdentity.turnId && !cliRestartInProgress) {
9631
+ void restartCliProcess('Codex RPC ambiguous delivery could not establish an attribution lease', { immediate: true, preservePending: true });
9632
+ }
9633
+ break;
9634
+ }
8349
9635
  codexAppPromptReplay.cancelSubmission(codexAppTurnLiveness, codexAppReadyAuthority, codexAppLivenessHandle);
8350
9636
  if (submissionPreparationFailed) {
8351
9637
  if (item.turnId) {
@@ -8389,6 +9675,10 @@ async function flushPending() {
8389
9675
  dispatchStillPending = codexAppTurnDispatchQueue.cancelExact(codexAppDispatchReservation.handle);
8390
9676
  }
8391
9677
  }
9678
+ if (bridgeTurnId) {
9679
+ codexBridgeQueue.stopRpcActive(bridgeTurnId, item.dispatchAttempt);
9680
+ codexBridgeQueue.finishSubmitVerification(bridgeTurnId, undefined, item.dispatchAttempt);
9681
+ }
8392
9682
  log(`writeInput threw: ${err?.message ?? err}`);
8393
9683
  if (blockedBeforeWrite && submissionBackend) {
8394
9684
  // This exact item is known not to have touched the PTY. Keep its
@@ -8451,6 +9741,15 @@ async function flushPending() {
8451
9741
  if (threadId)
8452
9742
  void syncFreshCodexNativeSessionTitle(threadId, codexRpcEngine);
8453
9743
  }
9744
+ if (result?.submitted === true && bridgeTurnId) {
9745
+ codexBridgeQueue.confirmPendingTurn(bridgeTurnId, undefined, item.dispatchAttempt);
9746
+ }
9747
+ else if (bridgeTurnId && !(result?.submitted === false && result.recheck && !result.failureReason)) {
9748
+ // Keep verification pending only while an adapter-supplied deferred
9749
+ // recheck can still produce authoritative submit evidence.
9750
+ codexBridgeQueue.finishSubmitVerification(bridgeTurnId, undefined, item.dispatchAttempt);
9751
+ }
9752
+ redriveRejectedStructuredReady();
8454
9753
  // `&& backend`: if the CLI exited during this write (pane gone → onExit
8455
9754
  // nulled backend) the user already got a "CLI exited" notice; don't also
8456
9755
  // nag that the submit wasn't confirmed.
@@ -8525,6 +9824,8 @@ async function flushPending() {
8525
9824
  // that correctly. Durable receiver attempts are the exception: they and
8526
9825
  // adjacent IM turns wait for separate idle edges so neither can be
8527
9826
  // HOL-dropped or steered into the other.
9827
+ if (rpcLifecycleFailClosedOwners.size > 0)
9828
+ break;
8528
9829
  if (shouldStopPendingBatch(item, pendingMessages[0]))
8529
9830
  break;
8530
9831
  }
@@ -8614,7 +9915,7 @@ function sendToPty(content, turnId, opts = {}) {
8614
9915
  // type-ahead write is dropped (no input box yet) — markPromptReady()'s flush
8615
9916
  // delivers queued messages instead. See input-gate.ts; this fixes dispatch's
8616
9917
  // brief reaching Codex before its first idle and never landing.
8617
- if (!sessionRenameInFlight && commandLineWritesPending === 0 && shouldWriteNow({
9918
+ if (!sessionRenameInFlight() && commandLineWritesPending === 0 && shouldWriteNow({
8618
9919
  isPromptReady,
8619
9920
  isFlushing,
8620
9921
  supportsTypeAhead,
@@ -8658,59 +9959,65 @@ function startScreenUpdates() {
8658
9959
  screenUpdateTimer = setInterval(() => {
8659
9960
  if (awaitingFirstPrompt)
8660
9961
  return;
8661
- let status = isPromptReady ? 'idle' : 'working';
8662
- // master removed the AI ScreenAnalyzer; PR #597's Codex App no-progress
8663
- // liveness projection still layers on top of the base idle/working verdict.
8664
- status = codexAppLivenessStatus(status);
8665
9962
  void (async () => {
8666
- let content = lastContent;
8667
- let changed = false;
8668
- // Capture only when the pane has emitted output since our last snapshot.
8669
- // During idle (the steady state for a parked session) this skips a tmux
8670
- // capture-pane + a throwaway xterm-headless instantiation every tick
8671
- // the dominant per-session background cost while the status-transition
8672
- // send below still fires off the cached content. The exception is a
8673
- // self-driven screen (observe backend with a live web-attach): the
8674
- // watermark can't be trusted there, so capture every tick.
8675
- const ptyActivity = lastPtyActivityAtMs;
8676
- if (shouldCaptureScreen({
8677
- ptyActivity,
8678
- lastCapturedPtyActivity: lastSnapshotPtyActivity,
8679
- screenSelfDriven: isScreenSelfDriven(backend),
8680
- })) {
8681
- lastSnapshotPtyActivity = ptyActivity;
8682
- // Preferred path: pipe-pane backends pull a fresh viewport snapshot
8683
- // from tmux every tick. This eliminates the accumulated-buffer drift
8684
- // that produced duplicated/staircase text in 'text' display mode.
8685
- const pipeText = await snapshotToText(backend, renderCols, renderRows, { filter: true });
8686
- if (pipeText) {
8687
- content = pipeText.content;
8688
- const hash = pipeText.ansi;
8689
- changed = hash !== lastTextSnapshotHash;
8690
- lastTextSnapshotHash = hash;
8691
- // Refresh the unfiltered snapshot cache (lastAnalyzerSnapshot) that
8692
- // usage-limit detection and the CoCo picker read from. Same tmux
8693
- // call would otherwise need to fire twice per tick.
8694
- if (changed) {
8695
- const rawSnap = await snapshotToText(backend, renderCols, renderRows, { filter: false });
8696
- if (rawSnap)
8697
- lastAnalyzerSnapshot = rawSnap.content;
9963
+ const { snapshot, status } = await snapshotWithLatestRuntimeStatus(async () => {
9964
+ let content = lastContent;
9965
+ let changed = false;
9966
+ // Capture only when the pane has emitted output since our last snapshot.
9967
+ // During idle (the steady state for a parked session) this skips a tmux
9968
+ // capture-pane + a throwaway xterm-headless instantiation every tick
9969
+ // the dominant per-session background cost while the status-transition
9970
+ // send below still fires off the cached content. The exception is a
9971
+ // self-driven screen (observe backend with a live web-attach): the
9972
+ // watermark can't be trusted there, so capture every tick.
9973
+ const ptyActivity = lastPtyActivityAtMs;
9974
+ if (shouldCaptureScreen({
9975
+ ptyActivity,
9976
+ lastCapturedPtyActivity: lastSnapshotPtyActivity,
9977
+ screenSelfDriven: isScreenSelfDriven(backend),
9978
+ })) {
9979
+ lastSnapshotPtyActivity = ptyActivity;
9980
+ // Preferred path: pipe-pane backends pull a fresh viewport snapshot
9981
+ // from tmux every tick. This eliminates the accumulated-buffer drift
9982
+ // that produced duplicated/staircase text in 'text' display mode.
9983
+ const pipeText = await snapshotToText(backend, renderCols, renderRows, { filter: true });
9984
+ if (pipeText) {
9985
+ content = pipeText.content;
9986
+ const hash = pipeText.ansi;
9987
+ changed = hash !== lastTextSnapshotHash;
9988
+ lastTextSnapshotHash = hash;
9989
+ // Refresh the unfiltered cache that ScreenAnalyzer reads from. Same
9990
+ // tmux call would otherwise need to fire twice per tick.
9991
+ if (changed) {
9992
+ const rawSnap = await snapshotToText(backend, renderCols, renderRows, { filter: false });
9993
+ if (rawSnap)
9994
+ lastAnalyzerSnapshot = rawSnap.content;
9995
+ }
8698
9996
  }
9997
+ else if (renderer) {
9998
+ const snap = renderer.snapshot();
9999
+ content = snap.content;
10000
+ changed = snap.changed;
10001
+ }
10002
+ else {
10003
+ return null;
10004
+ }
10005
+ lastContent = content;
8699
10006
  }
8700
- else if (renderer) {
8701
- const snap = renderer.snapshot();
8702
- content = snap.content;
8703
- changed = snap.changed;
8704
- }
8705
- else {
8706
- return;
8707
- }
8708
- lastContent = content;
8709
- }
8710
- const usageAware = classifyScreenUsageLimit(content, status);
8711
- if (changed || usageAware.status !== lastSentStatus) {
10007
+ return { content, changed };
10008
+ }, projectedRuntimeScreenStatus);
10009
+ if (!snapshot)
10010
+ return;
10011
+ const usageAware = usageLimitTracker.classify(snapshot.content, status);
10012
+ if (snapshot.changed || usageAware.status !== lastSentStatus) {
8712
10013
  lastSentStatus = usageAware.status;
8713
- send({ type: 'screen_update', content, ...usageAware, turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt });
10014
+ send({
10015
+ type: 'screen_update',
10016
+ content: snapshot.content,
10017
+ ...usageAware,
10018
+ turnId: currentBotmuxTurnId,
10019
+ dispatchAttempt: currentBotmuxDispatchAttempt,
10020
+ });
8714
10021
  }
8715
10022
  })();
8716
10023
  }, SCREEN_UPDATE_INTERVAL_MS);
@@ -9082,7 +10389,8 @@ function scheduleBusyPatternIdleProbe(source) {
9082
10389
  }
9083
10390
  async function spawnCli(cfg, opts = {}) {
9084
10391
  const spawnGeneration = ++cliSpawnGeneration;
9085
- clearSessionRenameInFlight();
10392
+ // Prefer force-clear so a half-finished rename cannot block the new generation.
10393
+ forceClearSessionRenameInFlight();
9086
10394
  currentCliCredentialIsolated = false;
9087
10395
  // Enrollment writes the fixed marker before any device credential appears.
9088
10396
  // From that instant onward every NEW local CLI must carry a credential
@@ -11420,7 +12728,7 @@ async function spawnCli(cfg, opts = {}) {
11420
12728
  if (effectiveCliSessionId) {
11421
12729
  const rolloutPath = findCodexRolloutBySessionId(effectiveCliSessionId);
11422
12730
  if (rolloutPath) {
11423
- codexBridgeAttach(rolloutPath, 'baseline-existing');
12731
+ codexBridgeAttach(rolloutPath, effectiveResume ? 'baseline-existing' : 'fresh-empty');
11424
12732
  }
11425
12733
  else {
11426
12734
  codexBridgePendingSessionId = effectiveCliSessionId;
@@ -11439,7 +12747,7 @@ async function spawnCli(cfg, opts = {}) {
11439
12747
  if (effectiveCliSessionId) {
11440
12748
  const rolloutPath = findTraexRolloutBySessionId(effectiveCliSessionId);
11441
12749
  if (rolloutPath) {
11442
- codexBridgeAttach(rolloutPath, 'baseline-existing');
12750
+ codexBridgeAttach(rolloutPath, effectiveResume ? 'baseline-existing' : 'fresh-empty');
11443
12751
  }
11444
12752
  else {
11445
12753
  codexBridgePendingSessionId = effectiveCliSessionId;
@@ -11542,6 +12850,7 @@ async function spawnCli(cfg, opts = {}) {
11542
12850
  ];
11543
12851
  log(`Ready gate skipped — preflight failed: ${reasons.join('; ')}`);
11544
12852
  }
12853
+ ptyOutputGeneration.reset();
11545
12854
  if (shouldArmReadyGate({
11546
12855
  injectsReadyHook: cliAdapter.injectsReadyHook === true,
11547
12856
  readySignalAvailable,
@@ -11938,6 +13247,9 @@ function killCli(opts = {}) {
11938
13247
  idleDetector = null;
11939
13248
  stopReattachIdleProbe();
11940
13249
  stopBusyPatternIdleProbe();
13250
+ stopStructuredStartGraceRecheck();
13251
+ structuredRejectedReadyEvidenceGeneration = undefined;
13252
+ ptyOutputGeneration.reset();
11941
13253
  // Cancel any pending ready-gate fallback / settle timers; spawnCli re-arms on respawn.
11942
13254
  if (readySignalTimer) {
11943
13255
  clearTimeout(readySignalTimer);
@@ -12007,9 +13319,11 @@ function killCli(opts = {}) {
12007
13319
  sandboxCleanup = null;
12008
13320
  }
12009
13321
  isPromptReady = false;
12010
- clearSessionRenameInFlight();
12011
- if (!opts.preservePending)
13322
+ forceClearSessionRenameInFlight();
13323
+ if (!opts.preservePending) {
12012
13324
  pendingMessages.length = 0;
13325
+ pendingAdoptMessages.length = 0;
13326
+ }
12013
13327
  // pendingRawInputs contains only commands that were accepted but never typed
12014
13328
  // because /rename owned the TUI or an owned CLI restart was already fenced.
12015
13329
  // Preserve them across restart; unlike an in-flight raw command, replaying
@@ -12034,6 +13348,10 @@ async function restartCliProcess(reason, opts = {}) {
12034
13348
  log(`Restart ignored in adopt mode (${reason})`);
12035
13349
  return;
12036
13350
  }
13351
+ // Invalidate queued/deferred callbacks before async teardown begins. Waiting
13352
+ // until killCli/spawnCli would leave a window where an old timer can mutate
13353
+ // the next durable attempt while destroySession is still awaiting.
13354
+ cliSpawnGeneration += 1;
12037
13355
  // Set before touching destroySession(): remote teardown can await for many
12038
13356
  // seconds while the old backend object is still non-null and still capable
12039
13357
  // of firing idle/task-done callbacks. Inputs accepted in that interval must
@@ -13397,7 +14715,7 @@ function emitTurnTerminal(turnId, status, errorCode, dispatchAttempt) {
13397
14715
  if (!emittedTurnTerminals.claim(sessionId, turnId, dispatchAttempt))
13398
14716
  return;
13399
14717
  if (status !== 'completed') {
13400
- const dropped = codexBridgeQueue.dropPendingTurn(turnId, dispatchAttempt);
14718
+ const dropped = codexBridgeQueue.dropPendingTurn(turnId, dispatchAttempt, true);
13401
14719
  if (dropped) {
13402
14720
  log(`Structured bridge retired terminal attempt turn=${turnId.slice(0, 12)} attempt=${dispatchAttempt ?? '-'} status=${status}`);
13403
14721
  }
@@ -13758,6 +15076,11 @@ process.on('message', async (raw) => {
13758
15076
  await prepareCodexNativeTitleGeneration(msg, codexRpcEngine);
13759
15077
  if (codexRpcEngine)
13760
15078
  armRpcStartupDialogDismiss(); // boundary #4: keep the --remote pane from freezing on a startup dialog
15079
+ if (deferredFreshRpcTurn) {
15080
+ const deferred = deferredFreshRpcTurn;
15081
+ deferredFreshRpcTurn = undefined;
15082
+ releaseRpcTurnTerminalDeferral(deferred.identity, deferred.generation);
15083
+ }
13761
15084
  // Queue the initial prompt — flushed when CLI shows idle.
13762
15085
  // Adapters with passesInitialPromptViaArgs (e.g. Gemini -i) bake the
13763
15086
  // prompt into CLI args, so we normally skip queuing to avoid double-send.
@@ -13778,7 +15101,9 @@ process.on('message', async (raw) => {
13778
15101
  // Mark it here before the CLI starts processing; late-attach is fine
13779
15102
  // because CodexBridgeQueue is path-agnostic until ingest discovers the
13780
15103
  // transcript file.
13781
- codexBridgeMarkPendingTurn(msg.prompt, msg.turnId, msg.dispatchAttempt);
15104
+ const bridgeTurnId = codexBridgeMarkPendingTurn(msg.prompt, msg.turnId, msg.dispatchAttempt);
15105
+ if (bridgeTurnId)
15106
+ codexBridgeQueue.confirmPendingTurn(bridgeTurnId, undefined, msg.dispatchAttempt);
13782
15107
  }
13783
15108
  // Queue the initial prompt for flushPending (pure decision, unit-tested):
13784
15109
  // - paste (no engine): normal queue.
@@ -13930,6 +15255,13 @@ process.on('message', async (raw) => {
13930
15255
  // submission mutex is held so a queued message cannot steal attribution
13931
15256
  // from the write/verification already in flight.
13932
15257
  let turnSeq = usageLimitTracker.currentTurn();
15258
+ // Adopt handlers are async and serialized below, so their renderer/usage
15259
+ // turn begins inside writeAdoptMessage. Non-adopt keeps the immediate
15260
+ // baseline while the message waits for normal flush scheduling.
15261
+ if (!lastInitConfig?.adoptMode) {
15262
+ renderer?.markNewTurn();
15263
+ usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
15264
+ }
13933
15265
  if (msg.nativeSessionTitlePrompt
13934
15266
  && msg.nativeSessionTitle
13935
15267
  && lastInitConfig?.cliId === 'codex'
@@ -13985,173 +15317,28 @@ process.on('message', async (raw) => {
13985
15317
  }
13986
15318
  }
13987
15319
  if (lastInitConfig?.adoptMode) {
13988
- let adoptWritePrepared = false;
13989
- const prepareAdoptWrite = () => {
13990
- if (adoptWritePrepared)
13991
- return;
13992
- adoptWritePrepared = true;
13993
- renderer?.markNewTurn();
13994
- turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
13995
- if (tmuxScrolledHalfPages > 0)
13996
- exitTmuxScrollMode();
13997
- currentBotmuxTurnId = msg.turnId;
13998
- currentBotmuxDispatchAttempt = msg.dispatchAttempt;
13999
- currentVcMeetingImTurnOrigin = msg.vcMeetingImTurnOrigin;
14000
- writeCliPidMarker();
14001
- publishSandboxRelayCapability();
14002
- // Capture the transcript baseline only after this turn owns the
14003
- // submission lock and immediately before its literal write.
14004
- if (bridgeJsonlPath) {
14005
- try {
14006
- bridgeIngest();
14007
- }
14008
- catch { /* best effort */ }
14009
- bridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
14010
- }
14011
- else if (codexBridgeFallbackActive()) {
14012
- if (codexBridgeIsCursor()) {
14013
- codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
14014
- try {
14015
- codexBridgeIngest();
14016
- }
14017
- catch { /* best effort */ }
14018
- }
14019
- else {
14020
- try {
14021
- codexBridgeIngest();
14022
- }
14023
- catch { /* best effort */ }
14024
- codexBridgeMarkPendingTurn(content, msg.turnId, msg.dispatchAttempt);
14025
- }
14026
- }
15320
+ const item = {
15321
+ content,
15322
+ turnId: msg.turnId,
15323
+ replyTurnId: msg.replyTurnId,
15324
+ dispatchAttempt: msg.dispatchAttempt,
15325
+ codexAppDispatchId: msg.codexAppDispatchId,
15326
+ queuedActivationToken: msg.queuedActivationToken,
15327
+ vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
15328
+ codexAppInput,
14027
15329
  };
14028
- // Adopt mode write:
14029
- // - Structured-bridge adopt-input CLIs (codex/traex/pi/grok/mtr)
14030
- // route through cliAdapter.writeInput so paste+Enter-retry +
14031
- // transcript/history verify (and grok's prompt_history
14032
- // cliSessionId re-attach after /new) all run. Hardcoding only
14033
- // codex/traex left grok on raw sendText, so /new rotation never
14034
- // re-attached the bridge.
14035
- // - everything else keeps the simple raw sendText+Enter — the
14036
- // claude-code adopt bridge has its own dual-write recovery
14037
- // path, and the other CLIs' adopt flows haven't surfaced
14038
- // this submit-detection issue.
14039
- if (backend) {
14040
- if (isStructuredBridgeAdoptInputCli(lastInitConfig?.cliId) && cliAdapter) {
14041
- // writeInput is async but we're already inside an async
14042
- // message handler. Errors are best-effort logged; attribution and
14043
- // bridge baselines are prepared only after the submission lock is
14044
- // held, immediately before the adapter write.
14045
- const submissionBackend = backend;
14046
- const submissionAdapter = cliAdapter;
14047
- let recoveryFailureReason;
14048
- const composerConflict = codexAdoptComposerConflict(submissionBackend);
14049
- if (composerConflict) {
14050
- log('Refused Codex adopt input because the local composer contains an unsubmitted draft');
14051
- scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, composerConflict, turnSeq, msg, 'failed');
14052
- return;
14053
- }
14054
- try {
14055
- const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, () => submissionAdapter.writeInput(adapterInputHandle(submissionBackend), content), settleVerifiableSubmissionForJournal, prepareAdoptWrite);
14056
- const result = transaction.result;
14057
- recoveryFailureReason = transaction.recoveryFailureReason;
14058
- if (result?.cliSessionId) {
14059
- persistCliSessionId(result.cliSessionId);
14060
- codexBridgeNotifyCliSessionId(result.cliSessionId);
14061
- }
14062
- if (result?.submitted === false || recoveryFailureReason) {
14063
- if (recoveryFailureReason) {
14064
- notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
14065
- }
14066
- else {
14067
- scheduleSubmitFailureNotify(content, result?.recheck, 'submit history', undefined, result?.failureReason, turnSeq, msg, 'failed');
14068
- }
14069
- }
14070
- else {
14071
- acknowledgeTurnInputCommitted(msg.turnId);
14072
- }
14073
- }
14074
- catch (err) {
14075
- recoveryFailureReason = err instanceof SubmissionWriteError
14076
- ? err.recoveryFailureReason
14077
- : recoveryFailureReason;
14078
- const blockedBeforeWrite = err instanceof SubmissionWriteError
14079
- && !!err.recoveryFailureReason
14080
- && !err.submissionStarted;
14081
- log(`Adopt writeInput error (${lastInitConfig?.cliId}): ${err.message}`);
14082
- if (msg.turnId
14083
- && msg.dispatchAttempt !== undefined
14084
- && blockedBeforeWrite) {
14085
- emitTurnTerminal(msg.turnId, 'failed', 'zmx_recovery_blocked_before_write', msg.dispatchAttempt);
14086
- }
14087
- else if (msg.turnId
14088
- && msg.dispatchAttempt !== undefined
14089
- && !recoveryFailureReason) {
14090
- emitTurnTerminal(msg.turnId, 'ambiguous', 'write_input_threw', msg.dispatchAttempt);
14091
- }
14092
- if (backend) {
14093
- if (recoveryFailureReason) {
14094
- notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
14095
- }
14096
- else {
14097
- scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, undefined, turnSeq, msg, 'ambiguous');
14098
- }
14099
- }
14100
- }
14101
- }
14102
- else if ('sendText' in backend && 'sendSpecialKeys' in backend) {
14103
- const submissionBackend = backend;
14104
- let recoveryFailureReason;
14105
- try {
14106
- const transaction = await runAmbiguousSubmissionTransaction(submissionBackend, async () => {
14107
- const input = adapterInputHandle(submissionBackend);
14108
- input.sendText(content);
14109
- // Beat between text and Enter so the adopted CLI's input
14110
- // layer registers the typed chars before submit.
14111
- await new Promise(r => setTimeout(r, 200));
14112
- input.sendSpecialKeys('Enter');
14113
- }, undefined, prepareAdoptWrite);
14114
- recoveryFailureReason = transaction.recoveryFailureReason;
14115
- if (recoveryFailureReason) {
14116
- throw new SubmissionWriteError(`backend could not commit the adopt submission journal; ${recoveryFailureReason}`, recoveryFailureReason);
14117
- }
14118
- acknowledgeTurnInputCommitted(msg.turnId);
14119
- }
14120
- catch (err) {
14121
- recoveryFailureReason = err instanceof SubmissionWriteError
14122
- ? err.recoveryFailureReason
14123
- : recoveryFailureReason;
14124
- const blockedBeforeWrite = err instanceof SubmissionWriteError
14125
- && !!err.recoveryFailureReason
14126
- && !err.submissionStarted;
14127
- log(`Adopt raw input error (${lastInitConfig?.cliId}): ${err.message}`);
14128
- if (msg.turnId
14129
- && msg.dispatchAttempt !== undefined
14130
- && blockedBeforeWrite) {
14131
- emitTurnTerminal(msg.turnId, 'failed', 'zmx_recovery_blocked_before_write', msg.dispatchAttempt);
14132
- }
14133
- else if (msg.turnId
14134
- && msg.dispatchAttempt !== undefined
14135
- && !recoveryFailureReason) {
14136
- emitTurnTerminal(msg.turnId, 'ambiguous', 'write_input_threw', msg.dispatchAttempt);
14137
- }
14138
- if (backend) {
14139
- if (recoveryFailureReason) {
14140
- notifyAmbiguousSubmissionRecovery(recoveryFailureReason, msg);
14141
- }
14142
- else {
14143
- scheduleSubmitFailureNotify(content, undefined, 'submit history', undefined, undefined, turnSeq, msg, 'ambiguous');
14144
- }
14145
- }
14146
- }
14147
- }
14148
- else {
14149
- prepareAdoptWrite();
14150
- backend.write(content + '\r');
14151
- acknowledgeTurnInputCommitted(msg.turnId);
14152
- }
14153
- isPromptReady = false;
14154
- idleDetector?.reset();
15330
+ // process.on('message') does not serialize async listeners. Hold the
15331
+ // per-worker queue across transcript mark + complete adapter write so
15332
+ // two CoCo/Codex paste→wait→Enter/history cycles cannot overlap.
15333
+ if (cliRestartInProgress || rawInputRestartGate || !backend || sessionRenameInFlight()) {
15334
+ pendingAdoptMessages.push(item);
15335
+ log(`Deferred adopt message until the CLI generation/input gate settles (${content.length} chars)`);
15336
+ }
15337
+ else {
15338
+ await runAdoptMessageForCapturedGeneration(item, () => {
15339
+ pendingAdoptMessages.push(item);
15340
+ log(`Re-queued stale adopt message for the replacement CLI generation (${content.length} chars)`);
15341
+ });
14155
15342
  }
14156
15343
  }
14157
15344
  else {
@@ -14193,7 +15380,7 @@ process.on('message', async (raw) => {
14193
15380
  // shell。bareShellCheckInProgress 覆盖“检查进行中”、bareShellLaunchBlocked
14194
15381
  // 覆盖“仍停在裸 shell 的安全 hold”两种状态,一并入队;若该进程随后出现
14195
15382
  // 真实 PTY prompt 且 leaf 已变为非 shell,markPromptReady 会恢复排空。
14196
- if (cliRestartInProgress || rawInputRestartGate || sessionRenameInFlight
15383
+ if (cliRestartInProgress || rawInputRestartGate || sessionRenameInFlight()
14197
15384
  || shouldHoldCodexRunnerInput(codexRunnerFreshness)
14198
15385
  || injectionFlushing || shouldDeferUserFlush(pendingInjections)
14199
15386
  || bareShellCheckInProgress || bareShellLaunchBlocked) {
@@ -14737,7 +15924,10 @@ process.on('message', async (raw) => {
14737
15924
  pendingMessages: pendingMessages.length,
14738
15925
  pendingRawInputs: pendingRawInputs.length,
14739
15926
  pendingSessionRename: pendingSessionRename !== null,
14740
- sessionRenameInFlight,
15927
+ // sessionRenameInFlight became a function in this branch's rename
15928
+ // lifecycle rework; riffWorkerShutdownInputBlocker (added on master by
15929
+ // the RIFF two-phase shutdown) still consumes it as a boolean field.
15930
+ sessionRenameInFlight: sessionRenameInFlight(),
14741
15931
  commandLineWritesPending,
14742
15932
  });
14743
15933
  if (preparedCloseRequestId || closeRequestInFlightId) {