botmux 2.63.1 → 2.65.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 (244) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +3 -3
  3. package/dist/adapters/backend/herdr-backend.d.ts +8 -1
  4. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  5. package/dist/adapters/backend/herdr-backend.js +15 -2
  6. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  7. package/dist/adapters/backend/tmux-backend.d.ts +17 -1
  8. package/dist/adapters/backend/tmux-backend.d.ts.map +1 -1
  9. package/dist/adapters/backend/tmux-backend.js +25 -4
  10. package/dist/adapters/backend/tmux-backend.js.map +1 -1
  11. package/dist/adapters/backend/types.d.ts +14 -0
  12. package/dist/adapters/backend/types.d.ts.map +1 -1
  13. package/dist/adapters/backend/types.js.map +1 -1
  14. package/dist/adapters/backend/zellij-backend.d.ts +12 -1
  15. package/dist/adapters/backend/zellij-backend.d.ts.map +1 -1
  16. package/dist/adapters/backend/zellij-backend.js +25 -8
  17. package/dist/adapters/backend/zellij-backend.js.map +1 -1
  18. package/dist/bot-registry.d.ts +40 -0
  19. package/dist/bot-registry.d.ts.map +1 -1
  20. package/dist/bot-registry.js +30 -0
  21. package/dist/bot-registry.js.map +1 -1
  22. package/dist/cli/send-dispatch.d.ts +23 -0
  23. package/dist/cli/send-dispatch.d.ts.map +1 -0
  24. package/dist/cli/send-dispatch.js +23 -0
  25. package/dist/cli/send-dispatch.js.map +1 -0
  26. package/dist/cli.d.ts.map +1 -1
  27. package/dist/cli.js +141 -58
  28. package/dist/cli.js.map +1 -1
  29. package/dist/config.d.ts +8 -6
  30. package/dist/config.d.ts.map +1 -1
  31. package/dist/config.js +8 -6
  32. package/dist/config.js.map +1 -1
  33. package/dist/core/ask-broker.d.ts +33 -0
  34. package/dist/core/ask-broker.d.ts.map +1 -1
  35. package/dist/core/ask-broker.js +58 -0
  36. package/dist/core/ask-broker.js.map +1 -1
  37. package/dist/core/ask-hook/claude-code.d.ts.map +1 -1
  38. package/dist/core/ask-hook/claude-code.js +15 -9
  39. package/dist/core/ask-hook/claude-code.js.map +1 -1
  40. package/dist/core/ask-hook/codex.d.ts.map +1 -1
  41. package/dist/core/ask-hook/codex.js +2 -1
  42. package/dist/core/ask-hook/codex.js.map +1 -1
  43. package/dist/core/ask-hook/opencode.d.ts.map +1 -1
  44. package/dist/core/ask-hook/opencode.js +9 -6
  45. package/dist/core/ask-hook/opencode.js.map +1 -1
  46. package/dist/core/ask-hook/types.d.ts +3 -1
  47. package/dist/core/ask-hook/types.d.ts.map +1 -1
  48. package/dist/core/ask-types.d.ts +13 -6
  49. package/dist/core/ask-types.d.ts.map +1 -1
  50. package/dist/core/ask-types.js.map +1 -1
  51. package/dist/core/command-handler.d.ts.map +1 -1
  52. package/dist/core/command-handler.js +255 -4
  53. package/dist/core/command-handler.js.map +1 -1
  54. package/dist/core/daemon-heartbeat.d.ts +15 -0
  55. package/dist/core/daemon-heartbeat.d.ts.map +1 -0
  56. package/dist/core/daemon-heartbeat.js +83 -0
  57. package/dist/core/daemon-heartbeat.js.map +1 -0
  58. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  59. package/dist/core/dashboard-ipc-server.js +80 -33
  60. package/dist/core/dashboard-ipc-server.js.map +1 -1
  61. package/dist/core/dispatch.d.ts +1 -23
  62. package/dist/core/dispatch.d.ts.map +1 -1
  63. package/dist/core/dispatch.js +1 -17
  64. package/dist/core/dispatch.js.map +1 -1
  65. package/dist/core/idle-worker-sweeper.d.ts +13 -0
  66. package/dist/core/idle-worker-sweeper.d.ts.map +1 -0
  67. package/dist/core/idle-worker-sweeper.js +42 -0
  68. package/dist/core/idle-worker-sweeper.js.map +1 -0
  69. package/dist/core/maintenance-schedule.d.ts +34 -0
  70. package/dist/core/maintenance-schedule.d.ts.map +1 -0
  71. package/dist/core/maintenance-schedule.js +72 -0
  72. package/dist/core/maintenance-schedule.js.map +1 -0
  73. package/dist/core/maintenance.d.ts +43 -0
  74. package/dist/core/maintenance.d.ts.map +1 -0
  75. package/dist/core/maintenance.js +160 -0
  76. package/dist/core/maintenance.js.map +1 -0
  77. package/dist/core/reply-target.d.ts +23 -0
  78. package/dist/core/reply-target.d.ts.map +1 -0
  79. package/dist/core/reply-target.js +47 -0
  80. package/dist/core/reply-target.js.map +1 -0
  81. package/dist/core/restart-report.d.ts +49 -0
  82. package/dist/core/restart-report.d.ts.map +1 -0
  83. package/dist/core/restart-report.js +98 -0
  84. package/dist/core/restart-report.js.map +1 -0
  85. package/dist/core/scheduler.d.ts.map +1 -1
  86. package/dist/core/scheduler.js +20 -0
  87. package/dist/core/scheduler.js.map +1 -1
  88. package/dist/core/session-manager.d.ts +26 -10
  89. package/dist/core/session-manager.d.ts.map +1 -1
  90. package/dist/core/session-manager.js +113 -31
  91. package/dist/core/session-manager.js.map +1 -1
  92. package/dist/core/session-marker.d.ts +13 -0
  93. package/dist/core/session-marker.d.ts.map +1 -0
  94. package/dist/core/session-marker.js +55 -0
  95. package/dist/core/session-marker.js.map +1 -0
  96. package/dist/core/types.d.ts +20 -1
  97. package/dist/core/types.d.ts.map +1 -1
  98. package/dist/core/types.js.map +1 -1
  99. package/dist/core/worker-budget.d.ts +19 -0
  100. package/dist/core/worker-budget.d.ts.map +1 -0
  101. package/dist/core/worker-budget.js +50 -0
  102. package/dist/core/worker-budget.js.map +1 -0
  103. package/dist/core/worker-pool.d.ts +5 -2
  104. package/dist/core/worker-pool.d.ts.map +1 -1
  105. package/dist/core/worker-pool.js +105 -12
  106. package/dist/core/worker-pool.js.map +1 -1
  107. package/dist/daemon.d.ts.map +1 -1
  108. package/dist/daemon.js +252 -38
  109. package/dist/daemon.js.map +1 -1
  110. package/dist/dashboard/public-redact.d.ts +2 -1
  111. package/dist/dashboard/public-redact.d.ts.map +1 -1
  112. package/dist/dashboard/public-redact.js +3 -1
  113. package/dist/dashboard/public-redact.js.map +1 -1
  114. package/dist/dashboard/registry.d.ts +2 -0
  115. package/dist/dashboard/registry.d.ts.map +1 -1
  116. package/dist/dashboard/registry.js.map +1 -1
  117. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  118. package/dist/dashboard/web/bot-defaults.js +123 -4
  119. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  120. package/dist/dashboard/web/groups.d.ts.map +1 -1
  121. package/dist/dashboard/web/groups.js +8 -3
  122. package/dist/dashboard/web/groups.js.map +1 -1
  123. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  124. package/dist/dashboard/web/i18n.js +44 -0
  125. package/dist/dashboard/web/i18n.js.map +1 -1
  126. package/dist/dashboard/web/overview.d.ts.map +1 -1
  127. package/dist/dashboard/web/overview.js +6 -4
  128. package/dist/dashboard/web/overview.js.map +1 -1
  129. package/dist/dashboard/web/roles.d.ts.map +1 -1
  130. package/dist/dashboard/web/roles.js +3 -2
  131. package/dist/dashboard/web/roles.js.map +1 -1
  132. package/dist/dashboard/web/sessions.js +2 -2
  133. package/dist/dashboard/web/sessions.js.map +1 -1
  134. package/dist/dashboard/web/settings.d.ts.map +1 -1
  135. package/dist/dashboard/web/settings.js +84 -13
  136. package/dist/dashboard/web/settings.js.map +1 -1
  137. package/dist/dashboard/web/ui.d.ts +33 -0
  138. package/dist/dashboard/web/ui.d.ts.map +1 -1
  139. package/dist/dashboard/web/ui.js +84 -0
  140. package/dist/dashboard/web/ui.js.map +1 -1
  141. package/dist/dashboard-web/app.js +573 -503
  142. package/dist/dashboard-web/style.css +25 -0
  143. package/dist/dashboard.js +88 -8
  144. package/dist/dashboard.js.map +1 -1
  145. package/dist/global-config.d.ts +46 -0
  146. package/dist/global-config.d.ts.map +1 -1
  147. package/dist/global-config.js +115 -0
  148. package/dist/global-config.js.map +1 -1
  149. package/dist/i18n/en.d.ts.map +1 -1
  150. package/dist/i18n/en.js +72 -1
  151. package/dist/i18n/en.js.map +1 -1
  152. package/dist/i18n/zh.d.ts.map +1 -1
  153. package/dist/i18n/zh.js +72 -1
  154. package/dist/i18n/zh.js.map +1 -1
  155. package/dist/im/lark/ask-card.d.ts.map +1 -1
  156. package/dist/im/lark/ask-card.js +15 -1
  157. package/dist/im/lark/ask-card.js.map +1 -1
  158. package/dist/im/lark/card-builder.d.ts +17 -0
  159. package/dist/im/lark/card-builder.d.ts.map +1 -1
  160. package/dist/im/lark/card-builder.js +146 -0
  161. package/dist/im/lark/card-builder.js.map +1 -1
  162. package/dist/im/lark/card-handler.d.ts.map +1 -1
  163. package/dist/im/lark/card-handler.js +119 -4
  164. package/dist/im/lark/card-handler.js.map +1 -1
  165. package/dist/im/lark/client.d.ts +14 -2
  166. package/dist/im/lark/client.d.ts.map +1 -1
  167. package/dist/im/lark/client.js +59 -5
  168. package/dist/im/lark/client.js.map +1 -1
  169. package/dist/im/lark/event-dispatcher.d.ts +7 -17
  170. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  171. package/dist/im/lark/event-dispatcher.js +174 -50
  172. package/dist/im/lark/event-dispatcher.js.map +1 -1
  173. package/dist/im/lark/forwarded-renderer.d.ts.map +1 -1
  174. package/dist/im/lark/forwarded-renderer.js +10 -3
  175. package/dist/im/lark/forwarded-renderer.js.map +1 -1
  176. package/dist/im/lark/merge-forward.d.ts.map +1 -1
  177. package/dist/im/lark/merge-forward.js +33 -8
  178. package/dist/im/lark/merge-forward.js.map +1 -1
  179. package/dist/im/lark/message-parser.d.ts +1 -0
  180. package/dist/im/lark/message-parser.d.ts.map +1 -1
  181. package/dist/im/lark/message-parser.js +1 -0
  182. package/dist/im/lark/message-parser.js.map +1 -1
  183. package/dist/im/lark/reply-mode-command.d.ts +2 -0
  184. package/dist/im/lark/reply-mode-command.d.ts.map +1 -0
  185. package/dist/im/lark/reply-mode-command.js +102 -0
  186. package/dist/im/lark/reply-mode-command.js.map +1 -0
  187. package/dist/services/bot-config-store.d.ts +144 -0
  188. package/dist/services/bot-config-store.d.ts.map +1 -0
  189. package/dist/services/bot-config-store.js +241 -0
  190. package/dist/services/bot-config-store.js.map +1 -0
  191. package/dist/services/card-prefs-store.d.ts +5 -0
  192. package/dist/services/card-prefs-store.d.ts.map +1 -1
  193. package/dist/services/card-prefs-store.js +47 -0
  194. package/dist/services/card-prefs-store.js.map +1 -1
  195. package/dist/services/chat-reply-mode-store.d.ts +28 -0
  196. package/dist/services/chat-reply-mode-store.d.ts.map +1 -0
  197. package/dist/services/chat-reply-mode-store.js +115 -0
  198. package/dist/services/chat-reply-mode-store.js.map +1 -0
  199. package/dist/services/groups-store.d.ts +2 -0
  200. package/dist/services/groups-store.d.ts.map +1 -1
  201. package/dist/services/groups-store.js +1 -0
  202. package/dist/services/groups-store.js.map +1 -1
  203. package/dist/services/hook-runner.d.ts +43 -0
  204. package/dist/services/hook-runner.d.ts.map +1 -0
  205. package/dist/services/hook-runner.js +394 -0
  206. package/dist/services/hook-runner.js.map +1 -0
  207. package/dist/services/observed-bots-store.d.ts.map +1 -1
  208. package/dist/services/observed-bots-store.js +5 -0
  209. package/dist/services/observed-bots-store.js.map +1 -1
  210. package/dist/services/restart-intent-store.d.ts +26 -0
  211. package/dist/services/restart-intent-store.d.ts.map +1 -0
  212. package/dist/services/restart-intent-store.js +84 -0
  213. package/dist/services/restart-intent-store.js.map +1 -0
  214. package/dist/services/session-lifecycle-hooks.d.ts +10 -0
  215. package/dist/services/session-lifecycle-hooks.d.ts.map +1 -0
  216. package/dist/services/session-lifecycle-hooks.js +66 -0
  217. package/dist/services/session-lifecycle-hooks.js.map +1 -0
  218. package/dist/services/session-store.d.ts +6 -0
  219. package/dist/services/session-store.d.ts.map +1 -1
  220. package/dist/services/session-store.js +25 -0
  221. package/dist/services/session-store.js.map +1 -1
  222. package/dist/skills/definitions.d.ts.map +1 -1
  223. package/dist/skills/definitions.js +28 -3
  224. package/dist/skills/definitions.js.map +1 -1
  225. package/dist/types.d.ts +33 -0
  226. package/dist/types.d.ts.map +1 -1
  227. package/dist/utils/install-info.d.ts +13 -0
  228. package/dist/utils/install-info.d.ts.map +1 -0
  229. package/dist/utils/install-info.js +56 -0
  230. package/dist/utils/install-info.js.map +1 -0
  231. package/dist/utils/listen-with-probe.d.ts +26 -0
  232. package/dist/utils/listen-with-probe.d.ts.map +1 -0
  233. package/dist/utils/listen-with-probe.js +64 -0
  234. package/dist/utils/listen-with-probe.js.map +1 -0
  235. package/dist/utils/web-terminal-listen.d.ts +30 -0
  236. package/dist/utils/web-terminal-listen.d.ts.map +1 -0
  237. package/dist/utils/web-terminal-listen.js +81 -0
  238. package/dist/utils/web-terminal-listen.js.map +1 -0
  239. package/dist/worker.js +71 -44
  240. package/dist/worker.js.map +1 -1
  241. package/dist/workflows/definition.d.ts +30 -30
  242. package/dist/workflows/events/payloads.d.ts +4 -4
  243. package/dist/workflows/events/schema.d.ts +156 -156
  244. package/package.json +1 -1
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Bind `server` to `port`, walking port+1, port+2 … up to `maxProbe` times when
3
+ * the port is already in use, and resolve with the actually-bound port.
4
+ *
5
+ * Why this exists: several daemon/dashboard listeners (dashboard-ipc-server.ts,
6
+ * dashboard.ts) historically did a single `server.listen(fixedPort)` with no
7
+ * 'error' listener / no probe, so on a shared machine a second botmux instance
8
+ * binding the same default port emitted an UNHANDLED 'error' that crashed the
9
+ * whole process (the IPC bind even took the daemon down at startup). This
10
+ * mirrors the already-proven probe in core/terminal-proxy.ts so those binds
11
+ * self-heal to a free port; callers MUST advertise the returned (bound) port to
12
+ * their consumers (the IPC port via the daemon descriptor, the dashboard port
13
+ * via ~/.botmux/.dashboard-port) since it may differ from the requested one.
14
+ */
15
+ export function listenWithProbe(opts) {
16
+ const { server, host } = opts;
17
+ const maxProbe = opts.maxProbe ?? 20;
18
+ const log = opts.log ?? (() => { });
19
+ return new Promise((resolve, reject) => {
20
+ let port = opts.port;
21
+ let attempts = 0;
22
+ let settled = false;
23
+ // Single persistent handlers reused across every probe attempt. Passing a
24
+ // callback to server.listen() would instead add a fresh one-time
25
+ // 'listening' listener on each retry that is never removed on a failed
26
+ // bind, leaking listeners (MaxListenersExceededWarning past 10 probes) and
27
+ // firing every stale callback once a bind finally succeeds.
28
+ const cleanup = () => {
29
+ server.removeListener('listening', onListening);
30
+ server.removeListener('error', onError);
31
+ };
32
+ const onListening = () => {
33
+ if (settled)
34
+ return;
35
+ settled = true;
36
+ cleanup();
37
+ const addr = server.address();
38
+ const bound = typeof addr === 'object' && addr ? addr.port : port;
39
+ // Keep a permanent handler so a post-bind runtime error can't become an
40
+ // unhandled 'error' event (which would crash the process).
41
+ server.on('error', (e) => log(`server error: ${e.message}`));
42
+ resolve(bound);
43
+ };
44
+ const onError = (err) => {
45
+ if (settled)
46
+ return;
47
+ if (err.code === 'EADDRINUSE' && attempts < maxProbe) {
48
+ attempts++;
49
+ log(`port ${port} in use, trying ${port + 1}`);
50
+ port++;
51
+ setImmediate(() => { if (!settled)
52
+ server.listen(port, host); });
53
+ return;
54
+ }
55
+ settled = true;
56
+ cleanup();
57
+ reject(err);
58
+ };
59
+ server.on('listening', onListening);
60
+ server.on('error', onError);
61
+ server.listen(port, host);
62
+ });
63
+ }
64
+ //# sourceMappingURL=listen-with-probe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"listen-with-probe.js","sourceRoot":"","sources":["../../src/utils/listen-with-probe.ts"],"names":[],"mappings":"AAYA;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,IAAyB;IACvD,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,IAAI,CAAC;IAC9B,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC;IACrC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,GAAc,CAAC,CAAC,CAAC;IAE/C,OAAO,IAAI,OAAO,CAAS,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QAC7C,IAAI,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;QACrB,IAAI,QAAQ,GAAG,CAAC,CAAC;QACjB,IAAI,OAAO,GAAG,KAAK,CAAC;QAEpB,0EAA0E;QAC1E,iEAAiE;QACjE,uEAAuE;QACvE,2EAA2E;QAC3E,4DAA4D;QAC5D,MAAM,OAAO,GAAG,GAAG,EAAE;YACnB,MAAM,CAAC,cAAc,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;YAChD,MAAM,CAAC,cAAc,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC1C,CAAC,CAAC;QACF,MAAM,WAAW,GAAG,GAAG,EAAE;YACvB,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,OAAO,EAAE,CAAC;YACV,MAAM,IAAI,GAAG,MAAM,CAAC,OAAO,EAAE,CAAC;YAC9B,MAAM,KAAK,GAAG,OAAO,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;YAClE,wEAAwE;YACxE,2DAA2D;YAC3D,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,CAAC,iBAAkB,CAAW,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC;YACxE,OAAO,CAAC,KAAK,CAAC,CAAC;QACjB,CAAC,CAAC;QACF,MAAM,OAAO,GAAG,CAAC,GAA0B,EAAE,EAAE;YAC7C,IAAI,OAAO;gBAAE,OAAO;YACpB,IAAI,GAAG,CAAC,IAAI,KAAK,YAAY,IAAI,QAAQ,GAAG,QAAQ,EAAE,CAAC;gBACrD,QAAQ,EAAE,CAAC;gBACX,GAAG,CAAC,QAAQ,IAAI,mBAAmB,IAAI,GAAG,CAAC,EAAE,CAAC,CAAC;gBAC/C,IAAI,EAAE,CAAC;gBACP,YAAY,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,OAAO;oBAAE,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;gBACjE,OAAO;YACT,CAAC;YACD,OAAO,GAAG,IAAI,CAAC;YACf,OAAO,EAAE,CAAC;YACV,MAAM,CAAC,GAAG,CAAC,CAAC;QACd,CAAC,CAAC;QAEF,MAAM,CAAC,EAAE,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;QACpC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QAC5B,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IAC5B,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -0,0 +1,30 @@
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import type { WebSocketServer } from 'ws';
3
+ export interface ListenWebTerminalOpts {
4
+ httpServer: HttpServer;
5
+ wss: WebSocketServer;
6
+ host: string;
7
+ /** Try this port first; fall back to an OS-assigned random port if busy. */
8
+ preferredPort?: number;
9
+ log?: (msg: string) => void;
10
+ }
11
+ /**
12
+ * Bind the per-session web-terminal HTTP server, recovering from a busy
13
+ * `preferredPort` (e.g. a persisted webPort reused on worker re-fork that some
14
+ * other process now holds) by retrying on an OS-assigned random port.
15
+ *
16
+ * CRITICAL — why the `wss.on('error')` below is load-bearing, not cosmetic:
17
+ * `new WebSocketServer({ server })` makes the ws library proxy the HTTP server's
18
+ * `'error'` event onto the WSS instance (ws ≥8: `addListeners` runs
19
+ * `server.on('error', wss.emit.bind(wss, 'error'))` at construction time). That
20
+ * proxy listener is registered BEFORE the HTTP server's own `'error'` handler
21
+ * here. So on EADDRINUSE the proxy fires first and re-emits `'error'` on the
22
+ * WSS; with no `'error'` listener on the WSS, Node throws an unhandled `'error'`
23
+ * event and CRASHES the whole worker process — before the HTTP fallback below
24
+ * ever runs, making the random-port recovery dead code. Note: ordering the HTTP
25
+ * handler first does NOT help (verified) — the unhandled WSS error still fires.
26
+ * The only fix is to give the WSS an `'error'` listener. Regression-tested in
27
+ * test/web-terminal-listen.test.ts.
28
+ */
29
+ export declare function listenWebTerminalWithFallback(opts: ListenWebTerminalOpts): Promise<number>;
30
+ //# sourceMappingURL=web-terminal-listen.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-terminal-listen.d.ts","sourceRoot":"","sources":["../../src/utils/web-terminal-listen.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,WAAW,CAAC;AACtD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,IAAI,CAAC;AAE1C,MAAM,WAAW,qBAAqB;IACpC,UAAU,EAAE,UAAU,CAAC;IACvB,GAAG,EAAE,eAAe,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,6BAA6B,CAAC,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAAC,MAAM,CAAC,CA+D1F"}
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Bind the per-session web-terminal HTTP server, recovering from a busy
3
+ * `preferredPort` (e.g. a persisted webPort reused on worker re-fork that some
4
+ * other process now holds) by retrying on an OS-assigned random port.
5
+ *
6
+ * CRITICAL — why the `wss.on('error')` below is load-bearing, not cosmetic:
7
+ * `new WebSocketServer({ server })` makes the ws library proxy the HTTP server's
8
+ * `'error'` event onto the WSS instance (ws ≥8: `addListeners` runs
9
+ * `server.on('error', wss.emit.bind(wss, 'error'))` at construction time). That
10
+ * proxy listener is registered BEFORE the HTTP server's own `'error'` handler
11
+ * here. So on EADDRINUSE the proxy fires first and re-emits `'error'` on the
12
+ * WSS; with no `'error'` listener on the WSS, Node throws an unhandled `'error'`
13
+ * event and CRASHES the whole worker process — before the HTTP fallback below
14
+ * ever runs, making the random-port recovery dead code. Note: ordering the HTTP
15
+ * handler first does NOT help (verified) — the unhandled WSS error still fires.
16
+ * The only fix is to give the WSS an `'error'` listener. Regression-tested in
17
+ * test/web-terminal-listen.test.ts.
18
+ */
19
+ export function listenWebTerminalWithFallback(opts) {
20
+ const { httpServer, wss, host, preferredPort } = opts;
21
+ const log = opts.log ?? (() => { });
22
+ return new Promise((resolve, reject) => {
23
+ // Defuse the ws→wss error proxy so a bind failure can't crash the process;
24
+ // the httpServer 'error' handler below owns recovery / rejection.
25
+ wss.on('error', (err) => {
26
+ log(`WSS server error: ${[err.code, err.message].filter(Boolean).join(' ')}`);
27
+ });
28
+ const currentPort = () => {
29
+ const addr = httpServer.address();
30
+ return typeof addr === 'object' && addr ? addr.port : 0;
31
+ };
32
+ const listenPort = preferredPort ?? 0;
33
+ let fallbackAttempted = false;
34
+ let settled = false;
35
+ const settleResolve = (port) => {
36
+ if (settled)
37
+ return;
38
+ settled = true;
39
+ resolve(port);
40
+ };
41
+ const settleReject = (err) => {
42
+ if (settled)
43
+ return;
44
+ settled = true;
45
+ reject(err);
46
+ };
47
+ const listen = (port, suffix = '') => {
48
+ httpServer.listen(port, host, () => {
49
+ const boundPort = currentPort();
50
+ log(`HTTP listening on ${host}:${boundPort}${suffix}`);
51
+ settleResolve(boundPort);
52
+ });
53
+ };
54
+ // Register before listen(): bind failures are asynchronous 'error' events,
55
+ // and the recovery handler must be present before the first listen attempt.
56
+ httpServer.on('error', (err) => {
57
+ // This handler outlives the Promise (we never detach it). Once we've
58
+ // resolved/rejected, a late 'error' must be inert — otherwise an
59
+ // EADDRINUSE arriving after a no-fallback resolve would re-enter listen()
60
+ // on an already-listening server (ERR_SERVER_ALREADY_LISTEN, thrown
61
+ // synchronously from the emit → unhandled → crash). Defense in depth.
62
+ if (settled)
63
+ return;
64
+ if (err.code === 'EADDRINUSE' && !fallbackAttempted) {
65
+ fallbackAttempted = true;
66
+ if (preferredPort) {
67
+ log(`Preferred port ${preferredPort} in use (${err.code}), falling back to random`);
68
+ }
69
+ else {
70
+ log(`Port ${listenPort} bind failed (${err.code}), retrying with random port`);
71
+ }
72
+ listen(0, ' (fallback)');
73
+ }
74
+ else {
75
+ settleReject(err);
76
+ }
77
+ });
78
+ listen(listenPort);
79
+ });
80
+ }
81
+ //# sourceMappingURL=web-terminal-listen.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"web-terminal-listen.js","sourceRoot":"","sources":["../../src/utils/web-terminal-listen.ts"],"names":[],"mappings":"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"}
package/dist/worker.js CHANGED
@@ -31,6 +31,7 @@ import { baselineJsonlCursor } from './services/jsonl-cursor.js';
31
31
  import { dirname } from 'node:path';
