botmux 2.87.0 → 2.88.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 (160) hide show
  1. package/README.en.md +1 -0
  2. package/README.md +1 -0
  3. package/dist/adapters/backend/liveness-gate.d.ts +38 -0
  4. package/dist/adapters/backend/liveness-gate.d.ts.map +1 -0
  5. package/dist/adapters/backend/liveness-gate.js +53 -0
  6. package/dist/adapters/backend/liveness-gate.js.map +1 -0
  7. package/dist/adapters/backend/tmux-backend.d.ts +21 -0
  8. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  9. package/dist/adapters/backend/tmux-backend.js +102 -27
  10. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  11. package/dist/adapters/backend/tmux-pipe-backend.d.ts +20 -0
  12. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  13. package/dist/adapters/backend/tmux-pipe-backend.js +85 -19
  14. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  15. package/dist/adapters/backend/zellij-observe-backend.d.ts +13 -0
  16. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
  17. package/dist/adapters/backend/zellij-observe-backend.js +51 -12
  18. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
  19. package/dist/adapters/cli/traex.d.ts.map +1 -1
  20. package/dist/adapters/cli/traex.js +4 -0
  21. package/dist/adapters/cli/traex.js.map +1 -1
  22. package/dist/adapters/cli/types.d.ts +6 -0
  23. package/dist/adapters/cli/types.d.ts.map +1 -1
  24. package/dist/bot-registry.d.ts +13 -0
  25. package/dist/bot-registry.d.ts.map +1 -1
  26. package/dist/bot-registry.js +3 -0
  27. package/dist/bot-registry.js.map +1 -1
  28. package/dist/cli.js +6 -0
  29. package/dist/cli.js.map +1 -1
  30. package/dist/core/command-handler.js +1 -1
  31. package/dist/core/command-handler.js.map +1 -1
  32. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  33. package/dist/core/dashboard-ipc-server.js +165 -5
  34. package/dist/core/dashboard-ipc-server.js.map +1 -1
  35. package/dist/core/dashboard-rows.d.ts +3 -0
  36. package/dist/core/dashboard-rows.d.ts.map +1 -1
  37. package/dist/core/dashboard-rows.js +4 -1
  38. package/dist/core/dashboard-rows.js.map +1 -1
  39. package/dist/core/session-activity.d.ts +1 -0
  40. package/dist/core/session-activity.d.ts.map +1 -1
  41. package/dist/core/session-activity.js +7 -4
  42. package/dist/core/session-activity.js.map +1 -1
  43. package/dist/core/session-create.d.ts +62 -0
  44. package/dist/core/session-create.d.ts.map +1 -0
  45. package/dist/core/session-create.js +131 -0
  46. package/dist/core/session-create.js.map +1 -0
  47. package/dist/core/session-manager.d.ts +40 -0
  48. package/dist/core/session-manager.d.ts.map +1 -1
  49. package/dist/core/session-manager.js +236 -2
  50. package/dist/core/session-manager.js.map +1 -1
  51. package/dist/core/worker-pool.d.ts.map +1 -1
  52. package/dist/core/worker-pool.js +78 -4
  53. package/dist/core/worker-pool.js.map +1 -1
  54. package/dist/daemon.d.ts.map +1 -1
  55. package/dist/daemon.js +11 -5
  56. package/dist/daemon.js.map +1 -1
  57. package/dist/dashboard/bot-payload.d.ts +1 -0
  58. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  59. package/dist/dashboard/bot-payload.js +1 -0
  60. package/dist/dashboard/bot-payload.js.map +1 -1
  61. package/dist/dashboard/session-cleanup.d.ts +30 -0
  62. package/dist/dashboard/session-cleanup.d.ts.map +1 -0
  63. package/dist/dashboard/session-cleanup.js +47 -0
  64. package/dist/dashboard/session-cleanup.js.map +1 -0
  65. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  66. package/dist/dashboard/web/bot-defaults.js +20 -5
  67. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  68. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  69. package/dist/dashboard/web/i18n.js +184 -2
  70. package/dist/dashboard/web/i18n.js.map +1 -1
  71. package/dist/dashboard/web/insights.d.ts.map +1 -1
  72. package/dist/dashboard/web/insights.js +355 -30
  73. package/dist/dashboard/web/insights.js.map +1 -1
  74. package/dist/dashboard/web/sessions.d.ts +2 -0
  75. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  76. package/dist/dashboard/web/sessions.js +457 -30
  77. package/dist/dashboard/web/sessions.js.map +1 -1
  78. package/dist/dashboard-web/app.js +767 -640
  79. package/dist/dashboard-web/style.css +257 -7
  80. package/dist/dashboard.js +213 -2
  81. package/dist/dashboard.js.map +1 -1
  82. package/dist/i18n/en.d.ts.map +1 -1
  83. package/dist/i18n/en.js +21 -1
  84. package/dist/i18n/en.js.map +1 -1
  85. package/dist/i18n/zh.d.ts.map +1 -1
  86. package/dist/i18n/zh.js +21 -1
  87. package/dist/i18n/zh.js.map +1 -1
  88. package/dist/im/lark/card-builder.d.ts +4 -5
  89. package/dist/im/lark/card-builder.d.ts.map +1 -1
  90. package/dist/im/lark/card-builder.js +113 -11
  91. package/dist/im/lark/card-builder.js.map +1 -1
  92. package/dist/im/lark/card-handler.d.ts +3 -2
  93. package/dist/im/lark/card-handler.d.ts.map +1 -1
  94. package/dist/im/lark/card-handler.js +167 -11
  95. package/dist/im/lark/card-handler.js.map +1 -1
  96. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  97. package/dist/im/lark/event-dispatcher.js +19 -5
  98. package/dist/im/lark/event-dispatcher.js.map +1 -1
  99. package/dist/im/lark/grant-pending.d.ts.map +1 -1
  100. package/dist/im/lark/grant-pending.js +9 -2
  101. package/dist/im/lark/grant-pending.js.map +1 -1
  102. package/dist/index-daemon.js +5 -0
  103. package/dist/index-daemon.js.map +1 -1
  104. package/dist/platform/bind.d.ts +2 -0
  105. package/dist/platform/bind.d.ts.map +1 -0
  106. package/dist/platform/bind.js +91 -0
  107. package/dist/platform/bind.js.map +1 -0
  108. package/dist/platform/binding.d.ts +22 -0
  109. package/dist/platform/binding.d.ts.map +1 -0
  110. package/dist/platform/binding.js +33 -0
  111. package/dist/platform/binding.js.map +1 -0
  112. package/dist/platform/tunnel-client.d.ts +15 -0
  113. package/dist/platform/tunnel-client.d.ts.map +1 -0
  114. package/dist/platform/tunnel-client.js +193 -0
  115. package/dist/platform/tunnel-client.js.map +1 -0
  116. package/dist/services/bot-config-store.d.ts.map +1 -1
  117. package/dist/services/bot-config-store.js +1 -0
  118. package/dist/services/bot-config-store.js.map +1 -1
  119. package/dist/services/git-worktree.d.ts +10 -0
  120. package/dist/services/git-worktree.d.ts.map +1 -1
  121. package/dist/services/git-worktree.js +71 -26
  122. package/dist/services/git-worktree.js.map +1 -1
  123. package/dist/services/grant-prefs-store.d.ts +3 -0
  124. package/dist/services/grant-prefs-store.d.ts.map +1 -1
  125. package/dist/services/grant-prefs-store.js +17 -2
  126. package/dist/services/grant-prefs-store.js.map +1 -1
  127. package/dist/services/insight/claude-span-reader.d.ts.map +1 -1
  128. package/dist/services/insight/claude-span-reader.js +18 -2
  129. package/dist/services/insight/claude-span-reader.js.map +1 -1
  130. package/dist/services/insight/codex-span-reader.d.ts.map +1 -1
  131. package/dist/services/insight/codex-span-reader.js +17 -1
  132. package/dist/services/insight/codex-span-reader.js.map +1 -1
  133. package/dist/services/insight/report.d.ts.map +1 -1
  134. package/dist/services/insight/report.js +48 -6
  135. package/dist/services/insight/report.js.map +1 -1
  136. package/dist/services/insight/types.d.ts +9 -0
  137. package/dist/services/insight/types.d.ts.map +1 -1
  138. package/dist/services/insight/types.js.map +1 -1
  139. package/dist/services/worktree-slug-ai.d.ts.map +1 -1
  140. package/dist/services/worktree-slug-ai.js +3 -1
  141. package/dist/services/worktree-slug-ai.js.map +1 -1
  142. package/dist/types.d.ts +12 -0
  143. package/dist/types.d.ts.map +1 -1
  144. package/dist/utils/crash-log.d.ts +22 -0
  145. package/dist/utils/crash-log.d.ts.map +1 -0
  146. package/dist/utils/crash-log.js +37 -0
  147. package/dist/utils/crash-log.js.map +1 -0
  148. package/dist/utils/input-gate.d.ts +10 -0
  149. package/dist/utils/input-gate.d.ts.map +1 -1
  150. package/dist/utils/input-gate.js +7 -0
  151. package/dist/utils/input-gate.js.map +1 -1
  152. package/dist/utils/stdio-epipe-guard.d.ts +39 -0
  153. package/dist/utils/stdio-epipe-guard.d.ts.map +1 -0
  154. package/dist/utils/stdio-epipe-guard.js +67 -0
  155. package/dist/utils/stdio-epipe-guard.js.map +1 -0
  156. package/dist/worker.js +210 -26
  157. package/dist/worker.js.map +1 -1
  158. package/dist/workflows/daemon-spawn.d.ts +2 -0
  159. package/dist/workflows/daemon-spawn.d.ts.map +1 -1
  160. package/package.json +1 -1
