botmux 3.5.0 → 3.6.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 (505) hide show
  1. package/README.en.md +56 -396
  2. package/README.md +55 -396
  3. package/dist/.runtime-build-id +1 -0
  4. package/dist/adapters/backend/sandbox.d.ts +99 -279
  5. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  6. package/dist/adapters/backend/sandbox.js +424 -826
  7. package/dist/adapters/backend/sandbox.js.map +1 -1
  8. package/dist/adapters/cli/claude-code.d.ts +6 -0
  9. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  10. package/dist/adapters/cli/claude-code.js +197 -11
  11. package/dist/adapters/cli/claude-code.js.map +1 -1
  12. package/dist/adapters/cli/coco.js +4 -4
  13. package/dist/adapters/cli/coco.js.map +1 -1
  14. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  15. package/dist/adapters/cli/codex-app.js +9 -6
  16. package/dist/adapters/cli/codex-app.js.map +1 -1
  17. package/dist/adapters/cli/codex.d.ts.map +1 -1
  18. package/dist/adapters/cli/codex.js +9 -5
  19. package/dist/adapters/cli/codex.js.map +1 -1
  20. package/dist/adapters/cli/fs-policy.d.ts +244 -0
  21. package/dist/adapters/cli/fs-policy.d.ts.map +1 -0
  22. package/dist/adapters/cli/fs-policy.js +537 -0
  23. package/dist/adapters/cli/fs-policy.js.map +1 -0
  24. package/dist/adapters/cli/grok.d.ts +2 -1
  25. package/dist/adapters/cli/grok.d.ts.map +1 -1
  26. package/dist/adapters/cli/grok.js +2 -1
  27. package/dist/adapters/cli/grok.js.map +1 -1
  28. package/dist/adapters/cli/hermes.d.ts.map +1 -1
  29. package/dist/adapters/cli/hermes.js +8 -4
  30. package/dist/adapters/cli/hermes.js.map +1 -1
  31. package/dist/adapters/cli/mtr.d.ts.map +1 -1
  32. package/dist/adapters/cli/mtr.js +3 -2
  33. package/dist/adapters/cli/mtr.js.map +1 -1
  34. package/dist/adapters/cli/opencode.d.ts.map +1 -1
  35. package/dist/adapters/cli/opencode.js +5 -4
  36. package/dist/adapters/cli/opencode.js.map +1 -1
  37. package/dist/adapters/cli/read-isolation.d.ts +14 -200
  38. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  39. package/dist/adapters/cli/read-isolation.js +18 -477
  40. package/dist/adapters/cli/read-isolation.js.map +1 -1
  41. package/dist/adapters/cli/relay.js +2 -2
  42. package/dist/adapters/cli/relay.js.map +1 -1
  43. package/dist/adapters/cli/runner-input.d.ts +2 -2
  44. package/dist/adapters/cli/runner-input.d.ts.map +1 -1
  45. package/dist/adapters/cli/runner-input.js +9 -6
  46. package/dist/adapters/cli/runner-input.js.map +1 -1
  47. package/dist/adapters/cli/seed.d.ts.map +1 -1
  48. package/dist/adapters/cli/seed.js +2 -1
  49. package/dist/adapters/cli/seed.js.map +1 -1
  50. package/dist/adapters/cli/traex.d.ts.map +1 -1
  51. package/dist/adapters/cli/traex.js +3 -2
  52. package/dist/adapters/cli/traex.js.map +1 -1
  53. package/dist/adapters/cli/types.d.ts +25 -18
  54. package/dist/adapters/cli/types.d.ts.map +1 -1
  55. package/dist/bot-registry.d.ts +54 -23
  56. package/dist/bot-registry.d.ts.map +1 -1
  57. package/dist/bot-registry.js +91 -0
  58. package/dist/bot-registry.js.map +1 -1
  59. package/dist/cli.d.ts.map +1 -1
  60. package/dist/cli.js +297 -104
  61. package/dist/cli.js.map +1 -1
  62. package/dist/codex-app-runner.js +85 -192
  63. package/dist/codex-app-runner.js.map +1 -1
  64. package/dist/config.d.ts +0 -16
  65. package/dist/config.d.ts.map +1 -1
  66. package/dist/config.js +0 -30
  67. package/dist/config.js.map +1 -1
  68. package/dist/core/ask-broker.d.ts +12 -0
  69. package/dist/core/ask-broker.d.ts.map +1 -1
  70. package/dist/core/ask-broker.js +41 -0
  71. package/dist/core/ask-broker.js.map +1 -1
  72. package/dist/core/bot-turn-mutation-gate.d.ts +24 -0
  73. package/dist/core/bot-turn-mutation-gate.d.ts.map +1 -0
  74. package/dist/core/bot-turn-mutation-gate.js +369 -0
  75. package/dist/core/bot-turn-mutation-gate.js.map +1 -0
  76. package/dist/core/command-handler.d.ts +5 -0
  77. package/dist/core/command-handler.d.ts.map +1 -1
  78. package/dist/core/command-handler.js +53 -73
  79. package/dist/core/command-handler.js.map +1 -1
  80. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  81. package/dist/core/dashboard-ipc-server.js +356 -84
  82. package/dist/core/dashboard-ipc-server.js.map +1 -1
  83. package/dist/core/dashboard-rows.d.ts +8 -0
  84. package/dist/core/dashboard-rows.d.ts.map +1 -1
  85. package/dist/core/dashboard-rows.js.map +1 -1
  86. package/dist/core/host-overload-alert.d.ts +157 -0
  87. package/dist/core/host-overload-alert.d.ts.map +1 -0
  88. package/dist/core/host-overload-alert.js +251 -0
  89. package/dist/core/host-overload-alert.js.map +1 -0
  90. package/dist/core/passthrough-commands.d.ts +8 -0
  91. package/dist/core/passthrough-commands.d.ts.map +1 -1
  92. package/dist/core/passthrough-commands.js +20 -1
  93. package/dist/core/passthrough-commands.js.map +1 -1
  94. package/dist/core/plugins/mcp/gateway-installer.d.ts.map +1 -1
  95. package/dist/core/plugins/mcp/gateway-installer.js +14 -2
  96. package/dist/core/plugins/mcp/gateway-installer.js.map +1 -1
  97. package/dist/core/restart-coordinator.d.ts +28 -0
  98. package/dist/core/restart-coordinator.d.ts.map +1 -0
  99. package/dist/core/restart-coordinator.js +82 -0
  100. package/dist/core/restart-coordinator.js.map +1 -0
  101. package/dist/core/restart-followup-policy.d.ts +112 -0
  102. package/dist/core/restart-followup-policy.d.ts.map +1 -0
  103. package/dist/core/restart-followup-policy.js +49 -0
  104. package/dist/core/restart-followup-policy.js.map +1 -0
  105. package/dist/core/role-library.d.ts +1 -1
  106. package/dist/core/role-library.js +1 -1
  107. package/dist/core/session-cwd.d.ts.map +1 -1
  108. package/dist/core/session-cwd.js +8 -0
  109. package/dist/core/session-cwd.js.map +1 -1
  110. package/dist/core/session-discovery.d.ts +14 -0
  111. package/dist/core/session-discovery.d.ts.map +1 -1
  112. package/dist/core/session-discovery.js +25 -0
  113. package/dist/core/session-discovery.js.map +1 -1
  114. package/dist/core/session-liveness.d.ts +15 -0
  115. package/dist/core/session-liveness.d.ts.map +1 -0
  116. package/dist/core/session-liveness.js +76 -0
  117. package/dist/core/session-liveness.js.map +1 -0
  118. package/dist/core/session-manager.d.ts.map +1 -1
  119. package/dist/core/session-manager.js +90 -60
  120. package/dist/core/session-manager.js.map +1 -1
  121. package/dist/core/session-row-enrichment.d.ts +15 -0
  122. package/dist/core/session-row-enrichment.d.ts.map +1 -0
  123. package/dist/core/session-row-enrichment.js +93 -0
  124. package/dist/core/session-row-enrichment.js.map +1 -0
  125. package/dist/core/slash-inject.js +1 -1
  126. package/dist/core/slash-inject.js.map +1 -1
  127. package/dist/core/trigger-final-suppression.d.ts +24 -0
  128. package/dist/core/trigger-final-suppression.d.ts.map +1 -0
  129. package/dist/core/trigger-final-suppression.js +97 -0
  130. package/dist/core/trigger-final-suppression.js.map +1 -0
  131. package/dist/core/trigger-session.d.ts.map +1 -1
  132. package/dist/core/trigger-session.js +64 -4
  133. package/dist/core/trigger-session.js.map +1 -1
  134. package/dist/core/types.d.ts +6 -0
  135. package/dist/core/types.d.ts.map +1 -1
  136. package/dist/core/types.js.map +1 -1
  137. package/dist/core/worker-pool.d.ts +44 -0
  138. package/dist/core/worker-pool.d.ts.map +1 -1
  139. package/dist/core/worker-pool.js +333 -38
  140. package/dist/core/worker-pool.js.map +1 -1
  141. package/dist/daemon.d.ts +5 -1
  142. package/dist/daemon.d.ts.map +1 -1
  143. package/dist/daemon.js +526 -35
  144. package/dist/daemon.js.map +1 -1
  145. package/dist/dashboard/aggregator.d.ts +1 -0
  146. package/dist/dashboard/aggregator.d.ts.map +1 -1
  147. package/dist/dashboard/aggregator.js +34 -6
  148. package/dist/dashboard/aggregator.js.map +1 -1
  149. package/dist/dashboard/bot-payload.d.ts +8 -0
  150. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  151. package/dist/dashboard/bot-payload.js +10 -0
  152. package/dist/dashboard/bot-payload.js.map +1 -1
  153. package/dist/dashboard/compat.d.ts +102 -2
  154. package/dist/dashboard/compat.d.ts.map +1 -1
  155. package/dist/dashboard/compat.js +78 -14
  156. package/dist/dashboard/compat.js.map +1 -1
  157. package/dist/dashboard/connector-api.d.ts.map +1 -1
  158. package/dist/dashboard/connector-api.js +1 -0
  159. package/dist/dashboard/connector-api.js.map +1 -1
  160. package/dist/dashboard/desktop-asks.d.ts +21 -0
  161. package/dist/dashboard/desktop-asks.d.ts.map +1 -0
  162. package/dist/dashboard/desktop-asks.js +49 -0
  163. package/dist/dashboard/desktop-asks.js.map +1 -0
  164. package/dist/dashboard/public-redact.d.ts +7 -0
  165. package/dist/dashboard/public-redact.d.ts.map +1 -1
  166. package/dist/dashboard/public-redact.js +35 -3
  167. package/dist/dashboard/public-redact.js.map +1 -1
  168. package/dist/dashboard/session-presentation.d.ts +16 -0
  169. package/dist/dashboard/session-presentation.d.ts.map +1 -0
  170. package/dist/dashboard/session-presentation.js +62 -0
  171. package/dist/dashboard/session-presentation.js.map +1 -0
  172. package/dist/dashboard/settings-write-applier.d.ts +2 -1
  173. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  174. package/dist/dashboard/settings-write-applier.js +19 -1
  175. package/dist/dashboard/settings-write-applier.js.map +1 -1
  176. package/dist/dashboard/trigger-api.d.ts.map +1 -1
  177. package/dist/dashboard/trigger-api.js +19 -1
  178. package/dist/dashboard/trigger-api.js.map +1 -1
  179. package/dist/dashboard/vc-consumer-profiles-api.d.ts +7 -2
  180. package/dist/dashboard/vc-consumer-profiles-api.d.ts.map +1 -1
  181. package/dist/dashboard/vc-consumer-profiles-api.js +13 -0
  182. package/dist/dashboard/vc-consumer-profiles-api.js.map +1 -1
  183. package/dist/dashboard/web/app.d.ts.map +1 -1
  184. package/dist/dashboard/web/app.js +18 -5
  185. package/dist/dashboard/web/app.js.map +1 -1
  186. package/dist/dashboard/web/bot-defaults-page.d.ts +16 -0
  187. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  188. package/dist/dashboard/web/bot-defaults-page.js +288 -7
  189. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  190. package/dist/dashboard/web/bot-defaults.d.ts +10 -0
  191. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  192. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  193. package/dist/dashboard/web/client-shell.d.ts +29 -0
  194. package/dist/dashboard/web/client-shell.d.ts.map +1 -0
  195. package/dist/dashboard/web/client-shell.js +89 -0
  196. package/dist/dashboard/web/client-shell.js.map +1 -0
  197. package/dist/dashboard/web/connectors-page.d.ts.map +1 -1
  198. package/dist/dashboard/web/connectors-page.js +5 -1
  199. package/dist/dashboard/web/connectors-page.js.map +1 -1
  200. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  201. package/dist/dashboard/web/i18n.js +130 -22
  202. package/dist/dashboard/web/i18n.js.map +1 -1
  203. package/dist/dashboard/web/preferences.d.ts +7 -0
  204. package/dist/dashboard/web/preferences.d.ts.map +1 -1
  205. package/dist/dashboard/web/preferences.js +32 -0
  206. package/dist/dashboard/web/preferences.js.map +1 -1
  207. package/dist/dashboard/web/sessions-kanban.d.ts +1 -1
  208. package/dist/dashboard/web/sessions-kanban.d.ts.map +1 -1
  209. package/dist/dashboard/web/sessions-kanban.js +1 -1
  210. package/dist/dashboard/web/sessions-kanban.js.map +1 -1
  211. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  212. package/dist/dashboard/web/sessions-page.js +78 -67
  213. package/dist/dashboard/web/sessions-page.js.map +1 -1
  214. package/dist/dashboard/web/sessions.d.ts +4 -0
  215. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  216. package/dist/dashboard/web/sessions.js +14 -1
  217. package/dist/dashboard/web/sessions.js.map +1 -1
  218. package/dist/dashboard/web/settings-page.d.ts +6 -0
  219. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  220. package/dist/dashboard/web/settings-page.js +55 -30
  221. package/dist/dashboard/web/settings-page.js.map +1 -1
  222. package/dist/dashboard/web/store.d.ts +1 -2
  223. package/dist/dashboard/web/store.d.ts.map +1 -1
  224. package/dist/dashboard/web/store.js +69 -20
  225. package/dist/dashboard/web/store.js.map +1 -1
  226. package/dist/dashboard/web/vc-consumer-profiles-section.d.ts.map +1 -1
  227. package/dist/dashboard/web/vc-consumer-profiles-section.js +128 -42
  228. package/dist/dashboard/web/vc-consumer-profiles-section.js.map +1 -1
  229. package/dist/dashboard/webhook-routes.d.ts.map +1 -1
  230. package/dist/dashboard/webhook-routes.js +9 -2
  231. package/dist/dashboard/webhook-routes.js.map +1 -1
  232. package/dist/dashboard-web/app.js +1 -1
  233. package/dist/dashboard-web/chunks/bot-defaults-page-VQMPUJ4Y.js +10 -0
  234. package/dist/dashboard-web/chunks/chunk-AGCPF6MT.js +1 -0
  235. package/dist/dashboard-web/chunks/{chunk-QCPPKCX3.js → chunk-JMRGZQDB.js} +1 -1
  236. package/dist/dashboard-web/chunks/{chunk-EJDYUINZ.js → chunk-NP4JUBGS.js} +1 -1
  237. package/dist/dashboard-web/chunks/{chunk-WXPMNEYV.js → chunk-P327LXOT.js} +1 -1
  238. package/dist/dashboard-web/chunks/chunk-SNKATZWK.js +49 -0
  239. package/dist/dashboard-web/chunks/connectors-page-DOY4J3ZL.js +1 -0
  240. package/dist/dashboard-web/chunks/{groups-page-3P4TLUMF.js → groups-page-74TS2DIH.js} +1 -1
  241. package/dist/dashboard-web/chunks/{insights-page-TDCZV56A.js → insights-page-AYCO65JC.js} +1 -1
  242. package/dist/dashboard-web/chunks/{monitor-room-QWCWL3SX.js → monitor-room-YKYLTSAN.js} +1 -1
  243. package/dist/dashboard-web/chunks/{monitoring-page-IOWFSNVC.js → monitoring-page-QBIUNRBR.js} +1 -1
  244. package/dist/dashboard-web/chunks/{office-page-5AUPEHZP.js → office-page-4PH2A26P.js} +1 -1
  245. package/dist/dashboard-web/chunks/overview-page-H73D5UT4.js +1 -0
  246. package/dist/dashboard-web/chunks/{roles-page-WJVB52ET.js → roles-page-U4BTOQ7F.js} +1 -1
  247. package/dist/dashboard-web/chunks/{schedules-page-RVRWHP4V.js → schedules-page-RHRHX7R7.js} +1 -1
  248. package/dist/dashboard-web/chunks/sessions-page-VSC6AM5G.js +2 -0
  249. package/dist/dashboard-web/chunks/settings-page-LF3RKM5P.js +2 -0
  250. package/dist/dashboard-web/chunks/{skills-page-7YT5TIX5.js → skills-page-SYOIGGXH.js} +1 -1
  251. package/dist/dashboard-web/chunks/{team-federation-page-NJZKTU7A.js → team-federation-page-RMBLASTW.js} +1 -1
  252. package/dist/dashboard-web/chunks/{v3-page-NQ73UA2Q.js → v3-page-XJXKKJRN.js} +1 -1
  253. package/dist/dashboard-web/chunks/{whiteboards-page-DSM3MDMN.js → whiteboards-page-K3YVSTHJ.js} +1 -1
  254. package/dist/dashboard-web/style.css +571 -3
  255. package/dist/dashboard.js +238 -9
  256. package/dist/dashboard.js.map +1 -1
  257. package/dist/global-config.d.ts +11 -0
  258. package/dist/global-config.d.ts.map +1 -1
  259. package/dist/global-config.js +27 -1
  260. package/dist/global-config.js.map +1 -1
  261. package/dist/i18n/en.d.ts.map +1 -1
  262. package/dist/i18n/en.js +6 -19
  263. package/dist/i18n/en.js.map +1 -1
  264. package/dist/i18n/zh.d.ts.map +1 -1
  265. package/dist/i18n/zh.js +6 -19
  266. package/dist/i18n/zh.js.map +1 -1
  267. package/dist/im/lark/card-builder.d.ts +1 -14
  268. package/dist/im/lark/card-builder.d.ts.map +1 -1
  269. package/dist/im/lark/card-builder.js +1 -35
  270. package/dist/im/lark/card-builder.js.map +1 -1
  271. package/dist/im/lark/card-handler.d.ts +10 -0
  272. package/dist/im/lark/card-handler.d.ts.map +1 -1
  273. package/dist/im/lark/card-handler.js +161 -44
  274. package/dist/im/lark/card-handler.js.map +1 -1
  275. package/dist/im/lark/client.d.ts +47 -0
  276. package/dist/im/lark/client.d.ts.map +1 -1
  277. package/dist/im/lark/client.js +127 -18
  278. package/dist/im/lark/client.js.map +1 -1
  279. package/dist/im/lark/event-dispatcher.d.ts +13 -0
  280. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  281. package/dist/im/lark/event-dispatcher.js +48 -3
  282. package/dist/im/lark/event-dispatcher.js.map +1 -1
  283. package/dist/im/lark/identity-cache.d.ts +25 -1
  284. package/dist/im/lark/identity-cache.d.ts.map +1 -1
  285. package/dist/im/lark/identity-cache.js +47 -1
  286. package/dist/im/lark/identity-cache.js.map +1 -1
  287. package/dist/im/lark/overload-nonce.d.ts +19 -0
  288. package/dist/im/lark/overload-nonce.d.ts.map +1 -0
  289. package/dist/im/lark/overload-nonce.js +72 -0
  290. package/dist/im/lark/overload-nonce.js.map +1 -0
  291. package/dist/im/lark/v3-progress-card.d.ts.map +1 -1
  292. package/dist/im/lark/v3-progress-card.js +5 -0
  293. package/dist/im/lark/v3-progress-card.js.map +1 -1
  294. package/dist/services/async-trigger-state.d.ts +82 -0
  295. package/dist/services/async-trigger-state.d.ts.map +1 -0
  296. package/dist/services/async-trigger-state.js +95 -0
  297. package/dist/services/async-trigger-state.js.map +1 -0
  298. package/dist/services/async-trigger-store.d.ts +28 -0
  299. package/dist/services/async-trigger-store.d.ts.map +1 -0
  300. package/dist/services/async-trigger-store.js +120 -0
  301. package/dist/services/async-trigger-store.js.map +1 -0
  302. package/dist/services/bot-config-store.d.ts.map +1 -1
  303. package/dist/services/bot-config-store.js +34 -2
  304. package/dist/services/bot-config-store.js.map +1 -1
  305. package/dist/services/bridge-fallback-gate.d.ts +32 -0
  306. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  307. package/dist/services/bridge-fallback-gate.js +39 -0
  308. package/dist/services/bridge-fallback-gate.js.map +1 -1
  309. package/dist/services/bridge-turn-queue.d.ts.map +1 -1
  310. package/dist/services/bridge-turn-queue.js +15 -1
  311. package/dist/services/bridge-turn-queue.js.map +1 -1
  312. package/dist/services/card-prefs-store.d.ts +4 -0
  313. package/dist/services/card-prefs-store.d.ts.map +1 -1
  314. package/dist/services/card-prefs-store.js +8 -0
  315. package/dist/services/card-prefs-store.js.map +1 -1
  316. package/dist/services/claude-transcript.d.ts +23 -0
  317. package/dist/services/claude-transcript.d.ts.map +1 -1
  318. package/dist/services/claude-transcript.js +33 -0
  319. package/dist/services/claude-transcript.js.map +1 -1
  320. package/dist/services/codex-app-runner-protocol.d.ts +61 -0
  321. package/dist/services/codex-app-runner-protocol.d.ts.map +1 -0
  322. package/dist/services/codex-app-runner-protocol.js +170 -0
  323. package/dist/services/codex-app-runner-protocol.js.map +1 -0
  324. package/dist/services/codex-app-turn-controller.d.ts +72 -0
  325. package/dist/services/codex-app-turn-controller.d.ts.map +1 -0
  326. package/dist/services/codex-app-turn-controller.js +524 -0
  327. package/dist/services/codex-app-turn-controller.js.map +1 -0
  328. package/dist/services/codex-runner-freshness.d.ts +39 -0
  329. package/dist/services/codex-runner-freshness.d.ts.map +1 -0
  330. package/dist/services/codex-runner-freshness.js +78 -0
  331. package/dist/services/codex-runner-freshness.js.map +1 -0
  332. package/dist/services/connector-store.d.ts +6 -0
  333. package/dist/services/connector-store.d.ts.map +1 -1
  334. package/dist/services/connector-store.js.map +1 -1
  335. package/dist/services/grant-store.d.ts.map +1 -1
  336. package/dist/services/grant-store.js +16 -0
  337. package/dist/services/grant-store.js.map +1 -1
  338. package/dist/services/hermes-transcript.d.ts +1 -0
  339. package/dist/services/hermes-transcript.d.ts.map +1 -1
  340. package/dist/services/hermes-transcript.js +49 -0
  341. package/dist/services/hermes-transcript.js.map +1 -1
  342. package/dist/services/sandbox-migration.d.ts +14 -0
  343. package/dist/services/sandbox-migration.d.ts.map +1 -0
  344. package/dist/services/sandbox-migration.js +85 -0
  345. package/dist/services/sandbox-migration.js.map +1 -0
  346. package/dist/services/sandbox-store.d.ts +28 -0
  347. package/dist/services/sandbox-store.d.ts.map +1 -1
  348. package/dist/services/sandbox-store.js +82 -0
  349. package/dist/services/sandbox-store.js.map +1 -1
  350. package/dist/services/schedule-split-migration.d.ts +2 -0
  351. package/dist/services/schedule-split-migration.d.ts.map +1 -0
  352. package/dist/services/schedule-split-migration.js +159 -0
  353. package/dist/services/schedule-split-migration.js.map +1 -0
  354. package/dist/services/schedule-store.d.ts +28 -4
  355. package/dist/services/schedule-store.d.ts.map +1 -1
  356. package/dist/services/schedule-store.js +118 -33
  357. package/dist/services/schedule-store.js.map +1 -1
  358. package/dist/services/trigger-types.d.ts +18 -1
  359. package/dist/services/trigger-types.d.ts.map +1 -1
  360. package/dist/services/trigger-types.js +3 -0
  361. package/dist/services/trigger-types.js.map +1 -1
  362. package/dist/services/vc-meeting-consumer-profile-store.d.ts.map +1 -1
  363. package/dist/services/vc-meeting-consumer-profile-store.js +1 -0
  364. package/dist/services/vc-meeting-consumer-profile-store.js.map +1 -1
  365. package/dist/services/vc-meeting-consumer-profile-templates.d.ts +32 -0
  366. package/dist/services/vc-meeting-consumer-profile-templates.d.ts.map +1 -0
  367. package/dist/services/vc-meeting-consumer-profile-templates.js +106 -0
  368. package/dist/services/vc-meeting-consumer-profile-templates.js.map +1 -0
  369. package/dist/services/vc-meeting-delivery-feed.d.ts +1 -1
  370. package/dist/services/vc-meeting-delivery-feed.d.ts.map +1 -1
  371. package/dist/services/vc-meeting-delivery-feed.js +2 -1
  372. package/dist/services/vc-meeting-delivery-feed.js.map +1 -1
  373. package/dist/services/vc-meeting-delivery-hub-store.d.ts +3 -1
  374. package/dist/services/vc-meeting-delivery-hub-store.d.ts.map +1 -1
  375. package/dist/services/vc-meeting-delivery-hub-store.js +11 -0
  376. package/dist/services/vc-meeting-delivery-hub-store.js.map +1 -1
  377. package/dist/services/vc-meeting-delivery-protocol.d.ts +9 -2
  378. package/dist/services/vc-meeting-delivery-protocol.d.ts.map +1 -1
  379. package/dist/services/vc-meeting-delivery-protocol.js +10 -1
  380. package/dist/services/vc-meeting-delivery-protocol.js.map +1 -1
  381. package/dist/services/vc-meeting-delivery-receiver.d.ts.map +1 -1
  382. package/dist/services/vc-meeting-delivery-receiver.js +10 -1
  383. package/dist/services/vc-meeting-delivery-receiver.js.map +1 -1
  384. package/dist/services/vc-meeting-delivery-store.d.ts +12 -2
  385. package/dist/services/vc-meeting-delivery-store.d.ts.map +1 -1
  386. package/dist/services/vc-meeting-delivery-store.js +14 -2
  387. package/dist/services/vc-meeting-delivery-store.js.map +1 -1
  388. package/dist/services/vc-meeting-im-reply.d.ts +2 -1
  389. package/dist/services/vc-meeting-im-reply.d.ts.map +1 -1
  390. package/dist/services/vc-meeting-im-reply.js +19 -3
  391. package/dist/services/vc-meeting-im-reply.js.map +1 -1
  392. package/dist/services/vc-meeting-listener-output-protocol.d.ts +27 -0
  393. package/dist/services/vc-meeting-listener-output-protocol.d.ts.map +1 -0
  394. package/dist/services/vc-meeting-listener-output-protocol.js +58 -0
  395. package/dist/services/vc-meeting-listener-output-protocol.js.map +1 -0
  396. package/dist/services/vc-meeting-listener-topic-store.d.ts +31 -0
  397. package/dist/services/vc-meeting-listener-topic-store.d.ts.map +1 -0
  398. package/dist/services/vc-meeting-listener-topic-store.js +120 -0
  399. package/dist/services/vc-meeting-listener-topic-store.js.map +1 -0
  400. package/dist/services/vc-meeting-runtime-store.d.ts +2 -1
  401. package/dist/services/vc-meeting-runtime-store.d.ts.map +1 -1
  402. package/dist/services/vc-meeting-runtime-store.js +8 -0
  403. package/dist/services/vc-meeting-runtime-store.js.map +1 -1
  404. package/dist/services/vc-meeting-send-policy.d.ts +8 -1
  405. package/dist/services/vc-meeting-send-policy.d.ts.map +1 -1
  406. package/dist/services/vc-meeting-send-policy.js +8 -0
  407. package/dist/services/vc-meeting-send-policy.js.map +1 -1
  408. package/dist/types.d.ts +53 -4
  409. package/dist/types.d.ts.map +1 -1
  410. package/dist/utils/allowed-users-apply.d.ts +76 -0
  411. package/dist/utils/allowed-users-apply.d.ts.map +1 -0
  412. package/dist/utils/allowed-users-apply.js +123 -0
  413. package/dist/utils/allowed-users-apply.js.map +1 -0
  414. package/dist/utils/allowed-users-cache.d.ts +29 -0
  415. package/dist/utils/allowed-users-cache.d.ts.map +1 -0
  416. package/dist/utils/allowed-users-cache.js +82 -0
  417. package/dist/utils/allowed-users-cache.js.map +1 -0
  418. package/dist/utils/child-env.d.ts +4 -3
  419. package/dist/utils/child-env.d.ts.map +1 -1
  420. package/dist/utils/child-env.js +16 -1
  421. package/dist/utils/child-env.js.map +1 -1
  422. package/dist/utils/cli-usage-limit.d.ts +27 -1
  423. package/dist/utils/cli-usage-limit.d.ts.map +1 -1
  424. package/dist/utils/cli-usage-limit.js +92 -20
  425. package/dist/utils/cli-usage-limit.js.map +1 -1
  426. package/dist/utils/input-gate.d.ts +2 -0
  427. package/dist/utils/input-gate.d.ts.map +1 -1
  428. package/dist/utils/input-gate.js +2 -0
  429. package/dist/utils/input-gate.js.map +1 -1
  430. package/dist/utils/runtime-build-id.d.ts +21 -0
  431. package/dist/utils/runtime-build-id.d.ts.map +1 -0
  432. package/dist/utils/runtime-build-id.js +83 -0
  433. package/dist/utils/runtime-build-id.js.map +1 -0
  434. package/dist/utils/terminal-renderer.d.ts +3 -3
  435. package/dist/utils/terminal-renderer.js +3 -3
  436. package/dist/vc-agent/cards.d.ts.map +1 -1
  437. package/dist/vc-agent/cards.js +21 -1
  438. package/dist/vc-agent/cards.js.map +1 -1
  439. package/dist/worker.js +1010 -669
  440. package/dist/worker.js.map +1 -1
  441. package/dist/workflows/events/payloads.d.ts +56 -0
  442. package/dist/workflows/events/payloads.d.ts.map +1 -1
  443. package/dist/workflows/events/payloads.js +9 -0
  444. package/dist/workflows/events/payloads.js.map +1 -1
  445. package/dist/workflows/events/schema.d.ts +86 -0
  446. package/dist/workflows/events/schema.d.ts.map +1 -1
  447. package/dist/workflows/hostExecutors/botmux-schedule.d.ts.map +1 -1
  448. package/dist/workflows/hostExecutors/botmux-schedule.js +13 -1
  449. package/dist/workflows/hostExecutors/botmux-schedule.js.map +1 -1
  450. package/dist/workflows/migration/v2-run-archive-schema.d.ts +20 -20
  451. package/dist/workflows/migration/v2-to-v3.d.ts.map +1 -1
  452. package/dist/workflows/migration/v2-to-v3.js +5 -1
  453. package/dist/workflows/migration/v2-to-v3.js.map +1 -1
  454. package/dist/workflows/shared/sandbox-policy.d.ts +10 -1
  455. package/dist/workflows/shared/sandbox-policy.d.ts.map +1 -1
  456. package/dist/workflows/shared/sandbox-policy.js +12 -0
  457. package/dist/workflows/shared/sandbox-policy.js.map +1 -1
  458. package/dist/workflows/v3/architect.d.ts.map +1 -1
  459. package/dist/workflows/v3/architect.js +1 -0
  460. package/dist/workflows/v3/architect.js.map +1 -1
  461. package/dist/workflows/v3/bot-resolve.d.ts.map +1 -1
  462. package/dist/workflows/v3/bot-resolve.js +36 -0
  463. package/dist/workflows/v3/bot-resolve.js.map +1 -1
  464. package/dist/workflows/v3/cli-run.d.ts +9 -0
  465. package/dist/workflows/v3/cli-run.d.ts.map +1 -1
  466. package/dist/workflows/v3/cli-run.js +17 -2
  467. package/dist/workflows/v3/cli-run.js.map +1 -1
  468. package/dist/workflows/v3/contract.d.ts +8 -0
  469. package/dist/workflows/v3/contract.d.ts.map +1 -1
  470. package/dist/workflows/v3/contract.js.map +1 -1
  471. package/dist/workflows/v3/goal-cli.d.ts +58 -0
  472. package/dist/workflows/v3/goal-cli.d.ts.map +1 -0
  473. package/dist/workflows/v3/goal-cli.js +430 -0
  474. package/dist/workflows/v3/goal-cli.js.map +1 -0
  475. package/dist/workflows/v3/library-materialize.d.ts.map +1 -1
  476. package/dist/workflows/v3/library-materialize.js +5 -1
  477. package/dist/workflows/v3/library-materialize.js.map +1 -1
  478. package/dist/workflows/v3/library-schema.d.ts +21 -0
  479. package/dist/workflows/v3/library-schema.d.ts.map +1 -1
  480. package/dist/workflows/v3/library-schema.js +95 -1
  481. package/dist/workflows/v3/library-schema.js.map +1 -1
  482. package/dist/workflows/v3/progress-projection.d.ts +3 -0
  483. package/dist/workflows/v3/progress-projection.d.ts.map +1 -1
  484. package/dist/workflows/v3/progress-projection.js +23 -1
  485. package/dist/workflows/v3/progress-projection.js.map +1 -1
  486. package/dist/workflows/v3/run-envelope.d.ts +46 -1
  487. package/dist/workflows/v3/run-envelope.d.ts.map +1 -1
  488. package/dist/workflows/v3/run-envelope.js +32 -1
  489. package/dist/workflows/v3/run-envelope.js.map +1 -1
  490. package/package.json +2 -2
  491. package/dist/dashboard-web/chunks/bot-defaults-page-O376WSER.js +0 -5
  492. package/dist/dashboard-web/chunks/chunk-H7P5UV4W.js +0 -49
  493. package/dist/dashboard-web/chunks/chunk-SZOB6VK2.js +0 -1
  494. package/dist/dashboard-web/chunks/connectors-page-IR54AOAI.js +0 -1
  495. package/dist/dashboard-web/chunks/overview-page-QJMI6NLW.js +0 -1
  496. package/dist/dashboard-web/chunks/sessions-page-KWLGBX6G.js +0 -3
  497. package/dist/dashboard-web/chunks/settings-page-TNZEPC7V.js +0 -2
  498. package/dist/services/sandbox-land.d.ts +0 -45
  499. package/dist/services/sandbox-land.d.ts.map +0 -1
  500. package/dist/services/sandbox-land.js +0 -394
  501. package/dist/services/sandbox-land.js.map +0 -1
  502. package/dist/utils/screen-analyzer.d.ts +0 -75
  503. package/dist/utils/screen-analyzer.d.ts.map +0 -1
  504. package/dist/utils/screen-analyzer.js +0 -333
  505. package/dist/utils/screen-analyzer.js.map +0 -1