32
32
  import { createServer as createHttpServer } from 'node:http';
33
33
  import { WebSocketServer, WebSocket } from 'ws';
34
+ import { listenWebTerminalWithFallback } from './utils/web-terminal-listen.js';
34
35
  import { TerminalRenderer } from './utils/terminal-renderer.js';
35
36
  import { DEFAULT_RENDER_COLS, DEFAULT_RENDER_ROWS, MAX_RENDER_COLS, MAX_RENDER_ROWS, MIN_RENDER_COLS, MIN_RENDER_ROWS, clamp, resolveRenderDimensions, } from './utils/render-dimensions.js';
36
37
  import { createCliAdapterSync, locateOnPath } from './adapters/cli/registry.js';
@@ -107,6 +108,17 @@ const pendingMessages = [];
107
108
  /** Alternate submit-confirmation signals. Some CLIs can consume PTY input and
108
109
  * start work before their history/transcript submit marker is observable. */
109
110
  let lastPtyActivityAtMs = 0;
111
+ let currentBotmuxTurnId;
112
+ function writeCliPidMarker() {
113
+ if (!cliPidMarker || !sessionId)
114
+ return;
115
+ try {
116
+ writeFileSync(cliPidMarker, JSON.stringify({ sessionId, turnId: currentBotmuxTurnId ?? null }));
117
+ }
118
+ catch (err) {
119
+ log(`Failed to update CLI PID marker: ${err?.message ?? err}`);
120
+ }
121
+ }
110
122
  let lastStructuredBridgeActivityAtMs = 0;
