botmux 3.2.0 → 3.2.1

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 (169) hide show
  1. package/README.en.md +1 -0
  2. package/README.md +1 -0
  3. package/dist/adapters/cli/codex.d.ts.map +1 -1
  4. package/dist/adapters/cli/codex.js +13 -1
  5. package/dist/adapters/cli/codex.js.map +1 -1
  6. package/dist/adapters/cli/traex.d.ts.map +1 -1
  7. package/dist/adapters/cli/traex.js +11 -1
  8. package/dist/adapters/cli/traex.js.map +1 -1
  9. package/dist/adapters/cli/types.d.ts +6 -0
  10. package/dist/adapters/cli/types.d.ts.map +1 -1
  11. package/dist/bot-registry.d.ts +7 -0
  12. package/dist/bot-registry.d.ts.map +1 -1
  13. package/dist/bot-registry.js +1 -0
  14. package/dist/bot-registry.js.map +1 -1
  15. package/dist/cli/daemon-lifecycle-env.d.ts +11 -0
  16. package/dist/cli/daemon-lifecycle-env.d.ts.map +1 -0
  17. package/dist/cli/daemon-lifecycle-env.js +25 -0
  18. package/dist/cli/daemon-lifecycle-env.js.map +1 -0
  19. package/dist/cli.d.ts.map +1 -1
  20. package/dist/cli.js +11 -4
  21. package/dist/cli.js.map +1 -1
  22. package/dist/codex-rpc-engine.d.ts +110 -0
  23. package/dist/codex-rpc-engine.d.ts.map +1 -0
  24. package/dist/codex-rpc-engine.js +496 -0
  25. package/dist/codex-rpc-engine.js.map +1 -0
  26. package/dist/codex-rpc-lifecycle.d.ts +157 -0
  27. package/dist/codex-rpc-lifecycle.d.ts.map +1 -0
  28. package/dist/codex-rpc-lifecycle.js +210 -0
  29. package/dist/codex-rpc-lifecycle.js.map +1 -0
  30. package/dist/config.d.ts +1 -0
  31. package/dist/config.d.ts.map +1 -1
  32. package/dist/config.js +14 -2
  33. package/dist/config.js.map +1 -1
  34. package/dist/core/attachment-path.d.ts +3 -0
  35. package/dist/core/attachment-path.d.ts.map +1 -0
  36. package/dist/core/attachment-path.js +8 -0
  37. package/dist/core/attachment-path.js.map +1 -0
  38. package/dist/core/command-handler.d.ts.map +1 -1
  39. package/dist/core/command-handler.js +6 -1
  40. package/dist/core/command-handler.js.map +1 -1
  41. package/dist/core/dashboard-images.d.ts +29 -0
  42. package/dist/core/dashboard-images.d.ts.map +1 -0
  43. package/dist/core/dashboard-images.js +113 -0
  44. package/dist/core/dashboard-images.js.map +1 -0
  45. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  46. package/dist/core/dashboard-ipc-server.js +13 -1
  47. package/dist/core/dashboard-ipc-server.js.map +1 -1
  48. package/dist/core/github-auth.d.ts +5 -0
  49. package/dist/core/github-auth.d.ts.map +1 -1
  50. package/dist/core/github-auth.js +38 -0
  51. package/dist/core/github-auth.js.map +1 -1
  52. package/dist/core/maintenance.d.ts +1 -0
  53. package/dist/core/maintenance.d.ts.map +1 -1
  54. package/dist/core/maintenance.js +9 -1
  55. package/dist/core/maintenance.js.map +1 -1
  56. package/dist/core/session-create.d.ts +10 -0
  57. package/dist/core/session-create.d.ts.map +1 -1
  58. package/dist/core/session-create.js +21 -0
  59. package/dist/core/session-create.js.map +1 -1
  60. package/dist/core/session-manager.d.ts +4 -1
  61. package/dist/core/session-manager.d.ts.map +1 -1
  62. package/dist/core/session-manager.js +25 -13
  63. package/dist/core/session-manager.js.map +1 -1
  64. package/dist/core/trigger-session.d.ts +3 -0
  65. package/dist/core/trigger-session.d.ts.map +1 -1
  66. package/dist/core/trigger-session.js +17 -3
  67. package/dist/core/trigger-session.js.map +1 -1
  68. package/dist/core/worker-pool.d.ts.map +1 -1
  69. package/dist/core/worker-pool.js +2 -0
  70. package/dist/core/worker-pool.js.map +1 -1
  71. package/dist/daemon.d.ts.map +1 -1
  72. package/dist/daemon.js +19 -3
  73. package/dist/daemon.js.map +1 -1
  74. package/dist/dashboard/connector-api.d.ts.map +1 -1
  75. package/dist/dashboard/connector-api.js +19 -0
  76. package/dist/dashboard/connector-api.js.map +1 -1
  77. package/dist/dashboard/settings-write-applier.d.ts +2 -1
  78. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  79. package/dist/dashboard/settings-write-applier.js +6 -0
  80. package/dist/dashboard/settings-write-applier.js.map +1 -1
  81. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  82. package/dist/dashboard/web/connectors-page.js +23 -2
  83. package/dist/dashboard/web/connectors-page.js.map +1 -1
  84. package/dist/dashboard/web/create-session-composer.d.ts +35 -0
  85. package/dist/dashboard/web/create-session-composer.d.ts.map +1 -0
  86. package/dist/dashboard/web/create-session-composer.js +70 -0
  87. package/dist/dashboard/web/create-session-composer.js.map +1 -0
  88. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  89. package/dist/dashboard/web/i18n.js +65 -5
  90. package/dist/dashboard/web/i18n.js.map +1 -1
  91. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  92. package/dist/dashboard/web/sessions-page.js +147 -1
  93. package/dist/dashboard/web/sessions-page.js.map +1 -1
  94. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  95. package/dist/dashboard/web/settings-page.js +2 -1
  96. package/dist/dashboard/web/settings-page.js.map +1 -1
  97. package/dist/dashboard/web/skills-page.d.ts.map +1 -1
  98. package/dist/dashboard/web/skills-page.js +5 -2
  99. package/dist/dashboard/web/skills-page.js.map +1 -1
  100. package/dist/dashboard/webhook-routes.d.ts +4 -0
  101. package/dist/dashboard/webhook-routes.d.ts.map +1 -1
  102. package/dist/dashboard/webhook-routes.js +19 -0
  103. package/dist/dashboard/webhook-routes.js.map +1 -1
  104. package/dist/dashboard-web/app.js +1 -1
  105. package/dist/dashboard-web/chunks/{bot-defaults-page-4PM4T6QQ.js → bot-defaults-page-O37SAQD5.js} +1 -1
  106. package/dist/dashboard-web/chunks/{chunk-RXVA6XW3.js → chunk-6QHMLMIM.js} +6 -6
  107. package/dist/dashboard-web/chunks/{chunk-IKCZ677K.js → chunk-B42PCL5P.js} +1 -1
  108. package/dist/dashboard-web/chunks/{chunk-3PMN55Z6.js → chunk-FYALHCPG.js} +1 -1
  109. package/dist/dashboard-web/chunks/{chunk-AIDN2WZB.js → chunk-O2DPQL7C.js} +1 -1
  110. package/dist/dashboard-web/chunks/connectors-page-DZBQ42MH.js +1 -0
  111. package/dist/dashboard-web/chunks/{groups-page-TAU2UIVS.js → groups-page-3IEGRZKQ.js} +1 -1
  112. package/dist/dashboard-web/chunks/{insights-page-PJ2XVM6T.js → insights-page-X3ADRON3.js} +1 -1
  113. package/dist/dashboard-web/chunks/{monitor-room-D6445HJO.js → monitor-room-IV36RGQY.js} +1 -1
  114. package/dist/dashboard-web/chunks/{monitoring-page-GUHMCKSO.js → monitoring-page-ADFKVON4.js} +1 -1
  115. package/dist/dashboard-web/chunks/{office-page-6K6U2MS5.js → office-page-CALAOWNS.js} +1 -1
  116. package/dist/dashboard-web/chunks/{overview-page-USDYU7U5.js → overview-page-SLMGLK3M.js} +1 -1
  117. package/dist/dashboard-web/chunks/{roles-page-O4OUA7ZH.js → roles-page-L7JJ4FFV.js} +1 -1
  118. package/dist/dashboard-web/chunks/{schedules-page-LNIBXJSZ.js → schedules-page-Z3672POL.js} +1 -1
  119. package/dist/dashboard-web/chunks/sessions-page-OKJ5YWUZ.js +3 -0
  120. package/dist/dashboard-web/chunks/settings-page-P6MI2ZUE.js +2 -0
  121. package/dist/dashboard-web/chunks/{skills-page-M5KYNXBA.js → skills-page-FT7BVZ6X.js} +4 -4
  122. package/dist/dashboard-web/chunks/{team-federation-page-GT7UFQMN.js → team-federation-page-DEYCPUFB.js} +1 -1
  123. package/dist/dashboard-web/chunks/{v3-page-UYZIRJ2C.js → v3-page-4IOWREZG.js} +1 -1
  124. package/dist/dashboard-web/chunks/{whiteboards-page-WAR77DQE.js → whiteboards-page-2VMBETZH.js} +1 -1
  125. package/dist/dashboard-web/style.css +191 -0
  126. package/dist/dashboard.js +33 -6
  127. package/dist/dashboard.js.map +1 -1
  128. package/dist/global-config.d.ts +7 -0
  129. package/dist/global-config.d.ts.map +1 -1
  130. package/dist/global-config.js +2 -0
  131. package/dist/global-config.js.map +1 -1
  132. package/dist/im/lark/ask-card.d.ts.map +1 -1
  133. package/dist/im/lark/ask-card.js +52 -4
  134. package/dist/im/lark/ask-card.js.map +1 -1
  135. package/dist/im/lark/card-handler.d.ts +2 -0
  136. package/dist/im/lark/card-handler.d.ts.map +1 -1
  137. package/dist/im/lark/card-handler.js +19 -2
  138. package/dist/im/lark/card-handler.js.map +1 -1
  139. package/dist/im/lark/event-dispatcher.d.ts +3 -0
  140. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  141. package/dist/im/lark/event-dispatcher.js +706 -693
  142. package/dist/im/lark/event-dispatcher.js.map +1 -1
  143. package/dist/im/lark/grant-pending.d.ts +8 -4
  144. package/dist/im/lark/grant-pending.d.ts.map +1 -1
  145. package/dist/im/lark/grant-pending.js +13 -6
  146. package/dist/im/lark/grant-pending.js.map +1 -1
  147. package/dist/services/connector-store.d.ts +8 -0
  148. package/dist/services/connector-store.d.ts.map +1 -1
  149. package/dist/services/connector-store.js.map +1 -1
  150. package/dist/services/session-store.d.ts +2 -1
  151. package/dist/services/session-store.d.ts.map +1 -1
  152. package/dist/services/session-store.js +60 -2
  153. package/dist/services/session-store.js.map +1 -1
  154. package/dist/services/skill-registry-store.d.ts +16 -0
  155. package/dist/services/skill-registry-store.d.ts.map +1 -1
  156. package/dist/services/skill-registry-store.js +343 -47
  157. package/dist/services/skill-registry-store.js.map +1 -1
  158. package/dist/services/trigger-types.d.ts +5 -0
  159. package/dist/services/trigger-types.d.ts.map +1 -1
  160. package/dist/services/trigger-types.js +12 -0
  161. package/dist/services/trigger-types.js.map +1 -1
  162. package/dist/types.d.ts +13 -2
  163. package/dist/types.d.ts.map +1 -1
  164. package/dist/worker.js +440 -34
  165. package/dist/worker.js.map +1 -1
  166. package/package.json +1 -1
  167. package/dist/dashboard-web/chunks/connectors-page-2MKF4YTN.js +0 -1
  168. package/dist/dashboard-web/chunks/sessions-page-B6U6TCFF.js +0 -3
  169. package/dist/dashboard-web/chunks/settings-page-PXLRFF7I.js +0 -2
