botmux 3.14.0 → 3.16.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 (812) hide show
  1. package/README.en.md +2 -2
  2. package/README.md +2 -2
  3. package/dist/.runtime-build-id +1 -1
  4. package/dist/adapters/backend/destroy-result.d.ts +159 -0
  5. package/dist/adapters/backend/destroy-result.d.ts.map +1 -0
  6. package/dist/adapters/backend/destroy-result.js +223 -0
  7. package/dist/adapters/backend/destroy-result.js.map +1 -0
  8. package/dist/adapters/backend/herdr-backend.d.ts +4 -0
  9. package/dist/adapters/backend/herdr-backend.d.ts.map +1 -1
  10. package/dist/adapters/backend/herdr-backend.js +23 -1
  11. package/dist/adapters/backend/herdr-backend.js.map +1 -1
  12. package/dist/adapters/backend/mojo-backend.d.ts +577 -0
  13. package/dist/adapters/backend/mojo-backend.d.ts.map +1 -0
  14. package/dist/adapters/backend/mojo-backend.js +2471 -0
  15. package/dist/adapters/backend/mojo-backend.js.map +1 -0
  16. package/dist/adapters/backend/mojo-budgets.d.ts +21 -0
  17. package/dist/adapters/backend/mojo-budgets.d.ts.map +1 -0
  18. package/dist/adapters/backend/mojo-budgets.js +22 -0
  19. package/dist/adapters/backend/mojo-budgets.js.map +1 -0
  20. package/dist/adapters/backend/mojo-isolated-workspace.d.ts +69 -0
  21. package/dist/adapters/backend/mojo-isolated-workspace.d.ts.map +1 -0
  22. package/dist/adapters/backend/mojo-isolated-workspace.js +213 -0
  23. package/dist/adapters/backend/mojo-isolated-workspace.js.map +1 -0
  24. package/dist/adapters/backend/mojo-process-tree.d.ts +292 -0
  25. package/dist/adapters/backend/mojo-process-tree.d.ts.map +1 -0
  26. package/dist/adapters/backend/mojo-process-tree.js +486 -0
  27. package/dist/adapters/backend/mojo-process-tree.js.map +1 -0
  28. package/dist/adapters/backend/mojo-types.d.ts +542 -0
  29. package/dist/adapters/backend/mojo-types.d.ts.map +1 -0
  30. package/dist/adapters/backend/mojo-types.js +682 -0
  31. package/dist/adapters/backend/mojo-types.js.map +1 -0
  32. package/dist/adapters/backend/reproduce-command.js +3 -3
  33. package/dist/adapters/backend/reproduce-command.js.map +1 -1
  34. package/dist/adapters/backend/riff-backend.js +1 -1
  35. package/dist/adapters/backend/riff-backend.js.map +1 -1
  36. package/dist/adapters/backend/sandbox.d.ts +44 -7
  37. package/dist/adapters/backend/sandbox.d.ts.map +1 -1
  38. package/dist/adapters/backend/sandbox.js +30 -7
  39. package/dist/adapters/backend/sandbox.js.map +1 -1
  40. package/dist/adapters/backend/session-backend-selector.d.ts +14 -1
  41. package/dist/adapters/backend/session-backend-selector.d.ts.map +1 -1
  42. package/dist/adapters/backend/session-backend-selector.js +82 -10
  43. package/dist/adapters/backend/session-backend-selector.js.map +1 -1
  44. package/dist/adapters/backend/tmux-pipe-backend.d.ts +30 -0
  45. package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
  46. package/dist/adapters/backend/tmux-pipe-backend.js +60 -0
  47. package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
  48. package/dist/adapters/backend/types.d.ts +108 -6
  49. package/dist/adapters/backend/types.d.ts.map +1 -1
  50. package/dist/adapters/backend/types.js.map +1 -1
  51. package/dist/adapters/backend/zmx-backend.d.ts +11 -0
  52. package/dist/adapters/backend/zmx-backend.d.ts.map +1 -1
  53. package/dist/adapters/backend/zmx-backend.js +16 -1
  54. package/dist/adapters/backend/zmx-backend.js.map +1 -1
  55. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  56. package/dist/adapters/cli/codex-app.js +2 -2
  57. package/dist/adapters/cli/codex-app.js.map +1 -1
  58. package/dist/adapters/cli/codex.d.ts.map +1 -1
  59. package/dist/adapters/cli/codex.js +7 -0
  60. package/dist/adapters/cli/codex.js.map +1 -1
  61. package/dist/adapters/cli/copilot.d.ts +3 -1
  62. package/dist/adapters/cli/copilot.d.ts.map +1 -1
  63. package/dist/adapters/cli/copilot.js +23 -5
  64. package/dist/adapters/cli/copilot.js.map +1 -1
  65. package/dist/adapters/cli/cursor.d.ts.map +1 -1
  66. package/dist/adapters/cli/cursor.js +26 -4
  67. package/dist/adapters/cli/cursor.js.map +1 -1
  68. package/dist/adapters/cli/dsh.d.ts.map +1 -1
  69. package/dist/adapters/cli/dsh.js +5 -1
  70. package/dist/adapters/cli/dsh.js.map +1 -1
  71. package/dist/adapters/cli/grok.d.ts.map +1 -1
  72. package/dist/adapters/cli/grok.js +24 -5
  73. package/dist/adapters/cli/grok.js.map +1 -1
  74. package/dist/adapters/cli/kimi.d.ts.map +1 -1
  75. package/dist/adapters/cli/kimi.js +19 -1
  76. package/dist/adapters/cli/kimi.js.map +1 -1
  77. package/dist/adapters/cli/mojo.d.ts +28 -0
  78. package/dist/adapters/cli/mojo.d.ts.map +1 -0
  79. package/dist/adapters/cli/mojo.js +58 -0
  80. package/dist/adapters/cli/mojo.js.map +1 -0
  81. package/dist/adapters/cli/pi.d.ts +20 -5
  82. package/dist/adapters/cli/pi.d.ts.map +1 -1
  83. package/dist/adapters/cli/pi.js +27 -5
  84. package/dist/adapters/cli/pi.js.map +1 -1
  85. package/dist/adapters/cli/read-isolation.d.ts +251 -1
  86. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  87. package/dist/adapters/cli/read-isolation.js +254 -1
  88. package/dist/adapters/cli/read-isolation.js.map +1 -1
  89. package/dist/adapters/cli/registry.d.ts +13 -1
  90. package/dist/adapters/cli/registry.d.ts.map +1 -1
  91. package/dist/adapters/cli/registry.js +19 -1
  92. package/dist/adapters/cli/registry.js.map +1 -1
  93. package/dist/adapters/cli/runner-input.d.ts +3 -3
  94. package/dist/adapters/cli/runner-input.d.ts.map +1 -1
  95. package/dist/adapters/cli/runner-input.js +4 -3
  96. package/dist/adapters/cli/runner-input.js.map +1 -1
  97. package/dist/adapters/cli/shared-hints.d.ts +3 -1
  98. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  99. package/dist/adapters/cli/shared-hints.js +51 -30
  100. package/dist/adapters/cli/shared-hints.js.map +1 -1
  101. package/dist/adapters/cli/types.d.ts +33 -4
  102. package/dist/adapters/cli/types.d.ts.map +1 -1
  103. package/dist/bot-registry.d.ts +42 -0
  104. package/dist/bot-registry.d.ts.map +1 -1
  105. package/dist/bot-registry.js +54 -4
  106. package/dist/bot-registry.js.map +1 -1
  107. package/dist/cli/arg-utils.d.ts +5 -0
  108. package/dist/cli/arg-utils.d.ts.map +1 -1
  109. package/dist/cli/arg-utils.js +7 -0
  110. package/dist/cli/arg-utils.js.map +1 -1
  111. package/dist/cli/bots-list-output.d.ts +130 -2
  112. package/dist/cli/bots-list-output.d.ts.map +1 -1
  113. package/dist/cli/bots-list-output.js +173 -27
  114. package/dist/cli/bots-list-output.js.map +1 -1
  115. package/dist/cli/daemon-lifecycle-env.d.ts +10 -3
  116. package/dist/cli/daemon-lifecycle-env.d.ts.map +1 -1
  117. package/dist/cli/daemon-lifecycle-env.js +38 -11
  118. package/dist/cli/daemon-lifecycle-env.js.map +1 -1
  119. package/dist/cli/dashboard-command.d.ts +15 -0
  120. package/dist/cli/dashboard-command.d.ts.map +1 -1
  121. package/dist/cli/dashboard-command.js +22 -0
  122. package/dist/cli/dashboard-command.js.map +1 -1
  123. package/dist/cli/log-tail.d.ts +54 -0
  124. package/dist/cli/log-tail.d.ts.map +1 -0
  125. package/dist/cli/log-tail.js +167 -0
  126. package/dist/cli/log-tail.js.map +1 -0
  127. package/dist/cli/pm2-env.d.ts +16 -0
  128. package/dist/cli/pm2-env.d.ts.map +1 -0
  129. package/dist/cli/pm2-env.js +63 -0
  130. package/dist/cli/pm2-env.js.map +1 -0
  131. package/dist/cli/pm2-fleet-lock.d.ts +26 -0
  132. package/dist/cli/pm2-fleet-lock.d.ts.map +1 -0
  133. package/dist/cli/pm2-fleet-lock.js +50 -0
  134. package/dist/cli/pm2-fleet-lock.js.map +1 -0
  135. package/dist/cli/pm2-god-retirement.d.ts +59 -0
  136. package/dist/cli/pm2-god-retirement.d.ts.map +1 -0
  137. package/dist/cli/pm2-god-retirement.js +95 -0
  138. package/dist/cli/pm2-god-retirement.js.map +1 -0
  139. package/dist/cli/session-list-wake.d.ts +41 -0
  140. package/dist/cli/session-list-wake.d.ts.map +1 -0
  141. package/dist/cli/session-list-wake.js +102 -0
  142. package/dist/cli/session-list-wake.js.map +1 -0
  143. package/dist/cli/workflow.d.ts.map +1 -1
  144. package/dist/cli/workflow.js +26 -0
  145. package/dist/cli/workflow.js.map +1 -1
  146. package/dist/cli.d.ts.map +1 -1
  147. package/dist/cli.js +941 -118
  148. package/dist/cli.js.map +1 -1
  149. package/dist/codex-app-runner.js +261 -49
  150. package/dist/codex-app-runner.js.map +1 -1
  151. package/dist/core/cli-runtime-update.d.ts +8 -0
  152. package/dist/core/cli-runtime-update.d.ts.map +1 -1
  153. package/dist/core/cli-runtime-update.js +127 -8
  154. package/dist/core/cli-runtime-update.js.map +1 -1
  155. package/dist/core/close-residual.d.ts +64 -0
  156. package/dist/core/close-residual.d.ts.map +1 -0
  157. package/dist/core/close-residual.js +93 -0
  158. package/dist/core/close-residual.js.map +1 -0
  159. package/dist/core/closed-session-card.d.ts.map +1 -1
  160. package/dist/core/closed-session-card.js +11 -3
  161. package/dist/core/closed-session-card.js.map +1 -1
  162. package/dist/core/command-discovery.d.ts.map +1 -1
  163. package/dist/core/command-discovery.js +4 -1
  164. package/dist/core/command-discovery.js.map +1 -1
  165. package/dist/core/command-handler.d.ts.map +1 -1
  166. package/dist/core/command-handler.js +129 -27
  167. package/dist/core/command-handler.js.map +1 -1
  168. package/dist/core/cost-calculator.d.ts +3 -1
  169. package/dist/core/cost-calculator.d.ts.map +1 -1
  170. package/dist/core/cost-calculator.js +230 -1
  171. package/dist/core/cost-calculator.js.map +1 -1
  172. package/dist/core/daemon-background-close.d.ts +19 -0
  173. package/dist/core/daemon-background-close.d.ts.map +1 -0
  174. package/dist/core/daemon-background-close.js +39 -0
  175. package/dist/core/daemon-background-close.js.map +1 -0
  176. package/dist/core/dashboard-command/overview.d.ts +4 -0
  177. package/dist/core/dashboard-command/overview.d.ts.map +1 -1
  178. package/dist/core/dashboard-command/overview.js +8 -1
  179. package/dist/core/dashboard-command/overview.js.map +1 -1
  180. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  181. package/dist/core/dashboard-ipc-server.js +415 -42
  182. package/dist/core/dashboard-ipc-server.js.map +1 -1
  183. package/dist/core/dashboard-rows.d.ts +4 -0
  184. package/dist/core/dashboard-rows.d.ts.map +1 -1
  185. package/dist/core/dashboard-rows.js +3 -0
  186. package/dist/core/dashboard-rows.js.map +1 -1
  187. package/dist/core/dashboard-url.d.ts +37 -0
  188. package/dist/core/dashboard-url.d.ts.map +1 -1
  189. package/dist/core/dashboard-url.js +71 -0
  190. package/dist/core/dashboard-url.js.map +1 -1
  191. package/dist/core/deferred-schedule-settlement.d.ts +15 -1
  192. package/dist/core/deferred-schedule-settlement.d.ts.map +1 -1
  193. package/dist/core/deferred-schedule-settlement.js +4 -1
  194. package/dist/core/deferred-schedule-settlement.js.map +1 -1
  195. package/dist/core/device-isolation-daemon.d.ts +91 -1
  196. package/dist/core/device-isolation-daemon.d.ts.map +1 -1
  197. package/dist/core/device-isolation-daemon.js +346 -2
  198. package/dist/core/device-isolation-daemon.js.map +1 -1
  199. package/dist/core/local-terminal-opener.d.ts.map +1 -1
  200. package/dist/core/local-terminal-opener.js +5 -1
  201. package/dist/core/local-terminal-opener.js.map +1 -1
  202. package/dist/core/loopback-target.d.ts +14 -0
  203. package/dist/core/loopback-target.d.ts.map +1 -0
  204. package/dist/core/loopback-target.js +20 -0
  205. package/dist/core/loopback-target.js.map +1 -0
  206. package/dist/core/maintenance.d.ts.map +1 -1
  207. package/dist/core/maintenance.js +16 -2
  208. package/dist/core/maintenance.js.map +1 -1
  209. package/dist/core/mojo-containment-command.d.ts +20 -0
  210. package/dist/core/mojo-containment-command.d.ts.map +1 -0
  211. package/dist/core/mojo-containment-command.js +266 -0
  212. package/dist/core/mojo-containment-command.js.map +1 -0
  213. package/dist/core/mojo-containment.d.ts +575 -0
  214. package/dist/core/mojo-containment.d.ts.map +1 -0
  215. package/dist/core/mojo-containment.js +1158 -0
  216. package/dist/core/mojo-containment.js.map +1 -0
  217. package/dist/core/mojo-launcher-env-quarantine.d.ts +30 -0
  218. package/dist/core/mojo-launcher-env-quarantine.d.ts.map +1 -0
  219. package/dist/core/mojo-launcher-env-quarantine.js +175 -0
  220. package/dist/core/mojo-launcher-env-quarantine.js.map +1 -0
  221. package/dist/core/mojo-session-identity.d.ts +3 -0
  222. package/dist/core/mojo-session-identity.d.ts.map +1 -0
  223. package/dist/core/mojo-session-identity.js +126 -0
  224. package/dist/core/mojo-session-identity.js.map +1 -0
  225. package/dist/core/persistent-backend.d.ts +32 -8
  226. package/dist/core/persistent-backend.d.ts.map +1 -1
  227. package/dist/core/persistent-backend.js +55 -17
  228. package/dist/core/persistent-backend.js.map +1 -1
  229. package/dist/core/plugins/mcp/gateway.d.ts +14 -1
  230. package/dist/core/plugins/mcp/gateway.d.ts.map +1 -1
  231. package/dist/core/plugins/mcp/gateway.js +70 -3
  232. package/dist/core/plugins/mcp/gateway.js.map +1 -1
  233. package/dist/core/plugins/mcp/host.d.ts +2 -0
  234. package/dist/core/plugins/mcp/host.d.ts.map +1 -1
  235. package/dist/core/plugins/mcp/host.js +2 -0
  236. package/dist/core/plugins/mcp/host.js.map +1 -1
  237. package/dist/core/plugins/pm2.d.ts.map +1 -1
  238. package/dist/core/plugins/pm2.js +16 -3
  239. package/dist/core/plugins/pm2.js.map +1 -1
  240. package/dist/core/plugins/service-manager.d.ts +9 -0
  241. package/dist/core/plugins/service-manager.d.ts.map +1 -1
  242. package/dist/core/plugins/service-manager.js +33 -16
  243. package/dist/core/plugins/service-manager.js.map +1 -1
  244. package/dist/core/preview-port-owner.d.ts +109 -0
  245. package/dist/core/preview-port-owner.d.ts.map +1 -0
  246. package/dist/core/preview-port-owner.js +320 -0
  247. package/dist/core/preview-port-owner.js.map +1 -0
  248. package/dist/core/remote-cli-ids.d.ts +5 -0
  249. package/dist/core/remote-cli-ids.d.ts.map +1 -0
  250. package/dist/core/remote-cli-ids.js +24 -0
  251. package/dist/core/remote-cli-ids.js.map +1 -0
  252. package/dist/core/{riff-shutdown-detach.d.ts → remote-shutdown-detach.d.ts} +40 -39
  253. package/dist/core/remote-shutdown-detach.d.ts.map +1 -0
  254. package/dist/core/{riff-shutdown-detach.js → remote-shutdown-detach.js} +85 -83
  255. package/dist/core/remote-shutdown-detach.js.map +1 -0
  256. package/dist/core/{riff-worker-shutdown-readiness.d.ts → remote-worker-shutdown-readiness.d.ts} +5 -4
  257. package/dist/core/remote-worker-shutdown-readiness.d.ts.map +1 -0
  258. package/dist/core/{riff-worker-shutdown-readiness.js → remote-worker-shutdown-readiness.js} +4 -3
  259. package/dist/core/remote-worker-shutdown-readiness.js.map +1 -0
  260. package/dist/core/reply-target.d.ts +16 -1
  261. package/dist/core/reply-target.d.ts.map +1 -1
  262. package/dist/core/reply-target.js +61 -0
  263. package/dist/core/reply-target.js.map +1 -1
  264. package/dist/core/session-group-birth.d.ts +26 -0
  265. package/dist/core/session-group-birth.d.ts.map +1 -1
  266. package/dist/core/session-group-birth.js +81 -4
  267. package/dist/core/session-group-birth.js.map +1 -1
  268. package/dist/core/session-manager.d.ts +15 -2
  269. package/dist/core/session-manager.d.ts.map +1 -1
  270. package/dist/core/session-manager.js +259 -5
  271. package/dist/core/session-manager.js.map +1 -1
  272. package/dist/core/session-model.d.ts +46 -0
  273. package/dist/core/session-model.d.ts.map +1 -0
  274. package/dist/core/session-model.js +42 -0
  275. package/dist/core/session-model.js.map +1 -0
  276. package/dist/core/session-owner-reminder.d.ts +63 -0
  277. package/dist/core/session-owner-reminder.d.ts.map +1 -0
  278. package/dist/core/session-owner-reminder.js +196 -0
  279. package/dist/core/session-owner-reminder.js.map +1 -0
  280. package/dist/core/session-preview-registry.d.ts +53 -0
  281. package/dist/core/session-preview-registry.d.ts.map +1 -0
  282. package/dist/core/session-preview-registry.js +82 -0
  283. package/dist/core/session-preview-registry.js.map +1 -0
  284. package/dist/core/session-preview.d.ts +114 -0
  285. package/dist/core/session-preview.d.ts.map +1 -0
  286. package/dist/core/session-preview.js +184 -0
  287. package/dist/core/session-preview.js.map +1 -0
  288. package/dist/core/session-rename.d.ts.map +1 -1
  289. package/dist/core/session-rename.js +5 -3
  290. package/dist/core/session-rename.js.map +1 -1
  291. package/dist/core/session-wake-deadline.d.ts +5 -0
  292. package/dist/core/session-wake-deadline.d.ts.map +1 -0
  293. package/dist/core/session-wake-deadline.js +13 -0
  294. package/dist/core/session-wake-deadline.js.map +1 -0
  295. package/dist/core/shutdown-budgets.d.ts +7 -6
  296. package/dist/core/shutdown-budgets.d.ts.map +1 -1
  297. package/dist/core/shutdown-budgets.js +11 -10
  298. package/dist/core/shutdown-budgets.js.map +1 -1
  299. package/dist/core/skills/frontmatter.d.ts.map +1 -1
  300. package/dist/core/skills/frontmatter.js +6 -1
  301. package/dist/core/skills/frontmatter.js.map +1 -1
  302. package/dist/core/terminal-control-grant.d.ts +74 -0
  303. package/dist/core/terminal-control-grant.d.ts.map +1 -0
  304. package/dist/core/terminal-control-grant.js +183 -0
  305. package/dist/core/terminal-control-grant.js.map +1 -0
  306. package/dist/core/terminal-proxy.d.ts.map +1 -1
  307. package/dist/core/terminal-proxy.js +4 -5
  308. package/dist/core/terminal-proxy.js.map +1 -1
  309. package/dist/core/terminal-write-auth.d.ts +4 -11
  310. package/dist/core/terminal-write-auth.d.ts.map +1 -1
  311. package/dist/core/terminal-write-auth.js +19 -16
  312. package/dist/core/terminal-write-auth.js.map +1 -1
  313. package/dist/core/trigger-session.d.ts.map +1 -1
  314. package/dist/core/trigger-session.js +36 -29
  315. package/dist/core/trigger-session.js.map +1 -1
  316. package/dist/core/types.d.ts +88 -10
  317. package/dist/core/types.d.ts.map +1 -1
  318. package/dist/core/types.js +9 -7
  319. package/dist/core/types.js.map +1 -1
  320. package/dist/core/workbench-link.d.ts +81 -0
  321. package/dist/core/workbench-link.d.ts.map +1 -0
  322. package/dist/core/workbench-link.js +109 -0
  323. package/dist/core/workbench-link.js.map +1 -0
  324. package/dist/core/worker-pool.d.ts +312 -11
  325. package/dist/core/worker-pool.d.ts.map +1 -1
  326. package/dist/core/worker-pool.js +2515 -217
  327. package/dist/core/worker-pool.js.map +1 -1
  328. package/dist/daemon.d.ts +6 -0
  329. package/dist/daemon.d.ts.map +1 -1
  330. package/dist/daemon.js +447 -116
  331. package/dist/daemon.js.map +1 -1
  332. package/dist/dashboard/aggregator.d.ts +33 -3
  333. package/dist/dashboard/aggregator.d.ts.map +1 -1
  334. package/dist/dashboard/aggregator.js +93 -17
  335. package/dist/dashboard/aggregator.js.map +1 -1
  336. package/dist/dashboard/auth-session-connections.d.ts +59 -0
  337. package/dist/dashboard/auth-session-connections.d.ts.map +1 -0
  338. package/dist/dashboard/auth-session-connections.js +109 -0
  339. package/dist/dashboard/auth-session-connections.js.map +1 -0
  340. package/dist/dashboard/auth.d.ts +99 -1
  341. package/dist/dashboard/auth.d.ts.map +1 -1
  342. package/dist/dashboard/auth.js +176 -64
  343. package/dist/dashboard/auth.js.map +1 -1
  344. package/dist/dashboard/bot-payload.d.ts +5 -0
  345. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  346. package/dist/dashboard/bot-payload.js +4 -0
  347. package/dist/dashboard/bot-payload.js.map +1 -1
  348. package/dist/dashboard/control-audit.d.ts +112 -0
  349. package/dist/dashboard/control-audit.d.ts.map +1 -0
  350. package/dist/dashboard/control-audit.js +195 -0
  351. package/dist/dashboard/control-audit.js.map +1 -0
  352. package/dist/dashboard/control-csrf.d.ts +123 -0
  353. package/dist/dashboard/control-csrf.d.ts.map +1 -0
  354. package/dist/dashboard/control-csrf.js +392 -0
  355. package/dist/dashboard/control-csrf.js.map +1 -0
  356. package/dist/dashboard/daemon-reconcile.d.ts +26 -0
  357. package/dist/dashboard/daemon-reconcile.d.ts.map +1 -0
  358. package/dist/dashboard/daemon-reconcile.js +46 -0
  359. package/dist/dashboard/daemon-reconcile.js.map +1 -0
  360. package/dist/dashboard/debug-terminal.d.ts.map +1 -1
  361. package/dist/dashboard/debug-terminal.js +18 -4
  362. package/dist/dashboard/debug-terminal.js.map +1 -1
  363. package/dist/dashboard/events-sse.d.ts +60 -0
  364. package/dist/dashboard/events-sse.d.ts.map +1 -0
  365. package/dist/dashboard/events-sse.js +63 -0
  366. package/dist/dashboard/events-sse.js.map +1 -0
  367. package/dist/dashboard/h5-auth.d.ts +256 -0
  368. package/dist/dashboard/h5-auth.d.ts.map +1 -0
  369. package/dist/dashboard/h5-auth.js +825 -0
  370. package/dist/dashboard/h5-auth.js.map +1 -0
  371. package/dist/dashboard/managed-spawn.d.ts +36 -0
  372. package/dist/dashboard/managed-spawn.d.ts.map +1 -0
  373. package/dist/dashboard/managed-spawn.js +169 -0
  374. package/dist/dashboard/managed-spawn.js.map +1 -0
  375. package/dist/dashboard/preview-content-capability.d.ts +41 -0
  376. package/dist/dashboard/preview-content-capability.d.ts.map +1 -0
  377. package/dist/dashboard/preview-content-capability.js +127 -0
  378. package/dist/dashboard/preview-content-capability.js.map +1 -0
  379. package/dist/dashboard/preview-contract.d.ts +85 -0
  380. package/dist/dashboard/preview-contract.d.ts.map +1 -0
  381. package/dist/dashboard/preview-contract.js +198 -0
  382. package/dist/dashboard/preview-contract.js.map +1 -0
  383. package/dist/dashboard/preview-guard-page.d.ts +52 -0
  384. package/dist/dashboard/preview-guard-page.d.ts.map +1 -0
  385. package/dist/dashboard/preview-guard-page.js +162 -0
  386. package/dist/dashboard/preview-guard-page.js.map +1 -0
  387. package/dist/dashboard/preview-interaction.d.ts +57 -0
  388. package/dist/dashboard/preview-interaction.d.ts.map +1 -0
  389. package/dist/dashboard/preview-interaction.js +198 -0
  390. package/dist/dashboard/preview-interaction.js.map +1 -0
  391. package/dist/dashboard/preview-proxy.d.ts +113 -0
  392. package/dist/dashboard/preview-proxy.d.ts.map +1 -0
  393. package/dist/dashboard/preview-proxy.js +543 -0
  394. package/dist/dashboard/preview-proxy.js.map +1 -0
  395. package/dist/dashboard/public-redact.d.ts +23 -0
  396. package/dist/dashboard/public-redact.d.ts.map +1 -1
  397. package/dist/dashboard/public-redact.js +90 -21
  398. package/dist/dashboard/public-redact.js.map +1 -1
  399. package/dist/dashboard/request-identity.d.ts +77 -0
  400. package/dist/dashboard/request-identity.d.ts.map +1 -0
  401. package/dist/dashboard/request-identity.js +72 -0
  402. package/dist/dashboard/request-identity.js.map +1 -0
  403. package/dist/dashboard/session-cleanup.d.ts +5 -0
  404. package/dist/dashboard/session-cleanup.d.ts.map +1 -1
  405. package/dist/dashboard/session-cleanup.js +4 -0
  406. package/dist/dashboard/session-cleanup.js.map +1 -1
  407. package/dist/dashboard/settings-write-applier.d.ts +5 -1
  408. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  409. package/dist/dashboard/settings-write-applier.js +14 -0
  410. package/dist/dashboard/settings-write-applier.js.map +1 -1
  411. package/dist/dashboard/standing-link.d.ts +76 -0
  412. package/dist/dashboard/standing-link.d.ts.map +1 -0
  413. package/dist/dashboard/standing-link.js +111 -0
  414. package/dist/dashboard/standing-link.js.map +1 -0
  415. package/dist/dashboard/terminal-control.d.ts +105 -0
  416. package/dist/dashboard/terminal-control.d.ts.map +1 -0
  417. package/dist/dashboard/terminal-control.js +296 -0
  418. package/dist/dashboard/terminal-control.js.map +1 -0
  419. package/dist/dashboard/terminal-front-proxy.d.ts +55 -0
  420. package/dist/dashboard/terminal-front-proxy.d.ts.map +1 -0
  421. package/dist/dashboard/terminal-front-proxy.js +397 -0
  422. package/dist/dashboard/terminal-front-proxy.js.map +1 -0
  423. package/dist/dashboard/terminal-view-capability.d.ts +97 -0
  424. package/dist/dashboard/terminal-view-capability.d.ts.map +1 -0
  425. package/dist/dashboard/terminal-view-capability.js +145 -0
  426. package/dist/dashboard/terminal-view-capability.js.map +1 -0
  427. package/dist/dashboard/web/agent-workbench-api.d.ts +59 -0
  428. package/dist/dashboard/web/agent-workbench-api.d.ts.map +1 -0
  429. package/dist/dashboard/web/agent-workbench-api.js +227 -0
  430. package/dist/dashboard/web/agent-workbench-api.js.map +1 -0
  431. package/dist/dashboard/web/agent-workbench-appearance-menu.d.ts +59 -0
  432. package/dist/dashboard/web/agent-workbench-appearance-menu.d.ts.map +1 -0
  433. package/dist/dashboard/web/agent-workbench-appearance-menu.js +195 -0
  434. package/dist/dashboard/web/agent-workbench-appearance-menu.js.map +1 -0
  435. package/dist/dashboard/web/agent-workbench-appearance.d.ts +222 -0
  436. package/dist/dashboard/web/agent-workbench-appearance.d.ts.map +1 -0
  437. package/dist/dashboard/web/agent-workbench-appearance.js +499 -0
  438. package/dist/dashboard/web/agent-workbench-appearance.js.map +1 -0
  439. package/dist/dashboard/web/agent-workbench-capabilities.d.ts +13 -0
  440. package/dist/dashboard/web/agent-workbench-capabilities.d.ts.map +1 -0
  441. package/dist/dashboard/web/agent-workbench-capabilities.js +24 -0
  442. package/dist/dashboard/web/agent-workbench-capabilities.js.map +1 -0
  443. package/dist/dashboard/web/agent-workbench-chat.d.ts +90 -0
  444. package/dist/dashboard/web/agent-workbench-chat.d.ts.map +1 -0
  445. package/dist/dashboard/web/agent-workbench-chat.js +168 -0
  446. package/dist/dashboard/web/agent-workbench-chat.js.map +1 -0
  447. package/dist/dashboard/web/agent-workbench-dock-page.d.ts +3 -0
  448. package/dist/dashboard/web/agent-workbench-dock-page.d.ts.map +1 -0
  449. package/dist/dashboard/web/agent-workbench-dock-page.js +23 -0
  450. package/dist/dashboard/web/agent-workbench-dock-page.js.map +1 -0
  451. package/dist/dashboard/web/agent-workbench-dock-view.d.ts +22 -0
  452. package/dist/dashboard/web/agent-workbench-dock-view.d.ts.map +1 -0
  453. package/dist/dashboard/web/agent-workbench-dock-view.js +76 -0
  454. package/dist/dashboard/web/agent-workbench-dock-view.js.map +1 -0
  455. package/dist/dashboard/web/agent-workbench-model.d.ts +196 -0
  456. package/dist/dashboard/web/agent-workbench-model.d.ts.map +1 -0
  457. package/dist/dashboard/web/agent-workbench-model.js +529 -0
  458. package/dist/dashboard/web/agent-workbench-model.js.map +1 -0
  459. package/dist/dashboard/web/agent-workbench-page.d.ts +3 -0
  460. package/dist/dashboard/web/agent-workbench-page.d.ts.map +1 -0
  461. package/dist/dashboard/web/agent-workbench-page.js +27 -0
  462. package/dist/dashboard/web/agent-workbench-page.js.map +1 -0
  463. package/dist/dashboard/web/agent-workbench-panes.d.ts +49 -0
  464. package/dist/dashboard/web/agent-workbench-panes.d.ts.map +1 -0
  465. package/dist/dashboard/web/agent-workbench-panes.js +468 -0
  466. package/dist/dashboard/web/agent-workbench-panes.js.map +1 -0
  467. package/dist/dashboard/web/agent-workbench-session-list.d.ts +42 -0
  468. package/dist/dashboard/web/agent-workbench-session-list.d.ts.map +1 -0
  469. package/dist/dashboard/web/agent-workbench-session-list.js +353 -0
  470. package/dist/dashboard/web/agent-workbench-session-list.js.map +1 -0
  471. package/dist/dashboard/web/agent-workbench-storage.d.ts +49 -0
  472. package/dist/dashboard/web/agent-workbench-storage.d.ts.map +1 -0
  473. package/dist/dashboard/web/agent-workbench-storage.js +197 -0
  474. package/dist/dashboard/web/agent-workbench-storage.js.map +1 -0
  475. package/dist/dashboard/web/agent-workbench-touch.d.ts +46 -0
  476. package/dist/dashboard/web/agent-workbench-touch.d.ts.map +1 -0
  477. package/dist/dashboard/web/agent-workbench-touch.js +96 -0
  478. package/dist/dashboard/web/agent-workbench-touch.js.map +1 -0
  479. package/dist/dashboard/web/agent-workbench-view.d.ts +34 -0
  480. package/dist/dashboard/web/agent-workbench-view.d.ts.map +1 -0
  481. package/dist/dashboard/web/agent-workbench-view.js +439 -0
  482. package/dist/dashboard/web/agent-workbench-view.js.map +1 -0
  483. package/dist/dashboard/web/app.d.ts.map +1 -1
  484. package/dist/dashboard/web/app.js +63 -2
  485. package/dist/dashboard/web/app.js.map +1 -1
  486. package/dist/dashboard/web/bot-defaults-page.d.ts +18 -0
  487. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  488. package/dist/dashboard/web/bot-defaults-page.js +362 -36
  489. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  490. package/dist/dashboard/web/bot-defaults.d.ts +8 -0
  491. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  492. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  493. package/dist/dashboard/web/control-csrf.d.ts +4 -0
  494. package/dist/dashboard/web/control-csrf.d.ts.map +1 -0
  495. package/dist/dashboard/web/control-csrf.js +26 -0
  496. package/dist/dashboard/web/control-csrf.js.map +1 -0
  497. package/dist/dashboard/web/dashboard-routes.d.ts.map +1 -1
  498. package/dist/dashboard/web/dashboard-routes.js +2 -0
  499. package/dist/dashboard/web/dashboard-routes.js.map +1 -1
  500. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  501. package/dist/dashboard/web/groups-page.js +11 -2
  502. package/dist/dashboard/web/groups-page.js.map +1 -1
  503. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  504. package/dist/dashboard/web/i18n.js +140 -6
  505. package/dist/dashboard/web/i18n.js.map +1 -1
  506. package/dist/dashboard/web/overview-page.d.ts.map +1 -1
  507. package/dist/dashboard/web/overview-page.js +3 -2
  508. package/dist/dashboard/web/overview-page.js.map +1 -1
  509. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  510. package/dist/dashboard/web/sessions-page.js +41 -1
  511. package/dist/dashboard/web/sessions-page.js.map +1 -1
  512. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  513. package/dist/dashboard/web/sessions.js +5 -1
  514. package/dist/dashboard/web/sessions.js.map +1 -1
  515. package/dist/dashboard/web/settings-page.d.ts +3 -0
  516. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  517. package/dist/dashboard/web/settings-page.js +56 -10
  518. package/dist/dashboard/web/settings-page.js.map +1 -1
  519. package/dist/dashboard/web/skills-page.d.ts.map +1 -1
  520. package/dist/dashboard/web/skills-page.js +4 -2
  521. package/dist/dashboard/web/skills-page.js.map +1 -1
  522. package/dist/dashboard/web/store.d.ts +27 -2
  523. package/dist/dashboard/web/store.d.ts.map +1 -1
  524. package/dist/dashboard/web/store.js +43 -4
  525. package/dist/dashboard/web/store.js.map +1 -1
  526. package/dist/dashboard/web/ui.d.ts +3 -0
  527. package/dist/dashboard/web/ui.d.ts.map +1 -1
  528. package/dist/dashboard/web/ui.js +11 -0
  529. package/dist/dashboard/web/ui.js.map +1 -1
  530. package/dist/dashboard/web/update-action.d.ts +12 -0
  531. package/dist/dashboard/web/update-action.d.ts.map +1 -1
  532. package/dist/dashboard/web/update-action.js +11 -0
  533. package/dist/dashboard/web/update-action.js.map +1 -1
  534. package/dist/dashboard/workbench-doctor.d.ts +114 -0
  535. package/dist/dashboard/workbench-doctor.d.ts.map +1 -0
  536. package/dist/dashboard/workbench-doctor.js +620 -0
  537. package/dist/dashboard/workbench-doctor.js.map +1 -0
  538. package/dist/dashboard/workbench-ticket.d.ts +165 -0
  539. package/dist/dashboard/workbench-ticket.d.ts.map +1 -0
  540. package/dist/dashboard/workbench-ticket.js +495 -0
  541. package/dist/dashboard/workbench-ticket.js.map +1 -0
  542. package/dist/dashboard-web/app.js +1 -1
  543. package/dist/dashboard-web/chunks/agent-workbench-dock-page-IKAC5CFL.js +1 -0
  544. package/dist/dashboard-web/chunks/agent-workbench-page-M53UN5EB.js +1 -0
  545. package/dist/dashboard-web/chunks/bot-defaults-page-YKE5SDZF.js +10 -0
  546. package/dist/dashboard-web/chunks/chunk-22WIADUT.js +2 -0
  547. package/dist/dashboard-web/chunks/chunk-24ONYQMR.js +1 -0
  548. package/dist/dashboard-web/chunks/{chunk-6O7KXFD2.js → chunk-2W6SCN3M.js} +1 -1
  549. package/dist/dashboard-web/chunks/chunk-4XNJX6B3.js +1 -0
  550. package/dist/dashboard-web/chunks/{chunk-Q2MT4VWH.js → chunk-F6KNPF4R.js} +11 -9
  551. package/dist/dashboard-web/chunks/chunk-FXW3V4JR.js +1 -0
  552. package/dist/dashboard-web/chunks/chunk-G5ZS2YUS.js +1 -0
  553. package/dist/dashboard-web/chunks/chunk-KTQ7B6ZR.js +1 -0
  554. package/dist/dashboard-web/chunks/{chunk-U3ZFSO7E.js → chunk-NACP7BMR.js} +1 -1
  555. package/dist/dashboard-web/chunks/{connectors-page-2KZO5KSJ.js → connectors-page-VOISXXII.js} +1 -1
  556. package/dist/dashboard-web/chunks/{feedback-page-CLVTOLLO.js → feedback-page-RN6HRZMS.js} +1 -1
  557. package/dist/dashboard-web/chunks/groups-page-UQT6DCB7.js +8 -0
  558. package/dist/dashboard-web/chunks/{insights-page-4RPU2CRU.js → insights-page-HJFDYR5N.js} +3 -3
  559. package/dist/dashboard-web/chunks/{monitor-room-OTKQ2CXK.js → monitor-room-V7PCP47V.js} +1 -1
  560. package/dist/dashboard-web/chunks/{monitoring-page-IJPUB6JT.js → monitoring-page-NKEKVQAP.js} +1 -1
  561. package/dist/dashboard-web/chunks/{office-page-QSR3J44Y.js → office-page-GVJF73XW.js} +1 -1
  562. package/dist/dashboard-web/chunks/overview-page-KHLV6VTI.js +1 -0
  563. package/dist/dashboard-web/chunks/{plugin-page-VEGTCEAS.js → plugin-page-J3OF5IY4.js} +1 -1
  564. package/dist/dashboard-web/chunks/{roles-page-6NWJMOB5.js → roles-page-WQIRSOLZ.js} +1 -1
  565. package/dist/dashboard-web/chunks/{schedules-page-6I3LNXD4.js → schedules-page-J2DEY2X6.js} +1 -1
  566. package/dist/dashboard-web/chunks/sessions-page-7BQFEA5P.js +8 -0
  567. package/dist/dashboard-web/chunks/settings-page-BJEIOFJH.js +2 -0
  568. package/dist/dashboard-web/chunks/{skills-page-M2Q6L5CB.js → skills-page-HGIFR3IU.js} +2 -2
  569. package/dist/dashboard-web/chunks/{team-federation-page-7I7FDE3B.js → team-federation-page-MR5ZPL4B.js} +1 -1
  570. package/dist/dashboard-web/chunks/{v3-page-HQXRL65L.js → v3-page-GBR5JWPE.js} +2 -2
  571. package/dist/dashboard-web/chunks/{whiteboards-page-LQKC4TAZ.js → whiteboards-page-QAZM4BN3.js} +1 -1
  572. package/dist/dashboard-web/index.html +8 -1
  573. package/dist/dashboard-web/style.css +2006 -10
  574. package/dist/dashboard.js +1017 -264
  575. package/dist/dashboard.js.map +1 -1
  576. package/dist/dsh-runner.js +8 -1
  577. package/dist/dsh-runner.js.map +1 -1
  578. package/dist/global-config.d.ts +30 -0
  579. package/dist/global-config.d.ts.map +1 -1
  580. package/dist/global-config.js +33 -0
  581. package/dist/global-config.js.map +1 -1
  582. package/dist/i18n/en.d.ts.map +1 -1
  583. package/dist/i18n/en.js +46 -27
  584. package/dist/i18n/en.js.map +1 -1
  585. package/dist/i18n/zh.d.ts.map +1 -1
  586. package/dist/i18n/zh.js +43 -27
  587. package/dist/i18n/zh.js.map +1 -1
  588. package/dist/im/lark/card-builder.d.ts +1 -1
  589. package/dist/im/lark/card-builder.d.ts.map +1 -1
  590. package/dist/im/lark/card-builder.js +22 -6
  591. package/dist/im/lark/card-builder.js.map +1 -1
  592. package/dist/im/lark/card-handler.d.ts.map +1 -1
  593. package/dist/im/lark/card-handler.js +118 -24
  594. package/dist/im/lark/card-handler.js.map +1 -1
  595. package/dist/im/lark/client.d.ts +15 -0
  596. package/dist/im/lark/client.d.ts.map +1 -1
  597. package/dist/im/lark/client.js +30 -0
  598. package/dist/im/lark/client.js.map +1 -1
  599. package/dist/im/lark/event-dispatcher.d.ts +48 -0
  600. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  601. package/dist/im/lark/event-dispatcher.js +138 -41
  602. package/dist/im/lark/event-dispatcher.js.map +1 -1
  603. package/dist/im/lark/identity-cache.d.ts +14 -6
  604. package/dist/im/lark/identity-cache.d.ts.map +1 -1
  605. package/dist/im/lark/identity-cache.js +69 -26
  606. package/dist/im/lark/identity-cache.js.map +1 -1
  607. package/dist/im/lark/lark-hosts.d.ts +13 -0
  608. package/dist/im/lark/lark-hosts.d.ts.map +1 -1
  609. package/dist/im/lark/lark-hosts.js +16 -0
  610. package/dist/im/lark/lark-hosts.js.map +1 -1
  611. package/dist/im/lark/overview-card.d.ts +22 -0
  612. package/dist/im/lark/overview-card.d.ts.map +1 -1
  613. package/dist/im/lark/overview-card.js +53 -3
  614. package/dist/im/lark/overview-card.js.map +1 -1
  615. package/dist/im/lark/sessions-card.d.ts.map +1 -1
  616. package/dist/im/lark/sessions-card.js +26 -3
  617. package/dist/im/lark/sessions-card.js.map +1 -1
  618. package/dist/im/lark/workflow-slash-command.d.ts +6 -0
  619. package/dist/im/lark/workflow-slash-command.d.ts.map +1 -1
  620. package/dist/im/lark/workflow-slash-command.js +7 -0
  621. package/dist/im/lark/workflow-slash-command.js.map +1 -1
  622. package/dist/index-daemon.js +27 -4
  623. package/dist/index-daemon.js.map +1 -1
  624. package/dist/index-dashboard.d.ts +3 -0
  625. package/dist/index-dashboard.d.ts.map +1 -0
  626. package/dist/index-dashboard.js +94 -0
  627. package/dist/index-dashboard.js.map +1 -0
  628. package/dist/platform/team-agents-client.d.ts +199 -0
  629. package/dist/platform/team-agents-client.d.ts.map +1 -0
  630. package/dist/platform/team-agents-client.js +263 -0
  631. package/dist/platform/team-agents-client.js.map +1 -0
  632. package/dist/platform/tunnel-client.d.ts +6 -0
  633. package/dist/platform/tunnel-client.d.ts.map +1 -1
  634. package/dist/platform/tunnel-client.js.map +1 -1
  635. package/dist/services/async-trigger-state.d.ts +7 -3
  636. package/dist/services/async-trigger-state.d.ts.map +1 -1
  637. package/dist/services/async-trigger-state.js +28 -0
  638. package/dist/services/async-trigger-state.js.map +1 -1
  639. package/dist/services/async-trigger-store.d.ts +13 -4
  640. package/dist/services/async-trigger-store.d.ts.map +1 -1
  641. package/dist/services/async-trigger-store.js +55 -8
  642. package/dist/services/async-trigger-store.js.map +1 -1
  643. package/dist/services/bot-config-store.d.ts +1 -11
  644. package/dist/services/bot-config-store.d.ts.map +1 -1
  645. package/dist/services/bot-config-store.js +27 -3
  646. package/dist/services/bot-config-store.js.map +1 -1
  647. package/dist/services/bot-profile-store.d.ts +17 -2
  648. package/dist/services/bot-profile-store.d.ts.map +1 -1
  649. package/dist/services/bot-profile-store.js +93 -12
  650. package/dist/services/bot-profile-store.js.map +1 -1
  651. package/dist/services/bridge-fallback-gate.d.ts +15 -7
  652. package/dist/services/bridge-fallback-gate.d.ts.map +1 -1
  653. package/dist/services/bridge-fallback-gate.js +43 -16
  654. package/dist/services/bridge-fallback-gate.js.map +1 -1
  655. package/dist/services/bridge-turn-queue.d.ts +10 -1
  656. package/dist/services/bridge-turn-queue.d.ts.map +1 -1
  657. package/dist/services/bridge-turn-queue.js +29 -9
  658. package/dist/services/bridge-turn-queue.js.map +1 -1
  659. package/dist/services/claude-transcript.d.ts +19 -0
  660. package/dist/services/claude-transcript.d.ts.map +1 -1
  661. package/dist/services/claude-transcript.js +57 -5
  662. package/dist/services/claude-transcript.js.map +1 -1
  663. package/dist/services/codex-app-threads.d.ts +2 -0
  664. package/dist/services/codex-app-threads.d.ts.map +1 -1
  665. package/dist/services/codex-app-threads.js +1 -1
  666. package/dist/services/codex-app-threads.js.map +1 -1
  667. package/dist/services/codex-reasoning-effort.d.ts +8 -0
  668. package/dist/services/codex-reasoning-effort.d.ts.map +1 -1
  669. package/dist/services/codex-reasoning-effort.js +24 -0
  670. package/dist/services/codex-reasoning-effort.js.map +1 -1
  671. package/dist/services/cursor-resume-policy.d.ts.map +1 -1
  672. package/dist/services/cursor-resume-policy.js +15 -3
  673. package/dist/services/cursor-resume-policy.js.map +1 -1
  674. package/dist/services/open-platform-rename.d.ts.map +1 -1
  675. package/dist/services/open-platform-rename.js +3 -2
  676. package/dist/services/open-platform-rename.js.map +1 -1
  677. package/dist/services/ordinary-turn-recovery.d.ts +76 -0
  678. package/dist/services/ordinary-turn-recovery.d.ts.map +1 -0
  679. package/dist/services/ordinary-turn-recovery.js +349 -0
  680. package/dist/services/ordinary-turn-recovery.js.map +1 -0
  681. package/dist/services/peer-cross-ref-store.d.ts +8 -0
  682. package/dist/services/peer-cross-ref-store.d.ts.map +1 -0
  683. package/dist/services/peer-cross-ref-store.js +95 -0
  684. package/dist/services/peer-cross-ref-store.js.map +1 -0
  685. package/dist/services/pi-transcript.d.ts.map +1 -1
  686. package/dist/services/pi-transcript.js +10 -6
  687. package/dist/services/pi-transcript.js.map +1 -1
  688. package/dist/services/resume-fresh-policy.d.ts +17 -0
  689. package/dist/services/resume-fresh-policy.d.ts.map +1 -0
  690. package/dist/services/resume-fresh-policy.js +24 -0
  691. package/dist/services/resume-fresh-policy.js.map +1 -0
  692. package/dist/services/session-group-title.d.ts +1 -0
  693. package/dist/services/session-group-title.d.ts.map +1 -1
  694. package/dist/services/session-group-title.js +16 -1
  695. package/dist/services/session-group-title.js.map +1 -1
  696. package/dist/services/session-groups-store.d.ts +30 -0
  697. package/dist/services/session-groups-store.d.ts.map +1 -1
  698. package/dist/services/session-groups-store.js +26 -4
  699. package/dist/services/session-groups-store.js.map +1 -1
  700. package/dist/services/session-owner-notification.d.ts +9 -0
  701. package/dist/services/session-owner-notification.d.ts.map +1 -0
  702. package/dist/services/session-owner-notification.js +10 -0
  703. package/dist/services/session-owner-notification.js.map +1 -0
  704. package/dist/services/session-owner-reminder-config-store.d.ts +9 -0
  705. package/dist/services/session-owner-reminder-config-store.d.ts.map +1 -0
  706. package/dist/services/session-owner-reminder-config-store.js +24 -0
  707. package/dist/services/session-owner-reminder-config-store.js.map +1 -0
  708. package/dist/services/session-owner-reminder-store.d.ts +5 -0
  709. package/dist/services/session-owner-reminder-store.d.ts.map +1 -0
  710. package/dist/services/session-owner-reminder-store.js +54 -0
  711. package/dist/services/session-owner-reminder-store.js.map +1 -0
  712. package/dist/services/session-store.d.ts +98 -21
  713. package/dist/services/session-store.d.ts.map +1 -1
  714. package/dist/services/session-store.js +406 -48
  715. package/dist/services/session-store.js.map +1 -1
  716. package/dist/services/structured-bridge-clis.d.ts +15 -2
  717. package/dist/services/structured-bridge-clis.d.ts.map +1 -1
  718. package/dist/services/structured-bridge-clis.js +16 -1
  719. package/dist/services/structured-bridge-clis.js.map +1 -1
  720. package/dist/services/transcript-resolver.d.ts +1 -1
  721. package/dist/services/transcript-resolver.d.ts.map +1 -1
  722. package/dist/services/transcript-resolver.js +6 -1
  723. package/dist/services/transcript-resolver.js.map +1 -1
  724. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  725. package/dist/setup/bot-config-editor.js +7 -2
  726. package/dist/setup/bot-config-editor.js.map +1 -1
  727. package/dist/setup/ensure-herdr-integrations.d.ts +13 -0
  728. package/dist/setup/ensure-herdr-integrations.d.ts.map +1 -1
  729. package/dist/setup/ensure-herdr-integrations.js +21 -3
  730. package/dist/setup/ensure-herdr-integrations.js.map +1 -1
  731. package/dist/setup/open-platform-automation.d.ts +1 -0
  732. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  733. package/dist/setup/open-platform-automation.js +107 -7
  734. package/dist/setup/open-platform-automation.js.map +1 -1
  735. package/dist/skills/definitions.d.ts +17 -0
  736. package/dist/skills/definitions.d.ts.map +1 -1
  737. package/dist/skills/definitions.js +78 -8
  738. package/dist/skills/definitions.js.map +1 -1
  739. package/dist/skills/injection-mode.d.ts +24 -3
  740. package/dist/skills/injection-mode.d.ts.map +1 -1
  741. package/dist/skills/injection-mode.js +64 -15
  742. package/dist/skills/injection-mode.js.map +1 -1
  743. package/dist/skills/installer.d.ts +18 -0
  744. package/dist/skills/installer.d.ts.map +1 -1
  745. package/dist/skills/installer.js +45 -1
  746. package/dist/skills/installer.js.map +1 -1
  747. package/dist/types.d.ts +222 -10
  748. package/dist/types.d.ts.map +1 -1
  749. package/dist/utils/bot-routing.d.ts +1 -1
  750. package/dist/utils/bot-routing.d.ts.map +1 -1
  751. package/dist/utils/bot-routing.js.map +1 -1
  752. package/dist/utils/child-env.d.ts +92 -2
  753. package/dist/utils/child-env.d.ts.map +1 -1
  754. package/dist/utils/child-env.js +195 -1
  755. package/dist/utils/child-env.js.map +1 -1
  756. package/dist/utils/cli-usage-limit.d.ts +25 -14
  757. package/dist/utils/cli-usage-limit.d.ts.map +1 -1
  758. package/dist/utils/cli-usage-limit.js +25 -15
  759. package/dist/utils/cli-usage-limit.js.map +1 -1
  760. package/dist/utils/daemon-rejection-guard.d.ts +35 -0
  761. package/dist/utils/daemon-rejection-guard.d.ts.map +1 -0
  762. package/dist/utils/daemon-rejection-guard.js +101 -0
  763. package/dist/utils/daemon-rejection-guard.js.map +1 -0
  764. package/dist/utils/dashboard-env.d.ts +53 -0
  765. package/dist/utils/dashboard-env.d.ts.map +1 -0
  766. package/dist/utils/dashboard-env.js +116 -0
  767. package/dist/utils/dashboard-env.js.map +1 -0
  768. package/dist/utils/global-install.d.ts.map +1 -1
  769. package/dist/utils/global-install.js +61 -5
  770. package/dist/utils/global-install.js.map +1 -1
  771. package/dist/utils/install-diagnostics.d.ts +14 -7
  772. package/dist/utils/install-diagnostics.d.ts.map +1 -1
  773. package/dist/utils/install-diagnostics.js +20 -11
  774. package/dist/utils/install-diagnostics.js.map +1 -1
  775. package/dist/utils/local-dev-update.d.ts +132 -0
  776. package/dist/utils/local-dev-update.d.ts.map +1 -0
  777. package/dist/utils/local-dev-update.js +176 -0
  778. package/dist/utils/local-dev-update.js.map +1 -0
  779. package/dist/utils/pending-input-queue.d.ts +12 -1
  780. package/dist/utils/pending-input-queue.d.ts.map +1 -1
  781. package/dist/utils/pending-input-queue.js.map +1 -1
  782. package/dist/worker.js +1517 -236
  783. package/dist/worker.js.map +1 -1
  784. package/dist/workflows/definition.d.ts +68 -68
  785. package/dist/workflows/events/payloads.d.ts +14 -14
  786. package/dist/workflows/events/schema.d.ts +312 -312
  787. package/dist/workflows/migration/v2-run-archive-schema.d.ts +40 -40
  788. package/dist/workflows/v3/cli-run.d.ts.map +1 -1
  789. package/dist/workflows/v3/cli-run.js +7 -0
  790. package/dist/workflows/v3/cli-run.js.map +1 -1
  791. package/dist/workflows/v3/goal-cli.d.ts +3 -0
  792. package/dist/workflows/v3/goal-cli.d.ts.map +1 -1
  793. package/dist/workflows/v3/goal-cli.js +11 -0
  794. package/dist/workflows/v3/goal-cli.js.map +1 -1
  795. package/dist/workflows/v3/run-envelope.d.ts +144 -144
  796. package/package.json +7 -2
  797. package/dist/cli/pm2-god-admission.d.ts +0 -8
  798. package/dist/cli/pm2-god-admission.d.ts.map +0 -1
  799. package/dist/cli/pm2-god-admission.js +0 -25
  800. package/dist/cli/pm2-god-admission.js.map +0 -1
  801. package/dist/core/riff-shutdown-detach.d.ts.map +0 -1
  802. package/dist/core/riff-shutdown-detach.js.map +0 -1
  803. package/dist/core/riff-worker-shutdown-readiness.d.ts.map +0 -1
  804. package/dist/core/riff-worker-shutdown-readiness.js.map +0 -1
  805. package/dist/dashboard-web/chunks/bot-defaults-page-4RENHE7J.js +0 -10
  806. package/dist/dashboard-web/chunks/chunk-2WQDS65D.js +0 -1
  807. package/dist/dashboard-web/chunks/chunk-G5XGBCM6.js +0 -1
  808. package/dist/dashboard-web/chunks/chunk-WLIVBPJC.js +0 -1
  809. package/dist/dashboard-web/chunks/groups-page-HLASRTSY.js +0 -8
  810. package/dist/dashboard-web/chunks/overview-page-6OK63V4V.js +0 -1
  811. package/dist/dashboard-web/chunks/sessions-page-B2TXGJDO.js +0 -8
  812. package/dist/dashboard-web/chunks/settings-page-6KJADSC6.js +0 -2