111
123
  // Per-turn usage-limit state machine. Owns the turn counter plus the
112
124
  // "did this turn hit a limit" / "suppress a stale retry-ready banner" flags, so
@@ -389,6 +401,7 @@ function maybeEmitAdoptPreamble(events) {
389
401
  bridgePreambleSent = true;
390
402
  send({
391
403
  type: 'adopt_preamble',
404
+ turnId: currentBotmuxTurnId,
392
405
  userText: truncatePreambleText(turn.userText, PREAMBLE_USER_MAX),
393
406
  assistantText: truncatePreambleText(turn.assistantText, PREAMBLE_ASSISTANT_MAX),
394
407
  });
@@ -413,6 +426,7 @@ function maybeEmitCodexAdoptPreamble(history) {
413
426
  codexBridgePreambleSent = true;
414
427
  send({
415
428
  type: 'adopt_preamble',
429
+ turnId: currentBotmuxTurnId,
416
430
  userText: truncatePreambleText(turn.userText, PREAMBLE_USER_MAX),
417
431
  assistantText: truncatePreambleText(turn.assistantText, PREAMBLE_ASSISTANT_MAX),
418
432
  });
@@ -1298,7 +1312,7 @@ function stopBridgeWatcher() {
1298
1312
  * no jsonl line will ever match, and `maybeSwitchBridgeJsonl` burns 99%
1299
1313
  * CPU scanning all sibling jsonls for it on every poll tick.
1300
1314
  */
1301
- function bridgeMarkPendingTurn(messageText) {
1315
+ function bridgeMarkPendingTurn(messageText, preferredTurnId) {
1302
1316
  if (!bridgeJsonlPath)
1303
1317
  return undefined;
1304
1318
  if (!bridgeBaselineDone) {
@@ -1314,7 +1328,7 @@ function bridgeMarkPendingTurn(messageText) {
1314
1328
  // pane to false-match than the 30-char substring fingerprint.
1315
1329
  const normalised = normaliseForFingerprint(messageText);
1316
1330
  const contentNormalized = normalised.length > 0 ? normalised : undefined;
1317
- const turnId = randomBytes(8).toString('hex');
1331
+ const turnId = preferredTurnId ?? randomBytes(8).toString('hex');
1318
1332
  bridgeQueue.mark(turnId, fingerprint, Date.now(), contentNormalized);
1319
1333
  return turnId;
1320
1334
  }
@@ -1853,10 +1867,10 @@ function codexBridgeIngest() {
1853
1867
  /** Mark a pending Lark turn for Codex. Crucially this works even before a
1854
1868
  * rollout path is known — the queue is path-agnostic, and ingest after
1855
1869
  * late-attach picks up the user_message and matches the fingerprint. */
1856
- function codexBridgeMarkPendingTurn(messageText) {
1870
+ function codexBridgeMarkPendingTurn(messageText, preferredTurnId) {
1857
1871
  if (!codexBridgeFallbackActive())
1858
1872
  return false;
1859
- const turnId = `codex-${randomBytes(8).toString('hex')}`;
1873
+ const turnId = preferredTurnId ?? `codex-${randomBytes(8).toString('hex')}`;
1860
1874
  codexBridgeQueue.mark(turnId, messageText);
1861
1875
  return true;
1862
1876
  }
@@ -2115,7 +2129,7 @@ function maybeEmitWorkflowTranscriptOutput() {
2115
2129
  type: 'final_output',
2116
2130
  content: workflowTranscript,
2117
2131
  lastUuid: `workflow-pty-${Date.now()}`,
2118
- turnId: `workflow-pty-${sessionId || 'unknown'}`,
2132
+ turnId: currentBotmuxTurnId ?? `workflow-pty-${sessionId || 'unknown'}`,
2119
2133
  });
2120
2134
  log('Workflow PTY transcript final_output emitted');
2121
2135
  }
@@ -2145,7 +2159,7 @@ function startScreenAnalyzer() {
2145
2159
  onAnalyzing: () => { },
2146
2160
  onTuiPrompt: (description, options, multiSelect) => {
2147
2161
  tuiPromptBlocking = true;
2148
- send({ type: 'tui_prompt', description, options, multiSelect });
2162
+ send({ type: 'tui_prompt', description, options, multiSelect, turnId: currentBotmuxTurnId });
2149
2163
  },
2150
2164
  onTuiPromptResolved: (selectedText) => {
2151
2165
  tuiPromptBlocking = false;
@@ -2513,7 +2527,7 @@ function handleCodexAppMarker(body) {
2513
2527
  return;
2514
2528
  }
2515
2529
  }
2516
- const turnId = typeof payload.turnId === 'string' ? payload.turnId : `${lastInitConfig?.cliId ?? 'app'}-${Date.now()}`;
2530
+ const turnId = typeof payload.turnId === 'string' ? payload.turnId : (currentBotmuxTurnId ?? `${lastInitConfig?.cliId ?? 'app'}-${Date.now()}`);
2517
2531
  send({
2518
2532
  type: 'final_output',
2519
2533
  content: payload.content,
@@ -2637,7 +2651,7 @@ function markPromptReady() {
2637
2651
  // (where the initial prompt is queued before the CLI becomes idle).
2638
2652
  if (renderer && pendingMessages.length === 0 && !isFlushing) {
2639
2653
  const { content } = renderer.snapshot();
2640
- send({ type: 'screen_update', content, ...usageLimitTracker.classify(content, 'idle') });
2654
+ send({ type: 'screen_update', content, ...usageLimitTracker.classify(content, 'idle'), turnId: currentBotmuxTurnId });
2641
2655
  }
2642
2656
  flushPending();
2643
2657
  }
@@ -2700,6 +2714,7 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
2700
2714
  log(`writeInput: submit impossible — notifying user immediately. reason="${reason}" preview="${preview}"`);
2701
2715
  send({
2702
2716
  type: 'user_notify',
2717
+ turnId: currentBotmuxTurnId,
2703
2718
  message: `⚠️ 刚才那条消息没有写入 ${cliName()},因为当前按键配置无法从终端自动提交。\n原因:${reason}\n请调整 Claude Code Chat keybinding 后重发。\n开头:${preview}`,
2704
2719
  });
2705
2720
  return;
@@ -2754,6 +2769,7 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
2754
2769
  log(`Deferred recheck still missing — notifying user. preview="${preview}"`);
2755
2770
  send({
2756
2771
  type: 'user_notify',
2772
+ turnId: currentBotmuxTurnId,
2757
2773
  message: `⚠️ 刚才那条消息发给 ${cliName()} 后没能确认提交(重试 Enter 后等了 ${Math.round(SUBMIT_DEFERRED_RECHECK_MS / 1000)}s 仍未在${transcriptLabel}里看到新记录)。可能卡在输入框里——请去 Web 终端看一下,手动按 Enter 或重发。\n开头:${preview}`,
2758
2774
  });
2759
2775
  }, SUBMIT_DEFERRED_RECHECK_MS);
@@ -2807,7 +2823,10 @@ async function flushPending() {
2807
2823
  }
2808
2824
  try {
2809
2825
  while (pendingMessages.length > 0 && backend && cliAdapter) {
2810
- const msg = pendingMessages.shift();
2826
+ const item = pendingMessages.shift();
2827
+ const msg = item.content;
2828
+ currentBotmuxTurnId = item.turnId;
2829
+ writeCliPidMarker();
2811
2830
  const turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
2812
2831
  // Bridge fallback: mark immediately before writeInput. Doing it here
2813
2832
  // (instead of at enqueue time) means markTimeMs anchors to the
@@ -2822,14 +2841,14 @@ async function flushPending() {
2822
2841
  bridgeIngest();
2823
2842
  }
2824
2843
  catch { /* best-effort */ }
2825
- bridgeTurnId = bridgeMarkPendingTurn(msg);
2844
+ bridgeTurnId = bridgeMarkPendingTurn(msg, item.turnId);
2826
2845
  }
2827
2846
  else if (codexBridgeActive) {
2828
2847
  // Codex mark works even before the rollout path is known: the
2829
2848
  // queue is path-agnostic, and the late-attach below will start
2830
2849
  // ingest from offset 0 so the user_message that lands shortly
2831
2850
  // after still fingerprint-matches this turn.
2832
- codexBridgeMarkPendingTurn(msg);
2851
+ codexBridgeMarkPendingTurn(msg, item.turnId);
2833
2852
  }
2834
2853
  log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
2835
2854
  // Defense in depth: TmuxPipeBackend's send methods no longer throw on a
@@ -2884,10 +2903,10 @@ async function flushPending() {
2884
2903
  isFlushing = false;
2885
2904
  }
2886
2905
  }
2887
- function sendToPty(content) {
2906
+ function sendToPty(content, turnId) {
2888
2907
  if (!backend || !cliAdapter)
2889
2908
  return;
2890
- pendingMessages.push(content);
2909
+ pendingMessages.push({ content, turnId });
2891
2910
  // User-override semantics: a fresh Lark message while a TUI prompt is "active"
2892
2911
  // takes precedence over the AI-detected prompt. The screen analyzer can be
2893
2912
  // wrong (false positive on a question that has no rendered options) and a
@@ -2966,7 +2985,7 @@ function startScreenUpdates() {
2966
2985
  const usageAware = usageLimitTracker.classify(content, status);
2967
2986
  if (changed || usageAware.status !== lastSentStatus) {
2968
2987
  lastSentStatus = usageAware.status;
2969
- send({ type: 'screen_update', content, ...usageAware });
2988
+ send({ type: 'screen_update', content, ...usageAware, turnId: currentBotmuxTurnId });
2970
2989
  }
2971
2990
  })();
2972
2991
  }, SCREEN_UPDATE_INTERVAL_MS);
@@ -3360,6 +3379,7 @@ function spawnCli(cfg) {
3360
3379
  const probe = isAbsolute(wantBin) ? `ls -l ${wantBin}` : `which ${wantBin}`;
3361
3380
  send({
3362
3381
  type: 'user_notify',
3382
+ turnId: currentBotmuxTurnId,
3363
3383
  message: `无法启动 ${cliName()}:找不到可执行文件「${wantBin}」。\n` +
3364
3384
  `请在运行 botmux daemon 的这台机器上确认它已安装并在 PATH 中(自查:${probe}),然后重发消息重试。\n` +
3365
3385
  `当前 daemon PATH=${process.env.PATH ?? '(空)'}`,
@@ -3385,6 +3405,9 @@ function spawnCli(cfg) {
3385
3405
  childEnv.BOTMUX_CHAT_ID = cfg.chatId;
3386
3406
  childEnv.BOTMUX_LARK_APP_ID = cfg.larkAppId;
3387
3407
  childEnv.BOTMUX_ROOT_MESSAGE_ID = cfg.rootMessageId;
3408
+ // Initial value only; long-lived panes get the latest turn via the JSON pid marker.
3409
+ if (cfg.turnId)
3410
+ childEnv.BOTMUX_TURN_ID = cfg.turnId;
3388
3411
  if (injectClaudeSandbox)
3389
3412
  childEnv.IS_SANDBOX = '1';
3390
3413
  if (claudeResumeTokenThreshold)
@@ -3410,7 +3433,7 @@ function spawnCli(cfg) {
3410
3433
  try {
3411
3434
  mkdirSync(markersDir, { recursive: true });
3412
3435
  cliPidMarker = join(markersDir, String(cliPid));
3413
- writeFileSync(cliPidMarker, cfg.sessionId);
3436
+ writeCliPidMarker();
3414
3437
  log(`CLI PID marker written: ${cliPid}`);
3415
3438
  }
3416
3439
  catch (err) {
@@ -3448,7 +3471,7 @@ function spawnCli(cfg) {
3448
3471
  const markersDir = join(process.env.SESSION_DATA_DIR, '.botmux-cli-pids');
3449
3472
  mkdirSync(markersDir, { recursive: true });
3450
3473
  cliPidMarker = join(markersDir, String(pid));
3451
- writeFileSync(cliPidMarker, cfg.sessionId);
3474
+ writeCliPidMarker();
3452
3475
  log(`CLI PID marker written (async): ${pid}`);
3453
3476
  }
3454
3477
  catch (err) {
@@ -3882,28 +3905,13 @@ function startWebServer(host, preferredPort) {
3882
3905
  });
3883
3906
  }
3884
3907
  });
3885
- const listenPort = preferredPort ?? 0;
3886
- httpServer.listen(listenPort, host, () => {
3887
- const addr = httpServer.address();
3888
- const port = typeof addr === 'object' && addr ? addr.port : 0;
3889
- log(`HTTP listening on ${host}:${port}`);
3890
- resolve(port);
3891
- });
3892
- httpServer.on('error', (err) => {
3893
- if (preferredPort && err.code === 'EADDRINUSE') {
3894
- // Preferred port in use — fall back to random
3895
- log(`Preferred port ${preferredPort} in use, falling back to random`);
3896
- httpServer.listen(0, host, () => {
3897
- const addr = httpServer.address();
3898
- const port = typeof addr === 'object' && addr ? addr.port : 0;
3899
- log(`HTTP listening on ${host}:${port} (fallback)`);
3900
- resolve(port);
3901
- });
3902
- }
3903
- else {
3904
- reject(err);
3905
- }
3906
- });
3908
+ // Bind + EADDRINUSE→random-port fallback live in a shared helper that also
3909
+ // attaches the load-bearing wss 'error' listener: `new WebSocketServer({
3910
+ // server })` makes ws proxy the http server's 'error' onto the wss, so a
3911
+ // busy port would otherwise emit an UNHANDLED 'error' on the wss and crash
3912
+ // the worker before this fallback can run. See web-terminal-listen.ts.
3913
+ listenWebTerminalWithFallback({ httpServer: httpServer, wss: wss, host, preferredPort, log })
3914
+ .then(resolve, reject);
3907
3915
  });
3908
3916
  }
3909
3917
  function getTerminalHtml(hasWrite) {
@@ -4194,6 +4202,10 @@ process.on('message', async (raw) => {
4194
4202
  renderRows = dims.rows;
4195
4203
  log(`Init: session=${sessionId}, cwd=${msg.workingDir}, render=${renderCols}x${renderRows}${msg.adoptMode ? ' (adopt-pane)' : ''}`);
4196
4204
  try {
4205
+ if (msg.turnId) {
4206
+ currentBotmuxTurnId = msg.turnId;
4207
+ writeCliPidMarker();
4208
+ }
4197
4209
  let port = 0;
4198
4210
  if (!isWorkflowWorker()) {
4199
4211
  port = await startWebServer(config.web.workerHost, msg.webPort);
@@ -4215,9 +4227,9 @@ process.on('message', async (raw) => {
4215
4227
  // Bridge mark is deferred to flushPending — see flushPending
4216
4228
  // comment for why marking at enqueue is wrong.
4217
4229
  if (msg.prompt && !cliAdapter?.passesInitialPromptViaArgs) {
4218
- pendingMessages.push(msg.prompt);
4230
+ pendingMessages.push({ content: msg.prompt, turnId: msg.turnId });
4219
4231
  }
4220
- send({ type: 'ready', port, token: writeToken });
4232
+ send({ type: 'ready', port, token: writeToken, turnId: currentBotmuxTurnId });
4221
4233
  }
4222
4234
  catch (err) {
4223
4235
  send({ type: 'error', message: `init failed: ${err.message}` });
@@ -4233,6 +4245,8 @@ process.on('message', async (raw) => {
4233
4245
  if (tmuxScrolledHalfPages > 0)
4234
4246
  exitTmuxScrollMode();
4235
4247
  const content = msg.content;
4248
+ currentBotmuxTurnId = msg.turnId;
4249
+ writeCliPidMarker();
4236
4250
  if (lastInitConfig?.adoptMode) {
4237
4251
  // Bridge mode: capture transcript baseline BEFORE writing to the pane,
4238
4252
  // so any assistant uuids appended after this point are attributed to
@@ -4244,7 +4258,7 @@ process.on('message', async (raw) => {
4244
4258
  bridgeIngest();
4245
4259
  }
4246
4260
  catch { /* best effort */ }
4247
- bridgeMarkPendingTurn(content);
4261
+ bridgeMarkPendingTurn(content, msg.turnId);
4248
4262
  }
4249
4263
  else if (codexBridgeFallbackActive()) {
4250
4264
  // Codex adopt: same idea, different bridge. ingest first so any
@@ -4256,7 +4270,7 @@ process.on('message', async (raw) => {
4256
4270
  // before this IPC message is handled. Mark first so that preexisting
4257
4271
  // current-line can still fingerprint-match instead of being marked
4258
4272
  // seen as an unmatched event.
4259
- codexBridgeMarkPendingTurn(content);
4273
+ codexBridgeMarkPendingTurn(content, msg.turnId);
4260
4274
  try {
4261
4275
  codexBridgeIngest();
4262
4276
  }
@@ -4267,7 +4281,7 @@ process.on('message', async (raw) => {
4267
4281
  codexBridgeIngest();
4268
4282
  }
4269
4283
  catch { /* best effort */ }
4270
- codexBridgeMarkPendingTurn(content);
4284
+ codexBridgeMarkPendingTurn(content, msg.turnId);
4271
4285
  }
4272
4286
  }
4273
4287
  // Adopt mode write:
@@ -4326,7 +4340,7 @@ process.on('message', async (raw) => {
4326
4340
  // arrival. Marking now would race with a still-running previous
4327
4341
  // turn whose `botmux send` could sneak its sentAtMs past this
4328
4342
  // turn's markTimeMs and falsely suppress its fallback.
4329
- sendToPty(content);
4343
+ sendToPty(content, msg.turnId);
4330
4344
  }
4331
4345
  break;
4332
4346
  }
@@ -4430,6 +4444,19 @@ process.on('message', async (raw) => {
4430
4444
  cleanup();
4431
4445
  process.exit(0);
4432
4446
  }
4447
+ case 'suspend': {
4448
+ log('Suspend requested');
4449
+ stopScreenshotLoop();
4450
+ stopBridgeWatcher();
4451
+ try {
4452
+ backend?.kill();
4453
+ }
4454
+ catch { /* detach best-effort */ }
4455
+ backend = null;
4456
+ isPromptReady = false;
4457
+ cleanup();
4458
+ process.exit(0);
4459
+ }
4433
4460
  }
4434
4461
  });
4435
4462
  // ─── Cleanup ─────────────────────────────────────────────────────────────────