package/dist/worker.js CHANGED
@@ -13,13 +13,15 @@
13
13
  * 7. On 'restart', kills CLI and re-spawns with --resume
14
14
  */
15
15
  import { randomBytes } from 'node:crypto';
16
- import { mkdirSync, unlinkSync, existsSync, statSync, readdirSync, readlinkSync, readFileSync, watch as fsWatch, createWriteStream } from 'node:fs';
16
+ import { mkdirSync, writeFileSync, unlinkSync, existsSync, statSync, readdirSync, readlinkSync, readFileSync, watch as fsWatch, createWriteStream } from 'node:fs';
17
17
  import { atomicWriteFileSync } from './utils/atomic-write.js';
18
18
  import { isAbsolute, join } from 'node:path';
19
19
  import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
20
20
  import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
21
21
  import { shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
22
- import { shouldWriteNow } from './utils/input-gate.js';
22
+ import { shouldReleaseFirstPromptTimeout, shouldWriteNow } from './utils/input-gate.js';
23
+ import { stripAnsiForLog, tailChars } from './utils/crash-log.js';
24
+ import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
23
25
  import { mergeQueuedCliInput } from './utils/pending-input-queue.js';
24
26
  import { ReadyGate, shouldArmReadyGate } from './utils/ready-gate.js';
25
27
  import { shouldRunStartupCommandsOnSpawn, shouldDeferInitialPromptForStartup } from './core/startup-commands.js';
@@ -105,6 +107,17 @@ let lastSpawnEffectiveResume = false;
105
107
  let lastSpawnEffectiveCliSessionId;
106
108
  let idleDetector = null;
107
109
  let isTmuxMode = false;
110
+ /** True once a crash diagnostic tmux shell (bmx-diag-<sid>) is live. */
111
+ let crashDiagnosticTmuxParked = false;
112
+ /** True once the daemon told us to stop & park a crash diagnostic (crash loop):
113
+ * the next user message retries the CLI. Distinct from the flag above because
114
+ * retry must still fire even if the tmux park itself failed (no hang). */
115
+ let crashDiagnosticStopped = false;
116
+ /** Exit code/signal of the just-exited CLI, stashed so a deferred park
117
+ * (park_diagnostic IPC) can stamp the captured log even though the park no
118
+ * longer happens inline in onExit. */
119
+ let lastCliExitCode = null;
120
+ let lastCliExitSignal = null;
108
121
  /** Adopt-bridge mode using TmuxPipeBackend: not a tmux attach client, all
109
122
  * web-terminal updates flow through the shared scrollback fan-out instead
110
123
  * of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
@@ -164,6 +177,14 @@ let readySignalTimer = null;
164
177
  * back. The real signal lands within ~ms of the input box rendering, so this is
165
178
  * pure insurance against a missing/failed hook — generous but bounded. */
166
179
  const READY_SIGNAL_TIMEOUT_MS = 45_000;
180
+ /** Soft fallback for CLIs that never emit an idle/ready signal during startup.
181
+ * Legacy adapters release queued first input here. Adapters that opt into
182
+ * deferFirstPromptTimeoutUntilReady wait for the real readyPattern until the
183
+ * hard cap below. */
184
+ const FIRST_PROMPT_TIMEOUT_MS = 15_000;
185
+ /** Hard cap for startup screens that outlive the soft fallback. Prevents a
186
+ * changed/missing readyPattern from trapping the first queued input forever. */
187
+ const FIRST_PROMPT_HARD_TIMEOUT_MS = 90_000;
167
188
  /** Epoch ms of the most recent PTY output — used to settle for quiescence
168
189
  * before the first flush (see settleThenFlush). */
169
190
  let lastPtyOutputAtMs = 0;
@@ -2276,6 +2297,8 @@ const MAX_SCROLLBACK = 1_000_000; // chars (~1MB)
2276
2297
  let scrollback = '';
2277
2298
  const WORKFLOW_TRANSCRIPT_MAX = 2_000_000; // chars (~2MB)
2278
2299
  const WORKFLOW_OUTPUT_END_MARKER = '</WORKFLOW_OUTPUT>';
2300
+ const CRASH_DIAGNOSTIC_RAW_MAX = 200_000; // enough scrollback for the web terminal without huge temp files
2301
+ const CRASH_LOG_TAIL_MAX = 2_500; // bounded Feishu text payload
2279
2302
  let workflowTranscript = '';
2280
2303
  let workflowFinalOutputSent = false;
2281
2304
  /** Tracks whether the CLI is currently in the alt screen buffer. Updated by
@@ -2287,6 +2310,97 @@ let workflowFinalOutputSent = false;
2287
2310
  let altBufferActive = false;
2288
2311
  const ALT_ENTER_RE = /\x1b\[\?(1049|1047|47)h/g;
2289
2312
  const ALT_EXIT_RE = /\x1b\[\?(1049|1047|47)l/g;
2313
+ function recentTerminalLogTail() {
2314
+ const plain = stripAnsiForLog(tailChars(scrollback, CRASH_DIAGNOSTIC_RAW_MAX));
2315
+ if (!plain)
2316
+ return undefined;
2317
+ return tailChars(plain, CRASH_LOG_TAIL_MAX);
2318
+ }
2319
+ function crashDiagnosticPath() {
2320
+ const dataDir = process.env.SESSION_DATA_DIR;
2321
+ if (!dataDir || !sessionId)
2322
+ return undefined;
2323
+ return join(dataDir, 'crash-diagnostics', `${sessionId}.ansi`);
2324
+ }
2325
+ function destroyCrashDiagnosticTerminal(reason) {
2326
+ // Leaving the stopped-awaiting-retry state regardless of whether a tmux shell
2327
+ // was actually parked (park may have failed); the next retry/close/suspend
2328
+ // funnels through here.
2329
+ crashDiagnosticStopped = false;
2330
+ if (!crashDiagnosticTmuxParked || !sessionId)
2331
+ return;
2332
+ try {
2333
+ TmuxBackend.killSession(TmuxBackend.diagnosticSessionName(sessionId));
2334
+ log(`Crash diagnostic tmux session destroyed (${reason})`);
2335
+ }
2336
+ catch (err) {
2337
+ log(`Crash diagnostic tmux cleanup failed (${reason}): ${err?.message ?? err}`);
2338
+ }
2339
+ // Best-effort: drop the captured .ansi file too so a long-lived daemon does
2340
+ // not accumulate one ~200 KB file per crashed session forever.
2341
+ const path = crashDiagnosticPath();
2342
+ if (path) {
2343
+ try {
2344
+ unlinkSync(path);
2345
+ }
2346
+ catch { /* already gone — benign */ }
2347
+ }
2348
+ crashDiagnosticTmuxParked = false;
2349
+ }
2350
+ function parkCrashDiagnosticTerminal(code, signal) {
2351
+ if (lastInitConfig?.adoptMode || effectiveBackendType !== 'tmux' || !sessionId)
2352
+ return false;
2353
+ const path = crashDiagnosticPath();
2354
+ if (!path)
2355
+ return false;
2356
+ try {
2357
+ mkdirSync(dirname(path), { recursive: true });
2358
+ const rawTail = tailChars(scrollback, CRASH_DIAGNOSTIC_RAW_MAX);
2359
+ const header = `[botmux] ${cliName()} exited (code: ${code ?? 'null'}, signal: ${signal ?? 'null'}).\n` +
2360
+ `[botmux] Captured at ${new Date().toISOString()}.\n\n`;
2361
+ writeFileSync(path, header + rawTail);
2362
+ }
2363
+ catch (err) {
2364
+ log(`Crash diagnostic log write failed: ${err?.message ?? err}`);
2365
+ return false;
2366
+ }
2367
+ // Park under a DISTINCT name (`bmx-diag-<sid>`), never the live CLI's
2368
+ // `bmx-<sid>` backing-session name. The whole persistent-backend machinery
2369
+ // (restore probe, hasSession reattach, idle-sweep cold-resume, `botmux
2370
+ // resume`) keys off `bmx-<sid>` to mean "the live CLI". Reusing that name for
2371
+ // a bare diagnostic shell makes restore/cold-resume reattach the shell as if
2372
+ // it were the CLI and type the user's next message into raw bash. With a
2373
+ // distinct name, `bmx-<sid>` is correctly absent after the crash, so every
2374
+ // one of those paths sees "no live CLI" and does the right thing; the web
2375
+ // terminal is pointed at the diagnostic name explicitly (see the WS attach).
2376
+ const ok = TmuxBackend.parkDiagnosticSession(TmuxBackend.diagnosticSessionName(sessionId), {
2377
+ cwd: lastInitConfig?.workingDir ?? process.cwd(),
2378
+ cols: renderCols || PTY_COLS,
2379
+ rows: renderRows || PTY_ROWS,
2380
+ contentPath: path,
2381
+ });
2382
+ if (!ok) {
2383
+ // tmux spawn failed after the .ansi was written — drop the orphan file.
2384
+ try {
2385
+ unlinkSync(path);
2386
+ }
2387
+ catch { /* benign */ }
2388
+ return false;
2389
+ }
2390
+ crashDiagnosticTmuxParked = true;
2391
+ isTmuxMode = true;
2392
+ isPipeMode = false;
2393
+ isZellijMode = false;
2394
+ // The CLI is gone; stop the screen-update + analyzer loops so a stale
2395
+ // `status='working'` tick can't un-freeze the daemon's frozen crash card.
2396
+ // The web terminal is served by per-client tmux-attach PTYs, not these loops,
2397
+ // so the diagnostic shell stays visible. flushPending's retry path restarts
2398
+ // both when the next message respawns the CLI.
2399
+ stopScreenUpdates();
2400
+ stopScreenAnalyzer();
2401
+ log(`Crash diagnostic tmux session parked at ${TmuxBackend.diagnosticSessionName(sessionId)}`);
2402
+ return true;
2403
+ }
2290
2404
  // ─── Screen Analyzer (AI-based TUI prompt detection) ────────────────────────
