botmux 3.15.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 (780) 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/types.d.ts +101 -6
  45. package/dist/adapters/backend/types.d.ts.map +1 -1
  46. package/dist/adapters/backend/types.js.map +1 -1
  47. package/dist/adapters/backend/zmx-backend.d.ts +11 -0
  48. package/dist/adapters/backend/zmx-backend.d.ts.map +1 -1
  49. package/dist/adapters/backend/zmx-backend.js +16 -1
  50. package/dist/adapters/backend/zmx-backend.js.map +1 -1
  51. package/dist/adapters/cli/codex-app.d.ts.map +1 -1
  52. package/dist/adapters/cli/codex-app.js +2 -2
  53. package/dist/adapters/cli/codex-app.js.map +1 -1
  54. package/dist/adapters/cli/codex.d.ts.map +1 -1
  55. package/dist/adapters/cli/codex.js +7 -0
  56. package/dist/adapters/cli/codex.js.map +1 -1
  57. package/dist/adapters/cli/copilot.d.ts +3 -1
  58. package/dist/adapters/cli/copilot.d.ts.map +1 -1
  59. package/dist/adapters/cli/copilot.js +23 -5
  60. package/dist/adapters/cli/copilot.js.map +1 -1
  61. package/dist/adapters/cli/cursor.d.ts.map +1 -1
  62. package/dist/adapters/cli/cursor.js +13 -3
  63. package/dist/adapters/cli/cursor.js.map +1 -1
  64. package/dist/adapters/cli/grok.d.ts.map +1 -1
  65. package/dist/adapters/cli/grok.js +24 -5
  66. package/dist/adapters/cli/grok.js.map +1 -1
  67. package/dist/adapters/cli/kimi.d.ts.map +1 -1
  68. package/dist/adapters/cli/kimi.js +19 -1
  69. package/dist/adapters/cli/kimi.js.map +1 -1
  70. package/dist/adapters/cli/mojo.d.ts +28 -0
  71. package/dist/adapters/cli/mojo.d.ts.map +1 -0
  72. package/dist/adapters/cli/mojo.js +58 -0
  73. package/dist/adapters/cli/mojo.js.map +1 -0
  74. package/dist/adapters/cli/pi.d.ts +6 -4
  75. package/dist/adapters/cli/pi.d.ts.map +1 -1
  76. package/dist/adapters/cli/pi.js +6 -4
  77. package/dist/adapters/cli/pi.js.map +1 -1
  78. package/dist/adapters/cli/read-isolation.d.ts +251 -1
  79. package/dist/adapters/cli/read-isolation.d.ts.map +1 -1
  80. package/dist/adapters/cli/read-isolation.js +254 -1
  81. package/dist/adapters/cli/read-isolation.js.map +1 -1
  82. package/dist/adapters/cli/registry.d.ts +13 -1
  83. package/dist/adapters/cli/registry.d.ts.map +1 -1
  84. package/dist/adapters/cli/registry.js +19 -1
  85. package/dist/adapters/cli/registry.js.map +1 -1
  86. package/dist/adapters/cli/runner-input.d.ts +3 -3
  87. package/dist/adapters/cli/runner-input.d.ts.map +1 -1
  88. package/dist/adapters/cli/runner-input.js +4 -3
  89. package/dist/adapters/cli/runner-input.js.map +1 -1
  90. package/dist/adapters/cli/shared-hints.d.ts +3 -1
  91. package/dist/adapters/cli/shared-hints.d.ts.map +1 -1
  92. package/dist/adapters/cli/shared-hints.js +22 -6
  93. package/dist/adapters/cli/shared-hints.js.map +1 -1
  94. package/dist/adapters/cli/types.d.ts +29 -4
  95. package/dist/adapters/cli/types.d.ts.map +1 -1
  96. package/dist/bot-registry.d.ts +20 -0
  97. package/dist/bot-registry.d.ts.map +1 -1
  98. package/dist/bot-registry.js +30 -4
  99. package/dist/bot-registry.js.map +1 -1
  100. package/dist/cli/arg-utils.d.ts +5 -0
  101. package/dist/cli/arg-utils.d.ts.map +1 -1
  102. package/dist/cli/arg-utils.js +7 -0
  103. package/dist/cli/arg-utils.js.map +1 -1
  104. package/dist/cli/bots-list-output.d.ts +130 -2
  105. package/dist/cli/bots-list-output.d.ts.map +1 -1
  106. package/dist/cli/bots-list-output.js +173 -27
  107. package/dist/cli/bots-list-output.js.map +1 -1
  108. package/dist/cli/daemon-lifecycle-env.d.ts +10 -3
  109. package/dist/cli/daemon-lifecycle-env.d.ts.map +1 -1
  110. package/dist/cli/daemon-lifecycle-env.js +38 -11
  111. package/dist/cli/daemon-lifecycle-env.js.map +1 -1
  112. package/dist/cli/dashboard-command.d.ts +15 -0
  113. package/dist/cli/dashboard-command.d.ts.map +1 -1
  114. package/dist/cli/dashboard-command.js +22 -0
  115. package/dist/cli/dashboard-command.js.map +1 -1
  116. package/dist/cli/log-tail.d.ts +54 -0
  117. package/dist/cli/log-tail.d.ts.map +1 -0
  118. package/dist/cli/log-tail.js +167 -0
  119. package/dist/cli/log-tail.js.map +1 -0
  120. package/dist/cli/pm2-env.d.ts +16 -0
  121. package/dist/cli/pm2-env.d.ts.map +1 -0
  122. package/dist/cli/pm2-env.js +63 -0
  123. package/dist/cli/pm2-env.js.map +1 -0
  124. package/dist/cli/pm2-fleet-lock.d.ts +26 -0
  125. package/dist/cli/pm2-fleet-lock.d.ts.map +1 -0
  126. package/dist/cli/pm2-fleet-lock.js +50 -0
  127. package/dist/cli/pm2-fleet-lock.js.map +1 -0
  128. package/dist/cli/pm2-god-retirement.d.ts +59 -0
  129. package/dist/cli/pm2-god-retirement.d.ts.map +1 -0
  130. package/dist/cli/pm2-god-retirement.js +95 -0
  131. package/dist/cli/pm2-god-retirement.js.map +1 -0
  132. package/dist/cli/session-list-wake.d.ts +41 -0
  133. package/dist/cli/session-list-wake.d.ts.map +1 -0
  134. package/dist/cli/session-list-wake.js +102 -0
  135. package/dist/cli/session-list-wake.js.map +1 -0
  136. package/dist/cli/workflow.d.ts.map +1 -1
  137. package/dist/cli/workflow.js +26 -0
  138. package/dist/cli/workflow.js.map +1 -1
  139. package/dist/cli.d.ts.map +1 -1
  140. package/dist/cli.js +933 -115
  141. package/dist/cli.js.map +1 -1
  142. package/dist/codex-app-runner.js +261 -49
  143. package/dist/codex-app-runner.js.map +1 -1
  144. package/dist/core/cli-runtime-update.d.ts +8 -0
  145. package/dist/core/cli-runtime-update.d.ts.map +1 -1
  146. package/dist/core/cli-runtime-update.js +127 -8
  147. package/dist/core/cli-runtime-update.js.map +1 -1
  148. package/dist/core/close-residual.d.ts +64 -0
  149. package/dist/core/close-residual.d.ts.map +1 -0
  150. package/dist/core/close-residual.js +93 -0
  151. package/dist/core/close-residual.js.map +1 -0
  152. package/dist/core/closed-session-card.d.ts.map +1 -1
  153. package/dist/core/closed-session-card.js +11 -3
  154. package/dist/core/closed-session-card.js.map +1 -1
  155. package/dist/core/command-handler.d.ts.map +1 -1
  156. package/dist/core/command-handler.js +129 -27
  157. package/dist/core/command-handler.js.map +1 -1
  158. package/dist/core/cost-calculator.d.ts +3 -1
  159. package/dist/core/cost-calculator.d.ts.map +1 -1
  160. package/dist/core/cost-calculator.js +230 -1
  161. package/dist/core/cost-calculator.js.map +1 -1
  162. package/dist/core/daemon-background-close.d.ts +19 -0
  163. package/dist/core/daemon-background-close.d.ts.map +1 -0
  164. package/dist/core/daemon-background-close.js +39 -0
  165. package/dist/core/daemon-background-close.js.map +1 -0
  166. package/dist/core/dashboard-command/overview.d.ts +4 -0
  167. package/dist/core/dashboard-command/overview.d.ts.map +1 -1
  168. package/dist/core/dashboard-command/overview.js +8 -1
  169. package/dist/core/dashboard-command/overview.js.map +1 -1
  170. package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
  171. package/dist/core/dashboard-ipc-server.js +373 -41
  172. package/dist/core/dashboard-ipc-server.js.map +1 -1
  173. package/dist/core/dashboard-rows.d.ts +4 -0
  174. package/dist/core/dashboard-rows.d.ts.map +1 -1
  175. package/dist/core/dashboard-rows.js +3 -0
  176. package/dist/core/dashboard-rows.js.map +1 -1
  177. package/dist/core/dashboard-url.d.ts +37 -0
  178. package/dist/core/dashboard-url.d.ts.map +1 -1
  179. package/dist/core/dashboard-url.js +71 -0
  180. package/dist/core/dashboard-url.js.map +1 -1
  181. package/dist/core/deferred-schedule-settlement.d.ts +15 -1
  182. package/dist/core/deferred-schedule-settlement.d.ts.map +1 -1
  183. package/dist/core/deferred-schedule-settlement.js +4 -1
  184. package/dist/core/deferred-schedule-settlement.js.map +1 -1
  185. package/dist/core/device-isolation-daemon.d.ts +91 -1
  186. package/dist/core/device-isolation-daemon.d.ts.map +1 -1
  187. package/dist/core/device-isolation-daemon.js +346 -2
  188. package/dist/core/device-isolation-daemon.js.map +1 -1
  189. package/dist/core/local-terminal-opener.d.ts.map +1 -1
  190. package/dist/core/local-terminal-opener.js +5 -1
  191. package/dist/core/local-terminal-opener.js.map +1 -1
  192. package/dist/core/loopback-target.d.ts +14 -0
  193. package/dist/core/loopback-target.d.ts.map +1 -0
  194. package/dist/core/loopback-target.js +20 -0
  195. package/dist/core/loopback-target.js.map +1 -0
  196. package/dist/core/maintenance.d.ts.map +1 -1
  197. package/dist/core/maintenance.js +16 -2
  198. package/dist/core/maintenance.js.map +1 -1
  199. package/dist/core/mojo-containment-command.d.ts +20 -0
  200. package/dist/core/mojo-containment-command.d.ts.map +1 -0
  201. package/dist/core/mojo-containment-command.js +266 -0
  202. package/dist/core/mojo-containment-command.js.map +1 -0
  203. package/dist/core/mojo-containment.d.ts +575 -0
  204. package/dist/core/mojo-containment.d.ts.map +1 -0
  205. package/dist/core/mojo-containment.js +1158 -0
  206. package/dist/core/mojo-containment.js.map +1 -0
  207. package/dist/core/mojo-launcher-env-quarantine.d.ts +30 -0
  208. package/dist/core/mojo-launcher-env-quarantine.d.ts.map +1 -0
  209. package/dist/core/mojo-launcher-env-quarantine.js +175 -0
  210. package/dist/core/mojo-launcher-env-quarantine.js.map +1 -0
  211. package/dist/core/mojo-session-identity.d.ts +3 -0
  212. package/dist/core/mojo-session-identity.d.ts.map +1 -0
  213. package/dist/core/mojo-session-identity.js +126 -0
  214. package/dist/core/mojo-session-identity.js.map +1 -0
  215. package/dist/core/persistent-backend.d.ts +32 -8
  216. package/dist/core/persistent-backend.d.ts.map +1 -1
  217. package/dist/core/persistent-backend.js +55 -17
  218. package/dist/core/persistent-backend.js.map +1 -1
  219. package/dist/core/plugins/mcp/gateway.d.ts +14 -1
  220. package/dist/core/plugins/mcp/gateway.d.ts.map +1 -1
  221. package/dist/core/plugins/mcp/gateway.js +70 -3
  222. package/dist/core/plugins/mcp/gateway.js.map +1 -1
  223. package/dist/core/plugins/mcp/host.d.ts +2 -0
  224. package/dist/core/plugins/mcp/host.d.ts.map +1 -1
  225. package/dist/core/plugins/mcp/host.js +2 -0
  226. package/dist/core/plugins/mcp/host.js.map +1 -1
  227. package/dist/core/plugins/pm2.d.ts.map +1 -1
  228. package/dist/core/plugins/pm2.js +16 -3
  229. package/dist/core/plugins/pm2.js.map +1 -1
  230. package/dist/core/plugins/service-manager.d.ts +9 -0
  231. package/dist/core/plugins/service-manager.d.ts.map +1 -1
  232. package/dist/core/plugins/service-manager.js +33 -16
  233. package/dist/core/plugins/service-manager.js.map +1 -1
  234. package/dist/core/preview-port-owner.d.ts +109 -0
  235. package/dist/core/preview-port-owner.d.ts.map +1 -0
  236. package/dist/core/preview-port-owner.js +320 -0
  237. package/dist/core/preview-port-owner.js.map +1 -0
  238. package/dist/core/remote-cli-ids.d.ts +5 -0
  239. package/dist/core/remote-cli-ids.d.ts.map +1 -0
  240. package/dist/core/remote-cli-ids.js +24 -0
  241. package/dist/core/remote-cli-ids.js.map +1 -0
  242. package/dist/core/{riff-shutdown-detach.d.ts → remote-shutdown-detach.d.ts} +40 -39
  243. package/dist/core/remote-shutdown-detach.d.ts.map +1 -0
  244. package/dist/core/{riff-shutdown-detach.js → remote-shutdown-detach.js} +85 -83
  245. package/dist/core/remote-shutdown-detach.js.map +1 -0
  246. package/dist/core/{riff-worker-shutdown-readiness.d.ts → remote-worker-shutdown-readiness.d.ts} +5 -4
  247. package/dist/core/remote-worker-shutdown-readiness.d.ts.map +1 -0
  248. package/dist/core/{riff-worker-shutdown-readiness.js → remote-worker-shutdown-readiness.js} +4 -3
  249. package/dist/core/remote-worker-shutdown-readiness.js.map +1 -0
  250. package/dist/core/reply-target.d.ts +16 -1
  251. package/dist/core/reply-target.d.ts.map +1 -1
  252. package/dist/core/reply-target.js +61 -0
  253. package/dist/core/reply-target.js.map +1 -1
  254. package/dist/core/session-group-birth.d.ts +26 -0
  255. package/dist/core/session-group-birth.d.ts.map +1 -1
  256. package/dist/core/session-group-birth.js +81 -4
  257. package/dist/core/session-group-birth.js.map +1 -1
  258. package/dist/core/session-manager.d.ts +15 -2
  259. package/dist/core/session-manager.d.ts.map +1 -1
  260. package/dist/core/session-manager.js +259 -5
  261. package/dist/core/session-manager.js.map +1 -1
  262. package/dist/core/session-model.d.ts +46 -0
  263. package/dist/core/session-model.d.ts.map +1 -0
  264. package/dist/core/session-model.js +42 -0
  265. package/dist/core/session-model.js.map +1 -0
  266. package/dist/core/session-owner-reminder.d.ts +63 -0
  267. package/dist/core/session-owner-reminder.d.ts.map +1 -0
  268. package/dist/core/session-owner-reminder.js +196 -0
  269. package/dist/core/session-owner-reminder.js.map +1 -0
  270. package/dist/core/session-preview-registry.d.ts +53 -0
  271. package/dist/core/session-preview-registry.d.ts.map +1 -0
  272. package/dist/core/session-preview-registry.js +82 -0
  273. package/dist/core/session-preview-registry.js.map +1 -0
  274. package/dist/core/session-preview.d.ts +114 -0
  275. package/dist/core/session-preview.d.ts.map +1 -0
  276. package/dist/core/session-preview.js +184 -0
  277. package/dist/core/session-preview.js.map +1 -0
  278. package/dist/core/session-rename.d.ts.map +1 -1
  279. package/dist/core/session-rename.js +5 -3
  280. package/dist/core/session-rename.js.map +1 -1
  281. package/dist/core/session-wake-deadline.d.ts +5 -0
  282. package/dist/core/session-wake-deadline.d.ts.map +1 -0
  283. package/dist/core/session-wake-deadline.js +13 -0
  284. package/dist/core/session-wake-deadline.js.map +1 -0
  285. package/dist/core/shutdown-budgets.d.ts +7 -6
  286. package/dist/core/shutdown-budgets.d.ts.map +1 -1
  287. package/dist/core/shutdown-budgets.js +11 -10
  288. package/dist/core/shutdown-budgets.js.map +1 -1
  289. package/dist/core/terminal-control-grant.d.ts +74 -0
  290. package/dist/core/terminal-control-grant.d.ts.map +1 -0
  291. package/dist/core/terminal-control-grant.js +183 -0
  292. package/dist/core/terminal-control-grant.js.map +1 -0
  293. package/dist/core/terminal-proxy.d.ts.map +1 -1
  294. package/dist/core/terminal-proxy.js +4 -5
  295. package/dist/core/terminal-proxy.js.map +1 -1
  296. package/dist/core/terminal-write-auth.d.ts +4 -11
  297. package/dist/core/terminal-write-auth.d.ts.map +1 -1
  298. package/dist/core/terminal-write-auth.js +19 -16
  299. package/dist/core/terminal-write-auth.js.map +1 -1
  300. package/dist/core/trigger-session.d.ts.map +1 -1
  301. package/dist/core/trigger-session.js +36 -29
  302. package/dist/core/trigger-session.js.map +1 -1
  303. package/dist/core/types.d.ts +88 -10
  304. package/dist/core/types.d.ts.map +1 -1
  305. package/dist/core/types.js +9 -7
  306. package/dist/core/types.js.map +1 -1
  307. package/dist/core/workbench-link.d.ts +81 -0
  308. package/dist/core/workbench-link.d.ts.map +1 -0
  309. package/dist/core/workbench-link.js +109 -0
  310. package/dist/core/workbench-link.js.map +1 -0
  311. package/dist/core/worker-pool.d.ts +312 -11
  312. package/dist/core/worker-pool.d.ts.map +1 -1
  313. package/dist/core/worker-pool.js +2494 -209
  314. package/dist/core/worker-pool.js.map +1 -1
  315. package/dist/daemon.d.ts +6 -0
  316. package/dist/daemon.d.ts.map +1 -1
  317. package/dist/daemon.js +443 -106
  318. package/dist/daemon.js.map +1 -1
  319. package/dist/dashboard/aggregator.d.ts +33 -3
  320. package/dist/dashboard/aggregator.d.ts.map +1 -1
  321. package/dist/dashboard/aggregator.js +93 -17
  322. package/dist/dashboard/aggregator.js.map +1 -1
  323. package/dist/dashboard/auth-session-connections.d.ts +59 -0
  324. package/dist/dashboard/auth-session-connections.d.ts.map +1 -0
  325. package/dist/dashboard/auth-session-connections.js +109 -0
  326. package/dist/dashboard/auth-session-connections.js.map +1 -0
  327. package/dist/dashboard/auth.d.ts +99 -1
  328. package/dist/dashboard/auth.d.ts.map +1 -1
  329. package/dist/dashboard/auth.js +176 -64
  330. package/dist/dashboard/auth.js.map +1 -1
  331. package/dist/dashboard/bot-payload.d.ts +1 -0
  332. package/dist/dashboard/bot-payload.d.ts.map +1 -1
  333. package/dist/dashboard/bot-payload.js +3 -0
  334. package/dist/dashboard/bot-payload.js.map +1 -1
  335. package/dist/dashboard/control-audit.d.ts +112 -0
  336. package/dist/dashboard/control-audit.d.ts.map +1 -0
  337. package/dist/dashboard/control-audit.js +195 -0
  338. package/dist/dashboard/control-audit.js.map +1 -0
  339. package/dist/dashboard/control-csrf.d.ts +123 -0
  340. package/dist/dashboard/control-csrf.d.ts.map +1 -0
  341. package/dist/dashboard/control-csrf.js +392 -0
  342. package/dist/dashboard/control-csrf.js.map +1 -0
  343. package/dist/dashboard/daemon-reconcile.d.ts +26 -0
  344. package/dist/dashboard/daemon-reconcile.d.ts.map +1 -0
  345. package/dist/dashboard/daemon-reconcile.js +46 -0
  346. package/dist/dashboard/daemon-reconcile.js.map +1 -0
  347. package/dist/dashboard/debug-terminal.d.ts.map +1 -1
  348. package/dist/dashboard/debug-terminal.js +18 -4
  349. package/dist/dashboard/debug-terminal.js.map +1 -1
  350. package/dist/dashboard/events-sse.d.ts +60 -0
  351. package/dist/dashboard/events-sse.d.ts.map +1 -0
  352. package/dist/dashboard/events-sse.js +63 -0
  353. package/dist/dashboard/events-sse.js.map +1 -0
  354. package/dist/dashboard/h5-auth.d.ts +256 -0
  355. package/dist/dashboard/h5-auth.d.ts.map +1 -0
  356. package/dist/dashboard/h5-auth.js +825 -0
  357. package/dist/dashboard/h5-auth.js.map +1 -0
  358. package/dist/dashboard/managed-spawn.d.ts +36 -0
  359. package/dist/dashboard/managed-spawn.d.ts.map +1 -0
  360. package/dist/dashboard/managed-spawn.js +169 -0
  361. package/dist/dashboard/managed-spawn.js.map +1 -0
  362. package/dist/dashboard/preview-content-capability.d.ts +41 -0
  363. package/dist/dashboard/preview-content-capability.d.ts.map +1 -0
  364. package/dist/dashboard/preview-content-capability.js +127 -0
  365. package/dist/dashboard/preview-content-capability.js.map +1 -0
  366. package/dist/dashboard/preview-contract.d.ts +85 -0
  367. package/dist/dashboard/preview-contract.d.ts.map +1 -0
  368. package/dist/dashboard/preview-contract.js +198 -0
  369. package/dist/dashboard/preview-contract.js.map +1 -0
  370. package/dist/dashboard/preview-guard-page.d.ts +52 -0
  371. package/dist/dashboard/preview-guard-page.d.ts.map +1 -0
  372. package/dist/dashboard/preview-guard-page.js +162 -0
  373. package/dist/dashboard/preview-guard-page.js.map +1 -0
  374. package/dist/dashboard/preview-interaction.d.ts +57 -0
  375. package/dist/dashboard/preview-interaction.d.ts.map +1 -0
  376. package/dist/dashboard/preview-interaction.js +198 -0
  377. package/dist/dashboard/preview-interaction.js.map +1 -0
  378. package/dist/dashboard/preview-proxy.d.ts +113 -0
  379. package/dist/dashboard/preview-proxy.d.ts.map +1 -0
  380. package/dist/dashboard/preview-proxy.js +543 -0
  381. package/dist/dashboard/preview-proxy.js.map +1 -0
  382. package/dist/dashboard/public-redact.d.ts +23 -0
  383. package/dist/dashboard/public-redact.d.ts.map +1 -1
  384. package/dist/dashboard/public-redact.js +90 -21
  385. package/dist/dashboard/public-redact.js.map +1 -1
  386. package/dist/dashboard/request-identity.d.ts +77 -0
  387. package/dist/dashboard/request-identity.d.ts.map +1 -0
  388. package/dist/dashboard/request-identity.js +72 -0
  389. package/dist/dashboard/request-identity.js.map +1 -0
  390. package/dist/dashboard/session-cleanup.d.ts +5 -0
  391. package/dist/dashboard/session-cleanup.d.ts.map +1 -1
  392. package/dist/dashboard/session-cleanup.js +4 -0
  393. package/dist/dashboard/session-cleanup.js.map +1 -1
  394. package/dist/dashboard/settings-write-applier.d.ts +5 -1
  395. package/dist/dashboard/settings-write-applier.d.ts.map +1 -1
  396. package/dist/dashboard/settings-write-applier.js +14 -0
  397. package/dist/dashboard/settings-write-applier.js.map +1 -1
  398. package/dist/dashboard/standing-link.d.ts +76 -0
  399. package/dist/dashboard/standing-link.d.ts.map +1 -0
  400. package/dist/dashboard/standing-link.js +111 -0
  401. package/dist/dashboard/standing-link.js.map +1 -0
  402. package/dist/dashboard/terminal-control.d.ts +105 -0
  403. package/dist/dashboard/terminal-control.d.ts.map +1 -0
  404. package/dist/dashboard/terminal-control.js +296 -0
  405. package/dist/dashboard/terminal-control.js.map +1 -0
  406. package/dist/dashboard/terminal-front-proxy.d.ts +55 -0
  407. package/dist/dashboard/terminal-front-proxy.d.ts.map +1 -0
  408. package/dist/dashboard/terminal-front-proxy.js +397 -0
  409. package/dist/dashboard/terminal-front-proxy.js.map +1 -0
  410. package/dist/dashboard/terminal-view-capability.d.ts +97 -0
  411. package/dist/dashboard/terminal-view-capability.d.ts.map +1 -0
  412. package/dist/dashboard/terminal-view-capability.js +145 -0
  413. package/dist/dashboard/terminal-view-capability.js.map +1 -0
  414. package/dist/dashboard/web/agent-workbench-api.d.ts +59 -0
  415. package/dist/dashboard/web/agent-workbench-api.d.ts.map +1 -0
  416. package/dist/dashboard/web/agent-workbench-api.js +227 -0
  417. package/dist/dashboard/web/agent-workbench-api.js.map +1 -0
  418. package/dist/dashboard/web/agent-workbench-appearance-menu.d.ts +59 -0
  419. package/dist/dashboard/web/agent-workbench-appearance-menu.d.ts.map +1 -0
  420. package/dist/dashboard/web/agent-workbench-appearance-menu.js +195 -0
  421. package/dist/dashboard/web/agent-workbench-appearance-menu.js.map +1 -0
  422. package/dist/dashboard/web/agent-workbench-appearance.d.ts +222 -0
  423. package/dist/dashboard/web/agent-workbench-appearance.d.ts.map +1 -0
  424. package/dist/dashboard/web/agent-workbench-appearance.js +499 -0
  425. package/dist/dashboard/web/agent-workbench-appearance.js.map +1 -0
  426. package/dist/dashboard/web/agent-workbench-capabilities.d.ts +13 -0
  427. package/dist/dashboard/web/agent-workbench-capabilities.d.ts.map +1 -0
  428. package/dist/dashboard/web/agent-workbench-capabilities.js +24 -0
  429. package/dist/dashboard/web/agent-workbench-capabilities.js.map +1 -0
  430. package/dist/dashboard/web/agent-workbench-chat.d.ts +90 -0
  431. package/dist/dashboard/web/agent-workbench-chat.d.ts.map +1 -0
  432. package/dist/dashboard/web/agent-workbench-chat.js +168 -0
  433. package/dist/dashboard/web/agent-workbench-chat.js.map +1 -0
  434. package/dist/dashboard/web/agent-workbench-dock-page.d.ts +3 -0
  435. package/dist/dashboard/web/agent-workbench-dock-page.d.ts.map +1 -0
  436. package/dist/dashboard/web/agent-workbench-dock-page.js +23 -0
  437. package/dist/dashboard/web/agent-workbench-dock-page.js.map +1 -0
  438. package/dist/dashboard/web/agent-workbench-dock-view.d.ts +22 -0
  439. package/dist/dashboard/web/agent-workbench-dock-view.d.ts.map +1 -0
  440. package/dist/dashboard/web/agent-workbench-dock-view.js +76 -0
  441. package/dist/dashboard/web/agent-workbench-dock-view.js.map +1 -0
  442. package/dist/dashboard/web/agent-workbench-model.d.ts +196 -0
  443. package/dist/dashboard/web/agent-workbench-model.d.ts.map +1 -0
  444. package/dist/dashboard/web/agent-workbench-model.js +529 -0
  445. package/dist/dashboard/web/agent-workbench-model.js.map +1 -0
  446. package/dist/dashboard/web/agent-workbench-page.d.ts +3 -0
  447. package/dist/dashboard/web/agent-workbench-page.d.ts.map +1 -0
  448. package/dist/dashboard/web/agent-workbench-page.js +27 -0
  449. package/dist/dashboard/web/agent-workbench-page.js.map +1 -0
  450. package/dist/dashboard/web/agent-workbench-panes.d.ts +49 -0
  451. package/dist/dashboard/web/agent-workbench-panes.d.ts.map +1 -0
  452. package/dist/dashboard/web/agent-workbench-panes.js +468 -0
  453. package/dist/dashboard/web/agent-workbench-panes.js.map +1 -0
  454. package/dist/dashboard/web/agent-workbench-session-list.d.ts +42 -0
  455. package/dist/dashboard/web/agent-workbench-session-list.d.ts.map +1 -0
  456. package/dist/dashboard/web/agent-workbench-session-list.js +353 -0
  457. package/dist/dashboard/web/agent-workbench-session-list.js.map +1 -0
  458. package/dist/dashboard/web/agent-workbench-storage.d.ts +49 -0
  459. package/dist/dashboard/web/agent-workbench-storage.d.ts.map +1 -0
  460. package/dist/dashboard/web/agent-workbench-storage.js +197 -0
  461. package/dist/dashboard/web/agent-workbench-storage.js.map +1 -0
  462. package/dist/dashboard/web/agent-workbench-touch.d.ts +46 -0
  463. package/dist/dashboard/web/agent-workbench-touch.d.ts.map +1 -0
  464. package/dist/dashboard/web/agent-workbench-touch.js +96 -0
  465. package/dist/dashboard/web/agent-workbench-touch.js.map +1 -0
  466. package/dist/dashboard/web/agent-workbench-view.d.ts +34 -0
  467. package/dist/dashboard/web/agent-workbench-view.d.ts.map +1 -0
  468. package/dist/dashboard/web/agent-workbench-view.js +439 -0
  469. package/dist/dashboard/web/agent-workbench-view.js.map +1 -0
  470. package/dist/dashboard/web/app.d.ts.map +1 -1
  471. package/dist/dashboard/web/app.js +63 -2
  472. package/dist/dashboard/web/app.js.map +1 -1
  473. package/dist/dashboard/web/bot-defaults-page.d.ts +11 -0
  474. package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
  475. package/dist/dashboard/web/bot-defaults-page.js +267 -36
  476. package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
  477. package/dist/dashboard/web/bot-defaults.d.ts +6 -0
  478. package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
  479. package/dist/dashboard/web/bot-defaults.js.map +1 -1
  480. package/dist/dashboard/web/control-csrf.d.ts +4 -0
  481. package/dist/dashboard/web/control-csrf.d.ts.map +1 -0
  482. package/dist/dashboard/web/control-csrf.js +26 -0
  483. package/dist/dashboard/web/control-csrf.js.map +1 -0
  484. package/dist/dashboard/web/dashboard-routes.d.ts.map +1 -1
  485. package/dist/dashboard/web/dashboard-routes.js +2 -0
  486. package/dist/dashboard/web/dashboard-routes.js.map +1 -1
  487. package/dist/dashboard/web/groups-page.d.ts.map +1 -1
  488. package/dist/dashboard/web/groups-page.js +11 -2
  489. package/dist/dashboard/web/groups-page.js.map +1 -1
  490. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  491. package/dist/dashboard/web/i18n.js +132 -6
  492. package/dist/dashboard/web/i18n.js.map +1 -1
  493. package/dist/dashboard/web/overview-page.d.ts.map +1 -1
  494. package/dist/dashboard/web/overview-page.js +3 -2
  495. package/dist/dashboard/web/overview-page.js.map +1 -1
  496. package/dist/dashboard/web/sessions-page.d.ts.map +1 -1
  497. package/dist/dashboard/web/sessions-page.js +41 -1
  498. package/dist/dashboard/web/sessions-page.js.map +1 -1
  499. package/dist/dashboard/web/sessions.d.ts.map +1 -1
  500. package/dist/dashboard/web/sessions.js +5 -1
  501. package/dist/dashboard/web/sessions.js.map +1 -1
  502. package/dist/dashboard/web/settings-page.d.ts +3 -0
  503. package/dist/dashboard/web/settings-page.d.ts.map +1 -1
  504. package/dist/dashboard/web/settings-page.js +56 -10
  505. package/dist/dashboard/web/settings-page.js.map +1 -1
  506. package/dist/dashboard/web/store.d.ts +27 -2
  507. package/dist/dashboard/web/store.d.ts.map +1 -1
  508. package/dist/dashboard/web/store.js +43 -4
  509. package/dist/dashboard/web/store.js.map +1 -1
  510. package/dist/dashboard/web/ui.d.ts +3 -0
  511. package/dist/dashboard/web/ui.d.ts.map +1 -1
  512. package/dist/dashboard/web/ui.js +11 -0
  513. package/dist/dashboard/web/ui.js.map +1 -1
  514. package/dist/dashboard/web/update-action.d.ts +12 -0
  515. package/dist/dashboard/web/update-action.d.ts.map +1 -1
  516. package/dist/dashboard/web/update-action.js +11 -0
  517. package/dist/dashboard/web/update-action.js.map +1 -1
  518. package/dist/dashboard/workbench-doctor.d.ts +114 -0
  519. package/dist/dashboard/workbench-doctor.d.ts.map +1 -0
  520. package/dist/dashboard/workbench-doctor.js +620 -0
  521. package/dist/dashboard/workbench-doctor.js.map +1 -0
  522. package/dist/dashboard/workbench-ticket.d.ts +165 -0
  523. package/dist/dashboard/workbench-ticket.d.ts.map +1 -0
  524. package/dist/dashboard/workbench-ticket.js +495 -0
  525. package/dist/dashboard/workbench-ticket.js.map +1 -0
  526. package/dist/dashboard-web/app.js +1 -1
  527. package/dist/dashboard-web/chunks/agent-workbench-dock-page-IKAC5CFL.js +1 -0
  528. package/dist/dashboard-web/chunks/agent-workbench-page-M53UN5EB.js +1 -0
  529. package/dist/dashboard-web/chunks/bot-defaults-page-YKE5SDZF.js +10 -0
  530. package/dist/dashboard-web/chunks/chunk-22WIADUT.js +2 -0
  531. package/dist/dashboard-web/chunks/chunk-24ONYQMR.js +1 -0
  532. package/dist/dashboard-web/chunks/{chunk-Z7B3E3US.js → chunk-2W6SCN3M.js} +1 -1
  533. package/dist/dashboard-web/chunks/chunk-4XNJX6B3.js +1 -0
  534. package/dist/dashboard-web/chunks/{chunk-VMWNR3SV.js → chunk-F6KNPF4R.js} +11 -9
  535. package/dist/dashboard-web/chunks/chunk-FXW3V4JR.js +1 -0
  536. package/dist/dashboard-web/chunks/chunk-G5ZS2YUS.js +1 -0
  537. package/dist/dashboard-web/chunks/chunk-KTQ7B6ZR.js +1 -0
  538. package/dist/dashboard-web/chunks/{chunk-Z7DO42GJ.js → chunk-NACP7BMR.js} +1 -1
  539. package/dist/dashboard-web/chunks/{connectors-page-CPH6VVFY.js → connectors-page-VOISXXII.js} +1 -1
  540. package/dist/dashboard-web/chunks/{feedback-page-J2FKQ3LK.js → feedback-page-RN6HRZMS.js} +1 -1
  541. package/dist/dashboard-web/chunks/groups-page-UQT6DCB7.js +8 -0
  542. package/dist/dashboard-web/chunks/{insights-page-A2LWTKJD.js → insights-page-HJFDYR5N.js} +3 -3
  543. package/dist/dashboard-web/chunks/{monitor-room-2YR752E3.js → monitor-room-V7PCP47V.js} +1 -1
  544. package/dist/dashboard-web/chunks/{monitoring-page-BGYSKZGM.js → monitoring-page-NKEKVQAP.js} +1 -1
  545. package/dist/dashboard-web/chunks/{office-page-ZZHTEAE4.js → office-page-GVJF73XW.js} +1 -1
  546. package/dist/dashboard-web/chunks/overview-page-KHLV6VTI.js +1 -0
  547. package/dist/dashboard-web/chunks/{plugin-page-VEGTCEAS.js → plugin-page-J3OF5IY4.js} +1 -1
  548. package/dist/dashboard-web/chunks/{roles-page-YUC4YLJG.js → roles-page-WQIRSOLZ.js} +1 -1
  549. package/dist/dashboard-web/chunks/{schedules-page-2L2NKTDJ.js → schedules-page-J2DEY2X6.js} +1 -1
  550. package/dist/dashboard-web/chunks/sessions-page-7BQFEA5P.js +8 -0
  551. package/dist/dashboard-web/chunks/settings-page-BJEIOFJH.js +2 -0
  552. package/dist/dashboard-web/chunks/{skills-page-DQPBBI3T.js → skills-page-HGIFR3IU.js} +1 -1
  553. package/dist/dashboard-web/chunks/{team-federation-page-LQKLDV4K.js → team-federation-page-MR5ZPL4B.js} +1 -1
  554. package/dist/dashboard-web/chunks/{v3-page-RVKQIMAP.js → v3-page-GBR5JWPE.js} +2 -2
  555. package/dist/dashboard-web/chunks/{whiteboards-page-S5K55HRZ.js → whiteboards-page-QAZM4BN3.js} +1 -1
  556. package/dist/dashboard-web/index.html +8 -1
  557. package/dist/dashboard-web/style.css +2006 -10
  558. package/dist/dashboard.js +1006 -263
  559. package/dist/dashboard.js.map +1 -1
  560. package/dist/global-config.d.ts +30 -0
  561. package/dist/global-config.d.ts.map +1 -1
  562. package/dist/global-config.js +33 -0
  563. package/dist/global-config.js.map +1 -1
  564. package/dist/i18n/en.d.ts.map +1 -1
  565. package/dist/i18n/en.js +31 -1
  566. package/dist/i18n/en.js.map +1 -1
  567. package/dist/i18n/zh.d.ts.map +1 -1
  568. package/dist/i18n/zh.js +28 -1
  569. package/dist/i18n/zh.js.map +1 -1
  570. package/dist/im/lark/card-builder.d.ts +1 -1
  571. package/dist/im/lark/card-builder.d.ts.map +1 -1
  572. package/dist/im/lark/card-builder.js +22 -6
  573. package/dist/im/lark/card-builder.js.map +1 -1
  574. package/dist/im/lark/card-handler.d.ts.map +1 -1
  575. package/dist/im/lark/card-handler.js +118 -24
  576. package/dist/im/lark/card-handler.js.map +1 -1
  577. package/dist/im/lark/client.d.ts +15 -0
  578. package/dist/im/lark/client.d.ts.map +1 -1
  579. package/dist/im/lark/client.js +30 -0
  580. package/dist/im/lark/client.js.map +1 -1
  581. package/dist/im/lark/event-dispatcher.d.ts +48 -0
  582. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  583. package/dist/im/lark/event-dispatcher.js +133 -22
  584. package/dist/im/lark/event-dispatcher.js.map +1 -1
  585. package/dist/im/lark/identity-cache.d.ts +14 -6
  586. package/dist/im/lark/identity-cache.d.ts.map +1 -1
  587. package/dist/im/lark/identity-cache.js +69 -26
  588. package/dist/im/lark/identity-cache.js.map +1 -1
  589. package/dist/im/lark/lark-hosts.d.ts +13 -0
  590. package/dist/im/lark/lark-hosts.d.ts.map +1 -1
  591. package/dist/im/lark/lark-hosts.js +16 -0
  592. package/dist/im/lark/lark-hosts.js.map +1 -1
  593. package/dist/im/lark/overview-card.d.ts +22 -0
  594. package/dist/im/lark/overview-card.d.ts.map +1 -1
  595. package/dist/im/lark/overview-card.js +53 -3
  596. package/dist/im/lark/overview-card.js.map +1 -1
  597. package/dist/im/lark/sessions-card.d.ts.map +1 -1
  598. package/dist/im/lark/sessions-card.js +26 -3
  599. package/dist/im/lark/sessions-card.js.map +1 -1
  600. package/dist/im/lark/workflow-slash-command.d.ts +6 -0
  601. package/dist/im/lark/workflow-slash-command.d.ts.map +1 -1
  602. package/dist/im/lark/workflow-slash-command.js +7 -0
  603. package/dist/im/lark/workflow-slash-command.js.map +1 -1
  604. package/dist/index-daemon.js +27 -4
  605. package/dist/index-daemon.js.map +1 -1
  606. package/dist/index-dashboard.d.ts +3 -0
  607. package/dist/index-dashboard.d.ts.map +1 -0
  608. package/dist/index-dashboard.js +94 -0
  609. package/dist/index-dashboard.js.map +1 -0
  610. package/dist/platform/team-agents-client.d.ts +199 -0
  611. package/dist/platform/team-agents-client.d.ts.map +1 -0
  612. package/dist/platform/team-agents-client.js +263 -0
  613. package/dist/platform/team-agents-client.js.map +1 -0
  614. package/dist/platform/tunnel-client.d.ts +6 -0
  615. package/dist/platform/tunnel-client.d.ts.map +1 -1
  616. package/dist/platform/tunnel-client.js.map +1 -1
  617. package/dist/services/async-trigger-state.d.ts +7 -3
  618. package/dist/services/async-trigger-state.d.ts.map +1 -1
  619. package/dist/services/async-trigger-state.js +28 -0
  620. package/dist/services/async-trigger-state.js.map +1 -1
  621. package/dist/services/async-trigger-store.d.ts +13 -4
  622. package/dist/services/async-trigger-store.d.ts.map +1 -1
  623. package/dist/services/async-trigger-store.js +55 -8
  624. package/dist/services/async-trigger-store.js.map +1 -1
  625. package/dist/services/bot-config-store.d.ts +1 -11
  626. package/dist/services/bot-config-store.d.ts.map +1 -1
  627. package/dist/services/bot-config-store.js +27 -3
  628. package/dist/services/bot-config-store.js.map +1 -1
  629. package/dist/services/bot-profile-store.d.ts +17 -2
  630. package/dist/services/bot-profile-store.d.ts.map +1 -1
  631. package/dist/services/bot-profile-store.js +93 -12
  632. package/dist/services/bot-profile-store.js.map +1 -1
  633. package/dist/services/bridge-turn-queue.d.ts +10 -1
  634. package/dist/services/bridge-turn-queue.d.ts.map +1 -1
  635. package/dist/services/bridge-turn-queue.js +29 -9
  636. package/dist/services/bridge-turn-queue.js.map +1 -1
  637. package/dist/services/claude-transcript.d.ts +19 -0
  638. package/dist/services/claude-transcript.d.ts.map +1 -1
  639. package/dist/services/claude-transcript.js +57 -5
  640. package/dist/services/claude-transcript.js.map +1 -1
  641. package/dist/services/codex-app-threads.d.ts +2 -0
  642. package/dist/services/codex-app-threads.d.ts.map +1 -1
  643. package/dist/services/codex-app-threads.js +1 -1
  644. package/dist/services/codex-app-threads.js.map +1 -1
  645. package/dist/services/codex-reasoning-effort.d.ts +8 -0
  646. package/dist/services/codex-reasoning-effort.d.ts.map +1 -1
  647. package/dist/services/codex-reasoning-effort.js +24 -0
  648. package/dist/services/codex-reasoning-effort.js.map +1 -1
  649. package/dist/services/cursor-resume-policy.d.ts.map +1 -1
  650. package/dist/services/cursor-resume-policy.js +15 -3
  651. package/dist/services/cursor-resume-policy.js.map +1 -1
  652. package/dist/services/open-platform-rename.d.ts.map +1 -1
  653. package/dist/services/open-platform-rename.js +3 -2
  654. package/dist/services/open-platform-rename.js.map +1 -1
  655. package/dist/services/ordinary-turn-recovery.d.ts +76 -0
  656. package/dist/services/ordinary-turn-recovery.d.ts.map +1 -0
  657. package/dist/services/ordinary-turn-recovery.js +349 -0
  658. package/dist/services/ordinary-turn-recovery.js.map +1 -0
  659. package/dist/services/resume-fresh-policy.d.ts +17 -0
  660. package/dist/services/resume-fresh-policy.d.ts.map +1 -0
  661. package/dist/services/resume-fresh-policy.js +24 -0
  662. package/dist/services/resume-fresh-policy.js.map +1 -0
  663. package/dist/services/session-group-title.d.ts +1 -0
  664. package/dist/services/session-group-title.d.ts.map +1 -1
  665. package/dist/services/session-group-title.js +16 -1
  666. package/dist/services/session-group-title.js.map +1 -1
  667. package/dist/services/session-groups-store.d.ts +30 -0
  668. package/dist/services/session-groups-store.d.ts.map +1 -1
  669. package/dist/services/session-groups-store.js +26 -4
  670. package/dist/services/session-groups-store.js.map +1 -1
  671. package/dist/services/session-owner-notification.d.ts +9 -0
  672. package/dist/services/session-owner-notification.d.ts.map +1 -0
  673. package/dist/services/session-owner-notification.js +10 -0
  674. package/dist/services/session-owner-notification.js.map +1 -0
  675. package/dist/services/session-owner-reminder-config-store.d.ts +9 -0
  676. package/dist/services/session-owner-reminder-config-store.d.ts.map +1 -0
  677. package/dist/services/session-owner-reminder-config-store.js +24 -0
  678. package/dist/services/session-owner-reminder-config-store.js.map +1 -0
  679. package/dist/services/session-owner-reminder-store.d.ts +5 -0
  680. package/dist/services/session-owner-reminder-store.d.ts.map +1 -0
  681. package/dist/services/session-owner-reminder-store.js +54 -0
  682. package/dist/services/session-owner-reminder-store.js.map +1 -0
  683. package/dist/services/session-store.d.ts +98 -21
  684. package/dist/services/session-store.d.ts.map +1 -1
  685. package/dist/services/session-store.js +406 -48
  686. package/dist/services/session-store.js.map +1 -1
  687. package/dist/services/structured-bridge-clis.d.ts +9 -5
  688. package/dist/services/structured-bridge-clis.d.ts.map +1 -1
  689. package/dist/services/structured-bridge-clis.js +9 -4
  690. package/dist/services/structured-bridge-clis.js.map +1 -1
  691. package/dist/services/transcript-resolver.d.ts +1 -1
  692. package/dist/services/transcript-resolver.d.ts.map +1 -1
  693. package/dist/services/transcript-resolver.js +6 -1
  694. package/dist/services/transcript-resolver.js.map +1 -1
  695. package/dist/setup/bot-config-editor.d.ts.map +1 -1
  696. package/dist/setup/bot-config-editor.js +7 -2
  697. package/dist/setup/bot-config-editor.js.map +1 -1
  698. package/dist/setup/ensure-herdr-integrations.d.ts +13 -0
  699. package/dist/setup/ensure-herdr-integrations.d.ts.map +1 -1
  700. package/dist/setup/ensure-herdr-integrations.js +21 -3
  701. package/dist/setup/ensure-herdr-integrations.js.map +1 -1
  702. package/dist/setup/open-platform-automation.d.ts +1 -0
  703. package/dist/setup/open-platform-automation.d.ts.map +1 -1
  704. package/dist/setup/open-platform-automation.js +107 -7
  705. package/dist/setup/open-platform-automation.js.map +1 -1
  706. package/dist/skills/definitions.d.ts +17 -0
  707. package/dist/skills/definitions.d.ts.map +1 -1
  708. package/dist/skills/definitions.js +78 -8
  709. package/dist/skills/definitions.js.map +1 -1
  710. package/dist/skills/injection-mode.d.ts +24 -3
  711. package/dist/skills/injection-mode.d.ts.map +1 -1
  712. package/dist/skills/injection-mode.js +64 -15
  713. package/dist/skills/injection-mode.js.map +1 -1
  714. package/dist/skills/installer.d.ts +18 -0
  715. package/dist/skills/installer.d.ts.map +1 -1
  716. package/dist/skills/installer.js +45 -1
  717. package/dist/skills/installer.js.map +1 -1
  718. package/dist/types.d.ts +221 -10
  719. package/dist/types.d.ts.map +1 -1
  720. package/dist/utils/child-env.d.ts +92 -2
  721. package/dist/utils/child-env.d.ts.map +1 -1
  722. package/dist/utils/child-env.js +195 -1
  723. package/dist/utils/child-env.js.map +1 -1
  724. package/dist/utils/cli-usage-limit.d.ts +25 -14
  725. package/dist/utils/cli-usage-limit.d.ts.map +1 -1
  726. package/dist/utils/cli-usage-limit.js +25 -15
  727. package/dist/utils/cli-usage-limit.js.map +1 -1
  728. package/dist/utils/daemon-rejection-guard.d.ts +35 -0
  729. package/dist/utils/daemon-rejection-guard.d.ts.map +1 -0
  730. package/dist/utils/daemon-rejection-guard.js +101 -0
  731. package/dist/utils/daemon-rejection-guard.js.map +1 -0
  732. package/dist/utils/dashboard-env.d.ts +53 -0
  733. package/dist/utils/dashboard-env.d.ts.map +1 -0
  734. package/dist/utils/dashboard-env.js +116 -0
  735. package/dist/utils/dashboard-env.js.map +1 -0
  736. package/dist/utils/global-install.d.ts.map +1 -1
  737. package/dist/utils/global-install.js +61 -5
  738. package/dist/utils/global-install.js.map +1 -1
  739. package/dist/utils/install-diagnostics.d.ts +14 -7
  740. package/dist/utils/install-diagnostics.d.ts.map +1 -1
  741. package/dist/utils/install-diagnostics.js +20 -11
  742. package/dist/utils/install-diagnostics.js.map +1 -1
  743. package/dist/utils/local-dev-update.d.ts +132 -0
  744. package/dist/utils/local-dev-update.d.ts.map +1 -0
  745. package/dist/utils/local-dev-update.js +176 -0
  746. package/dist/utils/local-dev-update.js.map +1 -0
  747. package/dist/utils/pending-input-queue.d.ts +12 -1
  748. package/dist/utils/pending-input-queue.d.ts.map +1 -1
  749. package/dist/utils/pending-input-queue.js.map +1 -1
  750. package/dist/worker.js +1284 -225
  751. package/dist/worker.js.map +1 -1
  752. package/dist/workflows/definition.d.ts +68 -68
  753. package/dist/workflows/events/payloads.d.ts +14 -14
  754. package/dist/workflows/events/schema.d.ts +312 -312
  755. package/dist/workflows/migration/v2-run-archive-schema.d.ts +40 -40
  756. package/dist/workflows/v3/cli-run.d.ts.map +1 -1
  757. package/dist/workflows/v3/cli-run.js +7 -0
  758. package/dist/workflows/v3/cli-run.js.map +1 -1
  759. package/dist/workflows/v3/goal-cli.d.ts +3 -0
  760. package/dist/workflows/v3/goal-cli.d.ts.map +1 -1
  761. package/dist/workflows/v3/goal-cli.js +11 -0
  762. package/dist/workflows/v3/goal-cli.js.map +1 -1
  763. package/dist/workflows/v3/run-envelope.d.ts +144 -144
  764. package/package.json +3 -2
  765. package/dist/cli/pm2-god-admission.d.ts +0 -8
  766. package/dist/cli/pm2-god-admission.d.ts.map +0 -1
  767. package/dist/cli/pm2-god-admission.js +0 -25
  768. package/dist/cli/pm2-god-admission.js.map +0 -1
  769. package/dist/core/riff-shutdown-detach.d.ts.map +0 -1
  770. package/dist/core/riff-shutdown-detach.js.map +0 -1
  771. package/dist/core/riff-worker-shutdown-readiness.d.ts.map +0 -1
  772. package/dist/core/riff-worker-shutdown-readiness.js.map +0 -1
  773. package/dist/dashboard-web/chunks/bot-defaults-page-65SMZ4QY.js +0 -10
  774. package/dist/dashboard-web/chunks/chunk-G5XGBCM6.js +0 -1
  775. package/dist/dashboard-web/chunks/chunk-IKN6WURK.js +0 -1
  776. package/dist/dashboard-web/chunks/chunk-WLIVBPJC.js +0 -1
  777. package/dist/dashboard-web/chunks/groups-page-VDOOLS4M.js +0 -8
  778. package/dist/dashboard-web/chunks/overview-page-7NYPKQSB.js +0 -1
  779. package/dist/dashboard-web/chunks/sessions-page-DYZZMKFL.js +0 -8
  780. package/dist/dashboard-web/chunks/settings-page-LIULUSMH.js +0 -2