package/dist/worker.js CHANGED
@@ -74,6 +74,8 @@ import { createCliAdapterSync, locateOnPath } from './adapters/cli/registry.js';
74
74
  import { buildWrappedLaunch, parseWrapperCli, isTtadkWrapper } from './setup/cli-selection.js';
75
75
  import { cliUnavailableMessage } from './setup/cli-availability.js';
76
76
  import { findLaunchedCliPid, scheduleWrapperRealCliPid, readComm, isBareShellComm, bareShellLaunchKind } from './core/session-discovery.js';
77
+ import { codexRpcEligible, paneRunsRemoteTui, orchestrateCodexRpcInit, rolloutUserTurnMatches, decideStartupDialogAction, shouldQueueInitialPrompt, shouldPreMarkFirstTurn, killAndVerifyPersistentPane } from './codex-rpc-lifecycle.js';
78
+ import { delay } from './utils/timing.js';
77
79
  import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, syncClaudeResumeTargetToCwd, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js';
78
80
  import { sessionReadyHookCommand } from './adapters/hook-command.js';
79
81
  import { mtrSessionIdForBotmuxSession } from './adapters/cli/mtr.js';
@@ -111,10 +113,273 @@ import { withCodexAppContext } from './utils/codex-app-context.js';
111
113
  import { resolveCodexAppFinalTurnIdentity } from './adapters/cli/codex-app-turn.js';