2291
2405
  let screenAnalyzer = null;
2292
2406
  /** When true, user messages are queued because a TUI prompt is active */
@@ -4505,6 +4619,16 @@ function spawnCli(cfg) {
4505
4619
  backend.onData(onPtyData);
4506
4620
  backend.onExit((code, signal) => {
4507
4621
  log(`${cliName()} exited (code: ${code}, signal: ${signal})`);
4622
+ const logTail = recentTerminalLogTail();
4623
+ // Don't park a diagnostic shell here: most exits are immediately
4624
+ // auto-restarted by the daemon, so an inline park would just be torn down
4625
+ // again (a wasted tmux session + .ansi write on every restart). Instead
4626
+ // report whether we COULD park; the daemon asks us to (park_diagnostic) only
4627
+ // when it actually gives up restarting (crash loop). Stash the exit reason
4628
+ // for that deferred park.
4629
+ lastCliExitCode = code;
4630
+ lastCliExitSignal = signal;
4631
+ const canParkDiagnostic = !lastInitConfig?.adoptMode && effectiveBackendType === 'tmux' && !!sessionId;
4508
4632
  // Inputs written but not yet consumed (no idle since the write) die with
4509
4633
  // the CLI — codex crashing mid-submit never records them, and the fresh
4510
4634
  // respawn comes up empty. Stash them so the next spawnCli re-queues and
@@ -4515,38 +4639,56 @@ function spawnCli(cfg) {
4515
4639
  }
4516
4640
  backend = null;
4517
4641
  isPromptReady = false;
4518
- send({ type: 'claude_exit', code, signal });
4642
+ send({ type: 'claude_exit', code, signal, logTail, canParkDiagnostic });
4519
4643
  });
4520
4644
  if (isPipeMode && backend && 'isReattach' in backend && backend.isReattach) {
4521
4645
  log(`Re-attached to existing ${effectiveBackendType} session via pipe backend: ${persistentSessionName}`);
4522
4646
  seedBackendScreen(`${effectiveBackendType} reattach`, backend);
4523
4647
  scheduleReattachIdleProbe(`${effectiveBackendType} reattach`, backend);
4524
4648
  }
4525
- // Fallback: if the CLI takes too long to show its prompt (e.g. slow
4526
- // plugin init, or a spinner blocks the idle detector), unblock screen
4527
- // updates AND deliver any queued prompts so the first user message
4528
- // isn't stranded until the second message arrives. markNewTurn() sets a
4529
- // clean baseline at the current cursor position so only content written
4530
- // *after* this point appears in the card.
4531
- setTimeout(() => {
4532
- if (awaitingFirstPrompt) {
4533
- awaitingFirstPrompt = false;
4534
- renderer?.markNewTurn();
4535
- log('First prompt timeout — enabling screen updates and flushing queued messages');
4536
- if (backend && cliAdapter?.busyPattern && probeBusyPatternIdle(`${cliName()} first-prompt-timeout`, backend)) {
4537
- return;
4538
- }
4539
- // For type-ahead adapters (Codex/CoCo/TRAE/Claude) the TUI is booted
4540
- // enough to park input even if the idle detector hasn't fired yet.
4541
- // Directly invoking markPromptReady() would claim the CLI is idle
4542
- // while it's still mid-boot, so flushPending() alone is safer — it
4543
- // respects typeAheadAllowed and drains pendingMessages now.
4544
- if (cliAdapter?.supportsTypeAhead)
4545
- flushPending();
4649
+ // Fallback: if the CLI takes too long to show its prompt (e.g. slow plugin
4650
+ // init, or a spinner blocks the idle detector), unblock screen updates AND
4651
+ // deliver any queued prompts so the first user message isn't stranded until
4652
+ // the second message arrives. Some adapters opt into deferring the soft
4653
+ // fallback until readyPattern, but still get a hard cap below.
4654
+ // markNewTurn() sets a clean baseline at the current cursor position so only
4655
+ // content written *after* this point appears in the card.
4656
+ const firstPromptBackend = backend;
4657
+ const releaseFirstPromptTimeout = (elapsedMs, forced) => {
4658
+ if (!awaitingFirstPrompt || backend !== firstPromptBackend)
4659
+ return;
4660
+ if (!shouldReleaseFirstPromptTimeout({
4661
+ deferFirstPromptTimeoutUntilReady: cliAdapter?.deferFirstPromptTimeoutUntilReady === true,
4662
+ hasReadyPattern: !!cliAdapter?.readyPattern,
4663
+ elapsedMs,
4664
+ hardTimeoutMs: FIRST_PROMPT_HARD_TIMEOUT_MS,
4665
+ })) {
4666
+ const hardWaitMs = Math.max(0, FIRST_PROMPT_HARD_TIMEOUT_MS - elapsedMs);
4667
+ log(`First prompt timeout ${cliName()} still waiting for readyPattern before flushing queued messages`);
4668
+ const hardTimer = setTimeout(() => releaseFirstPromptTimeout(FIRST_PROMPT_HARD_TIMEOUT_MS, true), hardWaitMs);
4669
+ hardTimer.unref?.();
4670
+ return;
4671
+ }
4672
+ awaitingFirstPrompt = false;
4673
+ renderer?.markNewTurn();
4674
+ log(forced
4675
+ ? `WARN First prompt hard timeout — ${cliName()} readyPattern did not arrive; forcing queued message flush`
4676
+ : 'First prompt timeout — enabling screen updates and flushing queued messages');
4677
+ if (backend && cliAdapter?.busyPattern && probeBusyPatternIdle(`${cliName()} first-prompt-timeout`, backend)) {
4678
+ return;
4546
4679
  }
4547
- }, 15_000);
4680
+ // For type-ahead adapters (Codex/CoCo/Claude/TraeX) the TUI is usually booted
4681
+ // enough to park input even if the idle detector hasn't fired yet. Directly
4682
+ // invoking markPromptReady() would claim the CLI is idle while it's still
4683
+ // mid-boot, so flushPending() alone is safer — it respects typeAheadAllowed
4684
+ // and drains pendingMessages now.
4685
+ if (cliAdapter?.supportsTypeAhead)
4686
+ flushPending();
4687
+ };
4688
+ setTimeout(() => releaseFirstPromptTimeout(FIRST_PROMPT_TIMEOUT_MS, false), FIRST_PROMPT_TIMEOUT_MS);
4548
4689
  }
4549
4690
  function killCli() {
4691
+ destroyCrashDiagnosticTerminal('killCli');
4550
4692
  idleDetector?.dispose();
4551
4693
  idleDetector = null;
4552
4694
  stopReattachIdleProbe();
@@ -4646,7 +4788,13 @@ function startWebServer(host, preferredPort) {
4646
4788
  // byte-for-byte (empty, separators, etc.) are not retransmitted, so
4647
4789
  // the earlier frame "bleeds through" — visible as a second
4648
4790
  // banner/prompt stacked above the new layout when scrolling up.
4649
- const tmuxTarget = lastInitConfig?.adoptTmuxTarget ?? TmuxBackend.sessionName(sessionId);
4791
+ // While a crash diagnostic shell is parked it lives under bmx-diag-<sid>
4792
+ // (not the live CLI's bmx-<sid>), so attach there to surface the startup
4793
+ // error; otherwise attach the normal backing session.
4794
+ const tmuxTarget = lastInitConfig?.adoptTmuxTarget
4795
+ ?? (crashDiagnosticTmuxParked
4796
+ ? TmuxBackend.diagnosticSessionName(sessionId)
4797
+ : TmuxBackend.sessionName(sessionId));
4650
4798
  let cp = null;
4651
4799
  const pendingInput = [];
4652
4800
  const startAttach = (cols, rows) => {
@@ -5202,6 +5350,16 @@ process.on('message', async (raw) => {
5202
5350
  const content = msg.content;
5203
5351
  currentBotmuxTurnId = msg.turnId;
5204
5352
  writeCliPidMarker();
5353
+ if (!backend && crashDiagnosticStopped && lastInitConfig && !lastInitConfig.adoptMode) {
5354
+ log('Message received after crash-loop stop; retrying CLI start');
5355
+ destroyCrashDiagnosticTerminal('retry after message');
5356
+ stopScreenAnalyzer();
5357
+ stopScreenUpdates();
5358
+ awaitingFirstPrompt = true;
5359
+ startScreenUpdates();
5360
+ startScreenAnalyzer();
5361
+ spawnCli({ ...lastInitConfig, resume: true, prompt: '' });
5362
+ }
5205
5363
  if (lastInitConfig?.adoptMode) {
5206
5364
  // Bridge mode: capture transcript baseline BEFORE writing to the pane,
5207
5365
  // so any assistant uuids appended after this point are attributed to
@@ -5332,6 +5490,16 @@ process.on('message', async (raw) => {
5332
5490
  }
5333
5491
  break;
5334
5492
  }
5493
+ case 'park_diagnostic': {
5494
+ // The daemon gave up auto-restarting (crash loop) and wants the last
5495
+ // terminal output preserved. Park the diagnostic shell now — deferred from
5496
+ // onExit so transient (auto-restarted) exits never pay for it. Mark the
5497
+ // stopped state even if the tmux park fails, so the next message still
5498
+ // retries the CLI (no hang) rather than writing into a dead pane.
5499
+ parkCrashDiagnosticTerminal(lastCliExitCode, lastCliExitSignal);
5500
+ crashDiagnosticStopped = true;
5501
+ break;
5502
+ }
5335
5503
  case 'restart': {
5336
5504
  if (lastInitConfig?.adoptMode) {
5337
5505
  log('Restart ignored in adopt mode');
@@ -5436,6 +5604,11 @@ process.on('message', async (raw) => {
5436
5604
  log('Suspend requested');
5437
5605
  stopScreenshotLoop();
5438
5606
  stopBridgeWatcher();
5607
+ // A parked crash diagnostic shell has backend===null, so the
5608
+ // destroySession/kill below is a no-op and would otherwise leak the
5609
+ // bmx-diag-<sid> session. Tear it down explicitly. (The session then
5610
+ // cold-resumes a FRESH CLI on the next message — bmx-<sid> is absent.)
5611
+ destroyCrashDiagnosticTerminal('suspend');
5439
5612
  // Free the CLI's memory, not just the worker's: destroySession kills the
5440
5613
  // backing tmux/herdr/zellij session AND the CLI process inside it (kill()
5441
5614
  // would only detach the pty viewer and leave the CLI running in the
@@ -5556,8 +5729,17 @@ function teardownSandboxBestEffort() {
5556
5729
  catch { /* */ }
5557
5730
  sandboxCleanup = null;
5558
5731
  }
5732
+ // Under pm2 the worker's stdout/stderr are pipes; a broken pipe (e.g. log
5733
+ // streaming detaches) would otherwise reach the uncaughtException handler below
5734
+ // and process.exit(1), killing a live session over a dropped log write. Install
5735
+ // the guard before any further stdout writes (log() writes to process.stdout).
5736
+ installStdioEpipeGuard();
5559
5737
  process.on('exit', () => { teardownSandboxBestEffort(); });
5560
5738
  process.on('uncaughtException', (err) => {
5739
+ // A broken pipe on stdout/stderr (or any socket) must not tear down a live
5740
+ // session — the stdio guard handles those it can; this is the backstop.
5741
+ if (isIgnorableStreamError(err))
5742
+ return;
5561
5743
  try {
5562
5744
  log(`Uncaught exception — tearing down sandbox before exit: ${err?.stack ?? err}`);
5563
5745
  }
@@ -5570,6 +5752,8 @@ process.on('uncaughtException', (err) => {
5570
5752
  process.exit(1);
5571
5753
  });
5572
5754
  process.on('unhandledRejection', (reason) => {
5755
+ if (isIgnorableStreamError(reason))
5756
+ return;
5573
5757
  try {
5574
5758
  log(`Unhandled rejection — tearing down sandbox before exit: ${reason?.stack ?? reason}`);
5575
5759
  }