@songsid/agend 2.3.0-beta.24 → 2.3.0-beta.26

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 (419) hide show
  1. package/dist/adapter-world.js.map +1 -1
  2. package/dist/agend-home.d.ts +5 -0
  3. package/dist/agend-home.js +7 -0
  4. package/dist/agend-home.js.map +1 -0
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.js +8 -0
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-file-read.js +1 -1
  9. package/dist/agent-file-read.js.map +1 -1
  10. package/dist/agent-whats-new.js.map +1 -1
  11. package/dist/apply-job.js.map +1 -1
  12. package/dist/atomic-write.js.map +1 -1
  13. package/dist/auth-api.d.ts +5 -0
  14. package/dist/auth-api.js +21 -0
  15. package/dist/auth-api.js.map +1 -1
  16. package/dist/authority/authority-state.d.ts +4 -0
  17. package/dist/authority/authority-state.js +17 -8
  18. package/dist/authority/authority-state.js.map +1 -1
  19. package/dist/authority/session-authorizations.js.map +1 -1
  20. package/dist/authority/web-authority.d.ts +14 -6
  21. package/dist/authority/web-authority.js +19 -5
  22. package/dist/authority/web-authority.js.map +1 -1
  23. package/dist/backend/antigravity.d.ts +2 -2
  24. package/dist/backend/antigravity.js +2 -1
  25. package/dist/backend/antigravity.js.map +1 -1
  26. package/dist/backend/claude-code.js.map +1 -1
  27. package/dist/backend/codex-package.js.map +1 -1
  28. package/dist/backend/codex-session-lookup.js.map +1 -1
  29. package/dist/backend/codex.js +1 -1
  30. package/dist/backend/codex.js.map +1 -1
  31. package/dist/backend/effort-metadata.js.map +1 -1
  32. package/dist/backend/grok.js.map +1 -1
  33. package/dist/backend/kiro-engine-ledger.js.map +1 -1
  34. package/dist/backend/kiro-kas-store.js.map +1 -1
  35. package/dist/backend/kiro-v2-store.js.map +1 -1
  36. package/dist/backend/kiro-v3-identity.js.map +1 -1
  37. package/dist/backend/kiro.js.map +1 -1
  38. package/dist/backend/launch-effort.js.map +1 -1
  39. package/dist/backend/mock.js.map +1 -1
  40. package/dist/backend/muse.js.map +1 -1
  41. package/dist/backend/opencode-permission.js.map +1 -1
  42. package/dist/backend/opencode.js.map +1 -1
  43. package/dist/backend/session-signals.js.map +1 -1
  44. package/dist/backend-availability.js.map +1 -1
  45. package/dist/backend-command.js.map +1 -1
  46. package/dist/backend-outage.js.map +1 -1
  47. package/dist/binary-probe.js.map +1 -1
  48. package/dist/bug-report.js.map +1 -1
  49. package/dist/bundled-skills.js.map +1 -1
  50. package/dist/cache-service.js +1 -1
  51. package/dist/cache-service.js.map +1 -1
  52. package/dist/channel/adapters/discord.js +29 -4
  53. package/dist/channel/adapters/discord.js.map +1 -1
  54. package/dist/channel/adapters/telegram.d.ts +6 -0
  55. package/dist/channel/adapters/telegram.js +54 -14
  56. package/dist/channel/adapters/telegram.js.map +1 -1
  57. package/dist/channel/mcp-server.js +10 -1
  58. package/dist/channel/mcp-server.js.map +1 -1
  59. package/dist/channel/mcp-tools.js +2 -2
  60. package/dist/channel/mcp-tools.js.map +1 -1
  61. package/dist/channel/message-queue.js.map +1 -1
  62. package/dist/channel/sticker-download.js.map +1 -1
  63. package/dist/channel/tool-router.js.map +1 -1
  64. package/dist/classic-channel-manager.js +3 -2
  65. package/dist/classic-channel-manager.js.map +1 -1
  66. package/dist/cli-entry.js.map +1 -1
  67. package/dist/cli-flags.js.map +1 -1
  68. package/dist/cli.js +15 -9
  69. package/dist/cli.js.map +1 -1
  70. package/dist/command-table.js.map +1 -1
  71. package/dist/compact-watch.js.map +1 -1
  72. package/dist/completion-install.js.map +1 -1
  73. package/dist/completion.js.map +1 -1
  74. package/dist/config-validator.js +20 -6
  75. package/dist/config-validator.js.map +1 -1
  76. package/dist/config.js +11 -3
  77. package/dist/config.js.map +1 -1
  78. package/dist/connection-secrets.js.map +1 -1
  79. package/dist/context-guardian.js.map +1 -1
  80. package/dist/coredump-filter.js.map +1 -1
  81. package/dist/cpu-profile.js.map +1 -1
  82. package/dist/cross-instance-envelope.js.map +1 -1
  83. package/dist/daemon.d.ts +3 -0
  84. package/dist/daemon.js +73 -3
  85. package/dist/daemon.js.map +1 -1
  86. package/dist/daily-summary.js.map +1 -1
  87. package/dist/darwin-memory.js.map +1 -1
  88. package/dist/dashboard-live.d.ts +8 -0
  89. package/dist/dashboard-live.js +35 -0
  90. package/dist/dashboard-live.js.map +1 -0
  91. package/dist/dashboard-permissions.d.ts +69 -0
  92. package/dist/dashboard-permissions.js +138 -0
  93. package/dist/dashboard-permissions.js.map +1 -0
  94. package/dist/decision-list.js.map +1 -1
  95. package/dist/delivery-consumed-watch.js.map +1 -1
  96. package/dist/delivery-outbox.js.map +1 -1
  97. package/dist/delivery-outbox.test.js.map +1 -1
  98. package/dist/delivery-reconciliation.test.js.map +1 -1
  99. package/dist/diagnostic-redaction.js.map +1 -1
  100. package/dist/doctor.js.map +1 -1
  101. package/dist/e2e-isolation.js.map +1 -1
  102. package/dist/event-log.js.map +1 -1
  103. package/dist/export-import.js +3 -2
  104. package/dist/export-import.js.map +1 -1
  105. package/dist/fleet-control-audit.js.map +1 -1
  106. package/dist/fleet-label.js.map +1 -1
  107. package/dist/fleet-lock.js +3 -2
  108. package/dist/fleet-lock.js.map +1 -1
  109. package/dist/fleet-manager.d.ts +77 -6
  110. package/dist/fleet-manager.js +488 -147
  111. package/dist/fleet-manager.js.map +1 -1
  112. package/dist/fleet-process-handlers.js +3 -2
  113. package/dist/fleet-process-handlers.js.map +1 -1
  114. package/dist/fleet-startup.d.ts +14 -0
  115. package/dist/fleet-startup.js +30 -0
  116. package/dist/fleet-startup.js.map +1 -0
  117. package/dist/fleet-traffic.js.map +1 -1
  118. package/dist/forged-envelope-scan.d.ts +0 -20
  119. package/dist/forged-envelope-scan.js +3 -2
  120. package/dist/forged-envelope-scan.js.map +1 -1
  121. package/dist/full-restart.js.map +1 -1
  122. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +2 -0
  123. package/dist/general-knowledge/skills/web-appearance/SKILL.md +2 -0
  124. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +36 -12
  125. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +45 -14
  126. package/dist/host-memory.js.map +1 -1
  127. package/dist/instance-config-impact.js +6 -0
  128. package/dist/instance-config-impact.js.map +1 -1
  129. package/dist/instance-lifecycle.js +1 -1
  130. package/dist/instance-lifecycle.js.map +1 -1
  131. package/dist/instructions.d.ts +2 -0
  132. package/dist/instructions.js +6 -2
  133. package/dist/instructions.js.map +1 -1
  134. package/dist/kiro-compact-pane.js.map +1 -1
  135. package/dist/kiro-db-reader.js.map +1 -1
  136. package/dist/kiro-engine-status.js.map +1 -1
  137. package/dist/kiro-log-gate.js.map +1 -1
  138. package/dist/kiro-transcript-lane.js.map +1 -1
  139. package/dist/locale.js +22 -0
  140. package/dist/locale.js.map +1 -1
  141. package/dist/log-service.js.map +1 -1
  142. package/dist/log-steps.js.map +1 -1
  143. package/dist/logger.js +3 -2
  144. package/dist/logger.js.map +1 -1
  145. package/dist/login-controller.js.map +1 -1
  146. package/dist/login-manager.js.map +1 -1
  147. package/dist/login-shell.js.map +1 -1
  148. package/dist/memory-pressure.js.map +1 -1
  149. package/dist/muse-usage-relay.js.map +1 -1
  150. package/dist/needs-you-hub.js.map +1 -1
  151. package/dist/needs-you-live.js.map +1 -1
  152. package/dist/network-family.js.map +1 -1
  153. package/dist/operator-control-paths.js +3 -4
  154. package/dist/operator-control-paths.js.map +1 -1
  155. package/dist/outbound-handlers.js.map +1 -1
  156. package/dist/outbound-schemas.d.ts +68 -68
  157. package/dist/outbound-schemas.js +1 -1
  158. package/dist/outbound-schemas.js.map +1 -1
  159. package/dist/outbox-open-error.js.map +1 -1
  160. package/dist/pane-context-cache.js.map +1 -1
  161. package/dist/paths.js.map +1 -1
  162. package/dist/private-dir.js.map +1 -1
  163. package/dist/probe-worker-pool.js.map +1 -1
  164. package/dist/profile-control.js.map +1 -1
  165. package/dist/prompt-file-ref.js.map +1 -1
  166. package/dist/provider-probe.js.map +1 -1
  167. package/dist/provider-secret-registry.js.map +1 -1
  168. package/dist/public-link-auto-open.d.ts +32 -0
  169. package/dist/public-link-auto-open.js +86 -0
  170. package/dist/public-link-auto-open.js.map +1 -0
  171. package/dist/public-link-owner.d.ts +10 -0
  172. package/dist/public-link-owner.js +36 -0
  173. package/dist/public-link-owner.js.map +1 -0
  174. package/dist/public-link-progress.js.map +1 -1
  175. package/dist/public-preview.js.map +1 -1
  176. package/dist/public-web-gateway.js +4 -2
  177. package/dist/public-web-gateway.js.map +1 -1
  178. package/dist/public-web-link.d.ts +35 -1
  179. package/dist/public-web-link.js +142 -43
  180. package/dist/public-web-link.js.map +1 -1
  181. package/dist/quickstart-api.js.map +1 -1
  182. package/dist/quickstart.d.ts +5 -0
  183. package/dist/quickstart.js +25 -16
  184. package/dist/quickstart.js.map +1 -1
  185. package/dist/reply-buttons-controller.js.map +1 -1
  186. package/dist/reply-dedup.js.map +1 -1
  187. package/dist/resource-report.js.map +1 -1
  188. package/dist/restart-guard.js.map +1 -1
  189. package/dist/restart-progress.js.map +1 -1
  190. package/dist/rollout-index.js.map +1 -1
  191. package/dist/runtime-cpu-profile.js.map +1 -1
  192. package/dist/scheduler/db.js.map +1 -1
  193. package/dist/scheduler/scheduler.js.map +1 -1
  194. package/dist/scheduler/task-board-text.js +16 -11
  195. package/dist/scheduler/task-board-text.js.map +1 -1
  196. package/dist/scheduler/task-board.d.ts +50 -10
  197. package/dist/scheduler/task-board.js +138 -44
  198. package/dist/scheduler/task-board.js.map +1 -1
  199. package/dist/scheduler/types.d.ts +1 -1
  200. package/dist/sd-notify.js.map +1 -1
  201. package/dist/search/search-index.js.map +1 -1
  202. package/dist/secret-file.js.map +1 -1
  203. package/dist/secret-store.js.map +1 -1
  204. package/dist/service-installer.d.ts +7 -1
  205. package/dist/service-installer.js +13 -1
  206. package/dist/service-installer.js.map +1 -1
  207. package/dist/service-plan.d.ts +33 -4
  208. package/dist/service-plan.js +177 -8
  209. package/dist/service-plan.js.map +1 -1
  210. package/dist/session-oom.d.ts +21 -0
  211. package/dist/session-oom.js +139 -0
  212. package/dist/session-oom.js.map +1 -0
  213. package/dist/settings-api.js +6 -1
  214. package/dist/settings-api.js.map +1 -1
  215. package/dist/settings-baseline.js.map +1 -1
  216. package/dist/settings-change.d.ts +5 -0
  217. package/dist/settings-change.js +71 -1
  218. package/dist/settings-change.js.map +1 -1
  219. package/dist/settings-confirmation.d.ts +4 -0
  220. package/dist/settings-confirmation.js +2 -0
  221. package/dist/settings-confirmation.js.map +1 -1
  222. package/dist/settings-control.js.map +1 -1
  223. package/dist/settings-effect.js +6 -1
  224. package/dist/settings-effect.js.map +1 -1
  225. package/dist/settings-http-confirmation.js +1 -0
  226. package/dist/settings-http-confirmation.js.map +1 -1
  227. package/dist/settings-instance-patch.js +13 -1
  228. package/dist/settings-instance-patch.js.map +1 -1
  229. package/dist/settings-transaction.js.map +1 -1
  230. package/dist/setup-auth.js.map +1 -1
  231. package/dist/setup-host.d.ts +5 -1
  232. package/dist/setup-host.js +8 -1
  233. package/dist/setup-host.js.map +1 -1
  234. package/dist/setup-wizard.js +5 -2
  235. package/dist/setup-wizard.js.map +1 -1
  236. package/dist/spawn-gate.d.ts +4 -0
  237. package/dist/spawn-gate.js +15 -5
  238. package/dist/spawn-gate.js.map +1 -1
  239. package/dist/startup-memory-deadline.d.ts +23 -0
  240. package/dist/startup-memory-deadline.js +55 -0
  241. package/dist/startup-memory-deadline.js.map +1 -0
  242. package/dist/statusline-watcher.js.map +1 -1
  243. package/dist/storm-window.js.map +1 -1
  244. package/dist/systemd-peer-bus.js.map +1 -1
  245. package/dist/target-queue-worker.js.map +1 -1
  246. package/dist/task-board-summary.d.ts +4 -4
  247. package/dist/task-board-summary.js +4 -4
  248. package/dist/task-board-summary.js.map +1 -1
  249. package/dist/task-board-tool.js +3 -2
  250. package/dist/task-board-tool.js.map +1 -1
  251. package/dist/task-board-waker.js +8 -4
  252. package/dist/task-board-waker.js.map +1 -1
  253. package/dist/tips.js.map +1 -1
  254. package/dist/tmux-control.js.map +1 -1
  255. package/dist/tmux-exec-helper.js.map +1 -1
  256. package/dist/tmux-manager.d.ts +1 -0
  257. package/dist/tmux-manager.js +2 -1
  258. package/dist/tmux-manager.js.map +1 -1
  259. package/dist/tmux-read.js +1 -1
  260. package/dist/tmux-read.js.map +1 -1
  261. package/dist/tool-permissions.js.map +1 -1
  262. package/dist/tool-progress.js.map +1 -1
  263. package/dist/topic-archiver.js.map +1 -1
  264. package/dist/topic-commands.js.map +1 -1
  265. package/dist/topic-link.js.map +1 -1
  266. package/dist/transcript-delta-reader.js.map +1 -1
  267. package/dist/transcript-jsonl.js.map +1 -1
  268. package/dist/transcript-monitor.js.map +1 -1
  269. package/dist/transcript-sources.js.map +1 -1
  270. package/dist/tunnel/cloudflared-install.js.map +1 -1
  271. package/dist/tunnel/cloudflared.js.map +1 -1
  272. package/dist/tunnel/lease.js +3 -2
  273. package/dist/tunnel/lease.js.map +1 -1
  274. package/dist/tunnel/manager.js.map +1 -1
  275. package/dist/tunnel/public-fetch.js.map +1 -1
  276. package/dist/tunnel/purpose-lane.js.map +1 -1
  277. package/dist/tunnel/types.js.map +1 -1
  278. package/dist/types.d.ts +26 -2
  279. package/dist/ui/chat-render.js +1 -15
  280. package/dist/ui/chat-store.js +171 -61
  281. package/dist/ui/chat-strings.js +7 -2
  282. package/dist/ui/chat-thread.js +48 -1
  283. package/dist/ui/fleet-canvas-layout.js +3 -0
  284. package/dist/ui/fleet-canvas.js +23 -20
  285. package/dist/ui/fleet-office-editor.js +149 -0
  286. package/dist/ui/fleet-office-model.js +358 -0
  287. package/dist/ui/fleet-office-pack.js +199 -0
  288. package/dist/ui/fleet-office-skin.js +474 -0
  289. package/dist/ui/fleet-office-store.js +49 -0
  290. package/dist/ui/fleet-office-zones.js +177 -0
  291. package/dist/ui/fleet-office.js +434 -0
  292. package/dist/ui/fleet-org.js +18 -5
  293. package/dist/ui/guest-refused.html +41 -0
  294. package/dist/ui/image-lightbox.js +9 -3
  295. package/dist/ui/panel-chat.js +14 -13
  296. package/dist/ui/panel-settings.js +30 -12
  297. package/dist/ui/settings-dialogs.js +15 -6
  298. package/dist/ui/settings-model.js +40 -3
  299. package/dist/ui/settings-strings.js +38 -0
  300. package/dist/ui/settings-web-authority.js +7 -2
  301. package/dist/ui/shared/agend-auth.js +2 -0
  302. package/dist/ui/shared/app-i18n.js +2 -2
  303. package/dist/ui/shared/app-prefs.js +2 -0
  304. package/dist/ui/shared/app-shell.js +12 -0
  305. package/dist/ui/shared/app-store.js +1 -0
  306. package/dist/ui/shared/app-stream.js +2 -1
  307. package/dist/ui/shared/app.css +132 -3
  308. package/dist/ui/shared/guest-refused.js +35 -0
  309. package/dist/ui/shared/header-tools.js +2 -2
  310. package/dist/ui/shared/instance-nav.js +25 -8
  311. package/dist/ui/shared/ui-dialog.js +2 -2
  312. package/dist/ui/shared/usage-dialog.js +152 -6
  313. package/dist/ui/shared/view-strings.js +20 -0
  314. package/dist/ui/shared/web-authorize.js +3 -1
  315. package/dist/update-check.js.map +1 -1
  316. package/dist/update-dispatch.js.map +1 -1
  317. package/dist/update-install.js.map +1 -1
  318. package/dist/update-launch.js.map +1 -1
  319. package/dist/update-marker.js.map +1 -1
  320. package/dist/update-preflight.js.map +1 -1
  321. package/dist/usage/format-rich.js.map +1 -1
  322. package/dist/usage/providers.d.ts +8 -0
  323. package/dist/usage/providers.js +6 -2
  324. package/dist/usage/providers.js.map +1 -1
  325. package/dist/usage/statusline-usage.js.map +1 -1
  326. package/dist/usage/usage-api.js +2 -1
  327. package/dist/usage/usage-api.js.map +1 -1
  328. package/dist/usage-report.js.map +1 -1
  329. package/dist/usage-rollup.js +1 -1
  330. package/dist/usage-rollup.js.map +1 -1
  331. package/dist/user-systemd.js.map +1 -1
  332. package/dist/view-api.js +1 -1
  333. package/dist/view-api.js.map +1 -1
  334. package/dist/wake-coordinator.js.map +1 -1
  335. package/dist/web-ambient.js.map +1 -1
  336. package/dist/web-api.d.ts +15 -2
  337. package/dist/web-api.js +174 -25
  338. package/dist/web-api.js.map +1 -1
  339. package/dist/web-app-icon.js.map +1 -1
  340. package/dist/web-appearance-policy.js.map +1 -1
  341. package/dist/web-appearance-tools.js.map +1 -1
  342. package/dist/web-appearance-view.js.map +1 -1
  343. package/dist/web-appearance.js.map +1 -1
  344. package/dist/web-auth.js +5 -4
  345. package/dist/web-auth.js.map +1 -1
  346. package/dist/web-backend-login.js.map +1 -1
  347. package/dist/web-card.js.map +1 -1
  348. package/dist/web-channel-echo.js.map +1 -1
  349. package/dist/web-chat-history.d.ts +41 -11
  350. package/dist/web-chat-history.js +93 -22
  351. package/dist/web-chat-history.js.map +1 -1
  352. package/dist/web-commands.js.map +1 -1
  353. package/dist/web-effect-bundle.d.ts +2 -2
  354. package/dist/web-effect-library.js.map +1 -1
  355. package/dist/web-effect-playback.js.map +1 -1
  356. package/dist/web-effect-profiles.d.ts +16 -16
  357. package/dist/web-effect-profiles.js +3 -2
  358. package/dist/web-effect-profiles.js.map +1 -1
  359. package/dist/web-effect-service.js.map +1 -1
  360. package/dist/web-effect-tools.js.map +1 -1
  361. package/dist/web-effect-trials.js.map +1 -1
  362. package/dist/web-effect-upload.js.map +1 -1
  363. package/dist/web-effect.d.ts +7 -7
  364. package/dist/web-effect.js.map +1 -1
  365. package/dist/web-experimental.js.map +1 -1
  366. package/dist/web-guest-access.js +1 -1
  367. package/dist/web-guest-access.js.map +1 -1
  368. package/dist/web-guest-rooms.js.map +1 -1
  369. package/dist/web-host-guard.js.map +1 -1
  370. package/dist/web-icons.js.map +1 -1
  371. package/dist/web-identity-color.js.map +1 -1
  372. package/dist/web-images.js.map +1 -1
  373. package/dist/web-libavoid.js.map +1 -1
  374. package/dist/web-login.js.map +1 -1
  375. package/dist/web-mentions.js.map +1 -1
  376. package/dist/web-mod-agent.js.map +1 -1
  377. package/dist/web-mod-store.js.map +1 -1
  378. package/dist/web-office-store.d.ts +104 -0
  379. package/dist/web-office-store.js +236 -0
  380. package/dist/web-office-store.js.map +1 -0
  381. package/dist/web-owner-css-store.js.map +1 -1
  382. package/dist/web-owner-css.js.map +1 -1
  383. package/dist/web-png.js.map +1 -1
  384. package/dist/web-prefs.js +2 -0
  385. package/dist/web-prefs.js.map +1 -1
  386. package/dist/web-profile.js.map +1 -1
  387. package/dist/web-reactions.d.ts +2 -1
  388. package/dist/web-request-context.d.ts +5 -0
  389. package/dist/web-request-context.js.map +1 -1
  390. package/dist/web-returning-device.js +1 -1
  391. package/dist/web-returning-device.js.map +1 -1
  392. package/dist/web-schedules.js.map +1 -1
  393. package/dist/web-send-dedupe.d.ts +36 -0
  394. package/dist/web-send-dedupe.js +69 -0
  395. package/dist/web-send-dedupe.js.map +1 -0
  396. package/dist/web-session-authorization.js.map +1 -1
  397. package/dist/web-session.js +1 -1
  398. package/dist/web-session.js.map +1 -1
  399. package/dist/web-settings-patch.d.ts +1 -0
  400. package/dist/web-settings-patch.js +6 -4
  401. package/dist/web-settings-patch.js.map +1 -1
  402. package/dist/web-settings-tools.js.map +1 -1
  403. package/dist/web-terminal-http.js.map +1 -1
  404. package/dist/web-terminal.js.map +1 -1
  405. package/dist/web-theme-store.js.map +1 -1
  406. package/dist/web-upload.d.ts +5 -1
  407. package/dist/web-upload.js +6 -4
  408. package/dist/web-upload.js.map +1 -1
  409. package/dist/web-widget.js.map +1 -1
  410. package/dist/webhook-emitter.js.map +1 -1
  411. package/dist/windows-shortcuts.js +1 -1
  412. package/dist/windows-shortcuts.js.map +1 -1
  413. package/dist/workflow-templates/default.md +1 -1
  414. package/dist/ws-server.js.map +1 -1
  415. package/dist/yaml-shared-nodes.js.map +1 -1
  416. package/launcher/agend-install.cjs +281 -6
  417. package/launcher/private-home.cjs +43 -0
  418. package/package.json +3 -2
  419. package/templates/windows-shortcuts.ps1 +1 -1
