@songsid/agend 2.1.12-beta.9 → 2.2.0-alpha.1

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 (358) hide show
  1. package/README.md +2 -2
  2. package/README.zh-TW.md +2 -2
  3. package/dist/auth/one-time-code.d.ts +19 -0
  4. package/dist/auth/one-time-code.js +49 -0
  5. package/dist/auth/one-time-code.js.map +1 -0
  6. package/dist/auth-api.d.ts +52 -0
  7. package/dist/auth-api.js +346 -0
  8. package/dist/auth-api.js.map +1 -0
  9. package/dist/backend/claude-code.d.ts +4 -0
  10. package/dist/backend/claude-code.js +97 -0
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +59 -30
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/kiro-agent.d.ts +66 -0
  15. package/dist/backend/kiro-agent.js +302 -0
  16. package/dist/backend/kiro-agent.js.map +1 -0
  17. package/dist/backend/kiro-identity.d.ts +80 -0
  18. package/dist/backend/kiro-identity.js +537 -0
  19. package/dist/backend/kiro-identity.js.map +1 -0
  20. package/dist/backend/kiro-v2-store-worker.d.ts +1 -0
  21. package/dist/backend/kiro-v2-store-worker.js +87 -0
  22. package/dist/backend/kiro-v2-store-worker.js.map +1 -0
  23. package/dist/backend/kiro-v2-store.d.ts +54 -0
  24. package/dist/backend/kiro-v2-store.js +131 -0
  25. package/dist/backend/kiro-v2-store.js.map +1 -0
  26. package/dist/backend/kiro-v3-identity.d.ts +5 -0
  27. package/dist/backend/kiro-v3-identity.js +22 -0
  28. package/dist/backend/kiro-v3-identity.js.map +1 -1
  29. package/dist/backend/kiro.d.ts +83 -7
  30. package/dist/backend/kiro.js +418 -91
  31. package/dist/backend/kiro.js.map +1 -1
  32. package/dist/backend/types.d.ts +45 -3
  33. package/dist/backend/types.js.map +1 -1
  34. package/dist/channel/adapters/discord.d.ts +1 -0
  35. package/dist/channel/adapters/discord.js +29 -10
  36. package/dist/channel/adapters/discord.js.map +1 -1
  37. package/dist/channel/adapters/telegram.d.ts +1 -0
  38. package/dist/channel/adapters/telegram.js +13 -1
  39. package/dist/channel/adapters/telegram.js.map +1 -1
  40. package/dist/channel/types.d.ts +9 -1
  41. package/dist/classic-channel-manager.d.ts +18 -10
  42. package/dist/classic-channel-manager.js +45 -18
  43. package/dist/classic-channel-manager.js.map +1 -1
  44. package/dist/cli.js +225 -127
  45. package/dist/cli.js.map +1 -1
  46. package/dist/command-table.d.ts +5 -3
  47. package/dist/command-table.js +29 -27
  48. package/dist/command-table.js.map +1 -1
  49. package/dist/config-validator.js +46 -0
  50. package/dist/config-validator.js.map +1 -1
  51. package/dist/config.js +12 -0
  52. package/dist/config.js.map +1 -1
  53. package/dist/connection-secrets.d.ts +6 -0
  54. package/dist/connection-secrets.js +4 -0
  55. package/dist/connection-secrets.js.map +1 -1
  56. package/dist/daemon.d.ts +89 -4
  57. package/dist/daemon.js +661 -77
  58. package/dist/daemon.js.map +1 -1
  59. package/dist/darwin-memory.d.ts +6 -1
  60. package/dist/darwin-memory.js +17 -6
  61. package/dist/darwin-memory.js.map +1 -1
  62. package/dist/delivery-consumed-watch.d.ts +85 -0
  63. package/dist/delivery-consumed-watch.js +244 -0
  64. package/dist/delivery-consumed-watch.js.map +1 -0
  65. package/dist/delivery-outbox.d.ts +61 -0
  66. package/dist/delivery-outbox.js +120 -2
  67. package/dist/delivery-outbox.js.map +1 -1
  68. package/dist/delivery-reconciliation.d.ts +14 -5
  69. package/dist/delivery-reconciliation.js +27 -4
  70. package/dist/delivery-reconciliation.js.map +1 -1
  71. package/dist/delivery-reconciliation.test.js +35 -1
  72. package/dist/delivery-reconciliation.test.js.map +1 -1
  73. package/dist/event-loop-watch.d.ts +28 -0
  74. package/dist/event-loop-watch.js +75 -3
  75. package/dist/event-loop-watch.js.map +1 -1
  76. package/dist/fleet-context.d.ts +22 -2
  77. package/dist/fleet-level-config.d.ts +2 -2
  78. package/dist/fleet-level-config.js +6 -1
  79. package/dist/fleet-level-config.js.map +1 -1
  80. package/dist/fleet-manager.d.ts +293 -12
  81. package/dist/fleet-manager.js +2364 -449
  82. package/dist/fleet-manager.js.map +1 -1
  83. package/dist/host-memory.d.ts +4 -0
  84. package/dist/host-memory.js.map +1 -1
  85. package/dist/instance-config-impact.js +5 -0
  86. package/dist/instance-config-impact.js.map +1 -1
  87. package/dist/instance-lifecycle.d.ts +10 -5
  88. package/dist/instance-lifecycle.js +162 -53
  89. package/dist/instance-lifecycle.js.map +1 -1
  90. package/dist/locale.js +150 -16
  91. package/dist/locale.js.map +1 -1
  92. package/dist/login-controller.d.ts +2 -0
  93. package/dist/login-controller.js +17 -5
  94. package/dist/login-controller.js.map +1 -1
  95. package/dist/memory-pressure.d.ts +7 -8
  96. package/dist/memory-pressure.js +76 -15
  97. package/dist/memory-pressure.js.map +1 -1
  98. package/dist/monotonic-budget.d.ts +2 -0
  99. package/dist/monotonic-budget.js +28 -0
  100. package/dist/monotonic-budget.js.map +1 -0
  101. package/dist/needs-you-hub.d.ts +114 -0
  102. package/dist/needs-you-hub.js +370 -0
  103. package/dist/needs-you-hub.js.map +1 -0
  104. package/dist/needs-you-live.d.ts +116 -0
  105. package/dist/needs-you-live.js +228 -0
  106. package/dist/needs-you-live.js.map +1 -0
  107. package/dist/needs-you.d.ts +132 -0
  108. package/dist/needs-you.js +164 -0
  109. package/dist/needs-you.js.map +1 -0
  110. package/dist/node-version-guard.d.ts +30 -0
  111. package/dist/node-version-guard.js +50 -0
  112. package/dist/node-version-guard.js.map +1 -0
  113. package/dist/operator-control-paths.d.ts +4 -0
  114. package/dist/operator-control-paths.js +24 -0
  115. package/dist/operator-control-paths.js.map +1 -0
  116. package/dist/outbound-handlers.d.ts +2 -0
  117. package/dist/outbound-handlers.js +9 -2
  118. package/dist/outbound-handlers.js.map +1 -1
  119. package/dist/outbound-schemas.d.ts +5 -5
  120. package/dist/profile-control.js +4 -20
  121. package/dist/profile-control.js.map +1 -1
  122. package/dist/public-web-gateway.d.ts +18 -0
  123. package/dist/public-web-gateway.js +141 -0
  124. package/dist/public-web-gateway.js.map +1 -0
  125. package/dist/public-web-link.d.ts +53 -0
  126. package/dist/public-web-link.js +175 -0
  127. package/dist/public-web-link.js.map +1 -0
  128. package/dist/quickstart-api.d.ts +8 -1
  129. package/dist/quickstart-api.js +86 -48
  130. package/dist/quickstart-api.js.map +1 -1
  131. package/dist/quickstart.d.ts +1 -0
  132. package/dist/quickstart.js +6 -2
  133. package/dist/quickstart.js.map +1 -1
  134. package/dist/scheduler/db.d.ts +7 -1
  135. package/dist/scheduler/db.js +38 -0
  136. package/dist/scheduler/db.js.map +1 -1
  137. package/dist/scheduler/index.d.ts +1 -1
  138. package/dist/scheduler/retry-label.d.ts +11 -0
  139. package/dist/scheduler/retry-label.js +24 -0
  140. package/dist/scheduler/retry-label.js.map +1 -0
  141. package/dist/scheduler/scheduler.d.ts +55 -2
  142. package/dist/scheduler/scheduler.js +220 -8
  143. package/dist/scheduler/scheduler.js.map +1 -1
  144. package/dist/scheduler/types.d.ts +22 -0
  145. package/dist/scheduler/types.js.map +1 -1
  146. package/dist/secret-store.d.ts +5 -0
  147. package/dist/secret-store.js +20 -0
  148. package/dist/secret-store.js.map +1 -1
  149. package/dist/service-activation.d.ts +110 -0
  150. package/dist/service-activation.js +302 -0
  151. package/dist/service-activation.js.map +1 -0
  152. package/dist/service-installer.d.ts +7 -0
  153. package/dist/service-installer.js +7 -4
  154. package/dist/service-installer.js.map +1 -1
  155. package/dist/service-restart-selection.d.ts +6 -5
  156. package/dist/service-restart-selection.js +6 -5
  157. package/dist/service-restart-selection.js.map +1 -1
  158. package/dist/settings-api.d.ts +10 -2
  159. package/dist/settings-api.js +175 -72
  160. package/dist/settings-api.js.map +1 -1
  161. package/dist/settings-baseline.d.ts +20 -0
  162. package/dist/settings-baseline.js +104 -0
  163. package/dist/settings-baseline.js.map +1 -0
  164. package/dist/settings-change.d.ts +21 -0
  165. package/dist/settings-change.js +133 -0
  166. package/dist/settings-change.js.map +1 -0
  167. package/dist/settings-confirmation.d.ts +105 -0
  168. package/dist/settings-confirmation.js +232 -0
  169. package/dist/settings-confirmation.js.map +1 -0
  170. package/dist/settings-control.d.ts +41 -0
  171. package/dist/settings-control.js +294 -0
  172. package/dist/settings-control.js.map +1 -0
  173. package/dist/settings-effect.d.ts +24 -0
  174. package/dist/settings-effect.js +169 -0
  175. package/dist/settings-effect.js.map +1 -0
  176. package/dist/settings-http-confirmation.d.ts +29 -0
  177. package/dist/settings-http-confirmation.js +123 -0
  178. package/dist/settings-http-confirmation.js.map +1 -0
  179. package/dist/settings-instance-patch.d.ts +4 -0
  180. package/dist/settings-instance-patch.js +26 -0
  181. package/dist/settings-instance-patch.js.map +1 -0
  182. package/dist/settings-request-capability.d.ts +16 -0
  183. package/dist/settings-request-capability.js +32 -0
  184. package/dist/settings-request-capability.js.map +1 -0
  185. package/dist/settings-transaction.d.ts +65 -0
  186. package/dist/settings-transaction.js +223 -0
  187. package/dist/settings-transaction.js.map +1 -0
  188. package/dist/setup-auth.d.ts +10 -18
  189. package/dist/setup-auth.js +14 -38
  190. package/dist/setup-auth.js.map +1 -1
  191. package/dist/setup-form.d.ts +1 -1
  192. package/dist/setup-form.js +75 -18
  193. package/dist/setup-form.js.map +1 -1
  194. package/dist/setup-guide.d.ts +13 -0
  195. package/dist/setup-guide.js +25 -0
  196. package/dist/setup-guide.js.map +1 -0
  197. package/dist/setup-host.d.ts +16 -0
  198. package/dist/setup-host.js +197 -32
  199. package/dist/setup-host.js.map +1 -1
  200. package/dist/setup-wizard.js +3 -0
  201. package/dist/setup-wizard.js.map +1 -1
  202. package/dist/slash-authz.d.ts +17 -3
  203. package/dist/slash-authz.js +5 -0
  204. package/dist/slash-authz.js.map +1 -1
  205. package/dist/spawn-gate.js +3 -3
  206. package/dist/spawn-gate.js.map +1 -1
  207. package/dist/statusline-watcher.d.ts +5 -0
  208. package/dist/statusline-watcher.js +2 -0
  209. package/dist/statusline-watcher.js.map +1 -1
  210. package/dist/steer-capability.d.ts +6 -0
  211. package/dist/steer-capability.js +8 -0
  212. package/dist/steer-capability.js.map +1 -1
  213. package/dist/sync-work-attribution.d.ts +14 -0
  214. package/dist/sync-work-attribution.js +43 -8
  215. package/dist/sync-work-attribution.js.map +1 -1
  216. package/dist/tips.js +10 -5
  217. package/dist/tips.js.map +1 -1
  218. package/dist/tmux-control.d.ts +35 -5
  219. package/dist/tmux-control.js +291 -57
  220. package/dist/tmux-control.js.map +1 -1
  221. package/dist/tmux-manager.d.ts +20 -6
  222. package/dist/tmux-manager.js +90 -47
  223. package/dist/tmux-manager.js.map +1 -1
  224. package/dist/tmux-read.d.ts +59 -0
  225. package/dist/tmux-read.js +213 -0
  226. package/dist/tmux-read.js.map +1 -0
  227. package/dist/topic-commands.d.ts +21 -10
  228. package/dist/topic-commands.js +191 -58
  229. package/dist/topic-commands.js.map +1 -1
  230. package/dist/transcript-delta-reader.js +2 -1
  231. package/dist/transcript-delta-reader.js.map +1 -1
  232. package/dist/tunnel/cloudflared-install.d.ts +2 -0
  233. package/dist/tunnel/cloudflared-install.js +1 -1
  234. package/dist/tunnel/cloudflared-install.js.map +1 -1
  235. package/dist/tunnel/purpose-lane.d.ts +14 -0
  236. package/dist/tunnel/purpose-lane.js +43 -0
  237. package/dist/tunnel/purpose-lane.js.map +1 -0
  238. package/dist/turn-reply-guard.d.ts +2 -0
  239. package/dist/turn-reply-guard.js +10 -1
  240. package/dist/turn-reply-guard.js.map +1 -1
  241. package/dist/types.d.ts +33 -0
  242. package/dist/ui/app.html +18 -0
  243. package/dist/ui/chat-render.js +478 -0
  244. package/dist/ui/chat-store.js +289 -0
  245. package/dist/ui/chat-strings.js +69 -0
  246. package/dist/ui/chat-thread.js +209 -0
  247. package/dist/ui/chat-tour.js +116 -0
  248. package/dist/ui/fleet-org.js +142 -0
  249. package/dist/ui/panel-chat.js +418 -0
  250. package/dist/ui/panel-fleet.js +413 -0
  251. package/dist/ui/panel-needs.js +202 -0
  252. package/dist/ui/panel-settings.js +750 -0
  253. package/dist/ui/preview.js +266 -0
  254. package/dist/ui/settings-apply.js +239 -0
  255. package/dist/ui/settings-confirm.js +186 -0
  256. package/dist/ui/settings-dialogs.js +546 -0
  257. package/dist/ui/settings-model.js +277 -0
  258. package/dist/ui/settings-strings.js +652 -0
  259. package/dist/ui/settings-wizard.js +140 -0
  260. package/dist/ui/shared/agend-auth.js +93 -0
  261. package/dist/ui/shared/app-ctx.js +80 -0
  262. package/dist/ui/shared/app-html.js +9 -0
  263. package/dist/ui/shared/app-i18n.js +95 -0
  264. package/dist/ui/shared/app-nav.js +73 -0
  265. package/dist/ui/shared/app-needs.js +114 -0
  266. package/dist/ui/shared/app-route.js +73 -0
  267. package/dist/ui/shared/app-session.js +66 -0
  268. package/dist/ui/shared/app-shell.js +344 -0
  269. package/dist/ui/shared/app-store.js +51 -0
  270. package/dist/ui/shared/app-stream.js +201 -0
  271. package/dist/ui/shared/app.css +676 -0
  272. package/dist/ui/shared/app.js +88 -0
  273. package/dist/ui/shared/fonts/OFL.txt +92 -0
  274. package/dist/ui/shared/fonts/README.md +21 -0
  275. package/dist/ui/shared/fonts/inter.woff2 +0 -0
  276. package/dist/ui/shared/htm.module.js +1 -0
  277. package/dist/ui/shared/panel-view.js +655 -0
  278. package/dist/ui/shared/preact-hooks.module.js +2 -0
  279. package/dist/ui/shared/preact.module.js +2 -0
  280. package/dist/ui/shared/signin.js +130 -0
  281. package/dist/ui/shared/theme.js +29 -0
  282. package/dist/ui/shared/tokens.css +153 -0
  283. package/dist/ui/shared/ui-confirm.js +43 -0
  284. package/dist/ui/shared/ui-dialog.js +45 -0
  285. package/dist/ui/shared/ui-icons.js +61 -0
  286. package/dist/ui/shared/ui-menu.js +39 -0
  287. package/dist/ui/shared/ui-states.js +20 -0
  288. package/dist/ui/shared/ui-toast.js +18 -0
  289. package/dist/ui/shared/vendor/htm.LICENSE +202 -0
  290. package/dist/ui/shared/vendor/preact.LICENSE +21 -0
  291. package/dist/ui/shared/vendor/vendor.json +39 -0
  292. package/dist/ui/shared/view-strings.js +309 -0
  293. package/dist/ui/signin.html +51 -0
  294. package/dist/update-check.d.ts +6 -2
  295. package/dist/update-check.js +12 -3
  296. package/dist/update-check.js.map +1 -1
  297. package/dist/update-install.d.ts +71 -0
  298. package/dist/update-install.js +162 -0
  299. package/dist/update-install.js.map +1 -0
  300. package/dist/upgrade-notices.d.ts +17 -0
  301. package/dist/upgrade-notices.js +93 -0
  302. package/dist/upgrade-notices.js.map +1 -0
  303. package/dist/usage/statusline-usage.d.ts +2 -0
  304. package/dist/usage/statusline-usage.js +14 -10
  305. package/dist/usage/statusline-usage.js.map +1 -1
  306. package/dist/view-api.d.ts +6 -1
  307. package/dist/view-api.js +62 -51
  308. package/dist/view-api.js.map +1 -1
  309. package/dist/web-api.d.ts +71 -14
  310. package/dist/web-api.js +492 -77
  311. package/dist/web-api.js.map +1 -1
  312. package/dist/web-auth.d.ts +71 -27
  313. package/dist/web-auth.js +169 -69
  314. package/dist/web-auth.js.map +1 -1
  315. package/dist/web-body.d.ts +3 -0
  316. package/dist/web-body.js +55 -0
  317. package/dist/web-body.js.map +1 -0
  318. package/dist/web-channel-echo.d.ts +9 -0
  319. package/dist/web-channel-echo.js +55 -0
  320. package/dist/web-channel-echo.js.map +1 -0
  321. package/dist/web-chat-history.d.ts +125 -0
  322. package/dist/web-chat-history.js +184 -0
  323. package/dist/web-chat-history.js.map +1 -0
  324. package/dist/web-continuation.d.ts +7 -0
  325. package/dist/web-continuation.js +22 -0
  326. package/dist/web-continuation.js.map +1 -0
  327. package/dist/web-host-guard.d.ts +34 -0
  328. package/dist/web-host-guard.js +57 -1
  329. package/dist/web-host-guard.js.map +1 -1
  330. package/dist/web-login.d.ts +72 -0
  331. package/dist/web-login.js +132 -0
  332. package/dist/web-login.js.map +1 -0
  333. package/dist/web-org.d.ts +55 -0
  334. package/dist/web-org.js +67 -0
  335. package/dist/web-org.js.map +1 -0
  336. package/dist/web-preview.d.ts +77 -0
  337. package/dist/web-preview.js +240 -0
  338. package/dist/web-preview.js.map +1 -0
  339. package/dist/web-request-context.d.ts +10 -0
  340. package/dist/web-request-context.js +13 -0
  341. package/dist/web-request-context.js.map +1 -0
  342. package/dist/web-session.d.ts +167 -0
  343. package/dist/web-session.js +389 -0
  344. package/dist/web-session.js.map +1 -0
  345. package/dist/web-shell-routes.d.ts +59 -0
  346. package/dist/web-shell-routes.js +96 -0
  347. package/dist/web-shell-routes.js.map +1 -0
  348. package/dist/web-upload.d.ts +183 -0
  349. package/dist/web-upload.js +468 -0
  350. package/dist/web-upload.js.map +1 -0
  351. package/dist/website-tips-renderer.d.ts +2 -2
  352. package/dist/website-tips-renderer.js +3 -3
  353. package/dist/website-tips-renderer.js.map +1 -1
  354. package/package.json +8 -5
  355. package/scripts/preinstall-guard.cjs +36 -0
  356. package/dist/ui/dashboard.html +0 -774
  357. package/dist/ui/settings.html +0 -2165
  358. package/dist/ui/view.html +0 -1139
@@ -1,7 +1,19 @@
1
+ import { SettingsBaselines } from "./settings-baseline.js";
2
+ import { SettingsConfirmationStore } from "./settings-confirmation.js";
3
+ import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
4
+ import { SettingsControlServer } from "./settings-control.js";
5
+ import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileResource, assertSettingsLease, trySettingsLease, waitSettingsLease, settingsUndo, undoSettingsPaths } from "./settings-transaction.js";
6
+ import { performance } from "node:perf_hooks";
7
+ import { gatewayRequestContext } from "./web-request-context.js";
8
+ import { createPublicWebGateway } from "./public-web-gateway.js";
9
+ import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
10
+ import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
11
+ import { ManagedTunnel } from "./tunnel/manager.js";
12
+ import { withinBudget } from "./monotonic-budget.js";
1
13
  import { measureSyncWork } from "./sync-work-attribution.js";
2
14
  import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
3
15
  import { ProfileControlServer } from "./profile-control.js";
4
- import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, constants as fsConstants } from "node:fs";
16
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs";
5
17
  import { createHash, randomBytes, randomUUID } from "node:crypto";
6
18
  import { spawnSync } from "node:child_process";
7
19
  import { freemem, totalmem, cpus, homedir } from "node:os";
@@ -34,6 +46,7 @@ import { TmuxManager } from "./tmux-manager.js";
34
46
  import { AccessManager } from "./channel/access-manager.js";
35
47
  import { IpcClient } from "./channel/ipc-bridge.js";
36
48
  import { createAdapter } from "./channel/factory.js";
49
+ import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
37
50
  import { TelegramAdapter } from "./channel/adapters/telegram.js";
38
51
  import { createBackend } from "./backend/factory.js";
39
52
  import { readEffortMetadata } from "./backend/effort-metadata.js";
@@ -42,6 +55,7 @@ import { createLogger, rotateLogIfNeeded } from "./logger.js";
42
55
  import { processAttachments } from "./channel/attachment-handler.js";
43
56
  import { routeToolCall } from "./channel/tool-router.js";
44
57
  import { Scheduler } from "./scheduler/index.js";
58
+ import { escapeTelegramHtml, scheduleClock, scheduleRetryLabel } from "./scheduler/retry-label.js";
45
59
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
46
60
  import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, parseCompactCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineModel } from "./topic-commands.js";
47
61
  import { DailySummary } from "./daily-summary.js";
@@ -104,14 +118,16 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
104
118
  }
105
119
  return tasks;
106
120
  }
107
- import { handleWebRequest, broadcastSseEvent } from "./web-api.js";
121
+ import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
122
+ import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
123
+ import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
108
124
  import { handleViewRequest, isViewPath } from "./view-api.js";
109
125
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
110
126
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
111
127
  import { LoginSession } from "./login-manager.js";
112
128
  import { tightenInstanceDirs } from "./private-dir.js";
113
129
  import { decideSlash } from "./slash-authz.js";
114
- import { commandSpec, decideCommand } from "./command-table.js";
130
+ import { commandSpec, decideCommand, ruleFor } from "./command-table.js";
115
131
  import { runVisibilityCommand } from "./cross-instance-notice.js";