@@ -0,0 +1,2471 @@
1
+ /**
2
+ * MojoBackend — API-backed SessionBackend for @byted/mojo.
3
+ *
4
+ * Implements botmux's pseudo-PTY SessionBackend surface on top of mojo's
5
+ * headless mode, in the same spirit as RiffBackend. Verified empirically
6
+ * against @byted/mojo 1.0.10 (linux-x64).
7
+ *
8
+ * ── Why not a TUI adapter (kimi/grok style) ──────────────────────────────────
9
+ * 1. `--yolo` / `-r` / `-c` / `--output-format` / `--timeout` / `--idle-timeout`
10
+ * are all "仅 -p" (headless only). Passing them without `-p` does not launch
11
+ * a TUI — the process just blocks on stdin until EOF.
12
+ * 2. mojo keeps NO local per-session transcript (`~/.mojo` holds only
13
+ * credentials/ memory/ skills/; session state is server-side), so the
14
+ * grok-style "tail updates.jsonl for turn end" bridge is impossible and only
15
+ * unreliable screen-scraping would remain.
16
+ *
17
+ * ── Why foreground streaming and not `--background` + polling ────────────────
18
+ * `--background` looks attractive (instant id, ask_user/confirm interactions,
19
+ * survives restarts) but it is CREATE-ONLY — verified:
20
+ * mojo -p --background -r <sid> → error invalid_argument "--background 不支持:--resume"
21
+ * mojo -p --background -c → error invalid_argument "--background 不支持:--continue"
22
+ * A chat bot is inherently multi-turn, so a create-only submit path would start a
23
+ * fresh context-less session on every IM message. Foreground `-p -r <sid>` does
24
+ * resume correctly (including sessions originally created by --background), and
25
+ * additionally gives real token-level streaming plus an exact turn boundary.
26
+ *
27
+ * The cost, made explicit so it isn't discovered in production: in foreground
28
+ * mode mojo AUTO-SKIPS ask_user and cancels the turn —
29
+ * warnings: ["agent 的提问(ask-user)在非交互模式下被自动跳过"]
30
+ * error: {code:"cancelled"}, exit code 1
31
+ * We detect exactly that and tell the user to supply the missing detail, instead
32
+ * of leaving them with a silently empty turn. See ASK_USER_SKIPPED_RE.
33
+ *
34
+ * ── Event stream (`-p --output-format stream-json --include-partial`) ────────
35
+ * {type:"system", subtype:"init", session_id, model} ← id available up-front
36
+ * {type:"text_delta", text} ← incremental
37
+ * {type:"text", text} ← whole segment
38
+ * {type:"tool_call", id, name, input}
39
+ * {type:"result", status, result, session_id, duration_ms, num_tool_calls,
40
+ * warnings, error} ← exact turn boundary
41
+ *
42
+ * NOTE: the foreground envelope is NOT the same shape as the `--background` /
43
+ * `session.*` schema-v1 envelope (which additionally carries schema_version,
44
+ * operation, state, turn_id, result_complete, interaction). Never assume `state`
45
+ * or `result_complete` exists on a foreground result. Also `error` is an OBJECT
46
+ * ({code, message, retryable}), not a string.
47
+ */
48
+ import { randomBytes } from 'node:crypto';
49
+ import { spawn as spawnProcess } from 'node:child_process';
50
+ import { classifyUnprovenTermination } from './destroy-result.js';
51
+ import { acquireContainmentHandle, containmentHandleKey, containmentHandles, killPreparedBoundary, prepareContainmentBoundary, proveContainmentQuiescent, recordContainmentHandle, releaseContainmentHandle, strongHandleFromPreparedBoundary, containedProvenQuiescence, containmentQuiescence, weakHandleRootStillOriginal, } from '../../core/mojo-containment.js';
52
+ import { MOJO_TREE_NONCE_ENV, terminationOutcomeFromQuiescence, quiescenceFromScan, readProcessIdentity, scanMojoTree, signalTurnTreeGroup, } from './mojo-process-tree.js';
53
+ import { accessSync, constants as fsConstants, writeFileSync } from 'node:fs';
54
+ import { delimiter, join } from 'node:path';
55
+ import { locateOnPath } from '../cli/registry.js';
56
+ import { buildWrappedLaunch } from '../../setup/cli-selection.js';
57
+ import { logger } from '../../utils/logger.js';
58
+ import { cleanupMojoIsolatedWorkspace, ensureMojoIsolatedWorkspace, } from './mojo-isolated-workspace.js';
59
+ import { buildEffectiveChildEnv, deriveMojoExecutionMode, findReservedMojoCliFlags, mojoRemoteProofFailureReason, isMojoRemoteGone, MOJO_CANONICAL_JWT_ENV_KEY, MOJO_CONTROL_ENV_KEYS, } from './mojo-types.js';
60
+ import { MOJO_CLI_TIMEOUT_MS, MOJO_DESTROY_SETTLE_MS, MOJO_CHILD_TERMINATION_PROOF_MS } from './mojo-budgets.js';
61
+ /** mojo silently drops an agent clarifying question in headless mode and marks
62
+ * the turn cancelled. Matching this is the difference between a helpful nudge
63
+ * and a mystifying empty reply. */
64
+ const ASK_USER_SKIPPED_RE = /ask-?user|提问.*被自动跳过/i;
65
+ /**
66
+ * The server-side session stays RUNNING for a short window AFTER the foreground
67
+ * process has already emitted its `result` event and exited. Verified: firing the
68
+ * next turn immediately fails with
69
+ * mojo: 会话 <sid> 正在执行中(RUNNING),稍后再试 (exit 1)
70
+ * A human typing in IM rarely hits this, but botmux flushes queued follow-ups the
71
+ * instant a turn boundary fires — so it hits reliably there. Retry with backoff
72
+ * instead of surfacing a spurious error to the user.
73
+ */
74
+ const SESSION_BUSY_RE = /正在执行中|RUNNING)|already running/i;
75
+ /**
76
+ * A resumed session id can stop being resumable (server-side GC, expiry, or a
77
+ * session created under a different workspace/agent). Without handling this the
78
+ * lineage is a permanent trap: every later message re-sends the same dead `-r
79
+ * <sid>` and fails forever.
80
+ *
81
+ * ⚠️ NOT EMPIRICALLY VERIFIED — @byted/mojo was not installable in the porting
82
+ * environment (npm 404, internal registry only), so the exact wording is
83
+ * unknown. The patterns below are deliberately BROAD, and the decision is
84
+ * additionally gated on `-r` having actually been passed (see maybeDropLineage),
85
+ * so a false positive costs one lost context rather than a wedged session.
86
+ * Calibrate against real output in the E2E pass — see OPEN_ITEMS.md.
87
+ */
88
+ const RESUME_DEAD_RE = /会话.*(不存在|已过期|已结束|无效|未找到)|session.*(not\s*found|expired|invalid|does not exist)|not_found|invalid_session/i;
89
+ const BUSY_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000];
90
+ /**
91
+ * Pre-exec cgroup enrolment shim (P0: the spawn→enrolment window).
92
+ *
93
+ * Post-spawn migration cannot capture descendants the child forked before the
94
+ * parent's `cgroup.procs` write landed — cgroup v2 does not retroactively move
95
+ * an existing process's descendants — so the enrolment has to happen INSIDE the
96
+ * child, before any target code runs. `/bin/sh` writes its own pid into the
97
+ * prepared boundary and only then `exec`s the real launch, keeping the same
98
+ * pid. Between fork and the write the process executes just this shim, which
99
+ * forks nothing, so every descendant of the target is born enrolled.
100
+ *
101
+ * The `|| exit 97` is the handshake: an enrolment failure must NOT fall through
102
+ * to running a credentialed binary outside the boundary. Exit 97 is reserved —
103
+ * the parent maps it to a refused turn (see MOJO_ENROLL_FAILED_EXIT).
104
+ *
105
+ * Invoked as: sh -c SHIM <name> <cgroup.procs path> <bin> <args...>
106
+ * ($0 = name, $1 = procs path; after `shift`, "$@" = bin + args.)
107
+ */
108
+ // NOTE: `exec "$@"` NOT `exec -- "$@"`. dash (the Linux /bin/sh) treats `exec --`
109
+ // as an attempt to exec a command literally named `--` and fails, so the shim
110
+ // would never reach mojo. The `--` guard is unneeded anyway: the bin is always a
111
+ // resolved ABSOLUTE path (never starts with `-`), so there is no option-injection
112
+ // to guard against.
113
+ export const MOJO_CGROUP_ENROLL_SHIM = 'echo "$$" > "$1" || exit 97; shift; exec "$@"';
114
+ export const MOJO_ENROLL_FAILED_EXIT = 97;
115
+ export class MojoBackend {
116
+ /** Mutable: applyLivePatch rotates credentials without a refork. */
117
+ config;
118
+ sessionId;
119
+ dataCb = null;
120
+ taskDoneCb = null;
121
+ exitCb = null;
122
+ taskIdCb = null;
123
+ turnFinalCb = null;
124
+ outputBuffer = '';
125
+ /**
126
+ * This turn's assistant answer, accumulated from the SAME text the user
127
+ * sees on the card (emitText is the single choke point for model prose —
128
+ * tool-call/warning chrome goes through emitLine and is deliberately left
129
+ * out). Reset per turn in runTurn(); handed to turnFinalCb at settleTurn().
130
+ */
131
+ turnFinalText = '';
132
+ /** mojo-side session id — the resume lineage. */
133
+ cliSessionId = null;
134
+ child = null;
135
+ killed = false;
136
+ closing = false;
137
+ /** A close attempt observed evidence that something credentialed may still be
138
+ * alive (an unproven local subtree, or a dispatched turn with no lineage).
139
+ *
140
+ * ONE-WAY for the lifetime of this backend: nothing clears it. The CLOSE stays
141
+ * retryable though — a later destroySession() whose terminateChildProven()
142
+ * succeeds proceeds to the remote cancel and returns ok:true — and that
143
+ * liveness property IS covered by a test, because a fence that also wedged the
144
+ * close would be worse than the bug it fixes.
145
+ *
146
+ * Honest scope note: the one-way lifetime itself is an implementation fact, not
147
+ * a tested guarantee. Once a close succeeds `killed` refuses writes anyway, so
148
+ * clearing this field at that point is an equivalent mutation (verified: it
149
+ * survives). Do not read it as a proven invariant. */
150
+ admissionFenced = false;
151
+ /** Graceful daemon shutdown is a non-cancelling detach. Fence only writes
152
+ * arriving after prepare, then wait just long enough for an already accepted
153
+ * first turn to publish its `system/init` lineage. */
154
+ shutdownDetaching = false;
155
+ shutdownDetachPrepared = false;
156
+ shutdownDetachAttempt = null;
157
+ shutdownDetachInFlight = null;
158
+ shutdownDetachAbortInFlight = null;
159
+ shutdownDetachWake = null;
160
+ lineageWaiters = new Set();
161
+ /** At least one turn crossed the adapter boundary while no lineage was
162
+ * known. A later process exit without `system/init` cannot prove that no
163
+ * remote session was created, so shutdown must not persist authoritative
164
+ * null merely because the local write promise settled. */
165
+ acceptedWriteWithoutLineage = false;
166
+ /** Inherited by every descendant of every turn, so the subtree stays
167
+ * enumerable after setsid/reparenting. Per BACKEND, not per turn: a tool left
168
+ * behind by an earlier turn must still be found. */
169
+ /**
170
+ * Env nonce injected into the turn subtree, inherited by every descendant.
171
+ *
172
+ * NOT `readonly`, and NOT freshly random per instance: a replacement worker
173
+ * generation builds a NEW backend for the SAME session, and a new nonce would
174
+ * make the previous generation's tree unenumerable forever (the env signal is
175
+ * the only one that survives setsid + reparenting). So it is adopted from an
176
+ * inherited containment handle whenever one is outstanding.
177
+ */
178
+ treeNonce = `botmux-mojo-${randomBytes(12).toString('hex')}`;
179
+ /**
180
+ * Worker generation, used only for operator-facing logs on the handle. Derived
181
+ * from how many handles this session already has outstanding, so a replacement
182
+ * generation is distinguishable from the first one.
183
+ */
184
+ containmentGeneration;
185
+ /**
186
+ * The cgroup boundary prepared for the CURRENT turn, created before spawn so
187
+ * the child can enrol itself pre-exec (see MOJO_CGROUP_ENROLL_SHIM). Null on
188
+ * hosts without cgroup v2 delegation — those turns get a weak handle instead.
189
+ */
190
+ preparedBoundary = null;
191
+ /** Realpath of this session's isolated workspace (host execution only).
192
+ * Populated lazily by resolveCwd(); spawn and close share this exact
193
+ * string so the close-side daemon-registry match cannot drift. */
194
+ isolatedWorkspace;
195
+ /** True for control-plane-only instances (the workerless orphan-cancel
196
+ * helper): they never run an agent turn, so isolating their cwd would
197
+ * only mint a junk workspace dir (and potentially a junk daemon) for a
198
+ * sentinel session id. */
199
+ controlPlaneOnly = false;
200
+ /** One-shot resolver for the CURRENT runTurn promise, fired by settleTurn.
201
+ * The turn is accounted for by its result event, never by the client
202
+ * process ending — see runTurn for why the process may outlive the turn. */
203
+ turnResolve = null;
204
+ /**
205
+ * Latched once a strong boundary proved unusable at runtime — the shim's
206
+ * enrolment write was rejected (exit 97). The prepare-time probe only opens
207
+ * cgroup.procs; a host that rejects the pid WRITE (delegation containment)
208
+ * would otherwise fail EVERY turn with exit 97 forever. After the first such
209
+ * failure this backend degrades to the weak post-spawn handle instead.
210
+ */
211
+ strongBoundaryUnusable = false;
212
+ /** True for the turn currently in flight iff it launched through the cgroup
213
+ * enrolment shim, so a genuine mojo `exit 97` is not misread as an enrolment
214
+ * failure (and vice versa) on weak-handle hosts where no shim runs. */
215
+ usedEnrolShim = false;
216
+ /**
217
+ * Latched when a spawned turn's containment handle could NOT be persisted
218
+ * AND the started subtree could not be proven terminated afterwards. While
219
+ * set, every close/destroy proof is refused: there is a tree nothing durable
220
+ * describes, so publishing a closed row would drop the device-isolation
221
+ * blocker over a subtree we cannot enumerate.
222
+ */
223
+ containmentUnrecorded = false;
224
+ /**
225
+ * Root pid of the most recent turn, kept AFTER `this.child` is cleared.
226
+ *
227
+ * The child's own `close` handler nulls `this.child`, so a later `/close` had
228
+ * nothing left to check and skipped the subtree scan entirely — the exact hole
229
+ * that let a survivor go unnoticed once its parent had exited.
230
+ */
231
+ lastTurnPid = null;
232
+ /**
233
+ * Recycle-proof identity of `lastTurnPid`, captured AT SPAWN.
234
+ *
235
+ * The pid number alone is not a handle: by the time teardown runs, the kernel
236
+ * may have recycled it onto an unrelated process, and `kill(-pid)` would then
237
+ * signal a stranger's whole process group. Binding boot id + starttime at
238
+ * spawn is what lets the signal path prove it is still aiming at OUR child.
239
+ */
240
+ turnIdentity = null;
241
+ /**
242
+ * Evidence class of the last quiescence attempt. DIAGNOSTIC ONLY.
243
+ *
244
+ * The previous wording claimed the blocker decision requires
245
+ * `boundaryProof === true` on this value. It did not, and still does not:
246
+ * nothing in production reads `TurnQuiescence.boundaryProof`, so that was a
247
+ * claim about code that was never written. The real gate is
248
+ * `TerminationOutcome.boundaryProven` (see the close path below), which is
249
+ * derived from `containmentReleaseDecision` in mojo-containment.ts. This field
250
+ * is kept for logs and for tests that assert the grading, and it is read
251
+ * through the `lastTurnQuiescence` getter only.
252
+ */
253
+ lastQuiescence = null;
254
+ lastTermination = null;
255
+ /** True once the current turn has emitted its `result` event, so a late
256
+ * process exit cannot fire a second turn boundary. */
257
+ turnSettled = true;
258
+ /** Buffer for partial NDJSON lines across stdout chunks. */
259
+ stdoutTail = '';
260
+ /** Set when --include-partial deltas have already rendered this turn's text,
261
+ * so the trailing whole-segment `text` event isn't printed twice. */
262
+ streamedThisTurn = false;
263
+ cliTimeoutMs = MOJO_CLI_TIMEOUT_MS;
264
+ /** How long /close waits for an in-flight turn to publish its session id
265
+ * before tearing down. Must stay well under the worker's close/restart
266
+ * race so teardown never becomes the thing that times out. */
267
+ destroySettleMs = MOJO_DESTROY_SETTLE_MS;
268
+ /**
269
+ * Captured from spawn(). The worker owns the authoritative cwd + env (the
270
+ * BOTMUX_* session context, per-bot `env`, credential paths, proxies) and
271
+ * hands them over exactly once; ignoring them silently drops repo selection,
272
+ * per-bot tokens and proxy settings. `config` values still win where set, so
273
+ * an explicit bots.json override remains authoritative.
274
+ */
275
+ spawnOpts = null;
276
+ /**
277
+ * Resolved launch PREFIX from BotConfig.wrapperCli (e.g. `env VAR=x mojo`,
278
+ * a ttadk gateway). The worker resolves the prefix into a real bin + args and
279
+ * passes them to spawn(); a PTY CLI is wrapped once for the life of its
280
+ * process, but mojo is invoked per turn, so the prefix must be re-applied to
281
+ * EVERY invocation. Null when no wrapper is configured, in which case the
282
+ * plain binary is used.
283
+ */
284
+ launchPrefix = null;
285
+ /** Guard so the config-side wrapper resolution is attempted at most once. */
286
+ wrapperResolved = false;
287
+ /** Resolved once per session — see resolveBin. */
288
+ pinnedBin = null;
289
+ /**
290
+ * Live JWT, THREE states — the distinction is why a clear used to fail:
291
+ * undefined → no live snapshot received; resolve from config/env as before
292
+ * string → use exactly this
293
+ * null → explicitly cleared; do NOT fall back to any config-layer env
294
+ *
295
+ * The daemon already folds the ambient fallback into the snapshot it sends, so
296
+ * `null` genuinely means "no credential from any config layer". Previously a
297
+ * clear only set `config.jwt = undefined`, and buildEnv then re-read `jwtEnv`
298
+ * out of the init-time `config.env` / `injectEnv`, reviving a stale token.
299
+ */
300
+ liveJwt = undefined;
301
+ /**
302
+ * Generic CLI args the worker composed for this session (today: CLI_EXTRA_ARGS,
303
+ * e.g. `--timeout 77`). The mojo adapter's buildArgs() returns [], so anything
304
+ * arriving here came from the worker's shared arg pipeline and must be applied
305
+ * to every turn — dropping it made the flag work with a wrapper configured
306
+ * (buildWrappedLaunch folds spawnArgs into the prefix) but silently vanish
307
+ * without one.
308
+ */
309
+ extraCliArgs = [];
310
+ writeChain = Promise.resolve();
311
+ constructor(config, sessionId, opts) {
312
+ this.config = config;
313
+ this.sessionId = sessionId;
314
+ this.controlPlaneOnly = opts?.controlPlaneOnly === true;
315
+ // Adopt the nonce of any subtree this session already owns. A replacement
316
+ // generation MUST keep hunting the previous generation's tree, and the env
317
+ // nonce is the only signal that survives setsid + reparenting to init — a
318
+ // fresh random value here would strand that tree permanently.
319
+ //
320
+ // `containmentHandles` THROWS on an unreadable store rather than answering
321
+ // "none". Letting that escape the constructor is deliberate: a backend that
322
+ // cannot know what it inherited must not come up and start a turn it can
323
+ // never prove quiescent.
324
+ const inherited = containmentHandles(sessionId);
325
+ this.containmentGeneration = inherited.length;
326
+ const adopted = inherited[0]?.nonce;
327
+ if (adopted !== undefined) {
328
+ this.treeNonce = adopted;
329
+ logger.warn(`[mojo] session ${sessionId}: adopting ${inherited.length} unproven turn `
330
+ + 'subtree(s) from a previous worker generation; the device-isolation blocker '
331
+ + 'stays until they are proven quiescent');
332
+ }
333
+ // Daemon-restart resume: the persisted mojo session id restores the
334
+ // lineage so the first write after a restart continues the conversation
335
+ // instead of cold-booting a context-less session.
336
+ if (config.resumeCliSessionId)
337
+ this.cliSessionId = config.resumeCliSessionId;
338
+ }
339
+ // ── SessionBackend surface ───────────────────────────────────────────────
340
+ spawn(bin, args, opts) {
341
+ // No persistent process is started here — the headless CLI is invoked
342
+ // once per turn — but the spawn contract is still where the worker hands
343
+ // over the authoritative cwd/env, so keep them for buildEnv()/runTurn().
344
+ this.spawnOpts = opts;
345
+ // FAIL CLOSED on a launch prefix we did not ask for.
346
+ //
347
+ // This used to assume a non-empty `bin` could only be wrapperCli, on the
348
+ // grounds that the FILE sandbox is refused for this backend before spawn
349
+ // (backendSandboxCompatibilityError). That misses a second, INDEPENDENT
350
+ // wrapping path: mandatory device-credential isolation, which
351
+ // read-isolation.ts documents as "independent of the optional bot sandbox
352
+ // toggle" and which rewrites spawnBin whenever the host is enrolled and the
353
+ // session is not provably remote — e.g. a mojo bot with no `cloud` set.
354
+ //
355
+ // In that state the old code dropped the wrapper AND passed its argv to
356
+ // mojo as extraCliArgs, so a boundary the platform mandates vanished while
357
+ // the session looked healthy. Refusing is the only safe answer here: this
358
+ // backend cannot tell which confinement it was handed, and guessing is
359
+ // what caused the silent downgrade.
360
+ if (bin && !this.config.wrapperCli) {
361
+ // Say WHY this session is not provably remote, from the shared helper.
362
+ // The old text always advised "run fully remote (cloud on, localDaemon
363
+ // off)" — useless for the common case where cloud is already on and the
364
+ // blocker is an env key, which is exactly the state that sends a session
365
+ // down this path in the first place.
366
+ const proofGap = mojoRemoteProofFailureReason(this.config);
367
+ throw new Error(`[mojo] refusing to launch session ${this.sessionId}: unexpected launch wrapper `
368
+ + `"${bin}" was supplied but no wrapperCli is configured. The mojo backend `
369
+ + 'invokes the CLI per turn and cannot carry an unknown confinement wrapper. '
370
+ + (proofGap
371
+ ? `This session is not provably remote, which is what engaged the wrapper: ${proofGap} `
372
+ + 'Resolve that so the credential boundary is satisfied remotely, or '
373
+ + 'configure wrapperCli explicitly.'
374
+ : 'Configure wrapperCli explicitly if this bot needs a launch prefix.'));
375
+ }
376
+ // Generic extra args come from the config: the worker deliberately keeps
377
+ // them out of both the spawn args and the wrapper prefix so they can be
378
+ // appended AFTER our own flags on every turn (last-value-wins). Fall back
379
+ // to the spawn args for any caller that has not been updated.
380
+ const requestedExtraArgs = this.config.extraCliArgs
381
+ ? [...this.config.extraCliArgs]
382
+ : (args.length > 0 && !this.config.wrapperCli ? [...args] : []);
383
+ // Defence in depth: the worker already refuses these, but this backend is
384
+ // also constructed by the daemon's cancel path. A reserved flag reaching
385
+ // here would override the frozen control plane, so drop it loudly rather
386
+ // than letting it through.
387
+ const reservedExtra = findReservedMojoCliFlags(requestedExtraArgs);
388
+ if (reservedExtra.length > 0) {
389
+ logger.warn(`[mojo] ignoring platform-owned flag(s) in extra CLI args: ${reservedExtra.join(' ')}`);
390
+ this.extraCliArgs = [];
391
+ }
392
+ else {
393
+ this.extraCliArgs = requestedExtraArgs;
394
+ }
395
+ if (this.config.wrapperCli) {
396
+ this.wrapperResolved = true;
397
+ if (bin) {
398
+ // The worker resolves the prefix with `[]` for args, so whatever
399
+ // arrives here is the wrapper itself and nothing else.
400
+ this.launchPrefix = { bin, args: [...args] };
401
+ logger.info(`[mojo] launch prefix from wrapperCli: ${bin} ${args.join(' ')}`);
402
+ }
403
+ else {
404
+ // Never claim a wrapper was applied while running the bare binary.
405
+ logger.warn(`[mojo] wrapperCli="${this.config.wrapperCli}" was configured but the worker `
406
+ + 'supplied no launch binary — running mojo unwrapped');
407
+ }
408
+ }
409
+ if (this.extraCliArgs.length > 0) {
410
+ logger.info(`[mojo] extra CLI args applied per turn: ${this.extraCliArgs.join(' ')}`);
411
+ }
412
+ // Execution-mode audit line (review F4): host execution BY DEFAULT is a
413
+ // posture change, so make "came from default" vs "came from explicit
414
+ // config" distinguishable in the log. Same shared derivation as
415
+ // buildArgs/buildEnv — label and env value cannot drift by construction.
416
+ const execMode = deriveMojoExecutionMode(this.config).label;
417
+ logger.info(`[mojo] spawn ${this.sessionId} in ${this.resolveCwd() ?? '(inherited cwd)'} (headless CLI invoked per turn, execution=${execMode})`);
418
+ }
419
+ /**
420
+ * Resolve the executable + leading args for one invocation, re-applying the
421
+ * wrapperCli prefix when present.
422
+ *
423
+ * The prefix normally arrives pre-resolved from the worker via spawn(). The
424
+ * daemon's workerless cancel path never calls spawn(), so when a wrapper is
425
+ * configured but unresolved we resolve it here from the config — otherwise
426
+ * `/close` would run an unwrapped binary that a wrapper-dependent setup
427
+ * (e.g. a gateway that injects auth) cannot reach.
428
+ */
429
+ resolveLaunch(cliArgs) {
430
+ const prefix = this.launchPrefix ?? this.resolveConfiguredWrapper();
431
+ if (prefix) {
432
+ return { bin: prefix.bin, args: [...prefix.args, ...cliArgs] };
433
+ }
434
+ return { bin: this.resolveBin(), args: cliArgs };
435
+ }
436
+ /**
437
+ * Resolve the binary ONCE and reuse it for every turn of this session.
438
+ *
439
+ * Without pinning, a bare `mojo` was re-resolved on each turn against the
440
+ * then-current PATH, so anything able to influence the environment between
441
+ * turns could substitute the executable. The live patch no longer carries
442
+ * `env` at all, but pinning removes the class of problem rather than one
443
+ * instance of it — and it also keeps a session on one binary if PATH shifts
444
+ * underneath a long-running worker.
445
+ */
446
+ resolveBin() {
447
+ if (this.pinnedBin)
448
+ return this.pinnedBin;
449
+ const configured = this.config.bin?.trim();
450
+ if (configured) {
451
+ this.pinnedBin = configured;
452
+ return this.pinnedBin;
453
+ }
454
+ // Resolve against the EFFECTIVE child PATH, not the daemon's own.
455
+ // `locateOnPath` reads this process's env, which silently ignored a
456
+ // per-bot `PATH` — the child would then run a different binary than the
457
+ // one that was pinned, changing the documented semantics of per-bot env.
458
+ this.pinnedBin = this.locateOnEffectivePath('mojo') ?? 'mojo';
459
+ logger.info(`[mojo] pinned binary for this session: ${this.pinnedBin}`);
460
+ return this.pinnedBin;
461
+ }
462
+ /**
463
+ * Find an executable using the PATH the CHILD will actually see.
464
+ *
465
+ * Layered exactly like buildEnv (worker env → per-bot injectEnv → mojo.env),
466
+ * so a per-bot PATH override takes effect. Falls back to the caller's own PATH
467
+ * when spawn() has not run (direct/unit use).
468
+ */
469
+ locateOnEffectivePath(cmd) {
470
+ const childPath = this.config.env?.PATH
471
+ ?? this.spawnOpts?.injectEnv?.PATH
472
+ ?? this.spawnOpts?.env?.PATH;
473
+ if (!childPath)
474
+ return locateOnPath(cmd);
475
+ for (const dir of childPath.split(delimiter)) {
476
+ if (!dir)
477
+ continue;
478
+ const candidate = join(dir, cmd);
479
+ try {
480
+ accessSync(candidate, fsConstants.X_OK);
481
+ return candidate;
482
+ }
483
+ catch { /* not here */ }
484
+ }
485
+ // Explicit child PATH is authoritative: do NOT fall back to the daemon's
486
+ // ambient PATH. Falling back is how an ambient install shadowed a per-bot
487
+ // one, which defeats the point of resolving on the child PATH at all.
488
+ return null;
489
+ }
490
+ /** Lazily resolve (and memoize) `config.wrapperCli` when spawn() never ran. */
491
+ resolveConfiguredWrapper() {
492
+ if (this.wrapperResolved)
493
+ return this.launchPrefix;
494
+ this.wrapperResolved = true;
495
+ const wrapperCli = this.config.wrapperCli?.trim();
496
+ if (!wrapperCli)
497
+ return null;
498
+ try {
499
+ // cliArgs is [] on purpose: the mojo adapter bakes nothing into launch
500
+ // args, so this yields the PREFIX only, and the per-turn args are
501
+ // appended by resolveLaunch.
502
+ // Same effective-PATH resolution as resolveBin: a per-bot PATH must
503
+ // decide the wrapper binary too, or the two disagree about which
504
+ // install is in use.
505
+ const launch = buildWrappedLaunch(wrapperCli, [], b => this.locateOnEffectivePath(b) ?? b);
506
+ if (!launch.bin)
507
+ return null;
508
+ this.launchPrefix = { bin: launch.bin, args: launch.args };
509
+ logger.info(`[mojo] launch prefix resolved from config: ${launch.bin} ${launch.args.join(' ')}`);
510
+ return this.launchPrefix;
511
+ }
512
+ catch (err) {
513
+ // Never let an unusable wrapper turn teardown into a crash — but do
514
+ // not pretend it was applied either.
515
+ logger.warn(`[mojo] could not resolve wrapperCli "${wrapperCli}": ${String(err)}`);
516
+ return null;
517
+ }
518
+ }
519
+ /** bots.json `mojo.cwd` wins; otherwise the worker's session working dir. */
520
+ /** The operator-facing working directory (the repo). Kept separate from
521
+ * resolveCwd(): host execution runs the CLI in an isolated per-session
522
+ * directory instead, and the decorate() preamble points the agent back
523
+ * here for repo work. */
524
+ realWorkingDir() {
525
+ return this.config.cwd ?? this.spawnOpts?.cwd;
526
+ }
527
+ /** True when this config executes tools on the bot host (shared derivation
528
+ * with buildEnv/buildArgs — see deriveMojoExecutionMode). */
529
+ hostExecution() {
530
+ return deriveMojoExecutionMode(this.config).agentLocalDaemon === '1';
531
+ }
532
+ /** HOME for the isolated workspace root. Prefer the CHILD env the worker
533
+ * hands over — in production it equals the daemon's own HOME (so the
534
+ * workerless close path, which uses os.homedir(), matches), while in
535
+ * tests it keeps backend instances from minting directories under the
536
+ * developer's real ~/.botmux (the full suite did exactly that once). */
537
+ isolationHome() {
538
+ const home = this.config.env?.HOME ?? this.spawnOpts?.injectEnv?.HOME ?? this.spawnOpts?.env?.HOME;
539
+ return typeof home === 'string' && home.length > 0 ? home : undefined;
540
+ }
541
+ resolveCwd() {
542
+ if (this.controlPlaneOnly || !this.hostExecution())
543
+ return this.realWorkingDir();
544
+ // Host execution: run the CLI from a physically distinct per-session
545
+ // directory. mojo keys its local execution daemon on
546
+ // hash(process.cwd()) — realpath, so a symlink would collapse back
547
+ // into the shared daemon (see mojo-isolated-workspace.ts for the
548
+ // whole P0/P1 story). Cached: spawn and close must use the SAME
549
+ // realpath string or the close-side registry match silently misses.
550
+ this.isolatedWorkspace ??= ensureMojoIsolatedWorkspace(this.sessionId, this.isolationHome());
551
+ return this.isolatedWorkspace;
552
+ }
553
+ write(data) {
554
+ if (this.killed || this.closing || this.shutdownDetaching)
555
+ return false;
556
+ if (this.containmentUnrecorded) {
557
+ // A prior turn's subtree could not be recorded AND could not be proven
558
+ // terminated. Running another credentialed turn on top of a possibly
559
+ // live, undescribed tree is exactly the fail-open the latch exists to
560
+ // prevent — refuse until a close proves (or contains) it.
561
+ this.emitLine('❌ mojo 进程围栏存在未登记的残留子进程,已拒绝新一轮,请先 /close 本会话。', 'err');
562
+ return false;
563
+ }
564
+ const text = data.trim();
565
+ if (!text)
566
+ return false;
567
+ if (!this.cliSessionId)
568
+ this.acceptedWriteWithoutLineage = true;
569
+ // Serialize turns: mojo rejects a concurrent turn on the same session,
570
+ // and a second message arriving before the first init event would fork a
571
+ // duplicate session (cliSessionId still null).
572
+ this.writeChain = this.writeChain
573
+ .then(() => (this.killed || this.closing) ? undefined : this.runTurnWithBusyRetry(text))
574
+ .catch((err) => {
575
+ logger.warn(`[mojo] turn failed: ${String(err)}`);
576
+ this.emitLine(`❌ mojo 执行失败:${this.fmtErr(err)}`, 'err');
577
+ this.settleTurn();
578
+ });
579
+ return true;
580
+ }
581
+ /**
582
+ * Rotate the JWT on a LIVE session, without a refork.
583
+ *
584
+ * Needed because the config is otherwise read once at worker init, so every
585
+ * subsequent per-turn CLI invocation kept using the ORIGINAL token — a rotated
586
+ * credential never took effect.
587
+ *
588
+ * Takes a COMPLETE snapshot rather than a sparse diff, so the two states that
589
+ * a sparse patch could not express both work:
590
+ * - `jwt: null` → cleared (a deleted `mojo.jwt` must not linger)
591
+ * - `jwt: <original>` → rolled back (A → B → A must return to A)
592
+ *
593
+ * Only the JWT is patchable. An `env` patch would be equivalent to replacing
594
+ * the launcher — see MOJO_LIVE_PATCH_KEYS.
595
+ */
596
+ applyLivePatch(patch) {
597
+ if (patch.jwt === undefined)
598
+ return;
599
+ if (this.liveJwt === patch.jwt)
600
+ return;
601
+ this.liveJwt = patch.jwt;
602
+ // Never log the value.
603
+ logger.info(`[mojo] live JWT ${patch.jwt === null ? 'cleared' : 'rotated'}`);
604
+ }
605
+ resize(_cols, _rows) { }
606
+ onData(cb) { this.dataCb = cb; }
607
+ /**
608
+ * NOT fired on per-turn CLI exit — the binary is spawned and exits every
609
+ * turn, so forwarding that would tear the session down after the first
610
+ * reply. It IS fired from kill(), mirroring RiffBackend: the worker needs to
611
+ * learn the backend is gone on teardown / daemon restart, and nothing else
612
+ * tells it.
613
+ */
614
+ onExit(cb) {
615
+ this.exitCb = cb;
616
+ }
617
+ /** Turn boundary — required: an API-backed backend produces no PTY output, so
618
+ * botmux's idle detector never fires and nothing else re-arms prompt-ready. */
619
+ onTaskDone(cb) { this.taskDoneCb = cb; }
620
+ /** This turn's assistant answer, for the worker's final_output bridge. A
621
+ * headless mojo session has no terminal the user could read instead, so an
622
+ * answer the agent never `botmux send`s would otherwise reach nobody. */
623
+ onTurnFinal(cb) { this.turnFinalCb = cb; }
624
+ /** Lineage id updates — forwarded to the daemon so multi-turn context
625
+ * survives a daemon restart. */
626
+ onTaskId(cb) {
627
+ this.taskIdCb = cb;
628
+ if (this.cliSessionId)
629
+ cb(this.cliSessionId);
630
+ }
631
+ captureCurrentScreen() { return this.outputBuffer; }
632
+ captureViewport() { return this.outputBuffer; }
633
+ getPaneSize() { return null; }
634
+ getChildPid() { return this.child?.pid ?? null; }
635
+ /**
636
+ * SIGTERM, then PROVE the child is gone (escalating to SIGKILL).
637
+ *
638
+ * `child.kill('SIGTERM')` returning true only means the signal was delivered.
639
+ * A child that ignores it keeps executing with the injected credential while
640
+ * the explicit close publishes the row as `closed` — and a closed row is
641
+ * filtered out of the device-isolation inventory
642
+ * (mergePersistedDeviceIsolationSessions), so the blocker vanishes with the
643
+ * process still alive. That is exactly the state this backend must never
644
+ * report as a successful teardown.
645
+ *
646
+ * Returns false when termination could not be proven; the caller must then
647
+ * refuse the close rather than let the row be published as closed.
648
+ */
649
+ /** Overridable so a behaviour test can exercise the escalation ladder without
650
+ * burning the production budget in wall-clock. Production never changes it. */
651
+ get terminationProofBudgetMs() {
652
+ return MOJO_CHILD_TERMINATION_PROOF_MS;
653
+ }
654
+ /** Overridable so a test can point the scan at a synthetic /proc. */
655
+ get procRoot() { return '/proc'; }
656
+ /** Overridable so a test can point boundary preparation at a synthetic
657
+ * cgroup root. `undefined` = the real /sys/fs/cgroup. */
658
+ get cgroupRoot() { return undefined; }
659
+ /** Overridable so a test can point the delegated-parent lookup
660
+ * (/proc/self/cgroup) at a synthetic /proc. `undefined` = the real /proc. */
661
+ get cgroupProcRoot() { return undefined; }
662
+ /**
663
+ * SIGTERM the whole turn SUBTREE, then gather the best evidence this host can
664
+ * give that nothing in it survives (escalating to SIGKILL).
665
+ *
666
+ * Read the result precisely. `ok: true` means "no executing member was
667
+ * found", NOT "the credential is now unreachable". Enumeration cannot see a
668
+ * descendant that both setsid'd and scrubbed its own environ, so a clean scan
669
+ * is a DIAGNOSTIC signal only — `boundaryProven` is the field that says
670
+ * whether a real boundary was established, and only kernel-level containment
671
+ * (a per-session cgroup) can set it true. `ok: true` with
672
+ * `boundaryProven: false` is therefore legal and, for Linux weak handles, the
673
+ * common case; destroySession() below consumes the two fields separately and
674
+ * downgrades the second case to a residual close.
675
+ *
676
+ * Three fail-open paths are closed here.
677
+ *
678
+ * 1. `child.kill()` returning true only means the signal was DELIVERED. A
679
+ * child that ignores SIGTERM keeps executing with the injected credential
680
+ * while the close publishes the row as `closed` — and a closed row is
681
+ * filtered out of the device-isolation inventory, so the blocker vanishes
682
+ * with the process still alive.
683
+ * 2. Signalling the direct pid leaves DESCENDANTS alive.
684
+ * 3. Signalling the process GROUP still leaves descendants that escaped it via
685
+ * setsid/detached. Enumeration therefore unions PGID, the inherited env
686
+ * nonce and the PPID chain — see mojo-process-tree.
687
+ *
688
+ * Reports `ok: false` whenever no such evidence could be obtained, INCLUDING when
689
+ * the scan itself fails: "cannot enumerate" must never read as "nothing is
690
+ * running". The caller then refuses the close, which keeps the row active and
691
+ * so keeps the device-isolation blocker in place. (destroySession makes ONE
692
+ * exception, for a platform that can never enumerate at all — see the
693
+ * `unsupported-platform` branch there.)
694
+ *
695
+ * Zombie members are discounted, because a reaped process executes nothing and
696
+ * cannot use the credential; only a definite `Z` state qualifies.
697
+ */
698
+ async terminateChildProven() {
699
+ const q = await this.proveTurnQuiescence();
700
+ // The projection is where evidence grading survives. Returning a bare
701
+ // boolean here is what discarded it: `diagnostic-clean` and
702
+ // `contained-proven` both collapsed to `true`, so a clean Linux scan --
703
+ // which cannot see a setsid'd, environ-scrubbed descendant -- authorised
704
+ // the same plain closed row as a real kernel-level proof.
705
+ const outcome = terminationOutcomeFromQuiescence(q);
706
+ this.lastTermination = outcome;
707
+ return outcome;
708
+ }
709
+ /** Structured evidence from the last termination attempt; see TerminationOutcome. */
710
+ get lastTerminationOutcome() {
711
+ return this.lastTermination;
712
+ }
713
+ /** Evidence class of the last termination attempt; see TurnQuiescence. */
714
+ get lastTurnQuiescence() {
715
+ return this.lastQuiescence;
716
+ }
717
+ /**
718
+ * SIGTERM the whole turn SUBTREE, then try to establish that nothing in it
719
+ * survives (escalating to SIGKILL), and report WHAT KIND of evidence we got.
720
+ */
721
+ async proveTurnQuiescence() {
722
+ const record = (q) => { this.lastQuiescence = q; return q; };
723
+ const child = this.child;
724
+ // lastTurnPid, not just `child`: the child's own `close` handler clears
725
+ // `this.child`, and an escaped descendant outlives its parent. Falling back
726
+ // to the remembered root pid is what keeps the subtree checkable.
727
+ const rootPid = (typeof child?.pid === 'number' && child.pid > 0) ? child.pid : this.lastTurnPid;
728
+ // The DURABLE view of what this session still owns. In-memory state is not
729
+ // enough: on a replacement generation `lastTurnPid` is always null, and the
730
+ // old code concluded "no turn ever spawned, so there is no subtree" — which
731
+ // returned true for a session that may still have a live credentialed
732
+ // survivor from the previous generation. Only the store can retire that claim.
733
+ //
734
+ // A throw here (unreadable store) propagates and refuses the close, which is
735
+ // the correct fail-closed outcome.
736
+ const outstanding = containmentHandles(this.sessionId);
737
+ if (rootPid === null && outstanding.length === 0) {
738
+ // No in-memory root AND nothing outstanding in the store: genuinely
739
+ // nothing was ever credentialed under this session.
740
+ this.child = null;
741
+ return record({ kind: 'diagnostic-clean', boundaryProof: false });
742
+ }
743
+ const exited = new Promise(resolve => {
744
+ if (!child)
745
+ return resolve();
746
+ const done = () => resolve();
747
+ child.once('exit', done);
748
+ child.once('close', done);
749
+ if (child.exitCode !== null || child.signalCode !== null)
750
+ resolve();
751
+ });
752
+ const budget = this.terminationProofBudgetMs;
753
+ const grace = Math.max(1, Math.floor(budget / 2));
754
+ // Never signal ourselves: the daemon shares neither nonce nor group, but an
755
+ // explicit guard is cheaper than trusting that while sending SIGKILL.
756
+ const excludePids = this.selfPids();
757
+ // rootPid may be null here while handles are still outstanding (a replacement
758
+ // generation inherited a tree it never spawned). There is no in-memory root to
759
+ // scan in that case, so the in-memory ladder is skipped and the inherited
760
+ // handles below become the ONLY thing that can retire the close.
761
+ if (rootPid === null) {
762
+ return record(this.dischargeContainment(outstanding, { kind: 'diagnostic-clean', boundaryProof: false }));
763
+ }
764
+ const look = () => quiescenceFromScan(
765
+ // turnIdentity was bound at spawn, while the pid was guaranteed to be
766
+ // this child. It gates PGID-based claiming inside the scan: without a
767
+ // verified root, only the env nonce (positive evidence of membership)
768
+ // may claim, so a recycled root pid can never pull a stranger's
769
+ // process group into a scan whose members get SIGKILLed.
770
+ scanMojoTree(rootPid, this.treeNonce, {
771
+ procRoot: this.procRoot,
772
+ excludePids,
773
+ ...(this.turnIdentity && this.turnIdentity.pid === rootPid
774
+ ? { rootIdentity: this.turnIdentity }
775
+ : {}),
776
+ }));
777
+ const settle = async (deadlineMs) => {
778
+ const deadline = Date.now() + deadlineMs;
779
+ for (;;) {
780
+ const now = look();
781
+ // Fail-closed: an unscannable host or an unsupported platform can
782
+ // never be read as "nothing is running", and retrying will not turn
783
+ // either into knowledge, so stop immediately.
784
+ if (now.kind === 'unscannable') {
785
+ logger.error(`[mojo] cannot enumerate turn subtree: ${now.reason}`);
786
+ return now;
787
+ }
788
+ if (now.kind === 'unsupported-platform') {
789
+ logger.error(`[mojo] cannot enumerate turn subtree on ${now.platform}: /proc is Linux-only, `
790
+ + 'so quiescence cannot be established on this host');
791
+ return now;
792
+ }
793
+ if (now.kind !== 'alive')
794
+ return now;
795
+ if (Date.now() >= deadline)
796
+ return now;
797
+ // Plain sleep: racing an ALREADY-RESOLVED `exited` here spun the
798
+ // loop as fast as the event loop allowed (a busy loop until the
799
+ // deadline). The direct child's exit is not the condition anyway —
800
+ // the scan is.
801
+ await new Promise(r => setTimeout(r, 25).unref?.());
802
+ }
803
+ };
804
+ const signalTree = (signal) => {
805
+ // Group first: it reaches processes that /proc may not have listed yet.
806
+ // But NEVER as a bare kill(-rootPid): the pid may have been recycled, so
807
+ // the identity captured at spawn is re-verified first and the signal is
808
+ // skipped entirely on mismatch, on failure to verify, or off-Linux.
809
+ if (this.turnIdentity === null) {
810
+ logger.error('[mojo] refusing to signal the turn process group: no spawn-time identity was '
811
+ + 'captured, so the pid cannot be proven to still be our child');
812
+ }
813
+ else {
814
+ const sent = signalTurnTreeGroup(this.turnIdentity, signal, { procRoot: this.procRoot });
815
+ if (sent.kind === 'identity-mismatch') {
816
+ logger.error(`[mojo] refusing to signal pid ${this.turnIdentity.pid}: it has been recycled `
817
+ + `(starttime ${this.turnIdentity.starttime} -> ${sent.actual.starttime}); `
818
+ + 'signalling it would hit an unrelated process group');
819
+ }
820
+ else if (sent.kind === 'unverifiable') {
821
+ logger.error(`[mojo] refusing to signal the turn process group: ${sent.reason}`);
822
+ }
823
+ else if (sent.kind === 'unsupported-platform') {
824
+ logger.error(`[mojo] refusing to signal the turn process group on ${sent.platform}: `
825
+ + 'pid identity cannot be verified without /proc');
826
+ }
827
+ }
828
+ // Per-pid signals still go out: they come from an enumeration that just
829
+ // observed each pid, and they are positive (not group-negated) targets.
830
+ const scan = look();
831
+ if (scan.kind === 'alive') {
832
+ for (const pid of scan.pids) {
833
+ try {
834
+ process.kill(pid, signal);
835
+ }
836
+ catch { /* raced us */ }
837
+ }
838
+ }
839
+ else if (scan.kind === 'unscannable' || scan.kind === 'unsupported-platform') {
840
+ // Cannot enumerate: fall back to the direct child handle, which is the
841
+ // only target we can name without /proc.
842
+ try {
843
+ child?.kill(signal);
844
+ }
845
+ catch { /* gone */ }
846
+ }
847
+ };
848
+ signalTree('SIGTERM');
849
+ let verdict = await settle(grace);
850
+ if (verdict.kind === 'diagnostic-clean') {
851
+ this.child = null;
852
+ await exited.catch?.(() => undefined);
853
+ return record(this.dischargeContainment(outstanding, verdict));
854
+ }
855
+ if (verdict.kind === 'alive') {
856
+ // Escalate. SIGKILL cannot be caught, so a survivor after this means we
857
+ // genuinely cannot prove quiescence (e.g. uninterruptible state).
858
+ signalTree('SIGKILL');
859
+ verdict = await settle(budget - grace);
860
+ if (verdict.kind === 'diagnostic-clean') {
861
+ this.child = null;
862
+ return record(this.dischargeContainment(outstanding, verdict));
863
+ }
864
+ }
865
+ logger.error(`[mojo] turn subtree rooted at ${rootPid} could not be proven `
866
+ + `quiescent (${verdict.kind}); refusing to report the close as successful — the session `
867
+ + 'row stays active so its device-isolation blocker is retained');
868
+ // Deliberately NOT clearing this.child / lastTurnPid / turnIdentity: the
869
+ // close is refused, so a retry must be able to signal the same subtree again.
870
+ return record(verdict);
871
+ }
872
+ /**
873
+ * Mint and PERSIST the containment handle for a freshly spawned turn root.
874
+ *
875
+ * Extracted so it can be exercised directly: doing this at close time would be
876
+ * too late, because a crash between spawn and record is exactly the window that
877
+ * loses the tree, and a lost tree can never be proven quiescent afterwards.
878
+ */
879
+ recordTurnContainment(rootPid) {
880
+ // Strong path: the boundary was prepared before spawn and the child enrols
881
+ // itself pre-exec, so the handle can simply adopt the directory — no pid
882
+ // migration, hence no spawn→enrolment window (the P0 this replaced).
883
+ const prepared = this.preparedBoundary;
884
+ if (prepared) {
885
+ recordContainmentHandle(strongHandleFromPreparedBoundary(prepared));
886
+ // The handle owns the directory now; a later spawn failure must not
887
+ // kill a boundary that belongs to a recorded turn.
888
+ this.preparedBoundary = null;
889
+ return;
890
+ }
891
+ // acquireContainmentHandle cannot return null: on a host with neither a
892
+ // preparable cgroup boundary nor a readable boot id it mints an
893
+ // `unprovable` handle instead, which is persisted, can never be released,
894
+ // and reports unsupported-platform. That is what makes the residual-close
895
+ // path safe — there is always something durable holding the
896
+ // device-isolation blocker, so there is no "nothing was recorded" case
897
+ // left to handle here.
898
+ recordContainmentHandle(acquireContainmentHandle({
899
+ sessionId: this.sessionId,
900
+ generation: this.containmentGeneration,
901
+ rootPid,
902
+ nonce: this.treeNonce,
903
+ }));
904
+ }
905
+ /**
906
+ * Terminate a subtree whose containment handle could not be persisted.
907
+ *
908
+ * `containmentUnrecorded` is already latched by the caller. It is cleared
909
+ * only when the boundary is PROVEN empty (rmdir accepted by the kernel) —
910
+ * anything less keeps every close proof refused, because a tree nothing
911
+ * durable describes must not be closable.
912
+ */
913
+ async containUnrecordedSpawn(child, cause) {
914
+ logger.error(`[mojo] containment handle could not be persisted for pid ${child.pid}; `
915
+ + `terminating the just-started subtree: ${cause instanceof Error ? cause.message : String(cause)}`);
916
+ this.emitLine('❌ mojo 本轮启动失败:进程围栏无法登记,已终止刚启动的子进程。', 'err');
917
+ const prepared = this.preparedBoundary;
918
+ // Belt and braces alongside cgroup.kill: the direct child is its own
919
+ // group leader (detached:true), so the group signal reaches a pre-exec
920
+ // shim that has not enrolled yet.
921
+ if (typeof child.pid === 'number' && child.pid > 0) {
922
+ try {
923
+ process.kill(-child.pid, 'SIGKILL');
924
+ }
925
+ catch { /* gone */ }
926
+ try {
927
+ child.kill('SIGKILL');
928
+ }
929
+ catch { /* gone */ }
930
+ }
931
+ // Wait for the reap: a SIGKILLed member lingers in cgroup.procs as a
932
+ // zombie until its parent (us) collects it, and the rmdir proof below
933
+ // would spin against that zombie forever.
934
+ await new Promise(resolve => {
935
+ if (child.exitCode !== null || child.signalCode !== null)
936
+ return resolve();
937
+ const timer = setTimeout(resolve, 5_000);
938
+ timer.unref?.();
939
+ child.once('close', () => { clearTimeout(timer); resolve(); });
940
+ });
941
+ if (prepared) {
942
+ this.preparedBoundary = null;
943
+ if (await killPreparedBoundary(prepared)) {
944
+ this.containmentUnrecorded = false;
945
+ return;
946
+ }
947
+ logger.error(`[mojo] boundary ${prepared.cgroupPath} could not be proven empty after the record `
948
+ + 'failure; every close proof for this generation stays refused');
949
+ }
950
+ else {
951
+ // Weak path: no boundary to prove emptiness with. The latch stays set —
952
+ // the tree may have descendants only a recorded handle could enumerate.
953
+ logger.error('[mojo] unrecorded subtree was signalled but cannot be proven terminated '
954
+ + '(no strong boundary); every close proof for this generation stays refused');
955
+ }
956
+ // Fail-closed state must live in the DURABLE store, not only in this
957
+ // memory latch: if the worker later dies, the workerless proof finds no
958
+ // outstanding handle and would publish a plain `closed`, dropping the
959
+ // blocker the latch was holding — and the latch dies with the process.
960
+ // Retry the record so `hasUnprovenContainment` stays true across a worker
961
+ // death (and `mojo-containment revoke` has something to target). If it
962
+ // still fails, the memory latch remains as the last line.
963
+ try {
964
+ recordContainmentHandle(prepared
965
+ ? strongHandleFromPreparedBoundary(prepared)
966
+ : acquireContainmentHandle({
967
+ sessionId: this.sessionId,
968
+ generation: this.containmentGeneration,
969
+ rootPid: child.pid ?? this.lastTurnPid ?? 0,
970
+ nonce: this.treeNonce,
971
+ }));
972
+ }
973
+ catch (err) {
974
+ logger.error(`[mojo] durable re-record of the unrecorded subtree ALSO failed; the fail-closed `
975
+ + `state survives only in memory for this worker's lifetime: ${String(err)}`);
976
+ }
977
+ }
978
+ /**
979
+ * Discharge every DURABLE handle this session owns, after the in-memory ladder
980
+ * believes its own root is gone.
981
+ *
982
+ * The in-memory verdict only ever speaks for the pid THIS backend spawned. A
983
+ * replacement generation inherits handles describing trees it never spawned, and
984
+ * those must be proven independently or the close stays refused. A handle leaves
985
+ * the store only against a `proven: true` verdict — `releaseContainmentHandle`
986
+ * takes the verdict itself and throws on anything else, so "clear the blocker
987
+ * without proof" is not representable here.
988
+ */
989
+ dischargeContainment(outstanding, cleanVerdict) {
990
+ const decisions = [];
991
+ for (const handle of outstanding) {
992
+ // An inherited handle means this generation TOOK OVER responsibility for
993
+ // cleaning that tree. Only proving it (never signalling it) left an
994
+ // escaped survivor that nothing would ever kill: every /close retry
995
+ // re-proved it alive and refused, so the session could never be closed
996
+ // while the credentialed process kept running. Proof alone turns the
997
+ // handle into a permanent tombstone, so signal first, then prove.
998
+ //
999
+ // Signalling is strictly GATED, because a pid is not an identity:
1000
+ // * weak handle only when weakHandleRootStillOriginal confirms the
1001
+ // recorded pid is still the same process (same boot id
1002
+ // AND same starttime). Without that, the number may
1003
+ // have been recycled onto a stranger and negating it
1004
+ // would take down an unrelated process group.
1005
+ // * unprovable never: it has no pid to signal at all.
1006
+ // Failing the gate means "do not signal", NOT "the tree is gone" — the
1007
+ // proof below still has to speak for the subtree.
1008
+ if (handle.kind === 'tree-identity') {
1009
+ const rootStillOriginal = weakHandleRootStillOriginal(handle, { procRoot: this.procRoot });
1010
+ if (!rootStillOriginal) {
1011
+ logger.warn(`[mojo] inherited handle ${containmentHandleKey(handle)}: recorded root pid is no `
1012
+ + 'longer the original process (recycled or gone), so its GROUP will not be '
1013
+ + 'signalled; enumerated members are still signalled individually');
1014
+ }
1015
+ this.signalInheritedTree(handle, rootStillOriginal);
1016
+ }
1017
+ const verdict = proveContainmentQuiescent(handle, {
1018
+ procRoot: this.procRoot,
1019
+ // `scanned: false` must NEVER collapse into `pids: []`: an empty pid
1020
+ // list reads as "nothing alive", which is precisely the fail-open the
1021
+ // scanner's failure modes exist to prevent.
1022
+ scan: weak => {
1023
+ const scan = scanMojoTree(weak.rootPid, weak.nonce, {
1024
+ procRoot: this.procRoot,
1025
+ excludePids: this.selfPids(),
1026
+ // The handle's RECORDED identity, so PGID claiming stays
1027
+ // disabled once the pid stops being the original process.
1028
+ rootIdentity: { pid: weak.rootPid, bootId: weak.bootId, starttime: weak.startTime },
1029
+ });
1030
+ return scan.ok
1031
+ // Zombies are discounted here for the SAME reason the
1032
+ // in-memory verdict discounts them: a reaped process
1033
+ // executes nothing and cannot use the credential. Passing
1034
+ // them through made the two paths disagree — the ladder
1035
+ // called the tree clean while the handle proof called the
1036
+ // very same tree alive, so a correct close was refused and
1037
+ // the handle could never be discharged.
1038
+ ? { scanned: true, pids: scan.members.filter(m => !m.zombie).map(m => m.pid) }
1039
+ : { scanned: false, pids: [], reason: scan.reason };
1040
+ },
1041
+ });
1042
+ if (!verdict.proven) {
1043
+ logger.error(`[mojo] inherited turn subtree (${containmentHandleKey(handle)}) could not be `
1044
+ + `proven quiescent (${verdict.reason}); refusing the close so the `
1045
+ + 'device-isolation blocker is retained');
1046
+ // Do NOT hand-roll the projection here. `unscannable` routes to a
1047
+ // FENCE, which latches write admission and fails the close; that is
1048
+ // right when a retry might still produce proof, and permanently wrong
1049
+ // on a host that can never enumerate at all. An INHERITED unprovable
1050
+ // handle is exactly that host: every /close after a worker generation
1051
+ // replacement re-derived `unscannable`, so admission stayed latched and
1052
+ // the session could never be closed -- the same permanent wedge C-7
1053
+ // fixed on the primary path, still reachable through this one.
1054
+ // `containmentQuiescence` owns the grading and maps an unprovable
1055
+ // handle to `unsupported-platform`, which routes to a RESIDUAL CLOSE:
1056
+ // the row closes, the blocker stays on the handle that was never
1057
+ // released. Grading belongs to the containment module, not to a
1058
+ // second copy of its rules living here.
1059
+ return containmentQuiescence(verdict);
1060
+ }
1061
+ // The decision is the containment module's to make, not ours: it says
1062
+ // whether the handle was actually discharged and whether a residual
1063
+ // must survive the close. Discarding it here and re-deriving the answer
1064
+ // from `handle.kind` is what made this path contradict the store --
1065
+ // a bootId-aged-out weak handle really was released, while the verdict
1066
+ // still reported `diagnostic-clean`, so the outcome asked the daemon to
1067
+ // keep a device-isolation blocker whose only evidence (the handle) had
1068
+ // just been deleted. The blocker IS the handle, so that was a lie in the
1069
+ // safe-looking direction, and a lie is what the review is about.
1070
+ decisions.push(releaseContainmentHandle(verdict));
1071
+ }
1072
+ // A boundary proof is the only thing that may upgrade the verdict, and
1073
+ // `boundaryProof` is the single field allowed to say so. It is true for
1074
+ // exactly ONE evidence — a changed boot id (a reboot), for a weak handle
1075
+ // OR a cgroup handle whose stamped bootId is gone. It is FALSE for a merely
1076
+ // clean /proc scan AND for an empty cgroup (a same-UID process can migrate
1077
+ // itself out of the leaf), both of which stay `diagnostic-clean` and keep
1078
+ // their residual.
1079
+ if (decisions.length > 0 && decisions.every(d => d.boundaryProof)) {
1080
+ // Built by the containment module, which owns the only constructor of a
1081
+ // proven boundary; this layer decides WHETHER it applies, never what it
1082
+ // looks like. Fabricating a verdict to feed containmentQuiescence would
1083
+ // be wrong here: a weak handle aged out by a changed boot id is a real
1084
+ // boundary proof, yet a synthesised cgroup-empty verdict on that same
1085
+ // handle would be graded back down to a diagnostic scan.
1086
+ return containedProvenQuiescence();
1087
+ }
1088
+ return cleanVerdict;
1089
+ }
1090
+ /** Pids that must never be signalled, whatever a scan says. */
1091
+ selfPids() {
1092
+ return [process.pid, process.ppid].filter(pid => typeof pid === 'number' && pid > 0);
1093
+ }
1094
+ /**
1095
+ * SIGKILL an inherited tree whose recorded root identity has already been
1096
+ * re-verified by the caller.
1097
+ *
1098
+ * SIGTERM is skipped deliberately: this tree belongs to a previous worker
1099
+ * generation that is already gone, so nobody is waiting to shut it down
1100
+ * gracefully, and the graceful attempt was made when that generation closed.
1101
+ * Every enumerated member is signalled individually as well, because a
1102
+ * descendant may have left the group via setsid and would survive the group
1103
+ * signal alone.
1104
+ *
1105
+ * Best effort by design: this only creates the CHANCE for the proof below to
1106
+ * succeed. If anything survives, the proof still refuses the close.
1107
+ */
1108
+ signalInheritedTree(handle, rootStillOriginal) {
1109
+ logger.warn(`[mojo] signalling inherited turn subtree rooted at ${handle.rootPid}: this generation owns `
1110
+ + 'its cleanup, and proving it without signalling would leave the session permanently unclosable');
1111
+ // The RECORDED identity, never a fresh read: re-reading the pid here and
1112
+ // passing that as "expected" made signalTurnTreeGroup compare a value to
1113
+ // itself, so the whole verification collapsed to a race — a pid recycled
1114
+ // between the caller's gate and this signal would still have its (new)
1115
+ // group taken down. signalTurnTreeGroup re-reads internally and refuses
1116
+ // on mismatch against the identity recorded when the tree was spawned.
1117
+ if (rootStillOriginal) {
1118
+ signalTurnTreeGroup({ pid: handle.rootPid, bootId: handle.bootId, starttime: handle.startTime }, 'SIGKILL', { procRoot: this.procRoot });
1119
+ }
1120
+ // Per-member signals stay safe even when the recorded root is long gone, and
1121
+ // this is the case that actually matters: a descendant that called setsid
1122
+ // OUTLIVES its parent, so the root-identity gate can never pass for it.
1123
+ //
1124
+ // Two properties make each kill attributable:
1125
+ // * HANDLE nonce, not this.treeNonce: the constructor only adopts the
1126
+ // FIRST inherited handle's nonce, so scanning a later handle with the
1127
+ // adopted nonce found nothing by env at all — its tree was only ever
1128
+ // reachable through fragile pgid number collisions.
1129
+ // * the recorded root identity gates PGID claiming inside the scan, so
1130
+ // every member here was claimed via the env nonce (or as a descendant
1131
+ // of one), which is positive evidence of THIS tree's membership — and
1132
+ // the target is a single positive pid rather than a negated group, so
1133
+ // even a raced pid cannot drag an unrelated group down with it.
1134
+ //
1135
+ // Without this, an escaped survivor was re-proven alive on every retry and
1136
+ // never signalled, so the session could never be closed at all.
1137
+ const scan = scanMojoTree(handle.rootPid, handle.nonce, {
1138
+ procRoot: this.procRoot,
1139
+ excludePids: this.selfPids(),
1140
+ rootIdentity: { pid: handle.rootPid, bootId: handle.bootId, starttime: handle.startTime },
1141
+ });
1142
+ if (!scan.ok)
1143
+ return;
1144
+ for (const member of scan.members) {
1145
+ if (member.zombie)
1146
+ continue; // already reaped; signalling it is pointless
1147
+ try {
1148
+ process.kill(member.pid, 'SIGKILL');
1149
+ }
1150
+ catch { /* raced us */ }
1151
+ }
1152
+ }
1153
+ kill() {
1154
+ if (this.killed)
1155
+ return;
1156
+ this.killed = true;
1157
+ // Daemon shutdown / worker teardown. SIGTERM is not proof of anything, and
1158
+ // NOTHING is released here on purpose: the handles recorded at spawn are
1159
+ // durable, so an unproven tree survives the restart as an explicit blocker
1160
+ // instead of being silently forgotten. Calling releaseContainmentHandle on
1161
+ // this path would be exactly the laundering this review exists to remove.
1162
+ try {
1163
+ const stillOwned = containmentHandles(this.sessionId).length;
1164
+ if (stillOwned > 0) {
1165
+ logger.warn(`[mojo] shutdown leaves ${stillOwned} unproven turn subtree(s) for `
1166
+ + `${this.sessionId}; the blocker will be reloaded on the next boot`);
1167
+ }
1168
+ }
1169
+ catch (err) {
1170
+ // Never let an unreadable store turn shutdown into a crash; the store
1171
+ // being unreadable already means the next boot fails closed.
1172
+ logger.warn(`[mojo] cannot report outstanding turn subtrees at shutdown: ${String(err)}`);
1173
+ }
1174
+ this.shutdownDetachWake?.();
1175
+ for (const wake of this.lineageWaiters)
1176
+ wake();
1177
+ this.child?.kill('SIGTERM');
1178
+ this.child = null;
1179
+ // Mirror RiffBackend: the server-side mojo session KEEPS RUNNING here.
1180
+ // kill() fires on worker teardown / daemon restart, where the persisted
1181
+ // cliSessionId resumes the lineage afterwards. Cancelling the remote
1182
+ // session belongs to the explicit /close path (destroySession).
1183
+ this.exitCb?.(0, null);
1184
+ }
1185
+ /** Test-only view of the adopted lineage, so a teardown test can assert it is
1186
+ * still unset inside the pre-init window it is exercising. */
1187
+ get cliSessionIdForTest() {
1188
+ return this.cliSessionId ?? undefined;
1189
+ }
1190
+ /** /close teardown — cancel the server-side session so it stops consuming
1191
+ * cloud sandbox time after the IM session is gone. */
1192
+ async destroySession() {
1193
+ // Set when the local subtree is unprovable for a reason no retry can fix
1194
+ // (a platform with no /proc). Carried onto the successful result as an
1195
+ // explicit residual marker rather than silently dropped.
1196
+ let residualOnPlatform = false;
1197
+ // Set when the ladder completed cleanly but produced no unforgeable boundary
1198
+ // proof (Linux weak handle / diagnostic-clean). Distinct from
1199
+ // residualOnPlatform: that one is "this host has no instrument at all", this
1200
+ // one is "the instrument answered, and its answer is not proof".
1201
+ let residualBoundaryUnproven = false;
1202
+ if (this.shutdownDetaching) {
1203
+ return {
1204
+ ok: false,
1205
+ ...(this.cliSessionId ? { taskId: this.cliSessionId } : {}),
1206
+ error: 'shutdown_detach_in_progress',
1207
+ };
1208
+ }
1209
+ if (this.containmentUnrecorded) {
1210
+ // A spawned subtree exists that no durable handle describes (the record
1211
+ // failed and termination could not be proven). Publishing ANY close
1212
+ // verdict would drop the device-isolation blocker over a tree we cannot
1213
+ // enumerate, so the close is refused outright.
1214
+ this.closing = true;
1215
+ this.admissionFenced = true;
1216
+ return {
1217
+ ok: false,
1218
+ ...(this.cliSessionId ? { taskId: this.cliSessionId } : {}),
1219
+ error: 'containment_unrecorded_subtree',
1220
+ recovery: 'retryable',
1221
+ admission: 'fenced',
1222
+ };
1223
+ }
1224
+ // Gate FIRST so no new turn is accepted, then let the in-flight one settle
1225
+ // before tearing anything down. Killing the child here (as this used to)
1226
+ // destroyed the only source of the lineage: cliSessionId is adopted from
1227
+ // the first `system/init` line, so a /close inside the "turn dispatched,
1228
+ // init not yet arrived" window found it null, skipped the cancel, and never
1229
+ // fired taskIdCb — leaving the daemon's orphan fallback without an id too.
1230
+ // The remote session then leaked, still holding the injected credential.
1231
+ //
1232
+ // Bounded, and only worth waiting for while a turn is actually in flight.
1233
+ // Budget sits under the worker's own close/restart race (see
1234
+ // RiffBackend.destroySession for the layered deadlines).
1235
+ this.closing = true;
1236
+ // Gate on "a turn was dispatched and its lineage has not arrived", NOT on
1237
+ // `this.child`. Keying it on a live child meant a mojo that accepted the
1238
+ // write and then exited before emitting `system/init` skipped the wait
1239
+ // entirely — and with cliSessionId still null the cancel below was skipped
1240
+ // too, so this returned ok:true for a remote session we cannot even name.
1241
+ // `prepareShutdownDetach` already uses this exact predicate
1242
+ // (`lineageExpected`); the two protocols must agree about what "proven
1243
+ // gone" means.
1244
+ const lineageExpected = this.acceptedWriteWithoutLineage;
1245
+ if (lineageExpected && !this.cliSessionId) {
1246
+ await Promise.race([
1247
+ this.writeChain.catch(() => undefined),
1248
+ new Promise(r => setTimeout(r, this.destroySettleMs).unref?.()),
1249
+ ]);
1250
+ }
1251
+ // Order matters: the local subtree is torn down BEFORE the remote cancel,
1252
+ // and a failure here returns immediately. The cancel is the only
1253
+ // irreversible step, so it must never run while an earlier step can still
1254
+ // veto the close -- previously an unproven local child still fell through
1255
+ // to a successful cancel, producing a failure the caller was told to roll
1256
+ // back even though the remote session was already gone forever.
1257
+ //
1258
+ // SIGTERM is not proof, and neither is the direct pid — see
1259
+ // terminateChildProven.
1260
+ const termination = await this.terminateChildProven();
1261
+ if (!termination.ok) {
1262
+ // Two very different facts reach this branch, and collapsing them is
1263
+ // what wedged non-Linux hosts.
1264
+ //
1265
+ // * "the instrument says something may still be running" — real
1266
+ // evidence of a possibly credentialed survivor. Admission must be
1267
+ // fenced, because admitting a new turn would layer it on top of a
1268
+ // live orphan.
1269
+ // * "this host has no instrument at all" — off Linux there is no
1270
+ // /proc, so no retry, no delay and no operator action can ever turn
1271
+ // this into a proof. Fencing it made /close fail forever AND
1272
+ // refused every rollback, so the session could neither be closed
1273
+ // nor written to again.
1274
+ //
1275
+ // classifyUnprovenTermination owns that split (see destroy-result.ts);
1276
+ // anything it does not positively recognise as a terminal platform
1277
+ // limit falls through to the fence, so the default stays fail-closed.
1278
+ const verdict = classifyUnprovenTermination(this.lastTurnQuiescence?.kind);
1279
+ if (verdict.outcome === 'residual-close') {
1280
+ // Deliberately NOT setting admissionFenced: that latch is exactly
1281
+ // the wedge. The credential boundary is carried instead by the
1282
+ // containment handle recorded at spawn — on a host this branch can
1283
+ // be reached from, that handle is `unprovable`, which can never be
1284
+ // released, so the device-isolation blocker is retained.
1285
+ //
1286
+ // Execution deliberately CONTINUES into the remote cancel below: a
1287
+ // residual local subtree is no reason to leak the remote session.
1288
+ logger.warn(`[mojo] session ${this.sessionId}: local turn subtree cannot be proven gone on `
1289
+ + `this platform (${verdict.reason}); closing with a residual marker instead of `
1290
+ + 'fencing the session forever — the device-isolation blocker is retained by the '
1291
+ + 'durable containment handle');
1292
+ residualOnPlatform = true;
1293
+ }
1294
+ else {
1295
+ // `closing` deliberately STAYS true, and the fence is latched. The
1296
+ // close is retryable (the irreversible remote cancel below has NOT
1297
+ // run), but a process that may still hold the injected credential is
1298
+ // possibly alive — admitting a new turn would layer it on top of that
1299
+ // live orphan. Clearing `closing` here is what made a post-abort
1300
+ // write() succeed on exactly this state.
1301
+ this.admissionFenced = true;
1302
+ // A dispatched turn whose lineage never materialised is an UNCERTAIN
1303
+ // outcome in its own right, and this earlier local failure must not
1304
+ // launder it into `retryable`: the very next check below would have
1305
+ // returned `uncertain` for the same session. Whichever step fails
1306
+ // first, an unnamable remote session stays unnamable.
1307
+ const unnamedRemotePossible = lineageExpected && !this.cliSessionId;
1308
+ return {
1309
+ ok: false,
1310
+ ...(this.cliSessionId ? { taskId: this.cliSessionId } : {}),
1311
+ error: verdict.reason,
1312
+ recovery: unnamedRemotePossible ? 'uncertain' : 'retryable',
1313
+ admission: 'fenced',
1314
+ };
1315
+ }
1316
+ }
1317
+ else if (!termination.boundaryProven) {
1318
+ // THE GATE. The ladder completed and found no executing member, but no
1319
+ // unforgeable boundary was established -- on Linux this is the weak
1320
+ // handle / `diagnostic-clean` case, which the old bare boolean laundered
1321
+ // into a plain `closed` row. A closed row is filtered out of the
1322
+ // device-isolation inventory, so the blocker vanished for a subtree that
1323
+ // a setsid'd, environ-scrubbed descendant could still be living in.
1324
+ //
1325
+ // Reviewer's second option, deliberately not the first: the clean scan is
1326
+ // allowed to stop the signalling (`signalsStopped`) and the session row is
1327
+ // allowed to close, but it does NOT authorise a plain closed row. The
1328
+ // residual marker below is what carries the device-isolation blocker past
1329
+ // the close.
1330
+ //
1331
+ // Admission is deliberately NOT fenced: unlike the `!ok` branch there is
1332
+ // no positive evidence of a live member, and fencing every clean-scan
1333
+ // close would wedge ordinary Linux sessions forever. Execution continues
1334
+ // into the remote cancel for the same reason it does on the
1335
+ // residual-close path: a residual LOCAL subtree is no reason to leak the
1336
+ // REMOTE session.
1337
+ //
1338
+ // Releasing the handle is not done here by design -- that decision lives
1339
+ // in mojo-containment.ts, and only `boundaryProven === true` may authorise
1340
+ // it. This branch is the negative case, so it releases nothing.
1341
+ logger.warn(`[mojo] session ${this.sessionId}: local turn subtree scanned clean but the credential `
1342
+ + `boundary is NOT proven (evidence ${termination.evidence}`
1343
+ + `${termination.residual?.reason ? `: ${termination.residual.reason}` : ''}); closing with a `
1344
+ + 'residual marker instead of a plain closed row — the device-isolation blocker is retained');
1345
+ residualBoundaryUnproven = true;
1346
+ }
1347
+ // A turn was dispatched but its lineage never materialised: there may be a
1348
+ // remote session we have no id for, so it cannot be cancelled and cannot be
1349
+ // claimed gone. Same verdict prepareShutdownDetach reaches from this state.
1350
+ if (lineageExpected && !this.cliSessionId) {
1351
+ // `closing` deliberately STAYS true. Clearing it here re-opened write
1352
+ // admission (a probe called write() straight after and it returned
1353
+ // true), which is exactly what `uncertain` must prevent: an unnamed
1354
+ // remote session may exist, so a fresh turn must not be layered on top
1355
+ // of a possible orphan. abortDestroySession() is the only legitimate
1356
+ // way back, and the worker must not call it for this verdict.
1357
+ // taskId is deliberately omitted rather than null: SessionDestroyResult
1358
+ // types it as an optional string, and "absent" is the honest answer —
1359
+ // there is no id to hand back for retry.
1360
+ //
1361
+ // `uncertain`, not `retryable`: an unnamed remote session may exist, so
1362
+ // admission must stay fenced instead of starting a fresh lineage on top
1363
+ // of a possible orphan. The row stays active, which keeps the
1364
+ // device-isolation blocker in place.
1365
+ this.admissionFenced = true;
1366
+ return {
1367
+ ok: false,
1368
+ error: 'mojo_lineage_not_materialized',
1369
+ recovery: 'uncertain',
1370
+ admission: 'fenced',
1371
+ };
1372
+ }
1373
+ if (this.cliSessionId) {
1374
+ let outcome;
1375
+ try {
1376
+ await this.runCliJson(['session', 'cancel', this.cliSessionId]);
1377
+ outcome = { kind: 'cancelled' };
1378
+ logger.info(`[mojo] cancelled session ${this.cliSessionId}`);
1379
+ }
1380
+ catch (err) {
1381
+ outcome = classifyMojoCancelFailure(err);
1382
+ logger.warn(`[mojo] session cancel failed: ${String(err)}`);
1383
+ }
1384
+ if (!isMojoRemoteGone(outcome)) {
1385
+ // Report it instead of swallowing it. This used to return void on
1386
+ // every path, so the worker ACKed a "successful" close and the
1387
+ // daemon published a closed row while the remote session kept
1388
+ // running and holding the injected credential.
1389
+ //
1390
+ // `killed` deliberately stays false and recovery is `retryable`:
1391
+ // the remote session was NOT torn down, so restoring admission is
1392
+ // both safe and required for the retry.
1393
+ this.closing = false;
1394
+ return {
1395
+ ok: false,
1396
+ taskId: this.cliSessionId,
1397
+ error: outcome.kind === 'failed' ? outcome.message : 'cancel not proven',
1398
+ recovery: 'retryable',
1399
+ // The local subtree was PROVEN gone above and the remote session
1400
+ // is named, so there is no unnamed survivor to fence against.
1401
+ admission: 'restorable',
1402
+ };
1403
+ }
1404
+ }
1405
+ this.killed = true;
1406
+ // Reap this session's isolated execution daemon LAST, after the close
1407
+ // verdict is already decided: a reaping failure is a leaked idle daemon
1408
+ // (availability), never grounds to fail or roll back a close whose
1409
+ // remote cancel already happened. kill()/shutdown-detach deliberately
1410
+ // do NOT reap — the session survives a daemon restart and its daemon
1411
+ // must keep serving the resumed lineage.
1412
+ if (this.hostExecution() && !this.controlPlaneOnly) {
1413
+ await cleanupMojoIsolatedWorkspace(this.sessionId, { home: this.isolationHome() })
1414
+ .catch(() => undefined);
1415
+ // Drop the cached realpath WITH the directory: a later CLI
1416
+ // invocation on this instance (a retried close, a repeat cancel)
1417
+ // must re-create the workspace via resolveCwd instead of spawning
1418
+ // into a deleted cwd (ENOENT).
1419
+ this.isolatedWorkspace = undefined;
1420
+ }
1421
+ return {
1422
+ ok: true,
1423
+ ...(this.cliSessionId ? { taskId: this.cliSessionId } : {}),
1424
+ ...(residualOnPlatform
1425
+ ? { residual: 'local_subtree_unprovable_on_platform' }
1426
+ : residualBoundaryUnproven
1427
+ ? { residual: 'local_subtree_boundary_unproven' }
1428
+ : {}),
1429
+ };
1430
+ }
1431
+ /**
1432
+ * Roll back a FAILED prepare (restore write admission).
1433
+ *
1434
+ * Only valid when the cancel did not succeed. A proven cancel is irreversible:
1435
+ * the remote session is gone, so restoring admission would produce a session
1436
+ * that looks active but can never continue.
1437
+ */
1438
+ abortDestroySession() {
1439
+ if (this.killed) {
1440
+ logger.warn('[mojo] abortDestroySession ignored: session was already torn down');
1441
+ // The session is gone for good, so writes are not "restored" here
1442
+ // either; saying otherwise would let the daemon clear a fence that the
1443
+ // irreversible teardown owns.
1444
+ return { admissionRestored: false, reason: 'session_already_torn_down' };
1445
+ }
1446
+ // A latched fence outranks the rollback. The daemon/worker may legitimately
1447
+ // abort a close it could not commit, but "the close was abandoned" is not
1448
+ // evidence that the possibly-live local subtree died, so restoring writes
1449
+ // here would defeat the fence destroySession deliberately kept.
1450
+ if (this.admissionFenced) {
1451
+ logger.warn('[mojo] abortDestroySession did NOT restore write admission: a previous close '
1452
+ + 'could not prove local termination (or the turn lineage never materialised). '
1453
+ + 'This session will not accept writes again; retry the close instead');
1454
+ // Reported, not swallowed: the daemon must persist "still fenced" rather
1455
+ // than infer success from a call that did not throw.
1456
+ return { admissionRestored: false, reason: 'local_termination_unproven' };
1457
+ }
1458
+ this.closing = false;
1459
+ return { admissionRestored: true };
1460
+ }
1461
+ /**
1462
+ * Prepare a daemon-restart detach without cancelling the remote Mojo
1463
+ * session. Unlike Riff, a Mojo turn can legitimately run for 60 seconds;
1464
+ * shutdown only needs the lineage from its first `system/init`, not the
1465
+ * whole answer. Therefore a pre-init turn waits at most destroySettleMs,
1466
+ * while a known lineage (or an idle backend with no accepted turn) prepares
1467
+ * immediately.
1468
+ */
1469
+ async prepareShutdownDetach() {
1470
+ if (this.shutdownDetachInFlight)
1471
+ return this.shutdownDetachInFlight;
1472
+ if (this.shutdownDetachPrepared) {
1473
+ return { ok: true, taskId: this.cliSessionId };
1474
+ }
1475
+ if (this.killed) {
1476
+ return { ok: false, taskId: this.cliSessionId, error: 'backend_killed' };
1477
+ }
1478
+ if (this.closing) {
1479
+ return { ok: false, taskId: this.cliSessionId, error: 'explicit_close_in_progress' };
1480
+ }
1481
+ const attempt = Symbol('mojo-shutdown-detach');
1482
+ const acceptedWrites = this.writeChain;
1483
+ const lineageExpected = this.acceptedWriteWithoutLineage;
1484
+ this.shutdownDetachAttempt = attempt;
1485
+ this.shutdownDetaching = true;
1486
+ const prepare = (async () => {
1487
+ if (!this.cliSessionId && lineageExpected) {
1488
+ await new Promise((resolve) => {
1489
+ let settled = false;
1490
+ const finish = () => {
1491
+ if (settled)
1492
+ return;
1493
+ settled = true;
1494
+ clearTimeout(timer);
1495
+ this.lineageWaiters.delete(finish);
1496
+ if (this.shutdownDetachWake === finish)
1497
+ this.shutdownDetachWake = null;
1498
+ resolve();
1499
+ };
1500
+ const timer = setTimeout(finish, this.destroySettleMs);
1501
+ timer.unref?.();
1502
+ this.lineageWaiters.add(finish);
1503
+ this.shutdownDetachWake = finish;
1504
+ void acceptedWrites.then(finish, finish);
1505
+ if (this.cliSessionId)
1506
+ finish();
1507
+ });
1508
+ if (this.killed || this.shutdownDetachAttempt !== attempt || !this.shutdownDetaching) {
1509
+ return { ok: false, taskId: this.cliSessionId, error: 'shutdown_detach_aborted' };
1510
+ }
1511
+ if (!this.cliSessionId) {
1512
+ return {
1513
+ ok: false,
1514
+ taskId: null,
1515
+ error: 'mojo_lineage_not_materialized',
1516
+ };
1517
+ }
1518
+ }
1519
+ if (this.closing) {
1520
+ return { ok: false, taskId: this.cliSessionId, error: 'explicit_close_in_progress' };
1521
+ }
1522
+ this.shutdownDetachPrepared = true;
1523
+ logger.info(`[mojo] graceful shutdown detach prepared`
1524
+ + `${this.cliSessionId ? ` (session ${this.cliSessionId})` : ' (no session lineage)'}`);
1525
+ return { ok: true, taskId: this.cliSessionId };
1526
+ })();
1527
+ this.shutdownDetachInFlight = prepare.finally(() => {
1528
+ this.shutdownDetachInFlight = null;
1529
+ });
1530
+ return this.shutdownDetachInFlight;
1531
+ }
1532
+ async abortShutdownDetach() {
1533
+ if (this.killed) {
1534
+ return { ok: false, taskId: this.cliSessionId, error: 'backend_killed' };
1535
+ }
1536
+ if (this.shutdownDetachAbortInFlight)
1537
+ return this.shutdownDetachAbortInFlight;
1538
+ const pending = this.shutdownDetachInFlight;
1539
+ this.shutdownDetachAttempt = null;
1540
+ this.shutdownDetachPrepared = false;
1541
+ this.shutdownDetachWake?.();
1542
+ this.shutdownDetachAbortInFlight = (async () => {
1543
+ if (pending)
1544
+ await pending.catch(() => undefined);
1545
+ if (this.killed) {
1546
+ return { ok: false, taskId: this.cliSessionId, error: 'backend_killed' };
1547
+ }
1548
+ if (this.closing || this.shutdownDetachAttempt !== null) {
1549
+ return {
1550
+ ok: false,
1551
+ taskId: this.cliSessionId,
1552
+ error: this.closing ? 'explicit_close_in_progress' : 'new_shutdown_detach_in_progress',
1553
+ };
1554
+ }
1555
+ this.shutdownDetaching = false;
1556
+ logger.info('[mojo] graceful shutdown detach aborted; write admission restored');
1557
+ return { ok: true, taskId: this.cliSessionId };
1558
+ })().finally(() => {
1559
+ this.shutdownDetachAbortInFlight = null;
1560
+ });
1561
+ return this.shutdownDetachAbortInFlight;
1562
+ }
1563
+ commitShutdownDetach() {
1564
+ this.shutdownDetachPrepared = false;
1565
+ this.shutdownDetachAttempt = null;
1566
+ // Keep admission fenced until the worker exits immediately after commit.
1567
+ this.shutdownDetaching = true;
1568
+ }
1569
+ // ── One turn ─────────────────────────────────────────────────────────────
1570
+ buildArgs(prompt) {
1571
+ const args = ['-p', '--output-format', 'stream-json'];
1572
+ // Token-level deltas → the IM layer can live-edit the reply card.
1573
+ if (this.config.stream !== false)
1574
+ args.push('--include-partial');
1575
+ // `--help` says the default is to auto-REJECT tools needing confirmation,
1576
+ // but that is NOT what happens on the cloud-sandbox path: verified without
1577
+ // --yolo that `echo … > f && cat f` returned return_code 0 with the file
1578
+ // actually written, and that `rm -rf <dir>` likewise succeeded — no
1579
+ // rejection, no warning, no interaction. So this flag is belt-and-braces
1580
+ // rather than load-bearing; we keep it for explicitness and in case a
1581
+ // future mojo build does enforce a confirmation gate headlessly.
1582
+ //
1583
+ // Consequence worth stating plainly (review F4): host execution is now
1584
+ // the DEFAULT, and with --yolo there is no per-tool approval — a mojo
1585
+ // bot's blast radius on the host is bounded only by the OS user and the
1586
+ // bot's allowedUsers gate. Operators who don't accept that must set
1587
+ // `cloud: true` (fully-remote sandbox) or `localDaemon: false`.
1588
+ if (this.config.disableCliBypass !== true)
1589
+ args.push('--yolo');
1590
+ if (this.cliSessionId)
1591
+ args.push('-r', this.cliSessionId);
1592
+ if (this.config.model?.trim())
1593
+ args.push('--model', this.config.model.trim());
1594
+ if (this.config.workspaceId)
1595
+ args.push('--workspace-id', this.config.workspaceId);
1596
+ if (this.config.agentId && !this.cliSessionId)
1597
+ args.push('--agent-id', this.config.agentId);
1598
+ // Run in the cloud sandbox instead of touching the bot host's filesystem.
1599
+ // Shared derivation with buildEnv()/the spawn audit log — see
1600
+ // deriveMojoExecutionMode for the precedence rules (explicit
1601
+ // localDaemon wins and suppresses --cloud) and why hand-copying this
1602
+ // logic produced fail-opens before.
1603
+ if (deriveMojoExecutionMode(this.config).passCloudFlag)
1604
+ args.push('--cloud');
1605
+ if (this.config.idleTimeoutSec)
1606
+ args.push('--idle-timeout', String(this.config.idleTimeoutSec));
1607
+ // Before the positional prompt, which must stay last. Placed after our own
1608
+ // flags so an operator's CLI_EXTRA_ARGS can override them.
1609
+ args.push(...this.extraCliArgs);
1610
+ args.push(this.decorate(prompt));
1611
+ return args;
1612
+ }
1613
+ /** Retry the "session still RUNNING" race with backoff (see SESSION_BUSY_RE). */
1614
+ async runTurnWithBusyRetry(prompt) {
1615
+ for (let attempt = 0;; attempt++) {
1616
+ const busy = await this.runTurn(prompt);
1617
+ if (!busy)
1618
+ return;
1619
+ const delay = BUSY_RETRY_DELAYS_MS[attempt];
1620
+ if (delay === undefined) {
1621
+ this.emitLine('❌ mojo 会话持续处于执行中,本条消息未能送达,请稍后重发。', 'err');
1622
+ this.settleTurn();
1623
+ return;
1624
+ }
1625
+ logger.info(`[mojo] session busy; retrying in ${delay}ms`);
1626
+ await new Promise(r => setTimeout(r, delay));
1627
+ if (this.killed || this.closing)
1628
+ return;
1629
+ }
1630
+ }
1631
+ /** Resolves `true` when the turn was rejected because the session is still
1632
+ * RUNNING (caller should retry), `false` once the turn is accounted for. */
1633
+ runTurn(prompt) {
1634
+ return new Promise((resolve, reject) => {
1635
+ const { bin, args } = this.resolveLaunch(this.buildArgs(prompt));
1636
+ this.turnSettled = false;
1637
+ this.streamedThisTurn = false;
1638
+ this.turnFinalText = '';
1639
+ this.stdoutTail = '';
1640
+ // The client PROCESS is not the turn. In host execution the CLI
1641
+ // auto-spawns the per-workspace mojo-daemon as its child and then
1642
+ // BABYSITS it — the process (and its stdio) can stay alive for
1643
+ // hours after the turn's result event (observed live, twice). The
1644
+ // turn is accounted for the moment settleTurn() runs; resolve
1645
+ // there, and treat any later process end as bookkeeping only.
1646
+ let resolved = false;
1647
+ const resolveOnce = (busy) => {
1648
+ if (resolved)
1649
+ return;
1650
+ resolved = true;
1651
+ if (this.turnResolve === settleHook)
1652
+ this.turnResolve = null;
1653
+ resolve(busy);
1654
+ };
1655
+ const settleHook = () => resolveOnce(false);
1656
+ this.turnResolve = settleHook;
1657
+ // The strong boundary must exist BEFORE the child does, and the child
1658
+ // must enter it BEFORE exec (see MOJO_CGROUP_ENROLL_SHIM): post-spawn
1659
+ // migration leaves already-forked descendants outside the cgroup, which
1660
+ // voids the entire strong proof. Null → weak handle fallback. Skipped
1661
+ // entirely once a prior turn proved enrolment impossible on this host.
1662
+ this.preparedBoundary = this.strongBoundaryUnusable
1663
+ ? null
1664
+ : prepareContainmentBoundary({
1665
+ sessionId: this.sessionId,
1666
+ generation: this.containmentGeneration,
1667
+ nonce: this.treeNonce,
1668
+ }, {
1669
+ ...(this.cgroupRoot !== undefined ? { cgroupRoot: this.cgroupRoot } : {}),
1670
+ ...(this.cgroupProcRoot !== undefined ? { procRoot: this.cgroupProcRoot } : {}),
1671
+ });
1672
+ this.usedEnrolShim = this.preparedBoundary !== null;
1673
+ const launchBin = this.preparedBoundary ? '/bin/sh' : bin;
1674
+ const launchArgs = this.preparedBoundary
1675
+ ? [
1676
+ '-c', MOJO_CGROUP_ENROLL_SHIM, 'mojo-cgroup-enroll',
1677
+ `${this.preparedBoundary.cgroupPath}/cgroup.procs`,
1678
+ bin, ...args,
1679
+ ]
1680
+ : args;
1681
+ const child = spawnProcess(launchBin, launchArgs, {
1682
+ cwd: this.resolveCwd(),
1683
+ env: this.buildEnv(),
1684
+ // stdin MUST be closed: mojo waits on socket-type stdin and an open
1685
+ // pipe makes `-p` block until EOF (observed as a silent hang).
1686
+ stdio: ['ignore', 'pipe', 'pipe'],
1687
+ // Own process GROUP, so teardown can prove the whole subtree is
1688
+ // gone instead of only the direct child. mojo runs tools and can
1689
+ // leave detached descendants that inherited X_JWT_TOKEN; those
1690
+ // survive a kill aimed at the direct pid, and the close would
1691
+ // still publish a `closed` row -- dropping the device-isolation
1692
+ // blocker while a credentialed process is still executing.
1693
+ //
1694
+ // Without a dedicated group there is no safe fix: the child would
1695
+ // share the daemon's group, so `kill(-pgid)` would take down the
1696
+ // daemon itself. detached only changes group/session membership
1697
+ // here; the pipes are still owned and awaited, so nothing is
1698
+ // orphaned by this flag on its own.
1699
+ detached: true,
1700
+ });
1701
+ this.child = child;
1702
+ if (typeof child.pid === 'number' && child.pid > 0) {
1703
+ this.lastTurnPid = child.pid;
1704
+ // Bind the identity NOW, while the pid is guaranteed to still be
1705
+ // this child: read later, it could already describe a recycled pid.
1706
+ const id = readProcessIdentity(child.pid, { procRoot: this.procRoot });
1707
+ this.turnIdentity = id.ok ? id.identity : null;
1708
+ if (!id.ok) {
1709
+ // Without an identity the group signal must be refused later, so
1710
+ // say so once, loudly, rather than discovering it during teardown.
1711
+ logger.warn(`[mojo] cannot bind turn identity for pid ${child.pid} (${id.reason}); `
1712
+ + 'group signalling will be refused and quiescence cannot be proven');
1713
+ }
1714
+ // Close the EMPTY-AT-BIRTH window: the strong handle is recorded
1715
+ // now, but its cgroup stays empty until the shim's own write lands
1716
+ // (~1-5ms). A workerless prove+release racing that window would read
1717
+ // the cgroup empty and drop the blocker while the shim then execs a
1718
+ // credentialed binary. The parent writes child.pid too — idempotent
1719
+ // with the shim's write (same pid), so the cgroup is non-empty from
1720
+ // the instant the handle exists. This does NOT reintroduce the old
1721
+ // post-spawn-migration P0: the shim forks nothing before enrolling,
1722
+ // so there are no pre-enrolment descendants to miss.
1723
+ if (this.preparedBoundary) {
1724
+ try {
1725
+ writeFileSync(`${this.preparedBoundary.cgroupPath}/cgroup.procs`, `${child.pid}\n`);
1726
+ }
1727
+ catch {
1728
+ // The shim's own write remains the authoritative enrolment;
1729
+ // this is belt-and-braces. A failure here is not fatal.
1730
+ }
1731
+ }
1732
+ // Mint and PERSIST the containment handle before the child can act.
1733
+ // Doing this at close time would be too late: a crash in between is
1734
+ // exactly the window that loses the tree, and a lost tree is one the
1735
+ // next generation can never prove quiescent.
1736
+ try {
1737
+ this.recordTurnContainment(child.pid);
1738
+ }
1739
+ catch (err) {
1740
+ // A child is RUNNING and nothing durable describes it. Failing
1741
+ // only the turn would leave that subtree alive behind a blocker
1742
+ // nobody recorded — so terminate it now, and refuse every close
1743
+ // proof until termination is itself proven (the latch is set
1744
+ // FIRST so a concurrent /close cannot slip through the async
1745
+ // window below).
1746
+ this.containmentUnrecorded = true;
1747
+ void this.containUnrecordedSpawn(child, err).catch((e) => {
1748
+ logger.error(`[mojo] containUnrecordedSpawn crashed (latch retained): ${String(e)}`);
1749
+ });
1750
+ }
1751
+ }
1752
+ let stderr = '';
1753
+ child.stdout.on('data', (chunk) => {
1754
+ // Fence on child identity for the SAME reason finalize() does:
1755
+ // this client is not awaited (settleTurn resolves the turn), so
1756
+ // its pipe can still deliver bytes hours later — after a LATER
1757
+ // turn has taken over the shared stream/turn state. Without
1758
+ // this, a late line from the OLD client was consumed as the
1759
+ // CURRENT turn's output (reproduced: stale text delivered as
1760
+ // the next turn's answer, real answer dropped).
1761
+ if (this.child !== child)
1762
+ return;
1763
+ this.consume(chunk.toString());
1764
+ });
1765
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
1766
+ child.on('error', (err) => {
1767
+ if (this.child === child)
1768
+ this.child = null;
1769
+ // Spawn failed: nothing ever ran, so nobody enrolled. Reap the
1770
+ // prepared boundary (a recorded handle, if the record already
1771
+ // happened, owns the directory instead and preparedBoundary is null).
1772
+ const prepared = this.preparedBoundary;
1773
+ if (prepared) {
1774
+ this.preparedBoundary = null;
1775
+ void killPreparedBoundary(prepared);
1776
+ }
1777
+ reject(err);
1778
+ });
1779
+ // 'close' alone is NOT a reliable end-of-turn signal here: in host
1780
+ // execution the CLI auto-spawns the per-workspace mojo-daemon as its
1781
+ // CHILD, which inherits our stdout/stderr pipes and keeps them open
1782
+ // for its whole lifetime — 'close' then never fires even though the
1783
+ // client exited minutes ago, the runTurn promise stays pending, and
1784
+ // every later write queues forever (observed live: turn 2 of a DM
1785
+ // session hung 8+ minutes with the daemon healthy). 'exit' + settled
1786
+ // turn is already conclusive; when the turn is NOT settled yet, give
1787
+ // trailing pipe output a bounded grace and then finalize anyway.
1788
+ let finalized = false;
1789
+ const finalize = (code) => {
1790
+ if (finalized)
1791
+ return;
1792
+ finalized = true;
1793
+ // Guarded: a LATER turn may already own this.child by the time
1794
+ // this long-lived client finally ends.
1795
+ const wasCurrent = this.child === child;
1796
+ if (wasCurrent)
1797
+ this.child = null;
1798
+ // Turn already accounted for via its result event — this late
1799
+ // process end is bookkeeping only. Touching stream/turn state
1800
+ // here would corrupt whichever turn is CURRENTLY in flight.
1801
+ if (resolved)
1802
+ return;
1803
+ // Same child fence as the stdout handler: never flush an OLD
1804
+ // client's tail into whichever turn currently owns the stream.
1805
+ if (wasCurrent)
1806
+ this.flushTail();
1807
+ // The pre-exec shim's handshake: enrolment into the prepared cgroup
1808
+ // failed, so it exited WITHOUT exec'ing mojo (nothing credentialed
1809
+ // ran). Gated on `usedEnrolShim` so a genuine mojo exit 97 (on a
1810
+ // weak-handle host, where no shim runs) is NOT misread as this and
1811
+ // does not lose its normal exit handling. On a real enrolment
1812
+ // failure the strong boundary is latched unusable, so the NEXT turn
1813
+ // degrades to a weak handle instead of failing here forever (the
1814
+ // prepare-time probe cannot catch a write-time delegation refusal).
1815
+ if (this.usedEnrolShim && code === MOJO_ENROLL_FAILED_EXIT
1816
+ && !this.streamedThisTurn && !this.turnSettled) {
1817
+ this.strongBoundaryUnusable = true;
1818
+ logger.error('[mojo] cgroup enrolment was rejected at write time (exit 97); this host cannot '
1819
+ + 'host a strong boundary, degrading to the weak handle for subsequent turns');
1820
+ this.emitLine('❌ mojo 启动失败:无法进入进程围栏(cgroup 入组被拒),本轮未执行,后续改用降级隔离。', 'err');
1821
+ this.settleTurn();
1822
+ return resolveOnce(false);
1823
+ }
1824
+ // exit 2 == unknown model; stderr carries the authoritative list.
1825
+ if (code === 2 && /未知模型|unknown model/i.test(stderr)) {
1826
+ this.emitLine(`❌ 模型名无效。${stderr.trim()}`, 'err');
1827
+ this.settleTurn();
1828
+ return resolveOnce(false);
1829
+ }
1830
+ // Busy race: nothing was streamed and the session is still RUNNING.
1831
+ if (!this.turnSettled && SESSION_BUSY_RE.test(stderr))
1832
+ return resolveOnce(true);
1833
+ // Dead resume lineage → drop it and let the user retry fresh.
1834
+ if (!this.turnSettled && this.maybeDropLineage(stderr)) {
1835
+ this.settleTurn();
1836
+ return resolveOnce(false);
1837
+ }
1838
+ // A `result` event already settled the turn in the normal path
1839
+ // (including the ask-user cancellation, which also exits 1).
1840
+ if (!this.turnSettled) {
1841
+ if (code !== 0) {
1842
+ this.emitLine(`❌ mojo 退出码 ${code}${stderr.trim() ? `:${stderr.trim()}` : ''}`, 'err');
1843
+ }
1844
+ this.settleTurn();
1845
+ }
1846
+ resolveOnce(false);
1847
+ };
1848
+ child.on('close', (code) => finalize(code));
1849
+ child.on('exit', (code) => {
1850
+ if (this.turnSettled) {
1851
+ // The result event already accounted for this turn; nothing
1852
+ // the withheld pipes could still deliver changes the verdict.
1853
+ finalize(code);
1854
+ return;
1855
+ }
1856
+ // Not settled: stderr/stdout may still be in flight through the
1857
+ // pipes (they outlive the process). Bounded grace, then the same
1858
+ // single finalize path — 'close' beats the timer when it does fire.
1859
+ const t = setTimeout(() => finalize(code), 2_000);
1860
+ t.unref?.();
1861
+ });
1862
+ });
1863
+ }
1864
+ /**
1865
+ * Drop a dead resume lineage so the NEXT message starts a fresh session
1866
+ * instead of re-sending the same doomed `-r <sid>` forever.
1867
+ *
1868
+ * Mirrors RiffBackend's broken-lineage path: the `null` broadcast is what
1869
+ * clears the DAEMON-side persisted id — without it a daemon restart would
1870
+ * resurrect the very session we just declared dead.
1871
+ *
1872
+ * Returns true when the lineage was dropped (caller must not treat the turn
1873
+ * as a generic failure).
1874
+ */
1875
+ maybeDropLineage(stderr) {
1876
+ // Only meaningful when this turn actually resumed something.
1877
+ if (!this.cliSessionId)
1878
+ return false;
1879
+ if (!RESUME_DEAD_RE.test(stderr))
1880
+ return false;
1881
+ logger.warn(`[mojo] resume lineage ${this.cliSessionId} looks dead; starting fresh next turn`);
1882
+ this.cliSessionId = null;
1883
+ this.taskIdCb?.(null);
1884
+ this.emitLine('⚠️ 之前的 mojo 会话已失效,下一条消息将新建会话(上下文不会延续)。', 'warn');
1885
+ return true;
1886
+ }
1887
+ /** Parse NDJSON incrementally — a chunk may split a line in half. */
1888
+ consume(chunk) {
1889
+ this.stdoutTail += chunk;
1890
+ const lines = this.stdoutTail.split('\n');
1891
+ this.stdoutTail = lines.pop() ?? '';
1892
+ for (const line of lines)
1893
+ this.handleLine(line);
1894
+ }
1895
+ flushTail() {
1896
+ const line = this.stdoutTail;
1897
+ this.stdoutTail = '';
1898
+ if (line.trim())
1899
+ this.handleLine(line);
1900
+ }
1901
+ handleLine(line) {
1902
+ const trimmed = line.trim();
1903
+ if (!trimmed)
1904
+ return;
1905
+ if (!trimmed.startsWith('{')) {
1906
+ // Startup notices / update hints are plain text — surface them dimly
1907
+ // rather than corrupting the transcript.
1908
+ logger.info(`[mojo] ${trimmed}`);
1909
+ return;
1910
+ }
1911
+ let ev;
1912
+ try {
1913
+ ev = JSON.parse(trimmed);
1914
+ }
1915
+ catch {
1916
+ logger.warn(`[mojo] unparseable stream line: ${trimmed.slice(0, 200)}`);
1917
+ return;
1918
+ }
1919
+ switch (ev.type) {
1920
+ case 'system': {
1921
+ const e = ev;
1922
+ if (e.subtype === 'init')
1923
+ this.adoptSession(e.session_id, e.model);
1924
+ return;
1925
+ }
1926
+ case 'text_delta': {
1927
+ const e = ev;
1928
+ if (e.text) {
1929
+ this.streamedThisTurn = true;
1930
+ this.emitText(e.text);
1931
+ }
1932
+ return;
1933
+ }
1934
+ case 'text': {
1935
+ // With --include-partial the deltas already rendered this text.
1936
+ const e = ev;
1937
+ if (!this.streamedThisTurn && e.text)
1938
+ this.emitText(e.text);
1939
+ return;
1940
+ }
1941
+ case 'tool_call': {
1942
+ const e = ev;
1943
+ this.emitLine(`🔧 ${e.name ?? '(tool)'}${this.summarizeInput(e.input)}`, 'info');
1944
+ return;
1945
+ }
1946
+ case 'tool_result': {
1947
+ // Without this the user sees `🔧 Bash {...}`, then 20–30s of dead
1948
+ // air while the tool runs, then a sudden final answer — it reads
1949
+ // like a hang. Surface a one-line outcome instead.
1950
+ const e = ev;
1951
+ this.emitLine(this.summarizeToolResult(e.output), 'plain');
1952
+ return;
1953
+ }
1954
+ case 'result':
1955
+ this.handleResult(ev);
1956
+ return;
1957
+ default:
1958
+ logger.info(`[mojo] unhandled event type: ${String(ev.type)}`);
1959
+ }
1960
+ }
1961
+ adoptSession(id, model) {
1962
+ if (!id || id === this.cliSessionId)
1963
+ return;
1964
+ this.cliSessionId = id;
1965
+ this.acceptedWriteWithoutLineage = false;
1966
+ for (const wake of this.lineageWaiters)
1967
+ wake();
1968
+ // Available in the FIRST event, so the lineage is persisted even if the
1969
+ // turn later dies — no grok-style "recapture the id afterwards" needed.
1970
+ this.taskIdCb?.(id);
1971
+ logger.info(`[mojo] session ${id} (model=${model ?? 'default'})`);
1972
+ }
1973
+ handleResult(ev) {
1974
+ // Some flows emit only `result` without any text event.
1975
+ if (!this.streamedThisTurn && typeof ev.result === 'string' && ev.result) {
1976
+ this.emitText(ev.result);
1977
+ }
1978
+ const warnings = Array.isArray(ev.warnings) ? ev.warnings : [];
1979
+ const askSkipped = warnings.some(w => ASK_USER_SKIPPED_RE.test(String(w)));
1980
+ if (askSkipped) {
1981
+ // The single most confusing failure mode: the agent wanted to ask a
1982
+ // clarifying question, mojo dropped it, and the turn came back
1983
+ // cancelled with little or no text.
1984
+ this.emitLine('⚠️ mojo 想向你追问以确认细节,但无头模式下提问会被自动跳过,本回合已中断。', 'warn');
1985
+ this.emitLine('请把缺少的信息(例如具体文件 / 路径 / 目标)补全后重新发一次。', 'info');
1986
+ }
1987
+ else {
1988
+ for (const w of warnings)
1989
+ this.emitLine(`⚠️ ${String(w)}`, 'warn');
1990
+ }
1991
+ if (ev.error && !askSkipped)
1992
+ this.emitLine(`❌ ${this.fmtErr(ev.error)}`, 'err');
1993
+ this.settleTurn();
1994
+ }
1995
+ /**
1996
+ * Fire the turn boundary exactly once.
1997
+ *
1998
+ * This is the ONLY authority on when a mojo turn ends, which is why the
1999
+ * worker must not run its generic IdleDetector for this backend: that
2000
+ * detector infers "done" from ~2s of output quiescence, and a mojo turn goes
2001
+ * quiet for far longer while a tool runs. An early idle would re-arm
2002
+ * prompt-ready mid-turn, flushing queued messages into a session that is
2003
+ * still RUNNING (rejected — see SESSION_BUSY_RE) and attributing the reply
2004
+ * to the wrong turn/card. See the `isRemoteBackendType` gate in worker.ts.
2005
+ */
2006
+ settleTurn() {
2007
+ // `turnSettled` alone provides the once-per-turn guarantee. A
2008
+ // `seenResults` Set used to be maintained alongside it and described as
2009
+ // "session ids whose boundary already fired", but nothing ever queried it
2010
+ // — it was only added to and, past a cap, cleared wholesale. Dead state
2011
+ // reading as if it enforced cross-turn dedup, so it is gone rather than
2012
+ // left to mislead. If per-session result dedup is ever actually needed,
2013
+ // it has to be a real lookup here.
2014
+ if (this.turnSettled)
2015
+ return;
2016
+ this.turnSettled = true;
2017
+ // BEFORE taskDoneCb: that callback re-arms prompt-ready and flushes
2018
+ // queued follow-ups, so emitting the answer afterwards would race the
2019
+ // next turn's card/turn attribution. A turn that produced no prose
2020
+ // (tool-only, cancelled, failed) hands over '' and the worker's gate
2021
+ // drops it — the backend does not decide deliverability.
2022
+ const finalText = this.turnFinalText;
2023
+ this.turnFinalText = '';
2024
+ this.turnFinalCb?.(finalText);
2025
+ this.taskDoneCb?.();
2026
+ // Free the runTurn promise (and with it the write chain) NOW: the
2027
+ // client process is deliberately not awaited — with an embedded
2028
+ // execution daemon as its child it can legitimately outlive the turn
2029
+ // by hours, and waiting on it wedged every subsequent turn.
2030
+ const r = this.turnResolve;
2031
+ this.turnResolve = null;
2032
+ r?.();
2033
+ }
2034
+ // ── Helpers ──────────────────────────────────────────────────────────────
2035
+ /** `error` is an object ({code, message, retryable}) on both envelope shapes;
2036
+ * naive interpolation yields "[object Object]". */
2037
+ fmtErr(err) {
2038
+ if (!err)
2039
+ return '未知错误';
2040
+ if (typeof err === 'string')
2041
+ return err;
2042
+ if (err instanceof Error)
2043
+ return err.message;
2044
+ const e = err;
2045
+ const code = e.code ? `[${e.code}] ` : '';
2046
+ return `${code}${e.message ?? JSON.stringify(err)}`;
2047
+ }
2048
+ /** Condense a tool_result payload into one status line. The output is a JSON
2049
+ * string for shell-like tools ({return_code, stdout, stderr, status}) but may
2050
+ * be arbitrary text for others, so both shapes are handled. */
2051
+ summarizeToolResult(output) {
2052
+ if (output === undefined || output === null)
2053
+ return ' ↳ (无输出)';
2054
+ const raw = typeof output === 'string' ? output : JSON.stringify(output);
2055
+ let parsed;
2056
+ try {
2057
+ parsed = JSON.parse(raw);
2058
+ }
2059
+ catch { /* plain text result */ }
2060
+ if (parsed && typeof parsed === 'object' && 'return_code' in parsed) {
2061
+ const p = parsed;
2062
+ const ok = p.return_code === 0;
2063
+ const body = String(p.stdout || p.stderr || '').trim();
2064
+ const head = ok ? ' ↳ ✓' : ` ↳ ✗ exit ${String(p.return_code)}`;
2065
+ return body ? `${head} ${this.clip(body)}` : head;
2066
+ }
2067
+ return ` ↳ ${this.clip(raw)}`;
2068
+ }
2069
+ clip(s, n = 160) {
2070
+ const oneLine = s.replace(/\s+/g, ' ').trim();
2071
+ return oneLine.length > n ? `${oneLine.slice(0, n)}…` : oneLine;
2072
+ }
2073
+ summarizeInput(input) {
2074
+ if (!input)
2075
+ return '';
2076
+ const s = typeof input === 'string' ? input : JSON.stringify(input);
2077
+ const oneLine = s.replace(/\s+/g, ' ').trim();
2078
+ return oneLine ? ` ${oneLine.length > 120 ? `${oneLine.slice(0, 120)}…` : oneLine}` : '';
2079
+ }
2080
+ /**
2081
+ * Prepend the platform-owned skill block and the operator's systemPrompt.
2082
+ *
2083
+ * Order matters and is deliberate: the skill catalog is APPENDED after the
2084
+ * operator prompt, never merged into it. Folding it into `systemPrompt` would
2085
+ * mean a bot that sets its own prompt silently loses skill discovery — the
2086
+ * same trap riff documented for its mandatory routing rules.
2087
+ *
2088
+ * `builtinSkillBlock` is only populated for `prompt` / `off`; in `global`
2089
+ * mode the files are already on disk (~/.mojo/skills) so it stays empty.
2090
+ */
2091
+ decorate(prompt) {
2092
+ const preamble = [
2093
+ this.config.systemPrompt?.trim(),
2094
+ this.config.builtinSkillBlock?.trim(),
2095
+ this.hostGuidanceBlock(),
2096
+ ]
2097
+ .filter((s) => !!s)
2098
+ .join('\n\n');
2099
+ return preamble ? `${preamble}\n\n---\n\n${prompt}` : prompt;
2100
+ }
2101
+ /**
2102
+ * Host-execution guidance (undefined in cloud mode). Two compensations for
2103
+ * the isolated per-session cwd:
2104
+ * 1. the initial working directory is NOT the repo — point the agent at
2105
+ * the real one so repo work still lands in the right place;
2106
+ * 2. every botmux command carries an explicit `--session-id`: the
2107
+ * execution daemon's env belongs to whichever session spawned it, so
2108
+ * an inherited BOTMUX_SESSION_ID must never be what routes a reply
2109
+ * (defence in depth — the isolated daemon already carries the right
2110
+ * env, this survives even a regression back to a shared daemon).
2111
+ */
2112
+ hostGuidanceBlock() {
2113
+ if (!this.hostExecution())
2114
+ return undefined;
2115
+ const repo = this.realWorkingDir();
2116
+ const lines = [
2117
+ repo
2118
+ ? `你的工作仓库在 ${repo} 。当前初始工作目录是一个会话隔离目录,不含仓库文件;涉及仓库文件的读写或命令,请显式 \`cd ${repo}\` 后再执行。`
2119
+ : '当前初始工作目录是一个会话隔离目录;如需在某个仓库/目录下工作,请先显式 cd 过去。',
2120
+ `所有 botmux 命令请显式带 \`--session-id ${this.sessionId}\`(例:\`botmux send --session-id ${this.sessionId} ...\`),确保回话投递到本会话。`,
2121
+ ];
2122
+ return lines.join('\n');
2123
+ }
2124
+ buildEnv() {
2125
+ // Layering, lowest → highest precedence:
2126
+ // worker-supplied env (BOTMUX_* session context, redacted process env)
2127
+ // → per-bot injectEnv (bots.json `env`, already sanitized)
2128
+ // → bots.json `mojo.env`
2129
+ // Falling back to process.env keeps direct/unit use working when spawn()
2130
+ // was never called.
2131
+ // Shared with the launcher's wrapper resolution — see
2132
+ // buildEffectiveChildEnv. Do NOT re-inline this layering: the two sites
2133
+ // drifted apart once already and the launcher silently dropped mojo.env.
2134
+ const env = buildEffectiveChildEnv({
2135
+ base: this.spawnOpts?.env ?? process.env,
2136
+ botEnv: this.spawnOpts?.injectEnv,
2137
+ mojoEnv: this.config.env,
2138
+ });
2139
+ // Prefer an injected JWT so the bot never depends on an interactive
2140
+ // `mojo auth login` on the host. Verified: X_JWT_TOKEN makes
2141
+ // `mojo auth status --json` report mode=jwt / source=env.
2142
+ //
2143
+ // Read from the ALREADY-MERGED env, never from process.env: the daemon's
2144
+ // ambient X_JWT_TOKEN is the lowest layer of that merge, so reaching back
2145
+ // to process.env here would let the host's token override a per-bot one
2146
+ // and silently run the bot as the wrong identity.
2147
+ // `jwtEnv` decides only WHERE the value is read from; the child is always
2148
+ // handed it under the canonical name below. The remote-execution proof
2149
+ // relies on exactly this (mojoUnprovableEnvKeys exempts the canonical name
2150
+ // and never `jwtEnv`), so both sites share one constant rather than two
2151
+ // literals that could drift apart and silently re-open the bypass.
2152
+ const jwtKey = this.config.jwtEnv ?? MOJO_CANONICAL_JWT_ENV_KEY;
2153
+ if (this.liveJwt !== undefined) {
2154
+ // A live snapshot is authoritative and already includes the daemon's
2155
+ // ambient fallback. `null` therefore means "no credential anywhere", so
2156
+ // the inherited value must be REMOVED rather than left to stand in —
2157
+ // otherwise deleting `mojo.jwt` / `jwtEnv` revived the stale token.
2158
+ delete env[jwtKey];
2159
+ delete env[MOJO_CANONICAL_JWT_ENV_KEY];
2160
+ if (this.liveJwt !== null)
2161
+ env[MOJO_CANONICAL_JWT_ENV_KEY] = this.liveJwt;
2162
+ }
2163
+ else {
2164
+ const jwt = this.config.jwt ?? env[jwtKey];
2165
+ if (jwt)
2166
+ env[MOJO_CANONICAL_JWT_ENV_KEY] = jwt;
2167
+ }
2168
+ // ── Control plane: config is the ONLY source ──────────────────────────
2169
+ // Drop every inherited control-plane variable BEFORE re-deriving it. The
2170
+ // mojo CLI reads its endpoint/profile/execution mode from env, so leaving
2171
+ // an inherited value in place is a back door around the frozen identity:
2172
+ // a live `env: { AGENT_BASE_URL: <tenant-b> }` would move an existing
2173
+ // session to another tenant even though `baseUrl` itself is frozen. Note
2174
+ // these were previously only CONDITIONALLY overwritten (`if (baseUrl)`),
2175
+ // so a session whose frozen snapshot had no baseUrl silently inherited it.
2176
+ //
2177
+ // Unconditional delete also means "frozen as unset" is honoured: the CLI
2178
+ // falls back to its own default instead of a value the operator added
2179
+ // after this session was created.
2180
+ for (const key of MOJO_CONTROL_ENV_KEYS)
2181
+ delete env[key];
2182
+ if (this.config.baseUrl)
2183
+ env.AGENT_BASE_URL = this.config.baseUrl;
2184
+ if (this.config.ppeEnv)
2185
+ env.MOJO_PPE_ENV = this.config.ppeEnv;
2186
+ // Execution mode. Host execution is the DEFAULT, matching every other
2187
+ // CLI adapter (claude-code, codex, … all run on the bot host): a mojo
2188
+ // bot with no `mojo` block used to be forced into the cloud sandbox
2189
+ // (AGENT_LOCAL_DAEMON=0 without --cloud), where `botmux` does not exist
2190
+ // while the skill catalog still teaches `botmux send` — the session
2191
+ // could neither see the host nor reply through the current bot.
2192
+ // '0' is written exactly when the config itself asks for it:
2193
+ // · cloud=true with localDaemon unset — the fully-remote shape that
2194
+ // isMojoFullyRemote() accepts as proof of remote execution, or
2195
+ // · an explicit localDaemon=false — operator opt-out of host tools
2196
+ // (without cloud=true the CLI then falls back to its sandbox).
2197
+ // `=== true` / `=== false`, NOT truthy: this value must stay in strict
2198
+ // lockstep with isMojoFullyRemote(), which compares strictly. A truthy
2199
+ // check here made the string "false" mean "local execution ON" while the
2200
+ // sandbox check read it as "not local, safe to bypass" — isolation off and
2201
+ // host execution on at once. Always written (never inherited), so an
2202
+ // ambient AGENT_LOCAL_DAEMON cannot flip the mode either way.
2203
+ env.AGENT_LOCAL_DAEMON = deriveMojoExecutionMode(this.config).agentLocalDaemon;
2204
+ // Never let an interactive upgrade prompt pollute the NDJSON stream.
2205
+ env.MOJO_NO_UPDATE = '1';
2206
+ // Termination authority, not configuration: this value is what makes the
2207
+ // subtree enumerable in /proc after a descendant escapes the process group
2208
+ // via setsid. It carries no privilege and is safe to expose to the child.
2209
+ //
2210
+ // Asserted LAST, as a hard invariant of this function: not only can no
2211
+ // config layer shadow it, no DELETE above can erase it either. Config
2212
+ // validation already rejects a `jwtEnv` naming a reserved key, but frozen
2213
+ // snapshots written by older builds bypass re-validation, and a
2214
+ // `delete env[jwtKey]` that hit this name would blind scanMojoTree to
2215
+ // every escaped descendant while the close still reported clean.
2216
+ env[MOJO_TREE_NONCE_ENV] = this.treeNonce;
2217
+ return env;
2218
+ }
2219
+ /** Single-shot CLI call returning one JSON envelope (session.* subcommands). */
2220
+ async runCliJson(args) {
2221
+ const out = await this.runCli(args);
2222
+ // Startup notices can precede the envelope — take the last JSON line
2223
+ // rather than parsing the whole buffer.
2224
+ const line = out.split(/\r?\n/).map(l => l.trim())
2225
+ .filter(l => l.startsWith('{') && l.endsWith('}')).pop();
2226
+ if (!line)
2227
+ throw new Error(`no JSON envelope in output: ${out.slice(0, 300)}`);
2228
+ const env = JSON.parse(line);
2229
+ if (env.error)
2230
+ throw new Error(this.fmtErr(env.error));
2231
+ return env;
2232
+ }
2233
+ runCli(args) {
2234
+ return new Promise((resolve, reject) => {
2235
+ const launch = this.resolveLaunch(args);
2236
+ const child = spawnProcess(launch.bin, launch.args, {
2237
+ cwd: this.resolveCwd(),
2238
+ env: this.buildEnv(),
2239
+ stdio: ['ignore', 'pipe', 'pipe'],
2240
+ });
2241
+ let stdout = '';
2242
+ let stderr = '';
2243
+ const timer = setTimeout(() => {
2244
+ child.kill('SIGKILL');
2245
+ reject(new Error(`mojo ${args.join(' ')} timed out after ${this.cliTimeoutMs}ms`));
2246
+ }, this.cliTimeoutMs);
2247
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
2248
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
2249
+ child.on('error', (err) => { clearTimeout(timer); reject(err); });
2250
+ child.on('close', (code) => {
2251
+ clearTimeout(timer);
2252
+ if (code !== 0 && !stdout.trim()) {
2253
+ return reject(new Error(`mojo exited ${code}: ${stderr.trim() || '(no stderr)'}`));
2254
+ }
2255
+ resolve(stdout);
2256
+ });
2257
+ });
2258
+ }
2259
+ /** Probe the authoritative model list: an invalid --model exits 2 and prints
2260
+ * "可用模型:a、b、c" to stderr. */
2261
+ async probeModels() {
2262
+ try {
2263
+ await this.runCli(['-p', '--model', '__botmux_probe_invalid__', 'x']);
2264
+ return null;
2265
+ }
2266
+ catch (err) {
2267
+ const msg = err instanceof Error ? err.message : String(err ?? '');
2268
+ const m = /可用模型:(.+)$/m.exec(msg);
2269
+ return m ? m[1].split(/[、,]/).map(s => s.trim()).filter(Boolean) : null;
2270
+ }
2271
+ }
2272
+ /** `mojo auth status --json` → {logged_in, identity, mode, source, expires_at}. */
2273
+ async authStatus() {
2274
+ const out = await this.runCli(['auth', 'status', '--json']);
2275
+ const line = out.split(/\r?\n/).map(l => l.trim()).filter(l => l.startsWith('{')).pop();
2276
+ return line ? JSON.parse(line) : null;
2277
+ }
2278
+ emitLine(text, style = 'info') {
2279
+ const codes = {
2280
+ info: '\x1b[36m',
2281
+ warn: '\x1b[33m',
2282
+ ok: '\x1b[32m',
2283
+ err: '\x1b[31m',
2284
+ title: '\x1b[1m',
2285
+ plain: '',
2286
+ };
2287
+ const open = codes[style] ?? '';
2288
+ const close = open ? '\x1b[0m' : '';
2289
+ const line = `\r\n${open}${text}${close}\r\n`;
2290
+ this.outputBuffer += line;
2291
+ this.dataCb?.(line);
2292
+ }
2293
+ /** Normalize newlines for xterm rendering (bare \n → \r\n). */
2294
+ emitText(text) {
2295
+ const normalized = text.replace(/\r?\n/g, '\r\n');
2296
+ this.outputBuffer += normalized;
2297
+ // Keep the bridge copy in the CLI's own newline convention — it is
2298
+ // destined for a Lark message, not a terminal.
2299
+ this.turnFinalText += text;
2300
+ this.dataCb?.(normalized);
2301
+ }
2302
+ }
2303
+ /**
2304
+ * Classify a failed `session cancel` into the outcome model.
2305
+ *
2306
+ * Currently ALWAYS `failed`. Distinguishing "the session had already finished"
2307
+ * from "cancellation is broken" requires the real @byted/mojo error codes/states,
2308
+ * which are not calibrated yet — and guessing from stderr text is precisely the
2309
+ * mistake that made the old boolean ambiguous. Failing closed here means a close
2310
+ * refuses rather than silently claiming a still-running session is gone.
2311
+ *
2312
+ * When the codes ARE calibrated (needs intranet CLI + a real JWT), this is the one
2313
+ * place that changes: return `already_terminal` with the matched code as evidence.
2314
+ */
2315
+ function classifyMojoCancelFailure(err) {
2316
+ const message = err instanceof Error ? err.message : String(err);
2317
+ return { kind: 'failed', message, retryable: true };
2318
+ }
2319
+ /**
2320
+ * Cancel a mojo session by id WITHOUT a live backend instance.
2321
+ *
2322
+ * The daemon needs this on the workerless `/close` path: the worker is already
2323
+ * gone, so `MojoBackend.destroySession()` is unreachable, yet the server-side
2324
+ * session must stop consuming cloud sandbox time (and stop an agent that may
2325
+ * still hold injected credentials). Mirrors `cancelRiffTaskById`.
2326
+ *
2327
+ * One retry, then a STRUCTURED outcome — see MojoCancelOutcome for why this is no
2328
+ * longer a boolean.
2329
+ */
2330
+ export async function cancelMojoSessionById(config, sessionId) {
2331
+ // Reuse the instance's CLI plumbing (env layering, JSON envelope parsing,
2332
+ // timeout) rather than duplicating spawn logic here. The sentinel session id
2333
+ // is only used for logging.
2334
+ const backend = (() => {
2335
+ try {
2336
+ return new MojoBackend(config, 'orphan-cancel', { controlPlaneOnly: true });
2337
+ }
2338
+ catch {
2339
+ return null;
2340
+ }
2341
+ })();
2342
+ if (backend === null) {
2343
+ // The constructor fails closed when the containment store is unreadable,
2344
+ // which is correct — but this function DECLARES a structured outcome, and a
2345
+ // throw here does not stay local. Its call site in worker-pool is a
2346
+ // fire-and-forget `void cancelMojoSessionById(...).then(...)` with no
2347
+ // .catch(), and the daemon installs no unhandledRejection handler, so on
2348
+ // Node 22 an unreadable store would terminate the daemon and every session
2349
+ // it serves. Fail-closed must not mean fail-crash.
2350
+ //
2351
+ // Reporting `failed` keeps the intended semantics and is strictly stronger:
2352
+ // an unreadable store is not proof the remote session is gone, so the caller
2353
+ // refuses the close and the device-isolation blocker is retained.
2354
+ logger.error(`[mojo] cannot construct a cancel backend for session ${sessionId}: the containment `
2355
+ + 'store is unreadable, so the close is refused (the blocker is retained)');
2356
+ return {
2357
+ kind: 'failed',
2358
+ message: `containment store unreadable for session ${sessionId}`,
2359
+ retryable: true,
2360
+ };
2361
+ }
2362
+ const attempt = async () => {
2363
+ await backend['runCliJson'](['session', 'cancel', sessionId]);
2364
+ };
2365
+ // The LOCAL subtree first. "There is no worker" must never be read as "there is
2366
+ // no local process": the worker dying is exactly what orphans a credentialed
2367
+ // descendant. Cancelling the remote session says nothing about a local one, so
2368
+ // without this check the daemon publishes the row `closed`, the row drops out of
2369
+ // the device-isolation inventory, and the blocker disappears while a credentialed
2370
+ // process is still executing.
2371
+ const local = proveWorkerlessLocalSubtree(sessionId);
2372
+ if (local.unproven)
2373
+ return local.unproven;
2374
+ // A weak-only local proof must survive every remote-gone outcome, or the
2375
+ // caller publishes a plain `closed` row while the handle (and the blocker)
2376
+ // silently stays behind — the exact laundering this type exists to prevent.
2377
+ const carryResidual = (outcome) => local.residual !== null && outcome.kind !== 'failed'
2378
+ ? { ...outcome, localResidual: local.residual }
2379
+ : outcome;
2380
+ try {
2381
+ await attempt();
2382
+ return carryResidual({ kind: 'cancelled' });
2383
+ }
2384
+ catch {
2385
+ try {
2386
+ await attempt();
2387
+ return carryResidual({ kind: 'cancelled' });
2388
+ }
2389
+ catch (err) {
2390
+ const outcome = classifyMojoCancelFailure(err);
2391
+ logger.warn(`[mojo] orphan session cancel failed (session ${sessionId} may keep running remotely): ${String(err)}`);
2392
+ return carryResidual(outcome);
2393
+ }
2394
+ }
2395
+ }
2396
+ /**
2397
+ * Prove (and discharge) the LOCAL subtree a dead worker may have left behind.
2398
+ *
2399
+ * `unproven: null` means every outstanding handle was proven quiescent (or none
2400
+ * existed). `residual` then reports whether any of those proofs was weak-only,
2401
+ * in which case the handle stays in the store and the caller must surface a
2402
+ * residual close instead of a plain one.
2403
+ */
2404
+ export function proveWorkerlessLocalSubtree(sessionId, opts = {}) {
2405
+ const procRoot = opts.procRoot ?? '/proc';
2406
+ const excludePids = [process.pid, process.ppid].filter(pid => typeof pid === 'number' && pid > 0);
2407
+ let outstanding;
2408
+ try {
2409
+ outstanding = containmentHandles(sessionId);
2410
+ }
2411
+ catch (err) {
2412
+ // Unreadable store: we cannot know what is outstanding, so the close must not
2413
+ // succeed. Retryable, because a later read may well work.
2414
+ return {
2415
+ unproven: {
2416
+ kind: 'failed',
2417
+ message: `cannot read containment store for ${sessionId}: ${String(err)}`,
2418
+ retryable: true,
2419
+ },
2420
+ residual: null,
2421
+ };
2422
+ }
2423
+ let residual = null;
2424
+ for (const handle of outstanding) {
2425
+ const verdict = proveContainmentQuiescent(handle, {
2426
+ procRoot,
2427
+ scan: weak => {
2428
+ const scan = scanMojoTree(weak.rootPid, weak.nonce, {
2429
+ procRoot,
2430
+ excludePids,
2431
+ // Recorded identity gates PGID claiming, as on the teardown path.
2432
+ rootIdentity: { pid: weak.rootPid, bootId: weak.bootId, starttime: weak.startTime },
2433
+ });
2434
+ // `scanned: false` must not collapse into `pids: []`: that reads as
2435
+ // "nothing alive" and would hand back a proof we do not have.
2436
+ return scan.ok
2437
+ // Same zombie discount as the teardown path, so the workerless
2438
+ // close cannot disagree with it about the same tree.
2439
+ ? { scanned: true, pids: scan.members.filter(m => !m.zombie).map(m => m.pid) }
2440
+ : { scanned: false, pids: [], reason: scan.reason };
2441
+ },
2442
+ });
2443
+ if (!verdict.proven) {
2444
+ logger.error(`[mojo] workerless close for ${sessionId}: local subtree `
2445
+ + `${containmentHandleKey(handle)} could not be proven quiescent (${verdict.reason}); `
2446
+ + 'refusing to report the session closed so its device-isolation blocker is retained');
2447
+ return {
2448
+ unproven: {
2449
+ kind: 'failed',
2450
+ message: `local subtree unproven: ${verdict.reason}`,
2451
+ retryable: true,
2452
+ },
2453
+ residual: null,
2454
+ };
2455
+ }
2456
+ const decision = releaseContainmentHandle(verdict);
2457
+ if (decision.residual !== null) {
2458
+ // Weak evidence: the handle (and the blocker) stays. The close may
2459
+ // proceed, but only as a residual one — mirrors the live-worker
2460
+ // teardown's `boundaryProven === false` branch so the two paths can
2461
+ // never disagree about the same evidence grade.
2462
+ logger.warn(`[mojo] workerless close for ${sessionId}: local subtree `
2463
+ + `${containmentHandleKey(handle)} scanned clean but the credential boundary is NOT `
2464
+ + `proven (evidence ${decision.evidence}); the close will carry a residual marker `
2465
+ + 'and the device-isolation blocker is retained');
2466
+ residual = 'local_subtree_boundary_unproven';
2467
+ }
2468
+ }
2469
+ return { unproven: null, residual };
2470
+ }
2471
+ //# sourceMappingURL=mojo-backend.js.map