botmux 2.86.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 (247) hide show
  1. package/README.en.md +27 -0
  2. package/README.md +27 -0
  3. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  4. package/dist/adapters/backend/herdr-backend.js +12 -1
  5. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  6. package/dist/adapters/backend/liveness-gate.d.ts +38 -0
  7. package/dist/adapters/backend/liveness-gate.d.ts.map +1 -0
  8. package/dist/adapters/backend/liveness-gate.js +53 -0
  9. package/dist/adapters/backend/liveness-gate.js.map +1 -0
  10. package/dist/adapters/backend/pty-backend.d.ts.map +1 -1
  11. package/dist/adapters/backend/pty-backend.js +4 -1
  12. package/dist/adapters/backend/pty-backend.js.map +1 -1
  13. package/dist/adapters/backend/tmux-backend.d.ts +22 -1
  14. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  15. package/dist/adapters/backend/tmux-backend.js +124 -36
  16. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  17. package/dist/adapters/backend/tmux-pipe-backend.d.ts +20 -0
  18. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  19. package/dist/adapters/backend/tmux-pipe-backend.js +86 -20
  20. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  21. package/dist/adapters/backend/types.d.ts +9 -0
  22. package/dist/adapters/backend/types.d.ts.map +1 -1
  23. package/dist/adapters/backend/types.js.map +1 -1
  24. package/dist/adapters/backend/zellij-backend.js +1 -1
  25. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  26. package/dist/adapters/backend/zellij-observe-backend.d.ts +13 -0
  27. package/dist/adapters/backend/zellij-observe-backend.d.ts.map +1 -1
  28. package/dist/adapters/backend/zellij-observe-backend.js +51 -12
  29. package/dist/adapters/backend/zellij-observe-backend.js.map +1 -1
  30. package/dist/adapters/cli/codex.d.ts.map +1 -1
  31. package/dist/adapters/cli/codex.js +8 -5
  32. package/dist/adapters/cli/codex.js.map +1 -1
  33. package/dist/adapters/cli/shared-hints.d.ts +2 -1
  34. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  35. package/dist/adapters/cli/shared-hints.js +25 -3
  36. package/dist/adapters/cli/shared-hints.js.map +1 -1
  37. package/dist/adapters/cli/traex.d.ts.map +1 -1
  38. package/dist/adapters/cli/traex.js +4 -0
  39. package/dist/adapters/cli/traex.js.map +1 -1
  40. package/dist/adapters/cli/types.d.ts +6 -0
  41. package/dist/adapters/cli/types.d.ts.map +1 -1
  42. package/dist/bot-registry.d.ts +23 -0
  43. package/dist/bot-registry.d.ts.map +1 -1
  44. package/dist/bot-registry.js +9 -0
  45. package/dist/bot-registry.js.map +1 -1
  46. package/dist/cli.d.ts.map +1 -1
  47. package/dist/cli.js +368 -16
  48. package/dist/cli.js.map +1 -1
  49. package/dist/core/command-handler.d.ts.map +1 -1
  50. package/dist/core/command-handler.js +7 -5
  51. package/dist/core/command-handler.js.map +1 -1
  52. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  53. package/dist/core/dashboard-ipc-server.js +210 -5
  54. package/dist/core/dashboard-ipc-server.js.map +1 -1
  55. package/dist/core/dashboard-rows.d.ts +3 -0
  56. package/dist/core/dashboard-rows.d.ts.map +1 -1
  57. package/dist/core/dashboard-rows.js +4 -1
  58. package/dist/core/dashboard-rows.js.map +1 -1
  59. package/dist/core/maintenance.d.ts +18 -0
  60. package/dist/core/maintenance.d.ts.map +1 -1
  61. package/dist/core/maintenance.js +58 -30
  62. package/dist/core/maintenance.js.map +1 -1
  63. package/dist/core/per-bot-env.d.ts +38 -0
  64. package/dist/core/per-bot-env.d.ts.map +1 -0
  65. package/dist/core/per-bot-env.js +79 -0
  66. package/dist/core/per-bot-env.js.map +1 -0
  67. package/dist/core/session-activity.d.ts +1 -0
  68. package/dist/core/session-activity.d.ts.map +1 -1
  69. package/dist/core/session-activity.js +7 -4
  70. package/dist/core/session-activity.js.map +1 -1
  71. package/dist/core/session-create.d.ts +62 -0
  72. package/dist/core/session-create.d.ts.map +1 -0
  73. package/dist/core/session-create.js +131 -0
  74. package/dist/core/session-create.js.map +1 -0
  75. package/dist/core/session-manager.d.ts +43 -0
  76. package/dist/core/session-manager.d.ts.map +1 -1
  77. package/dist/core/session-manager.js +306 -8
  78. package/dist/core/session-manager.js.map +1 -1
  79. package/dist/core/skills/discovery.d.ts +7 -0
  80. package/dist/core/skills/discovery.d.ts.map +1 -1
  81. package/dist/core/skills/discovery.js +51 -0
  82. package/dist/core/skills/discovery.js.map +1 -1
  83. package/dist/core/trigger-session.d.ts.map +1 -1
  84. package/dist/core/trigger-session.js +5 -2
  85. package/dist/core/trigger-session.js.map +1 -1
  86. package/dist/core/update-check.d.ts +67 -0
  87. package/dist/core/update-check.d.ts.map +1 -0
  88. package/dist/core/update-check.js +159 -0
  89. package/dist/core/update-check.js.map +1 -0
  90. package/dist/core/worker-pool.d.ts.map +1 -1
  91. package/dist/core/worker-pool.js +82 -4
  92. package/dist/core/worker-pool.js.map +1 -1
  93. package/dist/daemon.d.ts.map +1 -1
  94. package/dist/daemon.js +40 -11
  95. package/dist/daemon.js.map +1 -1
  96. package/dist/dashboard/bot-payload.d.ts +2 -0
  97. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  98. package/dist/dashboard/bot-payload.js +2 -0
  99. package/dist/dashboard/bot-payload.js.map +1 -1
  100. package/dist/dashboard/session-cleanup.d.ts +30 -0
  101. package/dist/dashboard/session-cleanup.d.ts.map +1 -0
  102. package/dist/dashboard/session-cleanup.js +47 -0
  103. package/dist/dashboard/session-cleanup.js.map +1 -0
  104. package/dist/dashboard/skill-install-request.d.ts +10 -0
  105. package/dist/dashboard/skill-install-request.d.ts.map +1 -1
  106. package/dist/dashboard/skill-install-request.js +17 -0
  107. package/dist/dashboard/skill-install-request.js.map +1 -1
  108. package/dist/dashboard/web/app.d.ts.map +1 -1
  109. package/dist/dashboard/web/app.js +29 -1
  110. package/dist/dashboard/web/app.js.map +1 -1
  111. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  112. package/dist/dashboard/web/bot-defaults.js +82 -6
  113. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  114. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  115. package/dist/dashboard/web/i18n.js +312 -2
  116. package/dist/dashboard/web/i18n.js.map +1 -1
  117. package/dist/dashboard/web/insights.d.ts.map +1 -1
  118. package/dist/dashboard/web/insights.js +355 -30
  119. package/dist/dashboard/web/insights.js.map +1 -1
  120. package/dist/dashboard/web/sessions.d.ts +2 -0
  121. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  122. package/dist/dashboard/web/sessions.js +457 -30
  123. package/dist/dashboard/web/sessions.js.map +1 -1
  124. package/dist/dashboard/web/settings.d.ts.map +1 -1
  125. package/dist/dashboard/web/settings.js +289 -0
  126. package/dist/dashboard/web/settings.js.map +1 -1
  127. package/dist/dashboard/web/skills.d.ts.map +1 -1
  128. package/dist/dashboard/web/skills.js +183 -10
  129. package/dist/dashboard/web/skills.js.map +1 -1
  130. package/dist/dashboard/web/whiteboards.d.ts +2 -0
  131. package/dist/dashboard/web/whiteboards.d.ts.map +1 -0
  132. package/dist/dashboard/web/whiteboards.js +267 -0
  133. package/dist/dashboard/web/whiteboards.js.map +1 -0
  134. package/dist/dashboard-web/app.js +879 -591
  135. package/dist/dashboard-web/index.html +1 -0
  136. package/dist/dashboard-web/style.css +528 -8
  137. package/dist/dashboard.js +472 -5
  138. package/dist/dashboard.js.map +1 -1
  139. package/dist/global-config.d.ts +6 -0
  140. package/dist/global-config.d.ts.map +1 -1
  141. package/dist/global-config.js +12 -0
  142. package/dist/global-config.js.map +1 -1
  143. package/dist/i18n/en.d.ts.map +1 -1
  144. package/dist/i18n/en.js +21 -1
  145. package/dist/i18n/en.js.map +1 -1
  146. package/dist/i18n/zh.d.ts.map +1 -1
  147. package/dist/i18n/zh.js +21 -1
  148. package/dist/i18n/zh.js.map +1 -1
  149. package/dist/im/lark/card-builder.d.ts +4 -5
  150. package/dist/im/lark/card-builder.d.ts.map +1 -1
  151. package/dist/im/lark/card-builder.js +113 -11
  152. package/dist/im/lark/card-builder.js.map +1 -1
  153. package/dist/im/lark/card-handler.d.ts +3 -2
  154. package/dist/im/lark/card-handler.d.ts.map +1 -1
  155. package/dist/im/lark/card-handler.js +174 -14
  156. package/dist/im/lark/card-handler.js.map +1 -1
  157. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  158. package/dist/im/lark/event-dispatcher.js +19 -5
  159. package/dist/im/lark/event-dispatcher.js.map +1 -1
  160. package/dist/im/lark/grant-pending.d.ts.map +1 -1
  161. package/dist/im/lark/grant-pending.js +9 -2
  162. package/dist/im/lark/grant-pending.js.map +1 -1
  163. package/dist/index-daemon.js +5 -0
  164. package/dist/index-daemon.js.map +1 -1
  165. package/dist/platform/bind.d.ts +2 -0
  166. package/dist/platform/bind.d.ts.map +1 -0
  167. package/dist/platform/bind.js +91 -0
  168. package/dist/platform/bind.js.map +1 -0
  169. package/dist/platform/binding.d.ts +22 -0
  170. package/dist/platform/binding.d.ts.map +1 -0
  171. package/dist/platform/binding.js +33 -0
  172. package/dist/platform/binding.js.map +1 -0
  173. package/dist/platform/tunnel-client.d.ts +15 -0
  174. package/dist/platform/tunnel-client.d.ts.map +1 -0
  175. package/dist/platform/tunnel-client.js +193 -0
  176. package/dist/platform/tunnel-client.js.map +1 -0
  177. package/dist/services/bot-config-store.d.ts.map +1 -1
  178. package/dist/services/bot-config-store.js +20 -0
  179. package/dist/services/bot-config-store.js.map +1 -1
  180. package/dist/services/git-worktree.d.ts +10 -0
  181. package/dist/services/git-worktree.d.ts.map +1 -1
  182. package/dist/services/git-worktree.js +71 -26
  183. package/dist/services/git-worktree.js.map +1 -1
  184. package/dist/services/grant-prefs-store.d.ts +3 -0
  185. package/dist/services/grant-prefs-store.d.ts.map +1 -1
  186. package/dist/services/grant-prefs-store.js +17 -2
  187. package/dist/services/grant-prefs-store.js.map +1 -1
  188. package/dist/services/insight/claude-span-reader.d.ts.map +1 -1
  189. package/dist/services/insight/claude-span-reader.js +18 -2
  190. package/dist/services/insight/claude-span-reader.js.map +1 -1
  191. package/dist/services/insight/codex-span-reader.d.ts.map +1 -1
  192. package/dist/services/insight/codex-span-reader.js +17 -1
  193. package/dist/services/insight/codex-span-reader.js.map +1 -1
  194. package/dist/services/insight/report.d.ts.map +1 -1
  195. package/dist/services/insight/report.js +48 -6
  196. package/dist/services/insight/report.js.map +1 -1
  197. package/dist/services/insight/types.d.ts +9 -0
  198. package/dist/services/insight/types.d.ts.map +1 -1
  199. package/dist/services/insight/types.js.map +1 -1
  200. package/dist/services/resumable-session-discovery.d.ts.map +1 -1
  201. package/dist/services/resumable-session-discovery.js +21 -4
  202. package/dist/services/resumable-session-discovery.js.map +1 -1
  203. package/dist/services/skill-registry-store.d.ts +1 -0
  204. package/dist/services/skill-registry-store.d.ts.map +1 -1
  205. package/dist/services/skill-registry-store.js +32 -0
  206. package/dist/services/skill-registry-store.js.map +1 -1
  207. package/dist/services/whiteboard-store.d.ts +67 -0
  208. package/dist/services/whiteboard-store.d.ts.map +1 -0
  209. package/dist/services/whiteboard-store.js +450 -0
  210. package/dist/services/whiteboard-store.js.map +1 -0
  211. package/dist/services/worktree-slug-ai.d.ts.map +1 -1
  212. package/dist/services/worktree-slug-ai.js +3 -1
  213. package/dist/services/worktree-slug-ai.js.map +1 -1
  214. package/dist/setup/bot-config-editor.d.ts +8 -0
  215. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  216. package/dist/setup/bot-config-editor.js +9 -0
  217. package/dist/setup/bot-config-editor.js.map +1 -1
  218. package/dist/skills/definitions.d.ts.map +1 -1
  219. package/dist/skills/definitions.js +118 -0
  220. package/dist/skills/definitions.js.map +1 -1
  221. package/dist/types.d.ts +15 -0
  222. package/dist/types.d.ts.map +1 -1
  223. package/dist/utils/crash-log.d.ts +22 -0
  224. package/dist/utils/crash-log.d.ts.map +1 -0
  225. package/dist/utils/crash-log.js +37 -0
  226. package/dist/utils/crash-log.js.map +1 -0
  227. package/dist/utils/input-gate.d.ts +10 -0
  228. package/dist/utils/input-gate.d.ts.map +1 -1
  229. package/dist/utils/input-gate.js +7 -0
  230. package/dist/utils/input-gate.js.map +1 -1
  231. package/dist/utils/install-diagnostics.d.ts +52 -0
  232. package/dist/utils/install-diagnostics.d.ts.map +1 -0
  233. package/dist/utils/install-diagnostics.js +155 -0
  234. package/dist/utils/install-diagnostics.js.map +1 -0
  235. package/dist/utils/install-info.d.ts +4 -0
  236. package/dist/utils/install-info.d.ts.map +1 -1
  237. package/dist/utils/install-info.js +6 -0
  238. package/dist/utils/install-info.js.map +1 -1
  239. package/dist/utils/stdio-epipe-guard.d.ts +39 -0
  240. package/dist/utils/stdio-epipe-guard.d.ts.map +1 -0
  241. package/dist/utils/stdio-epipe-guard.js +67 -0
  242. package/dist/utils/stdio-epipe-guard.js.map +1 -0
  243. package/dist/worker.js +222 -26
  244. package/dist/worker.js.map +1 -1
  245. package/dist/workflows/daemon-spawn.d.ts +2 -0
  246. package/dist/workflows/daemon-spawn.d.ts.map +1 -1
  247. package/package.json +1 -1