@@ -13,7 +13,9 @@ import { handleWebIconRequest, useAppIconStore } from "./web-icons.js";
13
13
  import { AppIconStore } from "./web-app-icon.js";
14
14
  import { trackWebServer } from "./web-server-drain.js";
15
15
  import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
16
- import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
16
+ import { PublicWebLink, publicLinkNamedHostname, publicLinkSettings } from "./public-web-link.js";
17
+ import { PublicLinkAutoOpen } from "./public-link-auto-open.js";
18
+ import { clearPublicLinkOwner, readPublicLinkOwner, writePublicLinkOwner } from "./public-link-owner.js";
17
19
  import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
18
20
  import { CloudflaredProvider } from "./tunnel/cloudflared.js";
19
21
  import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
@@ -36,6 +38,7 @@ import { fleetLabel } from "./fleet-label.js";
36
38
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
37
39
  import { formatUpdateProgress } from "./update-progress.js";
38
40
  import { sdNotify, sdNotifyBlocking, setSdNotifyWarn } from "./sd-notify.js";
41
+ import { StartupMemoryDeadline, fleetStopConcurrency } from "./startup-memory-deadline.js";
39
42
  import { readFleetMemory } from "./process-memory.js";
40
43
  import { MemoryPressure } from "./memory-pressure.js";