package/dist/worker.js CHANGED
@@ -18,14 +18,17 @@ import { atomicWriteFileSync } from './utils/atomic-write.js';
18
18
  import { join, basename, dirname, delimiter } from 'node:path';
19
19
  import { homedir, tmpdir } from 'node:os';
20
20
  import { spawnSync } from 'node:child_process';
21
- import { evaluateReadIsolationGate, evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildV2DenyPaths, buildV2DenyRegexes, buildV2CarveOuts, buildReadIsolationProtectedWriteRules, buildCliExecutableReadCarveOuts, buildWriteSandboxRules, buildLinuxReadIsolationMasks, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
21
+ import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, } from './adapters/cli/read-isolation.js';
22
+ import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields } from './adapters/cli/fs-policy.js';
22
23
  import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget } from './core/persistent-backend.js';
23
24
  import { readProcessStartIdentity } from './core/session-marker.js';
24
- import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff } from './services/claude-transcript.js';
25
+ import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, extractTurnStartText, splitTranscriptEventsByCutoff, isTranscriptRateLimitEvent, apiErrorMessageText } from './services/claude-transcript.js';
25
26
  import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js';
26
- import { shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
27
+ import { shouldEmitEmptyCompletedBridgeFallback, shouldSuppressBridgeEmit } from './services/bridge-fallback-gate.js';
27
28
  import { decideHardTimeoutAction, decideSettleMarkReady, shouldReleaseFirstPromptTimeout, shouldWaitForPostSessionStartPromptEvidence, shouldWriteNow, } from './utils/input-gate.js';
29
+ import { decideCodexRunnerFreshness, CodexRunnerFreshnessInputQueue, shouldHoldCodexRunnerInput, } from './services/codex-runner-freshness.js';
28
30
  import { canStartInjectionFlush, shouldDeferUserFlush, shouldFlushInjectionsFirst } from './core/inject-queue-policy.js';
31
+ import { decideRestartFollowup, settleDurableTurnForRestart } from './core/restart-followup-policy.js';
29
32
  import { stripAnsiForLog, tailChars } from './utils/crash-log.js';
30
33
  import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
31
34
  import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
@@ -40,7 +43,7 @@ import { sessionMcpGatewayPathRegex, startSessionMcpGatewayHost, } from './core/
40
43
  import { MCP_GATEWAY_REQUIRED_ENV, MCP_GATEWAY_SOCKET_ENV, } from './core/plugins/mcp/environment.js';
41
44
  import { readSessionMcpRuntimeManifest, sessionMcpRuntimeHostOnlyPaths, } from './core/plugins/mcp/session-runtime.js';
42
45
  import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js';
43
- import { loadBotConfigs } from './bot-registry.js';
46
+ import { loadBotConfigs, resolveBrandLabel } from './bot-registry.js';
44
47
  import { readGlobalConfig } from './global-config.js';
45
48
  import { deriveTerminalViewToken, deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
46
49
  import { readPlatformBinding } from './platform/binding.js';
@@ -65,6 +68,7 @@ import { drainCursorTranscript, findCursorChatIdByPid, findCursorTranscriptByCha
65
68
  import { shouldObserveCursorChatId, shouldPersistObservedCursorChatId } from './services/cursor-resume-policy.js';
66
69
  import { extractKiroSessionIdFromOutput } from './services/kiro-session.js';
67
70
  import { baselineJsonlCursor } from './services/jsonl-cursor.js';
71
+ import { fileURLToPath } from 'node:url';
68
72
  import { createServer as createHttpServer } from 'node:http';
69
73
  import { WebSocketServer, WebSocket } from 'ws';
70
74
  import { listenWebTerminalWithFallback } from './utils/web-terminal-listen.js';
@@ -76,7 +80,7 @@ import { DEFAULT_RENDER_COLS, DEFAULT_RENDER_ROWS, MAX_RENDER_COLS, MAX_RENDER_R
76
80
  import { createCliAdapterSync, locateOnPath } from './adapters/cli/registry.js';
77
81
  import { buildWrappedLaunch, parseWrapperCli, isTtadkWrapper } from './setup/cli-selection.js';
78
82
  import { cliUnavailableMessage } from './setup/cli-availability.js';
79
- import { findLaunchedCliPid, scheduleWrapperRealCliPid, readComm, isBareShellComm, bareShellLaunchKind } from './core/session-discovery.js';
83
+ import { findLaunchedCliPid, scheduleWrapperRealCliPid, readComm, isBareShellComm, bareShellLaunchKind, settleLaunchComm, } from './core/session-discovery.js';
80
84
  import { codexRpcEligible, paneRunsRemoteTui, orchestrateCodexRpcInit, rolloutUserTurnMatches, decideStartupDialogAction, shouldQueueInitialPrompt, shouldPreMarkFirstTurn, killAndVerifyPersistentPane } from './codex-rpc-lifecycle.js';
81
85
  import { delay } from './utils/timing.js';
82
86
  import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, syncClaudeResumeTargetToCwd, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js';
@@ -92,12 +96,11 @@ import { isObserveBackend } from './adapters/backend/types.js';
92
96
  import { selectSessionBackend, decideBackendGate, backendGateUserMessage } from './adapters/backend/session-backend-selector.js';
93
97
  import { buildReproduceCommand, selectReproduceLaunch } from './adapters/backend/reproduce-command.js';
94
98
  import { deriveRiffReposFromDirs, deriveRiffRepoFromWorkingDir, isValidRiffBaseUrl, isValidRiffSandboxCluster, } from './adapters/backend/riff-backend.js';
95
- import { prepareSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, sandboxedClaudeDataDir, localSandboxApplies, } from './adapters/backend/sandbox.js';
99
+ import { prepareDirectSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, localSandboxApplies, } from './adapters/backend/sandbox.js';
96
100
  import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js';
97
101
  import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js';
98
102
  import { tmuxRestartJitterMs } from './core/tmux-recovery.js';
99
103
  import { IdleDetector } from './utils/idle-detector.js';
100
- import { ScreenAnalyzer } from './utils/screen-analyzer.js';
101
104
  import { StuckDetector, matchHookReviewScreen } from './utils/stuck-detector.js';
102
105
  import { processStuckWarningTuiKeys, shouldRearmStuckDetector } from './utils/stuck-key-guard.js';
103
106
  import { captureToPng } from './utils/screenshot-renderer.js';
@@ -105,7 +108,7 @@ import { snapshotToPng, snapshotToText, shouldCaptureScreen, isScreenSelfDriven
105
108
  import { chooseWebTerminalSeed } from './utils/web-terminal-seed.js';
106
109
  import { mergeHerdrWebSnapshot, renderHerdrWebHistory, } from './utils/herdr-web-history.js';
107
110
  import { parseWorkerRequestUrl } from './utils/worker-http.js';
108
- import { detectCliUsageLimit, usageLimitStateKey } from './utils/cli-usage-limit.js';
111
+ import { detectCliUsageLimit, usageLimitStateKey, structuredRateLimitState } from './utils/cli-usage-limit.js';
109
112
  import { uploadImageBuffer } from './utils/lark-upload.js';
110
113
  import { redactChildEnv, scrubSessionCliHomeEnv } from './utils/child-env.js';
111
114
  import { decideSubmitConfirmationAction } from './services/submit-confirmation.js';
@@ -120,6 +123,7 @@ import { fetchDaemonIpc } from './core/daemon-ipc-auth.js';
120
123
  import { withCodexAppContext } from './utils/codex-app-context.js';
121
124
  import { resolveCodexAppFinalTurnIdentity } from './adapters/cli/codex-app-turn.js';
122
125
  import { RunnerControlDecoder } from './adapters/cli/runner-control-channel.js';
126
+ import { normalizeAppRunnerFinalMarker, normalizeCodexAppLifecycleEvent, projectAppRunnerFinalIds, } from './services/codex-app-runner-protocol.js';
123
127
  import { hasMatchingManagedOriginCapability, managedOriginCapabilityPath, RELAY_ORIGIN_CAPABILITY_BASENAME, replaceManagedOriginCapabilityFile, } from './core/managed-origin-capability.js';
124
128
  import { CodexRpcEngine } from './codex-rpc-engine.js';
125
129
  // A worker must never trust an INHERITED session-level CLI home pointer
@@ -690,7 +694,7 @@ function armRpcStartupDialogDismiss() {
690
694
  let cliPidMarker = null; // path to .botmux-cli-pids/<pid>
691
695
  let seatbeltProfilePath = null; // per-session Seatbelt .sb profile to rm at exit (external-wrapper read isolation)
692
696
  let sandboxStopWatcher = null; // stop fn for the sandbox outbox watcher
693
- let sandboxCleanup = null; // unmount overlays + rm the per-session sandbox tree
697
+ let sandboxCleanup = null; // reclaim deny-mask mountpoints + rm the per-session sandbox tree
694
698
  let sandboxRelayOutbox = null;
695
699
  let sandboxRelayCapability = null;
696
700
  let readIsolationOriginCapabilityFile = null;
@@ -953,6 +957,12 @@ const IDLE_PROBE_INTERVAL_MS = 3_500;
953
957
  const IDLE_PROBE_MAX_ATTEMPTS = 24;
954
958
  let busyPatternIdleProbeTimer = null;
955
959
  let reattachIdleProbeTimer = null;
960
+ let codexRunnerFreshness = 'current';
961
+ let persistCodexRunnerBuildOnReady = false;
962
+ let activeRestartAttemptId;
963
+ /** Distinguishes a replacement's synchronous ready signal (Riff) from a late
964
+ * idle callback emitted by the backend being torn down. */
965
+ let replacementSpawnInProgress = false;
956
966
  /** The effectiveResume flag used by the most recent spawnCli call. Written
957
967
  * immediately after the two-tier fallback check so late-attach timers
958
968
  * (hermes, cursor, etc.) can read THE SAME semantics the spawn used,
@@ -1104,6 +1114,17 @@ let bareShellLaunchBlocked = false;
1104
1114
  * one-shot so it also covers a reattach onto a pane that degraded to a bare
1105
1115
  * shell. Reset per spawn in spawnCli. */
1106
1116
  let bareShellChecked = false;
1117
+ /** True only while detectBareShellLaunch() is inside its async launch-settle
1118
+ * window (settleLaunchComm's bounded ≤2s poll for a wrapper's final `exec
1119
+ * <cli>`). The message/injection flush paths already hold the isFlushing /
1120
+ * injectionFlushing mutexes across that await, but raw_input (passthrough
1121
+ * slash commands: /compact, /model, /btw) deliberately bypasses those to
1122
+ * preserve busy-delivery — so it would otherwise type into a pane whose leaf
1123
+ * is still the transient shell (or a shell already about to be blocked). This
1124
+ * latch lets raw_input defer for exactly that window without borrowing the
1125
+ * general isFlushing mutex (which would wrongly also block busy-delivery when
1126
+ * no settle is in progress). Reset per spawn in spawnCli. */
1127
+ let bareShellCheckInProgress = false;
1107
1128
  /** Ready-gate (Claude-family): holds the first prompt until the SessionStart
1108
1129
  * hook proves a cjadk-style startup selector is behind us. Claude then needs
1109
1130
  * fresh post-hook prompt evidence because sibling hooks may still be running.
@@ -1342,11 +1363,42 @@ async function runStartupCommands() {
1342
1363
  isPromptReady = false;
1343
1364
  idleDetector?.reset();
1344
1365
  }
1345
- const pendingMessages = [];
1366
+ const freshnessInputQueue = new CodexRunnerFreshnessInputQueue(() => codexRunnerFreshness, state => { codexRunnerFreshness = state; });
1367
+ const pendingMessages = freshnessInputQueue.normal;
1368
+ /** Correlation ids that this worker actually wrote into the owned Codex App
1369
+ * runner. Runner lifecycle/final markers may route only to ids in this bounded
1370
+ * local set; model/user display bytes cannot add entries. */
1371
+ const submittedCodexAppReplyTurnIds = new Set();
1372
+ const pendingCodexAppSteerAckIds = new Map();
1373
+ const acknowledgedCodexAppSteers = new Set();
1374
+ const CODEX_APP_CORRELATION_LIMIT = 256;
1375
+ function rememberBounded(set, value) {
1376
+ set.delete(value);
1377
+ set.add(value);
1378
+ while (set.size > CODEX_APP_CORRELATION_LIMIT) {
1379
+ const oldest = set.values().next().value;
1380
+ if (typeof oldest !== 'string')
1381
+ break;
1382
+ set.delete(oldest);
1383
+ }
1384
+ }
1385
+ function rememberBoundedMap(map, key, value) {
1386
+ map.delete(key);
1387
+ map.set(key, value);
1388
+ while (map.size > CODEX_APP_CORRELATION_LIMIT) {
1389
+ const oldest = map.keys().next().value;
1390
+ if (typeof oldest !== 'string')
1391
+ break;
1392
+ map.delete(oldest);
1393
+ }
1394
+ }
1395
+ function shortCorrelationId(value) {
1396
+ return value?.slice(0, 12) ?? '-';
1397
+ }
1346
1398
  /** Literal commands that arrived while native /rename owned the TUI or while
1347
1399
  * an owned CLI restart was fenced. Normal raw_input commands are still
1348
1400
  * delivered immediately (including while busy). */
1349
- const pendingRawInputs = [];
1401
+ const pendingRawInputs = freshnessInputQueue.raw;
1350
1402
  /** Latest requested canonical session title. Unlike a normal prompt this is an
1351
1403
  * administrative TUI command: never type-ahead while the agent is busy, never
1352
1404
  * open a model turn, and latest-wins if several renames arrive before idle. */
@@ -1690,6 +1742,24 @@ function writeCliPidMarker() {
1690
1742
  }
1691
1743
  }
1692
1744
  let lastStructuredBridgeActivityAtMs = 0;
1745
+ /**
1746
+ * True when this CLI has an authoritative STRUCTURED rate-limit signal in its
1747
+ * transcript (Claude family — `error:"rate_limit"`, surfaced by
1748
+ * maybeEmitStructuredRateLimit). For those CLIs the screen-text `rate`
1749
+ * heuristic is not just redundant but harmful: the model's own output or a dev
1750
+ * editing rate-limit code/tests puts phrases like "429 Too Many Requests" /
1751
+ * "exceeded retry limit" on screen, which the scraper cannot distinguish from a
1752
+ * real limit. So we suppress the screen-scan `rate` verdict and let the
1753
+ * structured path be the sole authority. `usage` (quota "hit your limit …")
1754
+ * has no structured equivalent yet, so it still comes from the screen.
1755
+ *
1756
+ * reliableTurnTerminal is exactly the "transcript-backed" capability flag
1757
+ * (claude-code / seed set it); non-transcript CLIs (Codex, gemini, …) keep the
1758
+ * screen scanner as their only rate-limit signal.
1759
+ */
1760
+ function structuredRateLimitAuthoritative() {
1761
+ return cliAdapter?.reliableTurnTerminal === true;
1762
+ }
1693
1763
  // Per-turn usage-limit state machine. Owns the turn counter plus the
1694
1764
  // "did this turn hit a limit" / "suppress a stale retry-ready banner" flags, so
1695
1765
  // classify()'s state writes are explicit method calls rather than hidden
@@ -1708,7 +1778,7 @@ function createUsageLimitTracker() {
1708
1778
  beginTurn(snapshot) {
1709
1779
  turnSeq++;
1710
1780
  detectedTurn = undefined;
1711
- const current = detectCliUsageLimit(snapshot);
1781
+ const current = detectCliUsageLimit(snapshot, undefined, { suppressRateKind: structuredRateLimitAuthoritative() });
1712
1782
  suppressedRetryReadyKey = current.limited && current.retryReady
1713
1783
  ? usageLimitStateKey(current)
1714
1784
  : undefined;
@@ -1717,7 +1787,7 @@ function createUsageLimitTracker() {
1717
1787
  // Map a runtime status to a usage-limit-aware status, recording whether this
1718
1788
  // turn hit a limit (read back via detectedThisTurn).
1719
1789
  classify(content, status) {
1720
- const detected = detectCliUsageLimit(content);
1790
+ const detected = detectCliUsageLimit(content, undefined, { suppressRateKind: structuredRateLimitAuthoritative() });
1721
1791
  if (!detected.limited)
1722
1792
  return { status };
1723
1793
  const key = usageLimitStateKey(detected);
@@ -1731,6 +1801,15 @@ function createUsageLimitTracker() {
1731
1801
  detectedThisTurn(seq) {
1732
1802
  return detectedTurn === seq;
1733
1803
  },
1804
+ // Record a limit that came from a STRUCTURED signal (transcript error
1805
+ // record) rather than screen text. Mirrors classify()'s state writes so
1806
+ // the tracker stays coherent: mark this turn as having hit a limit (read
1807
+ // by detectedThisTurn for the submit-confirmation recheck) and clear any
1808
+ // stale retry-ready suppression. The actual emit is done by the caller.
1809
+ noteStructuredLimit() {
1810
+ suppressedRetryReadyKey = undefined;
1811
+ detectedTurn = turnSeq;
1812
+ },
1734
1813
  };
1735
1814
  }
1736
1815
  const usageLimitTracker = createUsageLimitTracker();
@@ -1800,6 +1879,11 @@ const bridgeSecondaryPaths = new Map(); // path → offset
1800
1879
  let bridgeOffset = 0;
1801
1880
  let bridgePendingTail = '';
1802
1881
  const bridgeQueue = new BridgeTurnQueue();
1882
+ /** uuids of Claude transcript rate-limit records we've already turned into a
1883
+ * `limited` emit. drainTranscript re-reads from offset 0 on truncation /
1884
+ * rotation and emitReadyTurns re-drains from 0, so the same rate_limit record
1885
+ * can resurface; keying on the record's stable uuid makes the emit idempotent. */
1886
+ const emittedRateLimitUuids = new Set();
1803
1887
  let bridgeWatcher = null;
1804
1888
  let bridgeFallbackTimer = null;
1805
1889
  let herdrAdoptBridgeQuietTimer = null;
@@ -1893,6 +1977,9 @@ function formatHeadlessLocalTurnContent(assistantText) {
1893
1977
  const a = truncatePreambleText(assistantText.trim(), LOCAL_TURN_ASSISTANT_MAX);
1894
1978
  return a || null;
1895
1979
  }
1980
+ function emptyCompletedBridgeFallbackContent() {
1981
+ return t('worker.empty_final_completed', { cliName: cliName() });
1982
+ }
1896
1983
  // ─── Bridge fallback marker (non-adopt) ────────────────────────────────────
1897
1984
  //
1898
1985
  // `botmux send` (cli.ts cmdSend) appends a line
@@ -2730,11 +2817,45 @@ function bridgeIngest() {
2730
2817
  if (result.events.length > 0)
2731
2818
  lastStructuredBridgeActivityAtMs = Date.now();
2732
2819
  bridgeQueue.ingest(result.events, bridgeJsonlPath);
2820
+ // Structured rate-limit: Claude Code writes an `error:"rate_limit"` record
2821
+ // at the turn's terminal boundary. This is the authoritative "limited"
2822
+ // signal — read it here (event-driven, once per record) instead of scraping
2823
+ // the TUI. The queue already skips it as an assistant reply.
2824
+ maybeEmitStructuredRateLimit(result.events);
2733
2825
  // Transcript terminal markers are authoritative and may settle a durable
2734
2826
  // turn immediately. Do not wait for the screen prompt: permission/AskUser
2735
2827
  // surfaces can resemble idle, while an explicit JSONL boundary cannot.
2736
2828
  emitReadyTurns({ explicitTerminalOnly: true });
2737
2829
  }
2830
+ /** Scan newly-drained Claude transcript events for a structured rate-limit
2831
+ * record and, on the first unseen one, emit a `limited` screen_update so the
2832
+ * session surfaces in Dashboard「需要你」with a retry countdown — identical
2833
+ * wire shape to the screen-text detector's classify() output, so the daemon /
2834
+ * card / persistence paths need no change. Claude-only (bridgeQueue is the
2835
+ * Claude bridge; Codex uses codexBridgeQueue and has no structured 429). */
2836
+ function maybeEmitStructuredRateLimit(events) {
2837
+ for (const ev of events) {
2838
+ if (!ev.uuid || emittedRateLimitUuids.has(ev.uuid))
2839
+ continue;
2840
+ if (!isTranscriptRateLimitEvent(ev))
2841
+ continue;
2842
+ emittedRateLimitUuids.add(ev.uuid);
2843
+ // Prefer a clock parsed from the record's own text ("... resets 10:40pm");
2844
+ // fall back to the shared bucketed cooldown when it carries none.
2845
+ const usageLimit = structuredRateLimitState(apiErrorMessageText(ev));
2846
+ usageLimitTracker.noteStructuredLimit();
2847
+ send({
2848
+ type: 'screen_update',
2849
+ content: currentUsageLimitSnapshot(),
2850
+ status: 'limited',
2851
+ usageLimit,
2852
+ turnId: currentBotmuxTurnId,
2853
+ dispatchAttempt: currentBotmuxDispatchAttempt,
2854
+ });
2855
+ log(`Structured rate-limit detected in Claude transcript (uuid=${ev.uuid.substring(0, 8)}, retryLabel=${usageLimit.retryLabel}) → emitted limited state.`);
2856
+ return; // one limited emit per ingest is enough; state key is stable
2857
+ }
2858
+ }
2738
2859
  function performBridgeIngestAndScheduleQuietEmit() {
2739
2860
  const beforePath = bridgeJsonlPath;
2740
2861
  const beforeOffset = bridgeOffset;
@@ -3263,6 +3384,7 @@ function hermesBridgeIngest() {
3263
3384
  // `/clear`-rotates mid-batch; the daemon accumulates them into its authorized
3264
3385
  // set, so a completed turn from an earlier source is not dropped as foreign.
3265
3386
  for (const boundSourceSessionId of filtered.newlyBoundSourceSessionIds) {
3387
+ persistCliSessionId(boundSourceSessionId);
3266
3388
  send({ type: 'bridge_source_session', bridge: 'hermes', sourceSessionId: boundSourceSessionId });
3267
3389
  log(`Hermes bridge bound sourceSessionId=${boundSourceSessionId}`);
3268
3390
  }
@@ -3675,6 +3797,37 @@ function codexBridgeDrainAndMaybeEmit(opts = {}) {
3675
3797
  }
3676
3798
  emitReadyCodexTurns();
3677
3799
  }
3800
+ /** Before we emit a fail-closed `ambiguous` terminal for a durable turn that a
3801
+ * CLI kill is about to interrupt, give an already-persisted `completed` record
3802
+ * a chance to win the worker-local terminal deduper. `reliableTurnTerminal`
3803
+ * CLIs (claude-code / codex / grok / traex) durably append their terminal line
3804
+ * to the transcript; if that line landed microseconds before the kill but the
3805
+ * fs.watch/1s poller hasn't consumed it yet, an unconditional `ambiguous` would
3806
+ * claim the deduper first and needlessly mark a turn that actually completed →
3807
+ * same durable key re-dispatchable → external side effect executed twice.
3808
+ * Draining here publishes that `completed` (claiming the deduper) so the later
3809
+ * `ambiguous` becomes a no-op. Shared by the CLI `onExit` path and the daemon
3810
+ * `restart` IPC path — both must drain identically before their ambiguous emit. */
3811
+ function drainReliableTerminalBeforeInterrupt() {
3812
+ if (cliAdapter?.reliableTurnTerminal !== true)
3813
+ return;
3814
+ if (bridgeJsonlPath) {
3815
+ try {
3816
+ bridgeDrainAndMaybeEmit();
3817
+ }
3818
+ catch (err) {
3819
+ log(`Bridge terminal drain failed: ${err.message}`);
3820
+ }
3821
+ }
3822
+ if (codexBridgeFallbackActive()) {
3823
+ try {
3824
+ codexBridgeDrainAndMaybeEmit({ signalIdle: false });
3825
+ }
3826
+ catch (err) {
3827
+ log(`Codex bridge terminal drain failed: ${err.message}`);
3828
+ }
3829
+ }
3830
+ }
3678
3831
  function emitReadyCodexTurns() {
3679
3832
  const ready = codexBridgeQueue.drainEmittable();
3680
3833
  if (ready.length === 0)
@@ -3698,11 +3851,22 @@ function emitReadyCodexTurns() {
3698
3851
  : undefined;
3699
3852
  for (let i = 0; i < ready.length; i++) {
3700
3853
  const turn = ready[i];
3701
- if (!turn.finalText)
3702
- continue;
3703
3854
  const sourceHermesSessionId = structuredBridgeIsHermes() ? turn.sourceSessionId : undefined;
3704
3855
  const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
3705
- if (shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal, finalText: turn.finalText }, nextBoundaryMs, markers, adoptMode)) {
3856
+ const gateInput = {
3857
+ markTimeMs: turn.markTimeMs,
3858
+ isLocal: turn.isLocal,
3859
+ finalText: turn.finalText,
3860
+ terminalStatus: turn.terminalStatus,
3861
+ };
3862
+ const content = turn.finalText && turn.finalText.trim()
3863
+ ? turn.finalText
3864
+ : shouldEmitEmptyCompletedBridgeFallback(gateInput, nextBoundaryMs, markers, adoptMode)
3865
+ ? emptyCompletedBridgeFallbackContent()
3866
+ : '';
3867
+ if (!content)
3868
+ continue;
3869
+ if (shouldSuppressBridgeEmit(gateInput, nextBoundaryMs, markers, adoptMode)) {
3706
3870
  log(`Codex bridge fallback suppressed for turn ${turn.turnId.substring(0, 8)} (gate)`);
3707
3871
  continue;
3708
3872
  }
@@ -3711,7 +3875,7 @@ function emitReadyCodexTurns() {
3711
3875
  // so the Lark thread sees a complete exchange instead of an orphan
3712
3876
  // reply. formatLocalTurnFields caps both texts to keep within
3713
3877
  // Lark's per-message limit; daemon owns the card chrome.
3714
- const fields = formatLocalTurnFields(turn.userText ?? '', turn.finalText);
3878
+ const fields = formatLocalTurnFields(turn.userText ?? '', content);
3715
3879
  if (!fields)
3716
3880
  continue;
3717
3881
  send({
@@ -3729,7 +3893,7 @@ function emitReadyCodexTurns() {
3729
3893
  send({
3730
3894
  type: 'final_output',
3731
3895
  ...(sourceHermesSessionId ? { sourceHermesSessionId } : {}),
3732
- content: turn.finalText,
3896
+ content,
3733
3897
  lastUuid: turn.turnId,
3734
3898
  turnId: turn.turnId,
3735
3899
  ...(turn.dispatchAttempt !== undefined ? { dispatchAttempt: turn.dispatchAttempt } : {}),
@@ -3851,8 +4015,9 @@ let renderRows = PTY_ROWS;
3851
4015
  // ─── Headless Terminal for Screen Capture ────────────────────────────────────
3852
4016
  let renderer = null;
3853
4017
  /** Most recent unfiltered viewport text — kept in sync by the screen_update
3854
- * timer for pipe-pane backends so ScreenAnalyzer (which is synchronous) has
3855
- * a fresh snapshot to read without needing its own tmux capture-pane call. */
4018
+ * timer for pipe-pane backends so usage-limit detection and the CoCo picker
4019
+ * have a fresh snapshot to read without needing their own tmux capture-pane
4020
+ * call. (Historically also fed the AI ScreenAnalyzer, now removed.) */
3856
4021
  let lastAnalyzerSnapshot = '';
3857
4022
  let screenUpdateTimer = null;
3858
4023
  const SCREEN_UPDATE_INTERVAL_MS = 2_000;
@@ -4026,14 +4191,14 @@ function parkCrashDiagnosticTerminal(code, signal) {
4026
4191
  // so the diagnostic shell stays visible. flushPending's retry path restarts
4027
4192
  // both when the next message respawns the CLI.
4028
4193
  stopScreenUpdates();
4029
- stopScreenAnalyzer();
4030
4194
  stopStuckDetector();
4031
4195
  log(`Crash diagnostic tmux session parked at ${TmuxBackend.diagnosticSessionName(sessionId)}`);
4032
4196
  return true;
4033
4197
  }
4034
- // ─── Screen Analyzer (AI-based TUI prompt detection) ────────────────────────
4035
- let screenAnalyzer = null;
4036
- /** When true, user messages are queued because a TUI prompt is active */
4198
+ // ─── TUI prompt blocking state ──────────────────────────────────────────────
4199
+ /** When true, user messages are queued because a TUI prompt is active. Set by
4200
+ * the ask-hook / CoCo picker paths (driveCocoPicker, handleTuiKeys); cleared
4201
+ * when the prompt resolves or a Lark/terminal input overrides it. */
4037
4202
  let tuiPromptBlocking = false;
4038
4203
  function isWorkflowWorker() {
4039
4204
  return process.env.BOTMUX_WORKFLOW === '1';
@@ -4095,54 +4260,6 @@ function maybeEmitWorkflowTranscriptOutput() {
4095
4260
  });
4096
4261
  log('Workflow PTY transcript final_output emitted');
4097
4262
  }
4098
- function startScreenAnalyzer() {
4099
- const sa = config.screenAnalyzer;
4100
- log(`ScreenAnalyzer config: enabled=${sa.enabled}, baseUrl=${sa.baseUrl ? 'set' : 'empty'}, model=${sa.model || 'empty'}, extraHeaders=${JSON.stringify(sa.extraHeaders)}`);
4101
- if (!sa.enabled || !sa.baseUrl || !sa.apiKey || !sa.model)
4102
- return;
4103
- screenAnalyzer = new ScreenAnalyzer({
4104
- baseUrl: sa.baseUrl,
4105
- apiKey: sa.apiKey,
4106
- model: sa.model,
4107
- intervalMs: sa.intervalMs,
4108
- stableCount: sa.stableCount,
4109
- snapshotMaxChars: sa.snapshotMaxChars,
4110
- extraHeaders: sa.extraHeaders,
4111
- extraBody: sa.extraBody,
4112
- }, {
4113
- getSnapshot: () => {
4114
- // ScreenAnalyzer is called every ~5s for TUI-prompt detection. We
4115
- // can't make this async without overhauling the analyzer, so cache
4116
- // the last pipe-pane text snapshot here and refresh it eagerly.
4117
- // For pipe-pane backends, the cache is repopulated by the screen
4118
- // update timer; for others, fall through to the long-lived renderer.
4119
- return lastAnalyzerSnapshot || renderer?.rawSnapshot() || '';
4120
- },
4121
- onAnalyzing: () => { },
4122
- onTuiPrompt: (description, options, multiSelect) => {
4123
- tuiPromptBlocking = true;
4124
- send({ type: 'tui_prompt', description, options, multiSelect, turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt });
4125
- },
4126
- onTuiPromptResolved: (selectedText) => {
4127
- tuiPromptBlocking = false;
4128
- send({
4129
- type: 'tui_prompt_resolved',
4130
- selectedText,
4131
- turnId: currentBotmuxTurnId,
4132
- dispatchAttempt: currentBotmuxDispatchAttempt,
4133
- });
4134
- // Flush any messages that were queued during the prompt
4135
- flushPending();
4136
- },
4137
- log,
4138
- });
4139
- screenAnalyzer.start();
4140
- }
4141
- function stopScreenAnalyzer() {
4142
- screenAnalyzer?.dispose();
4143
- screenAnalyzer = null;
4144
- tuiPromptBlocking = false;
4145
- }
4146
4263
  // ─── Stuck Detector (AI-free fallback for blocked CLI states) ───────────────
4147
4264
  let stuckDetector = null;
4148
4265
  // Monotonic counter bumped on every CLI/backend (re)start within this worker.
@@ -4339,8 +4456,6 @@ async function captureAndUpload() {
4339
4456
  return;
4340
4457
  }
4341
4458
  let status = isPromptReady ? 'idle' : 'working';
4342
- if (screenAnalyzer?.isAnalyzing)
4343
- status = 'analyzing';
4344
4459
  send({
4345
4460
  type: 'screenshot_uploaded',
4346
4461
  imageKey,
@@ -4430,14 +4545,12 @@ function handleTermAction(key) {
4430
4545
  else if (PTY_SEQ_MAP[key]) {
4431
4546
  backend.write(PTY_SEQ_MAP[key]);
4432
4547
  }
4433
- // ESC/Ctrl-C/Enter likely ends an active TUI prompt. The analyzer
4434
- // won't re-analyze while promptActive=true, so un-wedge both flags here.
4435
- // Without this, dismissing an AskUserQuestion dialog via the quick-key
4436
- // button leaves tuiPromptBlocking=true forever and silently queues every
4437
- // subsequent user message.
4548
+ // ESC/Ctrl-C/Enter likely ends an active TUI prompt. Un-wedge the blocking
4549
+ // flag here without this, dismissing an AskUserQuestion dialog via the
4550
+ // quick-key button leaves tuiPromptBlocking=true forever and silently queues
4551
+ // every subsequent user message.
4438
4552
  if (tuiPromptBlocking && (key === 'esc' || key === 'ctrlc' || key === 'enter')) {
4439
4553
  tuiPromptBlocking = false;
4440
- screenAnalyzer?.notifySelection(`term_action:${key}`);
4441
4554
  void flushPending();
4442
4555
  }
4443
4556
  log(`Term action: ${key}`);
@@ -4484,18 +4597,18 @@ async function handleTuiKeys(keys, isFinal) {
4484
4597
  isPromptReady = false;
4485
4598
  idleDetector?.reset();
4486
4599
  }
4487
- screenAnalyzer?.notifySelection('final');
4488
4600
  }
4489
4601
  log(`TUI keys: ${keys.join(' ')}${isFinal ? ' (final)' : ''}`);
4490
4602
  return true;
4491
4603
  }
4492
4604
  // 待注入的 TUI 命令队列。生命周期绑定当前 CLI 进程:killCli() 会清空它,
4493
4605
  // 防止 restart 后残留命令重放进新 CLI——新增清理状态时记得同步那里。
4494
- // barrier=true 标记该注入携带 updateWorkingDir(cwd-move,如 /cd):markPromptReady
4495
- // barrier 注入必须先于本次 pending 用户消息落地,防止用户消息在「记录已是新
4496
- // cwd、进程仍在旧 cwd」的窗口里被写进旧目录的 CLI。排队策略判定收敛到
4497
- // core/inject-queue-policy.ts(shouldDeferUserFlush / shouldFlushInjectionsFirst)
4498
- // 以获得可单测的纯函数单元——本文件只持有队列状态。
4606
+ // barrier 语义:barrier=true 的注入必须先于本次 pending 用户消息落地。历史上
4607
+ // 唯一的 barrier 来源是 cwd-move 的 /cd 注入;角色切换改为 restart respawn 后
4608
+ // 现存发送方(/slash 白名单注入)全部 barrier=false,机制保留供未来有顺序
4609
+ // 依赖的注入复用。排队策略判定收敛到 core/inject-queue-policy.ts
4610
+ // (shouldDeferUserFlush / shouldFlushInjectionsFirst)以获得可单测的纯函数
4611
+ // 单元——本文件只持有队列状态。
4499
4612
  const pendingInjections = [];
4500
4613
  let injectionFlushing = false;
4501
4614
  /** 排队注入一行 TUI 命令:idle(isPromptReady)时经 sendRawCommandLineSerially 敲入。 */
@@ -4532,9 +4645,18 @@ async function flushPendingInjections() {
4532
4645
  // path sends first runs the detection.
4533
4646
  if (!bareShellChecked) {
4534
4647
  bareShellChecked = true;
4535
- if (detectBareShellLaunch())
4648
+ if (await detectBareShellLaunch())
4536
4649
  return; // finally{} releases the mutex; queue stays
4537
4650
  }
4651
+ // The detector's settle await can span a restart's tmux jitter window
4652
+ // (cliRestartInProgress true, old backend still alive). Re-check the fence
4653
+ // before shift()/write so a queued injection never lands in a CLI already
4654
+ // being torn down. The pending injections are then handled by killCli's
4655
+ // restart policy — which drops them (barrier /cd is already durable in
4656
+ // workingDir; non-barrier injects are best-effort and not replayed
4657
+ // cross-process), unlike pendingMessages which killCli preserves.
4658
+ if (cliRestartInProgress)
4659
+ return;
4538
4660
  const item = pendingInjections.shift();
4539
4661
  const cmd = item.command;
4540
4662
  isPromptReady = false;
@@ -4594,7 +4716,6 @@ async function handleTuiTextInput(keys, text) {
4594
4716
  isPromptReady = false;
4595
4717
  idleDetector?.reset();
4596
4718
  }
4597
- screenAnalyzer?.notifySelection('text-input');
4598
4719
  // Wait briefly so the cursor position is stable before pasting
4599
4720
  await new Promise(r => setTimeout(r, 200));
4600
4721
  // Step 3: write text via cliAdapter (auto-switches to text mode + submits with Enter)
@@ -4753,9 +4874,74 @@ function handleCodexAppMarker(body) {
4753
4874
  persistCliSessionId(payload.threadId);
4754
4875
  return;
4755
4876
  }
4756
- if (kind === 'final' && typeof payload.content === 'string') {
4757
- const startedAtMs = typeof payload.startedAtMs === 'number' ? payload.startedAtMs : undefined;
4758
- const completedAtMs = typeof payload.completedAtMs === 'number' ? payload.completedAtMs : Date.now();
4877
+ if (kind === 'lifecycle') {
4878
+ const event = normalizeCodexAppLifecycleEvent(payload);
4879
+ if (!event) {
4880
+ log(`${cliName()} rejected malformed lifecycle marker`);
4881
+ return;
4882
+ }
4883
+ log(`${cliName()} lifecycle kind=${event.kind}`
4884
+ + ` appTurn=${shortCorrelationId(event.appTurnId)}`
4885
+ + ` replyTurn=${shortCorrelationId(event.replyTurnId)}`
4886
+ + `${event.queueLength !== undefined ? ` queue=${event.queueLength}` : ''}`);
4887
+ if (!event.replyTurnId || !submittedCodexAppReplyTurnIds.has(event.replyTurnId))
4888
+ return;
4889
+ if (event.kind === 'steer_attempt') {
4890
+ rememberBoundedMap(pendingCodexAppSteerAckIds, event.replyTurnId, event.appTurnId);
4891
+ return;
4892
+ }
4893
+ const steerKey = `${event.appTurnId}\0${event.replyTurnId}`;
4894
+ if (event.kind !== 'steer_accepted'
4895
+ || pendingCodexAppSteerAckIds.get(event.replyTurnId) !== event.appTurnId
4896
+ || acknowledgedCodexAppSteers.has(steerKey))
4897
+ return;
4898
+ pendingCodexAppSteerAckIds.delete(event.replyTurnId);
4899
+ rememberBounded(acknowledgedCodexAppSteers, steerKey);
4900
+ send({
4901
+ type: 'steer_accepted',
4902
+ appTurnId: event.appTurnId,
4903
+ turnId: event.replyTurnId,
4904
+ });
4905
+ return;
4906
+ }
4907
+ if (kind === 'final') {
4908
+ const marker = normalizeAppRunnerFinalMarker(payload);
4909
+ if (!marker) {
4910
+ log(`${cliName()} rejected malformed final marker`);
4911
+ return;
4912
+ }
4913
+ const startedAtMs = marker.startedAtMs;
4914
+ const completedAtMs = marker.completedAtMs ?? Date.now();
4915
+ if (marker.appTurnId) {
4916
+ const trustedReplyTurnId = marker.replyTurnId
4917
+ && submittedCodexAppReplyTurnIds.has(marker.replyTurnId)
4918
+ ? marker.replyTurnId
4919
+ : undefined;
4920
+ if (marker.replyTurnId && !trustedReplyTurnId) {
4921
+ log(`${cliName()} ignored unsubmitted final reply route `
4922
+ + `(replyTurn=${shortCorrelationId(marker.replyTurnId)})`);
4923
+ }
4924
+ const identity = projectAppRunnerFinalIds({ ...marker, replyTurnId: trustedReplyTurnId }, currentBotmuxTurnId, `${lastInitConfig?.cliId ?? 'app'}-${Date.now()}`);
4925
+ if (marker.appTurnId !== identity.turnId) {
4926
+ log(`${cliName()} app turn ${shortCorrelationId(marker.appTurnId)} mapped `
4927
+ + `to botmux turn ${shortCorrelationId(identity.turnId)}`);
4928
+ }
4929
+ const dispatchAttempt = currentBotmuxDispatchAttempt;
4930
+ if (startedAtMs !== undefined && shouldSuppressBridgeEmit({ markTimeMs: startedAtMs, isLocal: false, finalText: marker.content }, completedAtMs + 5_001, readSendMarkers(), false)) {
4931
+ log(`${cliName()} final_output suppressed (model already called botmux send)`);
4932
+ emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
4933
+ return;
4934
+ }
4935
+ send({
4936
+ type: 'final_output',
4937
+ content: marker.content,
4938
+ lastUuid: identity.lastUuid,
4939
+ turnId: identity.turnId,
4940
+ ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
4941
+ });
4942
+ emitTurnTerminal(identity.turnId, 'completed', undefined, dispatchAttempt);
4943
+ return;
4944
+ }
4759
4945
  // Codex App keeps the app-server-generated id separately for diagnostics.
4760
4946
  // Routing must use its stable clientUserMessageId marker; legacy envelopes
4761
4947
  // intentionally omit it and fall back to the worker's frozen botmux turn.
@@ -4780,7 +4966,7 @@ function handleCodexAppMarker(body) {
4780
4966
  // equality for compatibility, but can never select another attempt.
4781
4967
  const dispatchAttempt = currentBotmuxDispatchAttempt;
4782
4968
  if (startedAtMs !== undefined) {
4783
- const sentByModel = shouldSuppressBridgeEmit({ markTimeMs: startedAtMs, isLocal: false, finalText: payload.content }, completedAtMs + 5_001, readSendMarkers(), false);
4969
+ const sentByModel = shouldSuppressBridgeEmit({ markTimeMs: startedAtMs, isLocal: false, finalText: marker.content }, completedAtMs + 5_001, readSendMarkers(), false);
4784
4970
  if (sentByModel) {
4785
4971
  log(`${cliName()} final_output suppressed (model already called botmux send)`);
4786
4972
  emitTurnTerminal(turnId, 'completed', undefined, dispatchAttempt);
@@ -4789,7 +4975,7 @@ function handleCodexAppMarker(body) {
4789
4975
  }
4790
4976
  send({
4791
4977
  type: 'final_output',
4792
- content: payload.content,
4978
+ content: marker.content,
4793
4979
  lastUuid: turnId,
4794
4980
  turnId,
4795
4981
  ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
@@ -4943,6 +5129,10 @@ function markPromptReadyFromPty() {
4943
5129
  function markPromptReady() {
4944
5130
  if (isPromptReady)
4945
5131
  return; // guard against duplicate calls
5132
+ if (cliRestartInProgress && !replacementSpawnInProgress) {
5133
+ log('Ignoring prompt-ready from backend generation being replaced');
5134
+ return;
5135
+ }
4946
5136
  stopBusyPatternIdleProbe();
4947
5137
  // Ready-gate: a startup selector's ❯ (cjadk et al.) falsely matches
4948
5138
  // readyPattern → the IdleDetector fires idle while the CLI is NOT actually at
@@ -4972,6 +5162,14 @@ function markPromptReady() {
4972
5162
  log('Idle detected during ready-gate settle; deferring prompt-ready until settle completes');
4973
5163
  return;
4974
5164
  }
5165
+ const freshnessAction = freshnessInputQueue.onPromptReady();
5166
+ if (freshnessAction === 'reload') {
5167
+ log('Stale Codex App runner became idle; replacing it before releasing queued input');
5168
+ void restartCliProcess('stale runner reached idle', { immediate: true, preservePending: true });
5169
+ return;
5170
+ }
5171
+ if (freshnessAction === 'ignore')
5172
+ return;
4975
5173
  isPromptReady = true;
4976
5174
  clearSessionRenameInFlight();
4977
5175
  // An old backend can still report idle while its async teardown is running.
@@ -5003,6 +5201,33 @@ function markPromptReady() {
5003
5201
  renderer?.markNewTurn(); // exclude history replay from streaming card
5004
5202
  }
5005
5203
  send({ type: 'prompt_ready' });
5204
+ if (persistCodexRunnerBuildOnReady
5205
+ && lastInitConfig?.cliId === 'codex-app'
5206
+ && lastInitConfig.runnerBuildId) {
5207
+ send({ type: 'runner_build_ready', runnerBuildId: lastInitConfig.runnerBuildId });
5208
+ persistCodexRunnerBuildOnReady = false;
5209
+ }
5210
+ if (activeRestartAttemptId) {
5211
+ // Defense in depth: only report a successful restart when a replacement
5212
+ // backend is actually installed. Every legitimate ready path assigns
5213
+ // `backend` before firing (spawnCli sets it, then idle/PTY callbacks run);
5214
+ // a stray callback that reached here with no backend (e.g. a late stale
5215
+ // generation slipping through a future gate change) must NOT claim success
5216
+ // and consume the attempt id — leave it for the real replacement or the
5217
+ // coordinator timeout.
5218
+ if (backend) {
5219
+ send({
5220
+ type: 'restart_result',
5221
+ attemptId: activeRestartAttemptId,
5222
+ status: 'succeeded',
5223
+ category: 'prompt_ready',
5224
+ });
5225
+ activeRestartAttemptId = undefined;
5226
+ }
5227
+ else {
5228
+ log('prompt-ready with no backend installed — deferring restart success receipt');
5229
+ }
5230
+ }
5006
5231
  // Send immediate idle snapshot so Lark card reflects idle status.
5007
5232
  // BUT: skip when messages are pending — flushPending() will immediately
5008
5233
  // make the CLI busy, so the idle state is transient and shouldn't appear
@@ -5012,13 +5237,11 @@ function markPromptReady() {
5012
5237
  const { content } = renderer.snapshot();
5013
5238
  send({ type: 'screen_update', content, ...usageLimitTracker.classify(content, 'idle'), turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt });
5014
5239
  }
5015
- // cwd-move 注入(barrier=true,如 /cd)必须先于本次 pending 用户消息落地:
5016
- // 用户在 bot 发完「已切换角色」确认后立刻发下一条消息是最常见路径——若
5017
- // flushPending 先跑,该消息会在 barrier 注入之前被写进旧 cwd 的 CLI(daemon
5018
- // 记录已是新目录,实际执行仍在旧目录)。跳过本次 flushPending 不会饿死用户
5019
- // 消息:flushPending 自身的 injectionFlushing 守卫会挡住并发写入,
5020
- // flushPendingInjections 的 finally 会在注入完成、CLI 重新 idle 后补踢一次
5021
- // flushPending 排空 pendingMessages。
5240
+ // barrier 注入必须先于本次 pending 用户消息落地(现存发送方均 barrier=false,
5241
+ // 该分支目前不触发;机制保留见 pendingInjections 声明处注释)。跳过本次
5242
+ // flushPending 不会饿死用户消息:flushPending 自身的 injectionFlushing 守卫
5243
+ // 会挡住并发写入,flushPendingInjections finally 会在注入完成、CLI 重新
5244
+ // idle 后补踢一次 flushPending 排空 pendingMessages。
5022
5245
  if (shouldFlushInjectionsFirst(pendingInjections)) {
5023
5246
  void flushPendingInjections();
5024
5247
  }
@@ -5236,19 +5459,41 @@ function scheduleSubmitFailureNotify(msg, recheck, transcriptLabel, bridgeTurnId
5236
5459
  * and the pty/herdr backends (which `exec` the CLI directly — getChildPid is the
5237
5460
  * CLI itself, never a shell).
5238
5461
  *
5239
- * Returns true when a bare-shell launch was detected (caller must NOT flush).
5462
+ * Returns true when a persistent bare-shell launch was detected (caller must
5463
+ * NOT flush). A transient wrapper shell gets a bounded chance to exec the CLI.
5240
5464
  */
5241
- function detectBareShellLaunch() {
5465
+ async function detectBareShellLaunch() {
5242
5466
  if (bareShellLaunchBlocked)
5243
5467
  return true;
5244
5468
  if (lastInitConfig?.adoptMode)
5245
5469
  return false; // observing an existing pane, not launching
5246
5470
  if (lastInitConfig?.wrapperCli)
5247
5471
  return false; // launcher legitimately wraps the CLI (transient shell shim)
5248
- const pid = backend?.getChildPid?.();
5249
- if (!pid)
5472
+ // Hold the raw_input passthrough latch across the bounded settle poll: the
5473
+ // await below yields the event loop for up to 2s, and a concurrent raw_input
5474
+ // IPC handler (which bypasses isFlushing to preserve busy-delivery) must not
5475
+ // type into the pane while its leaf is still the unsettled shell.
5476
+ bareShellCheckInProgress = true;
5477
+ let comm;
5478
+ try {
5479
+ comm = await settleLaunchComm(() => {
5480
+ const pid = backend?.getChildPid?.();
5481
+ return pid ? readComm(pid) : undefined;
5482
+ });
5483
+ }
5484
+ finally {
5485
+ bareShellCheckInProgress = false;
5486
+ }
5487
+ // A restart can begin while we're suspended in the settle await: tmux staggers
5488
+ // teardown behind a 250–1999ms jitter, so cliRestartInProgress is already true
5489
+ // but the old backend is still alive. Do NOT classify that torn-down pane as a
5490
+ // failed launch — it would set the persistent bareShellLaunchBlocked and emit a
5491
+ // misdiagnosis card for a CLI that's about to be replaced. Return healthy; the
5492
+ // caller's own post-await restart fence keeps the queue intact for the
5493
+ // replacement generation (whose spawnCli resets bareShellChecked and re-runs
5494
+ // this check).
5495
+ if (cliRestartInProgress)
5250
5496
  return false;
5251
- const comm = readComm(pid);
5252
5497
  if (!isBareShellComm(comm))
5253
5498
  return false; // CLI (rust/go/node) is running — healthy launch
5254
5499
  // Bare shell is the pane leaf → the CLI never launched. Tier the message on
@@ -5297,6 +5542,8 @@ async function flushPending() {
5297
5542
  // backend's idle/task-done callback) write across that restart boundary.
5298
5543
  if (cliRestartInProgress)
5299
5544
  return;
5545
+ if (shouldHoldCodexRunnerInput(codexRunnerFreshness))
5546
+ return;
5300
5547
  if (isFlushing)
5301
5548
  return; // while loop in active flush will pick up new messages
5302
5549
  if (!backend || !cliAdapter)
@@ -5405,10 +5652,19 @@ async function flushPending() {
5405
5652
  // doesn't get them typed into the bare shell first.
5406
5653
  if (!bareShellChecked) {
5407
5654
  bareShellChecked = true;
5408
- if (detectBareShellLaunch()) {
5655
+ if (await detectBareShellLaunch()) {
5409
5656
  return; // finally{} releases the mutex; pendingMessages stay queued, untouched
5410
5657
  }
5411
5658
  }
5659
+ // detectBareShellLaunch() awaits a bounded settle poll; a restart can begin
5660
+ // during that yield (tmux staggers teardown behind a 250–1999ms jitter, so
5661
+ // cliRestartInProgress flips true while the old backend is still alive). Do
5662
+ // not write startup commands / rename / user prompts into a CLI already
5663
+ // promised to teardown — re-check the restart fence now, before any shift or
5664
+ // write. The queue is untouched; the replacement generation's markPromptReady
5665
+ // re-invokes flushPending.
5666
+ if (cliRestartInProgress)
5667
+ return; // finally{} releases the mutex; queue stays
5412
5668
  // One-shot per spawn: type the bot's startup commands (e.g. `/effort
5413
5669
  // ultracode`) into the CLI before the first user prompt drains. Both ready
5414
5670
  // paths funnel through flushPending — the ready-gate settle for Claude-family
@@ -5423,7 +5679,9 @@ async function flushPending() {
5423
5679
  // rename. Some passthroughs (/clear, /new) can rotate the native session;
5424
5680
  // applying the canonical title last keeps the resume-picker label aligned.
5425
5681
  if (rawInputReady && pendingRawInputs.length > 0 && backend) {
5426
- const raw = pendingRawInputs.shift();
5682
+ const raw = freshnessInputQueue.takeRaw();
5683
+ if (!raw)
5684
+ return;
5427
5685
  await deliverRawInput(raw);
5428
5686
  return;
5429
5687
  }
@@ -5452,7 +5710,9 @@ async function flushPending() {
5452
5710
  return;
5453
5711
  }
5454
5712
  while (pendingMessages.length > 0 && backend && cliAdapter) {
5455
- const item = pendingMessages.shift();
5713
+ const item = freshnessInputQueue.takeNormal();
5714
+ if (!item)
5715
+ break;
5456
5716
  const durableWrite = item.dispatchAttempt !== undefined;
5457
5717
  if (durableWrite)
5458
5718
  durableTurnInFlight = true;
@@ -5512,7 +5772,13 @@ async function flushPending() {
5512
5772
  log('Refused durable Claude submit: transcript terminal bridge is unavailable');
5513
5773
  break;
5514
5774
  }
5515
- log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
5775
+ if (lastInitConfig?.cliId === 'codex-app') {
5776
+ log(`Writing Codex App input to PTY (flush): `
5777
+ + `replyTurn=${shortCorrelationId(item.turnId)} chars=${msg.length}`);
5778
+ }
5779
+ else {
5780
+ log(`Writing to PTY (flush): "${msg.substring(0, 80)}"`);
5781
+ }
5516
5782
  // Defense in depth: TmuxPipeBackend's send methods no longer throw on a
5517
5783
  // dead pane (they fire onExit instead), but writeInput can still throw
5518
5784
  // for other reasons (fs errors while resolving the JSONL, a future
@@ -5531,10 +5797,10 @@ async function flushPending() {
5531
5797
  result = { submitted: true };
5532
5798
  }
5533
5799
  else if (item.codexAppInput && cliAdapter.writeStructuredInput) {
5534
- result = await cliAdapter.writeStructuredInput(backend, msg, item.codexAppInput);
5800
+ result = await cliAdapter.writeStructuredInput(backend, msg, item.codexAppInput, { turnId: item.turnId });
5535
5801
  }
5536
5802
  else {
5537
- result = await cliAdapter.writeInput(backend, msg);
5803
+ result = await cliAdapter.writeInput(backend, msg, { turnId: item.turnId });
5538
5804
  }
5539
5805
  scheduleBusyPatternIdleProbe(`${cliName()} post-submit`);
5540
5806
  }
@@ -5554,6 +5820,11 @@ async function flushPending() {
5554
5820
  scheduleSubmitFailureNotify(logicalMsg, undefined, '会话 JSONL', bridgeTurnId, undefined, turnSeq, item);
5555
5821
  break;
5556
5822
  }
5823
+ if (lastInitConfig?.cliId === 'codex-app'
5824
+ && item.turnId
5825
+ && result?.submitted !== false) {
5826
+ rememberBounded(submittedCodexAppReplyTurnIds, item.turnId);
5827
+ }
5557
5828
  // Persist any sessionId the adapter observed via authoritative sources
5558
5829
  // (Claude's pid file, Codex's history). Done independently of submit
5559
5830
  // outcome — the rotation is real even when the current Enter didn't
@@ -5613,7 +5884,7 @@ function sendToPty(content, turnId, opts = {}) {
5613
5884
  // old early-return silently dropped it after receiver had already persisted
5614
5885
  // DISPATCHED.
5615
5886
  if (cliRestartInProgress || !backend) {
5616
- pendingMessages.push(next);
5887
+ freshnessInputQueue.enqueueNormal(next);
5617
5888
  log(`Queued message while CLI backend is restarting (${pendingMessages.length} pending)`);
5618
5889
  return true;
5619
5890
  }
@@ -5624,13 +5895,14 @@ function sendToPty(content, turnId, opts = {}) {
5624
5895
  isFlushing,
5625
5896
  supportsTypeAhead,
5626
5897
  awaitingFirstPrompt,
5898
+ holdForRunnerReload: shouldHoldCodexRunnerInput(codexRunnerFreshness),
5627
5899
  }) && cliAdapter.mergeQueuedInput === true;
5628
5900
  const mergedQueued = shouldMergeQueued && mergeQueuedCliInput(pendingMessages, next);
5629
5901
  if (mergedQueued) {
5630
5902
  log(`Merged queued message (${pendingMessages.length} pending): "${content.substring(0, 80)}" — ${cliName()} ${awaitingFirstPrompt ? 'still booting' : 'is busy'}`);
5631
5903
  }
5632
5904
  else {
5633
- pendingMessages.push(next);
5905
+ freshnessInputQueue.enqueueNormal(next);
5634
5906
  }
5635
5907
  // User-override semantics: a fresh Lark message while a TUI prompt is "active"
5636
5908
  // takes precedence over the AI-detected prompt. The screen analyzer can be
@@ -5641,7 +5913,6 @@ function sendToPty(content, turnId, opts = {}) {
5641
5913
  if (tuiPromptBlocking) {
5642
5914
  log(`User override: incoming Lark message clears tuiPromptBlocking — "${content.substring(0, 80)}"`);
5643
5915
  tuiPromptBlocking = false;
5644
- screenAnalyzer?.notifySelection('lark-input');
5645
5916
  // Tear down the prompt card so the user doesn't see stale options.
5646
5917
  send({
5647
5918
  type: 'tui_prompt_resolved',
@@ -5661,7 +5932,11 @@ function sendToPty(content, turnId, opts = {}) {
5661
5932
  // delivers queued messages instead. See input-gate.ts; this fixes dispatch's
5662
5933
  // brief reaching Codex before its first idle and never landing.
5663
5934
  if (!sessionRenameInFlight && commandLineWritesPending === 0 && shouldWriteNow({
5664
- isPromptReady, isFlushing, supportsTypeAhead, awaitingFirstPrompt,
5935
+ isPromptReady,
5936
+ isFlushing,
5937
+ supportsTypeAhead,
5938
+ awaitingFirstPrompt,
5939
+ holdForRunnerReload: shouldHoldCodexRunnerInput(codexRunnerFreshness),
5665
5940
  })) {
5666
5941
  if (!mergedQueued)
5667
5942
  log(`Writing to PTY: "${content.substring(0, 80)}"`);
@@ -5697,8 +5972,6 @@ function startScreenUpdates() {
5697
5972
  if (awaitingFirstPrompt)
5698
5973
  return;
5699
5974
  let status = isPromptReady ? 'idle' : 'working';
5700
- if (screenAnalyzer?.isAnalyzing)
5701
- status = 'analyzing';
5702
5975
  void (async () => {
5703
5976
  let content = lastContent;
5704
5977
  let changed = false;
@@ -5725,8 +5998,9 @@ function startScreenUpdates() {
5725
5998
  const hash = pipeText.ansi;
5726
5999
  changed = hash !== lastTextSnapshotHash;
5727
6000
  lastTextSnapshotHash = hash;
5728
- // Refresh the unfiltered cache that ScreenAnalyzer reads from. Same
5729
- // tmux call would otherwise need to fire twice per tick.
6001
+ // Refresh the unfiltered snapshot cache (lastAnalyzerSnapshot) that
6002
+ // usage-limit detection and the CoCo picker read from. Same tmux
6003
+ // call would otherwise need to fire twice per tick.
5730
6004
  if (changed) {
5731
6005
  const rawSnap = await snapshotToText(backend, renderCols, renderRows, { filter: false });
5732
6006
  if (rawSnap)
@@ -6364,81 +6638,20 @@ async function spawnCli(cfg, opts = {}) {
6364
6638
  // so a fork inherits the whole submit-confirm + bridge-fallback machinery.
6365
6639
  let claudeDataDir = cliAdapter.claudeDataDir;
6366
6640
  let effectiveReadyHookInstall = cliAdapter.hookInstall;
6367
- // When this session will be file-sandboxed, the CLI's session jsonl is written
6368
- // into the overlay's EPHEMERAL home upper (CLAUDE_CONFIG_DIR lives under $HOME),
6369
- // invisible at the real path the bridge normally watches → "Bridge mark expired"
6370
- // and the turn never relays (2026-06-10 incident). Redirect every jsonl/pid/
6371
- // bridge gate below to the upper copy where the sandboxed CLI actually writes.
6372
- const willFileSandbox = process.platform === 'linux' && // bwrap overlay is Linux-only; macOS uses the Seatbelt write-sandbox below
6373
- (cfg.sandbox === true || sandboxEnabled()) &&
6374
- (effectiveBackendType === 'pty' || effectiveBackendType === 'tmux') &&
6375
- !!process.env.SESSION_DATA_DIR;
6376
- if (claudeDataDir && willFileSandbox) {
6377
- const redirected = sandboxedClaudeDataDir(cfg.sessionId, claudeDataDir, {
6378
- sourceWorkingDir: cfg.workingDir,
6379
- dataDir: process.env.SESSION_DATA_DIR,
6380
- });
6381
- log(`[sandbox] redirecting Claude bridge dataDir → overlay upper: ${redirected}`);
6382
- claudeDataDir = redirected;
6383
- }
6384
- // v2 read isolation: relocate the CLI's data root into the per-bot BOT_HOME
6385
- // (`<BOTMUX_HOME>/bots/<appId>/{claude,codex}`) so each bot's transcripts/memory
6386
- // land in its OWN (Seatbelt-allowed) dir, NOT the shared/global ~/.claude|~/.codex
6387
- // (which v2 denies wholesale). Decided EARLY — like willFileSandbox above — so
6388
- // every JSONL/bridge/resume path below already targets the per-bot dir. The
6389
- // matching CLAUDE_CONFIG_DIR/CODEX_HOME env, per-bot provisioning and Seatbelt
6390
- // wrapper are applied at spawn time further down. This gate is the SINGLE
6391
- // decision point: configured-but-unenforceable fail-closes HERE (never run a
6392
- // session unisolated that asked for isolation).
6393
- // UNIFIED file sandbox: read isolation engages with the same toggle as write
6394
- // isolation. On darwin the legacy standalone `readIsolation` flag ALSO engages it
6395
- // (Seatbelt can read-deny without a write overlay). On Linux read isolation RIDES
6396
- // the bwrap sandbox (its masks go into the same bwrap plan), so it requires the
6397
- // sandbox to be on — hence the trigger is the sandbox toggle there, never the bare
6398
- // legacy flag (which would redirect the data dir but apply no masks → a hole).
6399
- // riff runs in its own REMOTE sandbox with no local CLI process — every
6400
- // local isolation flavor (Linux bwrap, macOS Seatbelt write-sandbox, read
6401
- // isolation incl. the legacy fail-closed flag) is meaningless for it and
6402
- // must be bypassed on ALL platforms, or a sandbox/readIsolation-enabled bot
6403
- // bricks the moment it switches to riff.
6404
- const riffRemoteBackend = effectiveBackendType === 'riff';
6641
+ // ── UNIFIED file sandbox (fs-policy, 2026-07-16 refactor) ──
6642
+ // ONE toggle, BOTH platforms, identical three-tier deny-by-default semantics
6643
+ // (adapters/cli/fs-policy.ts). Legacy `readIsolation` is auto-migrated to
6644
+ // `sandbox` at daemon startup; honored here too for an unmigrated read-only
6645
+ // BOTS_CONFIG. riff runs in its own REMOTE sandbox with no local CLI process
6646
+ // local confinement is meaningless there and must be bypassed on ALL
6647
+ // platforms, or a sandbox-enabled bot bricks the moment it switches to riff.
6648
+ const riffRemoteBackend = !localSandboxApplies(effectiveBackendType);
6405
6649
  if (riffRemoteBackend && (cfg.sandbox === true || cfg.readIsolation === true)) {
6406
- log('Sandbox/read-isolation flags set but backend is riff (remote sandbox, no local process) — local isolation bypassed');
6407
- }
6408
- const wantsFileSandbox = !riffRemoteBackend && (cfg.sandbox === true || sandboxEnabled());
6409
- // Legacy EXPLICIT read-isolation opt-in (macOS only Linux read-iso rides the
6410
- // bwrap sandbox, so it's driven solely by the sandbox toggle there). Keeps
6411
- // FAIL-CLOSED semantics: you asked for read isolation → refuse to start without it.
6412
- const explicitLegacyReadIso = process.platform === 'darwin' && cfg.readIsolation === true && !riffRemoteBackend;
6413
- const readIsolationGate = evaluateReadIsolationGate({
6414
- configured: wantsFileSandbox || explicitLegacyReadIso,
6415
- adapterSupports: cliAdapter.supportsReadIsolation === true,
6416
- wrapperCliSet: !!cfg.wrapperCli,
6417
- platform: process.platform,
6418
- sessionDataDirSet: !!process.env.SESSION_DATA_DIR,
6419
- });
6420
- // Fail-closed ONLY for the explicit legacy flag. Under the UNIFIED sandbox toggle
6421
- // read isolation is BEST-EFFORT: a CLI/platform that can't enforce the read-deny
6422
- // (e.g. a non-supporting adapter) still runs WRITE-isolated — it just doesn't get
6423
- // the read masks. Never brick a sandboxed session just because read-iso is N/A.
6424
- if (readIsolationGate.failClosedReason && explicitLegacyReadIso) {
6425
- throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${readIsolationGate.failClosedReason}`);
6426
- }
6427
- const willReadIsolate = readIsolationGate.enabled;
6428
- // macOS FILE SANDBOX — the Seatbelt WRITE-isolation twin of the Linux bwrap
6429
- // overlay. On darwin, `sandbox: true` (or the global sandboxEnabled()) confines
6430
- // WRITES via the same `sandbox-exec` wrapper as read isolation instead of bwrap
6431
- // (Linux-only). Reads stay open, matching the Linux "read-all / write-isolated"
6432
- // model; if read isolation is ALSO on, its read-deny set layers into the SAME
6433
- // profile. Like bwrap this is FAIL-SAFE: a requested sandbox that can't be
6434
- // established (unsandboxable backend, no SESSION_DATA_DIR) is a hard error at the
6435
- // spawn site below, never a silent unconfined run.
6436
- const willWriteSandbox = process.platform === 'darwin' && wantsFileSandbox;
6437
- if (willWriteSandbox && !process.env.SESSION_DATA_DIR) {
6438
- throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: missing SESSION_DATA_DIR`);
6439
- }
6440
- const fullIsolationCoversCredentials = !riffRemoteBackend && ((process.platform === 'darwin' && (willReadIsolate || willWriteSandbox))
6441
- || (process.platform === 'linux' && wantsFileSandbox));
6650
+ log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local sandbox bypassed');
6651
+ }
6652
+ const sandboxRequested = !riffRemoteBackend
6653
+ && (cfg.sandbox === true || cfg.readIsolation === true || sandboxEnabled());
6654
+ const fullIsolationCoversCredentials = sandboxRequested;
6442
6655
  let credentialMechanismAvailable = true;
6443
6656
  let credentialMechanismExecutable;
6444
6657
  if (mandatoryCredentialIsolation && !riffRemoteBackend && !fullIsolationCoversCredentials) {
@@ -6471,16 +6684,11 @@ async function spawnCli(cfg, opts = {}) {
6471
6684
  const credentialOnlySeatbelt = credentialIsolationGate.mode === 'seatbelt';
6472
6685
  const credentialOnlyBwrap = credentialIsolationGate.mode === 'bwrap';
6473
6686
  const appliedIsolationCapabilities = [];
6474
- if (credentialBoundaryActive
6475
- || willReadIsolate
6476
- || (process.platform === 'linux' && wantsFileSandbox)) {
6687
+ if (credentialBoundaryActive || sandboxRequested) {
6477
6688
  appliedIsolationCapabilities.push('credential');
6478
6689
  }
6479
- if (willReadIsolate)
6480
- appliedIsolationCapabilities.push('read');
6481
- if (willWriteSandbox || (process.platform === 'linux' && wantsFileSandbox)) {
6482
- appliedIsolationCapabilities.push('write');
6483
- }
6690
+ if (sandboxRequested)
6691
+ appliedIsolationCapabilities.push('read', 'write');
6484
6692
  currentCliCredentialIsolated = appliedIsolationCapabilities.includes('credential');
6485
6693
  const isolationRuntimeDataDir = process.env.SESSION_DATA_DIR
6486
6694
  ?? join(defaultBotmuxHome, 'data');
@@ -6495,8 +6703,7 @@ async function spawnCli(cfg, opts = {}) {
6495
6703
  // Preserve that recorded target for compatibility unless this incarnation
6496
6704
  // requires an isolation/MCP boundary that only a Botmux-owned session can
6497
6705
  // safely provide. Fresh tasks use distinct agents in one machine-wide host.
6498
- reuseRecordedHerdrTarget: !willReadIsolate
6499
- && !willWriteSandbox
6706
+ reuseRecordedHerdrTarget: !sandboxRequested
6500
6707
  && !hasMcpRuntimeEntries,
6501
6708
  });
6502
6709
  let selectedBackend = selectBackend();
@@ -6504,6 +6711,18 @@ async function spawnCli(cfg, opts = {}) {
6504
6711
  isPipeMode = selectedBackend.isPipeMode;
6505
6712
  isZellijMode = selectedBackend.isZellijMode;
6506
6713
  backend = selectedBackend.backend;
6714
+ // BOT_HOME CLI-data redirect: sandboxed bots whose adapter supports it keep
6715
+ // their CLI data (transcripts/memory/auth) in their own BOT_HOME via
6716
+ // CLAUDE_CONFIG_DIR/CODEX_HOME — under deny-by-default the global ~/.claude|
6717
+ // ~/.codex are simply not exposed. Best-effort: a non-supporting adapter
6718
+ // keeps its REAL data dirs instead, which the policy exposes readWrite (the
6719
+ // sandbox itself still applies). Decided EARLY so every JSONL/bridge/resume
6720
+ // path below already targets the right dir. wrapperCli strips spawn args, so
6721
+ // the redirect (and its env) can't be guaranteed there → not redirected.
6722
+ const willRedirectCliData = sandboxRequested
6723
+ && cliAdapter.supportsReadIsolation === true
6724
+ && !cfg.wrapperCli
6725
+ && !!process.env.SESSION_DATA_DIR;
6507
6726
  // Bump the CLI-lifetime nonce: any stuck-warning card posted by a previous
6508
6727
  // backend instance (within this same worker) must not inject keys into the
6509
6728
  // new one. The worker echoes this nonce in stuck_warning and re-checks it on
@@ -6527,7 +6746,7 @@ async function spawnCli(cfg, opts = {}) {
6527
6746
  }
6528
6747
  let isolationBotHome;
6529
6748
  let isolatedCodexHome;
6530
- if (willReadIsolate) {
6749
+ if (willRedirectCliData) {
6531
6750
  isolationBotHome = ownBotHome;
6532
6751
  const isClaudeFam = !!claudeDataDir;
6533
6752
  if (isClaudeFam)
@@ -6571,19 +6790,19 @@ async function spawnCli(cfg, opts = {}) {
6571
6790
  // the full in-memory conversation. In that case the resume-vs-fresh question
6572
6791
  // is moot: we must NOT run the pre-flight fallback (which would drop --resume
6573
6792
  // and post a misleading "started a fresh clean session — context lost" card
6574
- // on EVERY restart, e.g. for a sandboxed session whose transcript lives in
6575
- // an ephemeral overlay upper that the probe can't see). Computed here (not at
6793
+ // on EVERY restart, e.g. for a sandboxed session whose transcript lives at a
6794
+ // real host path the probe may not be able to stat). Computed here (not at
6576
6795
  // the spawn site below) so the pre-flight can short-circuit on it.
6577
6796
  let persistentSessionName = selectedBackend.persistentSessionName;
6578
6797
  // [read-isolation] Before we decide to reattach a persistent pane: a pane can
6579
6798
  // survive a daemon restart still running a CLI that may NOT be isolated (e.g.
6580
6799
  // spawned before isolation was enabled, or by an old build). Isolation is only
6581
6800
  // injectable at spawn time, so reattaching such a pane would silently run
6582
- // unisolated. We stamp a versioned policy marker when we spawn an isolated
6583
- // pane; if an existing pane was not spawned under the current policy, kill it
6584
- // so the probe below sees no pane and we cold-spawn fresh isolated. A current-
6585
- // policy pane survives daemon restarts and suspendresume safely because the
6586
- // confinement remains attached to the live process.
6801
+ // unisolated. We stamp a boot-id marker when we spawn an isolated pane; if this
6802
+ // isolated bot's existing pane is NOT stamped by THIS daemon lifetime, kill it
6803
+ // so the probe below sees no pane and we cold-spawn fresh isolated. A pane from
6804
+ // this lifetime (suspend→resume) keeps its markerreattaches normally (it is
6805
+ // still the isolated process). This lets isolated bots use tmux/zellij/herdr.
6587
6806
  if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
6588
6807
  const persistentTarget = selectedBackend.persistentBackendTarget;
6589
6808
  const paneLive = persistentTarget
@@ -6648,6 +6867,27 @@ async function spawnCli(cfg, opts = {}) {
6648
6867
  willReattachPersistent = selectedBackend.isReattach === true;
6649
6868
  }
6650
6869
  }
6870
+ const replacementExpectedFresh = codexRunnerFreshness === 'restarting_fresh';
6871
+ const freshness = decideCodexRunnerFreshness({
6872
+ cliId: cfg.cliId,
6873
+ adoptMode: cfg.adoptMode === true,
6874
+ persistentReattach: willReattachPersistent,
6875
+ replacementExpectedFresh,
6876
+ currentBuildId: cfg.runnerBuildId,
6877
+ persistedBuildId: cfg.persistedRunnerBuildId,
6878
+ });
6879
+ codexRunnerFreshness = freshness.state;
6880
+ persistCodexRunnerBuildOnReady = freshness.persistOnReady;
6881
+ log(`Codex runner freshness=${freshness.state} reason=${freshness.reason}`);
6882
+ if (freshness.reason === 'replacement_reattached' && activeRestartAttemptId) {
6883
+ send({
6884
+ type: 'restart_result',
6885
+ attemptId: activeRestartAttemptId,
6886
+ status: 'failed',
6887
+ category: 'spawn_failed',
6888
+ });
6889
+ activeRestartAttemptId = undefined;
6890
+ }
6651
6891
  // The plugin set is stable only for the lifetime of one real CLI process.
6652
6892
  // A warm worker reattach keeps the existing Gateway and catalog untouched;
6653
6893
  // every fresh/resumed CLI spawn atomically refreshes both from current Bot config.
@@ -6671,6 +6911,7 @@ async function spawnCli(cfg, opts = {}) {
6671
6911
  // diagnostic instead of having the prompt typed into it.
6672
6912
  bareShellLaunchBlocked = false;
6673
6913
  bareShellChecked = false;
6914
+ bareShellCheckInProgress = false;
6674
6915
  // ── Resume pre-flight check + two-tier fallback ──────────────────────────
6675
6916
  // Tier 1 (adapter probe): adapter.checkResumeTargetExists returns false
6676
6917
  // → skip --resume, spawn FRESH.
@@ -6708,6 +6949,13 @@ async function spawnCli(cfg, opts = {}) {
6708
6949
  log(`WARN Claude resume transcript sync failed: ${err.message}`);
6709
6950
  }
6710
6951
  }
6952
+ // Hermes stores sessions in a SQLite state.db (not cwd-scoped JSONL). Resolve
6953
+ // the same path the bridge uses — honoring per-bot env, the forced
6954
+ // BOTMUX_SESSION_ID, and the hermes-botmux-session wrapper profile — so the
6955
+ // adapter probe below queries the exact DB Hermes will `--resume` against.
6956
+ const hermesResumeStateDbPath = cfg.cliId === 'hermes'
6957
+ ? resolveHermesStateDbPath({ ...process.env, ...sanitizePerBotEnv(cfg.env), BOTMUX_SESSION_ID: cfg.sessionId }, { botmuxSessionProfile: basename(cfg.cliPathOverride ?? '') === 'hermes-botmux-session' })
6958
+ : undefined;
6711
6959
  const tier2ForceFresh = effectiveResume && consecutiveInWorkerRestarts >= 2;
6712
6960
  let tier1ProbeFalse = false;
6713
6961
  if (effectiveResume && !tier2ForceFresh && !willReattachPersistent) {
@@ -6716,6 +6964,7 @@ async function spawnCli(cfg, opts = {}) {
6716
6964
  cliSessionId: effectiveCliSessionId,
6717
6965
  workingDir: cfg.workingDir,
6718
6966
  dataDir: claudeDataDir,
6967
+ stateDbPath: hermesResumeStateDbPath,
6719
6968
  });
6720
6969
  if (probe === false)
6721
6970
  tier1ProbeFalse = true;
@@ -6835,81 +7084,39 @@ async function spawnCli(cfg, opts = {}) {
6835
7084
  lastSpawnDeferInitialPrompt = deferInitialPrompt;
6836
7085
  kiroSessionIdCaptureArmed = cfg.cliId === 'kiro-cli' && !effectiveCliSessionId && !willReattachPersistent;
6837
7086
  kiroSessionIdCaptureBuffer = '';
6838
- // Per-bot local read isolation: assemble the Seatbelt profile context (the gate
6839
- // already fail-closed above reaching here with willReadIsolate means it is
6840
- // enforceable). The worker is on the host (NOT sandboxed), so it holds the
6841
- // secret; only the spawned CLI child is confined. A reattach that reaches here
6842
- // is safe: the stale-pane guard above already killed any persistent pane not
6843
- // stamped as isolated, so `willReattachPersistent` can now only be true for a
6844
- // pane spawned isolated (still the confined process).
6845
- let readIsolationCtx;
6846
- if (willReadIsolate) {
6847
- const sessionDataDir = process.env.SESSION_DATA_DIR;
6848
- readIsolationCtx = {
6849
- homeDir: homedir(),
6850
- botmuxHome: dirname(sessionDataDir),
6851
- defaultBotmuxHome: join(homedir(), '.botmux'),
6852
- sessionDataDir,
6853
- currentAppId: cfg.larkAppId,
6854
- currentSessionId: cfg.sessionId,
6855
- extraDenyPaths: cfg.readDenyExtraPaths,
6856
- };
6857
- readIsolationOriginCapabilityFile = process.platform === 'darwin'
6858
- ? managedOriginCapabilityPath(sessionDataDir, cfg.sessionId)
6859
- : null;
6860
- // Replace any legacy child-planted symlink before Seatbelt canonicalizes
6861
- // carve-outs. A failure is fatal: spawning with a missing/ambiguous
6862
- // capability transport would either break all IPC or reopen an attacker-
6863
- // selected realpath in the generated profile.
6864
- if (readIsolationOriginCapabilityFile) {
6865
- publishSandboxRelayCapability({ failClosed: true });
6866
- }
6867
- // Write this bot's OWN send-credential into its BOT_HOME (the same per-bot
6868
- // private storage as its CLI data; siblings' BOT_HOMEs are whole-denied).
6869
- // `botmux send` reads the secret from here instead of bots.json — so the
6870
- // secret never travels via env/argv (no cross-bot `ps aux` leak) and the CLI
6871
- // never needs to escape the sandbox.
7087
+ // Sandboxed sessions: write this bot's OWN send-credential into its BOT_HOME.
7088
+ // `botmux send` reads the secret from here instead of bots.json (which
7089
+ // deny-by-default never exposes) the secret never travels via env/argv (no
7090
+ // cross-bot `ps aux` leak) and the CLI never needs to escape the sandbox.
7091
+ // The worker itself runs on the host (NOT sandboxed) and keeps full access.
7092
+ if (sandboxRequested && process.env.SESSION_DATA_DIR) {
6872
7093
  try {
6873
- const credPath = sendCredFilePath(sessionDataDir, cfg.larkAppId);
7094
+ const credPath = sendCredFilePath(process.env.SESSION_DATA_DIR, cfg.larkAppId);
6874
7095
  mkdirSync(dirname(credPath), { recursive: true });
6875
7096
  writeFileSync(credPath, JSON.stringify({ larkAppId: cfg.larkAppId, larkAppSecret: cfg.larkAppSecret, brand: cfg.brand }), { mode: 0o600 });
6876
7097
  }
6877
7098
  catch (e) {
6878
- log(`[read-isolation] WARN could not write send-cred file: ${e.message}`);
7099
+ log(`[sandbox] WARN could not write send-cred file: ${e.message}`);
6879
7100
  }
6880
7101
  }
6881
- // macOS write-sandbox rules (pure): the writable-zone allow-list + crown-jewel
6882
- // re-denies. Realpath'd + emitted into the Seatbelt profile at the spawn site.
6883
- let writeSandboxRules;
6884
- if (willWriteSandbox && process.env.SESSION_DATA_DIR) {
6885
- const sessionDataDir = process.env.SESSION_DATA_DIR;
6886
- // Regex rules cannot be realpath'd after construction, so build their home
6887
- // prefix from the canonical path up front (Seatbelt matches canonical paths).
6888
- const canonicalSandboxPath = (path) => {
6889
- try {
6890
- return realpathSync(path);
6891
- }
6892
- catch {
6893
- return path;
6894
- }
6895
- };
6896
- const sandboxHome = canonicalSandboxPath(homedir());
6897
- writeSandboxRules = buildWriteSandboxRules({
6898
- homeDir: sandboxHome,
6899
- botmuxHome: canonicalSandboxPath(dirname(sessionDataDir)),
6900
- defaultBotmuxHome: canonicalSandboxPath(join(homedir(), '.botmux')),
6901
- sessionDataDir: canonicalSandboxPath(sessionDataDir),
6902
- workingDir: cfg.workingDir,
6903
- currentAppId: cfg.larkAppId,
6904
- // TMPDIR on macOS resolves under /private/var/folders (already allowed), but
6905
- // pass it explicitly so a customized TMPDIR is covered too.
6906
- extraWritePaths: process.env.TMPDIR ? [process.env.TMPDIR] : [],
6907
- });
6908
- }
7102
+ // In the file sandbox, canonicalize the workingDir handed to buildArgs. CLIs
7103
+ // that pass it as a chdir arg (codex/traex `-C`) fail otherwise on a symlinked
7104
+ // $HOME host: the lexical /home/u/... prefix doesn't exist inside the bwrap
7105
+ // root (only canonical /data00/... is bound), so the CLI's chdir/readlink
7106
+ // ENOENTs and it aborts with "No such file or directory (os error 2)". Off
7107
+ // sandbox this is a no-op (same dir); best-effort if unresolvable.
7108
+ const buildArgsWorkingDir = sandboxRequested
7109
+ ? (() => { try {
7110
+ return realpathSync(cfg.workingDir);
7111
+ }
7112
+ catch {
7113
+ return cfg.workingDir;
7114
+ } })()
7115
+ : cfg.workingDir;
6909
7116
  const args = cliAdapter.buildArgs({
6910
7117
  sessionId: effectiveAdapterSessionId,
6911
7118
  resume: effectiveResume,
6912
- workingDir: cfg.workingDir,
7119
+ workingDir: buildArgsWorkingDir,
6913
7120
  resumeSessionId: effectiveCliSessionId,
6914
7121
  initialPrompt: preparedInitialPrompt,
6915
7122
  botName: cfg.botName,
@@ -6919,12 +7126,7 @@ async function spawnCli(cfg, opts = {}) {
6919
7126
  model: ttadkGateway ? undefined : cfg.model,
6920
7127
  disableCliBypass: cfg.disableCliBypass === true,
6921
7128
  skillPluginDir: cfg.skillPluginDir,
6922
- readIsolation: willReadIsolate,
6923
- // Set (as a pair) by the init handler when hybrid RPC input is engaged;
6924
- // makes buildArgs launch `codex --remote <ws> resume <thread>` instead of
6925
- // the normal paste-mode codex. Undefined for every other bot/CLI.
6926
- remoteWsUrl,
6927
- remoteThreadId,
7129
+ readIsolation: willRedirectCliData,
6928
7130
  });
6929
7131
  // Pi's deferred long-first-prompt command is implemented by a session-scoped
6930
7132
  // extension. Keep its launch args across owned process restarts while the
@@ -7023,15 +7225,18 @@ async function spawnCli(cfg, opts = {}) {
7023
7225
  delete childEnv.BOTMUX_CHAT_TYPE;
7024
7226
  childEnv.BOTMUX_LARK_APP_ID = cfg.larkAppId;
7025
7227
  childEnv.BOTMUX_ROOT_MESSAGE_ID = cfg.rootMessageId;
7026
- // Session owner under the standard BOTMUX_* name (the riff backend already
7027
- // exposes it; `__OWNER_OPEN_ID` stays for back-compat). Custom CLI wrappers
7028
- // use it for per-user permission isolation. Advisory identity only botmux's
7029
- // own authz paths (v3 command authority, host.ts) ignore env owner values and
7030
- // resolve the current-turn caller from turn provenance instead.
7031
- if (cfg.ownerOpenId)
7032
- childEnv.BOTMUX_OWNER_OPEN_ID = cfg.ownerOpenId;
7033
- else
7034
- delete childEnv.BOTMUX_OWNER_OPEN_ID;
7228
+ // This bot's resolved brandLabel template, injected so a SANDBOXED `botmux
7229
+ // send` renders the role-name footer without reading bots.json (deny-by-
7230
+ // default EPERM role footer would silently fall back to the default
7231
+ // [botmux] label). resolveBrandLabel honours this env first (gated on the
7232
+ // own appId). Only set the key when a brandLabel is configured (present-but-
7233
+ // empty '' = suppress is preserved; unset key → the CLI falls through). It is
7234
+ // a cosmetic markdown template, not a credential, so env-passing is safe.
7235
+ {
7236
+ const bl = resolveBrandLabel(cfg.larkAppId);
7237
+ if (typeof bl === 'string')
7238
+ childEnv.BOTMUX_BRAND_LABEL = bl;
7239
+ }
7035
7240
  // NOTE: under read isolation `botmux send` gets this bot's secret from the worker-
7036
7241
  // written cred FILE in its BOT_HOME (send-cred.json, see sendCredFilePath) located
7037
7242
  // via the BOTMUX_LARK_APP_ID above — NOT from the env. The secret is deliberately kept OUT
@@ -7075,10 +7280,27 @@ async function spawnCli(cfg, opts = {}) {
7075
7280
  // already guarantee the pane starts clean, and the CLI's built-in default
7076
7281
  // preserves stock semantics.
7077
7282
  if (isolationBotHome) {
7283
+ // In the file sandbox, bwrap binds only CANONICAL paths (the fs-policy
7284
+ // realpaths every rule). On a symlinked-$HOME host (/home/u → /data00/home/u)
7285
+ // the lexical BOT_HOME (/home/u/.botmux/bots/…/claude) does NOT exist in the
7286
+ // fresh bwrap root, so the CLI can't open its own CLAUDE_CONFIG_DIR/CODEX_HOME
7287
+ // → settings.json (with ANTHROPIC_BASE_URL/token) is unreadable → it falls
7288
+ // back to the public endpoint and fails to connect. Canonicalize so the env
7289
+ // points at the same path bwrap bound. Best-effort: keep lexical if unresolved.
7290
+ const canonicalizeForSandbox = (p) => {
7291
+ if (!sandboxRequested)
7292
+ return p;
7293
+ try {
7294
+ return realpathSync(p);
7295
+ }
7296
+ catch {
7297
+ return p;
7298
+ }
7299
+ };
7078
7300
  if (claudeDataDir)
7079
- childEnv.CLAUDE_CONFIG_DIR = claudeDataDir; // = <BOT_HOME>/claude
7301
+ childEnv.CLAUDE_CONFIG_DIR = canonicalizeForSandbox(claudeDataDir); // = <BOT_HOME>/claude
7080
7302
  else
7081
- childEnv.CODEX_HOME = isolatedCodexHome;
7303
+ childEnv.CODEX_HOME = canonicalizeForSandbox(isolatedCodexHome);
7082
7304
  }
7083
7305
  // Per-bot env (bots.json `env`): extra vars for THIS bot's CLI only — e.g.
7084
7306
  // ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN to run a bot on GLM/a 3rd-party
@@ -7108,356 +7330,348 @@ async function spawnCli(cfg, opts = {}) {
7108
7330
  // reproduce-command.ts。这里只锁定"包装前的基础"这个事实。
7109
7331
  const reproduceBaseBin = spawnBin;
7110
7332
  const reproduceBaseArgs = [...spawnArgs];
7111
- // Read isolation (macOS): wrap the whole CLI process in a Seatbelt sandbox that
7112
- // denies reads of the sensitive paths (blocklist). The CLI bypasses its OWN
7113
- // sandbox (see adapter) so the outer wrapper is the sole enforcer. DARWIN ONLY —
7114
- // on Linux read isolation is enforced by bwrap masks fed into the overlay sandbox
7115
- // below (see readIsoLinuxMasks), NOT sandbox-exec (which doesn't exist there).
7116
- if (process.platform === 'darwin' && (readIsolationCtx || writeSandboxRules)) {
7117
- // Seatbelt matches CANONICAL paths (it resolves symlinks), so realpath every
7118
- // deny/allow before emitting the profile otherwise a sensitive root reached
7119
- // through a symlinked prefix (e.g. a symlinked home / SESSION_DATA_DIR) would
7120
- // silently fail-open (the /tmp→/private/tmp class of miss). realpath-if-exists:
7121
- // a non-existent path has nothing to read, so its literal form is harmless.
7122
- // The ROOT dirs are canonicalized FIRST (profileCtx) so the regex patterns —
7123
- // which can't be realpath'd as a result — are built on canonical prefixes too.
7333
+ // ── UNIFIED file sandbox (fs-policy): ONE policy source, BOTH platforms. ──
7334
+ // Three-tier deny-by-default whitelist compiled to Seatbelt (darwin) or bwrap
7335
+ // (linux). Cross-bot read isolation is inherent (siblings' data simply isn't
7336
+ // exposed) no enumeration, no deny-list to keep in sync.
7337
+ if (sandboxRequested) {
7338
+ const dataDir = process.env.SESSION_DATA_DIR;
7339
+ // FAIL-SAFE (not fail-open): a requested sandbox that can't be established
7340
+ // must be a HARD ERROR, never a silent unconfined run.
7341
+ if (effectiveBackendType !== 'pty' && effectiveBackendType !== 'tmux') {
7342
+ const msg = `Sandbox ENABLED but backend "${effectiveBackendType}" is not sandboxable (only pty/tmux) aborting spawn to avoid an unsandboxed run`;
7343
+ log(msg);
7344
+ throw new Error(msg);
7345
+ }
7346
+ if (!dataDir) {
7347
+ const msg = 'Sandbox ENABLED but SESSION_DATA_DIR is unset — aborting spawn to avoid an unsandboxed run';
7348
+ log(msg);
7349
+ throw new Error(msg);
7350
+ }
7351
+ // Both engines match CANONICAL paths (Seatbelt resolves symlinks, bwrap
7352
+ // resolves mount sources) — realpath everything, or a symlinked prefix
7353
+ // (the /tmp→/private/tmp class) silently fail-opens.
7124
7354
  const canonical = (p) => { try {
7125
7355
  return realpathSync(p);
7126
7356
  }
7127
7357
  catch {
7128
7358
  return p;
7129
7359
  } };
7130
- // READ rules — only when read isolation is on. v2 HYBRID model: whole-deny
7131
- // ~/.claude|~/.codex (F1 fix own CLI data is redirected into BOT_HOME,
7132
- // readable) + surgical-deny only the cross-bot-SENSITIVE parts of the otherwise-
7133
- // readable ~/.botmux + system creds; the own slice is re-opened via carve-outs.
7134
- // For a WRITE-sandbox-only session these stay empty → reads wide open (Linux parity).
7135
- let denyPaths = [], denyRegexes = [], allowPaths = [], finalDenyPaths = [], traverseDirs = [];
7136
- let protectedWrites;
7137
- if (readIsolationCtx) {
7138
- const profileCtx = {
7139
- ...readIsolationCtx,
7140
- homeDir: canonical(readIsolationCtx.homeDir),
7141
- botmuxHome: canonical(readIsolationCtx.botmuxHome),
7142
- defaultBotmuxHome: canonical(readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux')),
7143
- sessionDataDir: canonical(readIsolationCtx.sessionDataDir),
7144
- };
7145
- denyPaths = buildV2DenyPaths(profileCtx).map(canonical);
7146
- denyRegexes = buildV2DenyRegexes(profileCtx);
7147
- const carve = buildV2CarveOuts(profileCtx);
7148
- const capabilityCarvePath = managedOriginCapabilityPath(profileCtx.sessionDataDir, profileCtx.currentSessionId);
7149
- allowPaths = [
7150
- // Never realpath the capability leaf: another legacy sandbox can race
7151
- // a symlink into that shared host directory while multiple persistent
7152
- // sessions recover. Keeping the exact path may fail closed on a raced
7153
- // symlink, but can never turn its target into a Seatbelt allow rule.
7154
- ...carve.allowPaths.map(path => path === capabilityCarvePath ? path : canonical(path)),
7155
- ...buildCliExecutableReadCarveOuts({
7156
- homeDir: profileCtx.homeDir,
7157
- cliId: cliAdapter.id,
7158
- resolvedBin: canonical(cliAdapter.resolvedBin),
7159
- }),
7160
- // buildV2DenyPaths masks the shared pi-initial-prompts root. Re-open
7161
- // only this session's private child directory for Pi's @file/extension.
7162
- ...piInitialPromptReadonlyRoots.map(canonical),
7163
- ];
7164
- finalDenyPaths = carve.finalDenyPaths.map(canonical);
7165
- traverseDirs = carve.traverseDirs.map(canonical);
7166
- protectedWrites = buildReadIsolationProtectedWriteRules(profileCtx, {
7167
- // Keep lexical roots as well as their canonical targets. Canonical
7168
- // rules protect contents reached through a symlink; lexical literals
7169
- // prevent replacing the symlink/root directory entry itself.
7170
- dashboardRoots: [
7171
- readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux'),
7172
- readIsolationCtx.botmuxHome,
7173
- ],
7174
- sessionDataDirs: [readIsolationCtx.sessionDataDir],
7175
- });
7176
- }
7177
- if (credentialBoundaryActive) {
7178
- const credentialRules = buildCredentialIsolationRules({
7179
- homeDir: canonical(hostHomeDir),
7180
- botmuxHome: canonical(configuredBotmuxHome),
7181
- defaultBotmuxHome: canonical(defaultBotmuxHome),
7182
- });
7183
- denyPaths = [...new Set([...denyPaths, ...credentialRules.denyPaths.map(canonical)])];
7184
- denyRegexes = [...new Set([...denyRegexes, ...credentialRules.denyRegexes])];
7185
- protectedWrites = {
7186
- denyWritePaths: [...new Set([
7187
- ...(protectedWrites?.denyWritePaths ?? []),
7188
- ...credentialRules.denyWritePaths.map(canonical),
7189
- ])],
7190
- denyWriteRegexes: [...new Set([
7191
- ...(protectedWrites?.denyWriteRegexes ?? []),
7192
- ...credentialRules.denyWriteRegexes,
7193
- ])],
7194
- denyWriteLiterals: [...new Set([
7195
- ...(protectedWrites?.denyWriteLiterals ?? []),
7196
- ...credentialRules.denyWriteLiterals.map(canonical),
7197
- defaultBotmuxHome,
7198
- configuredBotmuxHome,
7199
- ])],
7200
- };
7201
- }
7202
- if (mcpRuntimeManifest) {
7203
- finalDenyPaths.push(...sessionMcpRuntimeHostOnlyPaths(mcpRuntimeManifest, config.session.dataDir).map(canonical));
7204
- }
7205
- // Every isolated macOS CLI is denied access to all same-UID Gateway socket
7206
- // directories. A session with its own Gateway gets one narrow, later READ
7207
- // carve-out for its token; writes stay denied so the CLI cannot unlink or
7208
- // replace even its own worker-managed socket. Sessions without plugin MCP
7209
- // state get no read carve-out at all.
7210
- const gatewaySocketRoot = canonical(sessionMcpGatewayHost ? dirname(sessionMcpGatewayHost.socketDir) : tmpdir());
7211
- const gatewaySocketRegex = sessionMcpGatewayPathRegex(gatewaySocketRoot);
7212
- denyRegexes.push(gatewaySocketRegex);
7213
- if (sessionMcpGatewayHost)
7214
- allowPaths.push(canonical(sessionMcpGatewayHost.socketDir));
7215
- protectedWrites = {
7216
- denyWritePaths: protectedWrites?.denyWritePaths ?? [],
7217
- denyWriteRegexes: [...(protectedWrites?.denyWriteRegexes ?? []), gatewaySocketRegex],
7218
- denyWriteLiterals: protectedWrites?.denyWriteLiterals ?? [],
7360
+ const sandboxHome = canonical(homedir());
7361
+ const expandTilde = (raw) => raw.replace(/^~(?=\/|$)/, sandboxHome);
7362
+ const keepExisting = (paths) => {
7363
+ const out = [];
7364
+ for (const raw of paths) {
7365
+ if (!raw || typeof raw !== 'string')
7366
+ continue;
7367
+ const p = expandTilde(raw);
7368
+ try {
7369
+ if (existsSync(p))
7370
+ out.push(canonical(p));
7371
+ }
7372
+ catch { /* */ }
7373
+ }
7374
+ return out;
7219
7375
  };
7220
- // WRITE rules the macOS file-sandbox (Linux-bwrap twin). Realpath the writable
7221
- // zones + crown-jewel re-denies for the same symlink-safety reason as reads.
7222
- const writeRules = writeSandboxRules
7223
- ? {
7224
- allowWritePaths: writeSandboxRules.allowWritePaths.map(canonical),
7225
- allowWriteRegexes: writeSandboxRules.allowWriteRegexes,
7226
- denyWritePaths: writeSandboxRules.denyWritePaths.map(canonical),
7227
- denyWriteRegexes: writeSandboxRules.denyWriteRegexes,
7376
+ // User three-tier lists: the new sandboxPaths field, or a pre-migration
7377
+ // session/config's legacy fields mapped through the SAME lossless mapping
7378
+ // the startup migration uses.
7379
+ const legacyMapped = migrateLegacySandboxFields({
7380
+ sandbox: cfg.sandbox,
7381
+ readIsolation: cfg.readIsolation,
7382
+ sandboxReadonlyPaths: cfg.sandboxReadonlyPaths,
7383
+ sandboxHidePaths: cfg.sandboxHidePaths,
7384
+ readDenyExtraPaths: cfg.readDenyExtraPaths,
7385
+ sandboxPaths: cfg.sandboxPaths,
7386
+ });
7387
+ const userLists = cfg.sandboxPaths ?? legacyMapped?.sandboxPaths;
7388
+ const droppedUser = [];
7389
+ const resolveUser = (paths) => {
7390
+ const out = [];
7391
+ for (const raw of paths ?? []) {
7392
+ if (!raw || typeof raw !== 'string')
7393
+ continue;
7394
+ const p = expandTilde(raw);
7395
+ if (existsSync(p))
7396
+ out.push(canonical(p));
7397
+ else
7398
+ droppedUser.push(raw);
7228
7399
  }
7400
+ return out;
7401
+ };
7402
+ // deny paths are NOT existence-filtered on EITHER platform: a deny must guard
7403
+ // a path that does not exist YET (else the agent creates it under an exposed
7404
+ // rw parent and the deny is bypassed — codex review finding). Seatbelt keeps
7405
+ // the rule literally; bwrap masks it with a tmpfs the mount creates (writes
7406
+ // land in the ephemeral tmpfs, the real path is never created).
7407
+ const resolveDeny = (paths) => (paths ?? []).filter((p) => typeof p === 'string' && !!p).map(p => canonical(expandTilde(p)));
7408
+ const userPaths = {
7409
+ readWrite: resolveUser(userLists?.readWrite),
7410
+ readOnly: resolveUser(userLists?.readOnly),
7411
+ deny: resolveDeny(userLists?.deny),
7412
+ };
7413
+ if (droppedUser.length)
7414
+ log(`[sandbox] sandboxPaths entries dropped (path not found): ${droppedUser.join(', ')}`);
7415
+ // Every executable spawned INSIDE the sandbox must be readable: the CLI
7416
+ // binary's dir, the daemon's own node (fnm farms under /run land here),
7417
+ // adapter second-stage bins, plus the standalone-codex package tree.
7418
+ const execDirs = [cliAdapter.resolvedBin, process.execPath, ...(cliAdapter.sandboxExtraExecPaths?.() ?? [])]
7419
+ .filter((p) => typeof p === 'string' && !!p)
7420
+ .map(p => dirname(canonical(p)));
7421
+ const execCarve = buildCliExecutableReadCarveOuts({
7422
+ homeDir: sandboxHome,
7423
+ cliId: cliAdapter.id,
7424
+ resolvedBin: canonical(cliAdapter.resolvedBin),
7425
+ }).map(canonical);
7426
+ // Linux relay outbox (created by prepareDirectSandbox; in the policy so the
7427
+ // compiled plan binds it read-write).
7428
+ const outbox = process.platform === 'linux'
7429
+ ? join(canonical(dataDir), 'sandboxes', cfg.sessionId, 'outbox')
7229
7430
  : undefined;
7230
- if (!locateOnPath('sandbox-exec')) {
7231
- throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: sandbox-exec not found`);
7232
- }
7233
- const profileDir = join(isolationRuntimeDataDir, 'read-isolation');
7234
- mkdirSync(profileDir, { recursive: true });
7235
- const profilePath = join(profileDir, `${cfg.sessionId}.sb`);
7236
- replaceManagedOriginCapabilityFile(profilePath, buildSeatbeltProfile(denyPaths, allowPaths, finalDenyPaths, traverseDirs, denyRegexes, writeRules, protectedWrites));
7237
- seatbeltProfilePath = profilePath;
7238
- spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs];
7239
- spawnBin = credentialMechanismExecutable ?? '/usr/bin/sandbox-exec';
7240
- log(`[file-sandbox] wrapping ${cliAdapter.id} in Seatbelt (read-isolation=${!!readIsolationCtx}, write-sandbox=${!!writeRules}): sandbox-exec -f ${profilePath}`);
7241
- }
7242
- // Fresh sandboxed spawn on a persistent backend: stamp the pane with this daemon's
7243
- // policy version + boot id so a later suspend→resume reattach can be trusted (see the stale-pane
7244
- // guard above). Applies to read-isolation AND write-sandbox panes (both carry the
7245
- // Seatbelt confinement on the live process). pty needs no marker (never reattached).
7246
- if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
7431
+ // Pre-create the outbox BEFORE buildFsPolicy so it survives the allow-rule
7432
+ // existence-filter below (a not-yet-existing readWrite path is dropped, and
7433
+ // bwrap can't bind a missing source → the sandboxed `botmux send` relay
7434
+ // would EPERM/ENOENT writing its <hash>.content into an unbound dir).
7435
+ // prepareDirectSandbox re-mkdirs it too; recursive make is idempotent.
7436
+ if (outbox) {
7437
+ try {
7438
+ mkdirSync(outbox, { recursive: true });
7439
+ }
7440
+ catch { /* best-effort; prepareDirectSandbox retries */ }
7441
+ }
7442
+ // The botmux install/checkout root (dir containing dist/ + node_modules).
7443
+ // This module compiles to <checkout>/dist/worker.js, so `../../` from here is
7444
+ // the checkout root. Exposed readOnly so `botmux` + claude hooks can exec
7445
+ // `node <checkout>/dist/cli.js`.
7446
+ const botmuxInstallRoot = canonical(dirname(dirname(fileURLToPath(import.meta.url))));
7447
+ // Pre-create the OWN writable dirs/files the sandboxed CLI creates on
7448
+ // demand, so they EXIST at spawn and survive the existence-filter below
7449
+ // (bwrap can't bind a nonexistent source; a dropped rule → the CLI's
7450
+ // mkdir/write EPERMs).
7451
+ // - data/turn-sends/<sessionId>.jsonl: `botmux send` appends its dedup
7452
+ // marker. The policy grants ONLY this exact file (not the whole dir) so a
7453
+ // sandboxed CLI can't rewrite another session's marker — pre-create the
7454
+ // file itself (touch) so the single-file bind has a source.
7455
+ // - data/attachments/<self>: `botmux quoted`/downloadResources writes here
7456
+ // (BOT_HOME + outbox are created elsewhere.) Best-effort — a failure just
7457
+ // reverts to the pre-existing drop behaviour.
7247
7458
  try {
7248
- const markerDir = join(isolationRuntimeDataDir, 'read-isolation');
7249
- mkdirSync(markerDir, { recursive: true });
7250
- replaceManagedOriginCapabilityFile(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities));
7251
- }
7252
- catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
7253
- }
7254
- // Sandbox wraps the spawned binary in bwrap. Works for pty (PtyBackend runs
7255
- // bwrap directly) and tmux (the tmux pane's command becomes `bwrap … -- cli`);
7256
- // env is carried via bwrap --setenv (see prepareSandbox), not the backend.
7257
- // Linux ONLY — on macOS the same `sandbox: true` is enforced by the Seatbelt
7258
- // write-sandbox above (willWriteSandbox), so bwrap must not run here.
7259
- //
7260
- // riff bypass: there is NO local CLI process to wrap — execution happens in
7261
- // riff's own remote sandbox, and the fail-safe "backend not sandboxable"
7262
- // hard error below would otherwise brick every sandbox-enabled bot the
7263
- // moment it switches to riff (the dashboard agent switch clears only
7264
- // readIsolation, not `sandbox`). Bypassing is safe (nothing local runs);
7265
- // log it so the state is visible.
7266
- if (cfg.sandbox === true && effectiveBackendType === 'riff') {
7267
- log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local file sandbox bypassed');
7268
- }
7269
- const sandboxOn = localSandboxApplies(process.platform, effectiveBackendType) && (cfg.sandbox === true || sandboxEnabled());
7270
- // Linux read isolation: build the bwrap MASK set (the Seatbelt read-deny twin) and
7271
- // fold it into the SAME overlay sandbox plan below. Enumerate sibling bots from the
7272
- // FILESYSTEM (the worker runs unsandboxed on the host; bots.json may be denied) —
7273
- // spawn-time-static, so a bot added after this spawn is covered on its next cold
7274
- // start (the accepted Linux tradeoff vs the macOS regex). Only when read isolation
7275
- // is enforceable here (willReadIsolate ⇒ readIsolationCtx set) AND on Linux.
7276
- let readIsoLinuxMasks;
7277
- if (readIsolationCtx && process.platform === 'linux') {
7278
- const canon = (p) => { try {
7279
- return realpathSync(p);
7459
+ const tsDir = join(dataDir, 'turn-sends');
7460
+ mkdirSync(tsDir, { recursive: true });
7461
+ const tsFile = join(tsDir, `${cfg.sessionId}.jsonl`);
7462
+ if (!existsSync(tsFile))
7463
+ writeFileSync(tsFile, '');
7280
7464
  }
7281
- catch {
7282
- return p;
7283
- } };
7284
- // Canonicalize the root prefixes FIRST so every derived mask path is symlink-safe
7285
- // (bwrap masks resolve symlinks — a symlinked home/data root would else fail-open).
7286
- const ctxReal = {
7287
- ...readIsolationCtx,
7288
- homeDir: canon(readIsolationCtx.homeDir),
7289
- botmuxHome: canon(readIsolationCtx.botmuxHome),
7290
- defaultBotmuxHome: canon(readIsolationCtx.defaultBotmuxHome ?? join(readIsolationCtx.homeDir, '.botmux')),
7291
- sessionDataDir: canon(readIsolationCtx.sessionDataDir),
7292
- };
7293
- const ids = new Set();
7294
- const scan = (dir, pick) => {
7295
- try {
7296
- for (const name of readdirSync(dir)) {
7297
- const id = pick(name);
7298
- if (id)
7299
- ids.add(id);
7300
- }
7301
- }
7302
- catch { /* dir absent → no siblings there */ }
7303
- };
7304
- scan(join(ctxReal.botmuxHome, 'bots'), n => n); // sibling BOT_HOME dirs
7305
- scan(join(ctxReal.homeDir, '.lark-cli-bots'), n => n); // sibling lark configs
7306
- scan(ctxReal.sessionDataDir, n => { const m = /^sessions-(.+)\.json$/.exec(n); return m ? m[1] : null; });
7307
- ids.delete(cfg.larkAppId); // never mask own
7308
- let sidecars = [];
7465
+ catch { /* */ }
7309
7466
  try {
7310
- sidecars = readdirSync(ctxReal.botmuxHome).filter(n => n.startsWith('bots.json.')).map(n => join(ctxReal.botmuxHome, n));
7467
+ mkdirSync(join(dataDir, 'attachments', cfg.larkAppId), { recursive: true });
7311
7468
  }
7312
7469
  catch { /* */ }
7313
- const m = buildLinuxReadIsolationMasks({ ctx: ctxReal, siblingAppIds: [...ids], botsJsonSidecars: sidecars });
7314
- // Same execvp carve-out as macOS (Codex 342a3e1c): a standalone Codex whose
7315
- // binary lives UNDER the now-masked ~/.codex would fail execvp. Re-expose ONLY
7316
- // its executable package tree (read-only, after the masks) — auth/config/sessions
7317
- // stay masked. No-op for npm/system installs (binary not under ~/.codex) + non-codex.
7318
- const execCarveOuts = buildCliExecutableReadCarveOuts({
7319
- homeDir: ctxReal.homeDir,
7320
- cliId: cliAdapter.id,
7321
- resolvedBin: canon(cliAdapter.resolvedBin),
7322
- }).map(canon);
7323
- readIsoLinuxMasks = {
7324
- hidePaths: m.hidePaths.map(canon),
7325
- ownReadWritePaths: m.ownReadWritePaths.map(canon),
7326
- ownReadOnlyPaths: [...m.ownReadOnlyPaths.map(canon), ...execCarveOuts],
7327
- };
7328
- log(`[read-isolation] linux bwrap masks: ${m.hidePaths.length} hide, ${[...ids].length} siblings enumerated${execCarveOuts.length ? ', +codex-standalone exec carve' : ''}`);
7329
- }
7330
- if (sandboxOn) {
7331
- // FAIL-SAFE (not fail-open): when the sandbox is requested, a missing
7332
- // precondition (no SESSION_DATA_DIR, or a backend we can't wrap) must be a
7333
- // HARD ERROR, never a silent skip — otherwise the CLI would spawn UNSANDBOXED
7334
- // with full host write access and no log, the exact opposite of the
7335
- // oncall-untrusted-agent invariant. In normal operation worker-pool sets
7336
- // SESSION_DATA_DIR and the backend is pty/tmux, so this never trips.
7337
- const dataDir = process.env.SESSION_DATA_DIR;
7338
- if (effectiveBackendType !== 'pty' && effectiveBackendType !== 'tmux') {
7339
- const msg = `Sandbox ENABLED but backend "${effectiveBackendType}" is not sandboxable (only pty/tmux) — aborting spawn to avoid an unsandboxed run`;
7340
- log(msg);
7341
- throw new Error(msg);
7470
+ // (Schedules moved into each bot's BOT_HOME the whole dir is already
7471
+ // bound readWrite for the owner, so no per-file pre-create is needed.)
7472
+ const mandatoryDenyPaths = [];
7473
+ const mandatoryDenyRegexes = [];
7474
+ const mandatoryReadOnlyPaths = [];
7475
+ // Linux: the per-session sandbox tree (`sandboxes/<sid>`) holds the deny-mask
7476
+ // cleanup manifest + the mode-000 empty ro-bind SOURCES. If SESSION_DATA_DIR
7477
+ // is configured INSIDE the working dir (a custom data dir under a RW-bound
7478
+ // project), the project's readWrite rule would otherwise cover the whole
7479
+ // tree and let the sandboxed CLI tamper with the manifest (→ trick teardown
7480
+ // into deleting arbitrary host paths) or the empty sources. Deny the tree
7481
+ // as a MANDATORY rule (user paths can't override); the outbox is re-granted
7482
+ // readWrite via ctx.outbox as a deeper carve-out (the compiler masks the
7483
+ // tree with a tmpfs that hosts the nested outbox bind, then remounts it RO —
7484
+ // manifest/empties stay unreadable AND unwritable in-sandbox). bwrap reads
7485
+ // ro-bind SOURCES from the host FS, so the empty masks still work even though
7486
+ // the child can't see them.
7487
+ if (process.platform === 'linux') {
7488
+ mandatoryDenyPaths.push(join(canonical(dataDir), 'sandboxes', cfg.sessionId));
7342
7489
  }
7343
- if (!dataDir) {
7344
- const msg = 'Sandbox ENABLED but SESSION_DATA_DIR is unset — aborting spawn to avoid an unsandboxed run';
7345
- log(msg);
7346
- throw new Error(msg);
7490
+ readIsolationOriginCapabilityFile = process.platform === 'darwin'
7491
+ ? managedOriginCapabilityPath(dataDir, cfg.sessionId)
7492
+ : null;
7493
+ // The macOS child reads the per-session rotating capability directly.
7494
+ // Materialize it before the policy's existence filter, and make the exact
7495
+ // file a mandatory read-only carve-out that user rules cannot shadow.
7496
+ if (readIsolationOriginCapabilityFile) {
7497
+ publishSandboxRelayCapability({ failClosed: true });
7498
+ mandatoryReadOnlyPaths.push(readIsolationOriginCapabilityFile);
7347
7499
  }
7348
- try {
7349
- if (willReattachPersistent) {
7350
- // Daemon-restart reattach to a persistent (tmux/herdr/zellij) pane whose
7351
- // bwrap'd CLI is STILL ALIVE. backend.spawn() ignores bin/args here and
7352
- // just re-attaches, and the live CLI is bound to its own namespace-pinned
7353
- // overlay — so we must NOT unmount/remount (prepareSandbox would leave a
7354
- // duplicate host-side overlay the CLI isn't using). We only re-wire the
7355
- // outbox watcher (so the live CLI's `botmux send` keeps being serviced)
7356
- // and the cleanup ref (so close/exit reclaims the residue). No re-prep.
7357
- const att = attachSandboxOutbox({ sessionId: cfg.sessionId, dataDir });
7358
- if (att) {
7359
- if (sandboxStopWatcher) {
7360
- try {
7361
- sandboxStopWatcher();
7362
- }
7363
- catch { /* */ }
7364
- }
7365
- if (sandboxCleanup) {
7366
- try {
7367
- sandboxCleanup();
7500
+ if (credentialBoundaryActive) {
7501
+ const credentialRules = buildCredentialIsolationRules({
7502
+ homeDir: sandboxHome,
7503
+ botmuxHome: canonical(configuredBotmuxHome),
7504
+ defaultBotmuxHome: canonical(defaultBotmuxHome),
7505
+ });
7506
+ mandatoryDenyPaths.push(...credentialRules.denyPaths.map(canonical));
7507
+ mandatoryDenyRegexes.push(...credentialRules.denyRegexes);
7508
+ // Materialize every existing atomic sidecar as a concrete Linux mask.
7509
+ // Seatbelt additionally keeps the regexes above for files created later.
7510
+ for (const root of [...new Set([defaultBotmuxHome, configuredBotmuxHome])]) {
7511
+ try {
7512
+ for (const name of readdirSync(root)) {
7513
+ if (isCredentialIsolationReservedBasename(name)) {
7514
+ mandatoryDenyPaths.push(canonical(join(root, name)));
7368
7515
  }
7369
- catch { /* */ }
7370
7516
  }
7371
- sandboxCleanup = att.cleanup;
7372
- sandboxRelayOutbox = att.outbox;
7373
- sandboxStopWatcher = startOutboxWatcher(att.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
7374
- publishSandboxRelayCapability();
7375
- log(`Sandbox REATTACH (${cfg.cliId}): live pane CLI kept, re-wired outbox=${att.outbox} (no remount)`);
7376
7517
  }
7377
- else {
7378
- // No sandbox tree on disk for a session we're reattaching to: the
7379
- // pane's CLI may be unsandboxed (sandbox enabled after it spawned). Do
7380
- // NOT remount under a live CLI; just continue the reattach as-is.
7381
- log(`Sandbox REATTACH (${cfg.cliId}): no on-disk sandbox tree — reattaching live pane without re-prep`);
7518
+ catch { /* absent authority root */ }
7519
+ }
7520
+ }
7521
+ if (mcpRuntimeManifest) {
7522
+ mandatoryDenyPaths.push(...sessionMcpRuntimeHostOnlyPaths(mcpRuntimeManifest, config.session.dataDir).map(canonical));
7523
+ }
7524
+ if (process.platform === 'darwin') {
7525
+ const gatewaySocketRoot = canonical(sessionMcpGatewayHost ? dirname(sessionMcpGatewayHost.socketDir) : tmpdir());
7526
+ mandatoryDenyRegexes.push(sessionMcpGatewayPathRegex(gatewaySocketRoot));
7527
+ if (sessionMcpGatewayHost) {
7528
+ mandatoryReadOnlyPaths.push(canonical(sessionMcpGatewayHost.socketDir));
7529
+ }
7530
+ }
7531
+ const policy = buildFsPolicy({
7532
+ platform: process.platform,
7533
+ homeDir: sandboxHome,
7534
+ botmuxHome: canonical(dirname(dataDir)),
7535
+ sessionDataDir: canonical(dataDir),
7536
+ workingDir: canonical(cfg.workingDir),
7537
+ currentAppId: cfg.larkAppId,
7538
+ sessionId: cfg.sessionId,
7539
+ botHome: canonical(ownBotHome),
7540
+ redirectedCliData: willRedirectCliData,
7541
+ cliDataPaths: willRedirectCliData ? undefined : keepExisting([
7542
+ cliAdapter.claudeDataDir,
7543
+ claudeDataDir ? `${sandboxHome}/.claude.json` : undefined,
7544
+ claudeDataDir ? `${sandboxHome}/.claude.json.lock` : undefined,
7545
+ claudeDataDir ? `${sandboxHome}/.claude.lock` : undefined,
7546
+ claudeDataDir ? `${sandboxHome}/.local/state/claude` : undefined,
7547
+ ]),
7548
+ authPaths: keepExisting([...(cliAdapter.authPaths ?? [])]),
7549
+ execPaths: keepExisting([...execDirs, ...execCarve]),
7550
+ readonlyRoots: keepExisting([
7551
+ ...(cfg.skillReadonlyRoots ?? []),
7552
+ ...piInitialPromptReadonlyRoots,
7553
+ ]),
7554
+ botmuxInstallRoot,
7555
+ outbox,
7556
+ extraWritePaths: keepExisting([process.env.TMPDIR]),
7557
+ userPaths,
7558
+ mandatoryDenyPaths,
7559
+ mandatoryDenyRegexes,
7560
+ mandatoryReadOnlyPaths,
7561
+ net: cfg.sandboxNetwork !== false,
7562
+ // Claude Code saves ~/.claude.json atomically via a PID/random-suffixed
7563
+ // sibling — only relevant when the data dir is NOT redirected to BOT_HOME.
7564
+ writeRegexes: process.platform === 'darwin' && !willRedirectCliData && claudeDataDir
7565
+ ? [`^${sandboxHome.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/\\.claude\\.json\\.tmp\\.[^/]+$`]
7566
+ : [],
7567
+ });
7568
+ // Existence-filter only the ALLOW rules (baseline entries are candidates,
7569
+ // not guarantees): a non-existent allow path has nothing to expose, and
7570
+ // bwrap cannot bind a non-existent SOURCE. DENY rules are kept regardless —
7571
+ // they must guard a path created later in the session (Seatbelt keeps the
7572
+ // literal rule; bwrap masks with a tmpfs whose mountpoint the mount creates).
7573
+ policy.rules = policy.rules.filter(r => {
7574
+ if (r.access === 'deny')
7575
+ return true;
7576
+ try {
7577
+ return existsSync(r.path);
7578
+ }
7579
+ catch {
7580
+ return false;
7581
+ }
7582
+ });
7583
+ if (process.platform === 'darwin') {
7584
+ if (!locateOnPath('sandbox-exec')) {
7585
+ throw new Error(`[file-sandbox] refusing to start session ${cfg.sessionId}: sandbox-exec not found`);
7586
+ }
7587
+ const profileDir = join(dataDir, 'read-isolation');
7588
+ mkdirSync(profileDir, { recursive: true });
7589
+ const profilePath = join(profileDir, `${cfg.sessionId}.sb`);
7590
+ writeFileSync(profilePath, compileToSeatbelt(policy), { mode: 0o600 });
7591
+ seatbeltProfilePath = profilePath;
7592
+ spawnArgs = ['-f', profilePath, spawnBin, ...spawnArgs];
7593
+ spawnBin = 'sandbox-exec';
7594
+ log(`[file-sandbox] wrapping ${cliAdapter.id} in Seatbelt (fs-policy, ${policy.rules.length} rules): sandbox-exec -f ${profilePath}`);
7595
+ }
7596
+ else if (willReattachPersistent) {
7597
+ // Daemon-restart reattach to a live bwrap'd pane: backend.spawn() ignores
7598
+ // bin/args and just re-attaches. Only re-wire the outbox watcher (so the
7599
+ // live CLI's `botmux send` keeps being serviced) + the cleanup ref. The
7600
+ // direct model has NO mounts — cleanup is a plain rm at close/exit.
7601
+ const att = attachSandboxOutbox({ sessionId: cfg.sessionId, dataDir });
7602
+ if (att) {
7603
+ if (sandboxStopWatcher) {
7604
+ try {
7605
+ sandboxStopWatcher();
7606
+ }
7607
+ catch { /* */ }
7382
7608
  }
7609
+ sandboxCleanup = att.cleanup;
7610
+ sandboxRelayOutbox = att.outbox;
7611
+ sandboxStopWatcher = startOutboxWatcher(att.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
7612
+ publishSandboxRelayCapability();
7613
+ log(`Sandbox REATTACH (${cfg.cliId}): live pane CLI kept, re-wired outbox=${att.outbox}`);
7383
7614
  }
7384
7615
  else {
7385
- const sbx = prepareSandbox({
7386
- enabled: sandboxOn,
7387
- cliId: cfg.cliId,
7388
- sessionId: cfg.sessionId,
7389
- sourceWorkingDir: cfg.workingDir,
7390
- dataDir,
7391
- cliBin: cliAdapter.resolvedBin,
7392
- cliArgs: args,
7393
- // Read-isolation masks (Linux) fold into the same plan: hide the cross-bot
7394
- // sensitive set, keep the own BOT_HOME real+writable (authPaths), and re-
7395
- // expose the own attachments bucket read-only after the masks (readonlyRoots).
7396
- hidePaths: [...(cfg.sandboxHidePaths ?? []), ...(readIsoLinuxMasks?.hidePaths ?? [])],
7397
- authPaths: [...(cliAdapter.authPaths ?? [])],
7398
- // BOTMUX_HOME is credential-masked wholesale. Re-open only this
7399
- // bot's daemon-derived private CLI home after that parent mask, then
7400
- // re-mask its send credential so an untrusted receiver cannot bypass
7401
- // the host-authorized relay with a direct Lark API call.
7402
- trustedWritablePaths: readIsoLinuxMasks?.ownReadWritePaths ?? [],
7403
- finalHidePaths: readIsoLinuxMasks
7404
- ? [sendCredFilePath(dataDir, cfg.larkAppId)]
7405
- : [],
7406
- extraExecPaths: cliAdapter.sandboxExtraExecPaths?.(),
7407
- readonlyRoots: [
7408
- ...(cfg.skillReadonlyRoots ?? []),
7409
- ...piInitialPromptReadonlyRoots,
7410
- ...(readIsoLinuxMasks?.ownReadOnlyPaths ?? []),
7411
- ],
7412
- mcpGatewaySocketPath: sessionMcpGatewayHost?.socketPath,
7413
- trustedBotmuxCommandPaths: [defaultGatewayEntry().command],
7414
- userReadonlyPaths: cfg.sandboxReadonlyPaths ?? [],
7415
- net: cfg.sandboxNetwork !== false,
7416
- });
7417
- if (sbx) {
7418
- spawnBin = sbx.bin;
7419
- spawnArgs = sbx.args;
7420
- // In the overlay model the child still chdirs to projectMount (via bwrap
7421
- // --chdir), so spawnCwd stays the real workingDir; the overlay merged dir
7422
- // is bound there. sbx.workDir is the UPPER changeset (for landing), NOT a cwd.
7423
- Object.assign(childEnv, sbx.env);
7424
- if (sandboxStopWatcher) {
7425
- try {
7426
- sandboxStopWatcher();
7427
- }
7428
- catch { /* */ }
7429
- }
7430
- if (sandboxCleanup) {
7431
- try {
7432
- sandboxCleanup();
7433
- }
7434
- catch { /* */ }
7435
- }
7436
- sandboxCleanup = sbx.cleanup;
7437
- sandboxRelayOutbox = sbx.outbox;
7438
- // session-id is FORCED here so a relayed send can't target another session.
7439
- sandboxStopWatcher = startOutboxWatcher(sbx.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
7440
- publishSandboxRelayCapability();
7441
- log(`Sandbox ON (${cfg.cliId}): upper=${sbx.workDir} outbox=${sbx.outbox}`);
7616
+ log(`Sandbox REATTACH (${cfg.cliId}): no on-disk sandbox tree — reattaching live pane as-is`);
7617
+ }
7618
+ }
7619
+ else {
7620
+ const sbx = prepareDirectSandbox({
7621
+ sessionId: cfg.sessionId,
7622
+ dataDir,
7623
+ policy,
7624
+ chdir: canonical(cfg.workingDir),
7625
+ home: sandboxHome,
7626
+ cliBin: cliAdapter.resolvedBin,
7627
+ cliArgs: args,
7628
+ trustedBotmuxCommandPaths: [defaultGatewayEntry().command],
7629
+ mcpGatewaySocketPath: sessionMcpGatewayHost?.socketPath,
7630
+ });
7631
+ if (!sbx) {
7632
+ // FAIL-SAFE: never silently run unsandboxed.
7633
+ const msg = 'sandbox requested but could not be established (bwrap missing or setup failed) — aborting spawn';
7634
+ log(msg);
7635
+ throw new Error(msg);
7636
+ }
7637
+ spawnBin = sbx.bin;
7638
+ spawnArgs = sbx.args;
7639
+ Object.assign(childEnv, sbx.env);
7640
+ if (sandboxStopWatcher) {
7641
+ try {
7642
+ sandboxStopWatcher();
7442
7643
  }
7443
- else {
7444
- // Sandbox was requested but prepareSandbox returned null (a required
7445
- // overlay mount failed, or non-Linux). Fail safe: do NOT silently run
7446
- // unsandboxed — surface a hard error so the session doesn't leak.
7447
- log(`Sandbox ENABLED but prepare returned null (mount failed / unsupported) — aborting spawn to avoid an unsandboxed run`);
7448
- throw new Error('sandbox requested but could not be established');
7644
+ catch { /* */ }
7645
+ }
7646
+ if (sandboxCleanup) {
7647
+ try {
7648
+ sandboxCleanup();
7449
7649
  }
7650
+ catch { /* */ }
7450
7651
  }
7652
+ sandboxCleanup = sbx.cleanup;
7653
+ sandboxRelayOutbox = sbx.outbox;
7654
+ // session-id is FORCED here so a relayed send can't target another session.
7655
+ sandboxStopWatcher = startOutboxWatcher(sbx.outbox, childEnv, cfg.sessionId, { authorize: authorizeManagedSend });
7656
+ publishSandboxRelayCapability();
7657
+ log(`Sandbox ON (${cfg.cliId}, fs-policy ${policy.rules.length} rules): outbox=${sbx.outbox}`);
7451
7658
  }
7452
- catch (err) {
7453
- log(`Sandbox prepare failed (${err.message}) aborting (sandbox is a hard requirement when enabled)`);
7454
- throw err;
7659
+ }
7660
+ // Fresh sandboxed spawn on a persistent backend: stamp the pane with this
7661
+ // daemon's boot id so a later reattach can be trusted (see the stale-pane
7662
+ // guard above). pty needs no marker (never reattached).
7663
+ if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
7664
+ try {
7665
+ const markerDir = join(isolationRuntimeDataDir, 'read-isolation');
7666
+ mkdirSync(markerDir, { recursive: true });
7667
+ writeFileSync(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities), { mode: 0o600 });
7455
7668
  }
7669
+ catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
7456
7670
  }
7457
7671
  // 通用启动前缀(wrapperCli):把启动命令重写成 `<wrapperCli> <CLI 参数>`(首 token 当
7458
7672
  // bin 走 PATH 解析),无需 wrapper 脚本、跨系统。aiden x claude 形态会剥掉 aiden 拒收的
7459
7673
  // --settings(见 buildWrappedLaunch)。与文件沙盒互斥:沙盒已把命令重写成 bwrap,叠加
7460
- // 前缀会破坏隔离,故 sandboxOn 时跳过并告警(网关 + oncall 沙盒本就不是合理组合)。
7674
+ // 前缀会破坏隔离,故沙盒开启时跳过并告警(网关 + oncall 沙盒本就不是合理组合)。
7461
7675
  // CJADK_INTERACTIVE is a cjadk-only knob we set on the cjadk wrapper branch
7462
7676
  // below. Strip any value inherited from the daemon's own env first so a
7463
7677
  // daemon launched under `cjadk feishu` (which exports it) can't leak it via
@@ -7466,8 +7680,8 @@ async function spawnCli(cfg, opts = {}) {
7466
7680
  // keeps the behaviour intentional rather than ambient. (Codex review note.)
7467
7681
  delete childEnv.CJADK_INTERACTIVE;
7468
7682
  if (cfg.wrapperCli && cfg.wrapperCli.trim()) {
7469
- if (sandboxOn) {
7470
- log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with bwrap)`);
7683
+ if (sandboxRequested) {
7684
+ log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with the sandbox wrapper)`);
7471
7685
  }
7472
7686
  else {
7473
7687
  const launch = buildWrappedLaunch(cfg.wrapperCli, spawnArgs, (b) => locateOnPath(b) ?? b, {
@@ -7660,7 +7874,7 @@ async function spawnCli(cfg, opts = {}) {
7660
7874
  baseBin: reproduceBaseBin,
7661
7875
  baseArgs: reproduceBaseArgs,
7662
7876
  wrapperCli: cfg.wrapperCli,
7663
- sandboxOn,
7877
+ sandboxOn: sandboxRequested,
7664
7878
  binResolver: (b) => locateOnPath(b) ?? b,
7665
7879
  ttadkModel: cfg.model,
7666
7880
  });
@@ -7726,7 +7940,7 @@ async function spawnCli(cfg, opts = {}) {
7726
7940
  // MARKER inference is unaffected (the launcher-pid marker is still a valid
7727
7941
  // ancestor of an in-CLI `botmux send`, and the env fallback covers it too).
7728
7942
  const startWrapperRealPidResolve = (launcherPid) => {
7729
- if (!cfg.wrapperCli || !cfg.wrapperCli.trim() || sandboxOn || !claudeDataDir)
7943
+ if (!cfg.wrapperCli || !cfg.wrapperCli.trim() || sandboxRequested || !claudeDataDir)
7730
7944
  return;
7731
7945
  const targetCliId = cfg.cliId;
7732
7946
  scheduleWrapperRealCliPid(launcherPid, {
@@ -7948,7 +8162,7 @@ async function spawnCli(cfg, opts = {}) {
7948
8162
  const readyHookAvailable = effectiveReadyHookInstall
7949
8163
  ? hasInstalledSessionReadyHook(effectiveReadyHookInstall)
7950
8164
  : true; // Hermes emits BOTMUX_READY_COMMAND directly instead of a config hook.
7951
- const isolatedReadyTransportRequired = sandboxOn || willReadIsolate;
8165
+ const isolatedReadyTransportRequired = sandboxRequested || credentialBoundaryActive;
7952
8166
  const readyPortAvailable = !isolatedReadyTransportRequired
7953
8167
  || parseDaemonIpcPort(childEnv.BOTMUX_DAEMON_IPC_PORT) !== undefined;
7954
8168
  const readyCapabilityAvailable = !isolatedReadyTransportRequired
@@ -8045,6 +8259,16 @@ async function spawnCli(cfg, opts = {}) {
8045
8259
  // prompt-ready — without this hook a follow-up arriving mid-task would sit
8046
8260
  // in pendingMessages forever once the task finishes.
8047
8261
  backend.onTaskDone?.(() => {
8262
+ // Generation fence (matches the onAgentStatus above and the onExit below):
8263
+ // a stale RiffBackend's `fetchAndEmitOutput(...).finally(taskDoneCb)` can
8264
+ // resolve AFTER destroySession()/kill() during a restart (neither clears
8265
+ // taskDoneCb nor awaits the in-flight fetch). If that late callback reached
8266
+ // markPromptReady() while a replacement is spawning, it would ride through
8267
+ // the global restart gate and emit a premature `restart_result: succeeded`,
8268
+ // swallowing the replacement's true terminal outcome. Only the current
8269
+ // backend generation may re-arm prompt-ready.
8270
+ if (backend !== observedBackend)
8271
+ return;
8048
8272
  log(`${cliName()} task finished — re-arming prompt-ready for queued follow-ups`);
8049
8273
  markPromptReady();
8050
8274
  });
@@ -8083,22 +8307,7 @@ async function spawnCli(cfg, opts = {}) {
8083
8307
  // before exiting while fs.watch/the 1s poller is still queued. Drain it
8084
8308
  // synchronously before claiming `cli_exit`; otherwise ambiguous wins the
8085
8309
  // deduper and needlessly replays a turn that actually completed.
8086
- if (bridgeJsonlPath) {
8087
- try {
8088
- bridgeDrainAndMaybeEmit();
8089
- }
8090
- catch (err) {
8091
- log(`Bridge exit drain failed: ${err.message}`);
8092
- }
8093
- }
8094
- if (codexBridgeFallbackActive()) {
8095
- try {
8096
- codexBridgeDrainAndMaybeEmit({ signalIdle: false });
8097
- }
8098
- catch (err) {
8099
- log(`Codex bridge exit drain failed: ${err.message}`);
8100
- }
8101
- }
8310
+ drainReliableTerminalBeforeInterrupt();
8102
8311
  // Race-safe with transcript final / submit-failure: the worker-local
8103
8312
  // terminal deduper lets exactly one status win for this attempt.
8104
8313
  emitTurnTerminal(exitedTurnId, 'ambiguous', 'cli_exit', exitedDispatchAttempt);
@@ -8140,6 +8349,17 @@ async function spawnCli(cfg, opts = {}) {
8140
8349
  isPromptReady = false;
8141
8350
  currentBotmuxTurnId = undefined;
8142
8351
  currentBotmuxDispatchAttempt = undefined;
8352
+ if (!intentionalRestart && activeRestartAttemptId) {
8353
+ send({
8354
+ type: 'restart_result',
8355
+ attemptId: activeRestartAttemptId,
8356
+ status: 'failed',
8357
+ category: 'runner_exited',
8358
+ });
8359
+ activeRestartAttemptId = undefined;
8360
+ }
8361
+ if (!intentionalRestart)
8362
+ freshnessInputQueue.onReplacementFailed();
8143
8363
  if (intentionalRestart) {
8144
8364
  log('Suppressed claude_exit for intentional in-worker restart');
8145
8365
  }
@@ -8242,8 +8462,8 @@ function killCli(opts = {}) {
8242
8462
  promptReadyDetectedDuringSettle = false;
8243
8463
  readyPatternSeenDuringHold = false;
8244
8464
  awaitingPostSessionStartPromptEvidence = false;
8245
- stopScreenAnalyzer();
8246
8465
  stopStuckDetector();
8466
+ tuiPromptBlocking = false;
8247
8467
  stopScreenUpdates();
8248
8468
  backend?.kill();
8249
8469
  backend = null;
@@ -8261,10 +8481,10 @@ function killCli(opts = {}) {
8261
8481
  cliPidMarker = null;
8262
8482
  }
8263
8483
  completeManagedTurnOriginRevocation(sandboxRelayCapability, currentBotmuxTurnId, currentBotmuxDispatchAttempt);
8264
- // Stop the sandbox outbox watcher, then unmount the overlays + remove the
8265
- // per-session sandbox tree. In the overlay model the upper layer (the
8266
- // changeset) must be landed BEFORE close `/land` runs while the session is
8267
- // still active, so by cleanup time anything worth keeping is already applied.
8484
+ // Stop the sandbox outbox watcher, then reclaim the deny-mask mountpoints +
8485
+ // remove the per-session sandbox tree. In the fs-policy model the CLI writes
8486
+ // the project DIRECTLY at real host paths (no upper changeset to land), so by
8487
+ // close time everything worth keeping is already on disk.
8268
8488
  if (sandboxStopWatcher) {
8269
8489
  try {
8270
8490
  sandboxStopWatcher();
@@ -8277,6 +8497,9 @@ function killCli(opts = {}) {
8277
8497
  currentBotmuxTurnId = undefined;
8278
8498
  currentBotmuxDispatchAttempt = undefined;
8279
8499
  currentVcMeetingImTurnOrigin = undefined;
8500
+ submittedCodexAppReplyTurnIds.clear();
8501
+ pendingCodexAppSteerAckIds.clear();
8502
+ acknowledgedCodexAppSteers.clear();
8280
8503
  if (sandboxCleanup) {
8281
8504
  try {
8282
8505
  sandboxCleanup();
@@ -8316,6 +8539,7 @@ async function restartCliProcess(reason, opts = {}) {
8316
8539
  // of firing idle/task-done callbacks. Inputs accepted in that interval must
8317
8540
  // remain queued until a replacement backend has been installed.
8318
8541
  cliRestartInProgress = true;
8542
+ replacementSpawnInProgress = false;
8319
8543
  rawInputRestartGate = true;
8320
8544
  // The Node worker stays alive through this restart, so the daemon will see
8321
8545
  // neither claude_exit nor worker-exit. Explicitly revoke the old turn's
@@ -8325,9 +8549,13 @@ async function restartCliProcess(reason, opts = {}) {
8325
8549
  revokeManagedTurnOriginForRestart();
8326
8550
  log(`Restart requested (${reason})`);
8327
8551
  // Tier-2 guard: 2nd consecutive in-worker restart forces FRESH. Tier-1
8328
- // adapter probing is still re-run on every spawn.
8329
- consecutiveInWorkerRestarts++;
8330
- log(`Restart count: ${consecutiveInWorkerRestarts} (>=2 forces FRESH)`);
8552
+ // adapter probing is still re-run on every spawn. skipRestartBudget(角色
8553
+ // 切换的 cwd-move respawn)不计入:那是用户主动迁移不是崩溃恢复,计进去
8554
+ // 会让「切角色 + 一次无关重启」无故触发强制 FRESH 丢上下文。
8555
+ if (!opts.skipRestartBudget) {
8556
+ consecutiveInWorkerRestarts++;
8557
+ log(`Restart count: ${consecutiveInWorkerRestarts} (>=2 forces FRESH)`);
8558
+ }
8331
8559
  const restart = async () => {
8332
8560
  try {
8333
8561
  tmuxRestartTimer = null;
@@ -8357,12 +8585,13 @@ async function restartCliProcess(reason, opts = {}) {
8357
8585
  killCli({ preservePending: opts.preservePending });
8358
8586
  awaitingFirstPrompt = true;
8359
8587
  setTimeout(async () => {
8588
+ let spawnedWorkingDir;
8360
8589
  if (lastInitConfig) {
8361
8590
  startScreenUpdates();
8362
- startScreenAnalyzer();
8363
8591
  startStuckDetector();
8364
8592
  try {
8365
8593
  const restartCfg = { ...lastInitConfig, resume: true, prompt: '', cliSessionId: rpcThreadId ?? lastInitConfig.cliSessionId };
8594
+ spawnedWorkingDir = restartCfg.workingDir;
8366
8595
  // Re-engage RPC so the new --remote pane binds to the CURRENT app-server
8367
8596
  // (a fresh port), not the dead prior one. engageCodexRpc only sets
8368
8597
  // remote* on success, else spawnCli falls back to paste.
@@ -8373,27 +8602,64 @@ async function restartCliProcess(reason, opts = {}) {
8373
8602
  rpcPluginGenerationPrepared = true;
8374
8603
  await engageCodexRpc(restartCfg);
8375
8604
  }
8605
+ replacementSpawnInProgress = true;
8376
8606
  await spawnCli(restartCfg, { pluginGenerationPrepared: rpcPluginGenerationPrepared });
8377
8607
  await prepareCodexNativeTitleGeneration(restartCfg, codexRpcEngine);
8608
+ replacementSpawnInProgress = false;
8378
8609
  if (codexRpcEngine)
8379
8610
  armRpcStartupDialogDismiss();
8380
8611
  }
8381
8612
  catch (err) {
8613
+ replacementSpawnInProgress = false;
8382
8614
  cliRestartInProgress = false;
8383
8615
  await sendFatalWorkerErrorAndExit(err);
8384
8616
  return;
8385
8617
  }
8386
8618
  }
8387
8619
  cliRestartInProgress = false;
8620
+ replacementSpawnInProgress = false;
8621
+ // Follow-up decision (pure, unit-tested in restart-followup-policy.ts):
8622
+ // - cwd-move: a role-switch restart landed after restartCfg was
8623
+ // snapshotted → CLI came up in the old cwd while daemon repinned to
8624
+ // the new one. Respawn to converge (budget skipped only if the
8625
+ // backend is still alive; a dead one keeps the crash evidence).
8626
+ // - replacement-recovery: the freshly-spawned CLI exited inside the
8627
+ // window cliRestartInProgress still covers (spawnCli +
8628
+ // prepareCodexNativeTitleGeneration). onExit nulled `backend`
8629
+ // synchronously, so `!backend` is the ground-truth recovery signal —
8630
+ // NOT a merged daemon auto-restart (a restart message carries no
8631
+ // trustworthy source, so a healthy duplicate /restart during the
8632
+ // window must NOT be misread as a crash and force a budget-burning
8633
+ // second restart). Recover now or the session strands at
8634
+ // backend=null needing a manual /restart. Genuine crash recovery →
8635
+ // COUNTS toward tier-2 FRESH.
8636
+ const followup = decideRestartFollowup({
8637
+ spawnedWorkingDir,
8638
+ currentWorkingDir: lastInitConfig?.workingDir,
8639
+ backendAlive: !!backend,
8640
+ });
8641
+ if (followup.kind !== 'none') {
8642
+ const reason = followup.kind === 'cwd-move'
8643
+ ? `cwd-move follow-up respawn → ${lastInitConfig?.workingDir}`
8644
+ : 'replacement-exit follow-up restart (replacement exited during in-flight restart)';
8645
+ log(reason);
8646
+ void restartCliProcess(reason, { preservePending: true, skipRestartBudget: followup.skipRestartBudget });
8647
+ return;
8648
+ }
8388
8649
  // Riff marks itself prompt-ready inside spawnCli(); that early flush is
8389
8650
  // intentionally held by the restart gate above. Release its raw-input
8390
8651
  // fence now; other backends keep it until their later markPromptReady().
8391
8652
  if (effectiveBackendType === 'riff' && isPromptReady)
8392
8653
  releaseRawInputRestartGate();
8393
- void flushPending();
8654
+ // A local replacement process can exist before its TUI input box does.
8655
+ // Only re-kick a prompt that became ready while the restart fence was
8656
+ // still armed; otherwise markPromptReady() owns the first flush.
8657
+ if (isPromptReady)
8658
+ void flushPending();
8394
8659
  }, 500);
8395
8660
  }
8396
8661
  catch (err) {
8662
+ replacementSpawnInProgress = false;
8397
8663
  cliRestartInProgress = false;
8398
8664
  try {
8399
8665
  await sendFatalWorkerErrorAndExit(err);
@@ -8516,6 +8782,14 @@ function startWebServer(host, preferredPort) {
8516
8782
  const startAttach = (cols, rows) => {
8517
8783
  if (cp)
8518
8784
  return;
8785
+ // Why: a prior web resize may leave the shared tmux window in manual mode.
8786
+ // `largest` restores the shared default without letting a smaller/newer
8787
+ // viewer resize every other attached client.
8788
+ spawnSync('tmux', ['set-option', '-t', tmuxTarget, 'window-size', 'largest'], {
8789
+ stdio: 'ignore',
8790
+ env: tmuxEnv(),
8791
+ timeout: 3000,
8792
+ });
8519
8793
  // Defense in depth: view-capability clients attach through tmux's
8520
8794
  // own read-only mode as well as the WebSocket input gate below.
8521
8795
  cp = pty.spawn('tmux', [
@@ -9609,6 +9883,15 @@ async function sendFatalWorkerErrorAndExit(err, turnId = currentBotmuxTurnId, di
9609
9883
  if (fatalWorkerErrorPending)
9610
9884
  return;
9611
9885
  fatalWorkerErrorPending = true;
9886
+ if (activeRestartAttemptId) {
9887
+ await sendAndFlush({
9888
+ type: 'restart_result',
9889
+ attemptId: activeRestartAttemptId,
9890
+ status: 'failed',
9891
+ category: 'spawn_failed',
9892
+ });
9893
+ activeRestartAttemptId = undefined;
9894
+ }
9612
9895
  await sendAndFlush({
9613
9896
  type: 'error',
9614
9897
  message: err instanceof Error ? err.message : String(err),
@@ -9632,6 +9915,7 @@ process.on('message', async (raw) => {
9632
9915
  if (lastInitConfig)
9633
9916
  return; // already initialized
9634
9917
  lastInitConfig = msg;
9918
+ activeRestartAttemptId = msg.restartAttemptId;
9635
9919
  sessionId = msg.sessionId;
9636
9920
  refreshTerminalViewToken();
9637
9921
  refreshTerminalWriteToken();
@@ -9677,7 +9961,6 @@ process.on('message', async (raw) => {
9677
9961
  if (!isWorkflowWorker()) {
9678
9962
  port = await startWebServer(config.web.workerHost, msg.webPort);
9679
9963
  startScreenUpdates();
9680
- startScreenAnalyzer();
9681
9964
  startStuckDetector();
9682
9965
  }
9683
9966
  else {
@@ -9845,12 +10128,11 @@ process.on('message', async (raw) => {
9845
10128
  if (!backend && crashDiagnosticStopped && lastInitConfig && !lastInitConfig.adoptMode) {
9846
10129
  log('Message received after crash-loop stop; retrying CLI start');
9847
10130
  destroyCrashDiagnosticTerminal('retry after message');
9848
- stopScreenAnalyzer();
9849
10131
  stopStuckDetector();
10132
+ tuiPromptBlocking = false;
9850
10133
  stopScreenUpdates();
9851
10134
  awaitingFirstPrompt = true;
9852
10135
  startScreenUpdates();
9853
- startScreenAnalyzer();
9854
10136
  startStuckDetector();
9855
10137
  try {
9856
10138
  const restartCfg = { ...lastInitConfig, resume: true, prompt: '' };
@@ -9993,9 +10275,18 @@ process.on('message', async (raw) => {
9993
10275
  // barrier(shouldDeferUserFlush——/cd 未落地前任何用户输入都不得写入,
9994
10276
  // 否则 passthrough 会执行在旧 cwd 的 CLI 里)时入队。注入排空后由
9995
10277
  // flushPendingInjections 的 finally 补踢 flushPending 送达。
10278
+ // 第四个例外是启动稳定窗口:detectBareShellLaunch() 采到裸 shell 时会
10279
+ // await settleLaunchComm() 最长 2s 等 wrapper 完成 `exec <cli>`——这段
10280
+ // await 让出事件循环,而 IPC handler 不串行(见 raw-input-followup-
10281
+ // atomicity.test.ts),若此刻放行 passthrough 就会打进尚未 exec 的临时
10282
+ // shell。bareShellCheckInProgress 覆盖"检查进行中"、bareShellLaunchBlocked
10283
+ // 覆盖"已确认裸 shell 启动失败"两种状态,一并入队。裸 shell 确认失败后
10284
+ // 这些 pending 不会再被 flush(与 pendingMessages 同款处理),符合预期。
9996
10285
  if (cliRestartInProgress || rawInputRestartGate || sessionRenameInFlight
9997
- || injectionFlushing || shouldDeferUserFlush(pendingInjections)) {
9998
- pendingRawInputs.push(msg);
10286
+ || shouldHoldCodexRunnerInput(codexRunnerFreshness)
10287
+ || injectionFlushing || shouldDeferUserFlush(pendingInjections)
10288
+ || bareShellCheckInProgress || bareShellLaunchBlocked) {
10289
+ freshnessInputQueue.enqueueRaw(msg);
9999
10290
  log(`Deferred passthrough slash command until CLI input gate settles: ${msg.content}`);
10000
10291
  }
10001
10292
  else {
@@ -10030,7 +10321,58 @@ process.on('message', async (raw) => {
10030
10321
  break;
10031
10322
  }
10032
10323
  case 'restart': {
10033
- await restartCliProcess('daemon request', { preservePending: true });
10324
+ // 角色切换的 cwd-move respawn:respawn 用 {...lastInitConfig, resume:true}
10325
+ // 先收敛 workingDir 才能让 CLI 在新目录重启(新 cwd 的 CLAUDE.md/记忆索引
10326
+ // 开场注入)。旧桶 transcript 由 resume 预检的 syncClaudeResumeTargetToCwd
10327
+ // (COPY 最新 <sid>.jsonl 进新 cwd 桶,已在 master)接住,上下文不丢。
10328
+ if (msg.updateWorkingDir && lastInitConfig) {
10329
+ lastInitConfig.workingDir = msg.updateWorkingDir;
10330
+ }
10331
+ // restart 合并:已有一轮 restart 在飞(teardown 进行中,或 tmux jitter
10332
+ // 定时器未触发)时不叠加第二轮——叠加会 clearTimeout 吃掉首轮 teardown、
10333
+ // 把重启预算无故烧到 tier-2 强制 FRESH(丢上下文),非 tmux 路径还会
10334
+ // 双 spawn。workingDir 已收敛进 lastInitConfig,pending 的 spawn 展开
10335
+ // {...lastInitConfig} 时自然拿到新目录。
10336
+ //
10337
+ // 这里**只 break、不记任何 flag**:restart 消息不带可信来源,无法区分
10338
+ // 「replacement 崩溃触发的 auto-restart」与「用户重复点了一次 restart」。
10339
+ // replacement 真退出时 onExit 已同步把 backend 置 null,续体用 !backend 即可
10340
+ // 补 recovery(见 decideRestartFollowup);健康的重复 restart 就该被合并掉,
10341
+ // 记 flag 反而会逼健康进程再重启一轮、烧预算丢 --resume(正是合并要防的)。
10342
+ if (cliRestartInProgress || tmuxRestartTimer) {
10343
+ log(`Restart request merged into in-flight restart${msg.updateWorkingDir ? ` (workingDir → ${msg.updateWorkingDir})` : ''}`);
10344
+ break;
10345
+ }
10346
+ activeRestartAttemptId = msg.attemptId;
10347
+ codexRunnerFreshness = 'restarting_fresh';
10348
+ // restart 杀死 CLI,在飞的 durable turn 随之死亡。对被杀的那次投递,主动发一个
10349
+ // 'ambiguous' 终端回执:CLI 被中途杀掉,副作用到底发没发是**真的无法证明**
10350
+ // (故不能报 'cancelled'),交由 daemon 的重试策略即时对账。不发的话 receipt 会
10351
+ // 悬着,等 daemon 租约到期走 expire_durable_turn 的「无法证明 → 扣 ACK → 超时
10352
+ // fencing teardown」慢路径(还会把刚 respawn 的新 CLI 二次 teardown)。
10353
+ // emitTurnTerminal 自带释放(复位 durableTurnInFlight、退休 inflight input、
10354
+ // 撤销 turn-origin relay、丢弃 bridge 尝试)并对后续 CLI-exit 终端去重;它排的
10355
+ // flushPending 微任务会因下面 restartCliProcess 同步置位 cliRestartInProgress 而空跑。
10356
+ // 结算被 restart 打断的在飞 durable turn(编排见 settleDurableTurnForRestart,
10357
+ // 已单测):drain 已落盘可靠终态让「刚好在 kill 前完成、watcher 未消费」的 turn
10358
+ // 以 completed 抢占 deduper → 复检仍在飞才补发 ambiguous(否则无条件 ambiguous
10359
+ // 会误标已完成投递 → 同 key 可重派 → 副作用两次)→ 兜底释放 latch 免卡 respawn。
10360
+ // emitTurnTerminal 自带释放(复位 durableTurnInFlight、退休 inflight input、撤销
10361
+ // turn-origin relay、丢弃 bridge 尝试);它排的 flushPending 微任务会因下面
10362
+ // restartCliProcess 同步置位 cliRestartInProgress 而空跑。
10363
+ settleDurableTurnForRestart({
10364
+ hasInFlightTurn: durableTurnInFlight,
10365
+ hasCurrentTurnId: !!currentBotmuxTurnId,
10366
+ drain: () => drainReliableTerminalBeforeInterrupt(),
10367
+ isStillInFlight: () => durableTurnInFlight,
10368
+ emitAmbiguous: () => emitTurnTerminal(currentBotmuxTurnId, 'ambiguous', undefined, currentBotmuxDispatchAttempt),
10369
+ release: () => { durableTurnInFlight = false; inflightInputs.onTurnComplete(); },
10370
+ });
10371
+ await restartCliProcess(msg.updateWorkingDir ? `cwd-move respawn → ${msg.updateWorkingDir}` : 'daemon request',
10372
+ // cwd-move 是用户主动的目录迁移、不是崩溃恢复,不计入 tier-2 强制
10373
+ // FRESH 的重启预算;respawn 真失败仍有 claude_exit → daemon
10374
+ // auto-restart 那条裸 restart 的计数兜底。
10375
+ { preservePending: true, skipRestartBudget: !!msg.updateWorkingDir });
10034
10376
  break;
10035
10377
  }
10036
10378
  case 'expire_durable_turn': {
@@ -10156,12 +10498,10 @@ process.on('message', async (raw) => {
10156
10498
  break;
10157
10499
  }
10158
10500
  case 'inject_command': {
10159
- // 会话内 /cd 移动后,worker 内部 respawn(claude_exit 自动重启 / IM /restart /
10160
- // dashboard restart)必须收敛到新目录,而不是陈旧的 lastInitConfig.workingDir。
10161
- if (msg.updateWorkingDir && lastInitConfig) {
10162
- lastInitConfig.workingDir = msg.updateWorkingDir;
10163
- }
10164
- pendingInjections.push({ command: msg.command, barrier: !!msg.updateWorkingDir });
10501
+ // 唯一发送方是 /slash 路由的白名单 TUI 命令注入。cwd 移动不再走注入
10502
+ // (角色切换已改为 restart+updateWorkingDir 的 respawn),这里不接受
10503
+ // 任何 workingDir 改写——那会绕过 cd 路由的角色库硬校验。
10504
+ pendingInjections.push({ command: msg.command, barrier: false });
10165
10505
  void flushPendingInjections();
10166
10506
  break;
10167
10507
  }
@@ -10294,10 +10634,11 @@ process.on('message', async (raw) => {
10294
10634
  catch { /* best-effort */ }
10295
10635
  backend = null;
10296
10636
  isPromptReady = false;
10297
- // Suspend INTENDS to resume later: preserve the sandbox overlay mount + the
10298
- // upper changeset across the suspension (on resume, prepareSandbox re-mounts
10299
- // over the SAME upper). So we stop the outbox watcher (no live CLI to serve)
10300
- // but DO NOT run sandboxCleanup (which would unmount + rm the changeset). We
10637
+ // Suspend INTENDS to resume later: keep the per-session sandbox tree (the
10638
+ // outbox + pre-created deny-mask mountpoints) intact across the suspension
10639
+ // (on resume, prepareDirectSandbox re-binds over the SAME tree). So we stop
10640
+ // the outbox watcher (no live CLI to serve) but DO NOT run sandboxCleanup
10641
+ // (which would reclaim the mask mountpoints + rm the tree). We
10301
10642
  // also disarm the exit-time teardown so process.exit(0) below can't reclaim
10302
10643
  // it. (Crash/SIGKILL of a suspended-but-active session is still backstopped
10303
10644
  // by the daemon's periodic sandbox reconciler.)
@@ -10308,7 +10649,7 @@ process.on('message', async (raw) => {
10308
10649
  catch { /* */ }
10309
10650
  sandboxStopWatcher = null;
10310
10651
  }
10311
- sandboxCleanup = null; // drop the ref WITHOUT calling it (keep the mount)
10652
+ sandboxCleanup = null; // drop the ref WITHOUT calling it (keep the tree)
10312
10653
  sandboxTeardownDone = true; // make the process.on('exit') hook a no-op
10313
10654
  cleanup();
10314
10655
  process.exit(0);
@@ -10384,15 +10725,15 @@ setInterval(() => {
10384
10725
  }
10385
10726
  }, 30_000).unref();
10386
10727
  // ─── Sandbox crash-time teardown ─────────────────────────────────────────────
10387
- // killCli() (which unmounts the overlays + rm's the per-session tree) only runs
10388
- // from the SIGTERM/SIGINT/disconnect/watchdog handlers and the close/suspend IPC
10389
- // cases. An UNCAUGHT exception or unhandled rejection kills the process WITHOUT
10390
- // any of those firing, so without this hook a crashed sandboxed worker would
10391
- // leak BOTH overlay mounts (mount-table growth) + its upper/work dirs (disk leak)
10392
- // per crash. We run a minimal, synchronous, best-effort sandbox teardown here so
10393
- // the overlay/dir residue is reclaimed even on an abnormal exit. (SIGKILL still
10394
- // can't be trapped — the daemon-side sweep + the periodic reconciler below are
10395
- // the backstop for that.)
10728
+ // killCli() (which reclaims the deny-mask mountpoints + rm's the per-session
10729
+ // tree) only runs from the SIGTERM/SIGINT/disconnect/watchdog handlers and the
10730
+ // close/suspend IPC cases. An UNCAUGHT exception or unhandled rejection kills
10731
+ // the process WITHOUT any of those firing, so without this hook a crashed
10732
+ // sandboxed worker would leak its pre-created host mask mountpoints (empty dirs/
10733
+ // files) + the per-session tree (disk leak) per crash. We run a minimal,
10734
+ // synchronous, best-effort sandbox teardown here so that residue is reclaimed
10735
+ // even on an abnormal exit. (SIGKILL still can't be trapped — the daemon-side
10736
+ // sweep + the periodic reconciler below are the backstop for that.)
10396
10737
  function teardownSandboxBestEffort() {
10397
10738
  if (sandboxTeardownDone)
10398
10739
  return;