package/dist/worker.js CHANGED
@@ -13,15 +13,16 @@
13
13
  * 7. On 'restart', kills CLI and re-spawns with --resume
14
14
  */
15
15
  import { createHash, randomBytes } from 'node:crypto';
16
- import { chmodSync, mkdirSync, writeFileSync, unlinkSync, rmdirSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream } from 'node:fs';
16
+ import { accessSync, chmodSync, mkdirSync, writeFileSync, unlinkSync, rmdirSync, existsSync, statSync, lstatSync, readdirSync, readlinkSync, readFileSync, realpathSync, copyFileSync, watch as fsWatch, createWriteStream, constants as fsConstants } from 'node:fs';
17
17
  import { atomicWriteFileSync } from './utils/atomic-write.js';
18
- import { join, basename, dirname } from 'node:path';
18
+ import { join, basename, dirname, delimiter } from 'node:path';
19
19
  import { resolveBotmuxWrapperBinDir, prependBotmuxBin } from './core/botmux-wrapper.js';
20
20
  import { homedir, tmpdir, userInfo } from 'node:os';
21
21
  import { spawnSync } from 'node:child_process';
22
- import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneOriginChannel, isolatedPaneReattachSafe, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, isolationPanePolicyDigest, } from './adapters/cli/read-isolation.js';
22
+ import { evaluateCredentialOnlyIsolationGate, credentialIsolationRequired, deviceCredentialIsolationMarkerPath, isCredentialIsolationReservedBasename, buildCredentialIsolationRules, buildSeatbeltProfile, isolatedPaneOriginChannel, isolatedPaneReattachSafe, evaluatePersistentPaneMigration, executePersistentPaneMigration, persistentTeardownKillKind, isolationPaneMarkerPath, policyOffTombstonePath, policyOffTombstoneContent, policyOffTombstoneValid, provenancePendingContent, provenancePendingNonce, sendCredFilePath, botHomePath, buildCliExecutableReadCarveOuts, isolationPaneMarkerContent, isolationPanePolicyDigest, } from './adapters/cli/read-isolation.js';
23
23
  import { buildFsPolicy, compileToSeatbelt, migrateLegacySandboxFields, resolveRedirectedAdapterAuthPaths, FsPolicyConfigError } from './adapters/cli/fs-policy.js';