41
44
  import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
@@ -77,7 +80,7 @@ import { routeToolCall } from "./channel/tool-router.js";
77
80
  import { Scheduler } from "./scheduler/index.js";
78
81
  import { boardActivity, boardCardLimit, boardToolAction } from "./task-board-tool.js";
79
82
  import { TaskBoardWaker } from "./task-board-waker.js";
80
- import { TaskBoardError, wrapUpGap } from "./scheduler/task-board.js";
83
+ import { TaskBoardError, waitsForPerson, wrapUpGap } from "./scheduler/task-board.js";
81
84
  import { escapeTelegramHtml, scheduleClock, scheduleRetryLabel } from "./scheduler/retry-label.js";
82
85
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
83
86
  import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, parseCompactCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineJson, statuslineModel } from "./topic-commands.js";
@@ -144,7 +147,7 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
144
147
  }
145
148
  return tasks;
146
149
  }
147
- import { handleWebRequest, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
150
+ import { handleWebRequest, webPeerOf, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
148
151
  import { LogService } from "./log-service.js";
149
152
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId, webChatFrom } from "./web-chat-history.js";
150
153
  import { WEB_ECHO_COPIES_MAX, WEB_MIRROR_TIMEOUT_MS, webReactionEmoji, webReactionProblem } from "./web-reactions.js";
@@ -154,6 +157,7 @@ import { OPERATOR, WebPrefsStore } from "./web-prefs.js";
154
157
  import { agendVersion } from "./agent-whats-new.js";
155
158
  import { OwnerCssStore } from "./web-owner-css-store.js";
156
159
  import { ThemeStore } from "./web-theme-store.js";
160
+ import { OfficeStore, OfficeWriteLimiter } from "./web-office-store.js";
157
161
  import { WebAppearance } from "./web-appearance.js";
158
162
  import { WebEffectLibrary } from "./web-effect-library.js";
159
163
  import { WebEffectTrials } from "./web-effect-trials.js";
@@ -235,12 +239,13 @@ import { CompactWatch, launchIdentity } from "./compact-watch.js";
235
239
  import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
236
240
  import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
237
241
  import { sharedRolloutIndex } from "./rollout-index.js";
242
+ import { TASK_BOARD_ON_NOTICE, dashboardRefusalIn, taskBoardSwitchedOn } from "./dashboard-permissions.js";
238
243
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
239
244
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken, sessionCookieUpgrade } from "./web-auth.js";
240
- import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
245
+ import { bypassesWebGate, handleAuthRequest, serveGuestRefusedPage, serveSigninPage } from "./auth-api.js";
241
246
  import { isWebPageNavigation } from "./web-shell-routes.js";
242
247
  import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