112
114
  import { RunnerControlDecoder } from './adapters/cli/runner-control-channel.js';
113
115
  import { hasMatchingManagedOriginCapability, managedOriginCapabilityPath, RELAY_ORIGIN_CAPABILITY_BASENAME, replaceManagedOriginCapabilityFile, } from './core/managed-origin-capability.js';
116
+ import { CodexRpcEngine } from './codex-rpc-engine.js';
114
117
  // ─── State ───────────────────────────────────────────────────────────────────
115
118
  let cliAdapter = null;
116
119
  let backend = null;
117
120
  let intentionalRestartBackend = null;
121
+ // Hybrid codex RPC input mode (opt-in per bot, cfg.codexRpcInput). When active,
122
+ // user input is delivered to codex via the app-server JSON-RPC channel
123
+ // (turn/start) instead of a tmux paste, and the pane runs `codex --remote`
124
+ // attached to this engine's thread. All three are set together or none.
125
+ let codexRpcEngine;
126
+ let remoteWsUrl;
127
+ let remoteThreadId;
128
+ let rpcDialogDismissTimer = null;
129
+ let rpcEnginePidMarker = null;
130
+ function stopCodexRpcEngine() {
131
+ const engine = codexRpcEngine;
132
+ codexRpcEngine = undefined;
133
+ remoteWsUrl = undefined;
134
+ remoteThreadId = undefined;
135
+ clearRpcEnginePidMarker();
136
+ try {
137
+ engine?.stop();
138
+ }
139
+ catch { /* best effort */ }
140
+ }
141
+ /** Resolve the persistent pane name + liveness for a backend type, mirroring
142
+ * spawnCli's willReattachPersistent probe (worker.ts) but callable from the init
143
+ * handler BEFORE spawnCli runs (effectiveBackendType is stale there). A wrong
144
+ * guess is safe: an assumed-tmux session that is really pty just has no live
145
+ * session → treated as fresh. Returns null for non-persistent backends. */
146
+ function persistentPaneInfo(backendType, sessionId) {
147
+ let name;
148
+ if (backendType === 'tmux')
149
+ name = TmuxBackend.sessionName(sessionId);
150
+ else if (backendType === 'herdr')
151
+ name = HerdrBackend.sessionName(sessionId);
152
+ else if (backendType === 'zellij')
153
+ name = ZellijBackend.sessionName(sessionId);
154
+ if (!name)
155
+ return null;
156
+ const live = backendType === 'tmux' ? TmuxBackend.hasSession(name)
157
+ : backendType === 'zellij' ? ZellijBackend.hasSession(name)
158
+ : HerdrBackend.hasSession(name);
159
+ return { name, live };
160
+ }
161
+ function persistentPaneLive(backendType, name) {
162
+ if (backendType === 'tmux')
163
+ return TmuxBackend.hasSession(name);
164
+ if (backendType === 'zellij')
165
+ return ZellijBackend.hasSession(name);
166
+ if (backendType === 'herdr')
167
+ return HerdrBackend.hasSession(name);
168
+ return false;
169
+ }
170
+ /** Kill a persistent session and VERIFY it is gone (bounded retry). killSession
171
+ * swallows exceptions and killPersistentSession returns void, so a plain
172
+ * try/catch can never observe a failed kill — and a surviving (dead-`--remote`)
173
+ * pane would then be reattached against the NEW app-server's fresh port,
174
+ * re-triggering the exact P0 freeze. Returns true only when the session is
175
+ * confirmed absent (Codex delta point 1). */
176
+ async function killPersistentSessionVerified(backendType, name) {
177
+ return killAndVerifyPersistentPane(name, {
178
+ kill: (resolvedName) => killPersistentSession(backendType, resolvedName),
179
+ isLive: (resolvedName) => persistentPaneLive(backendType, resolvedName),
180
+ wait: delay,
181
+ });
182
+ }
183
+ /** Poll the codex/traex rollout for POSITIVE evidence that the fresh first turn's
184
+ * user message was persisted (P1-1). Used only to resolve a dispatched-but-
185
+ * unacked first turn: a hit means the app-server accepted the turn → 'accepted'
186
+ * (never resend); no hit within the window stays 'ambiguous' (NOT downgraded to
187
+ * safe). A wrong sessions root (custom CODEX_HOME) simply fails to find → stays
188
+ * ambiguous, which is the safe direction. */
189
+ async function codexRolloutProbe(cliId, threadId, promptText, timeoutMs) {
190
+ const findPath = cliId === 'traex' ? findTraexRolloutBySessionId : findCodexRolloutBySessionId;
191
+ const deadline = Date.now() + timeoutMs;
192
+ do {
193
+ try {
194
+ const path = findPath(threadId);
195
+ if (path) {
196
+ const { events } = drainCodexRollout(path, 0);
197
+ if (rolloutUserTurnMatches(events, promptText))
198
+ return true;
199
+ }
200
+ }
201
+ catch { /* keep polling */ }
202
+ await delay(400);
203
+ } while (Date.now() < deadline);
204
+ return false;
205
+ }
206
+ /** Stand up (or re-establish) the per-session codex app-server + botmux-owned
207
+ * thread and point remote{WsUrl,ThreadId} at it, so the next spawnCli launches
208
+ * `codex --remote <ws> resume <thread>` and input flows over JSON-RPC. Fully
209
+ * fail-closed: on ANY failure the LOCAL engine is stopped and the vars cleared,
210
+ * so spawnCli falls back to paste (boundary #1 — nothing else is touched).
211
+ * Rebuilds from scratch each call (tears down a prior engine) so every
212
+ * incarnation binds the TUI to the CURRENT app-server (a fresh port each time),
213
+ * never a dead one — called from init AND the in-worker restart path (P1-3b).
214
+ *
215
+ * Ordering: engine.start() (/readyz) AND thread start/resume BOTH complete
216
+ * before returning true. For a FRESH session the first turn is ALSO sent here —
217
+ * an empty thread has no rollout, so `--remote resume` can't attach to it
218
+ * (verified), hence the first turn must persist the rollout BEFORE the pane
219
+ * spawns; it renders as history and later turns stream live. "thread ready" is
220
+ * thus a distinct step from "first turn sent". */
221
+ async function engageCodexRpc(cfg) {
222
+ if (!codexRpcEligible(cfg, { sandboxForced: sandboxEnabled() }))
223
+ return 'not-engaged';
224
+ const wantResume = cfg.resume === true && !!cfg.cliSessionId;
225
+ stopCodexRpcEngine();
226
+ let engine;
227
+ try {
228
+ const cliBin = createCliAdapterSync(cfg.cliId, cfg.cliPathOverride).resolvedBin;
229
+ const engineEnv = { ...redactChildEnv(process.env) };
230
+ engineEnv.PATH = `${join(homedir(), '.botmux', 'bin')}:${engineEnv.PATH ?? ''}`;
231
+ engineEnv.BOTMUX_SESSION_ID = cfg.sessionId;
232
+ engineEnv.BOTMUX_LARK_APP_ID = cfg.larkAppId;
233
+ // The app-server owns model execution in RPC mode. Its MCP gateway child
234
+ // must inherit the trusted host socket just like a native CLI process does.
235
+ if (sessionMcpGatewayHost) {
236
+ engineEnv[MCP_GATEWAY_SOCKET_ENV] = sessionMcpGatewayHost.socketPath;
237
+ engineEnv[MCP_GATEWAY_REQUIRED_ENV] = '1';
238
+ }
239
+ else {
240
+ delete engineEnv[MCP_GATEWAY_SOCKET_ENV];
241
+ delete engineEnv[MCP_GATEWAY_REQUIRED_ENV];
242
+ }
243
+ // P1-2: the app-server is where the model actually runs, so it needs the SAME
244
+ // per-bot provider env (base-url + token, proxy, feature flags) spawnCli
245
+ // injects into the TUI — else a 3rd-party-provider bot's app-server silently
246
+ // falls back to the default provider. Re-sanitized (crossed IPC).
247
+ Object.assign(engineEnv, sanitizePerBotEnv(cfg.env));
248
+ engine = new CodexRpcEngine({
249
+ cliBin, cwd: cfg.workingDir, env: engineEnv, sessionId: cfg.sessionId,
250
+ model: cfg.model, log: (m) => log(m),
251
+ onDead: () => {
252
+ if (codexRpcEngine === engine) {
253
+ log('Codex RPC app-server died; replacing the tmux session and re-engaging the thread');
254
+ // failAll() rejects the active sendTurn promise immediately after this
255
+ // callback returns. Let that microtask classify/notify the ambiguous
256
+ // submit while the old backend still exists, then replace the paired
257
+ // viewer on the next timer turn. Restarting synchronously here nulls
258
+ // backend first and suppresses the submit-failure notice.
259
+ const restartTimer = setTimeout(() => {
260
+ if (codexRpcEngine !== engine)
261
+ return; // close/restart won the race
262
+ void restartCliProcess('Codex RPC app-server died', { immediate: true, preservePending: true });
263
+ }, 0);
264
+ restartTimer.unref?.();
265
+ }
266
+ },
267
+ });
268
+ await engine.start();
269
+ // Shell tools run under the app-server process tree, not the viewer TUI.
270
+ // Mark its pid before the first turn so `botmux send` resolves the current
271
+ // per-turn identity instead of falling back to a stale/session-only env.
272
+ registerRpcEnginePidMarker(engine.appServerPid);
273
+ const threadId = wantResume ? await engine.resumeThread(cfg.cliSessionId) : await engine.startThread();
274
+ let outcome = wantResume ? 'resumed' : 'accepted';
275
+ if (!wantResume && cfg.prompt) {
276
+ // Three-state delivery (P1-1, exactly-once priority): 'accepted' (ack or
277
+ // rollout evidence), 'not-sent' (frame never dispatched → safe paste), or
278
+ // 'ambiguous' (dispatched, unconfirmed → engaged but NEVER resend).
279
+ const first = await engine.sendFirstTurn(cfg.prompt, cfg.turnId, (tid) => codexRolloutProbe(cfg.cliId, tid, cfg.prompt, 12_000));
280
+ if (first === 'not-sent') {
281
+ // The turn/start frame never left → the turn cannot have run → tear the
282
+ // engine down and fall back to paste. flushPending marks the bridge once
283
+ // on the paste path — we must NOT pre-mark here or that would double-mark
284
+ // the same turnId and leave a stale, never-consumed queue head (Codex P1).
285
+ log('Codex RPC fresh first turn: frame not dispatched → falling back to paste (safe, single execution)');
286
+ clearRpcEnginePidMarker();
287
+ try {
288
+ engine.stop();
289
+ }
290
+ catch { /* best effort */ }
291
+ return 'not-engaged';
292
+ }
293
+ // Bridge mark ONLY for a confirmed-accepted turn — so the structured
294
+ // fallback can attribute the reply even if the model skips `botmux send`.
295
+ // Marked here (after the outcome, before persistCliSessionId/attach — late
296
+ // attach from offset 0 still matches, timing-safe). 'ambiguous' does NOT
297
+ // mark: there is no positive evidence the turn ran, and an unstarted head
298
+ // would permanently block every later turn's drain — the notify + Web
299
+ // terminal are the authoritative recovery surface (Codex P1).
300
+ if (shouldPreMarkFirstTurn(first))
301
+ codexBridgeMarkPendingTurn(cfg.prompt, cfg.turnId);
302
+ outcome = first; // 'accepted' | 'ambiguous' — both stay engaged, prompt never re-queued
303
+ }
304
+ persistCliSessionId(threadId);
305
+ codexRpcEngine = engine;
306
+ remoteWsUrl = engine.wsUrl;
307
+ remoteThreadId = threadId;
308
+ log(`Codex RPC input engaged (${outcome}${wantResume ? '/resume' : '/fresh'}): app-server ${engine.wsUrl} thread ${threadId}`);
309
+ return outcome;
310
+ }
311
+ catch (err) {
312
+ log(`Codex RPC input failed to start (${err?.message ?? err}); falling back to paste mode`);
313
+ clearRpcEnginePidMarker();
314
+ try {
315
+ engine?.stop();
316
+ }
317
+ catch { /* best effort */ } // P1-3a: stop the LOCAL ref (codexRpcEngine may be unassigned)
318
+ codexRpcEngine = undefined;
319
+ remoteWsUrl = undefined;
320
+ remoteThreadId = undefined;
321
+ return 'not-engaged';
322
+ }
323
+ }
324
+ /** RPC panes have NO terminal input path (turns go via JSON-RPC), so codex's
325
+ * interactive startup dialogs — most notably "Update available … Press enter to
326
+ * continue" right after a codex release — would block the `--remote resume` TUI
327
+ * from ever attaching, freezing the Web terminal even though turns still
328
+ * process. Bounded startup hygiene (boundary #4): for RPC panes only, watch the
329
+ * pane briefly and dismiss such a dialog with a single Enter, self-cancelling
330
+ * once the composer (readyPattern) is up or a hard cap elapses. This is startup
331
+ * handling, NOT ongoing screen-scraping — an Enter at the ready composer is a
332
+ * harmless empty submit codex ignores, and it never runs for paste panes. */
333
+ function armRpcStartupDialogDismiss() {
334
+ if (rpcDialogDismissTimer) {
335
+ clearTimeout(rpcDialogDismissTimer);
336
+ rpcDialogDismissTimer = null;
337
+ }
338
+ const deadline = Date.now() + 30_000;
339
+ // The update dialog is disabled at the source via `-c check_for_update_on_startup=false`
340
+ // (codex.ts/traex.ts buildArgs). This watcher is only a fail-safe. It must NEVER
341
+ // blind-press keys on an update menu — the default selection can be "Update now",
342
+ // which would trigger a self-update. So: if an update dialog is somehow present
343
+ // (config not honored), WARN and do nothing. Only a plain "Press enter to
344
+ // continue" with NO update/menu options gets a single safe Enter.
345
+ let warnedUpdate = false;
346
+ const tick = () => {
347
+ rpcDialogDismissTimer = null;
348
+ if (!codexRpcEngine || !backend)
349
+ return; // RPC torn down / pane gone
350
+ if (Date.now() > deadline) {
351
+ log('Codex RPC: startup-dialog watch timed out');
352
+ return;
353
+ }
354
+ let screen = '';
355
+ try {
356
+ screen = renderer?.snapshot().content ?? '';
357
+ }
358
+ catch { /* renderer not ready yet */ }
359
+ const action = decideStartupDialogAction(screen, cliAdapter?.readyPattern);
360
+ if (action === 'warn-update') {
361
+ // Update menu present despite the config disable → NEVER auto-press (default
362
+ // may be "Update now"); warn the user so they dismiss it manually (P2).
363
+ if (!warnedUpdate) {
364
+ warnedUpdate = true;
365
+ log('Codex RPC: update dialog appeared despite check_for_update_on_startup=false — NOT auto-pressing; asking user to dismiss');
366
+ send({ type: 'user_notify', message: '⚠️ Codex 更新弹窗挡住了网页终端渲染。为避免误触自更新未自动处理——请在网页终端手动选「Skip」;消息仍经 RPC 正常处理,不影响回复。', turnId: currentBotmuxTurnId });
367
+ }
368
+ }
369
+ else if (action === 'dismiss-safe') {
370
+ log('Codex RPC: dismissing a safe "press enter to continue" prompt on the --remote pane');
371
+ try {
372
+ backend.write('\r');
373
+ }
374
+ catch { /* best effort */ }
375
+ }
376
+ else if (action === 'ready') {
377
+ return; // composer reached with no blocking dialog → done
378
+ }
379
+ rpcDialogDismissTimer = setTimeout(tick, 2000);
380
+ };
381
+ rpcDialogDismissTimer = setTimeout(tick, 2500);
382
+ }
118
383
  let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