@@ -40,6 +40,12 @@ export function botmuxVersion() {
40
40
  export function botmuxCliEntry() {
41
41
  return join(packageRoot(), 'dist', 'cli.js');
42
42
  }
43
+ /** Absolute path to this install's root (the dir holding package.json). For a
44
+ * source checkout this is the git working tree — used to derive a real version
45
+ * via `git describe` when package.json is the unbuilt 0.0.0. */
46
+ export function botmuxInstallRoot() {
47
+ return packageRoot();
48
+ }
43
49
  /** Walk up from this module to the nearest dir containing package.json. */
44
50
  function packageRoot() {
45
51
  let dir = dirname(fileURLToPath(import.meta.url));
@@ -1 +1 @@
1
- {"version":3,"file":"install-info.js","sourceRoot":"","sources":["../../src/utils/install-info.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,iFAAiF;AACjF,MAAM,UAAU,mBAAmB,CAAC,OAAe;IACjD,OAAO,UAAU,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;AAC/E,CAAC;AAED,IAAI,MAA2B,CAAC;AAEhC,2EAA2E;AAC3E,MAAM,UAAU,iBAAiB;IAC/B,IAAI,MAAM,KAAK,SAAS;QAAE,MAAM,GAAG,mBAAmB,CAAC,WAAW,EAAE,CAAC,CAAC;IACtE,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;yEAEyE;AACzE,MAAM,UAAU,aAAa;IAC3B,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,cAAc,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;QACnF,OAAO,OAAO,GAAG,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;IACjE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,OAAO,CAAC;IACjB,CAAC;AACH,CAAC;AAED;;+CAE+C;AAC/C,MAAM,UAAU,cAAc;IAC5B,OAAO,IAAI,CAAC,WAAW,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC;AAC/C,CAAC;AAED,2EAA2E;AAC3E,SAAS,WAAW;IAClB,IAAI,GAAG,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IAClD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC3B,IAAI,UAAU,CAAC,IAAI,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC;YAAE,OAAO,GAAG,CAAC;QACtD,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;QAC5B,IAAI,MAAM,KAAK,GAAG;YAAE,MAAM;QAC1B,GAAG,GAAG,MAAM,CAAC;IACf,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
1
+ {"version":3,"file":"install-info.js","sourceRoot":"","sources":["../../src/utils/install-info.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,iFAAiF;AACjF,MAAM,UAAU,mBAAmB,CAAC,OAAe;IACjD,OAAO,UAAU,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC;AAC/E,CAAC;AAED,IAAI,MAA2B,CAAC;AAEhC,2EAA2E;AAC3E,MAAM,UAAU,iBAAiB;IAC/B,IAAI,MAAM,KAAK,SAAS;QAAE,MAAM,GAAG,mBAAmB,CAAC,WAAW,EAAE,CAAC,CAAC;IACtE,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;yEAEyE;AACzE,MAAM,UAAU,aAAa;IAC3B,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,cAAc,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;QACnF,OAAO,OAAO,GAAG,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC;IACjE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,OAAO,CAAC;IACjB,CAAC;AACH,CAAC;AAED;;+CAE+C;AAC/C,MAAM,UAAU,cAAc;IAC5B,OAAO,IAAI,CAAC,WAAW,EAAE,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC;AAC/C,CAAC;AAED;;iEAEiE;AACjE,MAAM,UAAU,iBAAiB;IAC/B,OAAO,WAAW,EAAE,CAAC;AACvB,CAAC;AAED,2EAA2E;AAC3E,SAAS,WAAW;IAClB,IAAI,GAAG,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IAClD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;QAC3B,IAAI,UAAU,CAAC,IAAI,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC;YAAE,OAAO,GAAG,CAAC;QACtD,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;QAC5B,IAAI,MAAM,KAAK,GAAG;YAAE,MAAM;QAC1B,GAAG,GAAG,MAAM,CAAC;IACf,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Suppress EPIPE-class crashes on process.stdout / process.stderr.
3
+ *
4
+ * When botmux runs under pm2, the daemon's and each worker's stdout/stderr are
5
+ * pipes to the pm2 God daemon (→ out_file / error_file). If the reader end
6
+ * detaches — pm2 log streaming stops, the God daemon restarts, `botmux logs`
7
+ * is piped to a command that exits early (e.g. `| head`), or the terminal
8
+ * closes mid-stream — the next write fails with EPIPE. Because a piped
9
+ * stdout/stderr is a Socket, the failed write surfaces as an 'error' event;
10
+ * with no listener Node escalates it to an uncaughtException.
11
+ *
12
+ * That is fatal for botmux: the daemon registers no global uncaughtException
13
+ * handler (so a broken pipe would kill the whole daemon), and the worker's
14
+ * handler calls process.exit(1) on any uncaught error (so a broken pipe would
15
+ * take down a live CLI session). The real logs are captured by pm2's out/err
16
+ * files regardless, so the correct behavior is to drop the failed write
17
+ * silently and keep running rather than crash.
18
+ *
19
+ * Attaching an 'error' listener that ignores the broken-pipe family achieves
20
+ * exactly that. Genuinely unexpected stream errors are re-thrown so they still
21
+ * surface (i.e. behavior for non-broken-pipe errors is unchanged).
22
+ */
23
+ /** True if `err` is a broken-pipe-class stream error that is safe to swallow. */
24
+ export declare function isIgnorableStreamError(err: NodeJS.ErrnoException | null | undefined): boolean;
25
+ /**
26
+ * Attach a broken-pipe-tolerant 'error' listener to a single stream. Exported
27
+ * for unit testing with an injected EventEmitter.
28
+ */
29
+ export declare function attachStreamErrorGuard(stream: NodeJS.EventEmitter): void;
30
+ /**
31
+ * Install the guard on process.stdout/stderr. Idempotent — safe to call from
32
+ * multiple entry points (daemon, worker). Returns true if it installed this
33
+ * call, false if it was already installed.
34
+ *
35
+ * `streams` defaults to the real process streams; it is parameterized only so
36
+ * unit tests can inject fakes without touching the test runner's own stdio.
37
+ */
38
+ export declare function installStdioEpipeGuard(streams?: NodeJS.EventEmitter[]): boolean;
39
+ //# sourceMappingURL=stdio-epipe-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stdio-epipe-guard.d.ts","sourceRoot":"","sources":["../../src/utils/stdio-epipe-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAeH,iFAAiF;AACjF,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,CAAC,cAAc,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE7F;AAED;;;GAGG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,CAAC,YAAY,GAAG,IAAI,CAOxE;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,GAAE,MAAM,CAAC,YAAY,EAAqC,GAChE,OAAO,CAKT"}
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Suppress EPIPE-class crashes on process.stdout / process.stderr.
3
+ *
4
+ * When botmux runs under pm2, the daemon's and each worker's stdout/stderr are
5
+ * pipes to the pm2 God daemon (→ out_file / error_file). If the reader end
6
+ * detaches — pm2 log streaming stops, the God daemon restarts, `botmux logs`
7
+ * is piped to a command that exits early (e.g. `| head`), or the terminal
8
+ * closes mid-stream — the next write fails with EPIPE. Because a piped
9
+ * stdout/stderr is a Socket, the failed write surfaces as an 'error' event;
10
+ * with no listener Node escalates it to an uncaughtException.
11
+ *
12
+ * That is fatal for botmux: the daemon registers no global uncaughtException
13
+ * handler (so a broken pipe would kill the whole daemon), and the worker's
14
+ * handler calls process.exit(1) on any uncaught error (so a broken pipe would
15
+ * take down a live CLI session). The real logs are captured by pm2's out/err
16
+ * files regardless, so the correct behavior is to drop the failed write
17
+ * silently and keep running rather than crash.
18
+ *
19
+ * Attaching an 'error' listener that ignores the broken-pipe family achieves
20
+ * exactly that. Genuinely unexpected stream errors are re-thrown so they still
21
+ * surface (i.e. behavior for non-broken-pipe errors is unchanged).
22
+ */
23
+ /**
24
+ * Error codes that mean "the other end of stdout/stderr went away". These are
25
+ * benign for a long-lived process whose real logs live in pm2's files.
26
+ */
27
+ const IGNORED_STREAM_ERROR_CODES = new Set([
28
+ 'EPIPE',
29
+ 'ERR_STREAM_DESTROYED',
30
+ 'ECONNRESET',
31
+ 'EOF',
32
+ ]);
33
+ let installed = false;
34
+ /** True if `err` is a broken-pipe-class stream error that is safe to swallow. */
35
+ export function isIgnorableStreamError(err) {
36
+ return !!err && IGNORED_STREAM_ERROR_CODES.has(err.code ?? '');
37
+ }
38
+ /**
39
+ * Attach a broken-pipe-tolerant 'error' listener to a single stream. Exported
40
+ * for unit testing with an injected EventEmitter.
41
+ */
42
+ export function attachStreamErrorGuard(stream) {
43
+ stream.on('error', (err) => {
44
+ if (isIgnorableStreamError(err))
45
+ return;
46
+ // Not a broken pipe — re-throw so genuinely unexpected stream errors still
47
+ // surface (same outcome as having no listener at all for these codes).
48
+ throw err;
49
+ });
50
+ }
51
+ /**
52
+ * Install the guard on process.stdout/stderr. Idempotent — safe to call from
53
+ * multiple entry points (daemon, worker). Returns true if it installed this
54
+ * call, false if it was already installed.
55
+ *
56
+ * `streams` defaults to the real process streams; it is parameterized only so
57
+ * unit tests can inject fakes without touching the test runner's own stdio.
58
+ */
59
+ export function installStdioEpipeGuard(streams = [process.stdout, process.stderr]) {
60
+ if (installed)
61
+ return false;
62
+ installed = true;
63
+ for (const stream of streams)
64
+ attachStreamErrorGuard(stream);
65
+ return true;
66
+ }
67
+ //# sourceMappingURL=stdio-epipe-guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stdio-epipe-guard.js","sourceRoot":"","sources":["../../src/utils/stdio-epipe-guard.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH;;;GAGG;AACH,MAAM,0BAA0B,GAAG,IAAI,GAAG,CAAC;IACzC,OAAO;IACP,sBAAsB;IACtB,YAAY;IACZ,KAAK;CACN,CAAC,CAAC;AAEH,IAAI,SAAS,GAAG,KAAK,CAAC;AAEtB,iFAAiF;AACjF,MAAM,UAAU,sBAAsB,CAAC,GAA6C;IAClF,OAAO,CAAC,CAAC,GAAG,IAAI,0BAA0B,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC;AACjE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,sBAAsB,CAAC,MAA2B;IAChE,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAA0B,EAAE,EAAE;QAChD,IAAI,sBAAsB,CAAC,GAAG,CAAC;YAAE,OAAO;QACxC,2EAA2E;QAC3E,uEAAuE;QACvE,MAAM,GAAG,CAAC;IACZ,CAAC,CAAC,CAAC;AACL,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,sBAAsB,CACpC,UAAiC,CAAC,OAAO,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC;IAEjE,IAAI,SAAS;QAAE,OAAO,KAAK,CAAC;IAC5B,SAAS,GAAG,IAAI,CAAC;IACjB,KAAK,MAAM,MAAM,IAAI,OAAO;QAAE,sBAAsB,CAAC,MAAM,CAAC,CAAC;IAC7D,OAAO,IAAI,CAAC;AACd,CAAC"}
package/dist/worker.js CHANGED
@@ -13,16 +13,19 @@
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';
28
+ import { sanitizePerBotEnv } from './core/per-bot-env.js';
26
29
  import { InflightInputTracker } from './core/inflight-input-tracker.js';
27
30
  import { shouldRunQuietRotation, evaluatePidResolverPullback, decideFingerprintSwitch, shouldHealAbsentBaseline, sessionIdFromJsonlPath, SESSION_ID_FILENAME_RE, } from './services/bridge-rotation-policy.js';
28
31
  import { CodexBridgeQueue } from './services/codex-bridge-queue.js';
@@ -104,6 +107,17 @@ let lastSpawnEffectiveResume = false;
104
107
  let lastSpawnEffectiveCliSessionId;
105
108
  let idleDetector = null;
106
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;
107
121
  /** Adopt-bridge mode using TmuxPipeBackend: not a tmux attach client, all
108
122
  * web-terminal updates flow through the shared scrollback fan-out instead
109
123
  * of per-WS attach-session PTYs. Set in spawnCli's adopt branch. */
@@ -163,6 +177,14 @@ let readySignalTimer = null;
163
177
  * back. The real signal lands within ~ms of the input box rendering, so this is
164
178
  * pure insurance against a missing/failed hook — generous but bounded. */
165
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;
166
188
  /** Epoch ms of the most recent PTY output — used to settle for quiescence
167
189
  * before the first flush (see settleThenFlush). */
168
190
  let lastPtyOutputAtMs = 0;
@@ -2275,6 +2297,8 @@ const MAX_SCROLLBACK = 1_000_000; // chars (~1MB)
2275
2297
  let scrollback = '';
2276
2298
  const WORKFLOW_TRANSCRIPT_MAX = 2_000_000; // chars (~2MB)
2277
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
2278
2302
  let workflowTranscript = '';
2279
2303
  let workflowFinalOutputSent = false;
2280
2304
  /** Tracks whether the CLI is currently in the alt screen buffer. Updated by
@@ -2286,6 +2310,97 @@ let workflowFinalOutputSent = false;
2286
2310
  let altBufferActive = false;
2287
2311
  const ALT_ENTER_RE = /\x1b\[\?(1049|1047|47)h/g;
2288
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
+ }
2289
2404
  // ─── Screen Analyzer (AI-based TUI prompt detection) ────────────────────────
2290
2405
  let screenAnalyzer = null;
2291
2406
  /** When true, user messages are queued because a TUI prompt is active */
@@ -4063,6 +4178,16 @@ function spawnCli(cfg) {
4063
4178
  // them past the server's global env.
4064
4179
  if (cliAdapter.spawnEnv)
4065
4180
  Object.assign(childEnv, cliAdapter.spawnEnv);
4181
+ // Per-bot env (bots.json `env`): extra vars for THIS bot's CLI only — e.g.
4182
+ // ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN to run a bot on GLM/a 3rd-party
4183
+ // provider, an HTTPS_PROXY, or a CLI feature flag. Passed as injectEnv (NOT
4184
+ // merged into childEnv) so the tmux/zellij backends inject it via the per-pane
4185
+ // `/usr/bin/env` prefix and never into the shared backing-server global env,
4186
+ // keeping it from leaking across bots. Re-sanitized here (crossed IPC).
4187
+ const perBotInjectEnv = sanitizePerBotEnv(cfg.env);
4188
+ const perBotInjectKeys = Object.keys(perBotInjectEnv);
4189
+ if (perBotInjectKeys.length)
4190
+ log(`Injecting ${perBotInjectKeys.length} per-bot env var(s): ${perBotInjectKeys.join(', ')}`);
4066
4191
  // ── File sandbox (oncall): wrap the CLI in bwrap so it can only touch a
4067
4192
  // per-session project copy + de-identified config. The agent's `botmux send`
4068
4193
  // routes through a daemon-side outbox watcher (creds never enter the sandbox).
@@ -4230,6 +4355,7 @@ function spawnCli(cfg) {
4230
4355
  cols: PTY_COLS,
4231
4356
  rows: PTY_ROWS,
4232
4357
  env: childEnv,
4358
+ injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined,
4233
4359
  });
4234
4360
  // Write CLI PID marker so agent-facing subcommands (`botmux send`, etc.)
4235
4361
  // can verify they were spawned inside a botmux session by walking the
@@ -4493,6 +4619,16 @@ function spawnCli(cfg) {
4493
4619
  backend.onData(onPtyData);
4494
4620
  backend.onExit((code, signal) => {
4495
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;
4496
4632
  // Inputs written but not yet consumed (no idle since the write) die with
4497
4633
  // the CLI — codex crashing mid-submit never records them, and the fresh
4498
4634
  // respawn comes up empty. Stash them so the next spawnCli re-queues and
@@ -4503,38 +4639,56 @@ function spawnCli(cfg) {
4503
4639
  }
4504
4640
  backend = null;
4505
4641
  isPromptReady = false;
4506
- send({ type: 'claude_exit', code, signal });
4642
+ send({ type: 'claude_exit', code, signal, logTail, canParkDiagnostic });
4507
4643
  });
4508
4644
  if (isPipeMode && backend && 'isReattach' in backend && backend.isReattach) {
4509
4645
  log(`Re-attached to existing ${effectiveBackendType} session via pipe backend: ${persistentSessionName}`);
4510
4646
  seedBackendScreen(`${effectiveBackendType} reattach`, backend);
4511
4647
  scheduleReattachIdleProbe(`${effectiveBackendType} reattach`, backend);
4512
4648
  }
4513
- // Fallback: if the CLI takes too long to show its prompt (e.g. slow
4514
- // plugin init, or a spinner blocks the idle detector), unblock screen
4515
- // updates AND deliver any queued prompts so the first user message
4516
- // isn't stranded until the second message arrives. markNewTurn() sets a
4517
- // clean baseline at the current cursor position so only content written
4518
- // *after* this point appears in the card.
4519
- setTimeout(() => {
4520
- if (awaitingFirstPrompt) {
4521
- awaitingFirstPrompt = false;
4522
- renderer?.markNewTurn();
4523
- log('First prompt timeout — enabling screen updates and flushing queued messages');
4524
- if (backend && cliAdapter?.busyPattern && probeBusyPatternIdle(`${cliName()} first-prompt-timeout`, backend)) {
4525
- return;
4526
- }
4527
- // For type-ahead adapters (Codex/CoCo/TRAE/Claude) the TUI is booted
4528
- // enough to park input even if the idle detector hasn't fired yet.
4529
- // Directly invoking markPromptReady() would claim the CLI is idle
4530
- // while it's still mid-boot, so flushPending() alone is safer — it
4531
- // respects typeAheadAllowed and drains pendingMessages now.
4532
- if (cliAdapter?.supportsTypeAhead)
4533
- 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;
4534
4679
  }
4535
- }, 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);
4536
4689
  }
4537
4690
  function killCli() {
4691
+ destroyCrashDiagnosticTerminal('killCli');
4538
4692
  idleDetector?.dispose();
4539
4693
  idleDetector = null;
4540
4694
  stopReattachIdleProbe();
@@ -4634,7 +4788,13 @@ function startWebServer(host, preferredPort) {
4634
4788
  // byte-for-byte (empty, separators, etc.) are not retransmitted, so
4635
4789
  // the earlier frame "bleeds through" — visible as a second
4636
4790
  // banner/prompt stacked above the new layout when scrolling up.
4637
- 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));
4638
4798
  let cp = null;
4639
4799
  const pendingInput = [];
4640
4800
  const startAttach = (cols, rows) => {
@@ -5190,6 +5350,16 @@ process.on('message', async (raw) => {
5190
5350
  const content = msg.content;
5191
5351
  currentBotmuxTurnId = msg.turnId;
5192
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
+ }
5193
5363
  if (lastInitConfig?.adoptMode) {
5194
5364
  // Bridge mode: capture transcript baseline BEFORE writing to the pane,
5195
5365
  // so any assistant uuids appended after this point are attributed to
@@ -5320,6 +5490,16 @@ process.on('message', async (raw) => {
5320
5490
  }
5321
5491
  break;
5322
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
+ }
5323
5503
  case 'restart': {
5324
5504
  if (lastInitConfig?.adoptMode) {
5325
5505
  log('Restart ignored in adopt mode');
@@ -5424,6 +5604,11 @@ process.on('message', async (raw) => {
5424
5604
  log('Suspend requested');
5425
5605
  stopScreenshotLoop();
5426
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');
5427
5612
  // Free the CLI's memory, not just the worker's: destroySession kills the
5428
5613
  // backing tmux/herdr/zellij session AND the CLI process inside it (kill()
5429
5614
  // would only detach the pty viewer and leave the CLI running in the
@@ -5544,8 +5729,17 @@ function teardownSandboxBestEffort() {
5544
5729
  catch { /* */ }
5545
5730
  sandboxCleanup = null;
5546
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();
5547
5737
  process.on('exit', () => { teardownSandboxBestEffort(); });
5548
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;
5549
5743
  try {
5550
5744
  log(`Uncaught exception — tearing down sandbox before exit: ${err?.stack ?? err}`);
5551
5745
  }
@@ -5558,6 +5752,8 @@ process.on('uncaughtException', (err) => {
5558
5752
  process.exit(1);
5559
5753
  });
5560
5754
  process.on('unhandledRejection', (reason) => {
5755
+ if (isIgnorableStreamError(reason))
5756
+ return;
5561
5757
  try {
5562
5758
  log(`Unhandled rejection — tearing down sandbox before exit: ${reason?.stack ?? reason}`);
5563
5759
  }