243
- import { authorityNow, classifyWebPeer, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
248
+ import { authorityNow, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
244
249
  import { AuthorityStateStore } from "./authority/authority-state.js";
245
250
  import { SessionAuthorizations } from "./authority/session-authorizations.js";
246
251
  import { SessionAuthorizationRequests } from "./web-session-authorization.js";
@@ -267,6 +272,7 @@ import { carryClaudeConversation } from "./backend/claude-carry.js";
267
272
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
268
273
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
269
274
  import { cleanupWatchVerdict, formatRestartProgressCompletion, RestartProgress, } from "./restart-progress.js";
275
+ import { FleetStartup } from "./fleet-startup.js";
270
276
  import { launchFullRestartHelper } from "./full-restart.js";
271
277
  import { fullReloadExitCode } from "./service-owner.js";
272
278
  import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
@@ -814,6 +820,7 @@ export class FleetManager {
814
820
  // Adapter isolation: track state per adapter for retry + visibility
815
821
  /** #2175: this boot's progress message, whose completion may still be owed by its background retry. */
816
822
  startupProgress = null;
823
+ startupDashboard = null;
817
824
  adapterState = new Map();
818
825
  adapterRetryInFlight = new Map();
819
826
  /** Web Settings secret rotation is deliberately separate from reconnect
@@ -895,6 +902,7 @@ export class FleetManager {
895
902
  /** Days of event/activity history to keep. */
896
903
  static EVENT_LOG_RETENTION_DAYS = 30;
897
904
  watchdogTimer = null;
905
+ startupMemoryDeadline = null;
898
906
  eventLoopWatch = null;
899
907
  startedAt = 0;
900
908
  stormOpenNotifyTimer = null;
@@ -930,6 +938,28 @@ export class FleetManager {
930
938
  webFiles = new WebFileLedger({ limits: () => uploadLimits(this.fleetConfig?.web) });
931
939
  /** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
932
940
  webReplyOrder = new Map();
941
+ /**
942
+ * #1774: an instance name's reply generation — moved on when a removal of it starts, so replies sent from then on (a
943
+ * same-name replacement's) are told from the removed instance's. `webChatRemovals`: once a removal succeeded, the
944
+ * generations and the daemon whose replies are no longer anyone's to show.
945
+ */
946
+ webChatGenerations = new Map();
947
+ webChatRemovals = new Map();
948
+ /** #1774: a removal under way — its replies' fate waits for its outcome (success: theirs no more; failure: theirs). */
949
+ webChatRemovalsPending = new Map();
950
+ webChatGeneration(name) { return this.webChatGenerations.get(name) ?? 0; }
951
+ /** #1774: who an instance's reply is from, taken when it is sent: the name's generation and its daemon then. */
952
+ webChatIdentity(name) { return { generation: this.webChatGeneration(name), daemon: this.daemons.get(name) }; }
953
+ /** #1774: the reply's instance was not removed (a removal that failed changes nothing). */
954
+ webChatIdentityCurrent(name, id) {
955
+ const removed = this.webChatRemovals.get(name);
956
+ return !removed || (id.generation >= removed.below && (removed.daemon === undefined || id.daemon !== removed.daemon));
957
+ }
958
+ /** #1774: the removal still deciding this reply's fate (it was sent by what is being removed), or null. */
959
+ webChatRemovalPending(name, id) {
960
+ const pending = this.webChatRemovalsPending.get(name);
961
+ return pending && (id.generation < pending.below || (pending.daemon !== undefined && id.daemon === pending.daemon)) ? pending.done : null;
962
+ }
933
963
  /** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
934
964
  webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
935
965
  /** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
@@ -1015,6 +1045,11 @@ export class FleetManager {
1015
1045
  // r4.1 Q3 (b): the card's limits are read from fleet.yaml at each upload (a change applies without a restart).
1016
1046
  return (this.themeStoreInstance ??= new ThemeStore(this.dataDir, { limits: () => effectiveAppearanceLimits(this.fleetConfig?.web?.appearance).limits, effectsDropped: ids => this.effectLibrary.collectUnreferenced(ids) }));
1017
1047
  }
1048
+ officeStoreInstance;
1049
+ /** Fleet → Org chart → Office: the offices people made (web-offices.json, beside web-themes.json). */
1050
+ get officeStore() { return (this.officeStoreInstance ??= new OfficeStore(this.dataDir)); }
1051
+ /** How often a person may save the offices (PUT /ui/offices); memory only, as the appearance limiter. */
1052
+ officeWrites = new OfficeWriteLimiter();
1018
1053
  /** #1629: the theme tools, a person's reset and lock, and the one appearance rate limiter (shared with effects). */
1019
1054
  get webAppearance() {
1020
1055
  return (this.webAppearanceService ??= new WebAppearance(this.themeStore, () => this.fleetConfig?.web ?? null, () => { try {
@@ -1362,7 +1397,13 @@ export class FleetManager {
1362
1397
  const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
1363
1398
  if (auth.kind !== "ok")
1364
1399
  return null;
1365
- const record = auth.session, gateway = !!gatewayRequestContext(req), peer = classifyWebPeer(req);
1400
+ // #2453 (§0.2): never over the public link — not even a named-link session that is web-verified there.
1401
+ if (gatewayRequestContext(req))
1402
+ return null;
1403
+ // …and never a change that turns the named-link option on: that one needs a General's card or the host.
1404
+ if (!store.selfConfirmable(id))
1405
+ return null;
1406
+ const record = auth.session, gateway = !!gatewayRequestContext(req), peer = webPeerOf(req);
1366
1407
  const verified = () => {
1367
1408
  const now = this.webAuthority({ session: record, gateway, peer });
1368
1409
  // A refusal carries no authority (kind "none"); the handle pins the session, and with it its one account.
@@ -1629,6 +1670,7 @@ export class FleetManager {
1629
1670
  this.spawnGate = new SpawnGate({
1630
1671
  storm: this.stormWindow,
1631
1672
  memoryPressure: this.memoryPressure,
1673
+ onStartupMemoryWait: () => this.startupMemoryDeadline?.check(),
1632
1674
  concurrency: () => this.spawnConcurrency(),
1633
1675
  staggerMs: () => this.fleetConfig?.defaults?.startup?.stagger_delay_ms ?? 500,
1634
1676
  });
@@ -2674,6 +2716,7 @@ export class FleetManager {
2674
2716
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
2675
2717
  this.warnAboutRemovedLoginMode(loaded);
2676
2718
  this.enforceGuestSwitch();
2719
+ this.observeWebAuthority(); // #2453 review: a reload's web-authority switches are seen at once
2677
2720
  // Child tool/guidance readers must follow this fleet's actual config, including --config.
2678
2721
  // Scope the path to its owning data directory so another inherited home's override cannot enable CW.
2679
2722
  process.env.AGEND_FLEET_DATA_DIR = resolvePath(this.dataDir);
@@ -3280,7 +3323,8 @@ export class FleetManager {
3280
3323
  instancePresentation(name) {
3281
3324
  const execution_state = this.getInstanceExecutionState(name);
3282
3325
  const interaction = this.getInstanceInteraction(name);
3283
- return { state: presentationState(execution_state, interaction), execution_state,
3326
+ const startup_phase = !this.lifecycle.isPaused(name) ? this.startupDashboard?.phase(name) : undefined;
3327
+ return { ...(startup_phase ? { startup_phase } : {}), state: presentationState(execution_state, interaction), execution_state,
3284
3328
  interaction, interaction_summary: interactionSummary(interaction) };
3285
3329
  }
3286
3330
  /**
@@ -3832,6 +3876,10 @@ export class FleetManager {
3832
3876
  // #2305: the copies' own message ids, so an instance's reaction to the web message is put on them too.
3833
3877
  if (copies.length)
3834
3878
  this.rememberWebEchoCopies(messageId, copies);
3879
+ // #2281: and where they are, for a later web reply to this message to be a native reply there.
3880
+ const posts = copies.flatMap(c => c.adapterId ? [{ adapterId: c.adapterId, chatId: c.chatId, ...(c.threadId ? { threadId: c.threadId } : {}), messageId: c.messageId }] : []);
3881
+ if (posts.length)
3882
+ this.webChatHistory.addPosts(instance, messageId, posts);
3835
3883
  const m = this.webChatHistory.markChannel(instance, messageId);
3836
3884
  if (m)
3837
3885
  broadcastToGuests(this.sseClients, "message", m, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
@@ -3882,7 +3930,11 @@ export class FleetManager {
3882
3930
  return { ok: true, mirrored };
3883
3931
  }
3884
3932
  /** #2305: `copies` collects each room's copy (its message id), for a reaction to the web message. */
3885
- async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK, copies) {
3933
+ /**
3934
+ * `reply` (#2281): where the message a web reply quotes is on a platform. A room that has it gets the reply as a
3935
+ * native reply to it (`reply.text`: without the quoted line); every other room gets `text`, as before.
3936
+ */
3937
+ async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK, copies, reply) {
3886
3938
  const guard = this.webChannelEchoGuards.get(instance);
3887
3939
  const handled = new Set();
3888
3940
  let posted = 0;
@@ -3912,9 +3964,11 @@ export class FleetManager {
3912
3964
  try {
3913
3965
  if (!adapter?.sendText)
3914
3966
  continue;
3915
- const sent = await adapter.sendText(entry.channelId, text, { format: "text", allowedMentions: { parse: [] } });
3967
+ // A Telegram room is its chat; a Discord room is the guild's channel (an inbound's thread there).
3968
+ const native = reply?.posts.find(p => p.adapterId === adapter.id && (p.chatId === entry.channelId || p.threadId === entry.channelId));
3969
+ const sent = await adapter.sendText(entry.channelId, native ? reply.text : text, { format: "text", allowedMentions: { parse: [] }, ...(native ? { replyTo: native.messageId } : {}) });
3916
3970
  if (copies && sent && typeof sent.messageId === "string" && sent.messageId)
3917
- copies.push({ adapter, chatId: entry.channelId, messageId: sent.messageId });
3971
+ copies.push({ adapter, adapterId: adapter.id, chatId: entry.channelId, messageId: sent.messageId });
3918
3972
  ClassicChannelManager.logMessage(instance, sender, text, new Date());
3919
3973
  posted++;
3920
3974
  }
@@ -4971,66 +5025,92 @@ export class FleetManager {
4971
5025
  this.logger.info({ name }, "Instance already running, skipping");
4972
5026
  return;
4973
5027
  }
4974
- // An explicit start (CLI/API) begins a fresh idle window; boot and
4975
- // reconcile starts keep the persisted last-inbound seed (Phase 2a).
4976
- if (resumePaused)
4977
- this.lifecycle.markActivitySeedNow(name);
4978
- const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
4979
- if (config.general_topic) {
4980
- this.ensureGeneralInstructions(config.working_directory, backend, name);
4981
- }
4982
- else if (kind === "fleet-topic") {
4983
- // Workers get only role-eligible on-demand skills. Classic instances are
4984
- // deliberately excluded: their workspace and conversation lifecycle are
4985
- // managed independently from fleet-topic workers.
4986
- try {
4987
- const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
4988
- measureSyncWork("fleet.workerSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "worker"));
5028
+ await this.trackStartupInstance(name, async () => {
5029
+ // An explicit start (CLI/API) begins a fresh idle window; boot and
5030
+ // reconcile starts keep the persisted last-inbound seed (Phase 2a).
5031
+ if (resumePaused)
5032
+ this.lifecycle.markActivitySeedNow(name);
5033
+ const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
5034
+ if (config.general_topic) {
5035
+ this.ensureGeneralInstructions(config.working_directory, backend, name);
4989
5036
  }
4990
- catch (err) {
4991
- // Skill publishing is additive. A read-only or temporarily unavailable
4992
- // workspace must not turn an otherwise valid worker startup into a
4993
- // fleet outage.
4994
- this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
5037
+ else if (kind === "fleet-topic") {
5038
+ // Workers get only role-eligible on-demand skills. Classic instances are
5039
+ // deliberately excluded: their workspace and conversation lifecycle are
5040
+ // managed independently from fleet-topic workers.
5041
+ try {
5042
+ const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
5043
+ measureSyncWork("fleet.workerSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "worker"));
5044
+ }
5045
+ catch (err) {
5046
+ // Skill publishing is additive. A read-only or temporarily unavailable
5047
+ // workspace must not turn an otherwise valid worker startup into a
5048
+ // fleet outage.
5049
+ this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
5050
+ }
4995
5051
  }
4996
- }
4997
- else if (kind === "classic") {
4998
- // #2163: a Classic bot gets only the skills marked `classic` (its own look, the dashboard) — never the fleet's
4999
- // coordination playbooks (#557 kept those out of end-user bots) — and only into a workspace AgEnD manages: the
5000
- // room's own folder under <AGEND_HOME>/workspaces/. Anywhere else, nothing is written; `agend-agent skill <name>`
5001
- // reads any bundled skill on demand.
5002
- try {
5003
- const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
5004
- if (insideManagedWorkspaces(skillsWorkDir))
5005
- measureSyncWork("fleet.classicSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "classic"));
5006
- else
5007
- this.logger.debug({ name, skillsWorkDir }, "Classic workspace is not AgEnD-managed — no skills published");
5052
+ else if (kind === "classic") {
5053
+ // #2163: a Classic bot gets only the skills marked `classic` (its own look, the dashboard) — never the fleet's
5054
+ // coordination playbooks (#557 kept those out of end-user bots) — and only into a workspace AgEnD manages: the
5055
+ // room's own folder under <AGEND_HOME>/workspaces/. Anywhere else, nothing is written; `agend-agent skill <name>`
5056
+ // reads any bundled skill on demand.
5057
+ try {
5058
+ const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
5059
+ if (insideManagedWorkspaces(skillsWorkDir))
5060
+ measureSyncWork("fleet.classicSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "classic"));
5061
+ else
5062
+ this.logger.debug({ name, skillsWorkDir }, "Classic workspace is not AgEnD-managed — no skills published");
5063
+ }
5064
+ catch (err) {
5065
+ this.logger.warn({ err, name, backend }, "Failed to sync classic skills — continuing startup");
5066
+ }
5008
5067
  }
5009
- catch (err) {
5010
- this.logger.warn({ err, name, backend }, "Failed to sync classic skills — continuing startup");
5068
+ const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
5069
+ const identity = { kind, backend, model: this.resolveInstanceModel(name).display };
5070
+ if (receipt) {
5071
+ await this.lifecycle.start(name, config, topicMode, identity, transition, execution, daemon => {
5072
+ // Only this transition's actual publication can acquire its replacement.
5073
+ receipt.daemon = daemon;
5074
+ });
5011
5075
  }
5076
+ else
5077
+ await this.lifecycle.start(name, config, topicMode, identity, transition, execution);
5078
+ execution?.assert();
5079
+ // Only clear a stale process status after a real start succeeded. Clearing
5080
+ // it before lifecycle.start() can turn a crash-loop daemon's dead pane into
5081
+ // a falsely running instance when lifecycle.start() returns early.
5082
+ this.instanceProcessStatus.delete(name);
5083
+ // Recovery intent belongs to Daemon.start. lifecycle.start can return
5084
+ // before reaching it, so an await here is not proof the marker was read.
5085
+ // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
5086
+ await this.connectIpcToInstance(name);
5087
+ execution?.assert();
5088
+ this.requestDiscordUsagePresenceRefresh();
5089
+ });
5090
+ }
5091
+ /** A boot's observation cannot settle a replacement boot or a newer attempt of the same instance. */
5092
+ async trackStartupInstance(name, start) {
5093
+ const boot = this.startupDashboard;
5094
+ const owner = boot?.begin(name);
5095
+ if (owner)
5096
+ this.publishStartupStatus();
5097
+ let ready = false;
5098
+ try {
5099
+ await start();
5100
+ ready = this.daemons.has(name);
5012
5101
  }
5013
- const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
5014
- const identity = { kind, backend, model: this.resolveInstanceModel(name).display };
5015
- if (receipt) {
5016
- await this.lifecycle.start(name, config, topicMode, identity, transition, execution, daemon => {
5017
- // Only this transition's actual publication can acquire its replacement.
5018
- receipt.daemon = daemon;
5019
- });
5102
+ finally {
5103
+ if (boot && owner && this.startupDashboard === boot && boot.finish(name, owner, ready))
5104
+ this.publishStartupStatus();
5105
+ }
5106
+ }
5107
+ publishStartupStatus() {
5108
+ try {
5109
+ this.emitSseEvent("status", this.getUiStatus());
5110
+ }
5111
+ catch (err) {
5112
+ this.logger.debug({ err }, "Startup status unavailable; the next status frame retries");
5020
5113
  }
5021
- else
5022
- await this.lifecycle.start(name, config, topicMode, identity, transition, execution);
5023
- execution?.assert();
5024
- // Only clear a stale process status after a real start succeeded. Clearing
5025
- // it before lifecycle.start() can turn a crash-loop daemon's dead pane into
5026
- // a falsely running instance when lifecycle.start() returns early.
5027
- this.instanceProcessStatus.delete(name);
5028
- // Recovery intent belongs to Daemon.start. lifecycle.start can return
5029
- // before reaching it, so an await here is not proof the marker was read.
5030
- // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
5031
- await this.connectIpcToInstance(name);
5032
- execution?.assert();
5033
- this.requestDiscordUsagePresenceRefresh();
5034
5114
  }
5035
5115
  /** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
5036
5116
  async startPersistedPausedInstance(name, transition) {
@@ -5972,6 +6052,11 @@ export class FleetManager {
5972
6052
  revoke: id => {
5973
6053
  this.webLoginCodes?.revokeAudience(id);
5974
6054
  this.webGuestLoginCodes?.revokeAudience(id);
6055
+ // #2453 (§0.2): the link's sessions end, and the authorizations kept for them go with them (housekeeping: their
6056
+ // handles can never match again).
6057
+ const ended = new Set(this.webSessions?.list(undefined, r => r.exposureId === id).map(r => r.handle) ?? []);
6058
+ if (ended.size)
6059
+ this.sessionAuthorizationStore()?.prune(a => !ended.has(a.handle));
5975
6060
  const result = this.webSessions?.revokeExposure(id);
5976
6061
  if (result && !result.durable)
5977
6062
  this.logger.warn("Public session revocation could not be saved; exposure remains closed");
@@ -5992,6 +6077,66 @@ export class FleetManager {
5992
6077
  },
5993
6078
  });
5994
6079
  }
6080
+ publicLinkAutoOpen = null;
6081
+ /**
6082
+ * #named-link-ttl: the admin who last opened a named-tunnel link from /web, with the live binding — only while they
6083
+ * still may own one (publicOwnerCurrent: a fleet admin of a live bot, the same General). Else null.
6084
+ */
6085
+ rememberedPublicOwner() {
6086
+ const saved = readPublicLinkOwner(this.dataDir);
6087
+ if (!saved)
6088
+ return null;
6089
+ const owner = { ...saved, binding: this.adapters.get(saved.adapterId) };
6090
+ return this.publicOwnerCurrent(owner) ? owner : null;
6091
+ }
6092
+ /** A named-tunnel link was privately delivered: auto_open reopens it for this owner after a restart. */
6093
+ rememberPublicLinkOwner(owner) {
6094
+ if (!publicLinkNamedHostname(this.fleetConfig?.web))
6095
+ return;
6096
+ try {
6097
+ writePublicLinkOwner(this.dataDir, owner);
6098
+ }
6099
+ catch (err) {
6100
+ this.logger.warn({ err: err?.message }, "Could not remember the public link's owner; auto_open will not reopen it");
6101
+ }
6102
+ }
6103
+ /** Closed on purpose (Close, /web revoke): a restart must not reopen it. The next /web remembers again. */
6104
+ forgetPublicLinkOwner() {
6105
+ try {
6106
+ clearPublicLinkOwner(this.dataDir);
6107
+ }
6108
+ catch (err) {
6109
+ this.logger.warn({ err: err?.message }, "Could not forget the public link's owner");
6110
+ }
6111
+ }
6112
+ /**
6113
+ * #named-link-ttl: `web.public_link.auto_open` — once per start, after the web listener and the chat connections are
6114
+ * up. Opens the named link with nothing posted (sign-in still needs a code from /web or a returning device); a final
6115
+ * failure goes to its owner once, by DM, drawn as a failed /web is.
6116
+ */
6117
+ startPublicLinkAutoOpen() {
6118
+ this.publicLinkAutoOpen?.stop();
6119
+ const runner = this.publicLinkAutoOpen = new PublicLinkAutoOpen({
6120
+ web: () => this.fleetConfig?.web,
6121
+ ready: () => !this.shuttingDown && this.healthServerListening && this.publicLinkAutoOpen === runner,
6122
+ owner: () => this.rememberedPublicOwner(),
6123
+ open: (owner, onProgress) => this.getPublicWebLink().openUnattended(owner, onProgress),
6124
+ notifyFailed: async (owner, last) => {
6125
+ const adapter = owner.binding;
6126
+ if (this.shuttingDown || !adapter?.sendDirect || !this.publicOwnerCurrent(owner))
6127
+ return;
6128
+ const outcome = t("dashboard.public_auto_failed");
6129
+ try {
6130
+ await withinBudget(adapter.sendDirect(owner.userId, last ? renderPublicLinkProgress(last, performance.now(), outcome) : outcome, { disablePreview: true }), performance.now() + 5_000);
6131
+ }
6132
+ catch (err) {
6133
+ this.logDashboardDmFailure(err);
6134
+ }
6135
+ },
6136
+ log: (event, fields) => this.logger.info({ event, ...fields }, "Public web link"),
6137
+ });
6138
+ runner.start();
6139
+ }
5995
6140
  getPublicWebStatus() {
5996
6141
  return this.publicWebLink?.status() ?? { state: "closed" };
5997
6142
  }
@@ -6175,8 +6320,9 @@ export class FleetManager {
6175
6320
  // Show the action before any link exists. TG's Start instructions belong to confirmed DM refusal below.
6176
6321
  const menuText = this.topicCommands.getDashboardMenuText()
6177
6322
  + (publicAllowed ? "\n" + t("dashboard.public_risk") : "")
6178
- + (status.state !== "closed" ? "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
6179
- + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "") : "");
6323
+ + (status.state === "closed" ? "" : status.noExpiry ? "\n" + t("dashboard.public_status_no_expiry", status.state)
6324
+ : "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
6325
+ + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : ""));
6180
6326
  const choices = [{ action: "local", label: t("dashboard.local_signin") },
6181
6327
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
6182
6328
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
@@ -6223,6 +6369,7 @@ export class FleetManager {
6223
6369
  if (!entry.publicExposureId)
6224
6370
  return true;
6225
6371
  const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
6372
+ this.forgetPublicLinkOwner();
6226
6373
  if (result?.confirmed === false)
6227
6374
  data.ack?.(t("dashboard.public_cleanup"));
6228
6375
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"), data.editClicked);
@@ -6253,10 +6400,11 @@ export class FleetManager {
6253
6400
  return false;
6254
6401
  this.initializeWebSessions();
6255
6402
  const issued = this.webLoginCodes.issue({ epoch: tokenEpoch(token), audience: exposureId, owner });
6256
- // Only a public link has a link expiry; the code's five-minute lifetime applies to either packet.
6403
+ // Only a public link has a link expiry (null: none — a named tunnel's ttl_minutes 0); the code's five-minute
6404
+ // lifetime applies to either packet.
6257
6405
  const minutes = Math.round(LOGIN_CODE_TTL_MS / 60_000);
6258
6406
  const text = expiresAt !== undefined
6259
- ? t("dashboard.private_public", url, issued.display, minutes, formatFleetTime(expiresAt))
6407
+ ? t("dashboard.private_public", url, issued.display, minutes, expiresAt === null ? t("dashboard.public_no_expiry") : formatFleetTime(expiresAt))
6260
6408
  : t("dashboard.private_local", url, issued.display, minutes);
6261
6409
  const deadline = performance.now() + 10_000;
6262
6410
  const direct = owner.binding;
@@ -6319,6 +6467,8 @@ export class FleetManager {
6319
6467
  // Still closes the step editor below and tells the clicker: a throw must not leave the menu refreshing.
6320
6468
  this.logger.warn({ err }, "Dashboard sign-in delivery failed");
6321
6469
  }
6470
+ if (ok && action === "public")
6471
+ this.rememberPublicLinkOwner(owner);
6322
6472
  // Only safe, static words enter General; never link, code or platform error text.
6323
6473
  // #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
6324
6474
  // with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
@@ -6343,6 +6493,7 @@ export class FleetManager {
6343
6493
  revokeWebSessions() {
6344
6494
  this.initializeWebSessions();
6345
6495
  void this.publicWebLink?.close("dashboard revoke");
6496
+ this.forgetPublicLinkOwner();
6346
6497
  this.webLoginCodes.revoke();
6347
6498
  this.webGuestLoginCodes.revokeAll();
6348
6499
  this.guestRevokes++; // and every guest /web still on its way
@@ -7030,11 +7181,46 @@ export class FleetManager {
7030
7181
  }
7031
7182
  /** Start all instances from fleet config */
7032
7183
  async startAll(configPath) {
7184
+ this.startupMemoryDeadline?.stop();
7185
+ const deadline = new StartupMemoryDeadline({
7186
+ current: () => this.startupMemoryDeadline === deadline && !this.shuttingDown,
7187
+ waiting: () => this.spawnGate.isStartupMemoryDeferred(),
7188
+ notify: sdNotify,
7189
+ log: message => this.logger.info(message),
7190
+ });
7191
+ this.startupMemoryDeadline = deadline;
7192
+ // A held first General must not postpone event-loop liveness monitoring.
7193
+ // No independent heartbeat: a genuinely frozen main loop still stops pinging.
7194
+ setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
7195
+ if (this.watchdogTimer)
7196
+ clearInterval(this.watchdogTimer);
7197
+ this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
7198
+ this.eventLoopWatch?.stop();
7199
+ this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
7200
+ try {
7201
+ await this.startAllWithinDeadline(configPath, deadline);
7202
+ }
7203
+ finally {
7204
+ deadline.stop();
7205
+ if (this.startupMemoryDeadline === deadline) {
7206
+ this.startupMemoryDeadline = null;
7207
+ if (!this.startupComplete) {
7208
+ if (this.watchdogTimer)
7209
+ clearInterval(this.watchdogTimer);
7210
+ this.watchdogTimer = null;
7211
+ this.eventLoopWatch?.stop();
7212
+ this.eventLoopWatch = null;
7213
+ }
7214
+ }
7215
+ }
7216
+ }
7217
+ async startAllWithinDeadline(configPath, deadline) {
7033
7218
  this.loginWindow.reopen(); // a stopAll → startAll restart must accept login windows again
7034
7219
  this.loginController?.reopen();
7035
7220
  const startupStartedAt = Date.now();
7036
7221
  FleetManager.signalTarget = this;
7037
7222
  this.startupComplete = false;
7223
+ this.startupDashboard = null;
7038
7224
  // Cleared here, not at the end of doStopAll: a stop has an async tail, and
7039
7225
  // anything arriving during it is still part of the stop.
7040
7226
  this.shuttingDown = false;
@@ -7286,7 +7472,7 @@ export class FleetManager {
7286
7472
  continue;
7287
7473
  this.logger.warn({ adapter: need.adapterId, name }, "No general instance for adapter — auto-creating");
7288
7474
  const generalDir = join(getAgendHome(), name);
7289
- mkdirSync(generalDir, { recursive: true });
7475
+ mkdirSync(generalDir, { recursive: true, mode: 0o700 });
7290
7476
  const backendName = fleet.defaults.backend ?? "claude-code";
7291
7477
  this.ensureGeneralInstructions(generalDir, backendName, name);
7292
7478
  fleet.instances[name] = {
@@ -7337,6 +7523,13 @@ export class FleetManager {
7337
7523
  const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
7338
7524
  const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingProgressOperation === "full-restart" ? "reload" : pendingUpdateProgress ? "update" : "restart" });
7339
7525
  this.startupProgress = startupProgress;
7526
+ // Auth, restored chat, Classic roster and the task DB are ready. Listening is not service readiness:
7527
+ // the dashboard observes startup, while /health and READY retain their existing completion boundary.
7528
+ this.startupDashboard = new FleetStartup(this.configuredStartupInstanceNames(fleet, topicMode)
7529
+ .filter(name => !this.lifecycle.isPaused(name)));
7530
+ await this.startHealthServer(fleet.health_port ?? 19280);
7531
+ if (this.shuttingDown)
7532
+ return;
7340
7533
  if (generals.length > 0) {
7341
7534
  for (const [name, cfg] of generals) {
7342
7535
  try {
@@ -7401,22 +7594,6 @@ export class FleetManager {
7401
7594
  return startupProgress.start(this.restartProgressTarget());
7402
7595
  });
7403
7596
  }
7404
- // The systemd watchdog answers exactly one question: is this process still
7405
- // turning its event loop? Pinging from a timer proves that, and after the
7406
- // blocking child-process calls were made async it is a meaningful signal —
7407
- // a deadlocked or frozen fleet stops pinging and systemd restarts it.
7408
- //
7409
- // It deliberately does NOT gate on fleet health. "No adapter connected" or
7410
- // "an instance crashed" must not kill the process: the fleet would be restarted
7411
- // into the same broken state, and a user who has legitimately stopped every
7412
- // instance would get a restart loop. Those conditions surface through /health
7413
- // (which now returns 503) and through the General-topic notifications instead.
7414
- setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
7415
- this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
7416
- // #1231: a stalled loop is what turns a slash command into "did not respond";
7417
- // put each long stall in the log instead of leaving it to be guessed at.
7418
- this.eventLoopWatch?.stop();
7419
- this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
7420
7597
  // EventLog.prune() existed but was never called, so `events` and `activity`
7421
7598
  // grew without bound for the life of the install. Prune once at startup and
7422
7599
  // daily after that; the timer is unref'd so it never holds the loop open.
@@ -7592,9 +7769,9 @@ export class FleetManager {
7592
7769
  }
7593
7770
  // After "fleet ready", so the notice is not the first thing people see of a restart.
7594
7771
  void this.announceWebChatOnce(agendVersion);
7772
+ // #named-link-ttl: a named link with auto_open comes back now — after the web listener and the chat connections.
7773
+ this.startPublicLinkAutoOpen();
7595
7774
  }
7596
- // Health HTTP endpoint
7597
- this.startHealthServer(fleet.health_port ?? 19280);
7598
7775
  // #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
7599
7776
  // #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
7600
7777
  void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
@@ -7611,7 +7788,11 @@ export class FleetManager {
7611
7788
  // A SIGHUP may arrive after the PID/general is available but before the
7612
7789
  // rest of startup finishes. Replay one coalesced reload only after all
7613
7790
  // startup-owned lifecycle work and signal handlers are in place.
7791
+ if (this.shuttingDown || this.startupMemoryDeadline !== deadline)
7792
+ return;
7614
7793
  this.finishStartup(bootUpdateMarker);
7794
+ this.startupDashboard = null;
7795
+ this.publishStartupStatus();
7615
7796
  // Tell systemd we are ready only now. This used to fire right after the
7616
7797
  // generals started — before adapters, classic instances, topic creation and the
7617
7798
  // health server — so `systemctl start` returned success while the fleet was
@@ -8035,12 +8216,12 @@ export class FleetManager {
8035
8216
  return;
8036
8217
  }
8037
8218
  const accessDir = join(this.dataDir, "access");
8038
- mkdirSync(accessDir, { recursive: true });
8219
+ mkdirSync(accessDir, { recursive: true, mode: 0o700 });
8039
8220
  const accessStatePath = join(accessDir, "access.json");
8040
8221
  const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
8041
8222
  this.warnIfAccessModeOverridden(accessManager, accessStatePath);
8042
8223
  const inboxDir = join(this.dataDir, "inbox");
8043
- mkdirSync(inboxDir, { recursive: true });
8224
+ mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
8044
8225
  const adapterId = channelConfig.id ?? channelConfig.type;
8045
8226
  const adapter = await createAdapter(channelConfig, {
8046
8227
  id: adapterId,
@@ -8186,12 +8367,12 @@ export class FleetManager {
8186
8367
  return;
8187
8368
  }
8188
8369
  const accessDir = join(this.dataDir, "access");
8189
- mkdirSync(accessDir, { recursive: true });
8370
+ mkdirSync(accessDir, { recursive: true, mode: 0o700 });
8190
8371
  const accessStatePath = join(accessDir, `access-${adapterId}.json`);
8191
8372
  const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
8192
8373
  this.warnIfAccessModeOverridden(accessManager, accessStatePath);
8193
8374
  const inboxDir = join(this.dataDir, "inbox");
8194
- mkdirSync(inboxDir, { recursive: true });
8375
+ mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
8195
8376
  const adapter = await createAdapter(channelConfig, {
8196
8377
  id: adapterId,
8197
8378
  botToken,
@@ -9623,9 +9804,14 @@ export class FleetManager {
9623
9804
  * history (#1565). #1920 §5.2: said in the instance's topic, General or room, so it is that channel's, by that account.
9624
9805
  */
9625
9806
  emitPlatformInbound(instance, msg, text) {
9807
+ // #2281: where it is on the platform — a web reply to it is then a native reply there. The platform thread it came
9808
+ // from (#1085: a ClassicBot's `threadId` may be its registry key).
9809
+ const thread = msg.transportThreadId ?? msg.threadId;
9810
+ const posts = msg.adapterId && msg.chatId && msg.messageId
9811
+ ? [{ adapterId: msg.adapterId, chatId: msg.chatId, ...(thread && thread !== msg.chatId ? { threadId: thread } : {}), messageId: msg.messageId }] : undefined;
9626
9812
  this.emitSseEvent("message", {
9627
9813
  instance, sender: msg.username, role: "user", text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
9628
- channel: true, from: webChatFrom(msg.adapterId, msg.userId),
9814
+ channel: true, from: webChatFrom(msg.adapterId, msg.userId), ...(posts ? { posts } : {}),
9629
9815
  });
9630
9816
  }
9631
9817
  /** Handle outbound tool calls from a daemon instance */
@@ -9735,6 +9921,13 @@ export class FleetManager {
9735
9921
  this.logger.warn({ sink, instance: instanceName, profile, tool, enforced: true }, "tool-permissions: refused");
9736
9922
  return { allowed: false, message: toolRefusedMessage(profile, tool) };
9737
9923
  }
9924
+ // The person's dashboard switches (dashboard-permissions.ts), on top of the profile, as configured now.
9925
+ // Its own switches, else the defaults' — a ClassicBot identity has no instances entry and runs on the defaults (#2391 review).
9926
+ const switchedOff = dashboardRefusalIn(this.fleetConfig, instanceName, tool);
9927
+ if (switchedOff) {
9928
+ this.logger.warn({ sink, instance: instanceName, profile, tool, enforced: true, dashboard: true }, "tool-permissions: refused");
9929
+ return { allowed: false, message: switchedOff };
9930
+ }
9738
9931
  this.logger.debug({ sink, instance: instanceName, profile, tool, enforced: true }, "tool-permissions: allowed");
9739
9932
  return { allowed: true, message: "" };
9740
9933
  }
@@ -9742,6 +9935,9 @@ export class FleetManager {
9742
9935
  this.touchActivity(instanceName);
9743
9936
  this.setTopicIcon(instanceName, "green");
9744
9937
  const tool = msg.tool;
9938
+ // #1774: who sends this — taken before anything here awaits (sticker checks, the platform send): a reply that waits
9939
+ // must never be read as a same-name replacement's (removed and re-registered meanwhile).
9940
+ const sentBy = tool === "reply" ? this.webChatIdentity(instanceName) : undefined;
9745
9941
  const args = (msg.args ?? {});
9746
9942
  const requestId = msg.requestId;
9747
9943
  const fleetRequestId = msg.fleetRequestId;
@@ -9921,6 +10117,7 @@ export class FleetManager {
9921
10117
  args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
9922
10118
  }
9923
10119
  }
10120
+ const replyIdentity = sentBy; // #1774: taken at the handler's entry
9924
10121
  const original = respond;
9925
10122
  const respondAndRecord = (result, error) => {
9926
10123
  let buttonsView = null;
@@ -9952,12 +10149,13 @@ export class FleetManager {
9952
10149
  try {
9953
10150
  // #1920 §5.2: shown to the instance's guests only when it landed in the instance's own topic or room.
9954
10151
  let inChannel = false;
10152
+ const replyAdapterId = contextAdapterId ?? this.getInstanceAdapterId(instanceName);
9955
10153
  try {
9956
10154
  inChannel = outAdapter !== WEB_ONLY_REPLY_SINK
9957
- && this.landedInOwnChannel(instanceName, contextAdapterId ?? this.getInstanceAdapterId(instanceName), result);
10155
+ && this.landedInOwnChannel(instanceName, replyAdapterId, result);
9958
10156
  }
9959
10157
  catch { /* not known: not the channel's (fail closed), and the bookkeeping below still runs */ }
9960
- void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel);
10158
+ void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel, replyAdapterId ? { adapterId: replyAdapterId, result } : undefined, replyIdentity);
9961
10159
  }
9962
10160
  catch (err) {
9963
10161
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -10034,7 +10232,12 @@ export class FleetManager {
10034
10232
  return false;
10035
10233
  }
10036
10234
  }
10037
- afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false) {
10235
+ afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false, sentAs, identity = this.webChatIdentity(instanceName)) {
10236
+ // #2281: where the reply is, when it landed in the instance's own place — a web reply to it is then a native reply
10237
+ // there. A reply elsewhere (another chat, the web-only sink) records none.
10238
+ const sent = (sentAs?.result && typeof sentAs.result === "object" ? sentAs.result : {});
10239
+ const post = inChannel && sentAs && typeof sent.messageId === "string" && typeof sent.chatId === "string"
10240
+ ? { adapterId: sentAs.adapterId, chatId: sent.chatId, ...(typeof sent.threadId === "string" && sent.threadId ? { threadId: sent.threadId } : {}), messageId: sent.messageId } : undefined;
10038
10241
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
10039
10242
  // more work either. Split the difference: an instance that is clearly
10040
10243
  // idle loses the button now; one that looks busy keeps it (re-posted
@@ -10061,7 +10264,16 @@ export class FleetManager {
10061
10264
  // copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
10062
10265
  const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
10063
10266
  const ts = new Date().toISOString();
10064
- const show = (attachments) => {
10267
+ // #1774: the instance this reply is for — removed while its files were being copied (a same-name replacement
10268
+ // registered meanwhile included), the reply is no longer its to show: no chat made again, none of the new one's
10269
+ // touched, and the copies made for it go. A replacement's own replies are its own.
10270
+ // Its identity was taken when the reply was sent (identity), never re-read after the platform answered.
10271
+ const current = () => this.webChatIdentityCurrent(instanceName, identity);
10272
+ const show = (attachments, reserved) => {
10273
+ if (!current()) {
10274
+ this.webFiles.dropServed(attachments.map(a => a.id));
10275
+ return;
10276
+ }
10065
10277
  // The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
10066
10278
  // role that may get HTML preview cards. Never inferred from the sender name or the text.
10067
10279
  this.emitSseEvent("message", {
@@ -10071,18 +10283,25 @@ export class FleetManager {
10071
10283
  ...(attachments.length ? { attachments } : {}),
10072
10284
  ...(buttons ? { buttons } : {}), // #1266
10073
10285
  ...(inChannel ? { channel: true } : {}), // #1920 §5.2
10286
+ ...(post ? { posts: [post] } : {}), // #2281
10287
+ ...(reserved !== undefined ? { reserved } : {}), // #1774
10074
10288
  });
10075
10289
  };
10076
10290
  const before = this.webReplyOrder.get(instanceName);
10077
- if (!replyFiles.length && !before) {
10291
+ // #1774: a removal of this instance still going decides whether a reply sent before it began is shown — it waits.
10292
+ const removal = this.webChatRemovalPending(instanceName, identity);
10293
+ if (!replyFiles.length && !before && !removal) {
10078
10294
  show([]);
10079
10295
  return Promise.resolve();
10080
10296
  }
10297
+ // #1774: its place in the chat is now — a person's message sent while the files are copied comes after it.
10298
+ const reserved = this.webChatHistory.reserve();
10081
10299
  const done = (before ?? Promise.resolve())
10082
- .then(() => this.webReplyAttachments(instanceName, replyFiles))
10300
+ .then(() => this.webReplyAttachments(instanceName, replyFiles, current))
10301
+ .then(async (attachments) => { await this.webChatRemovalPending(instanceName, identity); return attachments; })
10083
10302
  .catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
10084
10303
  .then((attachments) => { try {
10085
- show(attachments);
10304
+ show(attachments, reserved);
10086
10305
  }
10087
10306
  catch (err) {
10088
10307
  this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
@@ -10097,11 +10316,13 @@ export class FleetManager {
10097
10316
  * the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
10098
10317
  * one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
10099
10318
  */
10100
- async webReplyAttachments(instanceName, paths) {
10319
+ async webReplyAttachments(instanceName, paths, current = () => true) {
10101
10320
  const home = getAgendHome();
10102
10321
  const dir = webFilesDir(home, instanceName);
10103
10322
  const out = [];
10104
10323
  for (const path of paths) {
10324
+ if (!current())
10325
+ break; // #1774: removed meanwhile — copy no more for it
10105
10326
  const id = randomBytes(16).toString("hex");
10106
10327
  const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
10107
10328
  if (got && "evicted" in got && got.evicted.length)
@@ -11353,6 +11574,7 @@ export class FleetManager {
11353
11574
  saveFleetConfig(explicitPatches = []) {
11354
11575
  this.publicWebLink?.refresh();
11355
11576
  this.enforceGuestSwitch(); // #1920: a Settings save turning guests off ends their sessions
11577
+ this.observeWebAuthority(); // #2453 review: so is a web-authority switch (off is always seen)
11356
11578
  if (!this.fleetConfig || !this.configPath)
11357
11579
  return;
11358
11580
  if (!this.savedFleetConfigSnapshot)
@@ -11643,10 +11865,27 @@ export class FleetManager {
11643
11865
  // Capture instance dir BEFORE lifecycle.remove, which deletes the instance
11644
11866
  // from fleetConfig. (The backend would be unresolvable afterwards.)
11645
11867
  const instanceDirToClean = this.getInstanceDir(name);
11646
- if (execution)
11647
- await this.lifecycle.remove(name, authorization, execution, transition);
11648
- else
11649
- await this.lifecycle.remove(name, authorization);
11868
+ // #1774: replies sent from here on are a later instance's (a replacement registered while this one goes); the ones
11869
+ // sent before wait for the outcome before they are shown.
11870
+ const removedBelow = this.webChatGeneration(name) + 1, removedDaemon = this.daemons.get(name);
11871
+ this.webChatGenerations.set(name, removedBelow);
11872
+ let settleRemoval;
11873
+ const removalPending = { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}), done: new Promise(r => { settleRemoval = r; }) };
11874
+ this.webChatRemovalsPending.set(name, removalPending);
11875
+ try {
11876
+ if (execution)
11877
+ await this.lifecycle.remove(name, authorization, execution, transition);
11878
+ else
11879
+ await this.lifecycle.remove(name, authorization);
11880
+ // #1774: removed — its replies still copying their files are no one's to show, replaced or not (a failed removal
11881
+ // throws above: its replies stay its own).
11882
+ this.webChatRemovals.set(name, { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}) });
11883
+ }
11884
+ finally {
11885
+ if (this.webChatRemovalsPending.get(name) === removalPending)
11886
+ this.webChatRemovalsPending.delete(name);
11887
+ settleRemoval();
11888
+ }
11650
11889
  // A later same-name registration owns its files and caches.
11651
11890
  // Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
11652
11891
  // re-registration) — #1677 review.
@@ -12342,7 +12581,7 @@ export class FleetManager {
12342
12581
  /** A preview under the inbox, named by a hash of the id (a Telegram file_id is long), pruned after a day. */
12343
12582
  storeStickerPreview(id, picture) {
12344
12583
  const dir = join(this.dataDir, "inbox", "sticker-previews");
12345
- mkdirSync(dir, { recursive: true });
12584
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
12346
12585
  const now = Date.now();
12347
12586
  for (const name of readdirSync(dir)) {
12348
12587
  try {
@@ -12408,7 +12647,7 @@ export class FleetManager {
12408
12647
  if (!/^\d{15,25}$/.test(id))
12409
12648
  throw new Error("not a Discord emoji id");
12410
12649
  const dir = join(this.dataDir, "inbox", "emoji-previews");
12411
- mkdirSync(dir, { recursive: true });
12650
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
12412
12651
  const now = Date.now();
12413
12652
  for (const name of readdirSync(dir)) {
12414
12653
  try {
@@ -13043,7 +13282,7 @@ export class FleetManager {
13043
13282
  const board = this.scheduler?.db.board;
13044
13283
  if (!board)
13045
13284
  return;
13046
- const read = board.summaryRead(since.toISOString(), until.toISOString());
13285
+ const read = board.summaryRead(since.toISOString(), until.toISOString(), this.dispatcherGone);
13047
13286
  const projects = board.listProjects(true);
13048
13287
  const stats = board.projectStats();
13049
13288
  const names = new Map(projects.map((p) => [p.id, p.name]));
@@ -13796,7 +14035,7 @@ export class FleetManager {
13796
14035
  throw new Error("the delivery outbox is not open");
13797
14036
  return this.deliveryOutbox.acknowledge(deliveryId, by);
13798
14037
  },
13799
- tasks: () => this.needsYouTasks(),
14038
+ tasks: () => this.needsYouTaskItems(),
13800
14039
  taskAction: action => this.taskActionFromChat(action),
13801
14040
  settings: () => ({
13802
14041
  liveMessage: this.fleetConfig?.needs_you?.live_message !== false,
@@ -13823,9 +14062,9 @@ export class FleetManager {
13823
14062
  return classic ? { chatId: classic } : null;
13824
14063
  }
13825
14064
  /**
13826
- * #2352: the instance whose own channel lists a waiting card — the instance that handed it out, else its project's
13827
- * lead — when that channel can be used now (a fleet instance of a live world, with a topic or room). undefined: its
13828
- * assignee's world's General, as before.
14065
+ * #2352: the instance whose own channel lists a waiting card — its project's lead — when that channel can be used now
14066
+ * (a fleet instance of a live world, with a topic or room). undefined: its assignee's world's General, as before.
14067
+ * #2434: only a card the person handed out waits for them, so the instance that handed one out is never its lane.
13829
14068
  */
13830
14069
  needsYouLane(card, leads) {
13831
14070
  const usable = (name) => {
@@ -13834,9 +14073,6 @@ export class FleetManager {
13834
14073
  const world = this.getInstanceAdapterId(name);
13835
14074
  return world !== undefined && this.worlds.has(world) && this.instanceChannelTarget(name) !== null;
13836
14075
  };
13837
- const dispatcher = card.dispatcher?.kind === "instance" && card.dispatcher.name !== card.assignee ? card.dispatcher.name : null;
13838
- if (dispatcher)
13839
- return usable(dispatcher) ? dispatcher : undefined;
13840
14076
  const lead = card.projectId != null ? leads.get(card.projectId) : null;
13841
14077
  return usable(lead) ? lead : undefined;
13842
14078
  }
@@ -13844,10 +14080,24 @@ export class FleetManager {
13844
14080
  * #2355 review: where a card's Needs you buttons belong now — read from the board and the fleet at the moment of a click
13845
14081
  * or a reply, never from the last render. null: it no longer waits for the person.
13846
14082
  */
14083
+ /**
14084
+ * #2425 follow-up: an instance that handed out a card and is no longer in the fleet — neither a configured instance nor
14085
+ * a ClassicBot identity (deleted). Its blocked and in-review cards fall back to the person (Needs you, the summary).
14086
+ * Not known before the config and the ClassicBot rooms are loaded: then no one is gone (a live dispatcher's cards never
14087
+ * flash into the person's list at startup).
14088
+ */
14089
+ dispatcherGone = (name) => {
14090
+ const instances = this.fleetConfig?.instances, classic = this.classicChannels;
14091
+ if (!instances || !classic)
14092
+ return false;
14093
+ return !Object.hasOwn(instances, name) && !classic.getAll().some((ch) => ch.instanceName === name);
14094
+ };
13847
14095
  needsYouRoute(code) {
13848
14096
  const board = this.scheduler?.db.board;
13849
14097
  const card = board?.findCard(code);
13850
- if (!board || !card || !(card.status === "blocked" || (card.status === "review" && card.needsReview)))
14098
+ // #2425 (Fable): the same rule as the list — a button rendered before the fix (or before the opt-in went off) for a
14099
+ // card that no longer waits for the person answers "no longer waits".
14100
+ if (!board || !card || this.fleetConfig?.needs_you?.task_board !== true || !waitsForPerson(card, this.dispatcherGone))
13851
14101
  return null;
13852
14102
  const leads = new Map(board.listProjects(true).map((p) => [p.id, p.leadInstance]));
13853
14103
  const lane = this.needsYouLane(card, leads);
@@ -13855,14 +14105,19 @@ export class FleetManager {
13855
14105
  }
13856
14106
  /**
13857
14107
  * #2246 step 3: the task board's cards that wait for the person, for Needs you — under each card's assignee, listed in
13858
- * its lane's channel (#2352: the instance that handed it out, else the project's lead), else in General.
14108
+ * its lane's channel (#2352: its project's lead; #2434: never the instance that handed it out), else in General.
13859
14109
  */
14110
+ needsYouTaskItems() {
14111
+ // #2425: the task board's cards join Needs you only when the fleet asks for them (needs_you.task_board, off by
14112
+ // default since 2.3.0-beta.25); prompts, deliveries and the rest are unchanged.
14113
+ return this.fleetConfig?.needs_you?.task_board === true ? this.needsYouTasks() : [];
14114
+ }
13860
14115
  needsYouTasks() {
13861
14116
  const board = this.scheduler?.db.board;
13862
14117
  if (!board)
13863
14118
  return [];
13864
14119
  const leads = new Map(board.listProjects(true).map((p) => [p.id, p.leadInstance]));
13865
- return board.waitingForPerson().map(({ card, since }) => {
14120
+ return board.waitingForPerson(this.dispatcherGone).map(({ card, since }) => {
13866
14121
  const gap = card.status === "review" ? wrapUpGap(card) : null;
13867
14122
  const lane = this.needsYouLane(card, leads);
13868
14123
  return {
@@ -14297,7 +14552,7 @@ export class FleetManager {
14297
14552
  ...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
14298
14553
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
14299
14554
  authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
14300
- policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now: Date.now(),
14555
+ policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now: Date.now(),
14301
14556
  });
14302
14557
  }
14303
14558
  sessionAuthorizations = null;
@@ -14322,7 +14577,7 @@ export class FleetManager {
14322
14577
  session, gateway, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
14323
14578
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
14324
14579
  authorization: { handle: session.handle, owner: session.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
14325
- policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
14580
+ policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
14326
14581
  });
14327
14582
  if (check.refusal)
14328
14583
  return { ok: false, refusal: check.refusal };
@@ -14337,9 +14592,30 @@ export class FleetManager {
14337
14592
  */
14338
14593
  webAuthorityState() {
14339
14594
  if (!this.dataDir)
14340
- return { enabled: false, loopbackOnly: true, epoch: 0 };
14595
+ return { enabled: false, loopbackOnly: true, namedLink: false, epoch: 0 };
14341
14596
  this.authorityStateStore ??= new AuthorityStateStore(join(this.dataDir, "web-authority.json"), this.logger, undefined, undefined, () => { this.ensureDeliveryOutbox?.(); return this.deliveryOutbox?.maxWebAuthorityEpoch() ?? 0; });
14342
- return this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
14597
+ const state = this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
14598
+ // #2453 (§0.2): the named-link option seen going off — the authority of every session over the link ends now, for
14599
+ // good: their authorizations go (a later "on" can never bring one back without its account's Confirm).
14600
+ if (state.namedLinkClosed)
14601
+ this.sessionAuthorizationStore()?.prune(a => this.webSessions?.findByHandle(a.handle)?.surface === "local");
14602
+ return state;
14603
+ }
14604
+ /**
14605
+ * #2453 review: the web-authority state read on every configuration change (a Settings save, the host's confirm, a
14606
+ * reload) — so a switch turned off and on again between two requests is still seen off: its epoch moves, and the
14607
+ * named-link sessions' authorizations are dropped. Reads that only happen on a request would miss it.
14608
+ */
14609
+ observeWebAuthority() {
14610
+ // Only once the state is in use: any authorization was given through a read that made it (and a first read at
14611
+ // start-up still sees what changed while the fleet was down). Nothing new is opened during a load.
14612
+ try {
14613
+ if (this.dataDir && this.authorityStateStore)
14614
+ this.webAuthorityState();
14615
+ }
14616
+ catch (err) {
14617
+ this.logger.warn({ err: err?.message }, "Web authority state could not be read after a configuration change");
14618
+ }
14343
14619
  }
14344
14620
  /**
14345
14621
  * #1939 S2a: Settings → Web → Web authority — the state in force (the switch counts as off while its state cannot be
@@ -14354,8 +14630,10 @@ export class FleetManager {
14354
14630
  const authorized = state.enabled ? (this.sessionAuthorizationStore()?.list() ?? [])
14355
14631
  .filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
14356
14632
  .map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
14357
- until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry) })) : [];
14358
- return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized };
14633
+ until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry),
14634
+ ...(sessions.get(a.handle).surface === "gateway" ? { named: true } : {}) })) : [];
14635
+ const host = this.fleetConfig?.web?.public_link?.hostname;
14636
+ return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized, namedHost: typeof host === "string" && host ? host : null };
14359
14637
  }
14360
14638
  authorizationRequests = null;
14361
14639
  /**
@@ -14368,7 +14646,9 @@ export class FleetManager {
14368
14646
  const adapterOf = (adapterId) => this.adapters.get(adapterId) ?? this.worlds.get(adapterId)?.adapter;
14369
14647
  // The host's clock, with its zone: the person may read the DM on a phone in another one.
14370
14648
  const when = (ms) => new Date(ms).toLocaleString(undefined, { timeZoneName: "short" });
14371
- const where = (peer) => t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
14649
+ const where = (peer) => peer === "named_link"
14650
+ ? t("web_auth.peer_named_link", String(this.fleetConfig?.web?.public_link?.hostname ?? "?")) // §0.2: the internet
14651
+ : t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
14372
14652
  return this.authorizationRequests = new SessionAuthorizationRequests({
14373
14653
  now: () => performance.now(), wallNow: () => Date.now(),
14374
14654
  maxSessionMs: () => webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs,
@@ -14402,7 +14682,8 @@ export class FleetManager {
14402
14682
  const record = this.webSessions?.findByHandle(handle);
14403
14683
  if (!record)
14404
14684
  return { ok: false, refusal: "session_gone" };
14405
- const r = this.authorizeWebSession(record, peer, false);
14685
+ // §0.2: a named-link session is judged as the gateway request it is (its exposure still the current one).
14686
+ const r = this.authorizeWebSession(record, peer, peer === "named_link");
14406
14687
  // What the card says: the end of the authorization, or the session's own end when that comes first.
14407
14688
  return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
14408
14689
  },
@@ -14448,7 +14729,7 @@ export class FleetManager {
14448
14729
  session: s, gateway: input.gateway, peer: input.peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
14449
14730
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
14450
14731
  authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
14451
- policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
14732
+ policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
14452
14733
  });
14453
14734
  return r.refusal;
14454
14735
  }
@@ -16559,8 +16840,12 @@ export class FleetManager {
16559
16840
  from: m.from,
16560
16841
  replyTo: m.replyTo,
16561
16842
  cards: m.cards,
16843
+ posts: m.posts, // #2281: kept beside the row, never broadcast (not a row field)
16844
+ reserved: m.reserved, // #1774: an agent reply's place, taken when it was sent
16562
16845
  });
16563
- broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
16846
+ // A sending page's own id for its pending bubble rides on the live copy only: never recorded, never replayed.
16847
+ const clientId = typeof data.clientId === "string" ? data.clientId : "";
16848
+ broadcastSseEvent(this.sseClients, event, clientId ? { ...recorded, clientId } : recorded, onError, this.webChatHistory.cursorOf(recorded));
16564
16849
  this.searchIndex?.record(chatDoc(recorded));
16565
16850
  // #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
16566
16851
  if (recorded.role === "agent")
@@ -16596,7 +16881,8 @@ export class FleetManager {
16596
16881
  if (this.fleetConfig?.needs_you?.live_message === false)
16597
16882
  return false;
16598
16883
  const card = db.board.findCard(uuid);
16599
- if (!card)
16884
+ // #2434: only while it waits for the person — a card that does not is in no Needs you, so no buttons anywhere.
16885
+ if (!card || !waitsForPerson(card, this.dispatcherGone))
16600
16886
  return false;
16601
16887
  const leads = new Map(db.board.listProjects(true).map((p) => [p.id, p.leadInstance]));
16602
16888
  return this.needsYouLane(card, leads) === instance;
@@ -17349,7 +17635,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17349
17635
  try {
17350
17636
  const tmpPath = await dlAdapter.downloadAttachment(att.fileId);
17351
17637
  const inboxDir = join(getAgendHome(), "workspaces", instanceName, "inbox");
17352
- mkdirSync(inboxDir, { recursive: true });
17638
+ mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
17353
17639
  const dest = join(inboxDir, basename(tmpPath));
17354
17640
  // Copy to destination — failure means this attachment is skipped
17355
17641
  try {
@@ -17761,7 +18047,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17761
18047
  }
17762
18048
  }
17763
18049
  const path = this.cliEnvPath(backend, profile);
17764
- mkdirSync(dirname(path), { recursive: true });
18050
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
17765
18051
  writeFileSync(path, JSON.stringify(env, null, 2));
17766
18052
  return env;
17767
18053
  }
@@ -19460,8 +19746,27 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19460
19746
  }
19461
19747
  tmuxExecHelperTimer = null;
19462
19748
  stopAllInFlight = null;
19749
+ /** Stops release memory. Reassess between batches without waiting for recovery. */
19750
+ async stopInstancesInBatches(entries, stop) {
19751
+ let index = 0;
19752
+ while (index < entries.length) {
19753
+ // Linux procfs can still be read after the periodic sampler is stopped.
19754
+ // Do not revive a stopped Darwin probe/worker just to size a quit batch;
19755
+ // its full shutdown uses the conservative batch size instead.
19756
+ const level = this.memoryPressure.platform === "darwin"
19757
+ ? this.shuttingDown ? "elevated" : this.memoryPressure.snapshot().level
19758
+ : this.memoryPressure.sample().level;
19759
+ const size = fleetStopConcurrency(entries.length, level);
19760
+ const batch = entries.slice(index, index + size);
19761
+ index += batch.length;
19762
+ await Promise.allSettled(batch.map(stop));
19763
+ }
19764
+ }
19463
19765
  async doStopAll() {
19766
+ this.startupMemoryDeadline?.stop();
19767
+ this.startupMemoryDeadline = null;
19464
19768
  this.startupComplete = false;
19769
+ this.startupDashboard = null;
19465
19770
  this.reloadPending = false;
19466
19771
  // Before anything is stopped: everything that dies from here on dies
19467
19772
  // because we asked it to. Set synchronously — doStopAll runs to its first
@@ -19473,6 +19778,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19473
19778
  const settingsControl = this.settingsControl;
19474
19779
  this.settingsControl = null;
19475
19780
  const settingsControlStopped = settingsControl?.close();
19781
+ this.publicLinkAutoOpen?.stop();
19476
19782
  const publicStopped = this.publicWebLink?.close("fleet shutdown");
19477
19783
  // #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
19478
19784
  // adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
@@ -19623,21 +19929,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19623
19929
  // tmux ops, while small fleets stay conservative to avoid overwhelming the
19624
19930
  // tmux server.
19625
19931
  const entries = [...this.daemons.entries()];
19626
- const STOP_CONCURRENCY = entries.length > 30 ? 15 : entries.length >= 10 ? 10 : 5;
19627
19932
  for (const [name] of entries)
19628
19933
  this.ipcStoppingInstances.add(name);
19629
- for (let i = 0; i < entries.length; i += STOP_CONCURRENCY) {
19630
- const batch = entries.slice(i, i + STOP_CONCURRENCY);
19631
- await Promise.all(batch.map(async ([name, daemon]) => {
19632
- try {
19633
- await daemon.stop();
19634
- }
19635
- catch (err) {
19636
- this.logger.warn({ name, err }, "Stop failed");
19637
- }
19638
- this.daemons.delete(name);
19639
- }));
19640
- }
19934
+ await this.stopInstancesInBatches(entries, async ([name, daemon]) => {
19935
+ try {
19936
+ await daemon.stop();
19937
+ }
19938
+ catch (err) {
19939
+ this.logger.warn({ name, err }, "Stop failed");
19940
+ }
19941
+ this.daemons.delete(name);
19942
+ });
19641
19943
  // Close IPC clients in parallel — serial close over a large fleet adds
19642
19944
  // noticeable latency.
19643
19945
  await Promise.all([...this.instanceIpcClients.values()].map(ipc => Promise.resolve(ipc.close()).catch(() => { })));
@@ -19848,6 +20150,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19848
20150
  this.rawFleetConfig = previousRawConfig;
19849
20151
  this.rawFleetDocument = previousRawDocument;
19850
20152
  this.savedFleetConfigSnapshot = previousSavedSnapshot;
20153
+ this.observeWebAuthority(); // #2453 review: the configuration back in force is the one seen
19851
20154
  this.logger.error({
19852
20155
  oldCount,
19853
20156
  newCount,
@@ -19956,6 +20259,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19956
20259
  const daemon = this.daemons.get(name);
19957
20260
  const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
19958
20261
  const change = classifyInstanceChange(runtimeConfig, config);
20262
+ // (b) The person switched its task board on: tell it (its tool list shows the `task` tool at once).
20263
+ if (taskBoardSwitchedOn(runtimeConfig.dashboard, config.dashboard)) {
20264
+ this.admitTaskBoardNotice(name, TASK_BOARD_ON_NOTICE, `task-board-on:${name}:${Date.now()}`);
20265
+ }
19959
20266
  if (change === "restart") {
19960
20267
  this.logger.info({ name }, "Instance config changed — restarting");
19961
20268
  observe?.(name, "restart", "running");
@@ -21429,7 +21736,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21429
21736
  await ipc.close();
21430
21737
  }
21431
21738
  this.instanceIpcClients.clear();
21432
- await Promise.allSettled(instanceNames.map(name => this.stopInstance(name)));
21739
+ await this.stopInstancesInBatches(instanceNames, name => this.stopInstance(name));
21433
21740
  // Kill remaining orphan windows to prevent stale state on restart
21434
21741
  try {
21435
21742
  const agendNames = new Set(instanceNames);
@@ -21750,6 +22057,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21750
22057
  if (this.guestScope(owner).size)
21751
22058
  return "ok";
21752
22059
  if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
22060
+ // Before adapters initialize, topic scope is unknown, not revoked. Keep the returning cookie but grant no
22061
+ // request until its configured world exists. Removed connections and initialized denied access stay final.
22062
+ const channels = this.fleetConfig.channels ?? (this.fleetConfig.channel ? [this.fleetConfig.channel] : []);
22063
+ if (this.startupDashboard && !this.worlds.has(owner.adapterId)
22064
+ && channels.some(channel => (channel.id ?? channel.type) === owner.adapterId))
22065
+ return "pending";
21753
22066
  const members = this.guestRooms(), rooms = guestGroupRooms(this.classicChannels?.getAll() ?? [], owner);
21754
22067
  if (rooms.some((room) => members.pending(room, owner.userId))) {
21755
22068
  void this.refreshGuestRooms(owner, { mode: "triggered" }); // rate-limited per room (30 s), single-flight
@@ -21848,7 +22161,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21848
22161
  res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
21849
22162
  return;
21850
22163
  }
21851
- if (closing) {
22164
+ if (closing || this.shuttingDown) {
21852
22165
  res.writeHead(503, { Connection: "close", "Retry-After": "10" });
21853
22166
  res.end(JSON.stringify({ error: "fleet stopping" }));
21854
22167
  return;
@@ -21896,6 +22209,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21896
22209
  // #1920: a guest reaches only GUEST_ROUTES, within the scope derived for it now — default deny, before any
21897
22210
  // handler, on this listener and the public link's gateway alike (both come through here).
21898
22211
  if (decision.via === "session" && decision.session.tier === "guest") {
22212
+ // A browser opening a page gets a page in its language, not the JSON an API caller gets (#2399 smoke).
22213
+ const navigating = req.method === "GET" && String(req.headers.accept ?? "").includes("text/html") && isWebPageNavigation(requestPath);
21899
22214
  const owner = decision.session.owner;
21900
22215
  const admitted = decision.session;
21901
22216
  const admission = this.guestAdmission(admitted);
@@ -21906,12 +22221,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21906
22221
  return;
21907
22222
  }
21908
22223
  if (admission === "gone") {
22224
+ if (navigating) {
22225
+ serveGuestRefusedPage(res, 401, "ended");
22226
+ return;
22227
+ }
21909
22228
  res.writeHead(401);
21910
22229
  res.end(JSON.stringify({ error: "guest_scope_empty" }));
21911
22230
  return;
21912
22231
  }
21913
22232
  const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
21914
22233
  if (verdict.kind === "deny") {
22234
+ if (navigating) {
22235
+ serveGuestRefusedPage(res, verdict.status, "members");
22236
+ return;
22237
+ }
21915
22238
  res.writeHead(verdict.status);
21916
22239
  res.end(JSON.stringify({ error: verdict.error }));
21917
22240
  return;
@@ -21944,6 +22267,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21944
22267
  res.end(JSON.stringify(health));
21945
22268
  return;
21946
22269
  }
22270
+ // Threat: lifecycle/config/chat writes racing boot-owned starts and reconciliation. Reads stay live;
22271
+ // authentication keeps its own checks so a browser can sign in or sign out while agents start.
22272
+ if (this.startupDashboard && !["GET", "HEAD", "OPTIONS"].includes(req.method ?? "GET")
22273
+ && !requestPath.startsWith("/auth/")) {
22274
+ res.writeHead(503, { "Retry-After": "2", "Cache-Control": "no-store", Connection: "close" });
22275
+ res.end(JSON.stringify({ code: "fleet_starting", error: t("fleet.starting_retry") }));
22276
+ return;
22277
+ }
21947
22278
  if (req.method === "GET" && req.url === "/status") {
21948
22279
  const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {
21949
22280
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
@@ -22214,6 +22545,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22214
22545
  }
22215
22546
  }
22216
22547
  startHealthServer(port) {
22548
+ let settled;
22549
+ const listening = new Promise(resolve => { settled = resolve; });
22217
22550
  this.startedAt = Date.now();
22218
22551
  this.healthServerListening = false;
22219
22552
  this.healthPortRetried = false;
@@ -22228,6 +22561,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22228
22561
  const drain = trackWebServer(server);
22229
22562
  this.healthServer = server;
22230
22563
  this.healthServerDrain = drain;
22564
+ server.once("close", settled);
22231
22565
  const markListening = (afterTakeover = false) => {
22232
22566
  this.healthServerListening = true;
22233
22567
  this.logger.info({ port }, afterTakeover
@@ -22241,6 +22575,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22241
22575
  // #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
22242
22576
  const bound = this.healthServer?.address();
22243
22577
  this.startPreviewListener(port, bound && typeof bound === "object" ? bound.port : port);
22578
+ settled();
22244
22579
  };
22245
22580
  this.healthServer.on("error", (err) => {
22246
22581
  this.healthServerListening = false;
@@ -22248,6 +22583,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22248
22583
  if (this.healthPortRetried) {
22249
22584
  this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
22250
22585
  this.notifyFleetError(t("dashboard.port_in_use", port));
22586
+ settled();
22251
22587
  return;
22252
22588
  }
22253
22589
  this.healthPortRetried = true;
@@ -22283,16 +22619,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22283
22619
  this.logger.debug({ err }, "Old fleet process kill skipped (already gone or no permission)");
22284
22620
  }
22285
22621
  setTimeout(() => {
22286
- if (!this.healthServer)
22622
+ if (this.healthServer !== server || this.shuttingDown) {
22623
+ settled();
22287
22624
  return;
22288
- this.healthServer.listen(port, "127.0.0.1", () => markListening(true));
22625
+ }
22626
+ server.listen(port, "127.0.0.1", () => markListening(true));
22289
22627
  }, 1500);
22290
22628
  return;
22291
22629
  }
22292
22630
  this.logger.error({ err, port }, "Health server error");
22293
22631
  this.notifyFleetError(t("dashboard.server_failed", err.message));
22632
+ settled();
22294
22633
  });
22295
22634
  this.healthServer.listen(port, "127.0.0.1", () => markListening());
22635
+ return listening;
22296
22636
  }
22297
22637
  /**
22298
22638
  * #1306: the preview listener, beside the web listener and closed with it. When it cannot listen (the port is
@@ -22521,6 +22861,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22521
22861
  return {
22522
22862
  canvas_worker: this.canvasWorkerEnabled(),
22523
22863
  instances,
22864
+ startup: this.startupDashboard?.snapshot() ?? null,
22524
22865
  publicLink: this.getPublicWebStatus(),
22525
22866
  uptime: Math.floor((Date.now() - this.startedAt) / 1000),
22526
22867
  // #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.