119
384
  let seatbeltProfilePath = null; // per-session Seatbelt .sb profile to rm at exit (external-wrapper read isolation)
120
385
  let sandboxStopWatcher = null; // stop fn for the sandbox outbox watcher
@@ -189,6 +454,25 @@ function refreshCliPluginGeneration(cfg, adapter) {
189
454
  deferredPluginSkillCatalog = generation.deferredSkillCatalog ?? null;
190
455
  log(`Plugin generation refreshed: ${generation.pluginManifest.pluginIds.join(', ') || '(none)'}`);
191
456
  }
457
+ /** Refresh the process-scoped Skill/MCP snapshot and bring up the trusted MCP
458
+ * host before the process that owns model execution starts. Native paste mode
459
+ * calls this from spawnCli; RPC mode calls it before starting the app-server so
460
+ * the fresh first turn includes the catalog and the gateway socket already
461
+ * exists when Codex reads its MCP config. */
462
+ async function prepareCliPluginGenerationAndGateway(cfg, adapter) {
463
+ refreshCliPluginGeneration(cfg, adapter);
464
+ const manifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
465
+ stopSessionMcpGatewayHost();
466
+ if (adapter.mcpGateway && manifest?.entries.length) {
467
+ sessionMcpGatewayHost = await startSessionMcpGatewayHost({
468
+ sessionId: cfg.sessionId,
469
+ dataDir: config.session.dataDir,
470
+ onError: error => log(`[mcp-gateway] host error: ${error.message}`),
471
+ });
472
+ log(`[mcp-gateway] trusted host listening for ${manifest.entries.length} plugin server(s)`);
473
+ }
474
+ return manifest;
475
+ }
192
476
  /** v2 read isolation — provision a bot's PER-BOT config dir under its BOT_HOME so the
193
477
  * CLI (redirected there via CLAUDE_CONFIG_DIR/CODEX_HOME) starts fully set up despite
194
478
  * the global ~/.claude|~/.codex being Seatbelt-denied. Idempotent (guards on
@@ -987,24 +1271,53 @@ function authorizeManagedSend(claim) {
987
1271
  }
988
1272
  : { ok: false, error: `${decision.errorCode}: ${decision.error}` };
989
1273
  }
990
- function writeCliPidMarker() {
991
- if (!cliPidMarker || !sessionId)
1274
+ function clearRpcEnginePidMarker() {
1275
+ if (!rpcEnginePidMarker)
992
1276
  return;
993
1277
  try {
994
- // 原子写:daemon 侧(killStalePids 等)随时读这个 marker JSON。
995
- const markerPid = Number(basename(cliPidMarker));
996
- const procStart = Number.isInteger(markerPid) && markerPid > 0
997
- ? readProcessStartIdentity(markerPid)
998
- : undefined;
999
- atomicWriteFileSync(cliPidMarker, JSON.stringify({
1000
- sessionId,
1001
- turnId: currentBotmuxTurnId ?? null,
1002
- dispatchAttempt: currentBotmuxDispatchAttempt ?? null,
1003
- ...(procStart ? { procStart } : {}),
1004
- }));
1278
+ unlinkSync(rpcEnginePidMarker);
1279
+ }
1280
+ catch { /* already gone */ }
1281
+ rpcEnginePidMarker = null;
1282
+ }
1283
+ function registerRpcEnginePidMarker(pid) {
1284
+ clearRpcEnginePidMarker();
1285
+ if (!pid || !process.env.SESSION_DATA_DIR)
1286
+ return;
1287
+ try {
1288
+ const markersDir = join(process.env.SESSION_DATA_DIR, '.botmux-cli-pids');
1289
+ mkdirSync(markersDir, { recursive: true });
1290
+ rpcEnginePidMarker = join(markersDir, String(pid));
1291
+ writeCliPidMarker();
1292
+ log(`Codex RPC app-server PID marker written: ${pid}`);
1005
1293
  }
1006
1294
  catch (err) {
1007
- log(`Failed to update CLI PID marker: ${err?.message ?? err}`);
1295
+ rpcEnginePidMarker = null;
1296
+ log(`Failed to write Codex RPC app-server PID marker: ${err?.message ?? err}`);
1297
+ }
1298
+ }
1299
+ function writeCliPidMarker() {
1300
+ if (!sessionId)
1301
+ return;
1302
+ for (const markerPath of [cliPidMarker, rpcEnginePidMarker]) {
1303
+ if (!markerPath)
1304
+ continue;
1305
+ try {
1306
+ // 原子写:daemon 侧(killStalePids 等)随时读这个 marker JSON。
1307
+ const markerPid = Number(basename(markerPath));
1308
+ const procStart = Number.isInteger(markerPid) && markerPid > 0
1309
+ ? readProcessStartIdentity(markerPid)
1310
+ : undefined;
1311
+ atomicWriteFileSync(markerPath, JSON.stringify({
1312
+ sessionId,
1313
+ turnId: currentBotmuxTurnId ?? null,
1314
+ dispatchAttempt: currentBotmuxDispatchAttempt ?? null,
1315
+ ...(procStart ? { procStart } : {}),
1316
+ }));
1317
+ }
1318
+ catch (err) {
1319
+ log(`Failed to update CLI PID marker ${markerPath}: ${err?.message ?? err}`);
1320
+ }
1008
1321
  }
1009
1322
  }