24
- import { killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, shouldRejectPersistentPostKillProbe } from './core/persistent-backend.js';
24
+ import { buildCloseResultMessage, normalizeDestroyResult, mayRestoreWriteAdmission, interpretAbortOutcome, classifyRestartTeardown } from './adapters/backend/destroy-result.js';
25
+ import { isRemoteBackendType, killPersistentBackendTarget, killPersistentSession, probePersistentBackendTarget, probePersistentSession, shouldRejectPersistentPostKillProbe } from './core/persistent-backend.js';
25
26
  import { finalizeRawCommandDelivery, writeRawCommandLine } from './core/raw-command-writer.js';
26
27
  import { rawCommandWriteOptionsFor } from './core/raw-command-write-options.js';
27
28
  import { publishCliSessionIdToDaemon } from './core/cli-session-id-publisher.js';
@@ -41,7 +42,7 @@ import { CodexUpdateDialogGuard } from './utils/codex-update-dialog.js';
41
42
  import { EffortConfirmDialogGuard } from './utils/effort-confirm-dialog.js';
42
43
  import { installStdioEpipeGuard, isIgnorableStreamError } from './utils/stdio-epipe-guard.js';
43
44
  import { handoffQueuedDurableInputsOnBackendExit, mergeQueuedCliInput, pendingInputMayFlush, pendingInputAllowsTypeAhead, resolveInitialPromptDelivery, shouldArmSpawnArgvInitialPromptBusy, shouldTrackArgvBakedFirstPrompt, shouldDeferArgsBakedDurablePrompt, shouldDeferInitialPromptForArgLimit, shouldStopPendingBatch, terminalReleasesDurableTurn, } from './utils/pending-input-queue.js';
44
- import { riffWorkerShutdownInputBlocker } from './core/riff-worker-shutdown-readiness.js';
45
+ import { remoteWorkerShutdownInputBlocker } from './core/remote-worker-shutdown-readiness.js';
45
46
  import { ReadyGate, shouldArmReadyGate } from './utils/ready-gate.js';
46
47
  import { shouldRunStartupCommandsOnSpawn, shouldDeferInitialPromptForStartup } from './core/startup-commands.js';
47
48
  import { sanitizePerBotEnv } from './core/per-bot-env.js';
@@ -54,8 +55,10 @@ import { MCP_GATEWAY_REQUIRED_ENV, MCP_GATEWAY_SOCKET_ENV, } from './core/plugin
54
55
  import { readSessionMcpRuntimeManifest, sessionMcpRuntimeHostOnlyPaths, } from './core/plugins/mcp/session-runtime.js';
55
56
  import { prepareCliPluginGeneration } from './core/plugins/cli-generation.js';
56
57
  import { loadBotConfigs, resolveBrandLabel, resolveUsageDisplay, } from './bot-registry.js';