116
132
  import { UPDATE_COMMAND, installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
117
133
  import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
@@ -126,7 +142,7 @@ import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBac
126
142
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
127
143
  import { validateFleetConfig } from "./config-validator.js";
128
144
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
129
- import { presentationState, interactionSummary } from "./interaction-observation.js";
145
+ import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
130
146
  import { readLastInboundAt } from "./daemon.js";
131
147
  import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
132
148
  import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
@@ -136,10 +152,18 @@ import { isFleetStartCommandLine, readProcessCommandLine, releaseProcessFleetLoc
136
152
  import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
137
153
  import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
138
154
  import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
155
+ import { NeedsYouHub } from "./needs-you-hub.js";
156
+ import { WEB_CHAT_NOTICE, WEB_REMOTE_DOCS_URL, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
139
157
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
158
+ import { buildOrgChart } from "./web-org.js";
140
159
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
141
- import { decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
160
+ import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
161
+ import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
162
+ import { isWebPageNavigation } from "./web-shell-routes.js";
163
+ import { tokenEpoch, WebSessionStore } from "./web-session.js";
164
+ import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
142
165
  import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
166
+ import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
143
167
  import { fleetLevelDifferences, fleetLevelSignature } from "./fleet-level-config.js";
144
168
  import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
145
169
  import { SecretStore } from "./secret-store.js";
@@ -154,6 +178,7 @@ import { mergeBackendOptions, profileKey, profileName, profileOf } from "./class
154
178
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
155
179
  import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
156
180
  import { launchFullRestartHelper } from "./full-restart.js";
181
+ import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
157
182
  import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
158
183
  import { StormWindow } from "./storm-window.js";
159
184
  import { SpawnGate } from "./spawn-gate.js";
@@ -289,8 +314,42 @@ const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
289
314
  const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
290
315
  const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
291
316
  const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
317
+ /** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
318
+ const NEEDS_PAUSE_TTL_MS = 10_000;
292
319
  const HANG_CALLBACK_PREFIX = "hang:";
293
320
  const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
321
+ /**
322
+ * The prompts the web dashboard also offers: the ones about an instance's own health, which a dashboard
323
+ * user — holding the full-fleet web credential — may answer exactly as a fleet admin on the platform may.
324
+ * Personal or channel-bound prompts stay where they were asked: a /clear confirmation, login, a Classic
325
+ * group's approval, tips, and the per-user /model and /effort menus (which are not nonce entries at all).
326
+ */
327
+ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
328
+ HANG_CALLBACK_PREFIX, EXIT_RESTART_CALLBACK_PREFIX, INTERACTIVE_ASSIST_CALLBACK_PREFIX,
329
+ ]);
330
+ /**
331
+ * Where a reply goes on a fleet with no chat platform (web track C4): it is "sent" by being shown in the
332
+ * web chat, which afterReplyRouted does for every reply. Only what routeToolCall's reply path calls exists
333
+ * here; the path checks (assertSendable, the file count) are the reply tool's own, run before these.
334
+ */
335
+ const WEB_ONLY_REPLY_SINK = {
336
+ type: "web",
337
+ sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
338
+ sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
339
+ };
340
+ /**
341
+ * Where an instance-health prompt (hang, clean exit, interactive prompt) is posted on a fleet with no chat platform:
342
+ * nowhere but the dashboard (#1307 item 6). Posting "succeeds" with an id of its own, so the prompt is armed and
343
+ * offered on the web exactly as a platform prompt is; there are no platform buttons to edit afterwards, and the
344
+ * outcome reaches the page through prompt_resolved as for any web-answered prompt.
345
+ */
346
+ const WEB_ONLY_PROMPT_SINK = {
347
+ type: "web",
348
+ notifyAlert: async () => ({ chatId: "web", messageId: newWebMessageId() }),
349
+ editMessage: async () => { },
350
+ editMessageRemoveButtons: async () => { },
351
+ sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
352
+ };
294
353
  const TIP_DISMISS_CALLBACK_PREFIX = "tip-dismiss:";
295
354
  const TIP_UNLOCK_CALLBACK_PREFIX = "tip-unlock:";
296
355
  export const LOGIN_CALLBACK_PREFIX = "login:";
@@ -517,6 +576,14 @@ export class FleetManager {
517
576
  instanceProgress = new Map();
518
577
  /** instanceName → tail of deliveries waiting for its IPC to come back. */
519
578
  ipcWaitTails = new Map();
579
+ webChannelEchoTails = new Map();
580
+ /**
581
+ * instanceName → the reservation whose echo callback is currently running.
582
+ * sendClassicWebEcho fences every per-entry copy against it: no new copy
583
+ * starts after the ordering budget is gone or the delivery epoch is revoked.
584
+ * Serialized per instance by the echo tail, so one slot is enough.
585
+ */
586
+ webChannelEchoGuards = new Map();
520
587
  /** instanceName → restart currently executing; concurrent callers join it. */
521
588
  restartsInFlight = new Map();
522
589
  /**
@@ -556,6 +623,20 @@ export class FleetManager {
556
623
  pendingModelSelects = new Map();
557
624
  /** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
558
625
  pendingNonceButtons = new Map();
626
+ /** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
627
+ needsYou = null;
628
+ /** When the fleet first saw an instance crashed, for its "Needs you" age; cleared when it is not. */
629
+ needsCrashedAt = new Map();
630
+ /** Pause reason/time read from the marker, per paused instance; dropped on any attention change of it, and at most
631
+ * NEEDS_PAUSE_TTL_MS old — so a recompute does not re-read the file every time (#1386 §4.3). */
632
+ needsPauseCache = new Map();
633
+ /**
634
+ * Clicks that came from the web dashboard (clickWebPrompt). Only that method adds to it, so nothing an
635
+ * adapter emits — whatever fields its payload carries — can claim a dashboard click's authority.
636
+ */
637
+ webPromptClicks = new WeakSet();
638
+ /** The web clicks consumeNonceCallback actually claimed (the others were refused or lost a race). */
639
+ webPromptClaims = new WeakSet();
559
640
  // Model failover state
560
641
  failoverActive = new Map(); // instance → current failover model
561
642
  // IPC reconnect: tracks instances being intentionally stopped (skip reconnect)
@@ -609,6 +690,12 @@ export class FleetManager {
609
690
  collabInstances = new Set();
610
691
  // Health endpoint
611
692
  healthServer = null;
693
+ /** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
694
+ previewListener = null;
695
+ previewListening = false;
696
+ /** The ports the preview listener was started for, and the inputs it was built from (a reload compares them). */
697
+ previewPorts = null;
698
+ previewInputs = "";
612
699
  healthPortRetried = false;
613
700
  updateCheckTimer = null;
614
701
  updateProgressTimer = null;
@@ -641,12 +728,228 @@ export class FleetManager {
641
728
  mirrorTimer = null;
642
729
  // Web UI: SSE clients + auth token
643
730
  sseClients = new Set();
731
+ /** The web chat's recent messages: what `/ui/history` serves and what a reconnecting SSE stream is sent. */
732
+ webChatHistory = new WebChatHistory();
733
+ /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
734
+ webFiles = new WebFileLedger();
644
735
  /**
645
736
  * Read from disk on every access rather than cached at startup: `agend
646
737
  * web-token rotate` runs in a separate process, and a cached copy would keep
647
738
  * authorizing revoked links and cookies until the fleet restarted.
648
739
  */
740
+ settingsGeneration = 0;
741
+ settingsRuntimeRevision = 0;
742
+ settingsConfirmation = null;
743
+ settingsControl = null;
744
+ settingsJobSettlements = new Map();
745
+ classicSettingsOwners = new WeakMap();
746
+ queueSettingsOperation(job, resources, execution, run) {
747
+ const generation = this.settingsGeneration;
748
+ const owned = execution ?? new SettingsExecution({ current: () => !this.shuttingDown && this.settingsGeneration === generation,
749
+ snapshot: () => [this.settingsRuntimeRevision, this.fleetConfig, settingsRevision(join(this.dataDir, ".env"))] });
750
+ const done = Promise.resolve().then(async () => {
751
+ let lease = null, admitted = false;
752
+ try {
753
+ lease = await waitSettingsLease(resources, () => owned.current(), performance.now() + owned.remainingMs, owned.owner);
754
+ owned.assert();
755
+ admitted = true;
756
+ await run(owned);
757
+ if (["applied", "applied_next_use", "reloaded", "restart_required"].includes(job.result) && !owned.completed)
758
+ throw new Error("missing_settings_receipt");
759
+ }
760
+ catch {
761
+ job.status = "done";
762
+ job.finishedAt = Date.now();
763
+ job.result = admitted ? "rollback_failed" : "rolled_back";
764
+ job.error = admitted ? "operation did not provide a committed receipt; operator attention required" : "operation was not admitted";
765
+ }
766
+ finally {
767
+ lease?.release();
768
+ if (!execution)
769
+ owned.close();
770
+ if ("envKey" in job) {
771
+ if (this.providerSecretInFlight.get(job.envKey) === job.id)
772
+ this.providerSecretInFlight.delete(job.envKey);
773
+ }
774
+ else {
775
+ if (this.connectionSecretInFlight.get(job.connectionId) === job.id)
776
+ this.connectionSecretInFlight.delete(job.connectionId);
777
+ if (this.connectionBindingInFlight.get(job.connectionId) === job.id)
778
+ this.connectionBindingInFlight.delete(job.connectionId);
779
+ }
780
+ }
781
+ });
782
+ this.settingsJobSettlements ??= new Map();
783
+ this.settingsJobSettlements.set(job.id, done);
784
+ while (this.settingsJobSettlements.size > 128) {
785
+ const first = this.settingsJobSettlements.keys().next().value;
786
+ const record = this.providerSecretJobs.get(first) ?? this.connectionSecretJobs.get(first) ?? this.connectionBindingJobs.get(first);
787
+ if (record?.status !== "done")
788
+ break;
789
+ this.settingsJobSettlements.delete(first);
790
+ }
791
+ }
792
+ settingsGate() {
793
+ if (this.settingsConfirmation)
794
+ return this.settingsConfirmation;
795
+ const generation = this.settingsGeneration;
796
+ const current = () => !this.shuttingDown && this.settingsGeneration === generation;
797
+ const baselines = new SettingsBaselines({ dataDir: this.dataDir, configPath: () => this.configPath,
798
+ config: () => this.fleetConfig, current: () => [this.settingsGeneration, this.settingsRuntimeRevision, this.shuttingDown],
799
+ proof: (kind, target, id, binding, key) => {
800
+ const map = kind === "provider" ? this.providerSecretChallenges : kind === "binding" ? this.connectionBindingChallenges : this.connectionSecretChallenges;
801
+ const proof = map.get(id);
802
+ if (!proof || proof.sessionBinding !== binding || proof.idempotencyKey !== key
803
+ || ("specId" in proof ? proof.specId : proof.connectionId) !== target
804
+ || proof.generation !== ("envKey" in proof ? this.providerSecretGeneration(proof.envKey) : this.secureConnectionGeneration(target)))
805
+ return null;
806
+ const remainingMs = proof.deadline === undefined ? proof.expiresAt - Date.now() : proof.deadline - performance.now();
807
+ if (remainingMs <= 0)
808
+ return null;
809
+ const envKey = "envKey" in proof ? proof.envKey : this.secureConnectionChannel(target)?.bot_token_env ?? "";
810
+ return { key: envKey, secret: "secret" in proof ? proof.secret : "", previous: process.env[envKey], remainingMs,
811
+ ...("binding" in proof ? { binding: proof.binding } : {}),
812
+ discard: () => { if (map.get(id) === proof)
813
+ map.delete(id); if ("secret" in proof)
814
+ proof.secret = ""; } };
815
+ } });
816
+ const store = new SettingsConfirmationStore({
817
+ audit: (event, fields) => this.logger.info({ event: `settings_confirmation.${event}`, ...fields }, "Settings confirmation audit"),
818
+ notify: view => view.state === "pending" ? this.promptSettingsChange(store, view) : Promise.resolve(),
819
+ });
820
+ const gate = new SettingsHttpConfirmation(store, {
821
+ principal: req => {
822
+ const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
823
+ if (auth.kind !== "ok" || !current())
824
+ return null;
825
+ const record = auth.session, sessions = this.webSessions, exposure = gatewayRequestContext(req);
826
+ return { id: record.idHash, label: record.label, source: exposure ? "public_link" : "web_session",
827
+ current: () => current() && sessions === this.webSessions && !!this.webToken
828
+ && sessions.isCurrent(record, tokenEpoch(this.webToken)) && (!exposure || exposure.isCurrent()) };
829
+ }, baseline: () => baselines.read(), snapshot: () => baselines.snapshot(),
830
+ job: async (id) => {
831
+ const settlement = this.settingsJobSettlements.get(id);
832
+ if (!settlement)
833
+ return false;
834
+ await settlement;
835
+ const job = this.providerSecretJobs.get(id) ?? this.connectionSecretJobs.get(id) ?? this.connectionBindingJobs.get(id);
836
+ return !!job && ["applied", "applied_next_use", "reloaded"].includes(job.result);
837
+ },
838
+ });
839
+ this.settingsConfirmation = gate;
840
+ return gate;
841
+ }
842
+ async promptSettingsChange(store, view) {
843
+ const generation = this.settingsGeneration;
844
+ const excluded = new Set(store.affectedConnections(view.id));
845
+ for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
846
+ if (!config.general_topic)
847
+ continue;
848
+ const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
849
+ const general = this.daemons.get(name);
850
+ if (!adapterId || !adapter || !general || excluded.has(adapterId) || !this.hasFleetAdmins(adapterId))
851
+ continue;
852
+ const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
853
+ if (!chatId || !topic)
854
+ continue;
855
+ let retired = false, attached = false;
856
+ const postingDeadline = performance.now() + Math.min(5000, view.remaining_ms);
857
+ const current = () => !retired && (attached || performance.now() < postingDeadline) && !this.shuttingDown && this.settingsGeneration === generation
858
+ && this.fleetConfig?.instances[name]?.general_topic === true
859
+ && this.daemons.get(name) === general && !this.ipcStoppingInstances.has(name)
860
+ && this.adapterState.get(adapterId)?.status === "connected"
861
+ && this.getInstanceAdapterId(name) === adapterId && this.getAdapterForInstance(name) === adapter
862
+ && this.getGroupIdForInstance(name) === chatId && String(this.fleetConfig?.instances[name]?.topic_id ?? "") === topic
863
+ && this.hasFleetAdmins(adapterId);
864
+ if (!current())
865
+ continue;
866
+ try {
867
+ const posted = this.postNonceButtonPromptOrThrow({ prefix: "settings-confirm:", alertType: "clear_confirm",
868
+ instanceName: name, adapterId, adapter, chatId, threadId: topic,
869
+ message: `🔒 Settings change awaiting fleet-admin confirmation\nSource: ${view.source}\nRequester: ${view.requested_by}\n${view.summary.join("\n")}`,
870
+ choices: [{ action: "confirm", label: "Confirm" }, { action: "reject", label: "Reject" }],
871
+ expiredText: "Settings confirmation is no longer pending.", timeoutMs: view.remaining_ms,
872
+ extra: { pendingChangeId: view.id, confirmationCurrent: current },
873
+ });
874
+ void posted.then(nonce => {
875
+ if (!retired)
876
+ return;
877
+ const entry = this.pendingNonceButtons.get(nonce);
878
+ if (entry?.pendingChangeId !== view.id)
879
+ return;
880
+ this.pendingNonceButtons.delete(nonce);
881
+ clearTimeout(entry.timer);
882
+ void this.retireNonceButtons(entry, entry.messageId ?? "", "Settings confirmation delivery expired.");
883
+ }, () => { });
884
+ const nonce = await withinBudget(posted, postingDeadline);
885
+ attached = true;
886
+ const retire = () => {
887
+ const entry = this.pendingNonceButtons.get(nonce);
888
+ if (!entry || entry.pendingChangeId !== view.id)
889
+ return;
890
+ this.pendingNonceButtons.delete(nonce);
891
+ clearTimeout(entry.timer);
892
+ void this.retireNonceButtons(entry, entry.messageId ?? "", "Settings confirmation is no longer pending.");
893
+ };
894
+ if (!current()) {
895
+ retire();
896
+ continue;
897
+ }
898
+ if (store.attachPrompt(view.id, retire))
899
+ return;
900
+ }
901
+ catch {
902
+ retired = true;
903
+ this.logger.info({ id: view.id, adapterId }, "Settings confirmation prompt unavailable; trying another General or host CLI");
904
+ }
905
+ }
906
+ // No platform confirmation was reachable. Host inspection is still required.
907
+ this.logger.info({ id: view.id }, "Settings change requires local `agend settings confirm <id>`");
908
+ }
909
+ async handleSettingsChangeCallback(data, adapterId, adapter) {
910
+ const result = this.consumeNonceCallback("settings-confirm:", /^settings-confirm:([0-9a-f]{32}):(confirm|reject)$/, data, adapterId, adapter);
911
+ if (!result)
912
+ return false;
913
+ if (result === "consumed")
914
+ return true;
915
+ const { entry, action } = result;
916
+ try {
917
+ const view = await this.settingsGate().store.decide(entry.pendingChangeId, action, {
918
+ label: `fleet admin ${data.userId}`,
919
+ current: () => entry.confirmationCurrent?.() === true && this.getAdapterForInstance(entry.instanceName) === entry.adapter
920
+ && !!data.userId && this.isFleetAdmin(data.userId, entry.adapterId),
921
+ });
922
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? "Settings change is applying.");
923
+ }
924
+ catch {
925
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, "Settings confirmation rejected or expired.");
926
+ }
927
+ return true;
928
+ }
929
+ async startSettingsConfirmationControl() {
930
+ if (this.shuttingDown || this.settingsControl)
931
+ return;
932
+ const generation = this.settingsGeneration, control = new SettingsControlServer(this.dataDir, this.settingsGate().store, () => !this.shuttingDown && this.settingsGeneration === generation);
933
+ this.settingsControl = control;
934
+ try {
935
+ await control.listen();
936
+ }
937
+ catch (err) {
938
+ if (this.settingsControl === control)
939
+ this.settingsControl = null;
940
+ throw err;
941
+ }
942
+ }
649
943
  get webToken() { return readWebToken(this.dataDir); }
944
+ /**
945
+ * Server-side web sessions (see web-session.ts). Created with the token, not per
946
+ * request: they are persisted, and a restart must find them again.
947
+ */
948
+ webSessions = null;
949
+ /** Heartbeat of the dashboard's SSE stream; public so a test can shorten it. */
950
+ sseHeartbeatMs = SSE_HEARTBEAT_MS;
951
+ /** The dashboard's login codes. Memory only: a code that outlives the process is a code nobody can prove was not copied. */
952
+ webLoginCodes = null;
650
953
  /**
651
954
  * Set while a Settings apply job is driving the reconcile. The reconcile
652
955
  * stays the single doer; it just says out loud what it is doing to whom, so
@@ -664,7 +967,6 @@ export class FleetManager {
664
967
  /** Set when the file on disk and the in-memory config disagree on a
665
968
  * startup-only key at startup. See checkStartupSignatureConsistency(). */
666
969
  fleetSignatureMismatch = null;
667
- viewToken = null;
668
970
  healthServerListening = false;
669
971
  constructor(dataDir) {
670
972
  this.dataDir = dataDir;
@@ -674,7 +976,8 @@ export class FleetManager {
674
976
  FleetManager.sighupHandlerInstalled = true;
675
977
  }
676
978
  this.stormWindow = new StormWindow();
677
- this.memoryPressure = new MemoryPressure({ onSample: snapshot => this.reportMemoryPressure(snapshot) });
979
+ this.memoryPressure = new MemoryPressure({ onSample: snapshot => this.reportMemoryPressure(snapshot),
980
+ onDarwinUnknown: memory => this.logger.info({ pressureRaw: memory?.darwinPressureRaw ?? null }, "macOS kernel memory pressure unknown — no notice or admission restriction") });
678
981
  this.spawnGate = new SpawnGate({
679
982
  storm: this.stormWindow,
680
983
  memoryPressure: this.memoryPressure,
@@ -706,6 +1009,9 @@ export class FleetManager {
706
1009
  if (target)
707
1010
  this.wakeCoordinator?.noteDelivered(target);
708
1011
  }
1012
+ if (event.deliveryId && (event.state === "failed" || event.state === "uncertain" || event.state === "delivered")) {
1013
+ this.needsYou?.poke();
1014
+ }
709
1015
  if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
710
1016
  const row = outbox.get(event.deliveryId);
711
1017
  if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
@@ -716,7 +1022,7 @@ export class FleetManager {
716
1022
  }
717
1023
  });
718
1024
  outbox.on("generation_recovered", () => this.scheduleDeliveryOutboxPump());
719
- this.replyObligationTimer = setInterval(() => this.sweepReplyObligations(), REPLY_OBLIGATION_SWEEP_MS);
1025
+ this.replyObligationTimer = setInterval(() => measureSyncWork("fleet.replyObligationSweep", () => this.sweepReplyObligations()), REPLY_OBLIGATION_SWEEP_MS);
720
1026
  this.replyObligationTimer.unref?.();
721
1027
  // #856: the text the target daemon received differs from what was
722
1028
  // admitted. Transport has never been seen to do this; a warning and the
@@ -728,6 +1034,8 @@ export class FleetManager {
728
1034
  this.wakeCoordinator.start();
729
1035
  outbox.on("expired", (event) => {
730
1036
  this.scheduleDeliveryOutboxPump();
1037
+ if (event.count)
1038
+ this.needsYou?.poke(); // #1386: rows that expired failed — they wait on someone now
731
1039
  this.wakeCoordinator?.kick();
732
1040
  if (event.count)
733
1041
  this.notifyFleetError(t("delivery.expired", event.count, event.uncertain ?? 0));
@@ -834,7 +1142,8 @@ export class FleetManager {
834
1142
  }
835
1143
  this.deliveryPumpTimer = setTimeout(() => {
836
1144
  this.deliveryPumpTimer = null;
837
- void this.runDeliveryOutboxPump();
1145
+ // The pump's body never awaits: the whole run is one synchronous stretch (#1235 attribution).
1146
+ void measureSyncWork("fleet.deliveryPump", () => this.runDeliveryOutboxPump());
838
1147
  }, Math.max(0, delayMs));
839
1148
  this.deliveryPumpTimer.unref?.();
840
1149
  }
@@ -1068,9 +1377,7 @@ export class FleetManager {
1068
1377
  }
1069
1378
  const changed = snapshot.level !== this.memoryLogLevel;
1070
1379
  if (this.memoryPressure.advisoryOnly()) {
1071
- // macOS (#1256): the sample is kept in the log for calibration, but nothing is sent to a channel.
1072
- if (changed)
1073
- this.logger.info({ hostMemory: snapshot }, "Host memory sample (macOS: logged only — no notice, no spawn throttling)");
1380
+ // The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
1074
1381
  this.memoryLogLevel = snapshot.level;
1075
1382
  return;
1076
1383
  }
@@ -1095,7 +1402,10 @@ export class FleetManager {
1095
1402
  if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
1096
1403
  return;
1097
1404
  const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
1098
- const text = t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing"));
1405
+ const action = t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing");
1406
+ const text = this.memoryPressure.platform === "darwin"
1407
+ ? t("memory.kernel_pressure", snapshot.memory.darwinPressureLevel ?? t("memory.unknown"), size(snapshot.memory.availableBytes), size(snapshot.memory.swapFreeBytes), action)
1408
+ : t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), action);
1099
1409
  if (this.notifyFleetError(text, { throttle: false })) {
1100
1410
  this.memoryNoticeAt = now;
1101
1411
  this.memoryNoticeLevel = snapshot.level;
@@ -1218,6 +1528,7 @@ export class FleetManager {
1218
1528
  }
1219
1529
  finishStartup() {
1220
1530
  this.startupComplete = true;
1531
+ this.startNeedsYou();
1221
1532
  // Resolve whatever a previous run — or a setup host that crashed — left
1222
1533
  // behind. A tunnel nobody is tracking is a public entrance nobody is
1223
1534
  // watching, and the fleet starting is the moment there is finally a process
@@ -1381,6 +1692,7 @@ export class FleetManager {
1381
1692
  const loaded = loadFleetConfig(configPath);
1382
1693
  this.assertProviderSecretEnvKeys(loaded);
1383
1694
  this.rawFleetConfig = raw;
1695
+ this.settingsRuntimeRevision++;
1384
1696
  this.fleetConfig = loaded;
1385
1697
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
1386
1698
  this.warnAboutRemovedLoginMode(loaded);
@@ -1542,6 +1854,11 @@ export class FleetManager {
1542
1854
  await data.respond(t("instance.not_found", requested));
1543
1855
  return;
1544
1856
  }
1857
+ // #754 audit: a General speaks for its own bot's instances only.
1858
+ if (this.getInstanceAdapterId(requested) !== adapterId) {
1859
+ await data.respond(t("instance.other_bot", requested));
1860
+ return;
1861
+ }
1545
1862
  target = requested;
1546
1863
  }
1547
1864
  await data.respond(await this.topicCommands.runPauseWake(target, action));
@@ -2063,6 +2380,9 @@ export class FleetManager {
2063
2380
  // asks "is anyone still reporting", which only the receiver can date.
2064
2381
  receivedAt: now,
2065
2382
  });
2383
+ // The dashboard's "working" line and its Stop button follow the edges, not the heartbeat.
2384
+ if (previous?.state !== state)
2385
+ this.emitSseEvent("activity", { instance: name, state: this.getInstanceExecutionState(name) });
2066
2386
  for (const check of this.instanceIdleWaiters.get(name) ?? [])
2067
2387
  check();
2068
2388
  // warm_cap: a fresh transition into idle may free this instance for eviction,
@@ -2105,6 +2425,16 @@ export class FleetManager {
2105
2425
  this.cancelButtonIdleRetireTimers.set(name, timer);
2106
2426
  }
2107
2427
  cacheInstanceProcessStatus(name, status) {
2428
+ // #1386: every transition into crashed is a new occurrence (a new "Needs you" item), and every other status ends
2429
+ // the previous one — at once, not when the coalesced collector next runs: running then crashed in one IPC chunk is
2430
+ // a second crash (#1398 review r2). The collector still recovers a missed running event.
2431
+ if (status === "crashed") {
2432
+ if (this.instanceProcessStatus.get(name) !== "crashed")
2433
+ this.needsCrashedAt.set(name, Date.now());
2434
+ }
2435
+ else
2436
+ this.needsCrashedAt.delete(name);
2437
+ this.needsYou?.poke();
2108
2438
  if (status === "running") {
2109
2439
  this.instanceProcessStatus.delete(name);
2110
2440
  // A prior crash-loop marker is one-shot. Successful respawn is the
@@ -2122,7 +2452,8 @@ export class FleetManager {
2122
2452
  this.instanceProcessStatus.set(name, status);
2123
2453
  // Never display the last ready prompt as current execution state after its
2124
2454
  // owning CLI process has exited.
2125
- this.instanceStateCache.delete(name);
2455
+ if (this.instanceStateCache.delete(name))
2456
+ this.emitSseEvent("activity", { instance: name, state: null });
2126
2457
  for (const check of this.instanceIdleWaiters.get(name) ?? [])
2127
2458
  check();
2128
2459
  }
@@ -2251,11 +2582,11 @@ export class FleetManager {
2251
2582
  finish(false);
2252
2583
  });
2253
2584
  }
2254
- async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false) {
2255
- if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
2585
+ async deliverWithIdleGate(instanceName, payload, timeoutMs, deliveryEpoch, noInlineWake = false, stillCurrent) {
2586
+ if (this.shuttingDown || !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
2256
2587
  return false;
2257
2588
  await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
2258
- if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
2589
+ if (this.shuttingDown || !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
2259
2590
  return false;
2260
2591
  let idleObservedAfter = this.lastDeliveryAt.get(instanceName) ?? 0;
2261
2592
  if (this.lifecycle.isPaused(instanceName) && noInlineWake) {
@@ -2267,7 +2598,7 @@ export class FleetManager {
2267
2598
  if (this.lifecycle.isPaused(instanceName)) {
2268
2599
  const wakeStartedAt = Date.now();
2269
2600
  await this.explicitWake(instanceName, 30_000);
2270
- if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
2601
+ if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
2271
2602
  return false;
2272
2603
  // Waking added one to the warm count — make room by evicting a different
2273
2604
  // LRU idle instance (never this one; it's about to work).
@@ -2275,23 +2606,23 @@ export class FleetManager {
2275
2606
  // Never satisfy a post-wake gate from a stale pre-pause cache entry.
2276
2607
  idleObservedAfter = Math.max(idleObservedAfter, wakeStartedAt);
2277
2608
  }
2278
- const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter, () => !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch));
2279
- if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
2609
+ const idle = await this.waitForInstanceIdle(instanceName, timeoutMs, idleObservedAfter, () => !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent));
2610
+ if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent)) {
2280
2611
  this.logger.info({ instanceName }, "Pending delivery dropped by user cancel");
2281
2612
  return false;
2282
2613
  }
2283
2614
  // A server crash can land while waitForInstanceIdle is pending. Re-check
2284
2615
  // immediately before the old timeout path would force text into a boot UI.
2285
2616
  await this.holdDeliveryForStorm(instanceName, deliveryEpoch);
2286
- if (this.shuttingDown || !this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
2617
+ if (this.shuttingDown || !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
2287
2618
  return false;
2288
2619
  if (!idle) {
2289
2620
  this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
2290
2621
  }
2291
- const sent = await this.sendWhenConnected(instanceName, payload, deliveryEpoch);
2622
+ const sent = await this.sendWhenConnected(instanceName, payload, deliveryEpoch, stillCurrent);
2292
2623
  if (!sent)
2293
2624
  return false;
2294
- if (this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
2625
+ if (this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent)) {
2295
2626
  this.lastDeliveryAt.set(instanceName, Date.now());
2296
2627
  }
2297
2628
  return true;
@@ -2318,8 +2649,8 @@ export class FleetManager {
2318
2649
  * instance, *including* when the socket happens to be up: otherwise a message
2319
2650
  * arriving after the reconnect could overtake one that has been waiting for it.
2320
2651
  */
2321
- async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
2322
- if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
2652
+ async sendWhenConnected(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName), stillCurrent) {
2653
+ if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
2323
2654
  return false;
2324
2655
  const queued = this.ipcWaitTails.get(instanceName);
2325
2656
  if (!queued) {
@@ -2330,9 +2661,9 @@ export class FleetManager {
2330
2661
  const attempt = (queued ?? Promise.resolve())
2331
2662
  .catch(() => { })
2332
2663
  .then(() => {
2333
- if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
2664
+ if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
2334
2665
  return false;
2335
- return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch);
2666
+ return this.sendAfterIpcReturns(instanceName, payload, deliveryEpoch, stillCurrent);
2336
2667
  });
2337
2668
  // The chain stores a settled-either-way promise so one failed delivery cannot
2338
2669
  // wedge every later one, and so `queued` above is safe to await unguarded.
@@ -2350,11 +2681,11 @@ export class FleetManager {
2350
2681
  }
2351
2682
  }
2352
2683
  /** Poll for the instance's IPC to come back, then send. Throws if it does not. */
2353
- async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName)) {
2684
+ async sendAfterIpcReturns(instanceName, payload, deliveryEpoch = this.getDeliveryEpoch(instanceName), stillCurrent) {
2354
2685
  const deadline = Date.now() + IPC_RECONNECT_GRACE_MS;
2355
2686
  let warned = false;
2356
2687
  for (;;) {
2357
- if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
2688
+ if (!this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
2358
2689
  return false;
2359
2690
  // Re-read every round: a reconnect replaces the IpcClient object entirely,
2360
2691
  // so a cached reference would stay dead forever.
@@ -2371,10 +2702,168 @@ export class FleetManager {
2371
2702
  await new Promise(resolve => setTimeout(resolve, IPC_RECONNECT_POLL_MS));
2372
2703
  }
2373
2704
  }
2705
+ /**
2706
+ * Reserve the display echo before IPC handoff: a fast reply cannot overtake
2707
+ * it. The web request settles admission without awaiting the platform POST.
2708
+ * Only accepted messages send an echo; failures release replies normally.
2709
+ * Each reservation has a 5s total monotonic ordering budget (queue/admission
2710
+ * included). Expiry drops unstarted copies and releases replies. An adapter
2711
+ * request already in flight may land late, but cannot retain the ordering lane.
2712
+ */
2713
+ /**
2714
+ * Post an owner web-chat echo to every ClassicBot channel entry opted in
2715
+ * for `instance` (#1320 part B). Per entry: the channel's own adapter, no
2716
+ * thread, mention suppression on. Returns the number of channels posted
2717
+ * to. Failures are per-entry warn-and-continue — a failed echo never fails
2718
+ * the web send. Entries without `web_echo: true` are never touched.
2719
+ *
2720
+ * Every copy is fenced at start time: an entry whose explicit world is
2721
+ * gone is skipped (only legacy adapterId-less entries fall back to the
2722
+ * primary adapter); when running under a reservation, an exhausted
2723
+ * ordering budget or a revoked delivery epoch stops the loop, and each
2724
+ * entry is re-resolved against current registration so a removed opt-in
2725
+ * or a rebound adapter is never posted through a stale route. A copy
2726
+ * already in flight may still land late — the contract permits that.
2727
+ */
2728
+ async sendClassicWebEcho(instance, text) {
2729
+ const guard = this.webChannelEchoGuards.get(instance);
2730
+ const handled = new Set();
2731
+ let posted = 0;
2732
+ for (;;) {
2733
+ if (guard) {
2734
+ if (performance.now() >= guard.deadlineAt) {
2735
+ this.logger.warn({ instance, posted }, "Classic web echo stopped: ordering budget exhausted");
2736
+ break;
2737
+ }
2738
+ if (!this.isDeliveryEpochCurrent(instance, guard.epoch)) {
2739
+ this.logger.warn({ instance, posted }, "Classic web echo stopped: delivery epoch revoked");
2740
+ break;
2741
+ }
2742
+ }
2743
+ const entry = (this.classicChannels?.getAll() ?? [])
2744
+ .find(candidate => candidate.instanceName === instance && candidate.webEcho === true
2745
+ && !handled.has(`${candidate.channelId}#${candidate.adapterId ?? ""}`));
2746
+ if (!entry)
2747
+ break;
2748
+ handled.add(`${entry.channelId}#${entry.adapterId ?? ""}`);
2749
+ const worldAdapter = entry.adapterId ? this.worlds.get(entry.adapterId)?.adapter : undefined;
2750
+ if (entry.adapterId && !worldAdapter) {
2751
+ this.logger.warn({ instance, channelId: entry.channelId, adapterId: entry.adapterId }, "Classic web echo skipped: adapter world unavailable");
2752
+ continue;
2753
+ }
2754
+ const adapter = worldAdapter ?? this.adapter;
2755
+ try {
2756
+ if (!adapter?.sendText)
2757
+ continue;
2758
+ await adapter.sendText(entry.channelId, text, { format: "text", allowedMentions: { parse: [] } });
2759
+ ClassicChannelManager.logMessage(instance, "web-user", text, new Date());
2760
+ posted++;
2761
+ }
2762
+ catch (err) {
2763
+ this.logger.warn({ err, instance, channelId: entry.channelId }, "Classic web echo failed");
2764
+ }
2765
+ }
2766
+ return posted;
2767
+ }
2768
+ reserveWebChannelEcho(instanceName, sendEcho) {
2769
+ const epoch = this.getDeliveryEpoch(instanceName);
2770
+ const deadlineAt = performance.now() + 5_000;
2771
+ let decide;
2772
+ const admission = new Promise(resolve => { decide = resolve; });
2773
+ const queue = this.webChannelEchoTails.get(instanceName)
2774
+ ?? { tail: Promise.resolve(), pending: new Set() };
2775
+ const previous = queue.tail;
2776
+ let resolveDone;
2777
+ const tail = new Promise(resolve => { resolveDone = resolve; });
2778
+ let done = false, expired = false;
2779
+ let timer;
2780
+ let guard;
2781
+ const finish = () => {
2782
+ if (done)
2783
+ return;
2784
+ done = true;
2785
+ clearTimeout(timer);
2786
+ queue.pending.delete(entry);
2787
+ if (guard && this.webChannelEchoGuards.get(instanceName) === guard) {
2788
+ this.webChannelEchoGuards.delete(instanceName);
2789
+ }
2790
+ resolveDone();
2791
+ };
2792
+ const entry = { started: false, drop: () => {
2793
+ this.logger.warn({ instanceName }, "Queued web channel echo dropped after ordering timeout");
2794
+ finish();
2795
+ } };
2796
+ const expire = () => {
2797
+ if (done)
2798
+ return;
2799
+ expired = true;
2800
+ this.logger.warn({ instanceName, inFlight: entry.started }, "Web channel echo ordering timed out");
2801
+ finish();
2802
+ // Do not post copies that were queued behind an ambiguous platform send.
2803
+ for (const pending of queue.pending)
2804
+ if (!pending.started)
2805
+ pending.drop();
2806
+ };
2807
+ const checkDeadline = () => {
2808
+ if (done)
2809
+ return;
2810
+ const remaining = deadlineAt - performance.now();
2811
+ if (remaining <= 0)
2812
+ expire();
2813
+ else
2814
+ timer = setTimeout(checkDeadline, Math.ceil(remaining));
2815
+ };
2816
+ queue.pending.add(entry);
2817
+ queue.tail = tail;
2818
+ this.webChannelEchoTails.set(instanceName, queue);
2819
+ checkDeadline();
2820
+ void Promise.all([previous, admission]).then(([, accepted]) => {
2821
+ if (done)
2822
+ return;
2823
+ if (!accepted || !this.isDeliveryEpochCurrent(instanceName, epoch)) {
2824
+ finish();
2825
+ return;
2826
+ }
2827
+ // A delayed timer callback must not admit an already expired copy.
2828
+ if (performance.now() >= deadlineAt) {
2829
+ expire();
2830
+ return;
2831
+ }
2832
+ entry.started = true;
2833
+ guard = { epoch, deadlineAt };
2834
+ this.webChannelEchoGuards.set(instanceName, guard);
2835
+ let request;
2836
+ try {
2837
+ request = Promise.resolve(sendEcho());
2838
+ }
2839
+ catch (err) {
2840
+ request = Promise.reject(err);
2841
+ }
2842
+ void request.then(() => {
2843
+ if (expired)
2844
+ this.logger.warn({ instanceName }, "Web channel echo completed late after ordering timeout");
2845
+ finish();
2846
+ }, err => {
2847
+ this.logger.warn({ err, instanceName }, expired
2848
+ ? "Web channel echo failed late after ordering timeout" : "Web channel echo failed");
2849
+ finish();
2850
+ });
2851
+ });
2852
+ void tail.then(() => {
2853
+ if (this.webChannelEchoTails.get(instanceName) === queue && queue.tail === tail && queue.pending.size === 0) {
2854
+ this.webChannelEchoTails.delete(instanceName);
2855
+ }
2856
+ });
2857
+ return decide;
2858
+ }
2374
2859
  /** Whether this target already has an ordinary non-user delivery in its FIFO. */
2375
2860
  hasPendingIdleGatedDelivery(instanceName) {
2376
2861
  return this.idleGatedDeliveryTails.has(instanceName);
2377
2862
  }
2863
+ /** The running launch's own steer capability (#1405); undefined for a backend without one, or no running Daemon. */
2864
+ instanceLaunchSupportsSteer(instanceName) {
2865
+ return this.daemons.get(instanceName)?.launchSupportsSteer();
2866
+ }
2378
2867
  /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
2379
2868
  async deliverToInstance(instanceName, payload, options = {}) {
2380
2869
  const deliveryEpoch = this.getDeliveryEpoch(instanceName);
@@ -2394,22 +2883,22 @@ export class FleetManager {
2394
2883
  return false;
2395
2884
  }
2396
2885
  await this.explicitWake(instanceName, 30_000);
2397
- if (!this.isDeliveryEpochCurrent(instanceName, deliveryEpoch))
2886
+ if (!this.deliveryCurrent(instanceName, deliveryEpoch, options.stillCurrent))
2398
2887
  return false;
2399
2888
  this.enforceWarmCap(instanceName); // woke one → evict a different LRU idle if over cap
2400
2889
  }
2401
- const sent = await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch);
2890
+ const sent = await this.sendWhenConnected(instanceName, deliveryPayload, deliveryEpoch, options.stillCurrent);
2402
2891
  if (!sent)
2403
2892
  return false;
2404
2893
  // A cross-instance item arriving before the daemon observes this turn as
2405
2894
  // working must not trust the stale idle snapshot from before the send.
2406
- if (this.isDeliveryEpochCurrent(instanceName, deliveryEpoch)) {
2895
+ if (this.deliveryCurrent(instanceName, deliveryEpoch, options.stillCurrent)) {
2407
2896
  this.lastDeliveryAt.set(instanceName, Date.now());
2408
2897
  }
2409
2898
  return true;
2410
2899
  }
2411
2900
  const previous = this.idleGatedDeliveryTails.get(instanceName) ?? Promise.resolve();
2412
- const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true));
2901
+ const delivery = previous.catch(() => { }).then(() => this.deliverWithIdleGate(instanceName, deliveryPayload, options.idleTimeoutMs ?? 60_000, deliveryEpoch, options.noInlineWake === true, options.stillCurrent));
2413
2902
  this.idleGatedDeliveryTails.set(instanceName, delivery);
2414
2903
  try {
2415
2904
  return await delivery;
@@ -2420,14 +2909,40 @@ export class FleetManager {
2420
2909
  }
2421
2910
  }
2422
2911
  }
2423
- /** Fleet admin is an explicit config allowlist entry, not merely an open/paired user. */
2912
+ /**
2913
+ * The fleet-admin list of exactly this adapter (#754): its `access.allowed_users`, read from fleet.yaml. An adapter
2914
+ * id that matches no configured channel and no running adapter has NO list — it is never answered with the primary
2915
+ * channel's, as `getChannelConfig` would. No id at all means the primary adapter, a single-adapter fleet's only one.
2916
+ */
2917
+ adminListOf(adapterId) {
2918
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
2919
+ const id = adapterId ?? this.getPrimaryAdapterId();
2920
+ if (!id)
2921
+ return null;
2922
+ const config = channels.find(ch => (ch.id ?? ch.type) === id) ?? this.worlds.get(id)?.channelConfig;
2923
+ return config ? (config.access?.allowed_users ?? []).map(String) : null;
2924
+ }
2925
+ /**
2926
+ * The one fleet-admin gate (#754): is this user an admin of the adapter that OWNS what they are acting on — the
2927
+ * target instance's or General's adapter, never merely the one the request arrived on. An empty list means nobody
2928
+ * (`disabled`, so the reply can say admin commands are off); an unknown adapter means nobody either (`denied`).
2929
+ * Fleet admin is an explicit config entry: a paired or open-mode user is not one.
2930
+ */
2931
+ adminGate(userId, ownerAdapterId) {
2932
+ const list = this.adminListOf(ownerAdapterId);
2933
+ if (!list)
2934
+ return "denied";
2935
+ if (list.length === 0)
2936
+ return "disabled";
2937
+ return list.includes(String(userId)) ? "ok" : "denied";
2938
+ }
2939
+ /** Fleet admin is an explicit config allowlist entry, not merely an open/paired user. See adminGate. */
2424
2940
  isFleetAdmin(userId, adapterId) {
2425
- const allowed = this.getChannelConfig(adapterId)?.access?.allowed_users ?? [];
2426
- return allowed.some(entry => String(entry) === String(userId));
2941
+ return this.adminGate(userId, adapterId) === "ok";
2427
2942
  }
2428
2943
  /** Whether this adapter has any fleet admin at all (an empty allowlist means the admin commands are off). */
2429
2944
  hasFleetAdmins(adapterId) {
2430
- return (this.getChannelConfig(adapterId)?.access?.allowed_users ?? []).length > 0;
2945
+ return (this.adminListOf(adapterId)?.length ?? 0) > 0;
2431
2946
  }
2432
2947
  /**
2433
2948
  * The one question the privileged commands (/update, /doctor, /dashboard, /collab) ask: may THIS caller,
@@ -2437,9 +2952,7 @@ export class FleetManager {
2437
2952
  * type a slash command could `/update` the host.
2438
2953
  */
2439
2954
  fleetAdminGate(userId, adapterId) {
2440
- if (!this.hasFleetAdmins(adapterId))
2441
- return "disabled";
2442
- return this.isFleetAdmin(userId, adapterId) ? "ok" : "denied";
2955
+ return this.adminGate(userId, adapterId);
2443
2956
  }
2444
2957
  runtimeCpuProfiler = null;
2445
2958
  cpuProfileControl = null;
@@ -2478,11 +2991,17 @@ export class FleetManager {
2478
2991
  * this caller speak here at all) and then the COMMAND TABLE (src/command-table.ts — does this command apply
2479
2992
  * in this kind of channel, and which kind of admin does it need). The table can only narrow what the door
2480
2993
  * let through. A command nobody registered is answered, not left to time out.
2994
+ *
2995
+ * The door, the table and the start of the command run in one synchronous stretch (#1399 review): no await between
2996
+ * the check and the act, so the channel's instance cannot be rebound to another bot in between.
2481
2997
  */
2482
2998
  async dispatchSlash(data, adapterId, adapter) {
2483
- const scope = await this.authorizeSlash(data, adapterId);
2484
- if (!scope)
2999
+ const door = this.slashDoor(data, adapterId);
3000
+ if ("refusal" in door) {
3001
+ await data.respond(t(door.refusal)).catch(() => { });
2485
3002
  return;
3003
+ }
3004
+ const scope = door.scope;
2486
3005
  const spec = commandSpec(data.command);
2487
3006
  if (!spec) {
2488
3007
  this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
@@ -2644,9 +3163,18 @@ export class FleetManager {
2644
3163
  await this.topicCommands.sendSysInfo(text => data.respond(text), { platform: "discord" });
2645
3164
  }
2646
3165
  else if (data.command === "dashboard") {
2647
- // Reply is ephemeral (adapter defers non-chat commands ephemerally), so
2648
- // the web-token-bearing URLs are only visible to the caller.
2649
- await data.respond(this.topicCommands.getDashboardText());
3166
+ if (String(data.options?.action ?? "").trim().toLowerCase() === "revoke") {
3167
+ const result = this.revokeWebSessions();
3168
+ await data.respond(result.durable ? t("dashboard.revoked", result.count) : t("dashboard.revoked_not_durable", result.count));
3169
+ }
3170
+ else {
3171
+ const owner = this.dashboardOwner(data.userId, adapterId, data.channelId);
3172
+ if (!owner) {
3173
+ await this.sendLocalDashboard(data, adapterId);
3174
+ return;
3175
+ }
3176
+ await this.showDashboardMenu(owner, data.respondButtons);
3177
+ }
2650
3178
  }
2651
3179
  else if (data.command === "restart") {
2652
3180
  await this.handleRestartSlash(data, adapterId);
@@ -2702,10 +3230,10 @@ export class FleetManager {
2702
3230
  }
2703
3231
  }
2704
3232
  /**
2705
- * The door every Discord slash command goes through (src/slash-authz.ts has the rule and the reasoning).
2706
- * Answers the caller itself when it refuses, so a refused command costs one reply and does nothing else.
3233
+ * The door every Discord slash command goes through (src/slash-authz.ts has the rule and the reasoning): the scope
3234
+ * it lets the command into, or the reply that refuses it. Synchronous, so the dispatch acts on what it judged.
2707
3235
  */
2708
- async authorizeSlash(data, adapterId) {
3236
+ slashDoor(data, adapterId) {
2709
3237
  const channelId = data.channelId;
2710
3238
  const classic = !!this.classicChannels?.isClassicChannel(channelId, adapterId);
2711
3239
  const fleetTarget = classic ? undefined : this.routing.resolve(channelId);
@@ -2727,21 +3255,28 @@ export class FleetManager {
2727
3255
  speaker = this.isFleetAdmin(data.userId, adapterId) || (am?.isAllowed(data.userId) ?? false) ? "allowed" : "denied";
2728
3256
  }
2729
3257
  }
3258
+ const spec = commandSpec(data.command);
3259
+ const rule = spec ? ruleFor(spec, commandScope, "discord") : undefined;
2730
3260
  const facts = {
2731
3261
  command: data.command,
2732
3262
  guildId: data.guildId,
2733
3263
  primaryGuildId: String(this.getChannelConfig(adapterId)?.group_id ?? ""),
2734
3264
  scope,
2735
3265
  speaker,
3266
+ // The table's level, plus the fleet-admin modes of a command whose handler decides by its options: /tips with a
3267
+ // mode saves fleet config or unlocks tips; bare /tips only draws one (#1396 review).
3268
+ fleetAdminCommand: (!!rule && "level" in rule && rule.level === "fleet-admin")
3269
+ || (data.command === "tips" && typeof data.options?.mode === "string" && data.options.mode.trim() !== ""),
3270
+ otherBotOwns: !!fleetTarget && !!this.getInstanceAdapterId(fleetTarget.name) && this.getInstanceAdapterId(fleetTarget.name) !== adapterId,
2736
3271
  };
2737
3272
  const decision = decideSlash(facts);
2738
3273
  if (decision.allow)
2739
- return commandScope;
3274
+ return { scope: commandScope };
2740
3275
  this.logger.info({ command: data.command, reason: decision.reason, adapterId, guildId: data.guildId ?? null, channelId, scope }, "Slash command refused");
2741
- await data.respond(t(decision.reason === "dm" ? "slash.dm_unsupported"
2742
- : decision.reason === "wrong-guild" ? "slash.wrong_server"
2743
- : "not_authorized")).catch(() => { });
2744
- return null;
3276
+ return { refusal: decision.reason === "dm" ? "slash.dm_unsupported"
3277
+ : decision.reason === "wrong-guild" ? "slash.wrong_server"
3278
+ : decision.reason === "other-bot" ? "slash.other_bot"
3279
+ : "not_authorized" };
2745
3280
  }
2746
3281
  /** Phase 2: delivery_worker for a target (instance override → fleet default → wake_only). */
2747
3282
  deliveryWorkerMode(target) {
@@ -2882,8 +3417,39 @@ export class FleetManager {
2882
3417
  reply_completion_guard: this.classicChannels?.getReplyCompletionGuardByInstance(instanceName, this.fleetConfig?.defaults?.reply_completion_guard) ?? true,
2883
3418
  };
2884
3419
  }
3420
+ /** Compensation retains this transaction's exact instance owner across its own transitions. */
3421
+ captureClassicSettingsRestoration(instanceName, changedFields, execution) {
3422
+ const generation = this.settingsGeneration;
3423
+ const receipt = { instanceName, epoch: this.lifecycle.epochOf(instanceName), daemon: this.daemons.get(instanceName) };
3424
+ if (execution)
3425
+ this.classicSettingsOwners.set(execution, receipt);
3426
+ return async () => {
3427
+ const cleanup = new SettingsExecution({ current: () => !this.shuttingDown && this.settingsGeneration === generation
3428
+ && this.lifecycle.epochOf(instanceName) === receipt.epoch && this.daemons.get(instanceName) === receipt.daemon, snapshot: () => null });
3429
+ this.classicSettingsOwners.set(cleanup, receipt);
3430
+ try {
3431
+ cleanup.assert();
3432
+ await this.restartClassicInstanceFromSettings(instanceName, changedFields, cleanup);
3433
+ }
3434
+ finally {
3435
+ cleanup.close();
3436
+ }
3437
+ };
3438
+ }
2885
3439
  /** Apply a Settings edit to a ClassicBot channel without waiting for the poller. */
2886
- async restartClassicInstanceFromSettings(instanceName, changedFields = []) {
3440
+ async restartClassicInstanceFromSettings(instanceName, changedFields = [], execution) {
3441
+ return this.lifecycle.runTransition(instanceName, transition => this.restartClassicSettingsOwned(instanceName, changedFields, execution, transition));
3442
+ }
3443
+ async restartClassicSettingsOwned(instanceName, changedFields, execution, transition) {
3444
+ const generation = this.settingsGeneration;
3445
+ const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
3446
+ const check = () => {
3447
+ execution?.assert();
3448
+ if (this.shuttingDown || this.settingsGeneration !== generation || receipt &&
3449
+ (receipt.instanceName !== instanceName || receipt.epoch !== this.lifecycle.epochOf(instanceName) || receipt.daemon !== this.daemons.get(instanceName)))
3450
+ throw new Error("Classic restart superseded");
3451
+ };
3452
+ check();
2887
3453
  if (!this.classicChannels)
2888
3454
  throw new Error("Classic channel manager not initialized");
2889
3455
  const wasRunning = this.daemons.has(instanceName);
@@ -2902,9 +3468,21 @@ export class FleetManager {
2902
3468
  this.logger.info({ instanceName, fields: changedFields }, "Classic instance hot config reloaded");
2903
3469
  return;
2904
3470
  }
2905
- await this.stopInstance(instanceName);
3471
+ const stopping = this.stopInstance(instanceName, transition);
3472
+ // stop() invalidates synchronously in our transition. Keep only that exact acquisition.
3473
+ if (receipt && this.lifecycle.epochOf(instanceName) === receipt.epoch + 1 && this.daemons.get(instanceName) === receipt.daemon)
3474
+ receipt.epoch++;
3475
+ await stopping;
3476
+ if (receipt && this.lifecycle.epochOf(instanceName) === receipt.epoch && !this.daemons.has(instanceName))
3477
+ receipt.daemon = undefined;
3478
+ check();
3479
+ const stoppedEpoch = this.lifecycle.epochOf(instanceName);
2906
3480
  await new Promise(resolve => setTimeout(resolve, 250));
2907
- await this.startClassicInstance(instanceName, this.classicChannels.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after));
3481
+ check();
3482
+ if (this.lifecycle.epochOf(instanceName) !== stoppedEpoch)
3483
+ throw new Error("Classic restart superseded by stop");
3484
+ await this.startClassicInstance(instanceName, this.classicChannels.getBackendByInstance(instanceName, this.fleetConfig?.defaults?.backend), this.classicChannels.getPreTaskCommand(channel.channelId, channel.adapterId), this.classicChannels.getModel(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channel.channelId, channel.adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition, execution);
3485
+ check();
2908
3486
  }
2909
3487
  /** Reload classicBot.yaml once. Kept callable so the periodic production
2910
3488
  * path is covered without relying on fake timers around startAll(). */
@@ -2980,7 +3558,8 @@ export class FleetManager {
2980
3558
  */
2981
3559
  resumePaused = false,
2982
3560
  /** Phase 2a: the caller's transition, for a start made from inside a restart or wake. */
2983
- transition) {
3561
+ transition, execution) {
3562
+ execution?.assert();
2984
3563
  // CLI single-instance cold starts bypass startAll. Start diagnostics before
2985
3564
  // any lifecycle/wake work; a shutdown must not revive the stopped sampler.
2986
3565
  if (!this.shuttingDown)
@@ -3039,11 +3618,17 @@ export class FleetManager {
3039
3618
  this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
3040
3619
  }
3041
3620
  }
3042
- await this.lifecycle.start(name, config, topicMode, {
3043
- kind,
3044
- backend,
3045
- model: this.resolveInstanceModel(name).display,
3046
- }, transition);
3621
+ const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
3622
+ const identity = { kind, backend, model: this.resolveInstanceModel(name).display };
3623
+ if (receipt) {
3624
+ await this.lifecycle.start(name, config, topicMode, identity, transition, execution, daemon => {
3625
+ // Only this transition's actual publication can acquire its replacement.
3626
+ receipt.daemon = daemon;
3627
+ });
3628
+ }
3629
+ else
3630
+ await this.lifecycle.start(name, config, topicMode, identity, transition, execution);
3631
+ execution?.assert();
3047
3632
  // Only clear a stale process status after a real start succeeded. Clearing
3048
3633
  // it before lifecycle.start() can turn a crash-loop daemon's dead pane into
3049
3634
  // a falsely running instance when lifecycle.start() returns early.
@@ -3052,6 +3637,7 @@ export class FleetManager {
3052
3637
  // before reaching it, so an await here is not proof the marker was read.
3053
3638
  // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
3054
3639
  await this.connectIpcToInstance(name);
3640
+ execution?.assert();
3055
3641
  this.requestDiscordUsagePresenceRefresh();
3056
3642
  }
3057
3643
  /** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
@@ -3692,10 +4278,11 @@ export class FleetManager {
3692
4278
  }
3693
4279
  }
3694
4280
  /** Load .env file from data dir into process.env */
3695
- loadEnvFile() {
4281
+ loadEnvFile(owner) {
3696
4282
  const envPath = join(this.dataDir, ".env");
3697
4283
  if (!existsSync(envPath))
3698
4284
  return;
4285
+ assertSettingsLease(settingsFileResource(envPath), owner);
3699
4286
  const content = readFileSync(envPath, "utf-8");
3700
4287
  for (const line of content.split("\n")) {
3701
4288
  const trimmed = line.trim();
@@ -3720,15 +4307,284 @@ export class FleetManager {
3720
4307
  // Creates web.token if absent; the value is then read back per request by
3721
4308
  // the `webToken` getter, so nothing is cached here.
3722
4309
  loadOrCreateWebToken(this.dataDir);
3723
- this.viewToken = randomBytes(24).toString("hex");
3724
- const viewTokenPath = join(this.dataDir, "view.token");
3725
- writeFileSync(viewTokenPath, this.viewToken, { encoding: "utf8", mode: 0o600 });
4310
+ this.initializeWebSessions();
4311
+ // A `view.token` file was written here for a read-only credential that nothing
4312
+ // ever accepted. Older installs still have one; it authorizes nothing, so do
4313
+ // not leave a credential-shaped file lying around.
3726
4314
  try {
3727
- chmodSync(viewTokenPath, 0o600);
4315
+ rmSync(join(this.dataDir, "view.token"), { force: true });
3728
4316
  }
3729
4317
  catch { /* best effort */ }
3730
4318
  this.healthServerListening = false;
3731
4319
  }
4320
+ initializeWebSessions() {
4321
+ if (!this.webSessions) {
4322
+ this.webSessions = new WebSessionStore({
4323
+ dataDir: this.dataDir,
4324
+ onWarn: message => this.logger.warn(message),
4325
+ });
4326
+ }
4327
+ if (!this.webLoginCodes) {
4328
+ this.webLoginCodes = new WebLoginCodes({
4329
+ onEvent: event => {
4330
+ if (event === "burned")
4331
+ this.logger.warn("A web login code was used up by wrong attempts");
4332
+ else if (event === "breaker-open")
4333
+ this.logger.warn("Web sign-in paused: too many wrong login codes");
4334
+ },
4335
+ });
4336
+ }
4337
+ }
4338
+ /**
4339
+ * A single-use login code for the dashboard, for a channel only the operator
4340
+ * can read (`/dashboard`). Null while the panel is closed (no web.token).
4341
+ */
4342
+ issueDashboardLogin() {
4343
+ const token = this.webToken;
4344
+ if (!token)
4345
+ return null;
4346
+ this.initializeWebSessions();
4347
+ const issued = this.webLoginCodes.issue({ tier: "admin", epoch: tokenEpoch(token) });
4348
+ return { display: issued.display, expiresAt: issued.expiresAt, ttlMinutes: Math.round(LOGIN_CODE_TTL_MS / 60_000) };
4349
+ }
4350
+ tunnelPurposeLane = null;
4351
+ publicWebLink = null;
4352
+ getTunnelLane() {
4353
+ return this.tunnelPurposeLane ??= new TunnelPurposeLane(new ManagedTunnel({ dataDir: this.dataDir,
4354
+ log: () => this.logger.warn("Managed tunnel cleanup requires attention") }));
4355
+ }
4356
+ publicOwnerCurrent(owner) {
4357
+ if (this.shuttingDown || !this.hasFleetAdmins(owner.adapterId) || !this.isFleetAdmin(owner.userId, owner.adapterId))
4358
+ return false;
4359
+ const configured = (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).find(ch => (ch.id ?? ch.type) === owner.adapterId);
4360
+ if (!configured || String(configured.group_id ?? "") !== owner.chatId)
4361
+ return false;
4362
+ const world = this.worlds.get(owner.adapterId);
4363
+ if (!world || !owner.binding || world.adapter !== owner.binding || this.adapters.get(owner.adapterId) !== owner.binding)
4364
+ return false;
4365
+ return Object.entries(this.fleetConfig?.instances ?? {}).some(([name, cfg]) => cfg.general_topic
4366
+ && this.getInstanceAdapterId(name) === owner.adapterId && String(this.getGroupIdForInstance(name) ?? "") === owner.chatId
4367
+ && cfg.topic_id?.toString() === owner.threadId);
4368
+ }
4369
+ /** Exact owning General. No primary-adapter or name fallback participates in authorization. */
4370
+ dashboardOwner(userId, adapterId, address, threadId) {
4371
+ const world = this.worlds.get(adapterId);
4372
+ if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
4373
+ return null;
4374
+ for (const [name, cfg] of Object.entries(this.fleetConfig?.instances ?? {})) {
4375
+ if (!cfg.general_topic || this.getInstanceAdapterId(name) !== adapterId)
4376
+ continue;
4377
+ const group = String(this.getGroupIdForInstance(name) ?? "");
4378
+ const topic = cfg.topic_id?.toString();
4379
+ if (!topic)
4380
+ continue;
4381
+ const dc = world.adapter.id === adapterId && world.channelConfig.type === "discord";
4382
+ const matched = dc ? topic === address || (group === address && topic === threadId)
4383
+ : group === address && ((topic === "1" && (threadId === undefined || threadId === "1")) || topic === threadId);
4384
+ if (group && matched)
4385
+ return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter };
4386
+ }
4387
+ return null;
4388
+ }
4389
+ getPublicWebLink() {
4390
+ this.initializeWebSessions();
4391
+ return this.publicWebLink ??= new PublicWebLink({
4392
+ dataDir: this.dataDir, web: () => this.fleetConfig?.web,
4393
+ permitted: owner => this.publicOwnerCurrent(owner),
4394
+ reserve: id => this.getTunnelLane().reserve("dashboard", id),
4395
+ createGateway: (exposureId, current, open, failed) => createPublicWebGateway({ exposureId, isCurrent: current, isOpen: open, onError: failed,
4396
+ dispatch: (req, res) => this.dispatchWebHttp(req, res, this.fleetConfig?.health_port ?? 19280) }),
4397
+ revoke: id => {
4398
+ this.webLoginCodes?.revokeAudience(id);
4399
+ const result = this.webSessions?.revokeExposure(id);
4400
+ if (result && !result.durable)
4401
+ this.logger.warn("Public session revocation could not be saved; exposure remains closed");
4402
+ },
4403
+ log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
4404
+ onCleanupUnconfirmed: (result, owner) => {
4405
+ this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
4406
+ const world = this.worlds.get(owner.adapterId);
4407
+ if (world && world.adapter === owner.binding)
4408
+ void world.adapter.sendText(owner.chatId, t("dashboard.public_cleanup"), { threadId: owner.threadId }).catch(() => { });
4409
+ },
4410
+ });
4411
+ }
4412
+ getPublicWebStatus() {
4413
+ return this.publicWebLink?.status() ?? { state: "closed" };
4414
+ }
4415
+ async confirmPublicWebLogin(info, current) {
4416
+ if (!current() || !this.publicOwnerCurrent(info.owner))
4417
+ throw new Error("public owner unavailable");
4418
+ const adapter = info.owner.binding;
4419
+ await adapter.sendText(info.owner.chatId, t("web.public_login_notice", info.label, info.handle.slice(0, 8)), { threadId: info.owner.threadId, allowedMentions: { parse: [] } });
4420
+ if (!current() || !this.publicOwnerCurrent(info.owner))
4421
+ throw new Error("public owner changed");
4422
+ }
4423
+ /** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
4424
+ async dashboardMenu(msg) {
4425
+ const id = msg.adapterId;
4426
+ const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId) : null;
4427
+ if (!owner) {
4428
+ const adapter = id ? this.adapters.get(id) : undefined;
4429
+ await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
4430
+ return;
4431
+ }
4432
+ await this.showDashboardMenu(owner);
4433
+ }
4434
+ async sendLocalDashboard(data, adapterId) {
4435
+ if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId))
4436
+ return;
4437
+ const text = this.topicCommands.getDashboardText();
4438
+ const adapter = this.adapters.get(adapterId);
4439
+ const token = this.webToken;
4440
+ if (!adapter || !token) {
4441
+ await data.respond(text);
4442
+ return;
4443
+ }
4444
+ this.initializeWebSessions();
4445
+ const login = this.webLoginCodes.issue({ epoch: tokenEpoch(token) });
4446
+ const current = () => !this.shuttingDown && this.adapters.get(adapterId) === adapter
4447
+ && this.webToken === token && this.hasFleetAdmins(adapterId) && this.isFleetAdmin(data.userId, adapterId);
4448
+ const privateText = `${text}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
4449
+ const deadline = performance.now() + 10_000;
4450
+ let deliveredByDm = false;
4451
+ try {
4452
+ try {
4453
+ if (!adapter.sendDirect)
4454
+ throw new Error("DM unavailable");
4455
+ await withinBudget(adapter.sendDirect(data.userId, privateText, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
4456
+ deliveredByDm = true;
4457
+ }
4458
+ catch {
4459
+ if (!current())
4460
+ throw new Error("owner changed");
4461
+ const id = await withinBudget(data.respond(privateText), deadline); // native slash response is already ephemeral
4462
+ if (!id)
4463
+ throw new Error("private delivery unconfirmed");
4464
+ }
4465
+ if (!current())
4466
+ throw new Error("owner changed");
4467
+ if (deliveredByDm)
4468
+ await withinBudget(data.respond(t("dashboard.private_sent")), deadline);
4469
+ }
4470
+ catch {
4471
+ this.webLoginCodes.revokeIfCurrent(login.issuanceId);
4472
+ }
4473
+ }
4474
+ async showDashboardMenu(owner, respondButtons) {
4475
+ if (!this.publicOwnerCurrent(owner))
4476
+ return;
4477
+ const adapter = owner.binding;
4478
+ const status = this.getPublicWebStatus();
4479
+ const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
4480
+ const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
4481
+ + (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
4482
+ + "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0);
4483
+ const choices = [{ action: "local", label: t("dashboard.local") },
4484
+ ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
4485
+ ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
4486
+ await this.postNonceButtonPromptOrThrow({
4487
+ prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
4488
+ chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
4489
+ message: menuText,
4490
+ choices, expiredText: t("buttons.stale"), extra: { requesterUserId: owner.userId, dashboardOwner: owner, publicExposureId: this.publicWebLink?.exposureId },
4491
+ ...(respondButtons ? { deliver: async (c) => {
4492
+ const messageId = await respondButtons(menuText, c);
4493
+ if (!messageId)
4494
+ throw new Error("menu refused");
4495
+ return { chatId: owner.chatId, threadId: owner.threadId, messageId };
4496
+ } } : {}),
4497
+ });
4498
+ }
4499
+ async handleDashboardCallback(data, adapterId, adapter) {
4500
+ const claimed = this.consumeNonceCallback("dashboard:", /^dashboard:([0-9a-f]{32}):(local|public|close)$/, data, adapterId, adapter);
4501
+ if (claimed === null)
4502
+ return false;
4503
+ if (claimed === "consumed")
4504
+ return true;
4505
+ const { entry, action } = claimed;
4506
+ const owner = entry.dashboardOwner;
4507
+ if (!owner || !this.publicOwnerCurrent(owner)) {
4508
+ data.ack?.(t("not_authorized"));
4509
+ return true;
4510
+ }
4511
+ if (action === "close") {
4512
+ if (!entry.publicExposureId)
4513
+ return true;
4514
+ const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
4515
+ if (result?.confirmed === false)
4516
+ data.ack?.(t("dashboard.public_cleanup"));
4517
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"));
4518
+ return true;
4519
+ }
4520
+ const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
4521
+ const token = this.webToken;
4522
+ if (!token || !current())
4523
+ return false;
4524
+ this.initializeWebSessions();
4525
+ const issued = this.webLoginCodes.issue({ epoch: tokenEpoch(token), audience: exposureId, owner });
4526
+ const text = t("dashboard.private_link", url, issued.display, Math.round(LOGIN_CODE_TTL_MS / 60_000), expiresAt ? new Date(expiresAt).toISOString() : t("dashboard.local"));
4527
+ const deadline = performance.now() + 10_000;
4528
+ const direct = owner.binding;
4529
+ try {
4530
+ const deliver = async (c = []) => {
4531
+ try {
4532
+ if (!direct.sendDirect)
4533
+ throw new Error("DM unavailable");
4534
+ return await withinBudget(direct.sendDirect(owner.userId, text, { disablePreview: true, choices: c }), Math.min(deadline, performance.now() + 5_000));
4535
+ }
4536
+ catch {
4537
+ if (direct.type !== "discord" || !data.respondPrivate || !current())
4538
+ throw new Error("private delivery unavailable");
4539
+ const sent = await withinBudget(data.respondPrivate(text, c), deadline);
4540
+ return { ...sent, chatId: owner.chatId, threadId: owner.threadId };
4541
+ }
4542
+ };
4543
+ if (exposureId)
4544
+ await withinBudget(this.postNonceButtonPromptOrThrow({
4545
+ prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter: direct, adapterId,
4546
+ chatId: owner.userId, message: t("dashboard.public_close"), choices: [{ action: "close", label: t("dashboard.public_close") }],
4547
+ expiredText: t("buttons.stale"), timeoutMs: 5 * 60_000, extra: { requesterUserId: owner.userId, publicExposureId: exposureId, dashboardOwner: owner }, deliver,
4548
+ }), deadline);
4549
+ else
4550
+ await deliver();
4551
+ if (!current() || this.webToken !== token)
4552
+ throw new Error("owner changed");
4553
+ return true;
4554
+ }
4555
+ catch {
4556
+ this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
4557
+ this.logger.info("Dashboard private delivery was not confirmed");
4558
+ return false;
4559
+ }
4560
+ };
4561
+ const ok = action === "public"
4562
+ ? await this.getPublicWebLink().deliver(owner, link => send(link.url, link.exposureId, link.expiresAt, link.isCurrent))
4563
+ : await send(`http://${this.fleetConfig?.hostname || "localhost"}:${this.fleetConfig?.health_port ?? 19280}/signin`);
4564
+ // Only safe, static words enter General; never link, code or platform error text.
4565
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t(ok ? "dashboard.private_sent" : "dashboard.private_failed"));
4566
+ return true;
4567
+ }
4568
+ /** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
4569
+ revokeWebSessions() {
4570
+ this.initializeWebSessions();
4571
+ void this.publicWebLink?.close("dashboard revoke");
4572
+ this.webLoginCodes.revoke();
4573
+ const result = this.webSessions.revokeAll();
4574
+ if (result.durable)
4575
+ this.logger.info({ count: result.count }, "Web sessions revoked (all)");
4576
+ else
4577
+ this.logger.warn({ count: result.count }, "Web sessions revoked in memory only — the session file could not be updated or removed");
4578
+ return result;
4579
+ }
4580
+ /** Called by the sign-in endpoint: a login the operator did not make should be visible to them. */
4581
+ onWebLogin(info) {
4582
+ if (this.fleetConfig?.web?.notify_login === false)
4583
+ return;
4584
+ // The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
4585
+ // sign-in from the same kind of browser is exactly the one the operator most needs to hear about.
4586
+ this.notifyFleetError(t("web.login_notice", info.label, info.surface, info.handle.slice(0, 8)));
4587
+ }
3732
4588
  getDashboardAccess() {
3733
4589
  return { ready: this.healthServerListening, token: this.webToken };
3734
4590
  }
@@ -3801,7 +4657,8 @@ export class FleetManager {
3801
4657
  // ambiguous under systemd because this helper shares the old cgroup.
3802
4658
  if (this.shuttingDown || !this.isOwnedFullRestartMarker(ownedMarker.startedAt, target))
3803
4659
  return;
3804
- if (!result.error && (result.code === 0 || result.code === null))
4660
+ // The restart job outlived the helper's wait (systemd still running it): the new fleet settles the marker.
4661
+ if (!result.error && (result.code === 0 || result.code === null || result.code === SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE))
3805
4662
  return;
3806
4663
  const detail = result.error
3807
4664
  ? "reload helper failed after launch"
@@ -3937,7 +4794,14 @@ export class FleetManager {
3937
4794
  // anything arriving during it is still part of the stop.
3938
4795
  this.shuttingDown = false;
3939
4796
  this.configPath = configPath;
3940
- this.loadEnvFile();
4797
+ const settingsGeneration = this.settingsGeneration;
4798
+ const envLease = await waitSettingsLease([settingsFileResource(join(this.dataDir, ".env"))], () => !this.shuttingDown && this.settingsGeneration === settingsGeneration, performance.now() + 30_000);
4799
+ try {
4800
+ this.loadEnvFile(envLease.owner);
4801
+ }
4802
+ finally {
4803
+ envLease.release();
4804
+ }
3941
4805
  this.ensureDeliveryOutbox();
3942
4806
  this.rotateFleetLogs();
3943
4807
  const fleet = this.loadConfig(configPath);
@@ -4024,7 +4888,8 @@ export class FleetManager {
4024
4888
  }
4025
4889
  // Poll classicBot.yaml for external changes every 30s
4026
4890
  this.classicReloadTimer = setInterval(() => {
4027
- void this.reloadClassicConfigFromDisk();
4891
+ // Attributes the synchronous part, up to the reload's first await (#1235).
4892
+ void measureSyncWork("fleet.classicReload", () => this.reloadClassicConfigFromDisk());
4028
4893
  }, 30_000);
4029
4894
  const costGuardConfig = {
4030
4895
  ...DEFAULT_COST_GUARD,
@@ -4073,6 +4938,8 @@ export class FleetManager {
4073
4938
  // Rotate classic channel chat logs daily (piggyback on daily summary timer)
4074
4939
  this.classicChannels?.rotateLogs();
4075
4940
  this.rotateInboxes();
4941
+ // Web uploads no message took before this restart (#1273).
4942
+ this.sweepOrphanedWebUploads();
4076
4943
  // Auto-create/adopt a general dispatcher — ONLY for the primary adapter.
4077
4944
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
4078
4945
  this.warnUnboundGeneralChannelIds(fleet);
@@ -4161,7 +5028,7 @@ export class FleetManager {
4161
5028
  ...DEFAULT_SCHEDULER_CONFIG,
4162
5029
  ...this.fleetConfig?.defaults.scheduler,
4163
5030
  };
4164
- this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule, runId) => this.handleScheduleTrigger(schedule, runId), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name));
5031
+ this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule, runId, retry) => this.handleScheduleTrigger(schedule, runId, retry), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name), (schedule, retry, reason) => this.scheduleRetryDropped(schedule, retry, reason));
4165
5032
  this.scheduler.init();
4166
5033
  this.logger.info("Scheduler initialized");
4167
5034
  // Tips share the daily-report clock but remain an independent internal
@@ -4402,6 +5269,8 @@ export class FleetManager {
4402
5269
  }
4403
5270
  await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
4404
5271
  }
5272
+ // After "fleet ready", so the notice is not the first thing people see of a restart.
5273
+ void this.announceWebChatOnce(agendVersion);
4405
5274
  }
4406
5275
  // Health HTTP endpoint
4407
5276
  this.startHealthServer(fleet.health_port ?? 19280);
@@ -4535,6 +5404,27 @@ export class FleetManager {
4535
5404
  this.logger.info({ deleted }, "Rotated inbox files");
4536
5405
  return deleted;
4537
5406
  }
5407
+ /**
5408
+ * Web uploads no message took before a restart (#1273): removed once older than the upload window. Younger ones
5409
+ * are looked at again when the first comes due (an unref'd timer, so it never holds the process).
5410
+ */
5411
+ sweepOrphanedWebUploads() {
5412
+ // Skip what this process's ledger still holds: those are uploads made since this start, timed by the ledger.
5413
+ // (Compared the way the sweep names files: the inbox directory resolved, the file name as it is.)
5414
+ const owned = new Set([...this.webFiles.ownedPaths()].map(p => { try {
5415
+ return join(realpathSync(dirname(p)), basename(p));
5416
+ }
5417
+ catch {
5418
+ return p;
5419
+ } }));
5420
+ const { deleted, nextDueInMs } = sweepOrphanedUploads(join(getAgendHome(), "workspaces"), Date.now(), undefined, owned);
5421
+ if (deleted > 0)
5422
+ this.logger.info({ deleted }, "Removed web uploads no message took before the restart");
5423
+ if (nextDueInMs !== null) {
5424
+ const t = setTimeout(() => this.sweepOrphanedWebUploads(), Math.max(1_000, nextDueInMs + 1_000));
5425
+ t.unref?.();
5426
+ }
5427
+ }
4538
5428
  /** Start the shared channel adapter(s) for topic mode */
4539
5429
  async startSharedAdapter(fleet) {
4540
5430
  const channelConfigs = fleet.channels ?? (fleet.channel ? [fleet.channel] : []);
@@ -4730,7 +5620,27 @@ export class FleetManager {
4730
5620
  };
4731
5621
  }
4732
5622
  /** Start the primary adapter (backward-compatible, sets this.adapter) */
4733
- async startSingleAdapter(fleet, channelConfig, onStarted) {
5623
+ async startSingleAdapter(fleet, channelConfig, onStarted, operation) {
5624
+ const id = channelConfig.id ?? channelConfig.type;
5625
+ const lease = trySettingsLease([`connection:${this.dataDir}:${id}`], operation?.owner);
5626
+ if (!lease)
5627
+ throw new Error("connection operation is still running");
5628
+ try {
5629
+ await this.startSingleAdapterOwned(fleet, channelConfig, onStarted, operation ?? { owner: lease.owner, assert: () => { } });
5630
+ }
5631
+ finally {
5632
+ lease.release();
5633
+ }
5634
+ }
5635
+ async startSingleAdapterOwned(fleet, channelConfig, onStarted, operation) {
5636
+ const ownerId = channelConfig.id ?? channelConfig.type, generation = this.settingsGeneration;
5637
+ const check = () => {
5638
+ operation?.assert();
5639
+ assertSettingsLease(`connection:${this.dataDir}:${ownerId}`, operation?.owner);
5640
+ if (this.shuttingDown || this.settingsGeneration !== generation)
5641
+ throw new Error("adapter admission superseded");
5642
+ };
5643
+ check();
4734
5644
  const botToken = process.env[channelConfig.bot_token_env];
4735
5645
  if (!botToken) {
4736
5646
  this.logger.warn({ env: channelConfig.bot_token_env }, "Bot token env not set, skipping shared adapter");
@@ -4741,39 +5651,46 @@ export class FleetManager {
4741
5651
  const accessStatePath = join(accessDir, "access.json");
4742
5652
  const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
4743
5653
  this.warnIfAccessModeOverridden(accessManager, accessStatePath);
4744
- this.accessManager = accessManager;
4745
5654
  const inboxDir = join(this.dataDir, "inbox");
4746
5655
  mkdirSync(inboxDir, { recursive: true });
4747
5656
  const adapterId = channelConfig.id ?? channelConfig.type;
4748
- this.adapter = await createAdapter(channelConfig, {
5657
+ const adapter = await createAdapter(channelConfig, {
4749
5658
  id: adapterId,
4750
5659
  botToken,
4751
5660
  accessManager,
4752
5661
  inboxDir,
4753
5662
  fleetLabel: fleetLabel(this.fleetConfig),
4754
5663
  });
4755
- const adapter = this.adapter;
5664
+ try {
5665
+ check();
5666
+ }
5667
+ catch (err) {
5668
+ await adapter.stop().catch(() => { });
5669
+ throw err;
5670
+ }
5671
+ this.adapter = adapter;
5672
+ this.accessManager = accessManager;
4756
5673
  const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
4757
5674
  this.worlds.set(adapterId, world);
4758
5675
  this.adapters.set(adapterId, adapter);
4759
5676
  this.bindAdapterHealth(adapter, adapterId);
4760
5677
  const isCurrentAdapter = () => this.adapters.get(adapterId) === adapter;
4761
- this.adapter.on("message", safeHandler(async (msg) => {
5678
+ adapter.on("message", safeHandler(async (msg) => {
4762
5679
  if (!isCurrentAdapter())
4763
5680
  return;
4764
5681
  await this.handleInboundMessage(msg);
4765
5682
  }, this.logger, "adapter.message"));
4766
- this.adapter.on("reaction", safeHandler(async (r) => {
5683
+ adapter.on("reaction", safeHandler(async (r) => {
4767
5684
  if (!isCurrentAdapter())
4768
5685
  return;
4769
5686
  await this.handleInboundReaction(r);
4770
5687
  }, this.logger, "adapter.reaction"));
4771
- this.adapter.on("callback_query", safeHandler(async (data) => {
4772
- await this.receiveAdapterCallback(data, adapterId, this.adapter ?? undefined, isCurrentAdapter);
5688
+ adapter.on("callback_query", safeHandler(async (data) => {
5689
+ await this.receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter);
4773
5690
  }, this.logger, "adapter.callback_query"));
4774
5691
  this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
4775
5692
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
4776
- this.adapter.on("slash_command", safeHandler(async (data) => {
5693
+ adapter.on("slash_command", safeHandler(async (data) => {
4777
5694
  if (!isCurrentAdapter())
4778
5695
  return;
4779
5696
  await this.dispatchSlash(data, adapterId, adapter);
@@ -4783,7 +5700,7 @@ export class FleetManager {
4783
5700
  // Background-probe each backend's CLI env (version/models) → cli-env cache.
4784
5701
  // Non-blocking: /model & status views read the cache; never delays startup.
4785
5702
  this.probeCliEnvs();
4786
- this.adapter.on("started", safeHandler((username, userId) => {
5703
+ adapter.on("started", safeHandler((username, userId) => {
4787
5704
  if (!isCurrentAdapter())
4788
5705
  return;
4789
5706
  this.logger.info(`Bot @${username} polling started. Ensure no other service is polling this bot token.`);
@@ -4799,19 +5716,19 @@ export class FleetManager {
4799
5716
  this.botUserId = userId;
4800
5717
  onStarted?.();
4801
5718
  }, this.logger, "adapter.started"));
4802
- this.adapter.on("polling_conflict", safeHandler(({ attempt, delay }) => {
5719
+ adapter.on("polling_conflict", safeHandler(({ attempt, delay }) => {
4803
5720
  this.logger.warn(`409 Conflict (attempt ${attempt}), retry in ${delay / 1000}s`);
4804
5721
  }, this.logger, "adapter.polling_conflict"));
4805
- this.adapter.on("handler_error", safeHandler((err) => {
5722
+ adapter.on("handler_error", safeHandler((err) => {
4806
5723
  this.logger.warn({ err: err instanceof Error ? err.message : String(err) }, "Adapter handler error");
4807
5724
  }, this.logger, "adapter.handler_error"));
4808
- this.adapter.on("error", (err) => {
5725
+ adapter.on("error", (err) => {
4809
5726
  if (!isCurrentAdapter())
4810
5727
  return;
4811
5728
  this.logger.error({ err }, "Primary adapter fatal error");
4812
- this.restartAdapter(this.adapter, adapterId).catch(() => { });
5729
+ this.restartAdapter(adapter, adapterId).catch(() => { });
4813
5730
  });
4814
- this.adapter.on("new_group_detected", safeHandler(async (data) => {
5731
+ adapter.on("new_group_detected", safeHandler(async (data) => {
4815
5732
  if (!isCurrentAdapter())
4816
5733
  return;
4817
5734
  const adminMsg = t("alert.bot_added", data.groupTitle, data.groupId, data.source);
@@ -4821,11 +5738,20 @@ export class FleetManager {
4821
5738
  await this.promptClassicApproval({ generalName: generalId, message: adminMsg, groupId: data.groupId, scope: data.source === "telegram" ? "group" : "guild" });
4822
5739
  }, this.logger, "adapter.new_group_detected"));
4823
5740
  // Start adapter AFTER all event listeners are registered (started event sets botUsername)
4824
- await this.adapter.start();
4825
- if (fleet.channel?.group_id) {
4826
- this.adapter.setChatId(String(fleet.channel.group_id));
5741
+ try {
5742
+ await adapter.start();
5743
+ check();
5744
+ if (!isCurrentAdapter())
5745
+ throw new Error("adapter ownership changed during login");
5746
+ }
5747
+ catch (err) {
5748
+ await this.disposeSettingsAdapter(adapterId, adapter);
5749
+ throw err;
5750
+ }
5751
+ if (channelConfig.group_id) {
5752
+ adapter.setChatId(String(channelConfig.group_id));
4827
5753
  }
4828
- if (this.discordPresenceTimer && this.adapter.type === "discord") {
5754
+ if (this.discordPresenceTimer && adapter.type === "discord") {
4829
5755
  this.requestDiscordUsagePresenceRefresh();
4830
5756
  }
4831
5757
  this.startTopicCleanupPoller();
@@ -4844,8 +5770,28 @@ export class FleetManager {
4844
5770
  void this.topicCommands.registerBotCommands(channelConfig).catch(e => this.logger.warn({ err: e, adapterId: channelConfig.id ?? channelConfig.type }, "registerBotCommands failed (non-fatal)"));
4845
5771
  }
4846
5772
  /** Start an additional (non-primary) adapter */
4847
- async startAdditionalAdapter(channelConfig, registerCommands = true, onStarted) {
5773
+ async startAdditionalAdapter(channelConfig, registerCommands = true, onStarted, operation) {
5774
+ const id = channelConfig.id ?? channelConfig.type;
5775
+ const lease = trySettingsLease([`connection:${this.dataDir}:${id}`], operation?.owner);
5776
+ if (!lease)
5777
+ throw new Error("connection operation is still running");
5778
+ try {
5779
+ await this.startAdditionalAdapterOwned(channelConfig, registerCommands, onStarted, operation ?? { owner: lease.owner, assert: () => { } });
5780
+ }
5781
+ finally {
5782
+ lease.release();
5783
+ }
5784
+ }
5785
+ async startAdditionalAdapterOwned(channelConfig, registerCommands = true, onStarted, operation) {
4848
5786
  const adapterId = channelConfig.id ?? channelConfig.type;
5787
+ const ownerId = channelConfig.id ?? channelConfig.type, generation = this.settingsGeneration;
5788
+ const check = () => {
5789
+ operation?.assert();
5790
+ assertSettingsLease(`connection:${this.dataDir}:${ownerId}`, operation?.owner);
5791
+ if (this.shuttingDown || this.settingsGeneration !== generation)
5792
+ throw new Error("adapter admission superseded");
5793
+ };
5794
+ check();
4849
5795
  const botToken = process.env[channelConfig.bot_token_env];
4850
5796
  if (!botToken) {
4851
5797
  this.logger.warn({ env: channelConfig.bot_token_env, adapterId }, "Bot token env not set, skipping adapter");
@@ -4866,6 +5812,13 @@ export class FleetManager {
4866
5812
  registerCommands,
4867
5813
  fleetLabel: fleetLabel(this.fleetConfig),
4868
5814
  });
5815
+ try {
5816
+ check();
5817
+ }
5818
+ catch (err) {
5819
+ await adapter.stop().catch(() => { });
5820
+ throw err;
5821
+ }
4869
5822
  const world = new AdapterWorld(adapterId, adapter, accessManager, channelConfig);
4870
5823
  this.worlds.set(adapterId, world);
4871
5824
  this.adapters.set(adapterId, adapter);
@@ -4922,7 +5875,16 @@ export class FleetManager {
4922
5875
  // at fleet start, on retry, and when Settings rebinds it or rotates its token: all of them start it here (#1191).
4923
5876
  this.registerTelegramCommandsFor(channelConfig);
4924
5877
  // Register lifecycle listeners before login; a fast ready/error must not be lost.
4925
- await adapter.start();
5878
+ try {
5879
+ await adapter.start();
5880
+ check();
5881
+ if (!isCurrentAdapter())
5882
+ throw new Error("adapter ownership changed during login");
5883
+ }
5884
+ catch (err) {
5885
+ await this.disposeSettingsAdapter(adapterId, adapter);
5886
+ throw err;
5887
+ }
4926
5888
  if (channelConfig.group_id) {
4927
5889
  adapter.setChatId(String(channelConfig.group_id));
4928
5890
  }
@@ -5153,10 +6115,23 @@ export class FleetManager {
5153
6115
  }
5154
6116
  }
5155
6117
  }
6118
+ async disposeSettingsAdapter(id, adapter) {
6119
+ adapter.removeAllListeners();
6120
+ await adapter.stop().catch(() => { });
6121
+ if (this.adapters.get(id) === adapter)
6122
+ this.adapters.delete(id);
6123
+ if (this.worlds.get(id)?.adapter === adapter)
6124
+ this.worlds.delete(id);
6125
+ if (this.adapter === adapter)
6126
+ this.adapter = null;
6127
+ }
5156
6128
  /** Restart a channel adapter after fatal error with infinite retry + 60s cap */
5157
6129
  async restartAdapter(adapter, id) {
5158
- if (this.adapterRestarting.has(id))
6130
+ if (this.adapterRestarting.has(id) || this.shuttingDown || this.adapters.get(id) !== adapter)
5159
6131
  return;
6132
+ const generation = this.settingsGeneration;
6133
+ const current = () => !this.shuttingDown && this.settingsGeneration === generation && this.adapters.get(id) === adapter;
6134
+ let lease = null;
5160
6135
  this.adapterRestarting.add(id);
5161
6136
  // Reflect reality in adapterState throughout. This loop used to leave the state
5162
6137
  // untouched, so getAdapterStates() — and therefore /health and the dashboard —
@@ -5167,10 +6142,15 @@ export class FleetManager {
5167
6142
  try {
5168
6143
  if (adapter.reconnectGateway) {
5169
6144
  try {
6145
+ lease = trySettingsLease([`connection:${this.dataDir}:${id}`]);
6146
+ if (!lease || !current())
6147
+ return;
5170
6148
  // Discord requires a fresh Client after destroy(). Its adapter owns the
5171
6149
  // single-flight, generation fence and bounded IDENTIFY backoff, so all
5172
6150
  // watchdog/manual/error triggers must converge here instead of stop/start.
5173
6151
  await adapter.reconnectGateway(previous?.lastError ?? "fleet adapter restart");
6152
+ if (!current())
6153
+ return;
5174
6154
  this.adapterState.set(id, { status: "connected", retryCount: 0 });
5175
6155
  if (this.discordPresenceTimer && adapter.type === "discord") {
5176
6156
  this.requestDiscordUsagePresenceRefresh();
@@ -5178,7 +6158,7 @@ export class FleetManager {
5178
6158
  this.logger.info({ id }, "Adapter gateway rebuilt successfully");
5179
6159
  }
5180
6160
  catch (err) {
5181
- if (!this.ipcStoppingInstances.has("__fleet_stopping__")) {
6161
+ if (current() && !this.ipcStoppingInstances.has("__fleet_stopping__")) {
5182
6162
  this.adapterState.set(id, {
5183
6163
  status: "failed",
5184
6164
  retryCount: previous?.retryCount ?? 0,
@@ -5189,15 +6169,22 @@ export class FleetManager {
5189
6169
  return;
5190
6170
  }
5191
6171
  for (let attempt = 1;; attempt++) {
5192
- if (this.ipcStoppingInstances.has("__fleet_stopping__"))
6172
+ if (!current() || this.ipcStoppingInstances.has("__fleet_stopping__"))
5193
6173
  return;
5194
6174
  const delay = attempt <= 3 ? 5000 * Math.pow(2, attempt - 1) : 60_000; // 5s, 10s, 20s, then 60s
5195
6175
  await new Promise(r => setTimeout(r, delay));
5196
- if (this.ipcStoppingInstances.has("__fleet_stopping__"))
6176
+ if (!current() || this.ipcStoppingInstances.has("__fleet_stopping__"))
6177
+ return;
6178
+ lease = trySettingsLease([`connection:${this.dataDir}:${id}`]);
6179
+ if (!lease)
5197
6180
  return;
5198
6181
  try {
5199
6182
  await adapter.stop().catch(() => { });
6183
+ if (!current())
6184
+ return;
5200
6185
  await adapter.start();
6186
+ if (!current())
6187
+ return;
5201
6188
  this.logger.info({ id, attempt }, "Adapter restarted successfully");
5202
6189
  this.adapterState.set(id, { status: "connected", retryCount: 0 });
5203
6190
  if (this.discordPresenceTimer && adapter.type === "discord") {
@@ -5206,18 +6193,23 @@ export class FleetManager {
5206
6193
  return;
5207
6194
  }
5208
6195
  catch (err) {
6196
+ if (!current())
6197
+ return;
5209
6198
  this.adapterState.set(id, {
5210
6199
  status: "retrying",
5211
6200
  retryCount: attempt,
5212
6201
  lastError: err?.message ?? String(err),
5213
6202
  });
5214
6203
  }
6204
+ lease.release();
6205
+ lease = null;
5215
6206
  if (attempt % 10 === 0) {
5216
6207
  this.logger.warn({ id, attempt }, "Adapter restart still failing");
5217
6208
  }
5218
6209
  }
5219
6210
  }
5220
6211
  finally {
6212
+ lease?.release();
5221
6213
  this.adapterRestarting.delete(id);
5222
6214
  }
5223
6215
  }
@@ -5442,12 +6434,29 @@ export class FleetManager {
5442
6434
  /**
5443
6435
  * #1346: whether this copy may claim the shared dedup key for
5444
6436
  * command-like text. Fleet topics resolve an owning adapter and only its
5445
- * copy proceeds; anything else (classic targets, unknown routing, no
5446
- * identity) passes through to the existing handling.
6437
+ * copy proceeds. Classic targets and unknown routing keep their existing
6438
+ * handling. At the receiving Telegram world's own forum root, an explicit
6439
+ * suffix requires a known matching username before dedup; present-thread
6440
+ * copies retain their permissive handling when that identity is unknown.
5447
6441
  */
5448
6442
  isOwnerCommandCopy(msg, threadId) {
5449
- if (!msg.adapterId || threadId === undefined)
6443
+ if (!msg.adapterId)
5450
6444
  return true;
6445
+ const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
6446
+ if (threadId === undefined) {
6447
+ // General at the forum root belongs to the receiving world. Prove an
6448
+ // explicit suffix before shared dedup, even when there is no message id.
6449
+ // Classic/private/foreign-forum copies retain their own dispatch rules.
6450
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
6451
+ const channel = channels.find(ch => (ch.id ?? ch.type) === msg.adapterId)
6452
+ ?? this.worlds.get(msg.adapterId)?.channelConfig;
6453
+ if (msg.source === "telegram" && channel?.type === "telegram"
6454
+ && channel.group_id != null && String(channel.group_id) === msg.chatId && suffix) {
6455
+ const username = this.worlds.get(msg.adapterId)?.botUsername;
6456
+ return !!username && suffix.toLowerCase() === username.toLowerCase();
6457
+ }
6458
+ return true;
6459
+ }
5451
6460
  const target = this.resolveInboundTarget(msg, threadId);
5452
6461
  if (!target || target.kind === "classic")
5453
6462
  return true;
@@ -5460,7 +6469,6 @@ export class FleetManager {
5460
6469
  // Telegram classic branch.
5461
6470
  // Hyphenated aliases count too (e.g. /install-cli): Telegram command
5462
6471
  // names cannot contain "-", but typed aliases can.
5463
- const suffix = msg.text?.trim().match(/^\/[\w-]+@(\S+)/)?.[1];
5464
6472
  if (suffix && owner) {
5465
6473
  const ownerUser = this.worlds.get(owner)?.botUsername;
5466
6474
  if (ownerUser && suffix.toLowerCase() !== ownerUser.toLowerCase())
@@ -5477,6 +6485,32 @@ export class FleetManager {
5477
6485
  });
5478
6486
  }
5479
6487
  async handleInboundMessage(msg) {
6488
+ // Platform author identity is the authority. This runs before routing,
6489
+ // collab/commands/access/dedup and does not depend on any echo setting/ACK.
6490
+ const fleetBotIds = new Set();
6491
+ for (const world of this.worlds.values()) {
6492
+ if (world.adapter.type !== msg.source)
6493
+ continue;
6494
+ const id = world.adapter.getBotUserId?.() ?? world.botUserId;
6495
+ if (id)
6496
+ fleetBotIds.add(id);
6497
+ }
6498
+ if (isWebChannelEcho(msg.text ?? "", msg.userId, fleetBotIds))
6499
+ return;
6500
+ const configured = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
6501
+ const identitiesPending = configured.some(channel => {
6502
+ if (channel.type !== msg.source)
6503
+ return false;
6504
+ const world = this.worlds.get(channel.id ?? channel.type);
6505
+ return !(world?.adapter.getBotUserId?.() ?? world?.botUserId);
6506
+ });
6507
+ // Startup/rebuild can receive a replay before another configured world's
6508
+ // authenticated ID is ready. Quarantine only bot-flagged prefix candidates;
6509
+ // humans keep flowing, and no unknown author is labelled a fleet account.
6510
+ if (identitiesPending && msg.isBotMessage === true && (msg.text ?? "").startsWith(WEB_ECHO_PREFIX)) {
6511
+ this.logger.debug({ source: msg.source, adapterId: msg.adapterId }, "Web echo candidate quarantined while bot identities are pending");
6512
+ return;
6513
+ }
5480
6514
  const threadId = this.inboundRouteThreadId(msg);
5481
6515
  this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
5482
6516
  // Ownership and per-adapter filtering both run before the dedup claim below.
@@ -5537,7 +6571,8 @@ export class FleetManager {
5537
6571
  // per-adapter keys — first-/start-contact onboarding needs every copy
5538
6572
  // to flow — so only fleet targets are judged here; the in-handler gates
5539
6573
  // (owner entry check, Discord ignore) cover classic instead.
5540
- if (msg.messageId && /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId)) {
6574
+ if ((msg.messageId || (msg.source === "telegram" && threadId === undefined))
6575
+ && /^\/\w/.test(msg.text?.trim() ?? "") && !this.isOwnerCommandCopy(msg, threadId)) {
5541
6576
  this.logger.debug({ adapterId: msg.adapterId, threadId }, "Non-owner command copy — skipping before dedup claim");
5542
6577
  return;
5543
6578
  }
@@ -5604,42 +6639,12 @@ export class FleetManager {
5604
6639
  }
5605
6640
  // Handle /start command
5606
6641
  if (text === "/start" || text.startsWith("/start ")) {
5607
- if (isPrivateChat) {
5608
- if (!this.classicChannels.isUserAllowed(msg.userId)) {
5609
- const generalId = this.findGeneralInstance(msg.adapterId);
5610
- if (generalId) {
5611
- this.notifyInstanceTopic(generalId, t("alert.unauth_user_private", msg.username, msg.userId, msg.source));
5612
- }
5613
- await msgAdapter?.sendText(chatId, t("classic.not_allowed_user"));
5614
- return;
5615
- }
5616
- }
5617
- else {
5618
- if (!this.classicChannels.isGroupAllowed(chatId)) {
5619
- // Notify admin about new group wanting access
5620
- const groupTitle = msg.chatTitle || chatId;
5621
- const adminMsg = t("alert.new_group", groupTitle, chatId, msg.username, msg.userId, msg.source);
5622
- const generalId = this.findGeneralInstance(msg.adapterId);
5623
- if (generalId) {
5624
- await this.promptClassicApproval({
5625
- // Guarded by isGroupAllowed → this belongs in allowed_groups,
5626
- // NOT allowed_guilds; writing the latter would change the file
5627
- // without unblocking the group.
5628
- generalName: generalId, message: adminMsg, groupId: String(chatId),
5629
- scope: "group", userId: msg.userId,
5630
- });
5631
- }
5632
- await msgAdapter?.sendText(chatId, t("classic.access_requested"));
5633
- return;
5634
- }
5635
- if (!this.classicChannels.isAdmin(msg.userId)) {
5636
- await msgAdapter?.sendText(chatId, t("classic.admin_only_start"));
5637
- const generalId = this.findGeneralInstance(msg.adapterId);
5638
- if (generalId) {
5639
- this.notifyInstanceTopic(generalId, t("alert.start_not_admin", msg.username, msg.userId, msg.source, chatId));
5640
- }
5641
- return;
5642
- }
6642
+ // All new starts, including explicit backend and later chooser callbacks,
6643
+ // share validateClassicStart. Existing channels are recognized first.
6644
+ const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
6645
+ if (blocker) {
6646
+ await msgAdapter?.sendText(chatId, blocker);
6647
+ return;
5643
6648
  }
5644
6649
  const channelName = msg.username || chatId;
5645
6650
  const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
@@ -5675,7 +6680,8 @@ export class FleetManager {
5675
6680
  }
5676
6681
  const pauseWake = parsePauseWakeCommand(text);
5677
6682
  if (pauseWake) {
5678
- if (!this.classicChannels.isAdmin(msg.userId)) {
6683
+ // Channel-admin, as on Discord (#754): a fleet admin of this bot or a ClassicBot admin.
6684
+ if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
5679
6685
  await msgAdapter?.sendText(chatId, t("permission.denied"));
5680
6686
  return;
5681
6687
  }
@@ -5703,7 +6709,7 @@ export class FleetManager {
5703
6709
  await msgAdapter?.sendText(chatId, await this.applyModel(modelInstance, modelName));
5704
6710
  }
5705
6711
  else if (msgAdapter) {
5706
- const fallback = await this.promptModelMenu(modelInstance, msg.userId, chatId, msgAdapter, chatId);
6712
+ const fallback = await this.promptModelMenu(modelInstance, msg.userId, chatId, msgAdapter, chatId, undefined, msg.adapterId);
5707
6713
  if (fallback)
5708
6714
  await msgAdapter.sendText(chatId, fallback);
5709
6715
  }
@@ -5712,7 +6718,8 @@ export class FleetManager {
5712
6718
  // Handle /compact command (admin only)
5713
6719
  const classicCompact = parseCompactCommand(text);
5714
6720
  if (classicCompact) {
5715
- if (!this.classicChannels.isAdmin(msg.userId)) {
6721
+ // Channel-admin, as on Discord (#754): a fleet admin of this bot or a ClassicBot admin.
6722
+ if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
5716
6723
  await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/compact"));
5717
6724
  return;
5718
6725
  }
@@ -5804,7 +6811,8 @@ export class FleetManager {
5804
6811
  }
5805
6812
  // Handle /save command (admin only)
5806
6813
  if (text === "/save" || text.startsWith("/save ") || text.startsWith("/save@")) {
5807
- if (!this.classicChannels.isAdmin(msg.userId)) {
6814
+ // Channel-admin, as on Discord (#754): a fleet admin of this bot or a ClassicBot admin.
6815
+ if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
5808
6816
  await msgAdapter?.sendText(chatId, t("cmd.admin_required", "/save"));
5809
6817
  return;
5810
6818
  }
@@ -5919,8 +6927,8 @@ export class FleetManager {
5919
6927
  this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
5920
6928
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), generalInstance);
5921
6929
  this.emitSseEvent("message", {
5922
- instance: generalInstance, sender: msg.username,
5923
- text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
6930
+ instance: generalInstance, sender: msg.username, role: "user",
6931
+ text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
5924
6932
  });
5925
6933
  this.trackInboundMsg(generalInstance, msg);
5926
6934
  void this.sendCancelButton(generalInstance);
@@ -6033,8 +7041,8 @@ export class FleetManager {
6033
7041
  this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
6034
7042
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
6035
7043
  this.emitSseEvent("message", {
6036
- instance: instanceName, sender: msg.username,
6037
- text: (text ?? "").slice(0, 2000), ts: new Date().toISOString(),
7044
+ instance: instanceName, sender: msg.username, role: "user",
7045
+ text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
6038
7046
  });
6039
7047
  this.trackInboundMsg(instanceName, msg);
6040
7048
  void this.sendCancelButton(instanceName);
@@ -6086,6 +7094,34 @@ export class FleetManager {
6086
7094
  * their fleet is organised. Both stay theirs — this only makes sure they are
6087
7095
  * not discovered by an agent failing at three in the morning.
6088
7096
  */
7097
+ /**
7098
+ * #1366: the first time a fleet runs 2.2 or later, tell each chat platform's General — once — that its agents can
7099
+ * now be talked to from a browser. Claimed in upgrade-notices.json before the send, released if the send fails.
7100
+ * Platforms with nowhere to post fleet notices (no group, or a Discord fleet with no General channel) are skipped
7101
+ * and not recorded, so they are told once they have one.
7102
+ */
7103
+ async announceWebChatOnce(version) {
7104
+ if (!hasWebChat(version))
7105
+ return;
7106
+ const path = upgradeNoticesPath(this.dataDir);
7107
+ for (const [adapterId, world] of this.worlds) {
7108
+ if (this.shuttingDown)
7109
+ return;
7110
+ const target = this.fleetNoticeTarget(adapterId);
7111
+ if (!target)
7112
+ continue;
7113
+ if (!claimNotice(path, WEB_CHAT_NOTICE, adapterId))
7114
+ continue;
7115
+ try {
7116
+ await world.adapter.sendText(target.chatId, t("upgrade.web_chat", WEB_REMOTE_DOCS_URL), target.opts);
7117
+ this.logger.info({ adapterId, notice: WEB_CHAT_NOTICE }, "Announced web chat in General");
7118
+ }
7119
+ catch (err) {
7120
+ const released = releaseNotice(path, WEB_CHAT_NOTICE, adapterId);
7121
+ this.logger.warn({ err, adapterId, released }, "Could not announce web chat in General — will try at the next start");
7122
+ }
7123
+ }
7124
+ }
6089
7125
  announceToolPermissionsChange() {
6090
7126
  try {
6091
7127
  const thirtyDaysAgo = new Date(Date.now() - 30 * 864e5).toISOString().slice(0, 19).replace("T", " ");
@@ -6150,7 +7186,10 @@ export class FleetManager {
6150
7186
  respond(null, permitted.message);
6151
7187
  return;
6152
7188
  }
6153
- if (this.worlds.size === 0) {
7189
+ // A fleet with no chat platform at all is driven from the web dashboard alone: a reply has nowhere
7190
+ // else to go, and "retry shortly" would have the agent retry forever. It goes to the web chat.
7191
+ const webOnlyReply = tool === "reply" && this.worlds.size === 0 && this.isWebOnlyFleet();
7192
+ if (this.worlds.size === 0 && !webOnlyReply) {
6154
7193
  respond(null, "Channel adapters are not ready — retry shortly");
6155
7194
  return;
6156
7195
  }
@@ -6178,7 +7217,8 @@ export class FleetManager {
6178
7217
  }
6179
7218
  const outAdapter = contextWorld?.adapter
6180
7219
  ?? this.getAdapterForInstance(senderInstanceName ?? instanceName)
6181
- ?? this.adapter;
7220
+ ?? this.adapter
7221
+ ?? (webOnlyReply ? WEB_ONLY_REPLY_SINK : null);
6182
7222
  if (!outAdapter) {
6183
7223
  respond(null, "No adapter available");
6184
7224
  return;
@@ -6206,6 +7246,28 @@ export class FleetManager {
6206
7246
  // still in flight and about to succeed. One real send, everyone gets its
6207
7247
  // outcome; a genuinely failed send clears the entry so a retry passes.
6208
7248
  if (tool === "reply") {
7249
+ // Registered synchronously before a web message is handed to this CLI.
7250
+ // This waits only on display ordering, never on the web delivery itself.
7251
+ const echoTail = this.webChannelEchoTails.get(instanceName)?.tail;
7252
+ if (echoTail) {
7253
+ const replyClient = this.instanceIpcClients.get(instanceName);
7254
+ const bindingName = senderInstanceName ?? instanceName;
7255
+ const binding = this.getAdapterForInstance(bindingName);
7256
+ const bindingGroup = this.getGroupIdForInstance(bindingName);
7257
+ const bindingTopic = this.fleetConfig?.instances[bindingName]?.topic_id;
7258
+ await echoTail;
7259
+ // A replacement daemon must not receive this old tool's response or
7260
+ // publish its reply after the new async ordering boundary.
7261
+ if (this.instanceIpcClients.get(instanceName) !== replyClient)
7262
+ return;
7263
+ if (this.getAdapterForInstance(bindingName) !== binding
7264
+ || this.getGroupIdForInstance(bindingName) !== bindingGroup
7265
+ || this.fleetConfig?.instances[bindingName]?.topic_id !== bindingTopic
7266
+ || (contextAdapterId && this.worlds.get(contextAdapterId)?.adapter !== outAdapter)) {
7267
+ respond(null, "Channel binding changed while waiting for the web echo");
7268
+ return;
7269
+ }
7270
+ }
6209
7271
  // Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
6210
7272
  const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
6211
7273
  if (stickerProblem) {
@@ -6238,6 +7300,14 @@ export class FleetManager {
6238
7300
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
6239
7301
  }
6240
7302
  }
7303
+ else if (!error && result != null && outAdapter === WEB_ONLY_REPLY_SINK) {
7304
+ // A daemon status line skips the bookkeeping, but on a web-only fleet the web chat is the only
7305
+ // place anyone could read it.
7306
+ this.emitSseEvent("message", {
7307
+ instance: instanceName, sender: senderSessionName ?? instanceName, role: "status",
7308
+ text: String(args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
7309
+ });
7310
+ }
6241
7311
  };
6242
7312
  if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord)) {
6243
7313
  return;
@@ -6266,6 +7336,15 @@ export class FleetManager {
6266
7336
  }
6267
7337
  }
6268
7338
  /** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
7339
+ /** No chat platform is configured at all: the web dashboard is the fleet's only surface. */
7340
+ isWebOnlyFleet() {
7341
+ const config = this.fleetConfig;
7342
+ return config != null && !config.channel && !(config.channels?.length);
7343
+ }
7344
+ /** On a fleet with no chat platform, the dashboard is where an instance-health prompt is posted (#1307 item 6). */
7345
+ webOnlyPromptPlace() {
7346
+ return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
7347
+ }
6269
7348
  afterReplyRouted(instanceName, args, senderSessionName) {
6270
7349
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
6271
7350
  // more work either. Split the difference: an instance that is clearly
@@ -6283,10 +7362,20 @@ export class FleetManager {
6283
7362
  this.reactDone(instanceName);
6284
7363
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
6285
7364
  this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
7365
+ // Files the agent attached are shown in the web chat too: registered for fetching by id (the path
7366
+ // already passed the reply tool's sendability check, and is resolved once more here), never by path.
7367
+ const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
7368
+ const attachments = replyFiles
7369
+ .map(path => this.webFiles.registerServed({ path, instance: instanceName }))
7370
+ .filter((f) => f !== null)
7371
+ .map(publicAttachment);
7372
+ // The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
7373
+ // role that may get HTML preview cards. Never inferred from the sender name or the text.
6286
7374
  this.emitSseEvent("message", {
6287
- instance: instanceName, sender: senderSessionName ?? instanceName,
6288
- text: (args.text ?? "").slice(0, 2000),
7375
+ instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
7376
+ text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
6289
7377
  ts: new Date().toISOString(),
7378
+ ...(attachments.length ? { attachments } : {}),
6290
7379
  });
6291
7380
  // Log bot reply to classic instance chat-log
6292
7381
  const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
@@ -6295,22 +7384,57 @@ export class FleetManager {
6295
7384
  }
6296
7385
  }
6297
7386
  // ===================== Scheduler =====================
6298
- async handleScheduleTrigger(schedule, stableRunId = randomUUID()) {
6299
- const { target, reply_chat_id, reply_thread_id, message, label, id, source, silent } = schedule;
7387
+ async handleScheduleTrigger(schedule, stableRunId = randomUUID(), retry) {
7388
+ const { target, reply_chat_id, reply_thread_id, label, id, source, silent } = schedule;
7389
+ // #1426: the retry of a deferred occurrence says so in every status it records.
7390
+ const runStatus = (status) => (retry ? `deferred → ${status} (retry)` : status);
6300
7391
  const RATE_LIMIT_DEFER_THRESHOLD = 85;
6301
7392
  const rl = this.statuslineWatcher.getRateLimits(target);
6302
- if (rl && rl.five_hour_pct > RATE_LIMIT_DEFER_THRESHOLD) {
6303
- this.scheduler.recordRun(id, "deferred", `5hr rate limit at ${rl.five_hour_pct}%`);
7393
+ // A reading whose window has reset describes a window that no longer exists: claude-code rewrites statusline.json
7394
+ // only when it renders, so an idle instance keeps its last percentage long after the reset (#1426).
7395
+ const nowMs = Date.now();
7396
+ const windowCurrent = rl != null && (rl.five_hour_resets_at_ms === null || nowMs < rl.five_hour_resets_at_ms);
7397
+ if (rl && windowCurrent && rl.five_hour_pct > RATE_LIMIT_DEFER_THRESHOLD) {
7398
+ const resetsAtMs = rl.five_hour_resets_at_ms;
7399
+ if (retry) {
7400
+ // The window it waited for reset, and a new one is over the threshold again: deferred again — given up.
7401
+ if (retry.resets_at_ms !== null && nowMs >= retry.resets_at_ms) {
7402
+ if (this.scheduler.endRetry(retry))
7403
+ this.scheduleRetryDropped(schedule, retry, "deferred_again");
7404
+ return;
7405
+ }
7406
+ // No reset time to wait for yet (or a new one learnt): look again later, within the deadline.
7407
+ const later = this.scheduler.postponeRetry(retry, resetsAtMs, nowMs);
7408
+ if ("dropped" in later)
7409
+ this.scheduleRetryDropped(schedule, later.retry, later.dropped);
7410
+ else
7411
+ this.logger.info({ target, scheduleId: id, runId: stableRunId, dueAt: new Date(later.due_at_ms).toISOString() }, "Schedule retry still rate limited — looking again later");
7412
+ return;
7413
+ }
7414
+ const pending = this.scheduler.deferForRetry(schedule, stableRunId, { deferredPct: rl.five_hour_pct, resetsAtMs, nowMs });
7415
+ const dueAt = "dropped" in pending ? null : pending.due_at_ms;
7416
+ this.scheduler.recordRun(id, "deferred", `5hr rate limit at ${rl.five_hour_pct}%`
7417
+ + (dueAt !== null ? `; retry at ${new Date(dueAt).toISOString()}` : ""));
6304
7418
  this.eventLog?.insert(target, "schedule_deferred", {
6305
7419
  schedule_id: id,
6306
7420
  label,
6307
7421
  five_hour_pct: rl.five_hour_pct,
7422
+ retry_at: dueAt !== null ? new Date(dueAt).toISOString() : null,
6308
7423
  });
6309
7424
  this.webhookEmitter?.emit("schedule_deferred", target, { schedule_id: id, label, five_hour_pct: rl.five_hour_pct });
6310
- this.notifyInstanceTopic(target, t("schedule.deferred", label ?? id, rl.five_hour_pct));
6311
- this.logger.info({ target, scheduleId: id, rateLimitPct: rl.five_hour_pct }, "Schedule deferred due to rate limit");
7425
+ this.notifyInstanceTopic(target, dueAt !== null
7426
+ ? t("schedule.deferred_retry", label ?? id, rl.five_hour_pct, scheduleClock(dueAt, schedule.timezone))
7427
+ : t("schedule.deferred", label ?? id, rl.five_hour_pct));
7428
+ this.logger.info({ target, scheduleId: id, rateLimitPct: rl.five_hour_pct, retryAt: dueAt }, "Schedule deferred due to rate limit");
7429
+ if ("dropped" in pending)
7430
+ this.scheduleRetryDropped(schedule, pending.retry, pending.dropped);
6312
7431
  return;
6313
7432
  }
7433
+ // #1426: the retry runs now — removed first, so a crash from here loses it rather than running it twice.
7434
+ if (retry && !this.scheduler.endRetry(retry))
7435
+ return;
7436
+ // The agent sees it is a retry, and of what (a silent schedule pastes its raw command unchanged).
7437
+ const message = retry && !silent ? `${scheduleRetryLabel(retry, schedule.timezone)}\n${schedule.message}` : schedule.message;
6314
7438
  // Silent mode: paste directly to tmux pane — no channel message.
6315
7439
  if (silent) {
6316
7440
  // The outbox commit is the schedule's durable acceptance point. The run
@@ -6336,7 +7460,7 @@ export class FleetManager {
6336
7460
  schedule_run_id: stableRunId,
6337
7461
  },
6338
7462
  });
6339
- this.scheduler.recordRun(id, "queued", `durable raw_paste delivery_id=${admitted.delivery.deliveryId}`);
7463
+ this.scheduler.recordRun(id, runStatus("queued"), `durable raw_paste delivery_id=${admitted.delivery.deliveryId}`);
6340
7464
  this.logger.info({ target, scheduleId: id, runId: stableRunId, deliveryId: admitted.delivery.deliveryId, duplicate: !admitted.inserted }, "Silent schedule durably admitted as raw_paste");
6341
7465
  this.scheduleDeliveryOutboxPump();
6342
7466
  return;
@@ -6344,7 +7468,12 @@ export class FleetManager {
6344
7468
  const schedulerDefaults = this.fleetConfig?.defaults.scheduler;
6345
7469
  const retryCount = schedulerDefaults?.retry_count ?? 3;
6346
7470
  const retryInterval = schedulerDefaults?.retry_interval_ms ?? 30_000;
7471
+ // #1426: a retry is only good until its deadline (the next occurrence or the cap) — through the idle wait and up to
7472
+ // the IPC hand-off itself, not just when it starts.
7473
+ const retryCurrent = retry ? () => Date.now() < retry.deadline_ms : undefined;
6347
7474
  const deliver = async () => {
7475
+ if (retryCurrent && !retryCurrent())
7476
+ return "past-deadline";
6348
7477
  try {
6349
7478
  // A schedule has no live inbound adapter context. Seed the daemon with
6350
7479
  // the target instance's configured world so replies use its persona
@@ -6361,7 +7490,7 @@ export class FleetManager {
6361
7490
  if (foreignWorld) {
6362
7491
  this.logger.warn({ scheduleId: id, target, foreignWorld, targetWorld: adapterId, chatId: reply_chat_id }, "Schedule reply target belongs to another channel world — not seeding chat context; the instance keeps its own last known chat");
6363
7492
  }
6364
- await this.deliverToInstance(target, {
7493
+ const handed = await this.deliverToInstance(target, {
6365
7494
  type: "fleet_schedule_trigger",
6366
7495
  payload: { schedule_id: id, message: `[Scheduled] ${message}`, label },
6367
7496
  meta: {
@@ -6372,7 +7501,9 @@ export class FleetManager {
6372
7501
  user: "scheduler",
6373
7502
  ...(adapterId ? { adapter_id: adapterId } : {}),
6374
7503
  },
6375
- }, { waitForIdle: true });
7504
+ }, { waitForIdle: true, ...(retryCurrent ? { stillCurrent: retryCurrent } : {}) });
7505
+ if (handed === false && retryCurrent && !retryCurrent())
7506
+ return "past-deadline";
6376
7507
  // A scheduled trigger also puts the instance to work — show a cancel button.
6377
7508
  void this.sendCancelButton(target);
6378
7509
  return true;
@@ -6382,22 +7513,35 @@ export class FleetManager {
6382
7513
  return false;
6383
7514
  }
6384
7515
  };
6385
- if (await deliver()) {
6386
- this.scheduler.recordRun(id, "delivered");
7516
+ const pastDeadline = () => {
7517
+ this.scheduleRetryDropped(schedule, retry, retry.deadline_kind === "next_occurrence" ? "superseded" : "expired");
7518
+ };
7519
+ const first = await deliver();
7520
+ if (first === "past-deadline") {
7521
+ pastDeadline();
7522
+ return;
7523
+ }
7524
+ if (first) {
7525
+ this.scheduler.recordRun(id, runStatus("delivered"));
6387
7526
  if (source !== target)
6388
7527
  this.notifySourceTopic(schedule);
6389
7528
  return;
6390
7529
  }
6391
7530
  for (let i = 0; i < retryCount; i++) {
6392
7531
  await new Promise((r) => setTimeout(r, retryInterval));
6393
- if (await deliver()) {
6394
- this.scheduler.recordRun(id, "delivered");
7532
+ const again = await deliver();
7533
+ if (again === "past-deadline") {
7534
+ pastDeadline();
7535
+ return;
7536
+ }
7537
+ if (again) {
7538
+ this.scheduler.recordRun(id, runStatus("delivered"));
6395
7539
  if (source !== target)
6396
7540
  this.notifySourceTopic(schedule);
6397
7541
  return;
6398
7542
  }
6399
7543
  }
6400
- this.scheduler.recordRun(id, "instance_offline", `retry ${retryCount}x failed`);
7544
+ this.scheduler.recordRun(id, runStatus("instance_offline"), `retry ${retryCount}x failed`);
6401
7545
  this.notifyScheduleFailure(schedule);
6402
7546
  }
6403
7547
  /**
@@ -6477,6 +7621,38 @@ export class FleetManager {
6477
7621
  threadId: schedule.reply_thread_id ?? undefined,
6478
7622
  }).catch((err) => this.logger.error({ err }, "Failed to send schedule failure notification"));
6479
7623
  }
7624
+ /**
7625
+ * #1426: a deferred occurrence's retry will not run — superseded by the next occurrence, deferred again, or its wait
7626
+ * capped. Recorded as `deferred → skipped (…)`, and said in the schedule's own chat with its admins @mentioned:
7627
+ * this is the case that used to be a silently lost day.
7628
+ */
7629
+ scheduleRetryDropped(schedule, retry, reason) {
7630
+ const why = { superseded: "superseded", deferred_again: "deferred again", expired: "expired" }[reason];
7631
+ this.scheduler?.recordRun(schedule.id, `deferred → skipped (${why})`, `run ${retry.run_id} deferred at ${retry.deferred_pct}%`);
7632
+ this.eventLog?.insert(schedule.target, "schedule_retry_dropped", { schedule_id: schedule.id, label: schedule.label, run_id: retry.run_id, reason });
7633
+ this.logger.warn({ scheduleId: schedule.id, target: schedule.target, runId: retry.run_id, reason }, "Deferred schedule occurrence will not run");
7634
+ const reasonText = reason === "superseded"
7635
+ ? t("schedule.retry_reason_superseded", scheduleClock(retry.deadline_ms, schedule.timezone))
7636
+ : reason === "deferred_again" ? t("schedule.retry_reason_deferred_again")
7637
+ : t("schedule.retry_reason_expired", Math.round(Scheduler.RETRY_MAX_WAIT_MS / 60_000));
7638
+ const adapter = this.scheduleSourceAdapter(schedule);
7639
+ if (!adapter) {
7640
+ this.notifyInstanceTopic(schedule.target, t("schedule.retry_dropped", "", schedule.label ?? schedule.id, scheduleClock(Date.parse(retry.run_id) || retry.deferred_at_ms, schedule.timezone), retry.deferred_pct, reasonText).trimStart());
7641
+ return;
7642
+ }
7643
+ const admins = this.adminListOf(adapter.id) ?? [];
7644
+ const html = adapter.type === "telegram";
7645
+ const mention = admins.map(adminId => adapter.type === "discord" ? `<@${adminId}>`
7646
+ : html ? `<a href="tg://user?id=${encodeURIComponent(adminId)}">admin</a>` : "").filter(Boolean).join(" ");
7647
+ const due = scheduleClock(Date.parse(retry.run_id) || retry.deferred_at_ms, schedule.timezone);
7648
+ const text = html
7649
+ ? t("schedule.retry_dropped", mention, escapeTelegramHtml(schedule.label ?? schedule.id), due, retry.deferred_pct, escapeTelegramHtml(reasonText))
7650
+ : t("schedule.retry_dropped", mention, schedule.label ?? schedule.id, due, retry.deferred_pct, reasonText);
7651
+ adapter.sendText(schedule.reply_chat_id, text.trimStart(), {
7652
+ threadId: schedule.reply_thread_id ?? undefined,
7653
+ ...(html ? { format: "html" } : {}),
7654
+ }).catch((err) => this.logger.error({ err }, "Failed to send the deferred-schedule escalation"));
7655
+ }
6480
7656
  /**
6481
7657
  * The typed IPC messages, all through one door.
6482
7658
  *
@@ -7353,6 +8529,7 @@ export class FleetManager {
7353
8529
  * non-identity instance leaves are canonicalized to inheritance afterward.
7354
8530
  */
7355
8531
  saveFleetConfig(explicitPatches = []) {
8532
+ this.publicWebLink?.refresh();
7356
8533
  if (!this.fleetConfig || !this.configPath)
7357
8534
  return;
7358
8535
  if (!this.savedFleetConfigSnapshot)
@@ -7398,6 +8575,7 @@ export class FleetManager {
7398
8575
  this.rawFleetDocument.deleteIn(parentPath);
7399
8576
  }
7400
8577
  }
8578
+ noteSettingsWrite(this.configPath, this.savedFleetConfigSnapshot, this.fleetConfig);
7401
8579
  const output = String(this.rawFleetDocument);
7402
8580
  if (redundantPaths.length > 0)
7403
8581
  this.writeFleetConfigBackup(source);
@@ -7440,9 +8618,18 @@ export class FleetManager {
7440
8618
  renameSync(tempPath, backupPath);
7441
8619
  }
7442
8620
  patchFleetDocument(document, path, before, after) {
7443
- if (Object.is(before, after))
8621
+ if (Object.is(before, after) || JSON.stringify(before) === JSON.stringify(after))
7444
8622
  return;
7445
8623
  if (Array.isArray(before) && Array.isArray(after)) {
8624
+ // Connection deltas are indexed only while the stable identities still
8625
+ // match. Never apply a stale manager's edit to a reordered world.
8626
+ if (path.length === 1 && path[0] === "channels") {
8627
+ const fresh = document.getIn(path)?.toJSON?.() ?? document.getIn(path);
8628
+ const ids = (items) => items.map(item => item?.id ?? item?.type);
8629
+ if (Array.isArray(fresh) && JSON.stringify(ids(fresh)) !== JSON.stringify(ids(before))) {
8630
+ throw new Error("Connection order changed; reload before saving");
8631
+ }
8632
+ }
7446
8633
  const shared = Math.min(before.length, after.length);
7447
8634
  for (let i = 0; i < shared; i++) {
7448
8635
  this.patchFleetDocument(document, [...path, i], before[i], after[i]);
@@ -7498,42 +8685,66 @@ export class FleetManager {
7498
8685
  }
7499
8686
  }
7500
8687
  }
7501
- async removeInstance(name, authorization) {
8688
+ getSettingsConfigPath() { return this.configPath; }
8689
+ async removeInstance(name, authorization, execution) {
8690
+ if (execution)
8691
+ return this.lifecycle.runTransition(name, transition => this.removeInstanceOwned(name, authorization, execution, transition));
8692
+ return this.removeInstanceOwned(name, authorization);
8693
+ }
8694
+ async removeInstanceOwned(name, authorization, execution, transition) {
8695
+ execution?.assert();
7502
8696
  assertExplicitInstanceRemoval(authorization);
7503
- // Drop cached pane context — the map is keyed by instance name and nothing
7504
- // else evicted deleted entries, so it grew for the life of the process.
7505
- forgetInstanceContext(name);
7506
- // Clean up schedules (scheduler is fleet-level, not lifecycle-level)
7507
- const config = this.fleetConfig?.instances[name];
7508
- if (this.scheduler && config?.topic_id) {
7509
- const count = this.scheduler.deleteByInstanceOrThread(name, String(config.topic_id));
7510
- if (count > 0) {
7511
- this.logger.info({ name, count }, "Cleaned up schedules for deleted instance");
7512
- }
7513
- }
7514
- // Clean up team memberships
7515
- if (this.fleetConfig?.teams) {
7516
- for (const [teamName, team] of Object.entries(this.fleetConfig.teams)) {
7517
- const idx = team.members.indexOf(name);
7518
- if (idx !== -1) {
7519
- team.members.splice(idx, 1);
7520
- this.logger.info({ team: teamName, instance: name }, "Removed deleted instance from team");
8697
+ const beforeRemoval = () => {
8698
+ // Drop cached pane context — the map is keyed by instance name and nothing
8699
+ // else evicted deleted entries, so it grew for the life of the process.
8700
+ forgetInstanceContext(name);
8701
+ // Clean up schedules (scheduler is fleet-level, not lifecycle-level)
8702
+ const config = this.fleetConfig?.instances[name];
8703
+ if (this.scheduler && config?.topic_id) {
8704
+ const count = this.scheduler.deleteByInstanceOrThread(name, String(config.topic_id));
8705
+ if (count > 0) {
8706
+ this.logger.info({ name, count }, "Cleaned up schedules for deleted instance");
7521
8707
  }
7522
- if (team.members.length === 0) {
7523
- delete this.fleetConfig.teams[teamName];
7524
- this.logger.info({ team: teamName }, "Deleted empty team");
8708
+ }
8709
+ // Clean up team memberships
8710
+ if (this.fleetConfig?.teams) {
8711
+ for (const [teamName, team] of Object.entries(this.fleetConfig.teams)) {
8712
+ const idx = team.members.indexOf(name);
8713
+ if (idx !== -1) {
8714
+ team.members.splice(idx, 1);
8715
+ this.logger.info({ team: teamName, instance: name }, "Removed deleted instance from team");
8716
+ }
8717
+ if (team.members.length === 0) {
8718
+ delete this.fleetConfig.teams[teamName];
8719
+ this.logger.info({ team: teamName }, "Deleted empty team");
8720
+ }
7525
8721
  }
7526
8722
  }
7527
- }
8723
+ };
8724
+ if (execution)
8725
+ execution.mutate(beforeRemoval);
8726
+ else
8727
+ beforeRemoval();
7528
8728
  // Capture instance dir BEFORE lifecycle.remove, which deletes the instance
7529
8729
  // from fleetConfig. (The backend would be unresolvable afterwards.)
7530
8730
  const instanceDirToClean = this.getInstanceDir(name);
7531
- await this.lifecycle.remove(name, authorization);
8731
+ if (execution)
8732
+ await this.lifecycle.remove(name, authorization, execution, transition);
8733
+ else
8734
+ await this.lifecycle.remove(name, authorization);
8735
+ // A later same-name registration owns its files and caches.
8736
+ if (this.fleetConfig?.instances[name] || this.daemons.has(name))
8737
+ return;
8738
+ execution?.assert();
7532
8739
  // Clean up per-instance tracking maps so they don't grow unbounded
7533
8740
  // as instances are created and deleted over the lifetime of the fleet.
7534
8741
  this.lastActivity.delete(name);
7535
8742
  this.lastInboundUser.delete(name);
7536
8743
  this.rateLimitWarnedAt.delete(name);
8744
+ // Its web chat goes with it (only after the removal succeeded): a later instance of the same name
8745
+ // must not be shown the old one's conversation, and deleted names must not pile up.
8746
+ this.webChatHistory.forget(name);
8747
+ this.webFiles.forget(name);
7537
8748
  // Clean up statusline watcher + instance directory
7538
8749
  this.statuslineWatcher.unwatch(name);
7539
8750
  // instanceDirToClean and effectiveBackend are captured before lifecycle.remove
@@ -7552,6 +8763,7 @@ export class FleetManager {
7552
8763
  // short home that would otherwise be inherited by a later same-name instance.
7553
8764
  try {
7554
8765
  const { CodexBackend } = await import("./backend/codex.js");
8766
+ execution?.assert();
7555
8767
  const shortHome = CodexBackend.shortHomeFor(instanceDirToClean);
7556
8768
  if (existsSync(shortHome))
7557
8769
  rmSync(shortHome, { recursive: true, force: true });
@@ -7559,6 +8771,7 @@ export class FleetManager {
7559
8771
  catch (err) {
7560
8772
  this.logger.debug({ err, name }, "Codex short home cleanup failed");
7561
8773
  }
8774
+ execution?.complete();
7562
8775
  }
7563
8776
  startStatuslineWatcher(name) {
7564
8777
  if (this.lifecycle.isPaused(name))
@@ -7643,8 +8856,13 @@ export class FleetManager {
7643
8856
  const listEmojis = adapter.listGuildEmojis.bind(adapter);
7644
8857
  let servers;
7645
8858
  try {
8859
+ // This authenticated metadata picker is not a start/admission path. Keep
8860
+ // its existing empty-list inventory so existing channels' emoji settings
8861
+ // do not disappear when new-start access changes (#1418).
8862
+ const guildList = this.classicChannels?.getDefaults().allowed_guilds;
8863
+ const unrestrictedInventory = !!this.classicChannels && (!Array.isArray(guildList) || guildList.length === 0);
7646
8864
  servers = adapter.listMemberGuilds
7647
- ? (await adapter.listMemberGuilds()).filter(g => g.primary || (this.classicChannels?.isGuildAllowed(g.id) ?? false))
8865
+ ? (await adapter.listMemberGuilds()).filter(g => g.primary || unrestrictedInventory || (this.classicChannels?.isGuildAllowed(g.id) ?? false))
7648
8866
  : [{ id: "", name: "", primary: true }];
7649
8867
  }
7650
8868
  catch (e) {
@@ -8202,6 +9420,12 @@ export class FleetManager {
8202
9420
  return reactionForm(resolved.platform, kind === "photo" ? resolved.photo : resolved.attachment);
8203
9421
  }
8204
9422
  reactMessageStatus(instanceName, chatId, messageId, status, threadId, receivedAt) {
9423
+ // A message the web user sent is no message on any platform: there is nothing to react on, and an id
9424
+ // like web-… would only fail there. Its ticks are the dashboard's (web track C3).
9425
+ if (isWebMessageId(messageId)) {
9426
+ this.reportWebDelivery(instanceName, messageId, status);
9427
+ return;
9428
+ }
8205
9429
  // React via the adapter BOUND to this instance — NOT the first discord world.
8206
9430
  // Otherwise, in a same-channel/same-guild multi-bot setup, the inbound 👀
8207
9431
  // (bound bot) and the delivery/confirm reactions (some other bot) come from
@@ -8224,6 +9448,14 @@ export class FleetManager {
8224
9448
  const key = `${adapterId}:${chatId}:${threadId ?? ""}:${messageId}`;
8225
9449
  this.queueDeliveryStatusReaction(adapter, key, chatId, messageId, statusEmoji, threadId, status, status === "received" ? receivedAt : undefined);
8226
9450
  }
9451
+ /** One delivery report for a web user's message: recorded with it, and sent to the pages when it moved. */
9452
+ reportWebDelivery(instanceName, messageId, status) {
9453
+ if (status !== "queued" && status !== "processing" && status !== "delivered" && status !== "failed")
9454
+ return;
9455
+ const m = this.webChatHistory.setDelivery(instanceName, messageId, status);
9456
+ if (m)
9457
+ this.emitSseEvent("delivery", { instance: instanceName, messageId, delivery: m.delivery });
9458
+ }
8227
9459
  /**
8228
9460
  * Apply a terminal delivery verdict. On Telegram the built-in delivered is
8229
9461
  * 👀 and failed is 👎; Discord shows ✅/❌ — both configurable (#1005).
@@ -8736,6 +9968,7 @@ export class FleetManager {
8736
9968
  // install-backend-menu.test.ts and classic-approve-buttons.test.ts.
8737
9969
  const nonce = randomBytes(16).toString("hex");
8738
9970
  const entry = {
9971
+ nonce,
8739
9972
  prefix: opts.prefix,
8740
9973
  instanceName: opts.instanceName,
8741
9974
  adapterId: opts.adapterId,
@@ -8743,6 +9976,7 @@ export class FleetManager {
8743
9976
  chatId: opts.chatId,
8744
9977
  threadId: opts.threadId,
8745
9978
  expiredText: opts.expiredText,
9979
+ createdAt: Date.now(),
8746
9980
  ...opts.extra,
8747
9981
  };
8748
9982
  entry.timer = setTimeout(() => {
@@ -8750,6 +9984,7 @@ export class FleetManager {
8750
9984
  if (pending !== entry)
8751
9985
  return;
8752
9986
  this.pendingNonceButtons.delete(nonce);
9987
+ this.webPromptGone(entry, entry.expiredText);
8753
9988
  if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
8754
9989
  entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to expire button prompt"));
8755
9990
  }
@@ -8757,11 +9992,12 @@ export class FleetManager {
8757
9992
  entry.timer.unref?.();
8758
9993
  this.pendingNonceButtons.set(nonce, entry);
8759
9994
  try {
8760
- const sent = await opts.adapter.notifyAlert(opts.chatId, {
9995
+ const choices = opts.choices.map(c => ({ id: `${opts.prefix}${nonce}:${c.action}`, label: c.label }));
9996
+ const sent = opts.deliver ? await opts.deliver(choices) : await opts.adapter.notifyAlert(opts.chatId, {
8761
9997
  type: opts.alertType,
8762
9998
  instanceName: opts.instanceName,
8763
9999
  message: opts.message,
8764
- choices: opts.choices.map(c => ({ id: `${opts.prefix}${nonce}:${c.action}`, label: c.label })),
10000
+ choices,
8765
10001
  }, opts.threadId ? { threadId: opts.threadId } : undefined);
8766
10002
  // Bind the nonce to the provider's canonical delivery address, not the
8767
10003
  // logical routing input. Telegram, for example, represents General as
@@ -8775,6 +10011,16 @@ export class FleetManager {
8775
10011
  entry.threadId = sent.threadId;
8776
10012
  }
8777
10013
  entry.messageId = sent.messageId;
10014
+ // Offered on the dashboard only once it is live on the platform (a failed post is disarmed above),
10015
+ // and only if nothing claimed or expired it meanwhile.
10016
+ if (WEB_MIRRORED_PROMPT_PREFIXES.has(opts.prefix) && this.pendingNonceButtons.get(nonce) === entry) {
10017
+ entry.web = {
10018
+ text: opts.message,
10019
+ actions: opts.choices.map(c => ({ id: c.action, label: c.label })),
10020
+ expiresAt: Date.now() + (opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS),
10021
+ };
10022
+ this.emitSseEvent("prompt", { instance: entry.instanceName, nonce, ...entry.web });
10023
+ }
8778
10024
  return nonce;
8779
10025
  }
8780
10026
  catch (err) {
@@ -8827,13 +10073,18 @@ export class FleetManager {
8827
10073
  // Bind the capability to the exact message/world that created it. Telegram
8828
10074
  // keyboards are visible to everyone, so mutating actions require fleet admin;
8829
10075
  // a Tip acknowledgement only records that the shared content was read.
8830
- const isAuthorized = data.userId
8831
- ? pending.allowAnyUser
8832
- ? true
8833
- : pending.authChannelId
8834
- ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
8835
- : this.isFleetAdmin(data.userId, callbackAdapterId)
8836
- : false;
10076
+ // A dashboard click carries the full-fleet web session (checked by the web gate before it got here),
10077
+ // and is good only for a prompt that was offered on the dashboard.
10078
+ const fromWeb = this.webPromptClicks.has(data);
10079
+ const isAuthorized = fromWeb
10080
+ ? pending.web !== undefined
10081
+ : data.userId
10082
+ ? pending.allowAnyUser
10083
+ ? true
10084
+ : pending.authChannelId
10085
+ ? this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
10086
+ : this.isFleetAdmin(data.userId, callbackAdapterId)
10087
+ : false;
8837
10088
  const mismatchedFields = [];
8838
10089
  if (pending.adapterId !== callbackAdapterId)
8839
10090
  mismatchedFields.push("adapterId");
@@ -8845,6 +10096,8 @@ export class FleetManager {
8845
10096
  mismatchedFields.push("messageId");
8846
10097
  if (!isAuthorized)
8847
10098
  mismatchedFields.push("authorization");
10099
+ if (pending.requesterUserId && pending.requesterUserId !== data.userId)
10100
+ mismatchedFields.push("requester");
8848
10101
  if (mismatchedFields.length > 0) {
8849
10102
  // Deliberately does NOT consume the nonce: the real admin can still click.
8850
10103
  this.logger.warn({
@@ -8862,6 +10115,10 @@ export class FleetManager {
8862
10115
  this.pendingNonceButtons.delete(match[1]);
8863
10116
  if (pending.timer)
8864
10117
  clearTimeout(pending.timer);
10118
+ if (fromWeb)
10119
+ this.webPromptClaims.add(data);
10120
+ // The dashboard's copy goes now; the outcome line follows from retireNonceButtons.
10121
+ this.webPromptGone(pending);
8865
10122
  return { entry: pending, action: match[2] };
8866
10123
  }
8867
10124
  /** Collapse a consumed prompt's buttons into a final status line. */
@@ -8900,12 +10157,18 @@ export class FleetManager {
8900
10157
  }
8901
10158
  /** Routes a click to the handler that owns its prefix; false when none does. */
8902
10159
  async dispatchAdapterCallback(data, adapterId, adapter) {
10160
+ if (this.needsYou?.handleCallback(data, adapterId))
10161
+ return true;
8903
10162
  if (await this.handleTipDismiss(data, adapterId, adapter))
8904
10163
  return true;
8905
10164
  if (await this.handleTipUnlock(data, adapterId, adapter))
8906
10165
  return true;
8907
10166
  if (await this.handleLoginBackendSelect(data, adapterId, adapter))
8908
10167
  return true;
10168
+ if (await this.handleSettingsChangeCallback(data, adapterId, adapter))
10169
+ return true;
10170
+ if (await this.handleDashboardCallback(data, adapterId, adapter))
10171
+ return true;
8909
10172
  if (await this.handleClassicApproval(data, adapterId, adapter))
8910
10173
  return true;
8911
10174
  if (this.handleRetiredPromptButton(data, adapterId, adapter))
@@ -8922,19 +10185,246 @@ export class FleetManager {
8922
10185
  return true;
8923
10186
  if (await this.handleClassicBackendSelection(data))
8924
10187
  return true;
8925
- if (await this.handleModelSelection(data))
10188
+ if (await this.handleModelSelection(data, adapterId))
8926
10189
  return true;
8927
- if (await this.handleEffortSelection(data))
10190
+ if (await this.handleEffortSelection(data, adapterId))
8928
10191
  return true;
8929
10192
  if (await this.handleHangPrompt(data, adapterId, adapter))
8930
10193
  return true;
8931
10194
  if (data.callbackData.startsWith("cancel:")) {
8932
- this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter ?? null, data);
10195
+ this.handleCancelClick(data.callbackData.slice("cancel:".length), adapter ?? null, data, adapterId);
8933
10196
  return true;
8934
10197
  }
8935
- return false;
10198
+ return false;
10199
+ }
10200
+ /**
10201
+ * A prompt the dashboard was offered is no longer open (answered on either surface, expired, or its
10202
+ * instance stopped): every page drops its buttons, and shows `outcome` when there is one.
10203
+ */
10204
+ webPromptGone(entry, outcome) {
10205
+ if (!entry.web || !entry.nonce)
10206
+ return;
10207
+ this.emitSseEvent("prompt_resolved", { instance: entry.instanceName, nonce: entry.nonce, ...(outcome ? { outcome } : {}) });
10208
+ }
10209
+ // ── #1386 "Needs you" ─────────────────────────────────────────────────────────────────────────────────
10210
+ /** The interaction wait an instance is in now, as a comparable key: which daemon/spawn owns it, and its episode. */
10211
+ interactionWaitKey(name) {
10212
+ const snapshot = this.getInstanceInteraction(name);
10213
+ if (!snapshot)
10214
+ return { owner: null, episode: null };
10215
+ const o = snapshot.owner;
10216
+ return { owner: o ? `${o.bootId}:${o.spawnGeneration}:${o.launchAttempt}:${o.launchFenceEpoch}` : null, episode: snapshot.episode };
10217
+ }
10218
+ /** An instance's interaction observation, pause or wake changed (relayed by the lifecycle): recompute now, not at the tick. */
10219
+ onAttentionChanged(name) {
10220
+ this.needsPauseCache.delete(name);
10221
+ this.needsYou?.poke();
10222
+ }
10223
+ /** The list for the web: every world's items, and those of instances with no world (#1386 §5.0). */
10224
+ needsYouItems() {
10225
+ return this.needsYou?.webItems() ?? [];
10226
+ }
10227
+ /**
10228
+ * #1389: the org chart's structure for the web — the instances the dashboard lists (getUiStatus's names), General,
10229
+ * fleet.yaml's teams, each instance's description and thread link. Read on demand; the live state rides the stream.
10230
+ */
10231
+ orgChart() {
10232
+ const config = this.fleetConfig;
10233
+ const fleetNames = Object.keys(config?.instances ?? {});
10234
+ const classic = new Map((this.classicChannels?.getAll() ?? []).filter(ch => !fleetNames.includes(ch.instanceName)).map(ch => [ch.instanceName, ch]));
10235
+ return buildOrgChart({
10236
+ names: [...fleetNames, ...classic.keys()],
10237
+ instances: Object.fromEntries([
10238
+ ...fleetNames.map(name => [name, config?.instances[name]]),
10239
+ ...[...classic].map(([name, ch]) => [name, { description: ch.description }]),
10240
+ ]),
10241
+ teams: config?.teams,
10242
+ isGeneral: name => !classic.has(name) && isGeneralInstance(config, name),
10243
+ isClassic: name => classic.has(name),
10244
+ place: name => {
10245
+ const world = this.worlds.get(this.getInstanceAdapterId(name) ?? "");
10246
+ return world ? { type: world.type, ...(world.groupId ? { groupId: world.groupId } : {}) } : undefined;
10247
+ },
10248
+ // fleet.yaml's topic only. A ClassicBot room has none here, so no link: its channel may sit in any allowed
10249
+ // guild, not necessarily its world's group.
10250
+ topic: name => {
10251
+ const topic = config?.instances[name]?.topic_id;
10252
+ return topic != null ? String(topic) : undefined;
10253
+ },
10254
+ });
10255
+ }
10256
+ /** The web's Acknowledge (any item; a signed-in session is fleet-admin level). */
10257
+ acknowledgeNeedsItem(id, principal) {
10258
+ if (!this.needsYou)
10259
+ return { status: 409, message: t("needs.ack_already") };
10260
+ return this.needsYou.webAcknowledge(id, principal);
10261
+ }
10262
+ needsYouInstances() {
10263
+ const names = new Set(Object.keys(this.fleetConfig?.instances ?? {}));
10264
+ for (const ch of this.classicChannels?.getAll() ?? [])
10265
+ names.add(ch.instanceName);
10266
+ const out = [];
10267
+ for (const name of names) {
10268
+ // Crashed only by the authoritative reconciliation: a daemon that recovered while its "running" IPC event was
10269
+ // missed is running (getInstanceStatus clears the stale cache — before the presentation below reads it). Every
10270
+ // observation that is not crashed forgets the crash time, so a later crash is a new item (#1398 review).
10271
+ const crashed = this.instanceProcessStatus.get(name) === "crashed" && this.getInstanceStatus(name) === "crashed";
10272
+ if (crashed) {
10273
+ if (!this.needsCrashedAt.has(name))
10274
+ this.needsCrashedAt.set(name, Date.now());
10275
+ }
10276
+ else
10277
+ this.needsCrashedAt.delete(name);
10278
+ const p = this.instancePresentation(name);
10279
+ const wait = p.interaction ? this.interactionWaitKey(name) : null;
10280
+ let pauseReason = null, pausedAt = null;
10281
+ if (this.lifecycle.isPaused(name)) {
10282
+ const now = performance.now();
10283
+ let cached = this.needsPauseCache.get(name);
10284
+ if (!cached || now - cached.readAt >= NEEDS_PAUSE_TTL_MS) {
10285
+ const dir = this.getInstanceDir(name);
10286
+ cached = { reason: readPauseReason(dir), pausedAt: readPausedAt(dir), readAt: now };
10287
+ this.needsPauseCache.set(name, cached);
10288
+ }
10289
+ ({ reason: pauseReason, pausedAt } = cached);
10290
+ }
10291
+ else {
10292
+ this.needsPauseCache.delete(name);
10293
+ }
10294
+ out.push({
10295
+ name, state: p.state ?? undefined,
10296
+ interaction: p.interaction ? { kind: p.interaction.kind, owner: wait?.owner ?? null, episode: p.interaction.episode, since: p.interaction.since } : null,
10297
+ interactionSummary: p.interaction_summary ?? null,
10298
+ pauseReason, pausedAt,
10299
+ crashedAt: crashed ? (this.needsCrashedAt.get(name) ?? null) : null,
10300
+ });
10301
+ }
10302
+ return out;
10303
+ }
10304
+ needsYouPrompts() {
10305
+ const out = [];
10306
+ for (const [nonce, e] of this.pendingNonceButtons) {
10307
+ if (!e.web || !WEB_MIRRORED_PROMPT_PREFIXES.has(e.prefix))
10308
+ continue;
10309
+ out.push({
10310
+ nonce, prefix: e.prefix, instance: e.instanceName, text: e.web.text, actions: e.web.actions,
10311
+ createdAt: e.createdAt ?? Date.now(), adapterId: e.adapterId, chatId: e.chatId,
10312
+ ...(e.threadId !== undefined ? { threadId: e.threadId } : {}), ...(e.messageId ? { messageId: e.messageId } : {}),
10313
+ ...(e.assistFor ? { assistFor: e.assistFor } : {}),
10314
+ });
10315
+ }
10316
+ return out;
10317
+ }
10318
+ /**
10319
+ * Shutdown: detach the hub first — nothing reaches it from here on, and the next startAll (finishStartup) builds a
10320
+ * fresh one; the old one's late ACKs are fenced by its own stop (#1398 review) — then let it retire its live
10321
+ * messages, bounded so a platform that does not answer cannot hold the shutdown.
10322
+ */
10323
+ stopNeedsYou() {
10324
+ const hub = this.needsYou;
10325
+ this.needsYou = null;
10326
+ if (!hub)
10327
+ return undefined;
10328
+ return Promise.race([hub.stop(), new Promise(resolve => setTimeout(resolve, 5_000).unref?.())]);
10329
+ }
10330
+ startNeedsYou() {
10331
+ if (this.needsYou)
10332
+ return;
10333
+ const ownerOf = (instance) => {
10334
+ const owner = this.getInstanceAdapterId(instance);
10335
+ return owner !== undefined && this.worlds.has(owner) ? owner : undefined;
10336
+ };
10337
+ this.needsYou = new NeedsYouHub({
10338
+ dataDir: this.dataDir,
10339
+ now: () => Date.now(),
10340
+ mono: () => performance.now(),
10341
+ setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
10342
+ clearTimer: h => clearTimeout(h),
10343
+ prompts: () => this.needsYouPrompts(),
10344
+ instances: () => this.needsYouInstances(),
10345
+ deliveries: since => (this.deliveryOutbox?.isOpen ? this.deliveryOutbox.needsAttention(since) : []).map(d => ({
10346
+ deliveryId: d.deliveryId, state: d.state, source: d.sourceInstance, target: d.targetInstance, kind: d.kind,
10347
+ finishedAt: Date.parse(d.finishedAt) || 0,
10348
+ })),
10349
+ ownerOf,
10350
+ worlds: () => [...this.worlds.values()].map((w) => ({
10351
+ id: w.id, adapter: w.adapter, place: { type: w.type, ...(w.groupId ? { groupId: w.groupId } : {}) },
10352
+ })),
10353
+ noticeTarget: world => {
10354
+ const target = this.fleetNoticeTarget(world);
10355
+ return target ? { chatId: target.chatId, ...(target.opts.threadId !== undefined ? { threadId: String(target.opts.threadId) } : {}) } : null;
10356
+ },
10357
+ instanceTopic: instance => {
10358
+ const topic = this.fleetConfig?.instances[instance]?.topic_id;
10359
+ if (topic != null)
10360
+ return String(topic);
10361
+ return this.classicChannels?.getChannelIdByInstance(instance) ?? undefined;
10362
+ },
10363
+ isFleetAdmin: (userId, world) => this.isFleetAdmin(userId, world),
10364
+ fleetAdmins: world => (this.getChannelConfig(world)?.access?.allowed_users ?? []).map(String),
10365
+ emitSse: (event, data) => this.emitSseEvent(event, data),
10366
+ acknowledge: (deliveryId, by) => {
10367
+ if (!this.deliveryOutbox?.isOpen)
10368
+ throw new Error("the delivery outbox is not open");
10369
+ return this.deliveryOutbox.acknowledge(deliveryId, by);
10370
+ },
10371
+ settings: () => ({
10372
+ liveMessage: this.fleetConfig?.needs_you?.live_message !== false,
10373
+ dm: this.fleetConfig?.needs_you?.dm === true,
10374
+ }),
10375
+ stopping: () => this.shuttingDown,
10376
+ t: (key, ...args) => t(key, ...args),
10377
+ log: (level, message, extra) => this.logger[level](extra ?? {}, message),
10378
+ });
10379
+ this.needsYou.start();
10380
+ }
10381
+ /** The prompts open on the dashboard right now (a page that loads after one was posted asks for them). */
10382
+ listWebPrompts() {
10383
+ const open = [];
10384
+ for (const [nonce, e] of this.pendingNonceButtons) {
10385
+ if (e.web)
10386
+ open.push({ instance: e.instanceName, nonce, ...e.web });
10387
+ }
10388
+ return open;
10389
+ }
10390
+ /**
10391
+ * A click on a prompt in the web dashboard (web track C4). It is the platform click, made by the
10392
+ * dashboard: the same handler, the same single claim (whoever answers first — here or on Telegram —
10393
+ * wins), and the platform's buttons collapse to the outcome exactly as for a click there.
10394
+ *
10395
+ * The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
10396
+ * on the dashboard, about the instance the page named, and the action one of its own buttons.
10397
+ */
10398
+ async clickWebPrompt(instance, nonce, action) {
10399
+ if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
10400
+ return { status: 400, error: "Malformed prompt answer" };
10401
+ const entry = this.pendingNonceButtons.get(nonce);
10402
+ // Unknown is the same answer as answered or expired: the page drops the buttons either way.
10403
+ if (!entry || !entry.web)
10404
+ return { status: 409, error: "This prompt is no longer open" };
10405
+ if (entry.instanceName !== instance)
10406
+ return { status: 403, error: "This prompt belongs to another instance" };
10407
+ if (!entry.web.actions.some(a => a.id === action))
10408
+ return { status: 400, error: "Not one of this prompt's answers" };
10409
+ let notice;
10410
+ const data = {
10411
+ callbackData: `${entry.prefix}${nonce}:${action}`,
10412
+ // The exact place the prompt lives, so the platform-side binding checks hold as for a click there.
10413
+ chatId: entry.chatId,
10414
+ threadId: entry.threadId,
10415
+ messageId: entry.messageId ?? "",
10416
+ userId: "web-user",
10417
+ ack: n => { if (notice === undefined && n)
10418
+ notice = n; },
10419
+ };
10420
+ this.webPromptClicks.add(data);
10421
+ await this.dispatchAdapterCallback(data, entry.adapterId, entry.adapter);
10422
+ if (this.webPromptClaims.has(data))
10423
+ return { status: 200 };
10424
+ return { status: 409, error: notice ?? "This prompt is no longer open" };
8936
10425
  }
8937
10426
  async retireNonceButtons(pending, messageId, text) {
10427
+ this.webPromptGone(pending, text);
8938
10428
  try {
8939
10429
  if (!pending.adapter.editMessageRemoveButtons)
8940
10430
  throw new Error("adapter cannot remove prompt buttons");
@@ -9045,7 +10535,8 @@ export class FleetManager {
9045
10535
  message: t("tips.advanced.unlock_prompt"),
9046
10536
  choices: [{ action: "unlock", label: t("tips.advanced.unlock") }],
9047
10537
  expiredText: t("tips.advanced.expired"),
9048
- extra: { allowAnyUser: true },
10538
+ // No allowAnyUser: unlocking changes a persistent setting, which typed and slash `/tips advanced on` reserve for a
10539
+ // fleet admin (#754 audit) — the default nonce check (fleet admin of the clicking adapter) applies.
9049
10540
  timeoutMs: TIP_BUTTON_TIMEOUT_MS,
9050
10541
  });
9051
10542
  return nonce !== null;
@@ -9202,9 +10693,69 @@ export class FleetManager {
9202
10693
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.cancelled", pending.instanceName));
9203
10694
  return true;
9204
10695
  }
9205
- await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("clear.clearing", pending.instanceName));
10696
+ // The nonce was claimed synchronously. Keep the exact owner across platform
10697
+ // retirement awaits; a replacement with the same name is not this clear's
10698
+ // target, and a role granted at claim time may be revoked while editing.
10699
+ const ipc = this.instanceIpcClients.get(pending.instanceName);
10700
+ const daemon = this.daemons.get(pending.instanceName);
10701
+ const epoch = this.getDeliveryEpoch(pending.instanceName);
10702
+ const groupId = this.getChannelConfig(callbackAdapterId)?.group_id;
10703
+ // Object identity survives a resident daemon's respawn/freeze, and stop
10704
+ // invalidates its lifecycle epoch before the queued work replaces objects.
10705
+ // These reads are cached and synchronous; they never probe the pane.
10706
+ const readOwner = () => {
10707
+ try {
10708
+ const owner = daemon?.getInteractionSnapshot?.()?.owner;
10709
+ if (!owner || typeof owner.bootId !== "string" || !owner.bootId
10710
+ || ![owner.spawnGeneration, owner.launchAttempt, owner.launchFenceEpoch]
10711
+ .every(n => Number.isSafeInteger(n) && n >= 0))
10712
+ return null;
10713
+ return { ...owner };
10714
+ }
10715
+ catch {
10716
+ return null;
10717
+ }
10718
+ };
10719
+ const readLifecycleEpoch = () => {
10720
+ try {
10721
+ const value = this.lifecycle?.epochOf(pending.instanceName);
10722
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
10723
+ }
10724
+ catch {
10725
+ return null;
10726
+ }
10727
+ };
10728
+ const owner = readOwner(), lifecycleEpoch = readLifecycleEpoch();
10729
+ const current = () => {
10730
+ const currentOwner = readOwner();
10731
+ try {
10732
+ return !this.shuttingDown
10733
+ && !!data.userId && !!pending.authChannelId
10734
+ && !this.webPromptClicks.has(data) // clear is deliberately not web-mirrored
10735
+ && this.worlds.get(callbackAdapterId)?.adapter === pending.adapter
10736
+ && this.commandChannelStillTargets(pending.instanceName, pending.authChannelId, callbackAdapterId, pending.chatId)
10737
+ && this.isModelAdmin(data.userId, pending.authChannelId, callbackAdapterId)
10738
+ && (this.classicChannels?.getInstanceByChannel(pending.authChannelId, callbackAdapterId) === pending.instanceName
10739
+ ? pending.adapter.type !== "telegram" || pending.chatId === pending.authChannelId
10740
+ : String(this.getChannelConfig(callbackAdapterId)?.group_id ?? "") === pending.chatId)
10741
+ && this.getChannelConfig(callbackAdapterId)?.group_id === groupId
10742
+ && this.daemons.get(pending.instanceName) === daemon
10743
+ && this.instanceIpcClients.get(pending.instanceName) === ipc
10744
+ && owner !== null && currentOwner !== null && sameInteractionOwner(owner, currentOwner)
10745
+ && lifecycleEpoch !== null && readLifecycleEpoch() === lifecycleEpoch
10746
+ && this.isDeliveryEpochCurrent(pending.instanceName, epoch);
10747
+ }
10748
+ catch {
10749
+ return false;
10750
+ } // unavailable authority cannot admit a clear
10751
+ };
10752
+ const admitted = current();
10753
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, admitted ? t("clear.clearing", pending.instanceName) : t("menu.click_stale"));
9206
10754
  try {
9207
- const result = await this.topicCommands.sendClear(pending.instanceName);
10755
+ // sendClear sends its first IPC synchronously; no await separates this
10756
+ // final check from that effect. Uncertainty/throw never reaches IPC.
10757
+ const result = admitted && current()
10758
+ ? await this.topicCommands.sendClear(pending.instanceName) : t("menu.click_stale");
9208
10759
  await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, result, pending.threadId);
9209
10760
  }
9210
10761
  catch (err) {
@@ -9237,9 +10788,11 @@ export class FleetManager {
9237
10788
  async retirePendingNoncePrompts(budgetMs = NONCE_RETIRE_BUDGET_MS) {
9238
10789
  const entries = [...this.pendingNonceButtons.values()];
9239
10790
  this.pendingNonceButtons.clear();
9240
- for (const entry of entries)
10791
+ for (const entry of entries) {
9241
10792
  if (entry.timer)
9242
10793
  clearTimeout(entry.timer);
10794
+ this.webPromptGone(entry, entry.expiredText);
10795
+ }
9243
10796
  const collapses = entries
9244
10797
  .filter(entry => entry.messageId && entry.adapter.editMessageRemoveButtons)
9245
10798
  .map(entry => entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId).catch(err => this.logger.debug({ err, instanceName: entry.instanceName, prefix: entry.prefix }, "Failed to retire button prompt during shutdown")));
@@ -9260,6 +10813,7 @@ export class FleetManager {
9260
10813
  this.pendingNonceButtons.delete(nonce);
9261
10814
  if (entry.timer)
9262
10815
  clearTimeout(entry.timer);
10816
+ this.webPromptGone(entry, entry.expiredText);
9263
10817
  if (entry.messageId && entry.adapter.editMessageRemoveButtons) {
9264
10818
  entry.adapter.editMessageRemoveButtons(entry.chatId, entry.messageId, entry.expiredText, entry.threadId)
9265
10819
  .catch(err => this.logger.debug({ err, instanceName, prefix: entry.prefix }, "Failed to collapse prompt during instance stop"));
@@ -9271,6 +10825,16 @@ export class FleetManager {
9271
10825
  * same-world General topic where an administrator can make the choice. */
9272
10826
  async notifyNormalExit(instanceName) {
9273
10827
  this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName));
10828
+ const web = this.webOnlyPromptPlace();
10829
+ if (web) {
10830
+ await this.postNonceButtonPrompt({
10831
+ prefix: EXIT_RESTART_CALLBACK_PREFIX, alertType: "exit_restart", instanceName, ...web,
10832
+ message: t("exit.general_notice", instanceName),
10833
+ choices: [{ action: "restart", label: t("exit.restart") }, { action: "ignore", label: t("exit.ignore") }],
10834
+ expiredText: t("exit.expired", instanceName),
10835
+ });
10836
+ return;
10837
+ }
9274
10838
  const worldId = this.getInstanceAdapterId(instanceName);
9275
10839
  const generalName = this.findGeneralInstance(worldId);
9276
10840
  if (!generalName) {
@@ -9349,6 +10913,20 @@ export class FleetManager {
9349
10913
  this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
9350
10914
  return;
9351
10915
  }
10916
+ const label = this.interactivePromptLabel(kind);
10917
+ const prompt = {
10918
+ prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX, alertType: "interactive_prompt", instanceName,
10919
+ message: t("interactive.general_notice", instanceName, label),
10920
+ choices: [{ action: "confirm", label: t("interactive.confirm") }, { action: "cancel", label: t("interactive.cancel") }],
10921
+ expiredText: t("interactive.expired", instanceName),
10922
+ extra: { generalName, promptKind: kind, assistFor: this.interactionWaitKey(instanceName) },
10923
+ };
10924
+ // No chat platform: the dashboard is where it is asked; Confirm still asks General to help.
10925
+ const web = this.webOnlyPromptPlace();
10926
+ if (web) {
10927
+ await this.postNonceButtonPrompt({ ...prompt, ...web });
10928
+ return;
10929
+ }
9352
10930
  const adapterId = this.getInstanceAdapterId(generalName);
9353
10931
  const adapter = this.getAdapterForInstance(generalName);
9354
10932
  const chatId = this.getGroupIdForInstance(generalName);
@@ -9358,23 +10936,7 @@ export class FleetManager {
9358
10936
  this.logger.warn({ instanceName, generalName, adapterId, chatId }, "Cannot address interactive prompt assistance controls");
9359
10937
  return;
9360
10938
  }
9361
- const label = this.interactivePromptLabel(kind);
9362
- await this.postNonceButtonPrompt({
9363
- prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX,
9364
- alertType: "interactive_prompt",
9365
- instanceName,
9366
- adapter,
9367
- adapterId,
9368
- chatId,
9369
- threadId,
9370
- message: t("interactive.general_notice", instanceName, label),
9371
- choices: [
9372
- { action: "confirm", label: t("interactive.confirm") },
9373
- { action: "cancel", label: t("interactive.cancel") },
9374
- ],
9375
- expiredText: t("interactive.expired", instanceName),
9376
- extra: { generalName, promptKind: kind },
9377
- });
10939
+ await this.postNonceButtonPrompt({ ...prompt, adapter, adapterId, chatId, threadId });
9378
10940
  }
9379
10941
  /**
9380
10942
  * Offer a one-tap re-login next to an auth alert.
@@ -9542,8 +11104,33 @@ export class FleetManager {
9542
11104
  * concern was real — a second click must not fire a second interrupt key at an
9543
11105
  * instance that has already started a new turn.
9544
11106
  */
9545
- handleCancelClick(instanceName, adapter, data) {
9546
- if (this.hasCancelButton(instanceName)) {
11107
+ handleCancelClick(instanceName, adapter, data, adapterId) {
11108
+ // #754 audit: the click names its instance in its own callback data, and Telegram delivers clicks from anyone in
11109
+ // the chat. So it may interrupt only when it comes through the instance's owning adapter (the one that posts its
11110
+ // buttons) from someone that adapter lets speak — and, on a live button, only that instance's own message.
11111
+ const owner = this.getInstanceAdapterId(instanceName);
11112
+ const live = this.cancelButtons.get(data.messageId);
11113
+ // Where the click came from: a live button's own message (and topic), or — when the fleet has no entry for the
11114
+ // message (a button being replaced, or forgotten across a restart) — the instance's destination as it is NOW, so a
11115
+ // button left behind in a channel the instance has since moved from, or a click from an unrelated chat, does not
11116
+ // cancel it (#1396 review).
11117
+ const dest = this.cancelButtonDestination(instanceName);
11118
+ const atDestination = !!owner && (live
11119
+ ? live.instanceName === instanceName && live.chatId === data.chatId
11120
+ && (live.threadId == null || this.clickAtDestination({ chatId: live.chatId, threadId: live.threadId }, data, owner))
11121
+ : !!dest && this.clickAtDestination(dest, data, owner));
11122
+ // Who may press it: in a ClassicBot chat, anyone there — exactly who a typed /cancel there answers, since ClassicBot
11123
+ // traffic is not admitted by the fleet's access policy (#1396 review); elsewhere someone the owning adapter lets speak.
11124
+ const classic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
11125
+ const access = owner ? this.worlds.get(owner)?.accessManager ?? (owner === this.getPrimaryAdapterId() ? this.accessManager : null) : null;
11126
+ const speaker = !!data.userId && !!owner
11127
+ && (classic || this.isFleetAdmin(data.userId, owner) || !!access?.isAllowed(data.userId));
11128
+ if (!owner || adapterId !== owner || !speaker || !atDestination) {
11129
+ this.logger.warn({ instanceName, adapterId, owner, userId: data.userId, live: !!live, atDestination }, "Refused cancel click: not this instance's button where it is now, or not someone its adapter lets speak");
11130
+ data.ack?.(t("buttons.not_allowed"));
11131
+ return;
11132
+ }
11133
+ if (live) {
9547
11134
  this.cancelInstance(instanceName);
9548
11135
  return;
9549
11136
  }
@@ -9561,6 +11148,28 @@ export class FleetManager {
9561
11148
  adapter?.editMessage(data.chatId, data.messageId, t("cancel.button_stale", instanceName), data.threadId)
9562
11149
  .catch(() => { });
9563
11150
  }
11151
+ /** Where an instance's cancel button is posted — and so the only place a click on it can come from. */
11152
+ cancelButtonDestination(instanceName) {
11153
+ const groupId = this.getGroupIdForInstance(instanceName) || undefined;
11154
+ const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
11155
+ // Fleet topic instance.
11156
+ if (topicId != null && groupId)
11157
+ return { chatId: String(groupId), threadId: String(topicId) };
11158
+ // Classic instance: channelId from the classic manager; General / flat fallback: the group (no thread).
11159
+ const chatId = this.classicChannels?.getChannelIdByInstance(instanceName) ?? (groupId ? String(groupId) : undefined);
11160
+ return chatId ? { chatId } : null;
11161
+ }
11162
+ /**
11163
+ * A click came from this destination. A Discord click names the guild as its chat and the channel as its thread; a
11164
+ * Telegram click names the chat and topic, with the General topic as thread 1 or none at all.
11165
+ */
11166
+ clickAtDestination(dest, data, ownerAdapterId) {
11167
+ const telegram = this.getChannelConfig(ownerAdapterId)?.type === "telegram";
11168
+ const thread = (id) => (telegram && (id === undefined || id === "1") ? undefined : id);
11169
+ if (thread(dest.threadId) !== undefined)
11170
+ return data.chatId === dest.chatId && thread(data.threadId) === thread(dest.threadId);
11171
+ return (thread(data.threadId) ?? data.chatId) === dest.chatId;
11172
+ }
9564
11173
  hasCancelButton(instanceName) {
9565
11174
  for (const e of this.cancelButtons.values()) {
9566
11175
  if (e.instanceName === instanceName)
@@ -9610,20 +11219,7 @@ export class FleetManager {
9610
11219
  const adapterId = this.getInstanceAdapterId(instanceName);
9611
11220
  const groupId = this.getGroupIdForInstance(instanceName) || undefined;
9612
11221
  const topicId = this.fleetConfig?.instances[instanceName]?.topic_id;
9613
- let chatId;
9614
- let threadId;
9615
- if (topicId != null && groupId) {
9616
- // Fleet topic instance.
9617
- chatId = String(groupId);
9618
- threadId = String(topicId);
9619
- }
9620
- else {
9621
- // Classic instance: channelId from the classic manager.
9622
- chatId = this.classicChannels?.getChannelIdByInstance(instanceName);
9623
- // General / flat fallback: post to the group (no thread).
9624
- if (!chatId && groupId)
9625
- chatId = String(groupId);
9626
- }
11222
+ const { chatId, threadId } = this.cancelButtonDestination(instanceName) ?? {};
9627
11223
  if (!chatId) {
9628
11224
  // A button that cannot be addressed must say so — this exact silence is how
9629
11225
  // "the cancel button sometimes never appears" stayed unreported-in-logs.
@@ -10131,6 +11727,10 @@ export class FleetManager {
10131
11727
  daemon.sendEscape().catch(e => this.logger.warn({ err: e, instanceName }, "sendEscape failed"));
10132
11728
  this.lastInboundMsg.delete(instanceName);
10133
11729
  this.clearCancelButton(instanceName);
11730
+ // The queued web messages were just dropped with the rest: their ticks say so, on every page.
11731
+ for (const m of this.webChatHistory.cancelPending(instanceName)) {
11732
+ this.emitSseEvent("delivery", { instance: instanceName, messageId: m.messageId, delivery: m.delivery });
11733
+ }
10134
11734
  return true;
10135
11735
  }
10136
11736
  getDeliveryEpoch(instanceName) {
@@ -10139,6 +11739,10 @@ export class FleetManager {
10139
11739
  isDeliveryEpochCurrent(instanceName, deliveryEpoch) {
10140
11740
  return deliveryEpoch === this.getDeliveryEpoch(instanceName);
10141
11741
  }
11742
+ /** The delivery epoch, and the caller's own fence when it gave one (DeliveryOptions.stillCurrent, #1426). */
11743
+ deliveryCurrent(instanceName, deliveryEpoch, stillCurrent) {
11744
+ return this.isDeliveryEpochCurrent(instanceName, deliveryEpoch) && (stillCurrent?.() ?? true);
11745
+ }
10142
11746
  /** Invalidate work queued before a user cancel and wake idle-gate waiters. */
10143
11747
  cancelPendingDeliveries(instanceName) {
10144
11748
  const next = this.getDeliveryEpoch(instanceName) + 1;
@@ -10174,6 +11778,7 @@ export class FleetManager {
10174
11778
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
10175
11779
  windowBusyMessage: () => this.loginWindow.busyMessage(),
10176
11780
  tunnelDataDir: () => this.dataDir,
11781
+ reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
10177
11782
  // Throws when the prompt cannot be posted: the controller tells the
10178
11783
  // user so instead of leaving "Starting…" as the last word (#1133).
10179
11784
  postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
@@ -10299,7 +11904,7 @@ export class FleetManager {
10299
11904
  this.notifyInstanceTopic(opts.generalName, opts.message);
10300
11905
  return;
10301
11906
  }
10302
- const choices = [{ action: "allow", label: t("classic.approve_group") }];
11907
+ const choices = [{ action: "allow", label: t(opts.scope === "user" ? "classic.approve_user" : "classic.approve_group") }];
10303
11908
  if (opts.userId)
10304
11909
  choices.push({ action: "allow-admin", label: t("classic.approve_group_admin") });
10305
11910
  choices.push({ action: "ignore", label: t("classic.approve_ignore") });
@@ -10314,7 +11919,7 @@ export class FleetManager {
10314
11919
  message: opts.message,
10315
11920
  choices,
10316
11921
  expiredText: t("buttons.stale"),
10317
- extra: { classicGroupId: opts.groupId, classicUserId: opts.userId, classicScope: opts.scope },
11922
+ extra: { classicGroupId: opts.groupId, classicUserId: opts.userId, classicScope: opts.scope, classicReplyTo: opts.replyTo },
10318
11923
  });
10319
11924
  }
10320
11925
  /**
@@ -10334,15 +11939,27 @@ export class FleetManager {
10334
11939
  return;
10335
11940
  }
10336
11941
  const scope = entry.classicScope ?? "guild";
10337
- const access = scope === "group" ? classic.allowGroup(groupId) : classic.allowGuild(groupId);
11942
+ const access = scope === "user" ? classic.allowUser(groupId)
11943
+ : scope === "group" ? classic.allowGroup(groupId) : classic.allowGuild(groupId);
10338
11944
  const admin = adminUserId ? classic.addAdminUser(adminUserId) : null;
10339
- const lines = [t(access === "added" ? "classic.approve_done_group"
10340
- : access === "already" ? "classic.approve_done_group_already"
10341
- : "classic.approve_done_group_open", groupId)];
11945
+ const lines = [t(access === "added" ? "classic.approve_done_group" : "classic.approve_done_group_already", groupId)];
10342
11946
  if (admin) {
10343
11947
  lines.push(t(admin === "added" ? "classic.approve_done_admin" : "classic.approve_done_admin_already", adminUserId ?? ""));
10344
11948
  }
10345
11949
  this.notifyInstanceTopic(entry.instanceName, lines.join("\n"));
11950
+ await this.notifyClassicRequester(entry, "classic.request_allowed");
11951
+ }
11952
+ async notifyClassicRequester(entry, key) {
11953
+ const reply = entry.classicReplyTo;
11954
+ // Do not use a replaced bot or guess a new recipient after adapter rebuild.
11955
+ if (!reply || this.worlds.get(reply.adapterId)?.adapter !== reply.adapter)
11956
+ return;
11957
+ try {
11958
+ await reply.adapter.sendText(reply.chatId, t(key));
11959
+ }
11960
+ catch (err) {
11961
+ this.logger.warn({ err, adapterId: reply.adapterId, scope: entry.classicScope }, "Could not notify Classic access requester");
11962
+ }
10346
11963
  }
10347
11964
  /** Consume a ClassicBot approval button. */
10348
11965
  async handleClassicApproval(data, callbackAdapterId, receivingAdapter) {
@@ -10356,6 +11973,7 @@ export class FleetManager {
10356
11973
  const userId = entry.classicUserId;
10357
11974
  if (action === "ignore") {
10358
11975
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("classic.approve_ignored", groupId));
11976
+ await this.notifyClassicRequester(entry, "classic.request_ignored");
10359
11977
  return true;
10360
11978
  }
10361
11979
  const grantAdmin = action === "allow-admin" && !!userId;
@@ -10804,7 +12422,23 @@ export class FleetManager {
10804
12422
  }
10805
12423
  /** Push an SSE event to all connected Web UI clients. */
10806
12424
  emitSseEvent(event, data) {
10807
- broadcastSseEvent(this.sseClients, event, data, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
12425
+ // #1386: a prompt opened or closed, or an instance's state moved — "Needs you" may have changed.
12426
+ if (event === "prompt" || event === "prompt_resolved" || event === "activity")
12427
+ this.needsYou?.poke();
12428
+ const onError = (err) => this.logger.debug({ err }, "SSE client write failed; evicting");
12429
+ if (event === "message" && data && typeof data === "object") {
12430
+ // A chat message: record it, and send it WITH its id so a reconnecting stream can ask for what it missed.
12431
+ const m = data;
12432
+ const recorded = this.webChatHistory.record({
12433
+ instance: String(m.instance ?? ""), sender: String(m.sender ?? ""), text: String(m.text ?? ""), ts: String(m.ts ?? new Date().toISOString()),
12434
+ attachments: Array.isArray(m.attachments) ? m.attachments : undefined,
12435
+ messageId: typeof m.messageId === "string" ? m.messageId : undefined,
12436
+ role: typeof m.role === "string" ? m.role : undefined,
12437
+ });
12438
+ broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
12439
+ return;
12440
+ }
12441
+ broadcastSseEvent(this.sseClients, event, data, onError);
10808
12442
  }
10809
12443
  listClaimedTasks(assignee) {
10810
12444
  try {
@@ -10815,8 +12449,9 @@ export class FleetManager {
10815
12449
  }
10816
12450
  }
10817
12451
  async sendHangNotification(instanceName, unchangedForMs) {
10818
- const adapter = this.getAdapterForInstance(instanceName) ?? this.adapter;
10819
- const adapterId = this.getInstanceAdapterId(instanceName);
12452
+ const web = this.webOnlyPromptPlace(); // no chat platform: asked on the dashboard (#1307 item 6)
12453
+ const adapter = web?.adapter ?? this.getAdapterForInstance(instanceName) ?? this.adapter;
12454
+ const adapterId = web?.adapterId ?? this.getInstanceAdapterId(instanceName);
10820
12455
  // Same three-way addressing as sendCancelButton: fleet topic → group+thread,
10821
12456
  // Classic → its own channel (Classic instances are absent from
10822
12457
  // fleetConfig.instances, so the topic path can never address them), else the
@@ -10826,7 +12461,10 @@ export class FleetManager {
10826
12461
  const groupId = this.getGroupIdForInstance(instanceName) || undefined;
10827
12462
  let chatId;
10828
12463
  let threadId;
10829
- if (topicId != null && groupId) {
12464
+ if (web) {
12465
+ chatId = web.chatId;
12466
+ }
12467
+ else if (topicId != null && groupId) {
10830
12468
  chatId = String(groupId);
10831
12469
  threadId = String(topicId);
10832
12470
  }
@@ -11774,7 +13412,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
11774
13412
  return done(cachedModel.trim(), "cli-default");
11775
13413
  // Say WHY it's unresolved: no fresh probe yet vs. the CLI not exposing a default
11776
13414
  // (e.g. claude-code's default is account-side, opencode's is provider-side).
11777
- return done("default", "unresolved", cliEnv ? "this CLI does not report a default" : "not probed yet");
13415
+ return done("default", "unresolved", cliEnv ? "this CLI does not report a default" : "detected when it starts");
11778
13416
  }
11779
13417
  /** Human-readable effective model, e.g. `auto (default)`. Used by /ctx. */
11780
13418
  modelDisplayForInstance(instanceName) {
@@ -12061,7 +13699,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12061
13699
  }));
12062
13700
  const timer = setTimeout(() => this.pendingEffortSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
12063
13701
  timer.unref?.();
12064
- this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, timer, respond: data.respond });
13702
+ this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
12065
13703
  try {
12066
13704
  await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
12067
13705
  }
@@ -12073,7 +13711,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12073
13711
  }
12074
13712
  }
12075
13713
  /** TG inline-keyboard effort menu. Returns null on success, else a fallback string. */
12076
- async promptEffortMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
13714
+ async promptEffortMenu(instanceName, userId, channelId, adapter, chatId, threadId, adapterId) {
12077
13715
  const levels = this.effortLevelsFor(instanceName);
12078
13716
  if (levels.length === 0) {
12079
13717
  return t("effort.unsupported", this.backendNameForInstance(instanceName));
@@ -12096,7 +13734,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12096
13734
  }
12097
13735
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
12098
13736
  timer.unref?.();
12099
- this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
13737
+ this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
12100
13738
  try {
12101
13739
  const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
12102
13740
  const pending = this.pendingEffortSelects.get(nonce);
@@ -12112,7 +13750,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12112
13750
  }
12113
13751
  }
12114
13752
  /** Consume an `/effort` selection callback. Mirrors handleModelSelection. */
12115
- async handleEffortSelection(data) {
13753
+ async handleEffortSelection(data, adapterId) {
12116
13754
  if (!data.callbackData.startsWith(EFFORT_SELECT_CALLBACK_PREFIX))
12117
13755
  return false;
12118
13756
  const match = data.callbackData.match(/^effort-select:([0-9a-f]+):(.+)$/);
@@ -12121,11 +13759,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12121
13759
  const pending = this.pendingEffortSelects.get(match[1]);
12122
13760
  if (!pending)
12123
13761
  return true;
12124
- if (data.userId && data.userId !== pending.userId)
12125
- return true;
13762
+ // The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
13763
+ // when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
12126
13764
  const cbChannel = data.threadId ?? data.chatId;
12127
- if (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
13765
+ if (!data.userId || data.userId !== pending.userId
13766
+ || (pending.adapterId !== undefined && pending.adapterId !== adapterId)
13767
+ || (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
13768
+ || !this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)) {
13769
+ data.ack?.(t("buttons.admin_only"));
12128
13770
  return true;
13771
+ }
12129
13772
  this.pendingEffortSelects.delete(match[1]);
12130
13773
  clearTimeout(pending.timer);
12131
13774
  const level = match[2];
@@ -12157,7 +13800,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12157
13800
  void (async () => {
12158
13801
  let result;
12159
13802
  try {
12160
- result = await this.applyEffort(pending.instanceName, level);
13803
+ // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
13804
+ result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
13805
+ ? await this.applyEffort(pending.instanceName, level) : t("menu.click_stale");
12161
13806
  }
12162
13807
  catch (err) {
12163
13808
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
@@ -12214,7 +13859,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12214
13859
  const choices = this.modelMenuChoices(name, nonce, options, currentModel);
12215
13860
  const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
12216
13861
  timer.unref?.();
12217
- this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, timer, respond: data.respond, respondChoices: data.respondChoices });
13862
+ this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond, respondChoices: data.respondChoices });
12218
13863
  try {
12219
13864
  await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
12220
13865
  }
@@ -12230,7 +13875,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12230
13875
  * pendingModelSelects coordinator as the DC Select Menu path.
12231
13876
  * Returns null on success (menu shown), or a fallback string to send.
12232
13877
  */
12233
- async promptModelMenu(instanceName, userId, channelId, adapter, chatId, threadId) {
13878
+ async promptModelMenu(instanceName, userId, channelId, adapter, chatId, threadId, adapterId) {
12234
13879
  const options = await this.getModelOptions(instanceName);
12235
13880
  if (options.length === 0) {
12236
13881
  return t("model.list_unavailable", instanceName);
@@ -12250,7 +13895,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12250
13895
  }
12251
13896
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
12252
13897
  timer.unref?.();
12253
- this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
13898
+ this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
12254
13899
  try {
12255
13900
  const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
12256
13901
  const pending = this.pendingModelSelects.get(nonce);
@@ -12379,8 +14024,40 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12379
14024
  await pending.respond(t("model.usage")).catch(() => { });
12380
14025
  }
12381
14026
  }
14027
+ /**
14028
+ * A /model or /effort menu click may still act (#1396 review): the instance is still owned by the adapter the click
14029
+ * came through (it may have been rebound since the menu opened) and the clicker is still that channel's admin. Asked
14030
+ * when the click is claimed and again right before the change is applied.
14031
+ */
14032
+ menuClickStillCurrent(instanceName, userId, channelId, adapterId, sourceChatId) {
14033
+ return this.commandChannelStillTargets(instanceName, channelId, adapterId, sourceChatId)
14034
+ && this.isModelAdmin(userId, channelId, adapterId);
14035
+ }
14036
+ /** Current source-to-target mapping, including same-adapter topic moves. */
14037
+ commandChannelStillTargets(instanceName, channelId, adapterId, sourceChatId) {
14038
+ if (this.getInstanceAdapterId(instanceName) !== adapterId)
14039
+ return false;
14040
+ const classic = this.classicChannels?.getInstanceByChannel(channelId, adapterId);
14041
+ if (classic !== undefined)
14042
+ return classic === instanceName;
14043
+ // Read the current config rather than relying on a pre-reload route cache.
14044
+ const targets = Object.entries(this.fleetConfig?.instances ?? {}).filter(([name, cfg]) => cfg.topic_id != null && String(cfg.topic_id) === channelId && this.getInstanceAdapterId(name) === adapterId);
14045
+ // A duplicate within one world is ambiguous (the slash table keeps the
14046
+ // last registration). Never authorize an old menu via the first match.
14047
+ if (targets.length > 1)
14048
+ return false;
14049
+ const channel = this.getChannelConfig(adapterId);
14050
+ if (targets.length === 1)
14051
+ return targets[0][0] === instanceName
14052
+ && (channel?.type !== "telegram" || sourceChatId !== undefined
14053
+ && channel.group_id != null && String(channel.group_id) === sourceChatId);
14054
+ // Root General menus carry the group address, not the logical topic id.
14055
+ return channel?.type === "telegram" && channel.group_id != null && String(channel.group_id) === channelId
14056
+ && this.fleetConfig?.instances[instanceName]?.general_topic === true
14057
+ && this.findGeneralInstance(adapterId) === instanceName;
14058
+ }
12382
14059
  /** Consume a `/model` selection callback. Returns true for all model-select ids (incl. stale). */
12383
- async handleModelSelection(data) {
14060
+ async handleModelSelection(data, adapterId) {
12384
14061
  if (!data.callbackData.startsWith(MODEL_SELECT_CALLBACK_PREFIX))
12385
14062
  return false;
12386
14063
  const match = data.callbackData.match(/^model-select:([0-9a-f]+):(.+)$/);
@@ -12389,12 +14066,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12389
14066
  const pending = this.pendingModelSelects.get(match[1]);
12390
14067
  if (!pending)
12391
14068
  return true;
12392
- // Only the admin who opened the menu, in the same channel, may consume it.
12393
- if (data.userId && data.userId !== pending.userId)
12394
- return true;
14069
+ // The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
14070
+ // when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
12395
14071
  const cbChannel = data.threadId ?? data.chatId;
12396
- if (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
14072
+ if (!data.userId || data.userId !== pending.userId
14073
+ || (pending.adapterId !== undefined && pending.adapterId !== adapterId)
14074
+ || (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
14075
+ || !this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)) {
14076
+ data.ack?.(t("buttons.admin_only"));
12397
14077
  return true;
14078
+ }
12398
14079
  this.pendingModelSelects.delete(match[1]);
12399
14080
  clearTimeout(pending.timer);
12400
14081
  const model = match[2];
@@ -12444,7 +14125,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12444
14125
  void (async () => {
12445
14126
  let result;
12446
14127
  try {
12447
- result = await this.applyModel(pending.instanceName, model);
14128
+ // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
14129
+ result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
14130
+ ? await this.applyModel(pending.instanceName, model) : t("menu.click_stale");
12448
14131
  }
12449
14132
  catch (err) {
12450
14133
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
@@ -12638,30 +14321,58 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12638
14321
  const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
12639
14322
  return adapter?.type === "telegram" ? `${key}.telegram` : key;
12640
14323
  }
14324
+ /** Approval is a system notification: General's agent need not be running. */
14325
+ findClassicApprovalGeneral(adapterId) {
14326
+ const generals = Object.entries(this.fleetConfig?.instances ?? {}).filter(([, cfg]) => cfg.general_topic === true);
14327
+ return (adapterId ? generals.find(([name, cfg]) => (cfg.channel_id ?? this.getInstanceAdapterId(name)) === adapterId) : undefined)?.[0]
14328
+ ?? generals[0]?.[0];
14329
+ }
12641
14330
  /** Return a user-facing blocker without mutating ClassicBot state. */
12642
14331
  validateClassicStart(channelId, userId, guildId, adapterId) {
12643
- if (!this.classicChannels)
14332
+ const classic = this.classicChannels;
14333
+ if (!classic)
12644
14334
  return t("classic.manager_unavailable");
12645
- if (guildId && !this.classicChannels.isGuildAllowed(guildId)) {
12646
- const generalId = this.findGeneralInstance(adapterId);
12647
- if (generalId) {
12648
- // Fire-and-forget, exactly as the notifyInstanceTopic it replaces: this
12649
- // function's return value is the rejection shown to the user, and it
12650
- // must not wait on posting buttons into the General topic.
12651
- void this.promptClassicApproval({
12652
- generalName: generalId,
12653
- message: t("alert.unauth_guild", guildId, userId),
12654
- groupId: String(guildId),
12655
- scope: "guild",
12656
- userId: userId ? String(userId) : undefined,
12657
- }).catch(err => this.logger.warn({ err, guildId }, "Classic approval prompt failed"));
12658
- }
12659
- return t("classic.not_authorized_guild");
12660
- }
12661
- if (this.classicChannels.isClassicChannel(channelId, adapterId))
14335
+ // Admission applies only to NEW channels. Revoking a start grant never
14336
+ // blocks an existing agent's chat or changes its registration.
14337
+ if (classic.isClassicChannel(channelId, adapterId))
12662
14338
  return t(this.classicStartKey("classic.already_active", adapterId));
12663
14339
  if (this.routing.resolve(channelId))
12664
14340
  return t("classic.topic_bound");
14341
+ const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
14342
+ // Discord DMs remain unsupported, including direct calls outside the slash door.
14343
+ if (!guildId && adapter?.type === "discord")
14344
+ return t("slash.dm_unsupported");
14345
+ // C is distinct from fleet admin. Only C bypasses new-channel admission.
14346
+ if (classic.isAdmin(userId))
14347
+ return undefined;
14348
+ const scope = guildId ? "guild" : channelId.startsWith("-") ? "group" : "user";
14349
+ const targetId = guildId ?? (scope === "user" ? userId : channelId);
14350
+ const allowed = scope === "guild" ? classic.isGuildAllowed(targetId)
14351
+ : scope === "group" ? classic.isGroupAllowed(targetId) : classic.isUserAllowed(targetId);
14352
+ if (!allowed) {
14353
+ const generalName = this.findClassicApprovalGeneral(adapterId);
14354
+ if (generalName) {
14355
+ const message = scope === "guild" ? t("alert.unauth_guild", targetId, userId)
14356
+ : scope === "group" ? t("alert.new_group", channelId, targetId, userId, userId, "telegram")
14357
+ : t("alert.unauth_user_private", userId, userId, "telegram");
14358
+ void this.promptClassicApproval({
14359
+ generalName, message, groupId: String(targetId), scope, userId,
14360
+ replyTo: adapter ? { adapterId: adapter.id, adapter, chatId: channelId } : undefined,
14361
+ }).catch(err => this.logger.warn({ err, scope }, "Classic approval prompt failed"));
14362
+ }
14363
+ else {
14364
+ this.logger.warn({ adapterId, scope }, "Classic access request has no running General");
14365
+ }
14366
+ return t("classic.access_requested");
14367
+ }
14368
+ // Preserve the existing group start role. Allow only grants the group;
14369
+ // Allow+admin also gives the requester permission to start there.
14370
+ if (scope === "group") {
14371
+ const generalName = this.findClassicApprovalGeneral(adapterId);
14372
+ if (generalName)
14373
+ this.notifyInstanceTopic(generalName, t("alert.start_not_admin", userId, userId, "telegram", channelId));
14374
+ return t("classic.admin_only_start");
14375
+ }
12665
14376
  return undefined;
12666
14377
  }
12667
14378
  isBackendInstalled(backend) {
@@ -12688,6 +14399,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12688
14399
  await data.respond(t("classic.backend_required"));
12689
14400
  return;
12690
14401
  }
14402
+ const blocker = this.validateClassicStart(data.channelId, data.userId, data.guildId, adapterId);
14403
+ if (blocker) {
14404
+ await data.respond(blocker);
14405
+ return;
14406
+ }
12691
14407
  const warning = this.getMissingBackendWarning(requestedBackend);
12692
14408
  // Keep the deferred ephemeral response useful even if daemon startup later
12693
14409
  // fails because the executable is absent. This is advisory, not a gate.
@@ -12793,7 +14509,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12793
14509
  await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
12794
14510
  }
12795
14511
  /** Start a classic channel instance with lightweight config */
12796
- async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter, transition) {
14512
+ async startClassicInstance(instanceName, backend, preTaskCommand, model, autoPauseAfter, transition, execution) {
14513
+ execution?.assert();
12797
14514
  if (this.daemons.has(instanceName))
12798
14515
  return;
12799
14516
  const workDir = join(getAgendHome(), "workspaces", instanceName);
@@ -12831,7 +14548,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12831
14548
  + "fix it in classicBot.yaml; it will not start on another login");
12832
14549
  }
12833
14550
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
12834
- await this.startInstance(instanceName, config, topicMode, "classic", false, transition);
14551
+ if (execution)
14552
+ await this.startInstance(instanceName, config, topicMode, "classic", false, transition, execution);
14553
+ else
14554
+ await this.startInstance(instanceName, config, topicMode, "classic", false, transition);
12835
14555
  }
12836
14556
  /** Handle /start slash command — register classic channel */
12837
14557
  async handleClassicStart(channelId, channelName, userId, guildId, adapterId, backend) {
@@ -12893,6 +14613,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12893
14613
  // because we asked it to. Set synchronously — doStopAll runs to its first
12894
14614
  // await in the same tick as the signal handler, so no event can slip in.
12895
14615
  this.shuttingDown = true;
14616
+ this.settingsGeneration++;
14617
+ this.settingsConfirmation?.store.close();
14618
+ this.settingsConfirmation = null;
14619
+ const settingsControl = this.settingsControl;
14620
+ this.settingsControl = null;
14621
+ const settingsControlStopped = settingsControl?.close();
14622
+ const publicStopped = this.publicWebLink?.close("fleet shutdown");
14623
+ // #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
14624
+ // adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
14625
+ const needsStopped = this.stopNeedsYou();
12896
14626
  const profileStopped = this.runtimeCpuProfiler?.shutdown("fleet shutdown");
12897
14627
  this.ipcStoppingInstances.add("__fleet_stopping__");
12898
14628
  // Release held delivery promises before awaiting daemon shutdown, then
@@ -12922,7 +14652,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
12922
14652
  // A login/install window is a dedicated tmux server with its own TTL
12923
14653
  // timer and HTTP listener living in THIS process: without an explicit
12924
14654
  // shutdown it would outlive us as an owner-less login CLI (sol B3).
14655
+ await publicStopped;
12925
14656
  await profileStopped;
14657
+ await needsStopped;
14658
+ await settingsControlStopped;
12926
14659
  await this.cpuProfileControl?.close();
12927
14660
  this.cpuProfileControl = null;
12928
14661
  await this.shutdownLoginWindows();
@@ -13049,6 +14782,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13049
14782
  this.healthServer.close();
13050
14783
  this.healthServer = null;
13051
14784
  }
14785
+ this.stopPreviewListener();
14786
+ // The store writes lastSeen at most once a minute; what that debounce is still holding — and any
14787
+ // write that failed and is still owed — is paid now, so a restart neither shortens the idle window
14788
+ // nor revives a session that was revoked while the disk was refusing writes.
14789
+ this.webSessions?.flush();
13052
14790
  this.eventLog?.close();
13053
14791
  const pidPath = join(this.dataDir, "fleet.pid");
13054
14792
  try {
@@ -13269,6 +15007,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13269
15007
  this.routing.rebuild(this.fleetConfig);
13270
15008
  this.reregisterClassicChannels();
13271
15009
  this.scheduler?.reload();
15010
+ this.reconcilePreviewListener();
15011
+ this.publicWebLink?.refresh();
13272
15012
  const newInstances = this.fleetConfig.instances;
13273
15013
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
13274
15014
  // Only what a fresh process can adopt, and only relative to the signature
@@ -13696,7 +15436,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13696
15436
  const challengeKey = `${input.sessionBinding}:api_key:${spec.id}:${spec.envKey}:${input.idempotencyKey}`;
13697
15437
  const existingId = this.providerSecretChallengesByKey.get(challengeKey);
13698
15438
  const existing = existingId ? this.providerSecretChallenges.get(existingId) : undefined;
13699
- if (existing && existing.expiresAt > Date.now()) {
15439
+ if (existing && (existing.deadline === undefined ? existing.expiresAt > Date.now() : existing.deadline > performance.now())) {
13700
15440
  return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt, spec_id: spec.id, activation: spec.activation };
13701
15441
  }
13702
15442
  if (existingId)
@@ -13718,7 +15458,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13718
15458
  generation: this.providerSecretGeneration(spec.envKey),
13719
15459
  operation: "provider-secret.apply",
13720
15460
  idempotencyKey: input.idempotencyKey,
13721
- expiresAt,
15461
+ expiresAt, deadline: performance.now() + SECRET_CHALLENGE_TTL_MS,
13722
15462
  secret: input.secret,
13723
15463
  };
13724
15464
  this.providerSecretChallenges.set(challenge.id, challenge);
@@ -13745,7 +15485,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13745
15485
  }
13746
15486
  const challenge = this.providerSecretChallenges.get(input.verificationId);
13747
15487
  const spec = providerSecretSpec(input.specId);
13748
- if (!challenge || challenge.expiresAt <= Date.now()) {
15488
+ if (!challenge || (challenge.deadline === undefined ? challenge.expiresAt <= Date.now() : challenge.deadline <= performance.now())) {
13749
15489
  if (challenge)
13750
15490
  this.providerSecretChallenges.delete(input.verificationId);
13751
15491
  return { error: "verification expired; verify the secret again" };
@@ -13772,7 +15512,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13772
15512
  this.providerSecretJobs.set(job.id, job);
13773
15513
  this.providerSecretJobSession.set(job.id, input.sessionBinding);
13774
15514
  this.providerSecretInFlight.set(spec.envKey, job.id);
13775
- queueMicrotask(() => void this.runProviderSecretApply(job, challenge.secret));
15515
+ this.queueSettingsOperation(job, [settingsFileResource(join(this.dataDir, ".env"))], input.execution, execution => this.runProviderSecretApply(job, challenge.secret, execution));
13776
15516
  return { job, reused: false };
13777
15517
  }
13778
15518
  startProviderApiKeyApply(input) {
@@ -13786,7 +15526,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13786
15526
  getProviderApiKeyApply(jobId, sessionBinding) {
13787
15527
  return this.getProviderSecretApply(jobId, sessionBinding);
13788
15528
  }
13789
- async runProviderSecretApply(job, secret) {
15529
+ async runProviderSecretApply(job, secret, execution) {
13790
15530
  const spec = providerSecretSpec(job.specId);
13791
15531
  const allowed = new Set([
13792
15532
  ...PROVIDER_SECRET_SPECS.map(item => item.envKey),
@@ -13801,18 +15541,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13801
15541
  throw new Error("provider secret is not configured");
13802
15542
  // Construct inside the transaction: symlink/permission refusal must
13803
15543
  // settle the job as a safe failure, not escape the queued microtask.
13804
- store = new SecretStore(join(this.dataDir, ".env"), allowed);
13805
- before = store.write(job.envKey, secret);
13806
- wrote = true;
13807
- process.env[job.envKey] = secret;
15544
+ store = new SecretStore(join(this.dataDir, ".env"), allowed, { owner: execution?.owner });
15545
+ const write = () => { before = store.write(job.envKey, secret); wrote = true; process.env[job.envKey] = secret; };
15546
+ if (execution)
15547
+ execution.mutate(write);
15548
+ else
15549
+ write();
13808
15550
  if (spec.activation === "reload_hook" && spec.reloadHookId) {
13809
15551
  await this.runProviderSecretReloadHook(spec.reloadHookId, secret, previousProcessValue);
15552
+ execution?.assert();
13810
15553
  job.result = "reloaded";
13811
15554
  }
13812
15555
  else {
13813
15556
  job.result = "applied_next_use";
13814
15557
  }
13815
- this.providerSecretGenerations.set(job.envKey, this.providerSecretGeneration(job.envKey) + 1);
15558
+ const commit = () => { this.providerSecretGenerations.set(job.envKey, this.providerSecretGeneration(job.envKey) + 1); };
15559
+ if (execution)
15560
+ execution.commit(commit);
15561
+ else
15562
+ commit();
13816
15563
  job.status = "done";
13817
15564
  job.finishedAt = Date.now();
13818
15565
  }
@@ -13820,12 +15567,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13820
15567
  const safe = safeSecretError(err, secret);
13821
15568
  this.logger.warn({ specId: job.specId, reason: safe }, "Provider API-key apply failed");
13822
15569
  try {
15570
+ // Memory and disk have separate receipts: a host-edited file must
15571
+ // remain, but cannot strand our owned process value or hook snapshot.
15572
+ if (wrote && process.env[job.envKey] === secret) {
15573
+ if (previousProcessValue === undefined)
15574
+ delete process.env[job.envKey];
15575
+ else
15576
+ process.env[job.envKey] = previousProcessValue;
15577
+ }
15578
+ if (spec?.reloadHookId && this.providerSecretHotSnapshots.get(spec.reloadHookId) === secret) {
15579
+ if (previousProcessValue === undefined)
15580
+ this.providerSecretHotSnapshots.delete(spec.reloadHookId);
15581
+ else
15582
+ this.providerSecretHotSnapshots.set(spec.reloadHookId, previousProcessValue);
15583
+ }
13823
15584
  if (wrote && before && store)
13824
- store.restore(before);
13825
- if (previousProcessValue === undefined)
13826
- delete process.env[job.envKey];
13827
- else
13828
- process.env[job.envKey] = previousProcessValue;
15585
+ store.restoreIfCurrent(before);
13829
15586
  // SecretStore.write is itself transactional; when it fails before a
13830
15587
  // snapshot is returned there is no new value to roll back. Report the
13831
15588
  // truthful no-op rather than claiming rollback_failed.
@@ -13842,7 +15599,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13842
15599
  job.finishedAt = Date.now();
13843
15600
  }
13844
15601
  finally {
13845
- this.providerSecretInFlight.delete(job.envKey);
15602
+ if (this.providerSecretInFlight.get(job.envKey) === job.id)
15603
+ this.providerSecretInFlight.delete(job.envKey);
13846
15604
  secret = "";
13847
15605
  }
13848
15606
  }
@@ -13861,10 +15619,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13861
15619
  await hook(_next, before);
13862
15620
  }
13863
15621
  catch (err) {
13864
- if (before === undefined)
13865
- this.providerSecretHotSnapshots.delete(hookId);
13866
- else
13867
- this.providerSecretHotSnapshots.set(hookId, before);
15622
+ if (this.providerSecretHotSnapshots.get(hookId) === _next) {
15623
+ if (before === undefined)
15624
+ this.providerSecretHotSnapshots.delete(hookId);
15625
+ else
15626
+ this.providerSecretHotSnapshots.set(hookId, before);
15627
+ }
13868
15628
  throw err;
13869
15629
  }
13870
15630
  }
@@ -13919,7 +15679,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13919
15679
  const key = `${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`;
13920
15680
  const existingId = this.connectionBindingChallengesByKey.get(key);
13921
15681
  const existing = existingId ? this.connectionBindingChallenges.get(existingId) : undefined;
13922
- if (existing && existing.expiresAt > Date.now()) {
15682
+ if (existing && (existing.deadline === undefined ? existing.expiresAt > Date.now() : existing.deadline > performance.now())) {
13923
15683
  return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt, binding: existing.binding, probe: existing.probe };
13924
15684
  }
13925
15685
  if (existingId)
@@ -13948,7 +15708,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13948
15708
  generation: afterGeneration,
13949
15709
  operation: "binding.apply",
13950
15710
  idempotencyKey: input.idempotencyKey,
13951
- expiresAt,
15711
+ expiresAt, deadline: performance.now() + SECRET_CHALLENGE_TTL_MS,
13952
15712
  binding,
13953
15713
  probe,
13954
15714
  };
@@ -13971,7 +15731,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13971
15731
  return { job, reused: true };
13972
15732
  }
13973
15733
  const challenge = this.connectionBindingChallenges.get(input.verificationId);
13974
- if (!challenge || challenge.expiresAt <= Date.now()) {
15734
+ if (!challenge || (challenge.deadline === undefined ? challenge.expiresAt <= Date.now() : challenge.deadline <= performance.now())) {
13975
15735
  this.connectionBindingChallenges.delete(input.verificationId);
13976
15736
  return { error: "binding verification expired; verify the binding again" };
13977
15737
  }
@@ -13996,7 +15756,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13996
15756
  this.connectionBindingJobs.set(job.id, job);
13997
15757
  this.connectionBindingJobSession.set(job.id, input.sessionBinding);
13998
15758
  this.connectionBindingInFlight.set(input.connectionId, job.id);
13999
- queueMicrotask(() => void this.runConnectionBindingApply(job, challenge.binding));
15759
+ this.queueSettingsOperation(job, [`connection:${this.dataDir}:${job.connectionId}`], input.execution, execution => this.runConnectionBindingApply(job, challenge.binding, execution));
14000
15760
  return { job, reused: false };
14001
15761
  }
14002
15762
  getConnectionBindingApply(jobId, sessionBinding) {
@@ -14004,9 +15764,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14004
15764
  return null;
14005
15765
  return this.connectionBindingJobs.get(jobId) ?? null;
14006
15766
  }
14007
- async runConnectionBindingApply(job, binding) {
15767
+ async runConnectionBindingApply(job, binding, execution) {
14008
15768
  try {
14009
- await this.rebuildAdapterForBinding(job.connectionId, binding);
15769
+ await this.rebuildAdapterForBinding(job.connectionId, binding, execution);
14010
15770
  job.result = "applied";
14011
15771
  }
14012
15772
  catch (err) {
@@ -14020,11 +15780,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14020
15780
  finally {
14021
15781
  job.status = "done";
14022
15782
  job.finishedAt = Date.now();
14023
- this.connectionBindingInFlight.delete(job.connectionId);
15783
+ if (this.connectionBindingInFlight.get(job.connectionId) === job.id)
15784
+ this.connectionBindingInFlight.delete(job.connectionId);
14024
15785
  }
14025
15786
  }
14026
15787
  /** Stop, rebuild and wait for a new adapter before committing YAML binding. */
14027
- async rebuildAdapterForBinding(connectionId, binding) {
15788
+ async rebuildAdapterForBinding(connectionId, binding, execution) {
15789
+ const generation = this.settingsGeneration;
15790
+ const current = () => !this.shuttingDown && this.settingsGeneration === generation;
15791
+ const check = () => { execution?.assert(); if (!current())
15792
+ throw new Error("adapter operation superseded"); };
15793
+ check();
14028
15794
  const channel = this.secureConnectionChannel(connectionId);
14029
15795
  if (!channel || !this.fleetConfig)
14030
15796
  throw new Error("connection not found");
@@ -14034,19 +15800,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14034
15800
  const oldPrimary = this.adapter;
14035
15801
  const oldAccess = this.accessManager;
14036
15802
  const oldState = this.adapterState.get(connectionId);
14037
- const oldChannel = structuredClone(channel);
15803
+ const beforeBinding = structuredClone(this.fleetConfig);
15804
+ const bindingPath = this.fleetConfig.channels
15805
+ ? ["channels", String(this.fleetConfig.channels.indexOf(channel))] : ["channel"];
15806
+ const bindingFields = [[...bindingPath, "group_id"],
15807
+ ...(binding.general_channel_id !== undefined ? [[...bindingPath, "options", "general_channel_id"]] : [])];
15808
+ let bindingUndo = [];
14038
15809
  const primary = this.getPrimaryAdapterId() === connectionId;
14039
15810
  if (primary && this.sessionPruneTimer) {
14040
15811
  clearInterval(this.sessionPruneTimer);
14041
15812
  this.sessionPruneTimer = null;
14042
15813
  }
14043
15814
  let fresh;
14044
- let persistedBinding = false;
14045
15815
  try {
14046
15816
  this.adapterState.set(connectionId, { status: "retrying", retryCount: oldState?.retryCount ?? 0 });
14047
15817
  if (oldAdapter) {
14048
15818
  oldAdapter.removeAllListeners();
14049
15819
  await oldAdapter.stop().catch(() => { });
15820
+ check();
14050
15821
  if (this.adapters.get(connectionId) === oldAdapter)
14051
15822
  this.adapters.delete(connectionId);
14052
15823
  if (this.worlds.get(connectionId)?.adapter === oldAdapter)
@@ -14058,21 +15829,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14058
15829
  const started = new Promise(resolve => { startedResolve = resolve; });
14059
15830
  const onStarted = () => { startedResolve?.(); };
14060
15831
  if (primary)
14061
- await this.startSingleAdapter(this.fleetConfig, candidate, onStarted);
15832
+ await this.startSingleAdapter(this.fleetConfig, candidate, onStarted, execution);
14062
15833
  else
14063
- await this.startAdditionalAdapter(candidate, true, onStarted);
15834
+ await this.startAdditionalAdapter(candidate, true, onStarted, execution);
15835
+ check();
14064
15836
  fresh = this.adapters.get(connectionId);
14065
15837
  if (!fresh)
14066
15838
  throw new Error("new adapter did not start");
14067
- const deadline = Date.now() + 15_000;
15839
+ const deadline = performance.now() + 15_000;
14068
15840
  if (!fresh.getHealthSnapshot) {
14069
15841
  let timer;
14070
15842
  const timeout = new Promise((_, reject) => {
14071
- timer = setTimeout(() => reject(new Error("new adapter did not become ready")), Math.max(1, deadline - Date.now()));
15843
+ timer = setTimeout(() => reject(new Error("new adapter did not become ready")), Math.max(1, deadline - performance.now()));
14072
15844
  timer.unref?.();
14073
15845
  });
14074
15846
  try {
14075
15847
  await Promise.race([started, timeout]);
15848
+ check();
14076
15849
  }
14077
15850
  finally {
14078
15851
  if (timer)
@@ -14080,11 +15853,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14080
15853
  }
14081
15854
  }
14082
15855
  else {
14083
- while (Date.now() < deadline) {
15856
+ while (performance.now() < deadline) {
14084
15857
  const health = fresh.getHealthSnapshot?.();
14085
15858
  if (health?.status === "connected" || this.adapterState.get(connectionId)?.status === "connected")
14086
15859
  break;
14087
15860
  await new Promise(resolve => setTimeout(resolve, 100));
15861
+ check();
14088
15862
  }
14089
15863
  const health = fresh.getHealthSnapshot?.();
14090
15864
  if (health && health.status !== "connected" && this.adapterState.get(connectionId)?.status !== "connected") {
@@ -14095,67 +15869,88 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14095
15869
  fresh.setChatId(String(candidate.group_id));
14096
15870
  // Commit only after the replacement adapter is ready. No allowlist,
14097
15871
  // topic, instance or schedule fields are touched here.
14098
- channel.group_id = String(binding.group_id);
14099
- if (binding.general_channel_id !== undefined) {
14100
- const options = { ...(channel.options ?? {}) };
14101
- if (binding.general_channel_id === null)
14102
- delete options.general_channel_id;
14103
- else
14104
- options.general_channel_id = String(binding.general_channel_id);
14105
- if (Object.keys(options).length === 0)
14106
- delete channel.options;
14107
- else
14108
- channel.options = options;
15872
+ const commit = () => {
15873
+ channel.group_id = String(binding.group_id);
15874
+ if (binding.general_channel_id !== undefined) {
15875
+ const options = { ...(channel.options ?? {}) };
15876
+ if (binding.general_channel_id === null)
15877
+ delete options.general_channel_id;
15878
+ else
15879
+ options.general_channel_id = String(binding.general_channel_id);
15880
+ if (Object.keys(options).length === 0)
15881
+ delete channel.options;
15882
+ else
15883
+ channel.options = options;
15884
+ }
15885
+ try {
15886
+ this.saveFleetConfig();
15887
+ }
15888
+ finally {
15889
+ bindingUndo = settingsUndo(this.configPath, beforeBinding, this.fleetConfig, bindingFields);
15890
+ }
15891
+ this.routing.rebuild(this.fleetConfig);
15892
+ this.reregisterClassicChannels();
15893
+ this.adapterState.set(connectionId, { status: "connected", retryCount: 0 });
15894
+ };
15895
+ if (execution)
15896
+ execution.commit(commit);
15897
+ else {
15898
+ check();
15899
+ commit();
14109
15900
  }
14110
- this.saveFleetConfig();
14111
- persistedBinding = true;
14112
- this.routing.rebuild(this.fleetConfig);
14113
- this.reregisterClassicChannels();
14114
- this.adapterState.set(connectionId, { status: "connected", retryCount: 0 });
14115
15901
  }
14116
15902
  catch (err) {
15903
+ let rollbackError;
15904
+ try {
15905
+ if (bindingUndo.length) {
15906
+ const disk = undoSettingsPaths(this.configPath, loadRawFleetConfig(this.configPath), bindingUndo);
15907
+ const runtime = undoSettingsPaths(this.configPath, this.fleetConfig, bindingUndo);
15908
+ if (disk.conflicts || runtime.conflicts)
15909
+ throw new Error("binding rollback conflict; newer configuration retained");
15910
+ noteSettingsWrite(this.configPath, this.fleetConfig, runtime.value);
15911
+ this.fleetConfig = runtime.value;
15912
+ this.saveFleetConfig(bindingFields.map(path => {
15913
+ let value = disk.value;
15914
+ for (const key of path)
15915
+ value = value?.[key];
15916
+ return { path, value, ...(value === undefined ? { remove: true } : {}) };
15917
+ }));
15918
+ }
15919
+ }
15920
+ catch (restoreErr) {
15921
+ rollbackError = restoreErr;
15922
+ }
14117
15923
  if (fresh && fresh !== oldAdapter)
14118
15924
  await fresh.stop().catch(() => { });
14119
- // Restore only the binding object in memory; unrelated connection and
14120
- // instance state remains exactly as it was before the attempt.
14121
- for (const key of Object.keys(channel)) {
14122
- if (!(key in oldChannel))
14123
- delete channel[key];
14124
- }
14125
- Object.assign(channel, oldChannel);
14126
- this.adapters.delete(connectionId);
14127
- this.worlds.delete(connectionId);
14128
- this.adapterState.delete(connectionId);
14129
- if (oldAdapter) {
14130
- try {
14131
- const onStarted = () => { };
14132
- if (primary)
14133
- await this.startSingleAdapter(this.fleetConfig, oldChannel, onStarted);
14134
- else
14135
- await this.startAdditionalAdapter(oldChannel, true, onStarted);
14136
- this.adapterState.set(connectionId, oldState ?? { status: "connected", retryCount: 0 });
14137
- }
14138
- catch (restoreErr) {
14139
- throw new Error(`binding rollback failed: ${safeSecretError(restoreErr)}`);
15925
+ if (this.adapters.get(connectionId) === fresh || this.adapters.get(connectionId) === oldAdapter) {
15926
+ this.adapters.delete(connectionId);
15927
+ if (this.worlds.get(connectionId)?.adapter === fresh || this.worlds.get(connectionId)?.adapter === oldAdapter)
15928
+ this.worlds.delete(connectionId);
15929
+ if (primary && (this.adapter === fresh || this.adapter === oldAdapter))
15930
+ this.adapter = null;
15931
+ }
15932
+ if (current()) {
15933
+ const retained = this.secureConnectionChannel(connectionId);
15934
+ if (retained) {
15935
+ const cleanup = { owner: execution?.owner ?? Symbol("adapter-cleanup"), assert: () => { if (!current())
15936
+ throw new Error("cleanup superseded"); } };
15937
+ try {
15938
+ if (primary)
15939
+ await this.startSingleAdapter(this.fleetConfig, retained, () => { }, cleanup);
15940
+ else
15941
+ await this.startAdditionalAdapter(retained, true, () => { }, cleanup);
15942
+ if (!current())
15943
+ throw new Error("cleanup superseded");
15944
+ this.routing.rebuild(this.fleetConfig);
15945
+ this.reregisterClassicChannels();
15946
+ }
15947
+ catch (restoreErr) {
15948
+ throw new Error(`binding rollback failed: ${safeSecretError(restoreErr)}`);
15949
+ }
14140
15950
  }
14141
15951
  }
14142
- else {
14143
- if (primary)
14144
- this.adapter = oldPrimary;
14145
- if (oldWorld)
14146
- this.worlds.set(connectionId, oldWorld);
14147
- if (oldAdapter)
14148
- this.adapters.set(connectionId, oldAdapter);
14149
- this.accessManager = oldAccess;
14150
- }
14151
- // The binding is committed to YAML before routing is rebuilt. If the
14152
- // post-commit rebuild fails, restore the durable document as well as the
14153
- // in-memory channel; otherwise a reload would resurrect the failed
14154
- // binding that the running fleet just rolled back.
14155
- if (persistedBinding)
14156
- this.saveFleetConfig();
14157
- this.routing.rebuild(this.fleetConfig);
14158
- this.reregisterClassicChannels();
15952
+ if (rollbackError)
15953
+ throw new Error(`binding rollback failed: ${safeSecretError(rollbackError)}`);
14159
15954
  throw err;
14160
15955
  }
14161
15956
  }
@@ -14170,7 +15965,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14170
15965
  const challengeKey = `${input.sessionBinding}:${input.connectionId}:${input.idempotencyKey}`;
14171
15966
  const existingId = this.connectionSecretChallengesByKey.get(challengeKey);
14172
15967
  const existing = existingId ? this.connectionSecretChallenges.get(existingId) : undefined;
14173
- if (existing && existing.expiresAt > Date.now()) {
15968
+ if (existing && (existing.deadline === undefined ? existing.expiresAt > Date.now() : existing.deadline > performance.now())) {
14174
15969
  return { ok: true, verification_id: existing.id, expires_at: existing.expiresAt };
14175
15970
  }
14176
15971
  if (existingId)
@@ -14192,7 +15987,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14192
15987
  generation: this.secureConnectionGeneration(input.connectionId),
14193
15988
  operation: "secret.apply",
14194
15989
  idempotencyKey: input.idempotencyKey,
14195
- expiresAt,
15990
+ expiresAt, deadline: performance.now() + SECRET_CHALLENGE_TTL_MS,
14196
15991
  secret: input.secret,
14197
15992
  };
14198
15993
  this.connectionSecretChallenges.set(challenge.id, challenge);
@@ -14222,7 +16017,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14222
16017
  }
14223
16018
  }
14224
16019
  const challenge = this.connectionSecretChallenges.get(input.verificationId);
14225
- if (!challenge || challenge.expiresAt <= Date.now()) {
16020
+ if (!challenge || (challenge.deadline === undefined ? challenge.expiresAt <= Date.now() : challenge.deadline <= performance.now())) {
14226
16021
  this.connectionSecretChallenges.delete(input.verificationId);
14227
16022
  return { error: "verification expired; verify the secret again" };
14228
16023
  }
@@ -14256,7 +16051,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14256
16051
  this.connectionSecretJobs.set(job.id, job);
14257
16052
  this.connectionSecretJobSession.set(job.id, input.sessionBinding);
14258
16053
  this.connectionSecretInFlight.set(input.connectionId, job.id);
14259
- queueMicrotask(() => void this.runConnectionSecretApply(job, challenge.secret));
16054
+ this.queueSettingsOperation(job, [settingsFileResource(join(this.dataDir, ".env")), `connection:${this.dataDir}:${job.connectionId}`], input.execution, execution => this.runConnectionSecretApply(job, challenge.secret, execution));
14260
16055
  return { job, reused: false };
14261
16056
  }
14262
16057
  getConnectionSecretApply(jobId, sessionBinding) {
@@ -14264,7 +16059,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14264
16059
  return null;
14265
16060
  return this.connectionSecretJobs.get(jobId) ?? null;
14266
16061
  }
14267
- async runConnectionSecretApply(job, secret) {
16062
+ async runConnectionSecretApply(job, secret, execution) {
14268
16063
  const channel = this.secureConnectionChannel(job.connectionId);
14269
16064
  const envKey = channel?.bot_token_env;
14270
16065
  const allowed = new Set((this.fleetConfig?.channels
@@ -14272,6 +16067,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14272
16067
  .map(item => item.bot_token_env));
14273
16068
  let before = null;
14274
16069
  let oldToken;
16070
+ let store;
16071
+ const generation = this.settingsGeneration;
14275
16072
  let replaced = false;
14276
16073
  try {
14277
16074
  if (!channel || !envKey || !allowed.has(envKey))
@@ -14281,14 +16078,21 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14281
16078
  .filter(item => item.bot_token_env === envKey);
14282
16079
  if (owners.length !== 1)
14283
16080
  throw new Error("secret key is shared by multiple connections");
14284
- const store = new SecretStore(join(this.dataDir, ".env"), allowed);
14285
- before = store.write(envKey, secret);
14286
- replaced = true;
14287
- oldToken = process.env[envKey];
14288
- process.env[envKey] = secret;
14289
- const generation = this.secureConnectionGeneration(job.connectionId) + 1;
14290
- this.connectionSecretGenerations.set(job.connectionId, generation);
14291
- const applied = await this.rebuildAdapterForSecret(job.connectionId, channel);
16081
+ store = new SecretStore(join(this.dataDir, ".env"), allowed, { owner: execution?.owner });
16082
+ const write = () => {
16083
+ before = store.write(envKey, secret);
16084
+ replaced = true;
16085
+ oldToken = process.env[envKey];
16086
+ process.env[envKey] = secret;
16087
+ this.connectionSecretGenerations.set(job.connectionId, this.secureConnectionGeneration(job.connectionId) + 1);
16088
+ };
16089
+ if (execution)
16090
+ execution.mutate(write);
16091
+ else
16092
+ write();
16093
+ const applied = await this.rebuildAdapterForSecret(job.connectionId, channel, false, execution);
16094
+ execution?.assert();
16095
+ execution?.complete(); // Linearize success inside the runner, before its promise settles.
14292
16096
  if (!applied) {
14293
16097
  job.result = "restart_required";
14294
16098
  job.status = "done";
@@ -14308,16 +16112,21 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14308
16112
  }
14309
16113
  else {
14310
16114
  try {
14311
- const store = new SecretStore(join(this.dataDir, ".env"), new Set([envKey]));
14312
- store.restore(before);
14313
- if (oldToken === undefined)
14314
- delete process.env[envKey];
14315
- else
14316
- process.env[envKey] = oldToken;
16115
+ store.restoreIfCurrent(before);
16116
+ if (process.env[envKey] === secret) {
16117
+ if (oldToken === undefined)
16118
+ delete process.env[envKey];
16119
+ else
16120
+ process.env[envKey] = oldToken;
16121
+ }
14317
16122
  // Build a fresh adapter from the restored token. If the old adapter
14318
16123
  // was stopped already, this is the only safe way to return to the
14319
16124
  // previous runtime without claiming a disk-only rollback succeeded.
14320
- const restored = await this.rebuildAdapterForSecret(job.connectionId, channel, true);
16125
+ const cleanup = { owner: execution?.owner ?? Symbol("secret-cleanup"), assert: () => {
16126
+ if (this.shuttingDown || this.settingsGeneration !== generation)
16127
+ throw new Error("secret cleanup superseded");
16128
+ } };
16129
+ const restored = await this.rebuildAdapterForSecret(job.connectionId, this.secureConnectionChannel(job.connectionId) ?? channel, true, cleanup);
14321
16130
  if (!restored)
14322
16131
  throw new Error("adapter rollback did not become connected");
14323
16132
  job.result = "rolled_back";
@@ -14332,13 +16141,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14332
16141
  job.finishedAt = Date.now();
14333
16142
  }
14334
16143
  finally {
14335
- this.connectionSecretInFlight.delete(job.connectionId);
16144
+ if (this.connectionSecretInFlight.get(job.connectionId) === job.id)
16145
+ this.connectionSecretInFlight.delete(job.connectionId);
14336
16146
  // Do not retain the token after the apply (success or rollback).
14337
16147
  secret = "";
14338
16148
  }
14339
16149
  }
14340
16150
  /** Stop the old provider client and construct a new one from process.env. */
14341
- async rebuildAdapterForSecret(connectionId, channel, force = false) {
16151
+ async rebuildAdapterForSecret(connectionId, channel, force = false, execution) {
16152
+ const generation = this.settingsGeneration;
16153
+ const current = () => !this.shuttingDown && this.settingsGeneration === generation;
16154
+ const check = () => { execution?.assert(); if (!current())
16155
+ throw new Error("adapter operation superseded"); };
16156
+ check();
14342
16157
  const old = this.adapters.get(connectionId);
14343
16158
  if (!old && !force)
14344
16159
  return false; // The secret is valid on disk; the next start adopts it.
@@ -14352,6 +16167,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14352
16167
  if (old) {
14353
16168
  old.removeAllListeners();
14354
16169
  await old.stop().catch(() => { });
16170
+ check();
14355
16171
  if (this.adapters.get(connectionId) === old)
14356
16172
  this.adapters.delete(connectionId);
14357
16173
  if (this.worlds.get(connectionId)?.adapter === old)
@@ -14363,13 +16179,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14363
16179
  const started = new Promise(resolve => { startedResolve = resolve; });
14364
16180
  const onStarted = () => { startedResolve?.(); };
14365
16181
  if (primary)
14366
- await this.startSingleAdapter(this.fleetConfig, channel, onStarted);
16182
+ await this.startSingleAdapter(this.fleetConfig, channel, onStarted, execution);
14367
16183
  else
14368
- await this.startAdditionalAdapter(channel, true, onStarted);
16184
+ await this.startAdditionalAdapter(channel, true, onStarted, execution);
16185
+ check();
14369
16186
  const fresh = this.adapters.get(connectionId);
14370
16187
  if (!fresh)
14371
16188
  throw new Error("new adapter did not start");
14372
- const deadline = Date.now() + 15_000;
16189
+ const deadline = performance.now() + 15_000;
14373
16190
  // Telegram has no gateway health snapshot. Its start() method launches the
14374
16191
  // grammY polling loop in the background, so completion of start() is not a
14375
16192
  // connected signal. The adapter's `started` event is emitted only after the
@@ -14377,11 +16194,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14377
16194
  if (!fresh.getHealthSnapshot) {
14378
16195
  let timer;
14379
16196
  const timeout = new Promise((_, reject) => {
14380
- timer = setTimeout(() => reject(new Error("new adapter did not emit started before deadline")), Math.max(1, deadline - Date.now()));
16197
+ timer = setTimeout(() => reject(new Error("new adapter did not emit started before deadline")), Math.max(1, deadline - performance.now()));
14381
16198
  timer.unref?.();
14382
16199
  });
14383
16200
  try {
14384
16201
  await Promise.race([started, timeout]);
16202
+ check();
14385
16203
  }
14386
16204
  finally {
14387
16205
  if (timer)
@@ -14390,11 +16208,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14390
16208
  this.adapterState.set(connectionId, { status: "connected", retryCount: 0 });
14391
16209
  return true;
14392
16210
  }
14393
- while (Date.now() < deadline) {
16211
+ while (performance.now() < deadline) {
14394
16212
  const health = fresh.getHealthSnapshot?.();
14395
16213
  if (health?.status === "connected" || this.adapterState.get(connectionId)?.status === "connected")
14396
16214
  return true;
14397
16215
  await new Promise(resolve => setTimeout(resolve, 100));
16216
+ check();
14398
16217
  }
14399
16218
  throw new Error("new adapter did not become connected");
14400
16219
  }
@@ -14801,14 +16620,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14801
16620
  this.rejectedHostsLogged.add(name);
14802
16621
  this.logger.warn({ host: name }, "Web request refused: Host is not allowed (add it to web.allowed_hosts if this is a proxy you run)");
14803
16622
  }
14804
- startHealthServer(port) {
14805
- this.startedAt = Date.now();
14806
- this.healthServerListening = false;
14807
- this.healthPortRetried = false;
14808
- // Defensive for direct/unit callers; normal startup initializes these before adapters.
14809
- if (!this.webToken || !this.viewToken)
14810
- this.initializeWebAuthTokens();
14811
- this.healthServer = createServer((req, res) => {
16623
+ /** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
16624
+ dispatchWebHttp(req, res, port) {
16625
+ try {
14812
16626
  res.setHeader("Content-Type", "application/json");
14813
16627
  // No Referer to a tunnel host, an upstream proxy, or any page linked from
14814
16628
  // the panel — the dashboard URL is itself a credential-bearing address.
@@ -14820,7 +16634,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14820
16634
  // Before any route, /health and /agent included: loopback binding does not
14821
16635
  // stop DNS rebinding, and the Host the browser sends is the one thing a
14822
16636
  // rebinding page cannot change.
14823
- if (!isHostAllowed(req.headers.host, allowedHostNames(this.fleetConfig))) {
16637
+ if (!gatewayRequestContext(req) && !isHostAllowed(req.headers.host, allowedHostNames(this.fleetConfig))) {
14824
16638
  this.noteRejectedHost(req.headers.host);
14825
16639
  res.writeHead(403);
14826
16640
  res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
@@ -14835,43 +16649,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14835
16649
  res.end();
14836
16650
  return;
14837
16651
  }
14838
- // Public health probe — no auth required.
14839
- if (req.method === "GET" && req.url === "/health") {
14840
- // fallthrough to existing handler below
14841
- }
14842
- else if (req.method === "POST" && req.url === "/agent") {
14843
- // /agent handles its own instance-level auth via X-Agend-Instance-Token
14844
- }
14845
- else if (isViewPath(new URL(req.url ?? "/", `http://localhost:${port}`).pathname)) {
14846
- // /view routes accept the read-only view.token (or web.token) and do
14847
- // their own per-method auth in view-api.ts — skip the web-token gate.
14848
- }
14849
- else if (isUsagePath(new URL(req.url ?? "/", `http://localhost:${port}`).pathname)) {
14850
- // /api/ai-usage is read-only GET data for the /view Usage panel — open
14851
- // like the other /view data routes (usage-api.ts rejects non-GET).
16652
+ // Public: the health probe, /agent (instance-token auth of its own), the
16653
+ // sign-in surface, and /view's reads unless web.view_access says otherwise.
16654
+ if (bypassesWebGate(req, requestPath, this.fleetConfig, p => !gatewayRequestContext(req) && (isViewPath(p) || isUsagePath(p)))) {
16655
+ // fall through to the handlers below
14852
16656
  }
14853
16657
  else {
14854
16658
  // All other endpoints require a session cookie or an X-Agend-Token
14855
- // header; a `?token=` in the URL is only redeemed for a cookie on a GET.
16659
+ // header; a `?token=` in the URL is not a credential.
14856
16660
  // /ui/* will also re-check in web-api.ts, which is harmless.
14857
16661
  const parsedUrl = new URL(req.url ?? "/", `http://localhost:${port}`);
14858
- const decision = decideWebGate(req, parsedUrl, this.webToken);
16662
+ const decision = decideWebGate(req, parsedUrl, this.webToken, this.webSessions);
14859
16663
  if (decision.kind === "reject") {
16664
+ // A browser navigating to a panel with no cookie gets the sign-in page,
16665
+ // not a JSON error: a SameSite=Strict cookie is not sent on a link
16666
+ // followed from a chat app, and the page can tell "no session" from "cookie
16667
+ // not sent" by asking from inside the site. API callers still get JSON.
16668
+ if (decision.reason === "no-credential" && req.method === "GET"
16669
+ && String(req.headers.accept ?? "").includes("text/html")
16670
+ && isWebPageNavigation(requestPath)) {
16671
+ serveSigninPage(res, 401);
16672
+ return;
16673
+ }
14860
16674
  res.writeHead(decision.status);
14861
16675
  res.end(JSON.stringify({ error: decision.message }));
14862
16676
  return;
14863
16677
  }
14864
- if (decision.kind === "exchange") {
14865
- res.setHeader("Set-Cookie", decision.setCookie);
14866
- res.setHeader("Location", decision.location);
14867
- // A cached redirect would replay a Set-Cookie for a rotated token.
14868
- res.setHeader("Cache-Control", "no-store");
14869
- res.writeHead(302);
14870
- // Browsers follow the Location; a script that does not gets told why
14871
- // its URL token stopped being echoed back as data.
14872
- res.end(JSON.stringify({ redirect: decision.location }));
14873
- return;
14874
- }
14875
16678
  }
14876
16679
  if (req.method === "GET" && req.url === "/health") {
14877
16680
  const health = this.getFleetHealth();
@@ -14971,12 +16774,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14971
16774
  ...this.instancePresentation(inst.name),
14972
16775
  };
14973
16776
  });
14974
- res.setHeader("Access-Control-Allow-Origin", "*");
16777
+ if (!gatewayRequestContext(req))
16778
+ res.setHeader("Access-Control-Allow-Origin", "*");
14975
16779
  res.writeHead(200);
14976
16780
  res.end(JSON.stringify({
14977
16781
  ...sysInfo,
14978
16782
  version: this.currentVersion,
14979
16783
  instances: enriched,
16784
+ publicLink: this.getPublicWebStatus(),
14980
16785
  }));
14981
16786
  }
14982
16787
  catch (err) {
@@ -15001,7 +16806,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15001
16806
  sinceIso = new Date(Date.now() - val * unit).toISOString();
15002
16807
  }
15003
16808
  const rows = this.eventLog?.listActivity({ since: sinceIso, limit: parseInt(limitParam, 10) }) ?? [];
15004
- res.setHeader("Access-Control-Allow-Origin", "*");
16809
+ if (!gatewayRequestContext(req))
16810
+ res.setHeader("Access-Control-Allow-Origin", "*");
15005
16811
  res.writeHead(200);
15006
16812
  res.end(JSON.stringify(rows));
15007
16813
  return;
@@ -15091,17 +16897,52 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15091
16897
  }
15092
16898
  // ── Web UI endpoints (delegated to web-api.ts) ─────
15093
16899
  const url = new URL(req.url ?? "/", `http://localhost:${port}`);
15094
- if (handleViewRequest(req, res, url, this))
15095
- return;
15096
- if (handleUsageRequest(req, res, url, this))
15097
- return;
15098
- if (handleSettingsRequest(req, res, url, this))
15099
- return;
15100
- if (handleWebRequest(req, res, url, this))
16900
+ // A handler that throws synchronously answers this request with a 500; it must never reach the process's
16901
+ // uncaughtException handler, which stops the whole fleet (#1252 review: one file name did that).
16902
+ try {
16903
+ if (handleAuthRequest(req, res, url, this))
16904
+ return;
16905
+ if (handleViewRequest(req, res, url, this))
16906
+ return;
16907
+ if (handleUsageRequest(req, res, url, this))
16908
+ return;
16909
+ const settingsNext = (request, response, address) => handleSettingsRequest(request, response, address, this)
16910
+ || handleWebRequest(request, response, address, this);
16911
+ if (this.settingsGate().handle(req, res, url, settingsNext))
16912
+ return;
16913
+ if (settingsNext(req, res, url))
16914
+ return;
16915
+ }
16916
+ catch (err) {
16917
+ this.logger.error({ err: err?.message, path: url.pathname }, "Web request handler threw");
16918
+ if (!res.headersSent) {
16919
+ res.writeHead(500);
16920
+ res.end(JSON.stringify({ error: "internal error" }));
16921
+ }
16922
+ else
16923
+ res.destroy();
15101
16924
  return;
16925
+ }
15102
16926
  res.writeHead(404);
15103
16927
  res.end(JSON.stringify({ error: "not found" }));
15104
- });
16928
+ }
16929
+ catch {
16930
+ if (!res.headersSent) {
16931
+ res.writeHead(400);
16932
+ res.end(JSON.stringify({ error: "invalid request" }));
16933
+ }
16934
+ else
16935
+ res.destroy();
16936
+ }
16937
+ }
16938
+ startHealthServer(port) {
16939
+ this.startedAt = Date.now();
16940
+ this.healthServerListening = false;
16941
+ this.healthPortRetried = false;
16942
+ // Defensive for direct/unit callers; normal startup initializes these before adapters.
16943
+ if (!this.webToken || !this.webSessions)
16944
+ this.initializeWebAuthTokens();
16945
+ this.healthServer = createServer((req, res) => this.dispatchWebHttp(req, res, port));
15105
16946
  const markListening = (afterTakeover = false) => {
15106
16947
  this.healthServerListening = true;
15107
16948
  this.logger.info({ port }, afterTakeover
@@ -15112,6 +16953,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15112
16953
  // `/dashboard` and `agend web` are the ways to get an authorized link.
15113
16954
  this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /dashboard or `agend web`)");
15114
16955
  this.logger.info({ url: `http://localhost:${port}/view` }, "Web View available");
16956
+ // #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
16957
+ const bound = this.healthServer?.address();
16958
+ this.startPreviewListener(port, bound && typeof bound === "object" ? bound.port : port);
15115
16959
  };
15116
16960
  this.healthServer.on("error", (err) => {
15117
16961
  this.healthServerListening = false;
@@ -15165,6 +17009,74 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15165
17009
  });
15166
17010
  this.healthServer.listen(port, "127.0.0.1", () => markListening());
15167
17011
  }
17012
+ /**
17013
+ * #1306: the preview listener, beside the web listener and closed with it. When it cannot listen (the port is
17014
+ * taken, web.preview: false), cards show Source and Download only; nothing else changes.
17015
+ */
17016
+ startPreviewListener(requestedPort, boundPort) {
17017
+ this.stopPreviewListener();
17018
+ this.previewPorts = { requested: requestedPort, bound: boundPort };
17019
+ this.previewInputs = this.previewInputsSignature();
17020
+ // web.preview is hot; web.preview_port and web.preview_origin are startup-only (STARTUP_ONLY_FLEET_KEYS): they
17021
+ // come from the configuration this process started on, so a reload that changes them reports "restart required"
17022
+ // and a hot re-enable never half-applies them.
17023
+ const started = (this.startupFleetConfig ?? this.fleetConfig)?.web;
17024
+ const settings = previewSettings({ preview: this.fleetConfig?.web?.preview, preview_port: started?.preview_port, preview_origin: started?.preview_origin }, requestedPort);
17025
+ if (!settings.enabled)
17026
+ return;
17027
+ if (settings.port === null) {
17028
+ this.logger.warn({ health_port: requestedPort }, "No port for the HTML preview listener (health_port is the highest port) — set web.preview_port; previews are off");
17029
+ return;
17030
+ }
17031
+ const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig });
17032
+ this.previewListener = listener;
17033
+ listener.server.on("error", (err) => {
17034
+ if (this.previewListener !== listener)
17035
+ return;
17036
+ this.logger.warn({ code: err.code, port: settings.port }, "Preview listener unavailable; HTML previews are off");
17037
+ this.previewListening = false;
17038
+ this.previewListener = null;
17039
+ });
17040
+ // An optional listener never takes the fleet down: a port Node refuses outright throws here, synchronously.
17041
+ try {
17042
+ listener.server.listen(settings.port, "127.0.0.1", () => { if (this.previewListener === listener)
17043
+ this.previewListening = true; });
17044
+ }
17045
+ catch (err) {
17046
+ this.logger.warn({ err: err.message, port: settings.port }, "Preview listener cannot listen; HTML previews are off");
17047
+ this.previewListening = false;
17048
+ this.previewListener = null;
17049
+ }
17050
+ }
17051
+ /** What a reload may change hot: web.preview, and the dashboard names (the shim's allow-list and frame-ancestors). */
17052
+ previewInputsSignature() {
17053
+ const c = this.fleetConfig;
17054
+ return JSON.stringify({ p: c?.web?.preview ?? null, h: c?.hostname ?? null, a: c?.web?.allowed_hosts ?? null });
17055
+ }
17056
+ /**
17057
+ * After a config reload: web.preview true→false stops the listener at once (/ui then offers no origin, boot or
17058
+ * frame-src), false→true starts it, and a change of dashboard names rebuilds it (a new boot id). preview_port and
17059
+ * preview_origin are not adopted here: they are startup-only, and the reload reports "restart required".
17060
+ */
17061
+ reconcilePreviewListener() {
17062
+ if (!this.previewPorts || this.previewInputsSignature() === this.previewInputs)
17063
+ return;
17064
+ this.logger.info({}, "HTML preview settings changed — rebuilding the preview listener");
17065
+ this.startPreviewListener(this.previewPorts.requested, this.previewPorts.bound);
17066
+ }
17067
+ stopPreviewListener() {
17068
+ this.previewListening = false;
17069
+ this.previewListener?.close();
17070
+ this.previewListener = null;
17071
+ }
17072
+ /** For one /ui load: the preview origin it may frame, and the listener's boot id (see web-preview.ts). */
17073
+ previewForUi(hostHeader, secure) {
17074
+ const listener = this.previewListening ? this.previewListener : null;
17075
+ const decided = previewAvailability(listener ? listener.settings : null, hostHeader, secure, listener ? listener.origins : undefined);
17076
+ if (!listener && this.fleetConfig?.web?.preview !== false)
17077
+ decided.reason = "Previews are not available on this fleet right now.";
17078
+ return { ...decided, boot: listener ? listener.bootId : null };
17079
+ }
15168
17080
  getUiStatus() {
15169
17081
  return measureSyncWork("fleet.uiStatus", () => this.getUiStatusSync());
15170
17082
  }
@@ -15221,6 +17133,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15221
17133
  name,
15222
17134
  display_name: display_name || undefined,
15223
17135
  status: this.getInstanceStatus(name),
17136
+ // `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
17137
+ // what the dashboard's activity events carry) come from instancePresentation (#1212).
15224
17138
  context_pct,
15225
17139
  cost,
15226
17140
  model,
@@ -15233,6 +17147,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15233
17147
  });
15234
17148
  return {
15235
17149
  instances,
17150
+ publicLink: this.getPublicWebStatus(),
15236
17151
  uptime: Math.floor((Date.now() - this.startedAt) / 1000),
15237
17152
  };
15238
17153
  }