1010
1323
  let lastStructuredBridgeActivityAtMs = 0;
@@ -4635,7 +4948,15 @@ async function flushPending() {
4635
4948
  durableTurnInFlight = true;
4636
4949
  // Track as in-flight until the CLI returns to idle (markPromptReady).
4637
4950
  // If the CLI exits first, onExit stashes these for re-queue on respawn.
4638
- inflightInputs.onWrite(item);
4951
+ // EXCEPT in RPC mode: the app-server (not the pane) owns turn execution, so
4952
+ // once turn/start is sent the turn runs regardless of the pane. Re-queuing
4953
+ // it on a pane restart would RE-SEND an already-accepted turn → double
4954
+ // execution (same class as #443's double-submit). So RPC turns are never
4955
+ // auto-re-queued: a lost/late ack surfaces as a submit-failure the user can
4956
+ // resend manually, and the stable clientUserMessageId lets codex dedupe a
4957
+ // resend (Codex delta P1-1).
4958
+ if (!codexRpcEngine)
4959
+ inflightInputs.onWrite(item);
4639
4960
  const msg = item.content;
4640
4961
  currentBotmuxTurnId = item.turnId;
4641
4962
  currentBotmuxDispatchAttempt = item.dispatchAttempt;
@@ -4687,9 +5008,21 @@ async function flushPending() {
4687
5008
  // worker — exactly the failure mode this change is closing. Contain it.
4688
5009
  let result;
4689
5010
  try {
4690
- result = item.codexAppInput && cliAdapter.writeStructuredInput
4691
- ? await cliAdapter.writeStructuredInput(backend, msg, item.codexAppInput)
4692
- : await cliAdapter.writeInput(backend, msg);
5011
+ if (codexRpcEngine) {
5012
+ // RPC input mode: deliver via JSON-RPC turn/start (its ack IS the
5013
+ // submit confirmation), which the attached `codex --remote` TUI
5014
+ // renders. No tmux paste → the history.jsonl verify/retry/recover
5015
+ // machinery is bypassed. A throw here falls into the catch below and
5016
+ // surfaces as a normal submit-failure notice.
5017
+ await codexRpcEngine.sendTurn(msg, item.turnId);
5018
+ result = { submitted: true };
5019
+ }
5020
+ else if (item.codexAppInput && cliAdapter.writeStructuredInput) {
5021
+ result = await cliAdapter.writeStructuredInput(backend, msg, item.codexAppInput);
5022
+ }
5023
+ else {
5024
+ result = await cliAdapter.writeInput(backend, msg);
5025
+ }
4693
5026
  scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);
4694
5027
  }
4695
5028
  catch (err) {
@@ -5188,7 +5521,7 @@ function scheduleBusyPatternIdleProbe(source) {
5188
5521
  busyPatternIdleProbeTimer = setTimeout(tick, IDLE_PROBE_INTERVAL_MS);
5189
5522
  busyPatternIdleProbeTimer.unref?.();
5190
5523
  }
5191
- async function spawnCli(cfg) {
5524
+ async function spawnCli(cfg, opts = {}) {
5192
5525
  clearSessionRenameInFlight();
5193
5526
  // (startupCommands one-shot is re-armed below, AFTER the reattach-vs-fresh
5194
5527
  // prediction — only a genuinely fresh CLI process replays them; see
@@ -5675,17 +6008,9 @@ async function spawnCli(cfg) {
5675
6008
  // A warm worker reattach keeps the existing Gateway and catalog untouched;
5676
6009
  // every fresh/resumed CLI spawn atomically refreshes both from current Bot config.
5677
6010
  if (!willReattachPersistent) {
5678
- refreshCliPluginGeneration(cfg, cliAdapter);
5679
- mcpRuntimeManifest = readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir);
5680
- stopSessionMcpGatewayHost();
5681
- if (cliAdapter.mcpGateway && mcpRuntimeManifest?.entries.length) {
5682
- sessionMcpGatewayHost = await startSessionMcpGatewayHost({
5683
- sessionId: cfg.sessionId,
5684
- dataDir: config.session.dataDir,
5685
- onError: error => log(`[mcp-gateway] host error: ${error.message}`),
5686
- });
5687
- log(`[mcp-gateway] trusted host listening for ${mcpRuntimeManifest.entries.length} plugin server(s)`);
5688
- }
6011
+ mcpRuntimeManifest = opts.pluginGenerationPrepared
6012
+ ? readSessionMcpRuntimeManifest(cfg.sessionId, config.session.dataDir)
6013
+ : await prepareCliPluginGenerationAndGateway(cfg, cliAdapter);
5689
6014
  }
5690
6015
  // Re-arm the startup-commands one-shot ONLY for a genuinely fresh CLI process.
5691
6016
  // A reattach to a LIVE persistent pane (daemon-restart recovery) is the SAME
@@ -5902,6 +6227,11 @@ async function spawnCli(cfg) {
5902
6227
  disableCliBypass: cfg.disableCliBypass === true,
5903
6228
  skillPluginDir: cfg.skillPluginDir,
5904
6229
  readIsolation: willReadIsolate,
6230
+ // Set (as a pair) by the init handler when hybrid RPC input is engaged;
6231
+ // makes buildArgs launch `codex --remote <ws> resume <thread>` instead of
6232
+ // the normal paste-mode codex. Undefined for every other bot/CLI.
6233
+ remoteWsUrl,
6234
+ remoteThreadId,
5905
6235
  });
5906
6236
  // Extra args from env (CLI_DISABLE_DEFAULT_ARGS is removed — adapters own their defaults)
5907
6237
  const extra = (process.env.CLI_EXTRA_ARGS ?? '').trim();
@@ -6813,6 +7143,16 @@ async function spawnCli(cfg) {
6813
7143
  emitTurnTerminal(exitedTurnId, 'ambiguous', 'cli_exit', exitedDispatchAttempt);
6814
7144
  }
6815
7145
  durableTurnInFlight = false;
7146
+ // Hybrid RPC mode: the `codex --remote` viewer just died — tear down the
7147
+ // paired app-server so it can't outlive the pane. A fresh spawn re-engages
7148
+ // the engine from scratch (or falls back to paste mode).
7149
+ if (codexRpcEngine) {
7150
+ stopCodexRpcEngine();
7151
+ if (rpcDialogDismissTimer) {
7152
+ clearTimeout(rpcDialogDismissTimer);
7153
+ rpcDialogDismissTimer = null;
7154
+ }
7155
+ }
6816
7156
  const logTail = recentTerminalLogTail();
6817
7157
  // Don't park a diagnostic shell here: most exits are immediately
6818
7158
  // auto-restarted by the daemon, so an inline park would just be torn down
@@ -6902,6 +7242,7 @@ async function spawnCli(cfg) {
6902
7242
  }
6903
7243
  function killCli(opts = {}) {
6904
7244
  stopSessionMcpGatewayHost();
7245
+ stopCodexRpcEngine();
6905
7246
  destroyCrashDiagnosticTerminal('killCli');
6906
7247
  idleDetector?.dispose();
6907
7248
  idleDetector = null;
@@ -7006,6 +7347,10 @@ async function restartCliProcess(reason, opts = {}) {
7006
7347
  const restart = async () => {
7007
7348
  try {
7008
7349
  tmuxRestartTimer = null;
7350
+ // Capture the RPC thread id before destroySession()/killCli() tears down
7351
+ // the engine and clears remoteThreadId. The replacement app-server must
7352
+ // resume this exact thread on its fresh port.
7353
+ const rpcThreadId = remoteThreadId;
7009
7354
  const restartingBackend = backend;
7010
7355
  if (restartingBackend)
7011
7356
  intentionalRestartBackend = restartingBackend;
@@ -7032,7 +7377,20 @@ async function restartCliProcess(reason, opts = {}) {
7032
7377
  startScreenUpdates();
7033
7378
  startScreenAnalyzer();
7034
7379
  try {
7035
- await spawnCli({ ...lastInitConfig, resume: true, prompt: '' });
7380
+ const restartCfg = { ...lastInitConfig, resume: true, prompt: '', cliSessionId: rpcThreadId ?? lastInitConfig.cliSessionId };
7381
+ // Re-engage RPC so the new --remote pane binds to the CURRENT app-server
7382
+ // (a fresh port), not the dead prior one. engageCodexRpc only sets
7383
+ // remote* on success, else spawnCli falls back to paste.
7384
+ let rpcPluginGenerationPrepared = false;
7385
+ if (codexRpcEligible(restartCfg, { sandboxForced: sandboxEnabled() })) {
7386
+ const adapter = createCliAdapterSync(restartCfg.cliId, restartCfg.cliPathOverride);
7387
+ await prepareCliPluginGenerationAndGateway(restartCfg, adapter);
7388
+ rpcPluginGenerationPrepared = true;
7389
+ await engageCodexRpc(restartCfg);
7390
+ }
7391
+ await spawnCli(restartCfg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
7392
+ if (codexRpcEngine)
7393
+ armRpcStartupDialogDismiss();
7036
7394
  }
7037
7395
  catch (err) {
7038
7396
  cliRestartInProgress = false;
@@ -8297,7 +8655,42 @@ process.on('message', async (raw) => {
8297
8655
  port = await startWebServer(config.web.workerHost, msg.webPort);
8298
8656
  log('Workflow worker mode: web terminal enabled; skipping screen updates and screen analyzer');
8299
8657
  }
8300
- await spawnCli(msg);
8658
+ // Hybrid codex RPC input (opt-in): bind the pane to a botmux-owned
8659
+ // app-server thread; input flows via JSON-RPC instead of a drop-prone
8660
+ // paste. The pure orchestrator (codex-rpc-lifecycle) decides fresh vs
8661
+ // resume vs stale-pane-replace vs fail-closed; the worker wires the real
8662
+ // effects and acts on the decision. On a kill-verify failure it ABORTS
8663
+ // init (throws) rather than let spawnCli reattach a dead `--remote` pane
8664
+ // to the fresh-port engine (Codex delta P0-2). engageCodexRpc readies the
8665
+ // engine + thread BEFORE any pane is touched (boundary #1); the pane is
8666
+ // then launched/respawned as `codex --remote resume` (codex.ts buildArgs)
8667
+ // against the CURRENT app-server (a fresh port each incarnation).
8668
+ const rpcBackendType = msg.backendType ?? config.daemon.backendType;
8669
+ let rpcPluginGenerationPrepared = false;
8670
+ const rpcDecision = await orchestrateCodexRpcInit(msg, {
8671
+ paneInfo: (sid) => persistentPaneInfo(rpcBackendType, sid),
8672
+ paneIsRemote: (name) => paneRunsRemoteTui(name),
8673
+ prepare: async () => {
8674
+ const adapter = createCliAdapterSync(msg.cliId, msg.cliPathOverride);
8675
+ await prepareCliPluginGenerationAndGateway(msg, adapter);
8676
+ rpcPluginGenerationPrepared = true;
8677
+ },
8678
+ engage: () => engageCodexRpc(msg),
8679
+ killVerify: (name) => killPersistentSessionVerified(rpcBackendType, name),
8680
+ teardownEngine: () => stopCodexRpcEngine(),
8681
+ log: (m) => log(m),
8682
+ notify: (m) => send({ type: 'user_notify', message: m, turnId: msg.turnId }),
8683
+ }, { sandboxForced: sandboxEnabled() });
8684
+ if (rpcDecision.engaged)
8685
+ log(`Codex RPC resume: pane bound to ${remoteWsUrl}`);
8686
+ if (rpcDecision.abortSpawn) {
8687
+ // Stale --remote pane couldn't be replaced — refuse to attach it. Abort
8688
+ // init so the daemon retries a clean incarnation instead of freezing.
8689
+ throw new Error('codex RPC resume: could not replace stale --remote pane; aborting init');
8690
+ }
8691
+ await spawnCli(msg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
8692
+ if (codexRpcEngine)
8693
+ armRpcStartupDialogDismiss(); // boundary #4: keep the --remote pane from freezing on a startup dialog
8301
8694
  // Queue the initial prompt — flushed when CLI shows idle.
8302
8695
  // Adapters with passesInitialPromptViaArgs (e.g. Gemini -i) bake the
8303
8696
  // prompt into CLI args, so we normally skip queuing to avoid double-send.
@@ -8328,7 +8721,20 @@ process.on('message', async (raw) => {
8328
8721
  // transcript file.
8329
8722
  codexBridgeMarkPendingTurn(msg.prompt, msg.turnId, msg.dispatchAttempt);
8330
8723
  }
8331
- if (msg.prompt && (!cliAdapter?.passesInitialPromptViaArgs || deferInitialPrompt)) {
8724
+ // Queue the initial prompt for flushPending (pure decision, unit-tested):
8725
+ // - paste (no engine): normal queue.
8726
+ // - RPC FRESH accepted/ambiguous: engine set + queuePrompt=false → NEVER
8727
+ // queue (the turn was pre-sent or is ambiguous; re-queuing = double
8728
+ // execution — exactly-once, Codex P1-1). Ambiguous never reaches here.
8729
+ // - RPC RESUME: queuePrompt=true → queue for post-ready flush (bridge
8730
+ // marked at flush time, P0-1).
8731
+ if (shouldQueueInitialPrompt({
8732
+ hasPrompt: !!msg.prompt,
8733
+ rpcEngineActive: !!codexRpcEngine,
8734
+ queuePrompt: rpcDecision.queuePrompt,
8735
+ passesInitialPromptViaArgs: cliAdapter?.passesInitialPromptViaArgs === true,
8736
+ deferInitialPrompt,
8737
+ })) {
8332
8738
  pendingMessages.push({
8333
8739
  content: msg.prompt,
8334
8740
  turnId: msg.turnId,
@@ -8876,7 +9282,7 @@ function teardownSandboxBestEffort() {
8876
9282
  // and process.exit(1), killing a live session over a dropped log write. Install
8877
9283
  // the guard before any further stdout writes (log() writes to process.stdout).
8878
9284
  installStdioEpipeGuard();
8879
- process.on('exit', () => { teardownSandboxBestEffort(); });
9285
+ process.on('exit', () => { teardownSandboxBestEffort(); stopCodexRpcEngine(); });
8880
9286
  process.on('uncaughtException', (err) => {
8881
9287
  // A broken pipe on stdout/stderr (or any socket) must not tear down a live
8882
9288
  // session — the stdio guard handles those it can; this is the backstop.