57
- import { readGlobalConfig } from './global-config.js';
58
- import { deriveTerminalViewToken, deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
58
+ import { readGlobalConfig, isWorkflowFeatureEnabled } from './global-config.js';
59
+ import { deriveTerminalWriteToken, resolveTerminalAccessForRequest, safeTerminalTokenEqual, } from './core/terminal-write-auth.js';
60
+ import { deriveWorkerViewGeneration, looksLikeTerminalControlGrant, TERMINAL_VIEW_FORWARD_HEADER, verifyTerminalControlGrant, verifyTerminalViewForward, } from './core/terminal-control-grant.js';
61
+ import { appendControlAudit, controlAuditRecord } from './dashboard/control-audit.js';
59
62
  import { readPlatformBinding } from './platform/binding.js';
60
63
  import { loadDashboardSecret, loadPersistedToken } from './dashboard/auth.js';
61
64
  import { InflightInputTracker } from './core/inflight-input-tracker.js';
@@ -115,6 +118,9 @@ import { AmbiguousSubmissionBlockedError, isObserveBackend, } from './adapters/b
115
118
  import { backendGateUserMessage, backendSandboxCompatibilityError, backendSandboxCompatibilityUserMessage, decideBackendGate, retireSupersededRecordedHerdrTarget, selectSessionBackend, } from './adapters/backend/session-backend-selector.js';
116
119
  import { buildReproduceCommand, selectReproduceLaunch } from './adapters/backend/reproduce-command.js';
117
120
  import { deriveRiffReposFromDirs, deriveRiffRepoFromWorkingDir, isValidRiffBaseUrl, isValidRiffSandboxCluster, } from './adapters/backend/riff-backend.js';
121
+ import { buildEffectiveChildEnv, buildEffectiveMojoConfig, findReservedMojoCliFlags, normalizeMojoConfig, normalizeMojoLivePatch } from './adapters/backend/mojo-types.js';
122
+ import { builtinSkillBlockForInjectsSessionContext } from './skills/injection-mode.js';
123
+ import { whiteboardEnabled } from './services/whiteboard-store.js';
118
124
  import { prepareDirectSandbox, prepareCredentialOnlySandbox, credentialOnlySandboxAvailable, probeHostCredentialIsolationMechanism, attachSandboxOutbox, startOutboxWatcher, sandboxEnabled, localSandboxApplies, } from './adapters/backend/sandbox.js';
119
125
  import { DEVICE_AUTHORITY_DIRECTORY, DEVICE_CREDENTIAL_FILE, } from './platform/device-paths.js';
120
126
  import { tmuxEnv, probeTmuxFunctionalWithRetry } from './setup/ensure-tmux.js';
@@ -1169,6 +1175,7 @@ async function prepareCliPluginGenerationAndGateway(cfg, adapter) {
1169
1175
  sessionMcpGatewayHost = await startSessionMcpGatewayHost({
1170
1176
  sessionId: cfg.sessionId,
1171
1177
  dataDir: config.session.dataDir,
1178
+ trustedTurnIdentity: currentGatewayTrustedTurnIdentity,
1172
1179
  onError: error => log(`[mcp-gateway] host error: ${error.message}`),
1173
1180
  });
1174
1181
  log(`[mcp-gateway] trusted host listening for ${manifest.entries.length} plugin server(s)`);
@@ -1449,7 +1456,7 @@ function backendScreenEvidenceIsAuthoritativeForMutation() {
1449
1456
  * generation. The daemon receives this over private IPC; child-writable PID
1450
1457
  * marker files remain diagnostics only. */
1451
1458
  let currentCliCredentialIsolated = false;
1452
- /** Successful Riff close prepare awaiting durable daemon commit. */
1459
+ /** Successful remote close prepare awaiting durable daemon commit. */
1453
1460
  let preparedCloseRequestId = null;
1454
1461
  let closeRequestInFlightId = null;
1455
1462
  let lastAbortedCloseRequestId = null;
@@ -1478,8 +1485,14 @@ const readOnlyRemoteScrollLimiter = new ReadOnlyRemoteScrollLimiter({
1478
1485
  // daemon restart re-forks every worker — a per-process random token would 403
1479
1486
  // every previously-issued operate link).
1480
1487
  let writeToken = randomBytes(16).toString('hex');
1481
- // Standalone/test fallback. Production replaces this after init with a stable
1482
- // per-session HMAC derived from the host-only dashboard secret.
1488
+ // Per-BOOT random read capability, reported to the daemon in `ready` and
1489
+ // embedded in Feishu card 「打开 Web 终端」 links. Deliberately NOT the stable
1490
+ // per-session HMAC any more (P1-5): a stable view token could never be revoked
1491
+ // — a viewer who fetched it once kept terminal read access forever, across
1492
+ // worker restarts included. Per-boot randomness bounds every card link to this
1493
+ // worker generation (restart ⇒ all previously issued view tokens die), and the
1494
+ // dashboard view-link API mints its own short-lived signed read grants instead
1495
+ // of ever handing this value out (see resolveTerminalAccessForReq).
1483
1496
  let viewToken = randomBytes(32).toString('base64url');
1484
1497
  // Active dashboard token, persisted by the dashboard process at this stable
1485
1498
  // path (mirrors dashboard.ts TOKEN_PATH). The platform proxy injects it as the
@@ -1487,10 +1500,30 @@ let viewToken = randomBytes(32).toString('base64url');
1487
1500
  // proves a request traversed the platform's authenticated front door.
1488
1501
  const DASHBOARD_TOKEN_PATH = join(homedir(), '.botmux', '.dashboard-token');
1489
1502
  const DASHBOARD_SECRET_PATH = join(homedir(), '.botmux', '.dashboard-secret');
1490
- function refreshTerminalViewToken() {
1503
+ // Test-only seam (inert in production): widen the SYNCHRONOUS `.dashboard-secret`
1504
+ // read that happens twice inside the terminal WS handshake — once at
1505
+ // `verifyClient`, once at the post-upgrade `connection` re-check — so the
1506
+ // integration test can land a capability's expiry inside that gap and prove the
1507
+ // second check still fail-closes it (see worker-terminal-read-auth P1-3). A real
1508
+ // slow HOME (NFS/slow disk) produces the same window in production. The old test
1509
+ // bloated the secret file with 32MB of whitespace to force this delay, which is
1510
+ // incompatible with #920's strict 0600 host-authority reader (its 256-byte cap
1511
+ // rejects a padded file); this env-gated busy-wait reproduces the timing without
1512
+ // an oversized or otherwise unsafe credential file. Only ever set by that test.
1513
+ const HANDSHAKE_SECRET_READ_DELAY_MS = Number.isFinite(Number(process.env.BOTMUX_TEST_TERMINAL_SECRET_READ_DELAY_MS))
1514
+ ? Math.max(0, Number(process.env.BOTMUX_TEST_TERMINAL_SECRET_READ_DELAY_MS))
1515
+ : 0;
1516
+ /** Read the dashboard secret on the terminal WS-handshake path. Identical to
1517
+ * {@link loadDashboardSecret} in production; adds a bounded synchronous delay
1518
+ * ONLY when the test seam env var is set, to make the handshake read window
1519
+ * observable without an oversized secret file. */
1520
+ function loadHandshakeSecret() {
1491
1521
  const secret = loadDashboardSecret(DASHBOARD_SECRET_PATH);
1492
- if (secret && sessionId)
1493
- viewToken = deriveTerminalViewToken(secret, sessionId);
1522
+ if (HANDSHAKE_SECRET_READ_DELAY_MS > 0) {
1523
+ const until = Date.now() + HANDSHAKE_SECRET_READ_DELAY_MS;
1524
+ while (Date.now() < until) { /* busy-wait: mimic a slow synchronous fs read */ }
1525
+ }
1526
+ return secret;
1494
1527
  }
1495
1528
  /** Re-derive the stable write (operate) token from the host-only dashboard
1496
1529
  * secret so a restarted worker mints the SAME token — keeping already-issued
@@ -1501,22 +1534,86 @@ function refreshTerminalWriteToken() {
1501
1534
  if (secret && sessionId)
1502
1535
  writeToken = deriveTerminalWriteToken(secret, sessionId);
1503
1536
  }
1504
- /**
1505
- * Resolve terminal write permission for one request. The platform-injected
1506
- * `X-Botmux-Role` header is trusted only when this machine is bound to a central
1507
- * platform AND the request actually came through the platform proxy (proven by a
1508
- * matching `botmux_dashboard_token` cookie — a secret a direct caller lacks).
1509
- * A matching private write-link `?token=` is an independent capability that
1510
- * grants write regardless of platform role (an explicitly issued write link
1511
- * must work for a viewer the platform sees as guest). See
1512
- * ./core/terminal-write-auth for the full rationale.
1513
- *
1514
- * Both the binding and the token are read PER REQUEST, never snapshotted:
1515
- * `botmux bind`/unbind and dashboard token rotation are hot-reloaded without
1516
- * restarting this worker, so a cached value would go stale.
1517
- */
1518
1537
  function resolveTerminalAccessForReq(req, url) {
1519
- return resolveTerminalAccessForRequest(req.headers, safeTerminalTokenEqual(url.searchParams.get('token'), writeToken), safeTerminalTokenEqual(url.searchParams.get('viewToken'), viewToken), () => readPlatformBinding() !== null, () => loadPersistedToken(DASHBOARD_TOKEN_PATH));
1538
+ // P1-6 invariant: an explicit, matching write-link `?token=` is an
1539
+ // independent capability with the HIGHEST priority. It wins outright — before
1540
+ // the internal control-grant header is even looked at — so a central proxy
1541
+ // that attaches a read-scope grant can never silently downgrade an explicitly
1542
+ // issued 「操作链接」 to read-only for a viewer the platform authenticated as
1543
+ // teammate/guest. (The front proxy additionally refrains from injecting a
1544
+ // grant next to explicit query capabilities; this ordering makes the
1545
+ // guarantee hold regardless of proxy behavior, not by accident of it.)
1546
+ if (safeTerminalTokenEqual(url.searchParams.get('token'), writeToken)) {
1547
+ return { hasRead: true, hasWrite: true, platformReadonly: false };
1548
+ }
1549
+ // `?viewToken=` read capability, two accepted forms (P1-5):
1550
+ // • this worker's per-boot random token (Feishu card links) — plain
1551
+ // equality; dies with the worker generation;
1552
+ // • a short-lived signed read grant minted by the dashboard view-link API.
1553
+ // The retired stable per-session HMAC matches neither form, so every
1554
+ // previously issued stable view token fails closed on this worker.
1555
+ //
1556
+ // The signed form is accepted ONLY when all four hold, because this worker
1557
+ // has no view of dashboard logout state and a URL is trivially copied:
1558
+ // 1. signature + expiry + session binding verify (stateless base check);
1559
+ // 2. scope is `read` — a leaked write-scope loopback grant must never
1560
+ // double as a browser-shareable capability;
1561
+ // 3. audience is `central` AND the central front proxy countersigned THIS
1562
+ // capability (X-Botmux-Terminal-View). That header is computed from the
1563
+ // host-only dashboard secret and is only emitted after the proxy checked
1564
+ // the capability's auth session is still live, so a raw view URL replayed
1565
+ // against this port — or against the daemon's own network-bound `/s/`
1566
+ // reverse proxy — cannot produce it and dies here;
1567
+ // 4. the pinned worker generation matches this boot. A restart re-randomizes
1568
+ // `viewToken`, so grants minted for the previous boot fail closed even
1569
+ // though `.dashboard-secret` is unchanged.
1570
+ // The WebSocket is additionally closed at the grant's expiresAt.
1571
+ const viewParam = url.searchParams.get('viewToken');
1572
+ let viewTokenMatches = safeTerminalTokenEqual(viewParam, viewToken);
1573
+ let viewGrantUser;
1574
+ let viewGrantExpiresAt;
1575
+ if (!viewTokenMatches && looksLikeTerminalControlGrant(viewParam) && sessionId) {
1576
+ const secret = loadHandshakeSecret();
1577
+ if (secret && verifyTerminalViewForward(secret, viewParam, req.headers[TERMINAL_VIEW_FORWARD_HEADER])) {
1578
+ const viewGrant = verifyTerminalControlGrant(secret, viewParam, sessionId);
1579
+ if (viewGrant.ok
1580
+ && viewGrant.claims.scope === 'read'
1581
+ && viewGrant.claims.audience === 'central'
1582
+ && viewGrant.claims.workerGeneration === deriveWorkerViewGeneration(secret, viewToken)) {
1583
+ viewTokenMatches = true;
1584
+ viewGrantUser = viewGrant.claims.userId;
1585
+ viewGrantExpiresAt = viewGrant.claims.expiresAt;
1586
+ }
1587
+ }
1588
+ }
1589
+ const legacy = resolveTerminalAccessForRequest(req.headers, false, viewTokenMatches, () => readPlatformBinding() !== null, () => loadPersistedToken(DASHBOARD_TOKEN_PATH));
1590
+ if (viewGrantExpiresAt !== undefined && legacy.hasRead && !legacy.hasWrite) {
1591
+ legacy.auditUser = viewGrantUser;
1592
+ legacy.controlExpiresAt = viewGrantExpiresAt;
1593
+ }
1594
+ // Most terminal HTTP/WS requests use the query capabilities above. Avoid a
1595
+ // second synchronous secret-file read on that hot path; only the central
1596
+ // front proxy supplies this internal header.
1597
+ if (req.headers['x-botmux-terminal-control'] === undefined)
1598
+ return legacy;
1599
+ const secret = loadHandshakeSecret();
1600
+ if (!secret || !sessionId)
1601
+ return legacy;
1602
+ const grant = verifyTerminalControlGrant(secret, req.headers['x-botmux-terminal-control'], sessionId);
1603
+ if (!grant.ok)
1604
+ return legacy;
1605
+ return {
1606
+ hasRead: true,
1607
+ hasWrite: grant.claims.scope === 'write',
1608
+ platformReadonly: grant.claims.scope === 'read',
1609
+ auditUser: grant.claims.userId,
1610
+ controlExpiresAt: grant.claims.expiresAt,
1611
+ };
1612
+ }
1613
+ function auditTerminalInput(user, data) {
1614
+ appendControlAudit(controlAuditRecord(user ?? 'legacy-capability', sessionId, 'terminal.input', {
1615
+ bytes: Buffer.byteLength(data),
1616
+ }));
1520
1617
  }
1521
1618
  /** Lazily-written locked-mode zellij config for per-WS web-terminal attach
1522
1619
  * clients: cleared keybinds + locked mode so every keystroke passes straight
@@ -1544,7 +1641,7 @@ let closeRequested = false;
1544
1641
  let capturedSpawnCommand = null;
1545
1642
  let deferredTopicOutputTail = '';
1546
1643
  const reportedDeferredTopicRoots = new Set();
1547
- const CLI_DISPLAY_NAMES = { 'claude-code': 'Claude', seed: 'Seed', relay: 'Relay', aiden: 'Aiden', coco: 'CoCo', codex: 'Codex', 'codex-app': 'Codex App', cursor: 'Cursor', gemini: 'Gemini', genius: 'Genius', opencode: 'OpenCode', opencode2: 'OpenCode 2', antigravity: 'Antigravity', mtr: 'MTR', hermes: 'Hermes', mira: 'Mira', mir: 'Mir CLI', traex: 'TRAE', pi: 'Pi', copilot: 'Copilot', 'oh-my-pi': 'Oh My Pi', kimi: 'Kimi', grok: 'Grok Build', 'kiro-cli': 'Kiro', riff: 'Riff', reasonix: 'Reasonix', dsh: 'DeepSeek Harness' };
1644
+ const CLI_DISPLAY_NAMES = { 'claude-code': 'Claude', seed: 'Seed', relay: 'Relay', aiden: 'Aiden', coco: 'CoCo', codex: 'Codex', 'codex-app': 'Codex App', cursor: 'Cursor', gemini: 'Gemini', genius: 'Genius', opencode: 'OpenCode', opencode2: 'OpenCode 2', antigravity: 'Antigravity', mtr: 'MTR', hermes: 'Hermes', mira: 'Mira', mir: 'Mir CLI', traex: 'TRAE', pi: 'Pi', copilot: 'Copilot', 'oh-my-pi': 'Oh My Pi', kimi: 'Kimi', grok: 'Grok Build', 'kiro-cli': 'Kiro', riff: 'Riff', reasonix: 'Reasonix', dsh: 'DeepSeek Harness', mojo: 'Mojo' };
1548
1645
  function cliName() {
1549
1646
  return (lastInitConfig?.cliRuntime?.source === 'configured'
1550
1647
  ? (lastInitConfig.cliRuntime.displayName?.trim() || lastInitConfig.cliRuntime.id)
@@ -1797,11 +1894,12 @@ async function runStartupCommands() {
1797
1894
  return;
1798
1895
  if (!backend)
1799
1896
  return;
1800
- // riff:generic startupCommands 是 PTY 语义(sendRawCommandLine = write 文本 +
1801
- // 200ms 后 write 回车),对 RiffBackend 每条会裂成两个远端任务并打乱血缘。
1802
- // riff 的初始化命令走自己的 riff.setupCommands(沙箱内执行),这里必须跳过。
1803
- if (effectiveBackendType === 'riff') {
1804
- log(`Skipping ${cmds.length} generic startup command(s) riff backend uses riff.setupCommands instead`);
1897
+ // 远端后端:generic startupCommands 是 PTY 语义(sendRawCommandLine = write 文本 +
1898
+ // 200ms 后 write 回车),对 riff/mojo 每条会裂成两个独立远端 turn 并打乱血缘。
1899
+ // riff 的初始化命令走自己的 riff.setupCommands(沙箱内执行);mojo 无对应机制,
1900
+ // 其环境准备由 --cloud 沙箱镜像负责。两者这里都必须跳过。
1901
+ if (effectiveBackendType === 'riff' || effectiveBackendType === 'mojo') {
1902
+ log(`Skipping ${cmds.length} generic startup command(s) — ${effectiveBackendType} backend has no PTY to drive`);
1805
1903
  return;
1806
1904
  }
1807
1905
  log(`Running ${cmds.length} startup command(s) before first prompt`);
@@ -1964,13 +2062,20 @@ async function deliverRawInput(msg) {
1964
2062
  let recoveryFailureReason;
1965
2063
  try {
1966
2064
  await sendRawCommandLineWithRecoveryFence(targetBackend, msg.content, () => {
2065
+ // A passthrough (/compact, /model, ...) lands as a REAL mojo turn, so the
2066
+ // credential snapshot has to be applied here — at the write moment, which
2067
+ // is the same point flushPending applies it for a queued message. Without
2068
+ // this, a cleared or rotated JWT did not take effect on these turns.
2069
+ applyMojoLivePatch(msg.mojoLivePatch);
1967
2070
  renderer?.markNewTurn();
1968
2071
  usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
1969
2072
  if (tmuxScrolledHalfPages > 0)
1970
2073
  exitTmuxScrollMode();
1971
2074
  currentBotmuxTurnId = msg.turnId;
1972
2075
  currentBotmuxDispatchAttempt = undefined;
2076
+ currentGatewayTrustedCaller = undefined;
1973
2077
  currentVcMeetingImTurnOrigin = undefined;
2078
+ stampMojoTurnMark(msg.turnId, undefined); // a passthrough IS a mojo turn
1974
2079
  writeCliPidMarker();
1975
2080
  publishSandboxRelayCapability();
1976
2081
  });
@@ -2070,7 +2175,7 @@ async function deliverRawInput(msg) {
2070
2175
  ...(followUpContent
2071
2176
  ? {
2072
2177
  writeFollowUp: async () => {
2073
- const result = await writeAdoptMessage(followUpContent, msg.followUpTurnId, undefined, undefined, fence);
2178
+ const result = await writeAdoptMessage(followUpContent, msg.followUpTurnId, undefined, undefined, undefined, fence);
2074
2179
  if (result === 'stale-before-write') {
2075
2180
  send({
2076
2181
  type: 'user_notify',
@@ -2113,8 +2218,16 @@ const inflightInputs = new InflightInputTracker();
2113
2218
  let lastPtyActivityAtMs = 0;
2114
2219
  let currentBotmuxTurnId;
2115
2220
  let currentBotmuxDispatchAttempt;
2221
+ let currentGatewayTrustedCaller;
2116
2222
  let currentVcMeetingImTurnOrigin;
2117
2223
  let durableTurnInFlight = false;
2224
+ function currentGatewayTrustedTurnIdentity() {
2225
+ return {
2226
+ ...(currentGatewayTrustedCaller ? { caller: currentGatewayTrustedCaller } : {}),
2227
+ ...(currentBotmuxTurnId ? { turnId: currentBotmuxTurnId } : {}),
2228
+ ...(currentBotmuxDispatchAttempt !== undefined ? { dispatchAttempt: currentBotmuxDispatchAttempt } : {}),
2229
+ };
2230
+ }
2118
2231
  function publishSandboxRelayCapability(opts = {}) {
2119
2232
  const daemonIpcPort = parseDaemonIpcPort(process.env.BOTMUX_DAEMON_IPC_PORT);
2120
2233
  const capability = {
@@ -3238,26 +3351,30 @@ function codexAppLivenessStatus(base, nowMs = Date.now()) {
3238
3351
  }
3239
3352
  /**
3240
3353
  * True when this CLI has an authoritative STRUCTURED rate-limit signal that is
3241
- * actually PUBLISHED as a `limited` screen_update i.e. the Claude family,
3242
- * whose `bridgeIngest → maybeEmitStructuredRateLimit()` reads the transcript's
3243
- * `error:"rate_limit"` record. For those CLIs the screen-text `rate` heuristic
3244
- * is not just redundant but harmful: the model's own output or a dev editing
3245
- * rate-limit code/tests puts phrases like "429 Too Many Requests" / "exceeded
3246
- * retry limit" on screen, which the scraper cannot distinguish from a real
3247
- * limit. So we suppress the screen-scan `rate` verdict and let the structured
3248
- * path be the sole authority. `usage` (quota "hit your limit …") has no
3249
- * structured equivalent yet, so it still comes from the screen.
3354
+ * actually PUBLISHED as a `limited` screen_update. Two families qualify: the
3355
+ * Claude family, whose `bridgeIngest → maybeEmitStructuredRateLimit()` reads the
3356
+ * transcript's `error:"rate_limit"` record; and codex, whose
3357
+ * `maybeEmitCodexStructuredRateLimit()` reads the rollout's `codex_rate_limited`
3358
+ * terminal (`isCodexRateLimitEvent`). For those CLIs the screen-text `rate`
3359
+ * heuristic is not just redundant but harmful: the model's own output or a dev
3360
+ * editing rate-limit code/tests puts phrases like "429 Too Many Requests" /
3361
+ * "exceeded retry limit" on screen, which the scraper cannot distinguish from a
3362
+ * real limit. So we suppress the screen-scan `rate` verdict and let the
3363
+ * structured path be the sole authority. `usage` (quota "hit your limit …") has
3364
+ * no structured equivalent yet, so it still comes from the screen.
3250
3365
  *
3251
- * Gate on `claudeDataDir` (the Claude-family marker: claude-code / seed /
3252
- * genius), NOT on `reliableTurnTerminal`. Both are "transcript-backed", but the
3253
- * structured rate-limit EMIT only exists on the Claude bridge (`bridgeJsonlPath`
3254
- * path). The codexBridgeQueue CLIs (codex / grok / traex / pi) map an `error`
3255
- * terminal to a failed/ambiguous receipt but publish NO `limited` state so
3256
- * suppressing their screen `rate` verdict would silently drop the Dashboard
3257
- * 「需要你」signal + backoff on a real 429. Pi joining reliableTurnTerminal made
3258
- * that latent over-suppression concrete; scoping to claudeDataDir fixes it for
3259
- * every codexBridgeQueue CLI at once. (A future structured rate-limit emit for
3260
- * those CLIs can widen this predicate.)
3366
+ * Gate on the two capability fields (`claudeDataDir` for the Claude family;
3367
+ * `emitsStructuredRateLimit` for codex), NOT on `reliableTurnTerminal`. The
3368
+ * structured rate-limit EMIT only exists where those fields are set: codex's
3369
+ * emit runs under the `structuredBridgeIsCodex()` gate, so among the
3370
+ * codexBridgeQueue CLIs only codex carries the flag. The rest (grok / traex /
3371
+ * pi / hermes / mtr / cursor) map an `error` terminal to a failed/ambiguous
3372
+ * receipt but publish NO `limited` state suppressing their screen `rate`
3373
+ * verdict would silently drop the Dashboard「需要你」signal + backoff on a real
3374
+ * 429. Most set `reliableTurnTerminal`, so gating on that flag would wrongly
3375
+ * suppress them; gating on the explicit capability fields keeps the split
3376
+ * exact. (A future structured rate-limit emit for another CLI just sets its
3377
+ * `emitsStructuredRateLimit`.)
3261
3378
  */
3262
3379
  function structuredRateLimitAuthoritative() {
3263
3380
  return isStructuredRateLimitAuthoritative(cliAdapter);
@@ -3591,6 +3708,81 @@ function notifyExplicitReplyObserved(turnId, marker) {
3591
3708
  ...(marker.messageId ? { messageId: marker.messageId } : {}),
3592
3709
  });
3593
3710
  }
3711
+ // ─── Mojo final-answer bridge ───────────────────────────────────────────────
3712
+ // A headless backend has no terminal for the user to fall back on: if the agent
3713
+ // answers without calling `botmux send`, the answer exists only inside the
3714
+ // worker's synthesised screen (the streaming card) and the thread stays silent.
3715
+ // So mojo's per-turn `result` text is bridged into the thread through the SAME
3716
+ // gate the transcript-driven CLIs use — no second dedup policy: the sentinel and
3717
+ // the "already sent this turn" window rules live in shouldSuppressBridgeEmit.
3718
+ //
3719
+ // The window's lower bound is this record, stamped immediately before the turn's
3720
+ // literal write (see prepareNormalWrite) so a still-running previous turn's send
3721
+ // cannot land inside it. There is no upper bound to compute: mojo serialises its
3722
+ // turns (one child per turn; queued follow-ups wait for prompt-ready), so at the
3723
+ // moment the gate runs no later turn has started and no later send can exist.
3724
+ let mojoTurnMark = null;
3725
+ /** Stamp the send window at a mojo turn's literal write. Both write paths call
3726
+ * it (queued message flush + passthrough slash command), each right where it
3727
+ * rotates currentBotmuxTurnId — that pairing is what the staleness check in
3728
+ * deliverMojoTurnFinal relies on. */
3729
+ function stampMojoTurnMark(turnId, dispatchAttempt) {
3730
+ if (effectiveBackendType !== 'mojo')
3731
+ return;
3732
+ mojoTurnMark = { turnId, dispatchAttempt, markTimeMs: Date.now() };
3733
+ }
3734
+ function deliverMojoTurnFinal(text) {
3735
+ if (!text.trim())
3736
+ return;
3737
+ // A mark from an EARLIER turn must not gate this one: its window opened
3738
+ // before the previous turn's sends, which would suppress this answer for the
3739
+ // wrong reason. Falling back to an unmarked window degrades to "always
3740
+ // deliver" (shouldSuppressBridgeEmit needs markTimeMs to suppress on sends),
3741
+ // which is the safe direction for a bug whose symptom is silence.
3742
+ const mark = mojoTurnMark && mojoTurnMark.turnId === currentBotmuxTurnId ? mojoTurnMark : null;
3743
+ const turnId = mark?.turnId ?? currentBotmuxTurnId;
3744
+ // Without a turn id the daemon cannot resolve a reply target, and lastUuid
3745
+ // would not dedupe a retry. Dropping is right: this can only be output that
3746
+ // belongs to no Lark turn.
3747
+ if (!turnId) {
3748
+ log('Mojo final bridge skipped: no turn id for this answer');
3749
+ return;
3750
+ }
3751
+ const dispatchAttempt = mark?.dispatchAttempt ?? currentBotmuxDispatchAttempt;
3752
+ // adoptMode is structurally impossible for mojo (no pane to adopt); passing
3753
+ // the real flag keeps this call identical in shape to the other gate callers.
3754
+ const adoptMode = lastInitConfig?.adoptMode === true;
3755
+ const markers = adoptMode ? [] : readSendMarkers();
3756
+ const gateInput = {
3757
+ markTimeMs: mark?.markTimeMs,
3758
+ isLocal: false,
3759
+ finalText: text,
3760
+ };
3761
+ if (shouldSuppressBridgeEmit(gateInput, undefined, markers, adoptMode)) {
3762
+ log(`Mojo final bridge suppressed for turn ${turnId.substring(0, 12)} `
3763
+ + `(${isBridgeNothingToSendFinal(text) ? 'nothing-to-send sentinel' : 'model already called botmux send'})`);
3764
+ // Same as the structured bridge: an explicit send IS this turn's reply, so
3765
+ // tell observers rather than leaving them waiting on a final that the gate
3766
+ // deliberately swallowed.
3767
+ notifyExplicitReplyObserved(turnId, explicitReplyMarkerForTurnWindow(gateInput, undefined, markers, adoptMode));
3768
+ return;
3769
+ }
3770
+ // Strip a trailing sentinel line: "prose + sentinel" with no send is the
3771
+ // "did the work, forgot to send" shape — post the prose, never the token.
3772
+ const postContent = bridgePostText(text, adoptMode);
3773
+ if (!postContent.trim())
3774
+ return;
3775
+ send({
3776
+ type: 'final_output',
3777
+ content: postContent,
3778
+ // Stable per turn: the daemon derives both its dedupe key and the Lark
3779
+ // idempotency uuid from this, so a retry collapses into one message.
3780
+ lastUuid: turnId,
3781
+ turnId,
3782
+ ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
3783
+ });
3784
+ log(`Mojo final bridge delivered ${postContent.length} chars for turn ${turnId.substring(0, 12)}`);
3785
+ }
3594
3786
  function submitActivityEvidenceSince(sinceMs) {
3595
3787
  if (lastPtyActivityAtMs > sinceMs)
3596
3788
  return 'pty-output';
@@ -4398,7 +4590,8 @@ function bridgeIngest() {
4398
4590
  * session surfaces in Dashboard「需要你」with a retry countdown — identical
4399
4591
  * wire shape to the screen-text detector's classify() output, so the daemon /
4400
4592
  * card / persistence paths need no change. Claude-only (bridgeQueue is the
4401
- * Claude bridge; Codex uses codexBridgeQueue and has no structured 429). */
4593
+ * Claude bridge; Codex has its own structured 429 via
4594
+ * maybeEmitCodexStructuredRateLimit on the codexBridgeQueue path). */
4402
4595
  function maybeEmitStructuredRateLimit(events) {
4403
4596
  for (const ev of events) {
4404
4597
  if (!ev.uuid || emittedRateLimitUuids.has(ev.uuid))
@@ -4668,6 +4861,11 @@ function emitReadyTurns(opts = {}) {
4668
4861
  const nothingToSendTurns = new Set();
4669
4862
  for (let i = 0; i < ready.length; i++) {
4670
4863
  const turn = ready[i];
4864
+ // Claude API-error text is execution metadata, not a model answer. The
4865
+ // structured terminal below owns retry/attention; never leak the raw
4866
+ // provider error through transcript fallback (regardless of send markers).
4867
+ if (turn.terminalOutcome && turn.terminalOutcome.status !== 'completed')
4868
+ continue;
4671
4869
  const nextBoundaryMs = (i + 1 < ready.length ? ready[i + 1].markTimeMs : nextPendingMarkTimeMs);
4672
4870
  if (turn.isLocal && shouldSuppressBridgeEmit({ markTimeMs: turn.markTimeMs, isLocal: turn.isLocal }, nextBoundaryMs, markers, adoptMode)) {
4673
4871
  const reason = turn.isLocal ? 'local-typed' : 'model called botmux send within window';
@@ -4770,8 +4968,12 @@ function emitReadyTurns(opts = {}) {
4770
4968
  // after the model used `botmux send`. Completion is not optional. Emit it
4771
4969
  // only after all corresponding final_output IPC messages have been queued so
4772
4970
  // the daemon observes a stable per-turn ordering.
4773
- for (const turn of ready)
4774
- emitTurnTerminal(turn.turnId, 'completed', undefined, turn.dispatchAttempt, nothingToSendTurns.has(turn) ? 'nothing_to_send' : undefined);
4971
+ for (const turn of ready) {
4972
+ if (turn.rateLimited)
4973
+ continue;
4974
+ const outcome = turn.terminalOutcome;
4975
+ emitTurnTerminal(turn.turnId, outcome?.status ?? 'completed', outcome && outcome.status !== 'completed' ? outcome.errorCode : undefined, turn.dispatchAttempt, nothingToSendTurns.has(turn) ? 'nothing_to_send' : undefined, outcome && outcome.status !== 'completed' ? outcome.retryable : undefined);
4976
+ }
4775
4977
  }
4776
4978
  /** Drain `path` from `fromOffset` and feed the events to the bridge queue
4777
4979
  * with that path as the source stamp. Pure side-effects on bridgeQueue +
@@ -6540,7 +6742,7 @@ function beginCliWriteCycle() {
6540
6742
  resetIdleDetector: () => { idleDetector?.reset(); },
6541
6743
  });
6542
6744
  }
6543
- async function writeAdoptMessage(content, turnId, dispatchAttempt, vcMeetingImTurnOrigin, fence) {
6745
+ async function writeAdoptMessage(content, turnId, dispatchAttempt, vcMeetingImTurnOrigin, trustedCaller, fence) {
6544
6746
  const executionFence = fence ?? captureAdoptWriteFence();
6545
6747
  if (!executionFence || !adoptWriteFenceIsCurrent(executionFence)) {
6546
6748
  return 'stale-before-write';
@@ -6550,6 +6752,7 @@ async function writeAdoptMessage(content, turnId, dispatchAttempt, vcMeetingImTu
6550
6752
  const turnSeq = usageLimitTracker.beginTurn(currentUsageLimitSnapshot());
6551
6753
  currentBotmuxTurnId = turnId;
6552
6754
  currentBotmuxDispatchAttempt = dispatchAttempt;
6755
+ currentGatewayTrustedCaller = trustedCaller;
6553
6756
  currentVcMeetingImTurnOrigin = vcMeetingImTurnOrigin;
6554
6757
  if (dispatchAttempt !== undefined)
6555
6758
  durableTurnInFlight = true;
@@ -6764,7 +6967,7 @@ async function runAdoptMessageForCapturedGeneration(item, requeue) {
6764
6967
  queue: adoptWriteQueue,
6765
6968
  isCurrent: () => adoptWriteFenceIsCurrent(fence),
6766
6969
  onStale: requeueOnce,
6767
- write: () => writeAdoptMessage(item.content, item.turnId, item.dispatchAttempt, item.vcMeetingImTurnOrigin, fence),
6970
+ write: () => writeAdoptMessage(item.content, item.turnId, item.dispatchAttempt, item.vcMeetingImTurnOrigin, item.trustedCaller, fence),
6768
6971
  });
6769
6972
  if (queued.status === 'stale-before-write')
6770
6973
  return 'stale-before-write';
@@ -6910,6 +7113,8 @@ let displayMode = 'hidden';
6910
7113
  let screenshotTimer = null;
6911
7114
  let pendingShotTimer = null;
6912
7115
  let lastShotHash = '';
7116
+ // Throttle for the happy-path upload log in captureAndUpload (one line/min).
7117
+ let lastUploadLogAtMs = 0;
6913
7118
  let larkAppIdForUpload = '';
6914
7119
  let larkAppSecretForUpload = '';
6915
7120
  let larkBrandForUpload = 'feishu';
@@ -7038,6 +7243,14 @@ async function captureAndUpload() {
7038
7243
  return;
7039
7244
  }
7040
7245
  const status = projectedRuntimeScreenStatus();
7246
+ // Success is otherwise completely silent (skips and failures log above), which
7247
+ // made "loop alive and uploading" indistinguishable from "loop never started"
7248
+ // in the daemon log. One throttled line keeps the happy path observable.
7249
+ const nowMs = Date.now();
7250
+ if (nowMs - lastUploadLogAtMs >= 60_000) {
7251
+ lastUploadLogAtMs = nowMs;
7252
+ log(`Screenshot uploaded (${png.length}B, key=${imageKey.slice(0, 12)}…)`);
7253
+ }
7041
7254
  send({
7042
7255
  type: 'screenshot_uploaded',
7043
7256
  imageKey,
@@ -7092,10 +7305,10 @@ function sendTermActionOnce(target, key) {
7092
7305
  return false;
7093
7306
  }
7094
7307
  async function handleTermAction(key) {
7095
- // riff:没有远端终端可驱动——把控制字符 write 进 RiffBackend 会变成一个内容为
7096
- // ANSI 序列的 follow-up 任务(^C 也不会 cancel 任务),必须整体拒绝。
7097
- if (effectiveBackendType === 'riff') {
7098
- log(`term_action '${key}' ignored — riff backend has no local terminal to drive`);
7308
+ // 远端后端:没有终端可驱动——把控制字符 write 进 riff/mojo 会变成一个内容为
7309
+ // ANSI 序列的 follow-up turn(^C 也不会 cancel 远端执行),必须整体拒绝。
7310
+ if (effectiveBackendType === 'riff' || effectiveBackendType === 'mojo') {
7311
+ log(`term_action '${key}' ignored — ${effectiveBackendType} backend has no local terminal to drive`);
7099
7312
  return;
7100
7313
  }
7101
7314
  if (!backend)
@@ -8866,7 +9079,12 @@ function markPromptReady() {
8866
9079
  // so a dropped Enter cannot create permanent false-busy.
8867
9080
  // Reject the heuristic and re-arm IdleDetector so the later transcript-final
8868
9081
  // fireIdle() can drive the real ready edge.
8869
- if (hasStructuredLifecycleBlock()) {
9082
+ if (hasStructuredLifecycleBlock() && !spawnArgvInitialPromptBusy) {
9083
+ // Grok is lifecycle-blocking, but its FIRST ready is still a pre-execution
9084
+ // SessionStart / idle-detector edge while the argv-baked prompt is running.
9085
+ // The spawnArgvInitialPromptBusy arm below must publish working and consume
9086
+ // itself on that first edge. After the arm is gone, later TUI redraws stay
9087
+ // blocked until turn_completed so they cannot publish idle mid-turn.
8870
9088
  const remainingMs = codexBridgeQueue.preStartLeaseRemainingMs();
8871
9089
  structuredRejectedReadyEvidenceGeneration = ptyOutputGeneration.snapshot();
8872
9090
  log('Ignoring prompt-ready heuristic while a structured turn is unfinished or submit verification/start is pending');
@@ -9477,10 +9695,11 @@ async function flushPending() {
9477
9695
  const rawInputReady = isPromptReady && pendingRawInputs.length > 0;
9478
9696
  const adoptInputReady = isPromptReady && lastInitConfig?.adoptMode === true && pendingAdoptMessages.length > 0;
9479
9697
  let supportedSessionRenameReady = sessionRenameReady;
9480
- if (sessionRenameReady && (!cliAdapter.buildSessionRenameCommand || effectiveBackendType === 'riff')) {
9698
+ const renameOnRemoteBackend = effectiveBackendType === 'riff' || effectiveBackendType === 'mojo';
9699
+ if (sessionRenameReady && (!cliAdapter.buildSessionRenameCommand || renameOnRemoteBackend)) {
9481
9700
  pendingSessionRename = null;
9482
9701
  supportedSessionRenameReady = false;
9483
- log(`Ignoring native session rename — unsupported by ${cliName()}${effectiveBackendType === 'riff' ? ' on riff backend' : ''}`);
9702
+ log(`Ignoring native session rename — unsupported by ${cliName()}${renameOnRemoteBackend ? ` on ${effectiveBackendType} backend` : ''}`);
9484
9703
  if (pendingMessages.length === 0 && pendingAdoptMessages.length === 0 && pendingRawInputs.length === 0)
9485
9704
  return;
9486
9705
  }
@@ -9623,6 +9842,10 @@ async function flushPending() {
9623
9842
  const item = freshnessInputQueue.takeNormal();
9624
9843
  if (!item)
9625
9844
  break;
9845
+ // Apply the credential snapshot that arrived WITH this turn, immediately
9846
+ // before the turn is written. Doing it at IPC-receive time collapsed two
9847
+ // queued credential turns (A/B/C ran as A/C/C).
9848
+ applyMojoLivePatch(item.mojoLivePatch);
9626
9849
  // Type-ahead can drain several items in one flush. Each logical submit
9627
9850
  // starts its own readiness generation before transcript marking/writing.
9628
9851
  beginCliWriteCycle();
@@ -9649,6 +9872,7 @@ async function flushPending() {
9649
9872
  renderer?.markNewTurn();
9650
9873
  currentBotmuxTurnId = item.turnId;
9651
9874
  currentBotmuxDispatchAttempt = item.dispatchAttempt;
9875
+ currentGatewayTrustedCaller = item.trustedCaller;
9652
9876
  currentVcMeetingImTurnOrigin = item.vcMeetingImTurnOrigin;
9653
9877
  // Acquire durable HOL ownership only after this turn owns the backend
9654
9878
  // submission mutex. If an older ZMX recovery debt rejects capture,
@@ -9683,6 +9907,13 @@ async function flushPending() {
9683
9907
  codexBridgeQueue.beginSubmitVerification(bridgeTurnId, undefined, item.dispatchAttempt);
9684
9908
  }
9685
9909
  }
9910
+ else {
9911
+ // Same anchoring rule as the transcript bridges above: stamp the send
9912
+ // window's lower bound at the literal write, not on IPC arrival, so a
9913
+ // previous turn's `botmux send` cannot fall inside this turn's window
9914
+ // and falsely suppress its final. No-op for non-mojo backends.
9915
+ stampMojoTurnMark(item.turnId, item.dispatchAttempt);
9916
+ }
9686
9917
  if (durableWrite
9687
9918
  && cliAdapter.reliableTurnTerminal === true
9688
9919
  && lastInitConfig?.cliId === 'claude-code'
@@ -9814,13 +10045,21 @@ async function flushPending() {
9814
10045
  }
9815
10046
  else if (item.codexAppInput && writeAdapter.writeStructuredInput) {
9816
10047
  submissionBackend = writeBackend;
9817
- const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeStructuredInput(adapterInputHandle(writeBackend), msg, item.codexAppInput, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
10048
+ const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeStructuredInput(adapterInputHandle(writeBackend), msg, item.codexAppInput, {
10049
+ turnId: item.turnId,
10050
+ ...(item.trustedCaller ? { trustedCaller: item.trustedCaller } : {}),
10051
+ ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}),
10052
+ }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
9818
10053
  result = transaction.result;
9819
10054
  recoveryFailureReason = transaction.recoveryFailureReason;
9820
10055
  }
9821
10056
  else {
9822
10057
  submissionBackend = writeBackend;
9823
- const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeInput(adapterInputHandle(writeBackend), msg, { turnId: item.turnId, ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}) }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
10058
+ const transaction = await runAmbiguousSubmissionTransaction(writeBackend, () => writeAdapter.writeInput(adapterInputHandle(writeBackend), msg, {
10059
+ turnId: item.turnId,
10060
+ ...(item.trustedCaller ? { trustedCaller: item.trustedCaller } : {}),
10061
+ ...(item.codexAppSteerable ? { codexAppSteerable: true } : {}),
10062
+ }), settleVerifiableSubmissionForJournal, prepareNormalWrite);
9824
10063
  result = transaction.result;
9825
10064
  recoveryFailureReason = transaction.recoveryFailureReason;
9826
10065
  }
@@ -10081,6 +10320,8 @@ async function flushPending() {
10081
10320
  // HOL-dropped or steered into the other.
10082
10321
  if (rpcLifecycleFailClosedOwners.size > 0)
10083
10322
  break;
10323
+ if (item.trustedCaller && lastInitConfig?.cliId === 'codex')
10324
+ break;
10084
10325
  if (shouldStopPendingBatch(item, pendingMessages[0]))
10085
10326
  break;
10086
10327
  }
@@ -10097,6 +10338,30 @@ async function flushPending() {
10097
10338
  }
10098
10339
  }
10099
10340
  }
10341
+ /**
10342
+ * Find an executable using the PATH the CHILD will actually see.
10343
+ *
10344
+ * The layered env (worker env → per-bot `env`) is authoritative: when it names a
10345
+ * PATH, that is the ONLY place searched, because falling back to the daemon's own
10346
+ * PATH is exactly how an ambient install shadowed a per-bot one.
10347
+ */
10348
+ function locateOnEffectiveChildPath(cmd, childEnvForPath) {
10349
+ const childPath = childEnvForPath.PATH;
10350
+ if (!childPath)
10351
+ return locateOnPath(cmd);
10352
+ for (const dir of childPath.split(delimiter)) {
10353
+ if (!dir)
10354
+ continue;
10355
+ const candidate = join(dir, cmd);
10356
+ try {
10357
+ accessSync(candidate, fsConstants.X_OK);
10358
+ return candidate;
10359
+ }
10360
+ catch { /* not here */ }
10361
+ }
10362
+ // Explicit child PATH is authoritative — do NOT fall back to the ambient one.
10363
+ return null;
10364
+ }
10100
10365
  function sendToPty(content, turnId, opts = {}) {
10101
10366
  const next = {
10102
10367
  content,
@@ -10105,7 +10370,9 @@ function sendToPty(content, turnId, opts = {}) {
10105
10370
  ...(opts.codexAppDispatchId ? { codexAppDispatchId: opts.codexAppDispatchId } : {}),
10106
10371
  ...(opts.codexAppSteerable ? { codexAppSteerable: true } : {}),
10107
10372
  ...(opts.queuedActivationToken ? { queuedActivationToken: opts.queuedActivationToken } : {}),
10373
+ ...(opts.mojoLivePatch ? { mojoLivePatch: opts.mojoLivePatch } : {}),
10108
10374
  ...(opts.codexAppInput ? { codexAppInput: opts.codexAppInput } : {}),
10375
+ ...(opts.trustedCaller ? { trustedCaller: opts.trustedCaller } : {}),
10109
10376
  ...(opts.dispatchAttempt !== undefined ? { dispatchAttempt: opts.dispatchAttempt } : {}),
10110
10377
  ...(opts.atMostOnce ? { noReplay: true } : {}),
10111
10378
  ...(opts.vcMeetingImTurnOrigin
@@ -10951,18 +11218,82 @@ async function spawnCli(cfg, opts = {}) {
10951
11218
  // PTY/tmux backends inject these into the child process env directly; riff
10952
11219
  // has no local process, so they go via config.env → the riff API's config.env.
10953
11220
  let riffBackendConfig = cfg.backendConfig;
11221
+ // mojo: combine the user's `mojo` block with the platform-owned launch identity
11222
+ // into the ONE EffectiveMojoConfig the backend runs on. Without this the backend
11223
+ // silently ignores everything botmux already resolved — repo selection
11224
+ // (workingDir), the dashboard/setup `model`, `disableCliBypass`,
11225
+ // `cliPathOverride` and the persisted session lineage.
11226
+ //
11227
+ // There is NO precedence question: the launch identity has exactly one source
11228
+ // (the top-level bot fields, frozen onto the session). The `mojo` block is not
11229
+ // a fallback for those keys — both config entry points reject them, and the
11230
+ // builder strips any that slip through. Do not reintroduce a `block ?? generic`
11231
+ // merge here; that was the double-entry-point bug.
11232
+ if (effectiveBackendType === 'mojo') {
11233
+ // Defensive third gate. Both config doors already validate, but the IPC
11234
+ // payload could be stale (written by an older daemon) or malformed, and the
11235
+ // failure mode is a security one: `localDaemon: "false"` satisfies the
11236
+ // sandbox check's `!== true` while being truthy when building the child env,
11237
+ // i.e. isolation skipped AND host execution enabled. Refuse to launch rather
11238
+ // than run in that state.
11239
+ const validated = normalizeMojoConfig(cfg.backendConfig);
11240
+ if (!validated.ok) {
11241
+ throw new Error(`mojo config is invalid: ${validated.errors.join('; ')}`);
11242
+ }
11243
+ // Shared with the daemon's workerless `/close` path (see
11244
+ // destroyOrphanedBackingSession) so a session that runs on a custom binary /
11245
+ // per-bot JWT can still be cancelled after its worker is gone.
11246
+ riffBackendConfig = buildEffectiveMojoConfig(validated.value, {
11247
+ cliPathOverride: cfg.cliPathOverride,
11248
+ workingDir: cfg.workingDir,
11249
+ model: cfg.model,
11250
+ disableCliBypass: cfg.disableCliBypass,
11251
+ env: cfg.env ? sanitizePerBotEnv(cfg.env) : undefined,
11252
+ // Resume the persisted lineage: daemon restart, /relay and worker rebuilds
11253
+ // all arrive with riffParentTaskId set (the shared remote-lineage channel —
11254
+ // see the riff_task_id IPC message, emitted by the generic
11255
+ // backend.onTaskId hook and therefore already carrying mojo ids).
11256
+ resumeCliSessionId: cfg.riffParentTaskId,
11257
+ wrapperCli: cfg.wrapperCli,
11258
+ // mojo is injectsSessionContext + global skillsDir (same shape as
11259
+ // genius/grok), so session-manager does NOT wrap the per-message skill
11260
+ // envelope for it — the catalog for `prompt` mode, and the help pointer for
11261
+ // `off`, must ride on the prompt MojoBackend builds. Resolved here because
11262
+ // the mode lookup needs larkAppId and the backend has no bot context.
11263
+ // `global` resolves to '' (files already installed under ~/.mojo/skills).
11264
+ builtinSkillBlock: builtinSkillBlockForInjectsSessionContext(cfg.larkAppId, cfg.locale, {
11265
+ asksViaHook: false,
11266
+ whiteboardEnabled: whiteboardEnabled(),
11267
+ // mojo emits NO <botmux_routing> block (unlike genius/grok, which build one
11268
+ // via buildBotmuxSystemPromptText). So the catalog must keep the
11269
+ // routing-covered skills (history/quoted/bots) — otherwise they are
11270
+ // documented nowhere — and must not cite a routing block that is absent.
11271
+ // Full routing/identity injection for mojo is deliberately out of scope
11272
+ // here: a sandboxed remote session cannot reuse the normal CLI's routing
11273
+ // semantics verbatim (see the --mention-back note in adapters/cli/mojo.ts).
11274
+ hasRoutingBlock: false,
11275
+ }),
11276
+ });
11277
+ const resumed = riffBackendConfig.resumeCliSessionId;
11278
+ if (resumed)
11279
+ log(`mojo resuming session lineage ${resumed}`);
11280
+ }
10954
11281
  if (effectiveBackendType === 'riff') {
10955
11282
  if (!cfg.backendConfig) {
10956
11283
  throw new Error('riff backend requires backendConfig (baseUrl, etc.)');
10957
11284
  }
11285
+ // backendConfig is a union shared with the mojo backend; inside this
11286
+ // riff-only branch the shape is known, so narrow once instead of casting at
11287
+ // each riff-specific field access below.
11288
+ const riffCfg = cfg.backendConfig;
10958
11289
  // Fail fast on a missing/invalid baseUrl — every config entry point funnels
10959
11290
  // through this spawn gate, and a late `fetch("undefined/api/…")` error is
10960
11291
  // far harder to diagnose than an explicit spawn refusal.
10961
- if (!isValidRiffBaseUrl(cfg.backendConfig.baseUrl)) {
10962
- throw new Error(`riff baseUrl 未配置或非法(需 http(s) URL,当前: ${JSON.stringify(cfg.backendConfig.baseUrl ?? null)})——请在 dashboard 的 Riff 配置中填写`);
11292
+ if (!isValidRiffBaseUrl(riffCfg.baseUrl)) {
11293
+ throw new Error(`riff baseUrl 未配置或非法(需 http(s) URL,当前: ${JSON.stringify(riffCfg.baseUrl ?? null)})——请在 dashboard 的 Riff 配置中填写`);
10963
11294
  }
10964
- if (cfg.backendConfig.sandboxCluster !== undefined && !isValidRiffSandboxCluster(cfg.backendConfig.sandboxCluster)) {
10965
- throw new Error(`riff sandboxCluster 非法(仅支持 boe/cn,当前: ${JSON.stringify(cfg.backendConfig.sandboxCluster)})——请在 dashboard 的 Riff 配置中重新选择`);
11295
+ if (riffCfg.sandboxCluster !== undefined && !isValidRiffSandboxCluster(riffCfg.sandboxCluster)) {
11296
+ throw new Error(`riff sandboxCluster 非法(仅支持 boe/cn,当前: ${JSON.stringify(riffCfg.sandboxCluster)})——请在 dashboard 的 Riff 配置中重新选择`);
10966
11297
  }
10967
11298
  const sessionEnv = {
10968
11299
  BOTMUX_SESSION_ID: cfg.sessionId,
@@ -11012,14 +11343,24 @@ async function spawnCli(cfg, opts = {}) {
11012
11343
  sessionEnv.BOTMUX_LARK_LIST_BOTS_API_ENABLED = chatBotDiscovery.listBotsApiEnabled ? 'true' : 'false';
11013
11344
  sessionEnv.BOTMUX_LARK_LIST_BOTS_API_TIMEOUT_MS = String(chatBotDiscovery.listBotsApiTimeoutMs);
11014
11345
  // Per-bot env (bots.json `env`) takes precedence over session context;
11015
- // explicit riff config.env takes precedence over both.
11016
- const mergedEnv = { ...sessionEnv, ...sanitizePerBotEnv(cfg.env), ...cfg.backendConfig.env };
11346
+ // explicit riff config.env takes precedence over both. The workflow
11347
+ // kill-switch is a host-resolved snapshot and is re-frozen AFTER this merge
11348
+ // (see below), so it is intentionally NOT set in sessionEnv here.
11349
+ const mergedEnv = { ...sessionEnv, ...sanitizePerBotEnv(cfg.env), ...riffCfg.env };
11017
11350
  // The effective policy is a host-resolved snapshot, not a user-overridable
11018
11351
  // backend env knob. Re-freeze it after config.env/per-bot env merge.
11019
11352
  if (cfg.feedback)
11020
11353
  mergedEnv.BOTMUX_FEEDBACK_POLICY = JSON.stringify(cfg.feedback);
11021
11354
  else
11022
11355
  delete mergedEnv.BOTMUX_FEEDBACK_POLICY;
11356
+ // The workflow kill-switch is likewise a host-resolved snapshot. Re-freeze it
11357
+ // AFTER the merge: unlike per-bot `env` (sanitizePerBotEnv strips the BOTMUX*
11358
+ // prefix), `riffCfg.env` merges LAST and is NOT sanitized, so a stale or
11359
+ // user-shaped backendConfig.env could otherwise flip the pane's
11360
+ // BOTMUX_WORKFLOW_ENABLED and desync the remote CLI-side gate from the
11361
+ // daemon's authoritative decision. The daemon-side gate is unaffected either
11362
+ // way; this keeps the in-pane defense-in-depth honest on the riff path too.
11363
+ mergedEnv.BOTMUX_WORKFLOW_ENABLED = isWorkflowFeatureEnabled() ? 'true' : 'false';
11023
11364
  // Re-freeze the no-transport capability keys AFTER the merge: a stale or
11024
11365
  // attacker-shaped backendConfig.env / per-bot env merges LAST and would
11025
11366
  // otherwise override the frozen values, restoring send capability for a
@@ -11037,11 +11378,14 @@ async function spawnCli(cfg, opts = {}) {
11037
11378
  // merge so a stale or user-supplied backend env cannot impersonate another
11038
11379
  // owner (or resurrect an owner on an ownerless session).
11039
11380
  applySessionOwnerEnv(mergedEnv, cfg.ownerOpenId);
11040
- riffBackendConfig = Object.assign({}, cfg.backendConfig, { env: mergedEnv, resumeParentTaskId: cfg.riffParentTaskId });
11381
+ // `riffCfg` is `cfg.backendConfig` already narrowed once at the top of this
11382
+ // riff-only branch — same object, so master's owner re-freeze above is
11383
+ // unaffected; keeping the typed alias avoids re-widening to the shared union.
11384
+ riffBackendConfig = Object.assign({}, riffCfg, { env: mergedEnv, resumeParentTaskId: cfg.riffParentTaskId });
11041
11385
  // 复用本地仓库+分支:多仓只认会话上的显式 stamp(仓库选择卡多选流按用户
11042
11386
  // 顺序写入 cfg.riffRepoDirs,首仓=primary);否则仅对 workingDir 本身做单仓
11043
11387
  // 推导——绝不扫描任意非 git 目录的子目录(home/仓库集合目录会乱带仓库)。
11044
- if (!cfg.backendConfig.repos || cfg.backendConfig.repos.length === 0) {
11388
+ if (!riffCfg.repos || riffCfg.repos.length === 0) {
11045
11389
  const derived = cfg.riffRepoDirs && cfg.riffRepoDirs.length > 0
11046
11390
  ? deriveRiffReposFromDirs(cfg.riffRepoDirs)
11047
11391
  : (() => { const one = deriveRiffRepoFromWorkingDir(cfg.workingDir); return one ? { repos: [one.repo], warnings: one.warnings } : null; })();
@@ -11077,15 +11421,29 @@ async function spawnCli(cfg, opts = {}) {
11077
11421
  // BOTS_CONFIG. riff runs in its own REMOTE sandbox with no local CLI process —
11078
11422
  // local confinement is meaningless there and must be bypassed on ALL
11079
11423
  // platforms, or a sandbox-enabled bot bricks the moment it switches to riff.
11080
- const riffRemoteBackend = !localSandboxApplies(effectiveBackendType);
11424
+ // mojo only counts as remote when it provably runs nothing locally
11425
+ // (cloud on, localDaemon off) — otherwise the local sandbox must stay engaged.
11426
+ const remoteBackendFullyOffBox = !localSandboxApplies(effectiveBackendType, effectiveBackendType === 'mojo'
11427
+ ? riffBackendConfig
11428
+ : undefined);
11429
+ const riffRemoteBackend = remoteBackendFullyOffBox;
11081
11430
  if (riffRemoteBackend && (cfg.sandbox === true || cfg.readIsolation === true)) {
11082
- log('Sandbox flag set but backend is riff (remote sandbox, no local process) — local sandbox bypassed');
11431
+ log(`Sandbox flag set but backend is ${effectiveBackendType} (remote sandbox, no local process) — local sandbox bypassed`);
11432
+ }
11433
+ if (effectiveBackendType === 'mojo' && !remoteBackendFullyOffBox
11434
+ && (cfg.sandbox === true || cfg.readIsolation === true)) {
11435
+ // Loud on purpose: this is the combination where a mojo bot DOES execute
11436
+ // locally, so the local sandbox stays on rather than being skipped.
11437
+ log('mojo runs tools locally (cloud not enabled, or localDaemon set) — keeping the local sandbox engaged');
11083
11438
  }
11084
11439
  const sandboxRequested = !riffRemoteBackend
11085
11440
  && (cfg.sandbox === true || cfg.readIsolation === true || sandboxEnabled());
11086
11441
  const backendIsolationGate = backendSandboxCompatibilityError({
11087
11442
  backendType: effectiveBackendType,
11088
11443
  fileSandboxRequested: sandboxRequested,
11444
+ ...(effectiveBackendType === 'mojo'
11445
+ ? { mojoConfig: (riffBackendConfig ?? {}) }
11446
+ : {}),
11089
11447
  // The unified sandbox request above already includes legacy readIsolation.
11090
11448
  effectiveReadIsolationRequested: false,
11091
11449
  });
@@ -11314,17 +11672,65 @@ async function spawnCli(cfg, opts = {}) {
11314
11672
  // so the probe below sees no pane and we cold-spawn fresh isolated. A pane from
11315
11673
  // this lifetime (suspend→resume) keeps its marker → reattaches normally (it is
11316
11674
  // still the isolated process). This lets isolated bots use tmux/zellij/herdr.
11675
+ //
11676
+ // The MIRROR case is just as load-bearing: policy is now OFF (no sandbox, not
11677
+ // enrolled → appliedIsolationCapabilities empty), but a pane spawned by an OLDER
11678
+ // build under the previous FORCED no-transport isolation is still alive AND still
11679
+ // stamped. That pane runs confined against a policy we no longer want; a bare
11680
+ // `capabilities.length > 0` gate would skip the check entirely and warm-reattach
11681
+ // the still-isolated process, silently contradicting "read scope follows local
11682
+ // config" on resume/restart (the 2026-08 no-transport放宽 upgrade path). So we
11683
+ // also enter when a boot marker is present on disk for THIS session — then the
11684
+ // policy-off arm below (no expected capabilities) demands the marker be truly
11685
+ // absent to reattach, else kills + cold-spawns unconfined. Presence is checked
11686
+ // by the no-follow existence probe (a planted/tampered leaf that reads as null
11687
+ // still counts as present, so it cannot be used to force a silent reattach).
11317
11688
  let persistentPaneOriginChannelId;
11318
- if (appliedIsolationCapabilities.length > 0 && persistentSessionName && effectiveBackendType !== 'pty') {
11689
+ const stalePaneMarkerPath = isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId);
11690
+ const policyOffTombstoneFilePath = policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId);
11691
+ // no-transport (apiOnly bot OR HTTP virtual chat) is the ONLY session shape the
11692
+ // removed force-isolation rule ever confined; the policy-off migration arm is
11693
+ // scoped to it so an ordinary transport-enabled chat is never subjected to the
11694
+ // tombstone requirement (no false kills). Computed locally — the merge-scoped
11695
+ // `noTransport` above is out of scope here.
11696
+ const noTransportSession = cfg.apiOnly === true
11697
+ || cfg.chatId?.startsWith('http_async_') === true
11698
+ || cfg.chatId?.startsWith('http_wait_') === true;
11699
+ const isolationCapableBackend = effectiveBackendType === 'tmux';
11700
+ // Existence via no-follow probes so a planted/tampered leaf still counts as
11701
+ // present (→ triggers cleanup / conservative kill) and can never be used to
11702
+ // force a silent reattach.
11703
+ const stalePaneMarkerPresent = hostEntryExistsNoFollow(stalePaneMarkerPath);
11704
+ const policyOffTombstonePresent = hostEntryExistsNoFollow(policyOffTombstoneFilePath);
11705
+ // The guard must ENTER the state machine whenever it could have anything to
11706
+ // decide, WITHOUT depending on provenance already being present for the live-
11707
+ // pane arms — else a no-transport pane whose best-effort isolation marker write
11708
+ // was lost would (NEITHER file) skip the guard and warm-reattach still confined.
11709
+ // Enter for:
11710
+ // · any policy-ON spawn (capability check runs on every persistent backend,
11711
+ // incl. credential-only zellij/herdr/zmx), OR
11712
+ // · a policy-OFF no-transport tmux session (the file-sandbox migration scope), OR
11713
+ // · ANY session (incl. transport-enabled, any backend) that has stale
11714
+ // provenance on disk — so a dead pane's leftover marker/tombstone is cleared
11715
+ // before cold-spawn on EVERY backend. Otherwise a transport chat that turned
11716
+ // sandbox OFF leaves a matching marker that would later warm-reattach a fresh
11717
+ // UNisolated pane as "isolated" when sandbox is re-enabled.
11718
+ const persistentPaneGuardApplies = appliedIsolationCapabilities.length > 0
11719
+ || (noTransportSession && isolationCapableBackend)
11720
+ || stalePaneMarkerPresent || policyOffTombstonePresent;
11721
+ if (persistentSessionName && effectiveBackendType !== 'pty' && persistentPaneGuardApplies) {
11319
11722
  const persistentTarget = selectedBackend.persistentBackendTarget;
11320
11723
  // ZMX ownership is verified against the frozen PID, not just the name — a
11321
11724
  // same-named session may belong to the user or to a newer generation.
11322
11725
  const zmxOwnedProbe = effectiveBackendType === 'zmx'
11323
11726
  ? probeOwnedZmxSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid)
11324
11727
  : undefined;
11325
- // ZMX ownership is label/PID-sensitive, so an inconclusive ZMX probe must
11326
- // fail closed. Other persistent backends retain the upstream semantics:
11327
- // their target probe returning unknown is not proof that a pane exists.
11728
+ // ZMX ownership is label/PID-sensitive, so an inconclusive ZMX probe is not
11729
+ // proof of anything. Other persistent backends: their target probe returning
11730
+ // unknown is likewise not proof that a pane exists. Liveness is passed TRI-STATE
11731
+ // (paneProbe) into the state machine, which fail-closes on `unknown` for EVERY
11732
+ // backend (refuse-inconclusive-probe) — no longer only ZMX, and no longer
11733
+ // collapsed into "dead" (which would clear a still-confined pane's provenance).
11328
11734
  const paneProbe = zmxOwnedProbe?.probe
11329
11735
  ?? (persistentTarget ? probePersistentBackendTarget(persistentTarget) : 'missing');
11330
11736
  if (effectiveBackendType === 'zmx'
@@ -11333,80 +11739,122 @@ async function spawnCli(cfg, opts = {}) {
11333
11739
  throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ` +
11334
11740
  'ZMX session appeared after the frozen launch probe');
11335
11741
  }
11336
- if (effectiveBackendType === 'zmx' && paneProbe === 'unknown') {
11337
- throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ` +
11338
- `could not verify existing ${effectiveBackendType} pane`);
11339
- }
11340
11742
  const paneLive = paneProbe === 'exists';
11341
- if (paneLive) {
11342
- const markerPath = join(isolationRuntimeDataDir, 'read-isolation', `${cfg.sessionId}.boot`);
11343
- const marker = readManagedOriginAuthorityFile(markerPath);
11344
- const originChannelPolicyExpected = !!managedOriginChannelPolicyDigest;
11345
- // A stamped pane must match even when the new policy is OFF. Otherwise a
11346
- // disable followed by restart could reattach the still-confined process
11347
- // without rebuilding its authority/profile. An unsafe planted marker
11348
- // leaf is treated as stamped/unknown by the no-follow existence check.
11349
- const policyMatches = appliedIsolationCapabilities.length > 0
11350
- ? isolatedPaneReattachSafe(marker, {
11351
- requiredCapabilities: appliedIsolationCapabilities,
11352
- exactCapabilities: true,
11353
- ...(originChannelPolicyExpected ? {
11354
- readIsolation: willReadIsolate,
11355
- writeSandbox: willWriteSandbox,
11356
- requireOriginChannel: true,
11357
- policyDigest: managedOriginChannelPolicyDigest,
11358
- } : {}),
11359
- })
11360
- : marker === null && !hostEntryExistsNoFollow(markerPath);
11361
- if (policyMatches) {
11362
- if (originChannelPolicyExpected) {
11363
- persistentPaneOriginChannelId = isolatedPaneOriginChannel(marker);
11364
- }
11365
- // Pane was spawned under the current isolation policy → still confined
11366
- // on the running process across daemon restarts; warm reattach preserves
11367
- // resume/context + tmux idle-suspend.
11368
- log(`[read-isolation] reattaching isolated persistent pane (${cfg.sessionId})`);
11743
+ const markerPath = stalePaneMarkerPath;
11744
+ const marker = paneLive ? readManagedOriginAuthorityFile(markerPath) : null;
11745
+ const originChannelPolicyExpected = !!managedOriginChannelPolicyDigest;
11746
+ // isolatedPaneReattachSafe only means anything under a policy-ON spawn; the
11747
+ // state machine consults it only in that arm.
11748
+ const isolationMarkerReattachSafe = appliedIsolationCapabilities.length > 0
11749
+ && isolatedPaneReattachSafe(marker, {
11750
+ requiredCapabilities: appliedIsolationCapabilities,
11751
+ exactCapabilities: true,
11752
+ ...(originChannelPolicyExpected ? {
11753
+ readIsolation: willReadIsolate,
11754
+ writeSandbox: willWriteSandbox,
11755
+ requireOriginChannel: true,
11756
+ policyDigest: managedOriginChannelPolicyDigest,
11757
+ } : {}),
11758
+ });
11759
+ // Tombstone authorizes a policy-off warm reattach ONLY when it passes a SECURE
11760
+ // read (real 0600 regular file, right owner) + schema/version validation — a
11761
+ // bare lstat "present" (empty / dir / symlink / garbage) must NOT authorize.
11762
+ // Presence (above) still drives cleanup; validity drives authorization.
11763
+ const policyOffTombstoneIsValid = paneLive && policyOffTombstonePresent
11764
+ && policyOffTombstoneValid(readManagedOriginAuthorityFile(policyOffTombstoneFilePath));
11765
+ // PENDING provenance: a present marker OR tombstone whose secure-read body is a
11766
+ // `state:'pending'` record. This is a generation whose fresh-attribution never
11767
+ // completed (crash between pending-write and commit, or an uncommitted
11768
+ // late-flip/collision). It DOMINATES the state machine (all backends, both
11769
+ // policy directions) see evaluatePersistentPaneMigration. Secure-read (not
11770
+ // lstat) because only a real 0600 file we wrote can be a trusted pending
11771
+ // record; a planted/garbage leaf reads as null → not pending → falls through
11772
+ // to the normal presence-but-invalid handling (still conservative).
11773
+ const pendingProvenancePresent = (stalePaneMarkerPresent
11774
+ && provenancePendingNonce(readManagedOriginAuthorityFile(stalePaneMarkerPath)) !== null)
11775
+ || (policyOffTombstonePresent
11776
+ && provenancePendingNonce(readManagedOriginAuthorityFile(policyOffTombstoneFilePath)) !== null);
11777
+ const migration = evaluatePersistentPaneMigration({
11778
+ appliedIsolationCapabilities,
11779
+ isolationCapableBackend,
11780
+ noTransport: noTransportSession,
11781
+ isolationMarkerPresent: stalePaneMarkerPresent,
11782
+ policyOffTombstonePresent,
11783
+ policyOffTombstoneValid: policyOffTombstoneIsValid,
11784
+ paneProbe,
11785
+ pendingProvenancePresent,
11786
+ isolationMarkerReattachSafe,
11787
+ });
11788
+ // Verified removal of a provenance file: unlink then confirm it is truly gone
11789
+ // (no-follow). A leaf we cannot remove (directory / planted / perm) must FAIL
11790
+ // CLOSED — never fall through to publish a new generation, or a later restart
11791
+ // re-reads the stale proof and mis-kills the fresh pane in a loop.
11792
+ const removeProvenanceOrThrow = (path, label) => {
11793
+ try {
11794
+ unlinkSync(path);
11369
11795
  }
11370
- else {
11371
- // Missing/legacy marker → pane predates the current policy and may retain
11372
- // obsolete permissions. Kill it before publishing any new capability.
11373
- log(`[read-isolation] legacy/unmarked persistent pane for ${cfg.sessionId} killing + cold-spawning with current policy`);
11374
- // Capture the name before re-selection: `persistentSessionName` is
11375
- // reassigned from the new selection below and widens back to
11376
- // `string | undefined`, but the backing name we are tearing down is
11377
- // this one and does not change.
11378
- const staleSessionName = persistentSessionName;
11379
- const stalePersistentTarget = selectedBackend.persistentBackendTarget;
11796
+ catch { /* may already be absent — verified below */ }
11797
+ if (hostEntryExistsNoFollow(path)) {
11798
+ throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: `
11799
+ + `could not remove stale ${label} at ${path}`);
11800
+ }
11801
+ };
11802
+ // Capture the stale name/target BEFORE any re-selection below (reselect
11803
+ // reassigns persistentSessionName and widens it back to string | undefined).
11804
+ const staleSessionName = persistentSessionName;
11805
+ const stalePersistentTarget = selectedBackend.persistentBackendTarget;
11806
+ const migrationEffects = {
11807
+ killStalePane: () => {
11380
11808
  try {
11381
11809
  // ZMX keeps its own call here rather than going through the target
11382
11810
  // helper: only this path holds the frozen PID, which makes the
11383
11811
  // ownership check stricter than the name+label check.
11384
11812
  if (effectiveBackendType === 'zmx') {
11385
- ZmxBackend.killManagedSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid);
11813
+ ZmxBackend.killManagedSession(staleSessionName, cfg.sessionId, resolvedZmxSessionPid);
11814
+ }
11815
+ else if (stalePersistentTarget) {
11816
+ killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId);
11386
11817
  }
11387
11818
  else {
11388
- if (stalePersistentTarget)
11389
- killPersistentBackendTarget(stalePersistentTarget, cfg.sessionId);
11390
- else
11391
- killPersistentSession(effectiveBackendType, persistentSessionName, cfg.sessionId);
11819
+ killPersistentSession(effectiveBackendType, staleSessionName, cfg.sessionId);
11392
11820
  }
11393
11821
  }
11394
11822
  catch (e) {
11395
11823
  throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: could not kill stale persistent pane (${e.message})`);
11396
11824
  }
11825
+ },
11826
+ confirmPaneGone: () => {
11397
11827
  const postKillProbe = effectiveBackendType === 'zmx'
11398
11828
  ? probeOwnedZmxSession(staleSessionName, cfg.sessionId).probe
11399
11829
  : (stalePersistentTarget
11400
11830
  ? probePersistentBackendTarget(stalePersistentTarget)
11401
11831
  : probePersistentSession(effectiveBackendType, staleSessionName));
11402
- if (shouldRejectPersistentPostKillProbe(effectiveBackendType, postKillProbe)) {
11403
- throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ` +
11404
- `could not confirm stale ${effectiveBackendType} pane termination`);
11832
+ // Migration teardown fail-closes on ANY non-`missing` post-kill probe for
11833
+ // EVERY backend not just ZMX. `exists` (kill didn't take) and `unknown`
11834
+ // (kill unconfirmed: tmux swallows kill errors incl. timeout, zellij does
11835
+ // not check its spawnSync exit) both mean "the confined pane may still be
11836
+ // alive", so publishing a new generation around it would silently keep the
11837
+ // old confinement. Only an authoritative `missing` confirms termination.
11838
+ // (This is STRICTER than the shared shouldRejectPersistentPostKillProbe,
11839
+ // which the separate mcp-gateway gate still uses with its own semantics.)
11840
+ if (postKillProbe !== 'missing') {
11841
+ throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: `
11842
+ + `could not confirm stale ${effectiveBackendType} pane termination `
11843
+ + `(post-kill probe: ${postKillProbe})`);
11405
11844
  }
11406
11845
  if (effectiveBackendType === 'zmx') {
11407
11846
  resolvedZmxSessionProbe = postKillProbe;
11408
11847
  resolvedZmxSessionPid = undefined;
11409
11848
  }
11849
+ },
11850
+ clearProvenanceVerified: () => {
11851
+ // Clear BOTH files (verified). Order-independent — both must end absent.
11852
+ if (stalePaneMarkerPresent)
11853
+ removeProvenanceOrThrow(stalePaneMarkerPath, 'isolation marker');
11854
+ if (policyOffTombstonePresent)
11855
+ removeProvenanceOrThrow(policyOffTombstoneFilePath, 'policy-off tombstone');
11856
+ },
11857
+ reselectBackend: () => {
11410
11858
  // ZMX backend selection consumes the frozen probe. Refresh it before
11411
11859
  // re-selecting or the replacement keeps isReattach=true for the pane
11412
11860
  // that this gate just proved was removed.
@@ -11417,8 +11865,40 @@ async function spawnCli(cfg, opts = {}) {
11417
11865
  backend = selectedBackend.backend;
11418
11866
  cliLifetimeNonce++;
11419
11867
  persistentSessionName = selectedBackend.persistentSessionName;
11868
+ },
11869
+ refuseInconclusiveProbe: () => {
11870
+ // The liveness probe was `unknown` where acting would be unsafe (the pane
11871
+ // may still be alive AND still confined under an obsolete policy). No
11872
+ // provenance is touched, no reselect — fail closed and let the next launch
11873
+ // re-probe once the backend is answering again.
11874
+ throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: `
11875
+ + `could not verify existing ${effectiveBackendType} pane `
11876
+ + `(liveness probe: ${paneProbe})`);
11877
+ },
11878
+ };
11879
+ if (migration.action === 'reattach') {
11880
+ if (originChannelPolicyExpected) {
11881
+ persistentPaneOriginChannelId = isolatedPaneOriginChannel(marker);
11420
11882
  }
11883
+ // Pane matches the current policy (isolated pane stamped under it, or a
11884
+ // no-transport pane with a VALIDATED policy-off tombstone) → still valid on
11885
+ // the running process across daemon restarts; warm reattach preserves
11886
+ // resume/context + tmux idle-suspend.
11887
+ log(`[read-isolation] reattaching persistent pane under current policy (${cfg.sessionId})`);
11888
+ }
11889
+ else if (migration.action === 'clear-stale-then-cold-spawn') {
11890
+ log(`[read-isolation] clearing stale provenance for dead pane before cold-spawn (${cfg.sessionId})`);
11891
+ }
11892
+ else if (migration.action === 'kill-then-cold-spawn') {
11893
+ log(`[read-isolation] persistent pane provenance mismatch for ${cfg.sessionId} — killing + cold-spawning with current policy`);
11421
11894
  }
11895
+ else if (migration.action === 'refuse-inconclusive-probe') {
11896
+ log(`[read-isolation] inconclusive liveness probe (${paneProbe}) for ${cfg.sessionId} — refusing to start rather than clear/cold-spawn around a possibly-live confined pane`);
11897
+ }
11898
+ // Ordered, fail-closed side effects (kill → confirm → clear → reselect) live
11899
+ // in executePersistentPaneMigration so the ordering + stop-on-failure
11900
+ // guarantees are unit-testable with injected mocks.
11901
+ executePersistentPaneMigration(migration, migrationEffects);
11422
11902
  }
11423
11903
  readIsolationOriginChannelId = managedOriginChannelRequired
11424
11904
  ? (persistentPaneOriginChannelId ?? randomBytes(32).toString('hex'))
@@ -11571,6 +12051,13 @@ async function spawnCli(cfg, opts = {}) {
11571
12051
  bareShellChecked = false;
11572
12052
  bareShellCheckInProgress = false;
11573
12053
  // ── Resume pre-flight check + two-tier fallback ──────────────────────────
12054
+ // Tier 0 (adapter capability): adapters whose buildArgs can only resume a
12055
+ // PRECISE cliSessionId (cursor/copilot/kimi — no --continue/latest fallback,
12056
+ // which would risk resuming a SIBLING session's conversation) start FRESH
12057
+ // when no id was persisted. Demote here so the user-visible fresh notice
12058
+ // fires — leaving effectiveResume=true would have the adapter silently
12059
+ // launch a blank session while the closed card / resume receipt still claim
12060
+ // history was restored.
11574
12061
  // Tier 1 (adapter probe): adapter.checkResumeTargetExists returns false
11575
12062
  // → skip --resume, spawn FRESH.
11576
12063
  // Tier 2 (restart count): 2nd consecutive in-worker restart → force FRESH,
@@ -11615,8 +12102,15 @@ async function spawnCli(cfg, opts = {}) {
11615
12102
  ? resolveHermesStateDbPath({ ...process.env, ...sanitizePerBotEnv(cfg.env), BOTMUX_SESSION_ID: cfg.sessionId }, { botmuxSessionProfile: basename(cfg.cliPathOverride ?? '') === 'hermes-botmux-session' })
11616
12103
  : undefined;
11617
12104
  const tier2ForceFresh = effectiveResume && consecutiveInWorkerRestarts >= 2;
12105
+ // Tier 0: see block comment above. The adapter itself drops --resume when
12106
+ // the id is missing (its buildArgs starts fresh); demoting HERE keeps
12107
+ // effectiveResume honest so the fresh-demotion notice below fires.
12108
+ const missingExactResumeId = effectiveResume
12109
+ && !willReattachPersistent
12110
+ && !effectiveCliSessionId
12111
+ && cliAdapter.resumeRequiresCliSessionId === true;
11618
12112
  let tier1ProbeFalse = false;
11619
- if (effectiveResume && !tier2ForceFresh && !willReattachPersistent) {
12113
+ if (effectiveResume && !tier2ForceFresh && !willReattachPersistent && !missingExactResumeId) {
11620
12114
  const probe = cliAdapter.checkResumeTargetExists?.({
11621
12115
  sessionId: effectiveAdapterSessionId,
11622
12116
  cliSessionId: effectiveCliSessionId,
@@ -11627,11 +12121,13 @@ async function spawnCli(cfg, opts = {}) {
11627
12121
  if (probe === false)
11628
12122
  tier1ProbeFalse = true;
11629
12123
  }
11630
- const fallBackToFresh = effectiveResume && !willReattachPersistent && (tier1ProbeFalse || tier2ForceFresh);
12124
+ const fallBackToFresh = effectiveResume && !willReattachPersistent && (tier1ProbeFalse || tier2ForceFresh || missingExactResumeId);
11631
12125
  if (fallBackToFresh) {
11632
12126
  const reason = tier2ForceFresh
11633
12127
  ? `consecutive restart x${consecutiveInWorkerRestarts} — 2nd failed resume attempt`
11634
- : 'adapter confirmed resume target does not exist on disk';
12128
+ : missingExactResumeId
12129
+ ? 'no persisted CLI session id — this CLI can only resume a precise session (no --continue fallback)'
12130
+ : 'adapter confirmed resume target does not exist on disk';
11635
12131
  log(`Resume fallback: dropping --resume (${reason}) → fresh session ${cfg.sessionId}`);
11636
12132
  effectiveResume = false;
11637
12133
  effectiveCliSessionId = undefined;
@@ -12011,6 +12507,10 @@ async function spawnCli(cfg, opts = {}) {
12011
12507
  const chatBotDiscovery = resolveChatBotDiscoveryConfig();
12012
12508
  childEnv.BOTMUX_LARK_LIST_BOTS_API_ENABLED = chatBotDiscovery.listBotsApiEnabled ? 'true' : 'false';
12013
12509
  childEnv.BOTMUX_LARK_LIST_BOTS_API_TIMEOUT_MS = String(chatBotDiscovery.listBotsApiTimeoutMs);
12510
+ // Inject the resolved workflow kill-switch so a pane's `botmux workflow …`
12511
+ // subcommand agrees with the daemon that spawned it, independent of stale
12512
+ // rcfile/tmux env (mirrors the chatBotDiscovery injection above).
12513
+ childEnv.BOTMUX_WORKFLOW_ENABLED = isWorkflowFeatureEnabled() ? 'true' : 'false';
12014
12514
  if (cliAdapter.injectsReadyHook)
12015
12515
  childEnv.BOTMUX_READY_COMMAND = sessionReadyHookCommand();
12016
12516
  // Initial value only; long-lived panes get the latest turn via the JSON pid marker.
@@ -12595,23 +13095,69 @@ async function spawnCli(cfg, opts = {}) {
12595
13095
  log(`Sandbox ON (${cfg.cliId}, fs-policy ${policy.rules.length} rules): outbox=${sbx.outbox}`);
12596
13096
  }
12597
13097
  }
12598
- // Fresh sandboxed spawn on a persistent backend: stamp the pane with this
12599
- // daemon's boot id so a later reattach can be trusted (see the stale-pane
12600
- // guard above). pty needs no marker (never reattached).
13098
+ let pendingProvenanceCommit = null;
12601
13099
  if (appliedIsolationCapabilities.length > 0 && persistentSessionName && !willReattachPersistent) {
13100
+ // Policy-ON isolation marker. The PENDING write is a spawn-time ADMISSION
13101
+ // PRECONDITION, NOT best-effort: if we cannot durably record the pending proof
13102
+ // and then backend.spawn() dynamically reattaches a late-arriving same-named
13103
+ // pane (zellij/TmuxBackend flip), the whole commit/teardown block below —
13104
+ // gated on pendingProvenanceCommit — would be SKIPPED, so the late-flip
13105
+ // teardown never runs and this launch keeps running attached to an
13106
+ // unattributed generation. "No committed proof → next launch cold-spawns" only
13107
+ // protects the NEXT launch, not THIS one. So a write failure must FAIL CLOSED
13108
+ // here (throw before spawn), exactly like the policy-off arm.
12602
13109
  try {
12603
- const markerDir = join(isolationRuntimeDataDir, 'read-isolation');
12604
- mkdirSync(markerDir, { recursive: true });
12605
- replaceManagedOriginCapabilityFile(join(markerDir, `${cfg.sessionId}.boot`), isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities, managedOriginChannelPolicyDigest
12606
- ? {
12607
- originChannelId: persistentPaneOriginChannelId,
12608
- readIsolation: willReadIsolate,
12609
- writeSandbox: willWriteSandbox,
12610
- policyDigest: managedOriginChannelPolicyDigest,
12611
- }
12612
- : undefined));
13110
+ mkdirSync(join(isolationRuntimeDataDir, 'read-isolation'), { recursive: true });
13111
+ const nonce = randomBytes(32).toString('hex');
13112
+ const markerPath = isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId);
13113
+ replaceManagedOriginCapabilityFile(markerPath, provenancePendingContent(nonce));
13114
+ pendingProvenanceCommit = {
13115
+ path: markerPath,
13116
+ nonce,
13117
+ committedContent: isolationPaneMarkerContent(cfg.daemonBootId ?? '', appliedIsolationCapabilities, managedOriginChannelPolicyDigest
13118
+ ? {
13119
+ originChannelId: persistentPaneOriginChannelId,
13120
+ readIsolation: willReadIsolate,
13121
+ writeSandbox: willWriteSandbox,
13122
+ policyDigest: managedOriginChannelPolicyDigest,
13123
+ }
13124
+ : undefined),
13125
+ // A committed policy-ON generation must not carry a stale policy-off
13126
+ // tombstone (a later flip to policy-off could read it as a no-sandbox gen).
13127
+ clearSiblingPath: policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId),
13128
+ label: 'isolation marker',
13129
+ };
13130
+ }
13131
+ catch (e) {
13132
+ throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: `
13133
+ + `could not record pending isolation-marker generation proof (${e.message})`);
13134
+ }
13135
+ }
13136
+ else if (appliedIsolationCapabilities.length === 0 && persistentSessionName
13137
+ && !willReattachPersistent && noTransportSession && isolationCapableBackend) {
13138
+ // Policy-OFF generation proof (tmux-only: isolationCapableBackend === tmux).
13139
+ // NOT best-effort: if we cannot durably record the PENDING proof we must fail
13140
+ // closed rather than spawn a pane we can never prove. The committed tombstone
13141
+ // is written post-spawn only on a confirmed fresh generation.
13142
+ try {
13143
+ mkdirSync(join(isolationRuntimeDataDir, 'read-isolation'), { recursive: true });
13144
+ const nonce = randomBytes(32).toString('hex');
13145
+ const tombstonePath = policyOffTombstonePath(isolationRuntimeDataDir, cfg.sessionId);
13146
+ replaceManagedOriginCapabilityFile(tombstonePath, provenancePendingContent(nonce));
13147
+ pendingProvenanceCommit = {
13148
+ path: tombstonePath,
13149
+ nonce,
13150
+ committedContent: policyOffTombstoneContent(cfg.daemonBootId ?? ''),
13151
+ // A committed policy-off generation must not be shadowed by a stale
13152
+ // isolation marker (which DOMINATES the tombstone in the guard).
13153
+ clearSiblingPath: isolationPaneMarkerPath(isolationRuntimeDataDir, cfg.sessionId),
13154
+ label: 'policy-off tombstone',
13155
+ };
13156
+ }
13157
+ catch (e) {
13158
+ throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: `
13159
+ + `could not record pending policy-off generation proof (${e.message})`);
12613
13160
  }
12614
- catch { /* non-fatal: worst case a same-lifetime reattach cold-spawns instead */ }
12615
13161
  }
12616
13162
  // 通用启动前缀(wrapperCli):把启动命令重写成 `<wrapperCli> <CLI 参数>`(首 token 当
12617
13163
  // bin 走 PATH 解析),无需 wrapper 脚本、跨系统。aiden x claude 形态会剥掉 aiden 拒收的
@@ -12624,12 +13170,53 @@ async function spawnCli(cfg, opts = {}) {
12624
13170
  // ever (re)set it. Harmless for non-cjadk CLIs (they don't read it), but this
12625
13171
  // keeps the behaviour intentional rather than ambient. (Codex review note.)
12626
13172
  delete childEnv.CJADK_INTERACTIVE;
13173
+ // mojo: hand the generic extra args (CLI_EXTRA_ARGS) to the backend through the
13174
+ // config instead of through spawn args, and keep them OUT of the wrapper prefix.
13175
+ //
13176
+ // buildWrappedLaunch folds whatever it is given into the prefix, i.e. BEFORE the
13177
+ // per-turn flags the backend appends. With last-value-wins parsing that
13178
+ // silently inverted precedence: `CLI_EXTRA_ARGS=--idle-timeout 77` plus
13179
+ // `mojo.idleTimeoutSec=12` produced `--idle-timeout 77 … --idle-timeout 12` and
13180
+ // 12 won, contradicting the documented "extra args can override built-ins".
13181
+ // Carrying them separately lets the backend append them AFTER its own flags on
13182
+ // every turn, which is the same order the no-wrapper path already produced.
13183
+ if (effectiveBackendType === 'mojo' && spawnArgs.length > 0) {
13184
+ // Refuse platform-owned flags. Extra args are appended AFTER the backend's
13185
+ // own flags so an operator can override behaviour knobs — but with
13186
+ // last-value-wins that also let CLI_EXTRA_ARGS override the control-plane
13187
+ // identity frozen on the session (`--workspace-id`, `--agent-id`, `--cloud`),
13188
+ // the approval bypass (`--yolo`) or the resume lineage (`-r`). Fail closed
13189
+ // rather than silently dropping them: a dropped flag reads as applied.
13190
+ const reserved = findReservedMojoCliFlags(spawnArgs);
13191
+ if (reserved.length > 0) {
13192
+ throw new Error(`CLI_EXTRA_ARGS may not set ${reserved.join(' / ')} for a mojo bot — `
13193
+ + 'these are platform-owned (control plane frozen per session, approval '
13194
+ + 'bypass, resume lineage). Configure them through the bot settings instead.');
13195
+ }
13196
+ riffBackendConfig.extraCliArgs = [...spawnArgs];
13197
+ log(`mojo extra CLI args deferred to per-turn append: ${spawnArgs.join(' ')}`);
13198
+ spawnArgs = [];
13199
+ }
12627
13200
  if (cfg.wrapperCli && cfg.wrapperCli.trim()) {
12628
13201
  if (sandboxRequested) {
12629
13202
  log(`wrapperCli="${cfg.wrapperCli}" ignored: file sandbox enabled and takes precedence (cannot combine launch prefix with the sandbox wrapper)`);
12630
13203
  }
12631
13204
  else {
12632
- const launch = buildWrappedLaunch(cfg.wrapperCli, spawnArgs, (b) => locateOnPath(b) ?? b, {
13205
+ // Resolve wrapper binaries against the EFFECTIVE child PATH the exact env
13206
+ // the child will really run with — not the daemon's own. `locateOnPath`
13207
+ // reads this process's env, so an ambient install shadowed a per-bot one.
13208
+ // The layering MUST come from the shared buildEffectiveChildEnv: this site
13209
+ // previously merged only childEnv + bot env and omitted the
13210
+ // highest-precedence `mojo.env`, so a wrapper pinned via `mojo.env.PATH`
13211
+ // lost to a same-named binary on the bot-level PATH.
13212
+ const effectiveChildEnv = buildEffectiveChildEnv({
13213
+ base: childEnv,
13214
+ botEnv: perBotInjectEnv,
13215
+ mojoEnv: effectiveBackendType === 'mojo'
13216
+ ? riffBackendConfig?.env
13217
+ : undefined,
13218
+ });
13219
+ const launch = buildWrappedLaunch(cfg.wrapperCli, spawnArgs, (b) => locateOnEffectiveChildPath(b, effectiveChildEnv) ?? b, {
12633
13220
  ttadkModel: cfg.model,
12634
13221
  });
12635
13222
  if (launch.bin) {
@@ -12872,6 +13459,123 @@ async function spawnCli(cfg, opts = {}) {
12872
13459
  }
12873
13460
  const actuallyReattachedPersistent = 'isReattach' in backend
12874
13461
  && backend.isReattach === true;
13462
+ // ── Generational-race commit/teardown for the read-isolation provenance proof ──
13463
+ // We wrote a PENDING proof before spawn (pendingProvenanceCommit). Now that spawn
13464
+ // has returned we know whether a FRESH generation was actually established.
13465
+ if (pendingProvenanceCommit) {
13466
+ const commit = pendingProvenanceCommit;
13467
+ // Verified removal of a proof file: unlink then confirm truly gone (no-follow).
13468
+ // A leaf we cannot remove must FAIL CLOSED — never leave an ambiguous proof.
13469
+ const removeProofOrThrow = (path, label) => {
13470
+ try {
13471
+ unlinkSync(path);
13472
+ }
13473
+ catch { /* may already be absent — verified below */ }
13474
+ if (hostEntryExistsNoFollow(path)) {
13475
+ throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: `
13476
+ + `could not remove ${label} at ${path}`);
13477
+ }
13478
+ };
13479
+ // Teardown the exact just-launched target, confirm authoritative `missing`,
13480
+ // and only THEN keep/clear the pending proof per the tri-state result. On a
13481
+ // non-missing (exists/unknown) post-kill probe we KEEP the pending proof and
13482
+ // refuse — never erase evidence of a possibly-live pane.
13483
+ //
13484
+ // CRITICAL: kill the EXACT backend target, NOT the session name. An isolated /
13485
+ // MCP herdr task lives as an agent on the SHARED host session `botmux`
13486
+ // (target = {sessionName:'botmux', agentName:<this topic>}); a name-only
13487
+ // killPersistentSession('herdr','botmux') would tear down the whole shared host
13488
+ // (every bot/topic agent). Mirror the migration effects' killStalePane /
13489
+ // confirmPaneGone: target helper for herdr's agent scope, frozen-PID path for
13490
+ // ZMX identity, name only as the last-resort fallback when no target exists.
13491
+ const teardownTarget = selectedBackend.persistentBackendTarget;
13492
+ const tearDownAndRefuse = (why) => {
13493
+ if (persistentSessionName) {
13494
+ // Pure policy (behaviorally tested): herdr's shared-host agent MUST be
13495
+ // killed via its target, never by the 'botmux' session name.
13496
+ const killKind = persistentTeardownKillKind({
13497
+ backendType: effectiveBackendType,
13498
+ hasBackendTarget: !!teardownTarget,
13499
+ });
13500
+ try {
13501
+ if (killKind === 'zmx') {
13502
+ ZmxBackend.killManagedSession(persistentSessionName, cfg.sessionId, resolvedZmxSessionPid);
13503
+ }
13504
+ else if (killKind === 'target') {
13505
+ killPersistentBackendTarget(teardownTarget, cfg.sessionId);
13506
+ }
13507
+ else {
13508
+ killPersistentSession(effectiveBackendType, persistentSessionName, cfg.sessionId);
13509
+ }
13510
+ }
13511
+ catch (killErr) {
13512
+ throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${why}; `
13513
+ + `could not kill the exact target (${killErr?.message ?? killErr}) — pending proof retained`);
13514
+ }
13515
+ const postKill = killKind === 'zmx'
13516
+ ? probeOwnedZmxSession(persistentSessionName, cfg.sessionId).probe
13517
+ : (killKind === 'target'
13518
+ ? probePersistentBackendTarget(teardownTarget)
13519
+ : probePersistentSession(effectiveBackendType, persistentSessionName));
13520
+ if (postKill !== 'missing') {
13521
+ throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${why}; `
13522
+ + `post-kill probe ${postKill} (not missing) — pending proof retained`);
13523
+ }
13524
+ }
13525
+ throw new Error(`[read-isolation] refusing to start session ${cfg.sessionId}: ${why}`);
13526
+ };
13527
+ // Condition #2: a predicted-fresh launch that backend.spawn() dynamically
13528
+ // flipped into a reattach (a same-named pane arrived before the in-spawn probe)
13529
+ // must NOT keep running — the live pane is a foreign/unknown generation. Tear
13530
+ // it down and refuse rather than "don't commit but keep running".
13531
+ if (actuallyReattachedPersistent) {
13532
+ tearDownAndRefuse('predicted-fresh persistent launch dynamically reattached a late-arriving pane');
13533
+ }
13534
+ else if (effectiveBackendType === 'zellij') {
13535
+ // Option B: zellij's session is created ASYNCHRONOUSLY inside the pty child,
13536
+ // so a synchronous `!isReattach` here does NOT prove a fresh generation
13537
+ // attributable to THIS launch (a late collision can still occur after the
13538
+ // last in-spawn hasSession probe). We have no synchronous attributable
13539
+ // signal, so we DELIBERATELY do not commit: the pending proof is left on
13540
+ // disk, and this isolation-capable zellij pane will always cold-spawn on the
13541
+ // next daemon restart/suspend-resume (no warm-reattach). The attributable-ack
13542
+ // protocol that would restore zellij warm-reattach is a separate follow-up.
13543
+ log(`[read-isolation] zellij persistent pane ${cfg.sessionId}: leaving PENDING proof (isolation-capable zellij does not warm-reattach; cold-spawn on next launch)`);
13544
+ }
13545
+ else {
13546
+ // tmux(-pipe) / herdr / zmx: attributable-fresh at the synchronous spawn
13547
+ // return — TmuxPipe throws on new-session collision, herdr/zmx throw on a
13548
+ // name collision / verify their own bootstrap+launchPid handshake. So a
13549
+ // returned spawn + !isReattach here IS a fresh generation created by THIS
13550
+ // launch. COMMIT with compare-before-replace + generation fence.
13551
+ try {
13552
+ // F1 generation fence: a superseded spawn must not let this commit run.
13553
+ if (spawnGeneration !== cliSpawnGeneration)
13554
+ throw new CliSpawnSupersededError();
13555
+ // F2 compare-before-replace: the pending file on disk must STILL be our
13556
+ // nonce (a same-worker restart / backend replacement between the pending
13557
+ // write and here could have replaced it with a newer generation's pending).
13558
+ const current = provenancePendingNonce(readManagedOriginAuthorityFile(commit.path));
13559
+ if (current !== commit.nonce) {
13560
+ throw new Error(`pending proof nonce mismatch (superseded generation) at ${commit.path}`);
13561
+ }
13562
+ // Mutual exclusivity: clear the sibling proof (verified) BEFORE committing,
13563
+ // so the committed generation is never shadowed by a stale sibling.
13564
+ if (commit.clearSiblingPath)
13565
+ removeProofOrThrow(commit.clearSiblingPath, `stale ${commit.label} sibling`);
13566
+ // Atomic replace pending → committed.
13567
+ replaceManagedOriginCapabilityFile(commit.path, commit.committedContent);
13568
+ }
13569
+ catch (err) {
13570
+ if (err instanceof CliSpawnSupersededError)
13571
+ throw err;
13572
+ // Condition #3: a commit-write failure must not leave an ambiguous started
13573
+ // pane running as if successful — tear down the exact target and refuse.
13574
+ // (The pending proof, if it survived, keeps the next launch fail-closed.)
13575
+ tearDownAndRefuse(`could not commit ${commit.label} generation proof (${err.message})`);
13576
+ }
13577
+ }
13578
+ }
12875
13579
  try {
12876
13580
  finalizeCodexAppControlGeneration(cfg, actuallyReattachedPersistent, !!persistentSessionName);
12877
13581
  }
@@ -13272,11 +13976,12 @@ async function spawnCli(cfg, opts = {}) {
13272
13976
  else
13273
13977
  markPromptReady();
13274
13978
  };
13275
- // Set up idle detection. Riff (remote HTTP backend) has no PTY output and
13276
- // is marked ready immediately after spawn (see below), so the idle detector
13979
+ // Set up idle detection. Remote backends (riff / mojo) have no PTY output and
13980
+ // are marked ready immediately after spawn (see below), so the idle detector
13277
13981
  // is unnecessary — and without a readyPattern it would fire on every
13278
13982
  // quiescence, repeatedly triggering markPromptReady() and duplicate cards.
13279
- if (effectiveBackendType !== 'riff') {
13983
+ // (For mojo it is also actively harmful — see MojoBackend.settleTurn.)
13984
+ if (!isRemoteBackendType(effectiveBackendType)) {
13280
13985
  idleDetector = new IdleDetector(cliAdapter);
13281
13986
  wireIdleDetectorBusyTransition(idleDetector, `${cliName()} PTY`);
13282
13987
  idleDetector.onIdle(async (evidenceSource) => {
@@ -13367,6 +14072,22 @@ async function spawnCli(cfg, opts = {}) {
13367
14072
  log(`${cliName()} task finished — re-arming prompt-ready for queued follow-ups`);
13368
14073
  markPromptReady();
13369
14074
  });
14075
+ // Headless final-answer bridge (mojo): deliver the turn's answer to the
14076
+ // thread when the agent produced one but never called `botmux send`. Same
14077
+ // generation fence as onTaskDone above — a late callback from a backend the
14078
+ // worker has already replaced must not post into the current turn.
14079
+ backend.onTurnFinal?.((text) => {
14080
+ if (fatalWorkerErrorPending)
14081
+ return;
14082
+ if (backend !== observedBackend)
14083
+ return;
14084
+ try {
14085
+ deliverMojoTurnFinal(text);
14086
+ }
14087
+ catch (err) {
14088
+ log(`Mojo final bridge failed: ${err instanceof Error ? err.message : String(err)}`);
14089
+ }
14090
+ });
13370
14091
  // riff:任务 id 变更同步给 daemon 持久化,daemon 重启后 follow-up 血缘不断。
13371
14092
  backend.onTaskId?.((taskId) => {
13372
14093
  send({ type: 'riff_task_id', taskId });
@@ -13517,6 +14238,7 @@ async function spawnCli(cfg, opts = {}) {
13517
14238
  isPromptReady = false;
13518
14239
  currentBotmuxTurnId = undefined;
13519
14240
  currentBotmuxDispatchAttempt = undefined;
14241
+ currentGatewayTrustedCaller = undefined;
13520
14242
  if (!intentionalRestart && activeRestartAttemptId) {
13521
14243
  send({
13522
14244
  type: 'restart_result',
@@ -13606,15 +14328,79 @@ async function spawnCli(cfg, opts = {}) {
13606
14328
  markPromptReady();
13607
14329
  };
13608
14330
  setTimeout(() => releaseFirstPromptTimeout(FIRST_PROMPT_TIMEOUT_MS, false), FIRST_PROMPT_TIMEOUT_MS);
13609
- // Riff (and other remote HTTP backends) have no local boot process — the
13610
- // backend is ready immediately after spawn(). The idle detector never fires
13611
- // for them (no PTY output), and the first-prompt timeout only flushes for
13612
- // type-ahead adapters, so isPromptReady would otherwise stay false forever
13613
- // and the first message would never reach the riff API. Mark ready right away.
13614
- if (effectiveBackendType === 'riff') {
14331
+ // Remote backends (riff / mojo) have no local boot process — the backend is
14332
+ // ready immediately after spawn(). The idle detector never fires for them (no
14333
+ // PTY output), and the first-prompt timeout only flushes for type-ahead
14334
+ // adapters, so isPromptReady would otherwise stay false until the ~15s
14335
+ // fallback and every first message would be needlessly delayed.
14336
+ if (isRemoteBackendType(effectiveBackendType)) {
14337
+ // BEFORE markPromptReady, which can flush a queued turn: a replacement
14338
+ // backend is built from the original init config, so a rotated — or cleared —
14339
+ // credential must be re-applied first or the queued turn runs against the
14340
+ // revived token.
14341
+ restoreMojoLivePatchAfterRespawn();
13615
14342
  markPromptReady();
13616
14343
  }
13617
14344
  }
14345
+ /**
14346
+ * Hand a credential/behaviour patch to a live MojoBackend.
14347
+ *
14348
+ * Validated first: the daemon is trusted, but this arrives over IPC and a stale
14349
+ * or malformed payload must not reach the backend. Non-mojo backends ignore it.
14350
+ */
14351
+ /**
14352
+ * Last VALIDATED credential snapshot applied to a backend in this worker.
14353
+ *
14354
+ * Survives a backend replacement (`/restart`, crash respawn, lease-fenced
14355
+ * restart), which is the only place it matters: a replacement backend is
14356
+ * constructed from the ORIGINAL init config, so a credential that had since been
14357
+ * rotated — or, worse, cleared — silently reverted. A queued clear turn then ran
14358
+ * against the revived token. Restart IPC deliberately does NOT need to carry the
14359
+ * snapshot; the worker already knows it.
14360
+ */
14361
+ let lastAppliedMojoLivePatch;
14362
+ function applyMojoLivePatch(patch) {
14363
+ if (!patch || effectiveBackendType !== 'mojo')
14364
+ return;
14365
+ // Patch-specific validator. The CONFIG validator was wrong here: it requires a
14366
+ // non-empty `jwt` string, so it rejected every `null` tombstone and the backend
14367
+ // never received a clear request — the credential stayed live for the worker's
14368
+ // whole lifetime. This one accepts exactly `{ jwt: string | null }`.
14369
+ const validated = normalizeMojoLivePatch(patch);
14370
+ if (!validated.ok) {
14371
+ log(`Ignoring invalid mojo live patch: ${validated.errors.join('; ')}`);
14372
+ return;
14373
+ }
14374
+ if (validated.value.jwt !== undefined)
14375
+ lastAppliedMojoLivePatch = validated.value;
14376
+ pushMojoLivePatchToBackend(validated.value);
14377
+ }
14378
+ /** Hand an already-validated snapshot to the live backend, if it can take one. */
14379
+ function pushMojoLivePatchToBackend(patch) {
14380
+ // Duck-typed rather than `instanceof`: importing MojoBackend as a value here
14381
+ // would pull the backend module into the worker's eager import graph for every
14382
+ // CLI, and the capability check is what actually matters.
14383
+ const current = backend;
14384
+ if (typeof current?.applyLivePatch !== 'function')
14385
+ return;
14386
+ current.applyLivePatch(patch);
14387
+ }
14388
+ /**
14389
+ * Re-apply the remembered snapshot onto a freshly installed backend.
14390
+ *
14391
+ * Must run AFTER the replacement backend is installed and BEFORE any pending turn
14392
+ * is flushed into it, or a queued clear would execute against the reverted
14393
+ * credential.
14394
+ */
14395
+ function restoreMojoLivePatchAfterRespawn() {
14396
+ if (effectiveBackendType !== 'mojo')
14397
+ return;
14398
+ const remembered = lastAppliedMojoLivePatch;
14399
+ if (!remembered || remembered.jwt === undefined)
14400
+ return;
14401
+ pushMojoLivePatchToBackend(remembered);
14402
+ log(`Restored live mojo JWT state after backend replacement (${remembered.jwt === null ? 'cleared' : 'rotated'})`);
14403
+ }
13618
14404
  function killCli(opts = {}) {
13619
14405
  // Invalidate an in-flight async spawn before any other teardown hook runs. The
13620
14406
  // remaining cleanup is synchronous today, but generation ownership must not
@@ -13692,6 +14478,7 @@ function killCli(opts = {}) {
13692
14478
  readIsolationOriginChannelId = null;
13693
14479
  currentBotmuxTurnId = undefined;
13694
14480
  currentBotmuxDispatchAttempt = undefined;
14481
+ currentGatewayTrustedCaller = undefined;
13695
14482
  currentVcMeetingImTurnOrigin = undefined;
13696
14483
  submittedCodexAppReplyTurnIds.clear();
13697
14484
  pendingCodexAppSteerAckIds.clear();
@@ -13778,14 +14565,39 @@ async function restartCliProcess(reason, opts = {}) {
13778
14565
  if (restartingBackend)
13779
14566
  intentionalRestartBackend = restartingBackend;
13780
14567
  const teardown = restartingBackend?.destroySession?.();
14568
+ let teardownOutcome = { kind: 'absent' };
13781
14569
  if (teardown && typeof teardown.then === 'function') {
13782
- try {
13783
- await Promise.race([
13784
- teardown,
13785
- new Promise(resolve => setTimeout(resolve, 22_000)),
13786
- ]);
13787
- }
13788
- catch { /* destroySession logs its own failure details */ }
14570
+ // Every branch is CAPTURED rather than discarded. The old code raced the
14571
+ // promise against a 22s sleep inside `try { } catch {}`, so a resolved
14572
+ // ok:false, a won timeout and a rejection were all indistinguishable from a
14573
+ // clean teardown — and all three fell through to killCli() + respawn.
14574
+ teardownOutcome = await Promise.race([
14575
+ Promise.resolve(teardown).then((raw) => ({ kind: 'settled', raw }), (error) => ({ kind: 'rejected', error })),
14576
+ new Promise(resolve => setTimeout(() => resolve({ kind: 'timeout' }), 22_000)),
14577
+ ]);
14578
+ }
14579
+ const teardownVerdict = classifyRestartTeardown(teardownOutcome, {
14580
+ remote: isRemoteBackendType(effectiveBackendType),
14581
+ });
14582
+ if (!teardownVerdict.mayRespawn) {
14583
+ // Fail closed instead of respawning. The old subtree may still hold the
14584
+ // injected credential, so a replacement lineage must not be layered on top
14585
+ // of it: that is exactly how a second credentialed tree used to appear
14586
+ // while the first became unenumerable under a fresh nonce.
14587
+ //
14588
+ // Deliberately NOT calling abortDestroySession(): rollback is not the
14589
+ // legitimate exit from an uncertain teardown (see destroy-result.ts), and
14590
+ // for a latched fence the backend would refuse anyway. Admission therefore
14591
+ // stays fenced, and the persisted containment handle keeps the
14592
+ // device-isolation blocker in place for the next generation to resolve.
14593
+ log(`Restart ABORTED (${reason}): teardown not proven `
14594
+ + `(${teardownVerdict.reason ?? 'unknown'}, `
14595
+ + `${teardownVerdict.recovery ?? 'retryable'}); refusing to respawn a second `
14596
+ + 'lineage over a possibly-live credentialed subtree');
14597
+ // cliRestartInProgress / rawInputRestartGate deliberately stay armed so no
14598
+ // input is accepted while the outcome is unknown.
14599
+ await sendFatalWorkerErrorAndExit(new Error(`restart teardown not proven: ${teardownVerdict.reason ?? 'unknown'}`));
14600
+ return;
13789
14601
  }
13790
14602
  killCli({ preservePending: opts.preservePending });
13791
14603
  awaitingFirstPrompt = true;
@@ -13853,10 +14665,11 @@ async function restartCliProcess(reason, opts = {}) {
13853
14665
  void restartCliProcess(reason, { preservePending: true, skipRestartBudget: followup.skipRestartBudget });
13854
14666
  return;
13855
14667
  }
13856
- // Riff marks itself prompt-ready inside spawnCli(); that early flush is
13857
- // intentionally held by the restart gate above. Release its raw-input
13858
- // fence now; other backends keep it until their later markPromptReady().
13859
- if (effectiveBackendType === 'riff' && isPromptReady)
14668
+ // Remote backends mark themselves prompt-ready inside spawnCli(); that
14669
+ // early flush is intentionally held by the restart gate above. Release
14670
+ // the raw-input fence now; local backends keep it until their later
14671
+ // markPromptReady().
14672
+ if (isRemoteBackendType(effectiveBackendType) && isPromptReady)
13860
14673
  releaseRawInputRestartGate();
13861
14674
  // A local replacement process can exist before its TUI input box does.
13862
14675
  // Only re-kick a prompt that became ready while the restart fence was
@@ -13951,21 +14764,62 @@ function startWebServer(host, preferredPort) {
13951
14764
  },
13952
14765
  });
13953
14766
  wss.on('connection', (ws, req) => {
13954
- wsClients.add(ws);
13955
- // Read access was already checked before the WebSocket handshake. Resolve
13956
- // again here only to decide whether this client may write.
14767
+ // P1-3 — the pre-handshake verdict is a SNAPSHOT, and this callback runs
14768
+ // strictly after it: ws only completes the upgrade once verifyClient
14769
+ // called back, and re-resolving access here re-reads `.dashboard-secret`
14770
+ // synchronously. A capability whose expiry — or the worker generation it
14771
+ // is pinned to, or the host secret it is signed under — turns over inside
14772
+ // that window was authorized by the FIRST check and is already dead by
14773
+ // this one. This callback used to recompute `access` but consume only
14774
+ // `hasWrite`, so such a socket was registered in `wsClients`, seeded with
14775
+ // the terminal scrollback, and — the second resolution carrying no
14776
+ // controlExpiresAt — never given an expiry timer at all: an unbounded
14777
+ // read-only stream outliving its own credential.
14778
+ //
14779
+ // FAIL CLOSED on the SECOND result instead. A socket that no longer
14780
+ // resolves to read authority is closed before any registration, so it
14781
+ // never joins a broadcast set and never receives the history seed; and
14782
+ // the expiry timer below is armed from THIS resolution's expiresAt —
14783
+ // never inherited from the handshake and never skipped.
13957
14784
  const url = parseWorkerRequestUrl(req);
13958
14785
  if (!url) {
13959
14786
  log(`Bad worker WS URL rejected: ${JSON.stringify(req.url ?? '')}`);
13960
- wsClients.delete(ws);
13961
14787
  ws.close(1008, 'Bad Request');
13962
14788
  return;
13963
14789
  }
13964
- const { hasWrite } = resolveTerminalAccessForReq(req, url);
14790
+ const access = resolveTerminalAccessForReq(req, url);
14791
+ const { hasRead, hasWrite, auditUser, controlExpiresAt } = access;
14792
+ if (!hasRead || (controlExpiresAt !== undefined && controlExpiresAt <= Date.now())) {
14793
+ log('WS client refused at the connection re-check: capability no longer valid');
14794
+ ws.close(4003, 'authorization expired');
14795
+ return;
14796
+ }
14797
+ wsClients.add(ws);
13965
14798
  const allowReadOnlyRemoteScroll = canHandleReadOnlyRemoteScroll();
13966
14799
  if (hasWrite)
13967
14800
  authedClients.add(ws);
13968
14801
  log(`WS client connected (total: ${wsClients.size}, write: ${hasWrite})`);
14802
+ // A signed Dashboard grant is fixed-expiry — for READ scope as much as
14803
+ // for write (P1-5). Even if the central proxy's socket invalidation is
14804
+ // delayed or bypassed, the worker independently removes any granted
14805
+ // authority and closes this connection at the signed boundary; a
14806
+ // reconnect must then present a still-valid credential. Read-only
14807
+ // sockets used to outlive their authentication here; now they cannot.
14808
+ let controlExpiryTimer;
14809
+ if (controlExpiresAt !== undefined) {
14810
+ const expiredReason = hasWrite ? 'control expired' : 'view expired';
14811
+ controlExpiryTimer = setTimeout(() => {
14812
+ authedClients.delete(ws);
14813
+ if (ws.readyState === WebSocket.OPEN)
14814
+ ws.close(4003, expiredReason);
14815
+ }, Math.max(0, controlExpiresAt - Date.now()));
14816
+ controlExpiryTimer.unref();
14817
+ }
14818
+ ws.once('close', () => {
14819
+ if (controlExpiryTimer)
14820
+ clearTimeout(controlExpiryTimer);
14821
+ authedClients.delete(ws);
14822
+ });
13969
14823
  if (isTmuxMode && !isPipeMode && sessionId) {
13970
14824
  // ── Tmux-attach mode: per-client attach ──
13971
14825
  // Each WS client gets its own `tmux attach-session` PTY.
@@ -14047,6 +14901,7 @@ function startWebServer(host, preferredPort) {
14047
14901
  // A view capability therefore forwards no input bytes at all.
14048
14902
  if (!authedClients.has(ws))
14049
14903
  return;
14904
+ auditTerminalInput(auditUser, msg.data);
14050
14905
  if (cp)
14051
14906
  cp.write(msg.data);
14052
14907
  else
@@ -14097,7 +14952,12 @@ function startWebServer(host, preferredPort) {
14097
14952
  name: 'xterm-256color',
14098
14953
  cols,
14099
14954
  rows,
14100
- env: zellijEnv(),
14955
+ // redactChildEnv first, matching the tmux viewer above (tmuxEnv()
14956
+ // folds REDACTED_CHILD_ENV_KEYS into its own strip set, zellijEnv()
14957
+ // only drops ZELLIJ*). A viewer client needs none of the daemon's
14958
+ // secrets — bare IM-app creds, GitHub tokens, or the Dashboard H5
14959
+ // credentials — so it must not carry them into a pty it owns.
14960
+ env: zellijEnv(redactChildEnv(process.env)),
14101
14961
  });
14102
14962
  attachStarted = true;
14103
14963
  observeBe?.setLiveAttach(true);
@@ -14130,6 +14990,7 @@ function startWebServer(host, preferredPort) {
14130
14990
  else if (msg.type === 'input' && typeof msg.data === 'string') {
14131
14991
  if (!authedClients.has(ws))
14132
14992
  return;
14993
+ auditTerminalInput(auditUser, msg.data);
14133
14994
  if (cp)
14134
14995
  cp.write(msg.data);
14135
14996
  else
@@ -14215,6 +15076,7 @@ function startWebServer(host, preferredPort) {
14215
15076
  // A read-only view capability must never forward bytes to the backend.
14216
15077
  if (!authedClients.has(ws))
14217
15078
  return;
15079
+ auditTerminalInput(auditUser, msg.data);
14218
15080
  if (usesHerdrSnapshotWebHistory()) {
14219
15081
  if (msg.data.includes('\x1b[<64;'))
14220
15082
  herdrWebScrollDirection = 'up';
@@ -14401,12 +15263,46 @@ if(!hasToken){
14401
15263
  else{var _rb=document.getElementById('readonly-banner');_rb.classList.add('show');_rb.addEventListener('click',function(){_rb.classList.remove('show')});}
14402
15264
  }
14403
15265
 
15266
+ // ── 终端字号按容器宽度自适应 ─────────────────────────────────────────────────
15267
+ // xterm 的 fontSize 是这一页唯一的几何输入:字号写死,列数就只能被容器宽度被动
15268
+ // 决定。14px 是照桌面宽度调的,手机把这一页直嵌进 390px 宽的 iframe 之后一行只
15269
+ // 剩约 44 列 —— 字大得离谱,CLI 的 TUI 还被迫在 44 列上硬折行,逻辑行换行稀碎。
15270
+ // 这里把因果反过来:先定一个可读的目标列数,再由容器实际宽度反推字号。
15271
+ //
15272
+ // _WB_FONT_ADVANCE 是等宽字体的标称「字宽 / 字号」比(JetBrains Mono、Fira Code、
15273
+ // Menlo、DejaVu Sans Mono 都是 0.6),只用于估算;真实列数仍由 xterm 量完字体后
15274
+ // 自己 fit() 出来,估算偏一点最多让列数在目标附近浮动,不会错位。
15275
+ // 只缩不放:算出来比 _WB_FONT_BASE 大一律按 base 走,所以宽度够(约 521px 以上)
15276
+ // 的桌面一律还是 14px,存量渲染零变化;窄容器才往下缩,最低到 _WB_FONT_MIN。
15277
+ //
15278
+ // 注意:这一整段在 worker.ts 的模板字符串里,注释和代码都不能出现反引号,
15279
+ // 也不能出现「美元号 + 左花括号」的插值起始序列,否则会把模板字符串截断。
15280
+ var _WB_FONT_MIN=9,_WB_FONT_MAX=15,_WB_FONT_BASE=14,_WB_FONT_ADVANCE=.6,_WB_TARGET_COLS=62;
15281
+ function _wbAutoFontSize(width){
15282
+ if(!(width>0))return _WB_FONT_BASE;
15283
+ var ideal=width/(_WB_TARGET_COLS*_WB_FONT_ADVANCE);
15284
+ var capped=ideal>_WB_FONT_BASE?_WB_FONT_BASE:ideal;
15285
+ // 半档取整:比整数细一档,又不会停在容易糊掉的亚像素字号上。
15286
+ var stepped=Math.round(capped*2)/2;
15287
+ // 硬边界:字号是这一页唯一的几何输入,任何异常宽度都不许把它推到读不了的档位。
15288
+ if(stepped<_WB_FONT_MIN)return _WB_FONT_MIN;
15289
+ if(stepped>_WB_FONT_MAX)return _WB_FONT_MAX;
15290
+ return stepped;
15291
+ }
15292
+ function _wbTerminalWidth(){
15293
+ var host=document.getElementById('terminal');
15294
+ var w=host?host.clientWidth:0;
15295
+ if(w>0)return w;
15296
+ // 容器还没布局出宽度(隐藏面板、刚插进 DOM 的 iframe)时退回视口宽;再量不到就
15297
+ // 返回 0,_wbAutoFontSize 保持基准字号,等尺寸事件到了再纠正。
15298
+ return window.innerWidth||0;
15299
+ }
14404
15300
  var term=new Terminal({
14405
15301
  theme:{background:'#1a1b26',foreground:'#a9b1d6',cursor:'#c0caf5',
14406
15302
  selectionBackground:'#33467c',black:'#15161e',red:'#f7768e',
14407
15303
  green:'#9ece6a',yellow:'#e0af68',blue:'#7aa2f7',magenta:'#bb9af7',
14408
15304
  cyan:'#7dcfff',white:'#a9b1d6'},
14409
- fontSize:14,fontFamily:"'JetBrains Mono','Fira Code',monospace",
15305
+ fontSize:_wbAutoFontSize(_wbTerminalWidth()),fontFamily:"'JetBrains Mono','Fira Code',monospace",
14410
15306
  cursorBlink:!isTouch,scrollback:50000,allowProposedApi:true
14411
15307
  });
14412
15308
  var fit=new FitAddon.FitAddon();
@@ -14426,7 +15322,53 @@ try{
14426
15322
  }catch(_e){
14427
15323
  try{term.loadAddon(new CanvasAddon.CanvasAddon())}catch(_e2){}
14428
15324
  }
15325
+ // 首帧字号已经在 new Terminal 里按容器宽度定好了;这个函数负责后续尺寸变化
15326
+ // (转屏、iframe 改宽、分屏、地址栏收放)时复算。返回 true 表示字号真的换了档。
15327
+ function _wbApplyAutoFontSize(){
15328
+ var next=_wbAutoFontSize(_wbTerminalWidth());
15329
+ if(term.options.fontSize===next)return false;
15330
+ term.options.fontSize=next;
15331
+ return true;
15332
+ }
14429
15333
  fit.fit();
15334
+ // 工作台下发的终端外观(消息类型 botmux:wb-appearance)。终端画布住在这个跨文档
15335
+ // iframe 里,父页换 class / 换 CSS 变量都传不进来,配色只能靠 postMessage 递过来。
15336
+ // 注意:这一整段在 worker.ts 的模板字符串里,注释和代码都不能出现反引号,
15337
+ // 也不能出现「美元号 + 左花括号」的插值起始序列,否则会把模板字符串截断。
15338
+ // 安全边界:这个页面会被跨 origin 嵌入,因此只认 window.parent 发来的、结构完全
15339
+ // 对得上的消息,且每个颜色都必须是 #rgb / #rrggbb 十六进制字面量;少一个键、多一个
15340
+ // 非法值,整条消息丢弃(宁可保持现状,也不要把半套主题刷到画布上)。
15341
+ var _WB_THEME_KEYS=['background','foreground','cursor','selectionBackground',
15342
+ 'black','red','green','yellow','blue','magenta','cyan','white'];
15343
+ function _wbSanitizeTheme(raw){
15344
+ if(!raw||typeof raw!=='object')return null;
15345
+ var out={};
15346
+ for(var _i=0;_i<_WB_THEME_KEYS.length;_i++){
15347
+ var _k=_WB_THEME_KEYS[_i],_v=raw[_k];
15348
+ if(typeof _v!=='string'||!/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(_v))return null;
15349
+ out[_k]=_v;
15350
+ }
15351
+ return out;
15352
+ }
15353
+ window.addEventListener('message',function(_ev){
15354
+ if(_ev.source===window||_ev.source!==window.parent)return;
15355
+ var _d=_ev.data;
15356
+ if(!_d||_d.type!=='botmux:wb-appearance')return;
15357
+ var _theme=_wbSanitizeTheme(_d.theme);
15358
+ if(!_theme)return;
15359
+ try{
15360
+ term.options.theme=_theme;
15361
+ // 阅读风是大行距 1.3;经典保持 xterm 默认的 1,「经典 = 原样」的一部分。
15362
+ // 经典那套色值与上面写死的字面量逐色相同,所以没开阅读风的用户零变化。
15363
+ // 这两个数字的唯一出处是 WORKBENCH_TERM_LINE_HEIGHTS(agent-workbench-appearance.ts),
15364
+ // 接缝测试按那张表比对这一行的字面量:改一处必须两处一起改。
15365
+ // 曾经是 1.55,单元格高约 24.5px,CLI 底部那块固定 8 行的 chrome(提示 + 输入框 +
15366
+ // 状态条)就要占掉约 196px —— 一屏四分之一全是 chrome,正文被挤走。
15367
+ term.options.lineHeight=_d.termStyle==='reader'?1.3:1;
15368
+ // 行距变了可视行数就变,必须复算一次。几何契约不动:只有画布内重绘。
15369
+ fit.fit();
15370
+ }catch(_e){}
15371
+ });
14430
15372
  // xterm parses writes asynchronously. On a brand-new page the first tmux /
14431
15373
  // zellij frame (or relay history seed) can therefore finish after the browser
14432
15374
  // has initialised the viewport scrollbar, leaving that viewport at scrollTop=0
@@ -14643,9 +15585,25 @@ function sendResize(){
14643
15585
  // text re-wraps, i.e. the reported flicker. Coalesce to the settled size.
14644
15586
  function onViewportResize(){
14645
15587
  clearTimeout(_rzT);
14646
- _rzT=setTimeout(function(){if(!fixedSize){try{fit.fit()}catch(e){}}sendResize()},250);
15588
+ _rzT=setTimeout(function(){
15589
+ // 先复算字号再 fit:字号换档字宽就变,反过来 fit 出来的是旧字号下的列数。
15590
+ // 只动画布内的渲染几何,与后端的 resize 语义一如既往由下面的 sendResize 决定。
15591
+ try{_wbApplyAutoFontSize()}catch(e){}
15592
+ if(!fixedSize){try{fit.fit()}catch(e){}}
15593
+ sendResize();
15594
+ },250);
14647
15595
  }
14648
15596
  window.addEventListener('resize',onViewportResize);
15597
+ // 转屏:部分 iOS 版本只发 orientationchange 或先于 resize 发,重复触发被防抖吃掉。
15598
+ window.addEventListener('orientationchange',onViewportResize);
15599
+ // 嵌入方(工作台终端面板、会话坞)单独改 iframe 宽度时,窗口 resize 未必可靠地传
15600
+ // 进来。直接盯容器自己的尺寸,这一页才真正自包含 —— 任何嵌入方都跟着受益。
15601
+ if(typeof ResizeObserver!=='undefined'){
15602
+ try{
15603
+ var _wbHost=document.getElementById('terminal');
15604
+ if(_wbHost)new ResizeObserver(onViewportResize).observe(_wbHost);
15605
+ }catch(_e){}
15606
+ }
14649
15607
  (function connect(){
14650
15608
  // Derive base from the current path so the WS connects to the same prefix the
14651
15609
  // page was served under — works both directly (path '/') and behind the
@@ -15149,7 +16107,7 @@ const emittedTurnTerminals = new TurnTerminalDeduper();
15149
16107
  /** Report CLI processing completion independently from user-visible output.
15150
16108
  * Keep a bounded worker-local dedup set because transcript watchers and app
15151
16109
  * runners may surface the same final boundary more than once. */
15152
- function emitTurnTerminal(turnId, status, errorCode, dispatchAttempt, outputDisposition) {
16110
+ function emitTurnTerminal(turnId, status, errorCode, dispatchAttempt, outputDisposition, retryable) {
15153
16111
  if (!sessionId || !turnId)
15154
16112
  return;
15155
16113
  if (!emittedTurnTerminals.claim(sessionId, turnId, dispatchAttempt))
@@ -15171,6 +16129,7 @@ function emitTurnTerminal(turnId, status, errorCode, dispatchAttempt, outputDisp
15171
16129
  ...(dispatchAttempt !== undefined ? { dispatchAttempt } : {}),
15172
16130
  ...(errorCode ? { errorCode } : {}),
15173
16131
  ...(outputDisposition ? { outputDisposition } : {}),
16132
+ ...(retryable !== undefined ? { retryable } : {}),
15174
16133
  });
15175
16134
  if (terminalReleasesDurableTurn({ turnId: currentBotmuxTurnId, dispatchAttempt: currentBotmuxDispatchAttempt }, { turnId, dispatchAttempt })) {
15176
16135
  // Durable replay ownership moves back to the receiver/hub at terminal.
@@ -15344,7 +16303,7 @@ process.on('message', async (raw) => {
15344
16303
  const ordinaryImTurnId = !msg.adoptMode
15345
16304
  && msg.dispatchAttempt === undefined
15346
16305
  && !!msg.prompt
15347
- && msg.turnId?.startsWith('om_')
16306
+ && (msg.turnId?.startsWith('om_') || msg.turnId?.startsWith('bmx-recovery-'))
15348
16307
  ? msg.turnId
15349
16308
  : undefined;
15350
16309
  if (lastInitConfig) {
@@ -15381,7 +16340,8 @@ process.on('message', async (raw) => {
15381
16340
  initialInputOwnershipPending = !!msg.prompt;
15382
16341
  activeRestartAttemptId = msg.restartAttemptId;
15383
16342
  sessionId = msg.sessionId;
15384
- refreshTerminalViewToken();
16343
+ // The view token intentionally stays per-boot random (no refresh): a
16344
+ // worker restart must invalidate every previously issued read link.
15385
16345
  refreshTerminalWriteToken();
15386
16346
  applySessionOwnerEnv(process.env, msg.ownerOpenId);
15387
16347
  // Pin this worker's i18n locale early so every t() call below resolves
@@ -15451,6 +16411,7 @@ process.on('message', async (raw) => {
15451
16411
  if (msg.turnId) {
15452
16412
  currentBotmuxTurnId = msg.turnId;
15453
16413
  currentBotmuxDispatchAttempt = msg.dispatchAttempt;
16414
+ currentGatewayTrustedCaller = msg.trustedCaller;
15454
16415
  currentVcMeetingImTurnOrigin = msg.vcMeetingImTurnOrigin;
15455
16416
  writeCliPidMarker();
15456
16417
  publishSandboxRelayCapability();
@@ -15570,6 +16531,7 @@ process.on('message', async (raw) => {
15570
16531
  : undefined),
15571
16532
  codexAppInput: entry.codexAppInput,
15572
16533
  vcMeetingImTurnOrigin: entry.vcMeetingImTurnOrigin,
16534
+ trustedCaller: msg.trustedCaller,
15573
16535
  }));
15574
16536
  let initialInputCommitted = false;
15575
16537
  if (shouldQueueInitialPrompt({
@@ -15597,6 +16559,7 @@ process.on('message', async (raw) => {
15597
16559
  codexAppDispatchId: msg.codexAppDispatchId,
15598
16560
  queuedActivationToken: msg.queuedActivationToken,
15599
16561
  vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
16562
+ trustedCaller: msg.trustedCaller,
15600
16563
  codexAppInput: msg.promptCodexAppInput,
15601
16564
  // Thread the root's steer authorization so ordered pre-final steer can
15602
16565
  // fold follow-ups into THIS turn (the runner's canSteer requires the
@@ -15671,10 +16634,22 @@ process.on('message', async (raw) => {
15671
16634
  break;
15672
16635
  }
15673
16636
  case 'message': {
16637
+ // 每轮消息都捎带 daemon 侧当前解析出的模型,覆盖 respawn 快照——理由与
16638
+ // restart 那条通道相同,但这里管的是**崩溃 park 后由消息触发的恢复重启**:
16639
+ // 那条路在 worker 内部直接 `{...lastInitConfig, resume:true}` 起 CLI,没有
16640
+ // restart IPC 可携带新值。三分态同 restart:undefined = 不携带(旧 daemon)
16641
+ // 保持快照;null = 明确不传模型。对已经在跑的 CLI 无影响(只在下次 spawn 用)。
16642
+ if (msg.model !== undefined && lastInitConfig) {
16643
+ lastInitConfig.model = msg.model === null ? undefined : msg.model;
16644
+ }
16645
+ // NOTE: the mojo credential snapshot is deliberately NOT applied here. It
16646
+ // rides on the queue item and is applied when THIS turn is actually written
16647
+ // to the backend (see flushPending) — applying at receive time made two
16648
+ // queued credential turns collapse to the newest value for both.
15674
16649
  const messageAdoptMode = lastInitConfig?.adoptMode === true;
15675
16650
  const ordinaryImTurnId = !messageAdoptMode
15676
16651
  && msg.dispatchAttempt === undefined
15677
- && msg.turnId?.startsWith('om_')
16652
+ && (msg.turnId?.startsWith('om_') || msg.turnId?.startsWith('bmx-recovery-'))
15678
16653
  ? msg.turnId
15679
16654
  : undefined;
15680
16655
  if (ordinaryImTurnId) {
@@ -15765,6 +16740,7 @@ process.on('message', async (raw) => {
15765
16740
  codexAppDispatchId: msg.codexAppDispatchId,
15766
16741
  queuedActivationToken: msg.queuedActivationToken,
15767
16742
  vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
16743
+ trustedCaller: msg.trustedCaller,
15768
16744
  codexAppInput,
15769
16745
  };
15770
16746
  // process.on('message') does not serialize async listeners. Hold the
@@ -15796,6 +16772,9 @@ process.on('message', async (raw) => {
15796
16772
  queuedActivationToken: msg.queuedActivationToken,
15797
16773
  replyTurnId: msg.replyTurnId,
15798
16774
  vcMeetingImTurnOrigin: msg.vcMeetingImTurnOrigin,
16775
+ trustedCaller: msg.trustedCaller,
16776
+ // Applied when THIS item is written, not on receipt.
16777
+ ...(msg.mojoLivePatch ? { mojoLivePatch: msg.mojoLivePatch } : {}),
15799
16778
  });
15800
16779
  if (inputCommitted)
15801
16780
  acknowledgeTurnInputCommitted(msg.turnId);
@@ -15864,6 +16843,14 @@ process.on('message', async (raw) => {
15864
16843
  log('Refused Riff generation restart; the existing lineage-owning worker is retained');
15865
16844
  break;
15866
16845
  }
16846
+ // Mojo is deliberately NOT refused here (unlike riff): the mojo restart
16847
+ // teardown cancels the remote session and cold-boots a replacement, and
16848
+ // internal machinery (per-bot env hot updates, backend-replacement tests)
16849
+ // relies on that. USER-facing restart for every remote backend is instead
16850
+ // rejected at the daemon boundary — /restart in command-handler and the
16851
+ // dashboard restart route both gate on isRemoteBackendSession — so a
16852
+ // restart IPC that reaches a mojo worker is a daemon-internal decision,
16853
+ // not a user command that would silently destroy remote context.
15867
16854
  // 角色切换的 cwd-move respawn:respawn 用 {...lastInitConfig, resume:true},
15868
16855
  // 先收敛 workingDir 才能让 CLI 在新目录重启(新 cwd 的 CLAUDE.md/记忆索引
15869
16856
  // 开场注入)。旧桶 transcript 由 resume 预检的 syncClaudeResumeTargetToCwd
@@ -15881,6 +16868,14 @@ process.on('message', async (raw) => {
15881
16868
  if (msg.env !== undefined && lastInitConfig) {
15882
16869
  lastInitConfig.env = msg.env === null ? undefined : msg.env;
15883
16870
  }
16871
+ // model 热更,与 env 同一条通道、同一套三分态。model 不进冻结集合、每次
16872
+ // spawn 按当前 bot 配置解析(见 resolveSessionLaunchModel),但 live-worker
16873
+ // restart 不 refork,没有 init 重发这条通道——不覆盖的话,改完模型再重启
16874
+ // 起来的还是快照里的旧模型。undefined=不携带(旧 daemon / 取不到)保持快照;
16875
+ // null=当前不该传模型(bot 未配 / 已清空)→ 移除快照。同样放在合并守卫之前。
16876
+ if (msg.model !== undefined && lastInitConfig) {
16877
+ lastInitConfig.model = msg.model === null ? undefined : msg.model;
16878
+ }
15884
16879
  // restart 合并:已有一轮 restart 在飞(teardown 进行中,或 tmux jitter
15885
16880
  // 定时器未触发)时不叠加第二轮——叠加会 clearTimeout 吃掉首轮 teardown、
15886
16881
  // 把重启预算无故烧到 tier-2 强制 FRESH(丢上下文),非 tmux 路径还会
@@ -16226,8 +17221,13 @@ process.on('message', async (raw) => {
16226
17221
  break;
16227
17222
  }
16228
17223
  case 'refresh_screen': {
16229
- if (displayMode !== 'screenshot')
17224
+ // The daemon only offers manual refresh on a screenshot-mode card, so
17225
+ // landing here in another mode means the display-mode re-sync was lost —
17226
+ // say so instead of eating the click silently.
17227
+ if (displayMode !== 'screenshot') {
17228
+ log(`Manual screenshot refresh ignored: displayMode=${displayMode}`);
16230
17229
  break;
17230
+ }
16231
17231
  lastShotHash = '';
16232
17232
  if (screenshotTimer) {
16233
17233
  clearInterval(screenshotTimer);
@@ -16240,11 +17240,17 @@ process.on('message', async (raw) => {
16240
17240
  case 'close': {
16241
17241
  log('Close requested');
16242
17242
  // destroySession kills tmux session permanently; kill() only detaches.
16243
- // riff destroySession 是异步远端取消——必须有界 await:紧跟着的
16244
- // process.exit 会掐断未发出的 fetch,让已关闭话题的远端 agent 继续跑。
16245
- if (effectiveBackendType === 'riff') {
17243
+ // Remote destroySession is an asynchronous cancellation. Explicit close
17244
+ // must therefore use prepare/commit: an immediate process.exit would cut
17245
+ // off the request and leave the remote agent running. A request-less
17246
+ // close on ANY remote backend is refused outright: the legacy path below
17247
+ // calls destroySession() — for Mojo that is `mojo session cancel` — so
17248
+ // letting a generic lifecycle teardown fall through there irreversibly
17249
+ // cancelled the remote session with no close_result to report ok:false
17250
+ // or residuals. Remote retirement must always carry a requestId.
17251
+ if (isRemoteBackendType(effectiveBackendType)) {
16246
17252
  if (!msg.requestId) {
16247
- log('Refused unsafe request-less Riff close; explicit close requires prepare/commit');
17253
+ log(`Refused unsafe request-less ${effectiveBackendType} close; explicit close requires prepare/commit`);
16248
17254
  break;
16249
17255
  }
16250
17256
  let result;
@@ -16272,33 +17278,57 @@ process.on('message', async (raw) => {
16272
17278
  lastAbortedCloseRequestId = null;
16273
17279
  closeRequestInFlightId = msg.requestId;
16274
17280
  try {
16275
- const raw = await backend?.destroySession?.();
16276
- result = raw && typeof raw === 'object' && 'ok' in raw
16277
- ? raw
16278
- : { ok: true };
17281
+ if (!backend?.destroySession) {
17282
+ result = { ok: false, error: 'remote_close_unsupported' };
17283
+ }
17284
+ else {
17285
+ result = normalizeDestroyResult(await backend.destroySession(), { remote: isRemoteBackendType(effectiveBackendType) });
17286
+ }
16279
17287
  }
16280
17288
  catch (err) {
16281
- result = { ok: false, error: err instanceof Error ? err.message : String(err) };
17289
+ // A THROWN destroySession leaves the outcome unknown: it may already
17290
+ // have cancelled the remote session or spawned side effects. Defaulting
17291
+ // this to retryable re-opened write admission on a possibly-dead
17292
+ // lineage, so an unknown outcome must fence instead.
17293
+ result = {
17294
+ ok: false,
17295
+ error: err instanceof Error ? err.message : String(err),
17296
+ recovery: 'uncertain',
17297
+ // Stated explicitly: an unknown outcome must fence writes even if a
17298
+ // later reader decides the close itself may be retried.
17299
+ admission: 'fenced',
17300
+ };
16282
17301
  }
16283
17302
  }
16284
17303
  if (result.ok) {
16285
17304
  preparedCloseRequestId = msg.requestId;
16286
17305
  }
16287
17306
  else if (attemptedPrepare) {
16288
- await backend?.abortDestroySession?.();
16289
- lastAbortedCloseRequestId = msg.requestId;
17307
+ // Two separate questions, deliberately not collapsed: `recovery` says
17308
+ // whether the CLOSE may be retried, `admission` says whether WRITES may
17309
+ // be admitted again — mayRestoreWriteAdmission answers only the second.
17310
+ // Keying this on `recovery` re-opened writes on an unproven-local-child
17311
+ // close (retryable, yet a credentialed process may still be alive), and
17312
+ // aborting on every ok:false additionally re-opened writes on a lineage
17313
+ // already cancelled remotely.
17314
+ if (mayRestoreWriteAdmission(result)) {
17315
+ await backend?.abortDestroySession?.();
17316
+ lastAbortedCloseRequestId = msg.requestId;
17317
+ }
17318
+ else {
17319
+ log(`${effectiveBackendType} close prepare failed as `
17320
+ + `${result.recovery ?? 'retryable'}/`
17321
+ + `${result.admission ?? 'derived'}; write admission stays fenced `
17322
+ + '(session remains active for retry)');
17323
+ }
16290
17324
  }
16291
17325
  if (closeRequestInFlightId === msg.requestId)
16292
17326
  closeRequestInFlightId = null;
16293
- send({
16294
- type: 'close_result',
16295
- requestId: msg.requestId,
16296
- ok: result.ok,
16297
- ...(result.taskId ? { taskId: result.taskId } : {}),
16298
- ...(result.error ? { error: result.error } : {}),
16299
- });
17327
+ // Built by the shared helper so the payload (notably `recovery`, which the
17328
+ // daemon needs to decide whether a rollback is legal) is covered by tests.
17329
+ send(buildCloseResultMessage(msg.requestId, result));
16300
17330
  if (!result.ok) {
16301
- log(`Riff close prepare failed (${result.error ?? 'cancel failed'}); session stays active for retry`);
17331
+ log(`${effectiveBackendType} close prepare failed (${result.error ?? 'cancel failed'}); session stays active for retry`);
16302
17332
  }
16303
17333
  break;
16304
17334
  }
@@ -16317,8 +17347,10 @@ process.on('message', async (raw) => {
16317
17347
  stopScreenshotLoop();
16318
17348
  stopBridgeWatcher();
16319
17349
  stopCodexBridge();
16320
- // Local close: destroySession kills persistent owned sessions. Riff has
16321
- // already been handled above and cannot enter this request-less path.
17350
+ // Local close destroys persistent owned sessions. Remote backends never
17351
+ // reach here: the branch above fences them all (request-less remote close
17352
+ // is refused; with a requestId it goes through prepare/commit), so
17353
+ // destroySession() below only ever tears down local backing sessions.
16322
17354
  const closeTeardown = backend?.destroySession?.();
16323
17355
  if (closeTeardown && typeof closeTeardown.then === 'function') {
16324
17356
  try {
@@ -16356,28 +17388,28 @@ process.on('message', async (raw) => {
16356
17388
  // We still request a clean exit; the daemon SIGKILL is the hard backstop.
16357
17389
  process.exit(0);
16358
17390
  }
16359
- case 'riff_shutdown_prepare': {
16360
- if (effectiveBackendType !== 'riff') {
17391
+ case 'remote_shutdown_prepare': {
17392
+ if (!isRemoteBackendType(effectiveBackendType)) {
16361
17393
  send({
16362
- type: 'riff_shutdown_result',
17394
+ type: 'remote_shutdown_result',
16363
17395
  requestId: msg.requestId,
16364
17396
  phase: 'prepare',
16365
17397
  ok: false,
16366
17398
  taskId: null,
16367
- error: 'not_riff_backend',
17399
+ error: 'not_remote_backend',
16368
17400
  });
16369
17401
  break;
16370
17402
  }
16371
17403
  let result;
16372
- const inputBlocker = riffWorkerShutdownInputBlocker({
17404
+ const inputBlocker = remoteWorkerShutdownInputBlocker({
16373
17405
  initPromptMaterialized,
16374
17406
  isFlushing,
16375
17407
  pendingMessages: pendingMessages.length,
16376
17408
  pendingRawInputs: pendingRawInputs.length,
16377
17409
  pendingSessionRename: pendingSessionRename !== null,
16378
17410
  // sessionRenameInFlight became a function in this branch's rename
16379
- // lifecycle rework; riffWorkerShutdownInputBlocker (added on master by
16380
- // the RIFF two-phase shutdown) still consumes it as a boolean field.
17411
+ // lifecycle rework; remoteWorkerShutdownInputBlocker (added on master by
17412
+ // the original Riff two-phase shutdown) still consumes it as a boolean field.
16381
17413
  sessionRenameInFlight: sessionRenameInFlight(),
16382
17414
  commandLineWritesPending,
16383
17415
  });
@@ -16417,7 +17449,7 @@ process.on('message', async (raw) => {
16417
17449
  }
16418
17450
  }
16419
17451
  send({
16420
- type: 'riff_shutdown_result',
17452
+ type: 'remote_shutdown_result',
16421
17453
  requestId: msg.requestId,
16422
17454
  phase: 'prepare',
16423
17455
  ok: result.ok,
@@ -16426,14 +17458,14 @@ process.on('message', async (raw) => {
16426
17458
  });
16427
17459
  break;
16428
17460
  }
16429
- case 'riff_shutdown_commit': {
16430
- if (effectiveBackendType !== 'riff'
17461
+ case 'remote_shutdown_commit': {
17462
+ if (!isRemoteBackendType(effectiveBackendType)
16431
17463
  || shutdownDetachRequestId !== msg.requestId
16432
17464
  || shutdownDetachPhase !== 'prepared') {
16433
- log(`Ignoring stale Riff shutdown commit ${msg.requestId}`);
17465
+ log(`Ignoring stale remote shutdown commit ${msg.requestId}`);
16434
17466
  break;
16435
17467
  }
16436
- log(`Riff shutdown detach committed (${msg.requestId})`);
17468
+ log(`Remote shutdown detach committed (${msg.requestId})`);
16437
17469
  shutdownDetachRequestId = null;
16438
17470
  shutdownDetachPhase = null;
16439
17471
  backend?.commitShutdownDetach?.();
@@ -16443,11 +17475,11 @@ process.on('message', async (raw) => {
16443
17475
  cleanup();
16444
17476
  process.exit(0);
16445
17477
  }
16446
- case 'riff_shutdown_abort': {
17478
+ case 'remote_shutdown_abort': {
16447
17479
  if (shutdownDetachRequestId !== msg.requestId) {
16448
- log(`Ignoring stale Riff shutdown abort ${msg.requestId}`);
17480
+ log(`Ignoring stale remote shutdown abort ${msg.requestId}`);
16449
17481
  send({
16450
- type: 'riff_shutdown_result',
17482
+ type: 'remote_shutdown_result',
16451
17483
  requestId: msg.requestId,
16452
17484
  phase: 'abort',
16453
17485
  ok: false,
@@ -16456,7 +17488,7 @@ process.on('message', async (raw) => {
16456
17488
  });
16457
17489
  break;
16458
17490
  }
16459
- log(`Riff shutdown detach aborted (${msg.requestId})`);
17491
+ log(`Remote shutdown detach aborted (${msg.requestId})`);
16460
17492
  let result;
16461
17493
  try {
16462
17494
  result = await backend?.abortShutdownDetach?.()
@@ -16474,7 +17506,7 @@ process.on('message', async (raw) => {
16474
17506
  shutdownDetachPhase = null;
16475
17507
  }
16476
17508
  send({
16477
- type: 'riff_shutdown_result',
17509
+ type: 'remote_shutdown_result',
16478
17510
  requestId: msg.requestId,
16479
17511
  phase: 'abort',
16480
17512
  ok: result.ok,
@@ -16521,17 +17553,33 @@ process.on('message', async (raw) => {
16521
17553
  });
16522
17554
  break;
16523
17555
  }
16524
- log(`Close aborted (${msg.requestId}); Riff admission restored`);
17556
+ log(`Close aborted (${msg.requestId}); remote admission restore requested`);
16525
17557
  let abortError = null;
17558
+ // A REFUSED rollback is not an error, and it is not a success either. The
17559
+ // backend may be holding a latched fence (an unproven local subtree, an
17560
+ // unnamed remote session), and the daemon used to infer "admission restored"
17561
+ // from a call that merely did not throw — persisting a cleared fence while
17562
+ // write() kept returning false.
17563
+ let admissionRestored = true;
17564
+ let fenceReason;
16526
17565
  if (!alreadyRestored) {
16527
17566
  try {
16528
- await backend?.abortDestroySession?.();
17567
+ const outcome = interpretAbortOutcome(await backend?.abortDestroySession?.());
17568
+ admissionRestored = outcome.admissionRestored;
17569
+ fenceReason = outcome.reason;
16529
17570
  }
16530
17571
  catch (err) {
16531
17572
  abortError = err instanceof Error ? err.message : String(err);
16532
17573
  }
16533
17574
  }
16534
- if (!abortError) {
17575
+ if (!abortError && !admissionRestored) {
17576
+ log(`Close abort ${msg.requestId} did NOT restore write admission `
17577
+ + `(${fenceReason ?? 'still fenced'}); this session will not accept writes again \u2014 retry the close`);
17578
+ }
17579
+ // Only clear the close bookkeeping when admission really came back. Leaving
17580
+ // it set on a still-fenced session keeps a later close_abort from being
17581
+ // rejected as stale while the fence is what actually needs resolving.
17582
+ if (!abortError && admissionRestored) {
16535
17583
  preparedCloseRequestId = null;
16536
17584
  closeRequestInFlightId = null;
16537
17585
  lastAbortedCloseRequestId = null;
@@ -16540,6 +17588,10 @@ process.on('message', async (raw) => {
16540
17588
  type: 'close_abort_result',
16541
17589
  requestId: msg.requestId,
16542
17590
  ok: abortError === null,
17591
+ // Distinct from `ok`: the abort was handled, but writes may still be
17592
+ // fenced. The daemon must persist THIS, not `ok`.
17593
+ admissionRestored: abortError === null && admissionRestored,
17594
+ ...(fenceReason ? { fenceReason } : {}),
16543
17595
  ...(abortError ? { error: abortError } : {}),
16544
17596
  });
16545
17597
  break;
@@ -16567,7 +17619,14 @@ process.on('message', async (raw) => {
16567
17619
  // uses to recover sessions after a reboot kills the tmux server).
16568
17620
  revokeManagedTurnOriginForRestart();
16569
17621
  try {
16570
- (backend?.destroySession ?? backend?.kill)?.call(backend);
17622
+ // mojo:suspend 语义是「休眠待续」——绝不能 cancel 远端会话(血缘已持久化,
17623
+ // 恢复时 follow-up 续上);只断流 detach。mojo 本来就没有常驻本地进程,
17624
+ // destroySession 想省的那份 CLI 内存并不存在。
17625
+ // riff 已在本 case 开头被直接拒绝(需 prepare/commit),所以这里只剩 mojo。
17626
+ if (isRemoteBackendType(effectiveBackendType))
17627
+ backend?.kill();
17628
+ else
17629
+ (backend?.destroySession ?? backend?.kill)?.call(backend);
16571
17630
  }
16572
17631
  catch { /* best-effort */ }
16573
17632
  backend = null;