@songsid/agend 2.3.0-beta.25 → 2.3.0-beta.27

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 (440) hide show
  1. package/README.md +7 -5
  2. package/README.zh-TW.md +7 -5
  3. package/dist/adapter-world.js.map +1 -1
  4. package/dist/agend-home.d.ts +5 -0
  5. package/dist/agend-home.js +7 -0
  6. package/dist/agend-home.js.map +1 -0
  7. package/dist/agent-cli.js.map +1 -1
  8. package/dist/agent-endpoint.js +8 -0
  9. package/dist/agent-endpoint.js.map +1 -1
  10. package/dist/agent-file-read.js +1 -1
  11. package/dist/agent-file-read.js.map +1 -1
  12. package/dist/agent-whats-new.js +12 -2
  13. package/dist/agent-whats-new.js.map +1 -1
  14. package/dist/apply-job.js.map +1 -1
  15. package/dist/atomic-write.js.map +1 -1
  16. package/dist/auth-api.d.ts +5 -0
  17. package/dist/auth-api.js +21 -0
  18. package/dist/auth-api.js.map +1 -1
  19. package/dist/authority/authority-state.d.ts +4 -0
  20. package/dist/authority/authority-state.js +17 -8
  21. package/dist/authority/authority-state.js.map +1 -1
  22. package/dist/authority/session-authorizations.js.map +1 -1
  23. package/dist/authority/web-authority.d.ts +14 -6
  24. package/dist/authority/web-authority.js +19 -5
  25. package/dist/authority/web-authority.js.map +1 -1
  26. package/dist/backend/antigravity.d.ts +2 -2
  27. package/dist/backend/antigravity.js +2 -1
  28. package/dist/backend/antigravity.js.map +1 -1
  29. package/dist/backend/claude-code.js.map +1 -1
  30. package/dist/backend/codex-package.js.map +1 -1
  31. package/dist/backend/codex-session-lookup.js.map +1 -1
  32. package/dist/backend/codex.js +1 -1
  33. package/dist/backend/codex.js.map +1 -1
  34. package/dist/backend/effort-metadata.js.map +1 -1
  35. package/dist/backend/grok.js.map +1 -1
  36. package/dist/backend/kiro-engine-ledger.js.map +1 -1
  37. package/dist/backend/kiro-kas-store.js.map +1 -1
  38. package/dist/backend/kiro-v2-store.js.map +1 -1
  39. package/dist/backend/kiro-v3-identity.js.map +1 -1
  40. package/dist/backend/kiro.js.map +1 -1
  41. package/dist/backend/launch-effort.js.map +1 -1
  42. package/dist/backend/mock.js.map +1 -1
  43. package/dist/backend/muse.js.map +1 -1
  44. package/dist/backend/opencode-permission.js.map +1 -1
  45. package/dist/backend/opencode.js.map +1 -1
  46. package/dist/backend/session-signals.js.map +1 -1
  47. package/dist/backend-availability.js.map +1 -1
  48. package/dist/backend-command.js.map +1 -1
  49. package/dist/backend-outage.js.map +1 -1
  50. package/dist/binary-probe.js.map +1 -1
  51. package/dist/bug-report.js.map +1 -1
  52. package/dist/bundled-skills.js.map +1 -1
  53. package/dist/cache-service.js +1 -1
  54. package/dist/cache-service.js.map +1 -1
  55. package/dist/channel/adapters/discord.d.ts +2 -0
  56. package/dist/channel/adapters/discord.js +40 -5
  57. package/dist/channel/adapters/discord.js.map +1 -1
  58. package/dist/channel/adapters/telegram.d.ts +6 -0
  59. package/dist/channel/adapters/telegram.js +54 -14
  60. package/dist/channel/adapters/telegram.js.map +1 -1
  61. package/dist/channel/mcp-server.js +10 -1
  62. package/dist/channel/mcp-server.js.map +1 -1
  63. package/dist/channel/mcp-tools.js +2 -2
  64. package/dist/channel/mcp-tools.js.map +1 -1
  65. package/dist/channel/message-queue.js.map +1 -1
  66. package/dist/channel/sticker-download.js.map +1 -1
  67. package/dist/channel/tool-router.js.map +1 -1
  68. package/dist/classic-channel-manager.js +3 -2
  69. package/dist/classic-channel-manager.js.map +1 -1
  70. package/dist/cli-entry.js.map +1 -1
  71. package/dist/cli-flags.js.map +1 -1
  72. package/dist/cli.d.ts +1 -0
  73. package/dist/cli.js +44 -9
  74. package/dist/cli.js.map +1 -1
  75. package/dist/command-table.js.map +1 -1
  76. package/dist/compact-watch.js.map +1 -1
  77. package/dist/completion-install.js.map +1 -1
  78. package/dist/completion.js.map +1 -1
  79. package/dist/config-validator.js +22 -6
  80. package/dist/config-validator.js.map +1 -1
  81. package/dist/config.js +8 -2
  82. package/dist/config.js.map +1 -1
  83. package/dist/connection-secrets.js.map +1 -1
  84. package/dist/context-guardian.js.map +1 -1
  85. package/dist/coredump-filter.js.map +1 -1
  86. package/dist/cpu-profile.js.map +1 -1
  87. package/dist/cross-instance-envelope.js.map +1 -1
  88. package/dist/daemon.d.ts +3 -0
  89. package/dist/daemon.js +76 -4
  90. package/dist/daemon.js.map +1 -1
  91. package/dist/daily-summary.js.map +1 -1
  92. package/dist/darwin-memory.js.map +1 -1
  93. package/dist/dashboard-live.d.ts +8 -0
  94. package/dist/dashboard-live.js +35 -0
  95. package/dist/dashboard-live.js.map +1 -0
  96. package/dist/dashboard-permissions.d.ts +69 -0
  97. package/dist/dashboard-permissions.js +138 -0
  98. package/dist/dashboard-permissions.js.map +1 -0
  99. package/dist/decision-list.js.map +1 -1
  100. package/dist/delivery-consumed-watch.js.map +1 -1
  101. package/dist/delivery-outbox.js.map +1 -1
  102. package/dist/delivery-outbox.test.js.map +1 -1
  103. package/dist/delivery-reconciliation.test.js.map +1 -1
  104. package/dist/diagnostic-redaction.js.map +1 -1
  105. package/dist/doctor.d.ts +3 -0
  106. package/dist/doctor.js +10 -0
  107. package/dist/doctor.js.map +1 -1
  108. package/dist/e2e-isolation.js.map +1 -1
  109. package/dist/event-log.js.map +1 -1
  110. package/dist/export-import.js +3 -2
  111. package/dist/export-import.js.map +1 -1
  112. package/dist/fleet-control-audit.js.map +1 -1
  113. package/dist/fleet-label.js.map +1 -1
  114. package/dist/fleet-lock.js +3 -2
  115. package/dist/fleet-lock.js.map +1 -1
  116. package/dist/fleet-manager.d.ts +84 -7
  117. package/dist/fleet-manager.js +562 -158
  118. package/dist/fleet-manager.js.map +1 -1
  119. package/dist/fleet-process-handlers.js +3 -2
  120. package/dist/fleet-process-handlers.js.map +1 -1
  121. package/dist/fleet-startup.d.ts +14 -0
  122. package/dist/fleet-startup.js +30 -0
  123. package/dist/fleet-startup.js.map +1 -0
  124. package/dist/fleet-traffic.js.map +1 -1
  125. package/dist/forged-envelope-scan.d.ts +0 -20
  126. package/dist/forged-envelope-scan.js +3 -2
  127. package/dist/forged-envelope-scan.js.map +1 -1
  128. package/dist/full-restart.js.map +1 -1
  129. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +2 -0
  130. package/dist/general-knowledge/skills/web-appearance/SKILL.md +2 -0
  131. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +43 -21
  132. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +52 -23
  133. package/dist/host-memory.js.map +1 -1
  134. package/dist/idle-gate-log.d.ts +19 -0
  135. package/dist/idle-gate-log.js +15 -0
  136. package/dist/idle-gate-log.js.map +1 -0
  137. package/dist/instance-config-impact.js +6 -0
  138. package/dist/instance-config-impact.js.map +1 -1
  139. package/dist/instance-lifecycle.js +1 -1
  140. package/dist/instance-lifecycle.js.map +1 -1
  141. package/dist/instructions.d.ts +2 -0
  142. package/dist/instructions.js +6 -2
  143. package/dist/instructions.js.map +1 -1
  144. package/dist/kiro-compact-pane.js.map +1 -1
  145. package/dist/kiro-db-reader.js.map +1 -1
  146. package/dist/kiro-engine-status.js.map +1 -1
  147. package/dist/kiro-log-gate.js.map +1 -1
  148. package/dist/kiro-transcript-lane.js.map +1 -1
  149. package/dist/locale.js +40 -6
  150. package/dist/locale.js.map +1 -1
  151. package/dist/log-service.js.map +1 -1
  152. package/dist/log-steps.js.map +1 -1
  153. package/dist/logger.js +3 -2
  154. package/dist/logger.js.map +1 -1
  155. package/dist/login-controller.js.map +1 -1
  156. package/dist/login-manager.js.map +1 -1
  157. package/dist/login-shell.js.map +1 -1
  158. package/dist/memory-pressure.js.map +1 -1
  159. package/dist/muse-usage-relay.js.map +1 -1
  160. package/dist/needs-you-hub.js.map +1 -1
  161. package/dist/needs-you-live.js.map +1 -1
  162. package/dist/network-family.js.map +1 -1
  163. package/dist/operator-control-paths.js +3 -4
  164. package/dist/operator-control-paths.js.map +1 -1
  165. package/dist/outbound-handlers.js.map +1 -1
  166. package/dist/outbound-schemas.d.ts +68 -68
  167. package/dist/outbound-schemas.js +1 -1
  168. package/dist/outbound-schemas.js.map +1 -1
  169. package/dist/outbox-open-error.js.map +1 -1
  170. package/dist/pane-context-cache.js.map +1 -1
  171. package/dist/paths.js.map +1 -1
  172. package/dist/private-dir.js.map +1 -1
  173. package/dist/probe-worker-pool.js.map +1 -1
  174. package/dist/profile-control.js.map +1 -1
  175. package/dist/prompt-file-ref.js.map +1 -1
  176. package/dist/provider-probe.js.map +1 -1
  177. package/dist/provider-secret-registry.js.map +1 -1
  178. package/dist/public-link-auto-open.d.ts +32 -0
  179. package/dist/public-link-auto-open.js +86 -0
  180. package/dist/public-link-auto-open.js.map +1 -0
  181. package/dist/public-link-owner.d.ts +10 -0
  182. package/dist/public-link-owner.js +36 -0
  183. package/dist/public-link-owner.js.map +1 -0
  184. package/dist/public-link-progress.js.map +1 -1
  185. package/dist/public-preview.js.map +1 -1
  186. package/dist/public-web-gateway.js +7 -2
  187. package/dist/public-web-gateway.js.map +1 -1
  188. package/dist/public-web-link.d.ts +35 -1
  189. package/dist/public-web-link.js +142 -43
  190. package/dist/public-web-link.js.map +1 -1
  191. package/dist/quickstart-api.js.map +1 -1
  192. package/dist/quickstart.d.ts +5 -0
  193. package/dist/quickstart.js +25 -16
  194. package/dist/quickstart.js.map +1 -1
  195. package/dist/reply-buttons-controller.js.map +1 -1
  196. package/dist/reply-dedup.js.map +1 -1
  197. package/dist/resource-report.js.map +1 -1
  198. package/dist/restart-guard.js.map +1 -1
  199. package/dist/restart-progress.js.map +1 -1
  200. package/dist/rollout-index.js.map +1 -1
  201. package/dist/runtime-cpu-profile.js.map +1 -1
  202. package/dist/scheduler/db.js.map +1 -1
  203. package/dist/scheduler/scheduler.js.map +1 -1
  204. package/dist/scheduler/task-board-text.js +16 -11
  205. package/dist/scheduler/task-board-text.js.map +1 -1
  206. package/dist/scheduler/task-board.d.ts +39 -11
  207. package/dist/scheduler/task-board.js +123 -45
  208. package/dist/scheduler/task-board.js.map +1 -1
  209. package/dist/scheduler/types.d.ts +1 -1
  210. package/dist/sd-notify.js.map +1 -1
  211. package/dist/search/search-index.js.map +1 -1
  212. package/dist/secret-file.js.map +1 -1
  213. package/dist/secret-store.js.map +1 -1
  214. package/dist/service-installer.d.ts +7 -1
  215. package/dist/service-installer.js +13 -1
  216. package/dist/service-installer.js.map +1 -1
  217. package/dist/service-plan.d.ts +33 -4
  218. package/dist/service-plan.js +177 -8
  219. package/dist/service-plan.js.map +1 -1
  220. package/dist/session-oom.d.ts +21 -0
  221. package/dist/session-oom.js +139 -0
  222. package/dist/session-oom.js.map +1 -0
  223. package/dist/settings-api.js +3 -1
  224. package/dist/settings-api.js.map +1 -1
  225. package/dist/settings-baseline.js.map +1 -1
  226. package/dist/settings-change.d.ts +5 -0
  227. package/dist/settings-change.js +71 -1
  228. package/dist/settings-change.js.map +1 -1
  229. package/dist/settings-confirmation.d.ts +4 -0
  230. package/dist/settings-confirmation.js +2 -0
  231. package/dist/settings-confirmation.js.map +1 -1
  232. package/dist/settings-control.js.map +1 -1
  233. package/dist/settings-effect.js +4 -1
  234. package/dist/settings-effect.js.map +1 -1
  235. package/dist/settings-http-confirmation.js +1 -0
  236. package/dist/settings-http-confirmation.js.map +1 -1
  237. package/dist/settings-instance-patch.js +13 -1
  238. package/dist/settings-instance-patch.js.map +1 -1
  239. package/dist/settings-transaction.js.map +1 -1
  240. package/dist/setup-auth.js.map +1 -1
  241. package/dist/setup-host.d.ts +5 -1
  242. package/dist/setup-host.js +8 -1
  243. package/dist/setup-host.js.map +1 -1
  244. package/dist/setup-wizard.js +5 -2
  245. package/dist/setup-wizard.js.map +1 -1
  246. package/dist/spawn-gate.d.ts +4 -0
  247. package/dist/spawn-gate.js +15 -5
  248. package/dist/spawn-gate.js.map +1 -1
  249. package/dist/start-attempt.d.ts +1 -0
  250. package/dist/start-attempt.js +18 -0
  251. package/dist/start-attempt.js.map +1 -0
  252. package/dist/start-failure-notice.d.ts +4 -0
  253. package/dist/start-failure-notice.js +20 -0
  254. package/dist/start-failure-notice.js.map +1 -0
  255. package/dist/start-failure.d.ts +147 -0
  256. package/dist/start-failure.js +281 -0
  257. package/dist/start-failure.js.map +1 -0
  258. package/dist/startup-memory-deadline.d.ts +23 -0
  259. package/dist/startup-memory-deadline.js +55 -0
  260. package/dist/startup-memory-deadline.js.map +1 -0
  261. package/dist/statusline-watcher.js.map +1 -1
  262. package/dist/storm-window.js.map +1 -1
  263. package/dist/systemd-peer-bus.js.map +1 -1
  264. package/dist/target-queue-worker.js.map +1 -1
  265. package/dist/task-board-summary.js.map +1 -1
  266. package/dist/task-board-tool.js +3 -2
  267. package/dist/task-board-tool.js.map +1 -1
  268. package/dist/task-board-waker.js +8 -4
  269. package/dist/task-board-waker.js.map +1 -1
  270. package/dist/tips.js.map +1 -1
  271. package/dist/tmux-control.js.map +1 -1
  272. package/dist/tmux-exec-helper.js.map +1 -1
  273. package/dist/tmux-manager.d.ts +1 -0
  274. package/dist/tmux-manager.js +2 -1
  275. package/dist/tmux-manager.js.map +1 -1
  276. package/dist/tmux-read.js +1 -1
  277. package/dist/tmux-read.js.map +1 -1
  278. package/dist/tool-permissions.js.map +1 -1
  279. package/dist/tool-progress.js.map +1 -1
  280. package/dist/topic-archiver.js.map +1 -1
  281. package/dist/topic-commands.js.map +1 -1
  282. package/dist/topic-link.js.map +1 -1
  283. package/dist/transcript-delta-reader.js.map +1 -1
  284. package/dist/transcript-jsonl.js.map +1 -1
  285. package/dist/transcript-monitor.js.map +1 -1
  286. package/dist/transcript-sources.js.map +1 -1
  287. package/dist/tunnel/cloudflared-install.js.map +1 -1
  288. package/dist/tunnel/cloudflared.js.map +1 -1
  289. package/dist/tunnel/lease.js +3 -2
  290. package/dist/tunnel/lease.js.map +1 -1
  291. package/dist/tunnel/manager.js.map +1 -1
  292. package/dist/tunnel/public-fetch.js.map +1 -1
  293. package/dist/tunnel/purpose-lane.js.map +1 -1
  294. package/dist/tunnel/types.js.map +1 -1
  295. package/dist/types.d.ts +22 -1
  296. package/dist/ui/chat-render.js +7 -17
  297. package/dist/ui/chat-store.js +171 -61
  298. package/dist/ui/chat-strings.js +9 -4
  299. package/dist/ui/chat-thread.js +48 -1
  300. package/dist/ui/fleet-canvas-layout.js +3 -0
  301. package/dist/ui/fleet-canvas.js +23 -20
  302. package/dist/ui/fleet-office-editor.js +149 -0
  303. package/dist/ui/fleet-office-model.js +358 -0
  304. package/dist/ui/fleet-office-pack.js +199 -0
  305. package/dist/ui/fleet-office-skin.js +474 -0
  306. package/dist/ui/fleet-office-store.js +49 -0
  307. package/dist/ui/fleet-office-zones.js +177 -0
  308. package/dist/ui/fleet-office.js +434 -0
  309. package/dist/ui/fleet-org.js +18 -5
  310. package/dist/ui/guest-refused.html +41 -0
  311. package/dist/ui/image-lightbox.js +9 -3
  312. package/dist/ui/panel-chat.js +35 -22
  313. package/dist/ui/panel-settings.js +24 -7
  314. package/dist/ui/settings-dialogs.js +11 -2
  315. package/dist/ui/settings-model.js +20 -0
  316. package/dist/ui/settings-strings.js +36 -0
  317. package/dist/ui/settings-web-authority.js +7 -2
  318. package/dist/ui/shared/agend-auth.js +2 -0
  319. package/dist/ui/shared/app-i18n.js +2 -2
  320. package/dist/ui/shared/app-prefs.js +2 -0
  321. package/dist/ui/shared/app-shell.js +62 -9
  322. package/dist/ui/shared/app-store.js +1 -0
  323. package/dist/ui/shared/app-stream.js +2 -1
  324. package/dist/ui/shared/app.css +164 -15
  325. package/dist/ui/shared/guest-refused.js +35 -0
  326. package/dist/ui/shared/header-tools.js +2 -2
  327. package/dist/ui/shared/instance-nav.js +25 -8
  328. package/dist/ui/shared/tokens.css +4 -0
  329. package/dist/ui/shared/ui-dialog.js +2 -2
  330. package/dist/ui/shared/usage-dialog.js +152 -6
  331. package/dist/ui/shared/view-strings.js +20 -0
  332. package/dist/ui/shared/web-authorize.js +3 -1
  333. package/dist/update-check.js.map +1 -1
  334. package/dist/update-dispatch.js.map +1 -1
  335. package/dist/update-install.js.map +1 -1
  336. package/dist/update-launch.js.map +1 -1
  337. package/dist/update-marker.js.map +1 -1
  338. package/dist/update-preflight.js.map +1 -1
  339. package/dist/update-progress.d.ts +3 -1
  340. package/dist/update-progress.js +3 -1
  341. package/dist/update-progress.js.map +1 -1
  342. package/dist/usage/format-rich.js.map +1 -1
  343. package/dist/usage/providers.d.ts +8 -0
  344. package/dist/usage/providers.js +6 -2
  345. package/dist/usage/providers.js.map +1 -1
  346. package/dist/usage/statusline-usage.js.map +1 -1
  347. package/dist/usage/usage-api.js +2 -1
  348. package/dist/usage/usage-api.js.map +1 -1
  349. package/dist/usage-report.js.map +1 -1
  350. package/dist/usage-rollup.js +1 -1
  351. package/dist/usage-rollup.js.map +1 -1
  352. package/dist/user-systemd.js.map +1 -1
  353. package/dist/view-api.js +1 -1
  354. package/dist/view-api.js.map +1 -1
  355. package/dist/wake-coordinator.js.map +1 -1
  356. package/dist/web-ambient.js.map +1 -1
  357. package/dist/web-api.d.ts +19 -2
  358. package/dist/web-api.js +180 -28
  359. package/dist/web-api.js.map +1 -1
  360. package/dist/web-app-icon.js.map +1 -1
  361. package/dist/web-appearance-policy.js.map +1 -1
  362. package/dist/web-appearance-tools.js.map +1 -1
  363. package/dist/web-appearance-view.js.map +1 -1
  364. package/dist/web-appearance.js.map +1 -1
  365. package/dist/web-auth.js +5 -4
  366. package/dist/web-auth.js.map +1 -1
  367. package/dist/web-backend-login.js.map +1 -1
  368. package/dist/web-card.js.map +1 -1
  369. package/dist/web-channel-echo.js.map +1 -1
  370. package/dist/web-chat-history.d.ts +41 -11
  371. package/dist/web-chat-history.js +93 -22
  372. package/dist/web-chat-history.js.map +1 -1
  373. package/dist/web-commands.js.map +1 -1
  374. package/dist/web-effect-bundle.d.ts +2 -2
  375. package/dist/web-effect-library.js.map +1 -1
  376. package/dist/web-effect-playback.js.map +1 -1
  377. package/dist/web-effect-profiles.d.ts +16 -16
  378. package/dist/web-effect-profiles.js +3 -2
  379. package/dist/web-effect-profiles.js.map +1 -1
  380. package/dist/web-effect-service.js.map +1 -1
  381. package/dist/web-effect-tools.js.map +1 -1
  382. package/dist/web-effect-trials.js.map +1 -1
  383. package/dist/web-effect-upload.js.map +1 -1
  384. package/dist/web-effect.d.ts +7 -7
  385. package/dist/web-effect.js.map +1 -1
  386. package/dist/web-experimental.js.map +1 -1
  387. package/dist/web-guest-access.js +1 -1
  388. package/dist/web-guest-access.js.map +1 -1
  389. package/dist/web-guest-rooms.js.map +1 -1
  390. package/dist/web-host-guard.js.map +1 -1
  391. package/dist/web-icons.js.map +1 -1
  392. package/dist/web-identity-color.js.map +1 -1
  393. package/dist/web-images.js.map +1 -1
  394. package/dist/web-libavoid.js.map +1 -1
  395. package/dist/web-login.js.map +1 -1
  396. package/dist/web-mentions.js.map +1 -1
  397. package/dist/web-mod-agent.js.map +1 -1
  398. package/dist/web-mod-store.js.map +1 -1
  399. package/dist/web-office-store.d.ts +104 -0
  400. package/dist/web-office-store.js +236 -0
  401. package/dist/web-office-store.js.map +1 -0
  402. package/dist/web-owner-css-store.js.map +1 -1
  403. package/dist/web-owner-css.js.map +1 -1
  404. package/dist/web-png.js.map +1 -1
  405. package/dist/web-prefs.js +2 -0
  406. package/dist/web-prefs.js.map +1 -1
  407. package/dist/web-profile.js.map +1 -1
  408. package/dist/web-reactions.d.ts +2 -1
  409. package/dist/web-request-context.d.ts +5 -0
  410. package/dist/web-request-context.js.map +1 -1
  411. package/dist/web-returning-device.js +1 -1
  412. package/dist/web-returning-device.js.map +1 -1
  413. package/dist/web-schedules.js.map +1 -1
  414. package/dist/web-send-dedupe.d.ts +36 -0
  415. package/dist/web-send-dedupe.js +69 -0
  416. package/dist/web-send-dedupe.js.map +1 -0
  417. package/dist/web-session-authorization.js.map +1 -1
  418. package/dist/web-session.js +1 -1
  419. package/dist/web-session.js.map +1 -1
  420. package/dist/web-settings-patch.d.ts +1 -0
  421. package/dist/web-settings-patch.js +6 -4
  422. package/dist/web-settings-patch.js.map +1 -1
  423. package/dist/web-settings-tools.js.map +1 -1
  424. package/dist/web-terminal-http.js.map +1 -1
  425. package/dist/web-terminal.js.map +1 -1
  426. package/dist/web-theme-store.js.map +1 -1
  427. package/dist/web-upload.d.ts +5 -1
  428. package/dist/web-upload.js +6 -4
  429. package/dist/web-upload.js.map +1 -1
  430. package/dist/web-widget.js.map +1 -1
  431. package/dist/webhook-emitter.js.map +1 -1
  432. package/dist/windows-shortcuts.js +1 -1
  433. package/dist/windows-shortcuts.js.map +1 -1
  434. package/dist/workflow-templates/default.md +1 -1
  435. package/dist/ws-server.js.map +1 -1
  436. package/dist/yaml-shared-nodes.js.map +1 -1
  437. package/launcher/agend-install.cjs +281 -6
  438. package/launcher/private-home.cjs +43 -0
  439. package/package.json +3 -2
  440. 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";
@@ -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";
@@ -213,6 +217,7 @@ import { assertExplicitInstanceRemoval } from "./instance-removal.js";
213
217
  import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
214
218
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
215
219
  import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
220
+ import { idleGateTimeoutLog } from "./idle-gate-log.js";
216
221
  import { DEFAULT_STUCK_TIMEOUT_MS, readLastInboundAt } from "./daemon.js";
217
222
  import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
218
223
  import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
@@ -235,12 +240,13 @@ import { CompactWatch, launchIdentity } from "./compact-watch.js";
235
240
  import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
236
241
  import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
237
242
  import { sharedRolloutIndex } from "./rollout-index.js";
243
+ import { TASK_BOARD_ON_NOTICE, dashboardRefusalIn, taskBoardSwitchedOn } from "./dashboard-permissions.js";
238
244
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
239
245
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken, sessionCookieUpgrade } from "./web-auth.js";
240
- import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
246
+ import { bypassesWebGate, handleAuthRequest, serveGuestRefusedPage, serveSigninPage } from "./auth-api.js";
241
247
  import { isWebPageNavigation } from "./web-shell-routes.js";
242
248
  import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
243
- import { authorityNow, classifyWebPeer, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
249
+ import { authorityNow, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
244
250
  import { AuthorityStateStore } from "./authority/authority-state.js";
245
251
  import { SessionAuthorizations } from "./authority/session-authorizations.js";
246
252
  import { SessionAuthorizationRequests } from "./web-session-authorization.js";
@@ -267,6 +273,7 @@ import { carryClaudeConversation } from "./backend/claude-carry.js";
267
273
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
268
274
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
269
275
  import { cleanupWatchVerdict, formatRestartProgressCompletion, RestartProgress, } from "./restart-progress.js";
276
+ import { FleetStartup } from "./fleet-startup.js";
270
277
  import { launchFullRestartHelper } from "./full-restart.js";
271
278
  import { fullReloadExitCode } from "./service-owner.js";
272
279
  import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
@@ -671,7 +678,8 @@ export class FleetManager {
671
678
  ]);
672
679
  logger = createLogger("info");
673
680
  /** Report each bundled skill collision once per fleet, across instance starts. */
674
- skippedBundledSkills = new Set();
681
+ /** #2482: per skills directory, the skipped set (names + their content) last said — this process's view of the record. */
682
+ skippedSkillsSaid = new Map();
675
683
  topicCommands;
676
684
  // sessionName → instanceName mapping for external sessions
677
685
  sessionRegistry = new Map();
@@ -814,6 +822,7 @@ export class FleetManager {
814
822
  // Adapter isolation: track state per adapter for retry + visibility
815
823
  /** #2175: this boot's progress message, whose completion may still be owed by its background retry. */
816
824
  startupProgress = null;
825
+ startupDashboard = null;
817
826
  adapterState = new Map();
818
827
  adapterRetryInFlight = new Map();
819
828
  /** Web Settings secret rotation is deliberately separate from reconnect
@@ -895,6 +904,7 @@ export class FleetManager {
895
904
  /** Days of event/activity history to keep. */
896
905
  static EVENT_LOG_RETENTION_DAYS = 30;
897
906
  watchdogTimer = null;
907
+ startupMemoryDeadline = null;
898
908
  eventLoopWatch = null;
899
909
  startedAt = 0;
900
910
  stormOpenNotifyTimer = null;
@@ -930,6 +940,28 @@ export class FleetManager {
930
940
  webFiles = new WebFileLedger({ limits: () => uploadLimits(this.fleetConfig?.web) });
931
941
  /** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
932
942
  webReplyOrder = new Map();
943
+ /**
944
+ * #1774: an instance name's reply generation — moved on when a removal of it starts, so replies sent from then on (a
945
+ * same-name replacement's) are told from the removed instance's. `webChatRemovals`: once a removal succeeded, the
946
+ * generations and the daemon whose replies are no longer anyone's to show.
947
+ */
948
+ webChatGenerations = new Map();
949
+ webChatRemovals = new Map();
950
+ /** #1774: a removal under way — its replies' fate waits for its outcome (success: theirs no more; failure: theirs). */
951
+ webChatRemovalsPending = new Map();
952
+ webChatGeneration(name) { return this.webChatGenerations.get(name) ?? 0; }
953
+ /** #1774: who an instance's reply is from, taken when it is sent: the name's generation and its daemon then. */
954
+ webChatIdentity(name) { return { generation: this.webChatGeneration(name), daemon: this.daemons.get(name) }; }
955
+ /** #1774: the reply's instance was not removed (a removal that failed changes nothing). */
956
+ webChatIdentityCurrent(name, id) {
957
+ const removed = this.webChatRemovals.get(name);
958
+ return !removed || (id.generation >= removed.below && (removed.daemon === undefined || id.daemon !== removed.daemon));
959
+ }
960
+ /** #1774: the removal still deciding this reply's fate (it was sent by what is being removed), or null. */
961
+ webChatRemovalPending(name, id) {
962
+ const pending = this.webChatRemovalsPending.get(name);
963
+ return pending && (id.generation < pending.below || (pending.daemon !== undefined && id.daemon === pending.daemon)) ? pending.done : null;
964
+ }
933
965
  /** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
934
966
  webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
935
967
  /** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
@@ -1015,6 +1047,11 @@ export class FleetManager {
1015
1047
  // r4.1 Q3 (b): the card's limits are read from fleet.yaml at each upload (a change applies without a restart).
1016
1048
  return (this.themeStoreInstance ??= new ThemeStore(this.dataDir, { limits: () => effectiveAppearanceLimits(this.fleetConfig?.web?.appearance).limits, effectsDropped: ids => this.effectLibrary.collectUnreferenced(ids) }));
1017
1049
  }
1050
+ officeStoreInstance;
1051
+ /** Fleet → Org chart → Office: the offices people made (web-offices.json, beside web-themes.json). */
1052
+ get officeStore() { return (this.officeStoreInstance ??= new OfficeStore(this.dataDir)); }
1053
+ /** How often a person may save the offices (PUT /ui/offices); memory only, as the appearance limiter. */
1054
+ officeWrites = new OfficeWriteLimiter();
1018
1055
  /** #1629: the theme tools, a person's reset and lock, and the one appearance rate limiter (shared with effects). */
1019
1056
  get webAppearance() {
1020
1057
  return (this.webAppearanceService ??= new WebAppearance(this.themeStore, () => this.fleetConfig?.web ?? null, () => { try {
@@ -1362,7 +1399,13 @@ export class FleetManager {
1362
1399
  const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
1363
1400
  if (auth.kind !== "ok")
1364
1401
  return null;
1365
- const record = auth.session, gateway = !!gatewayRequestContext(req), peer = classifyWebPeer(req);
1402
+ // #2453 (§0.2): never over the public link — not even a named-link session that is web-verified there.
1403
+ if (gatewayRequestContext(req))
1404
+ return null;
1405
+ // …and never a change that turns the named-link option on: that one needs a General's card or the host.
1406
+ if (!store.selfConfirmable(id))
1407
+ return null;
1408
+ const record = auth.session, gateway = !!gatewayRequestContext(req), peer = webPeerOf(req);
1366
1409
  const verified = () => {
1367
1410
  const now = this.webAuthority({ session: record, gateway, peer });
1368
1411
  // A refusal carries no authority (kind "none"); the handle pins the session, and with it its one account.
@@ -1629,6 +1672,7 @@ export class FleetManager {
1629
1672
  this.spawnGate = new SpawnGate({
1630
1673
  storm: this.stormWindow,
1631
1674
  memoryPressure: this.memoryPressure,
1675
+ onStartupMemoryWait: () => this.startupMemoryDeadline?.check(),
1632
1676
  concurrency: () => this.spawnConcurrency(),
1633
1677
  staggerMs: () => this.fleetConfig?.defaults?.startup?.stagger_delay_ms ?? 500,
1634
1678
  });
@@ -2674,6 +2718,7 @@ export class FleetManager {
2674
2718
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
2675
2719
  this.warnAboutRemovedLoginMode(loaded);
2676
2720
  this.enforceGuestSwitch();
2721
+ this.observeWebAuthority(); // #2453 review: a reload's web-authority switches are seen at once
2677
2722
  // Child tool/guidance readers must follow this fleet's actual config, including --config.
2678
2723
  // Scope the path to its owning data directory so another inherited home's override cannot enable CW.
2679
2724
  process.env.AGEND_FLEET_DATA_DIR = resolvePath(this.dataDir);
@@ -3280,7 +3325,8 @@ export class FleetManager {
3280
3325
  instancePresentation(name) {
3281
3326
  const execution_state = this.getInstanceExecutionState(name);
3282
3327
  const interaction = this.getInstanceInteraction(name);
3283
- return { state: presentationState(execution_state, interaction), execution_state,
3328
+ const startup_phase = !this.lifecycle.isPaused(name) ? this.startupDashboard?.phase(name) : undefined;
3329
+ return { ...(startup_phase ? { startup_phase } : {}), state: presentationState(execution_state, interaction), execution_state,
3284
3330
  interaction, interaction_summary: interactionSummary(interaction) };
3285
3331
  }
3286
3332
  /**
@@ -3714,7 +3760,9 @@ export class FleetManager {
3714
3760
  if (this.shuttingDown || !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
3715
3761
  return false;
3716
3762
  if (!idle) {
3717
- this.logger.warn({ instanceName, timeoutMs }, "Idle gate timed out; forcing delivery");
3763
+ // #2482: a busy target is the normal case (its CLI queues the message): INFO; a WARN only for a likely anomaly.
3764
+ const log = idleGateTimeoutLog(instanceName, timeoutMs, this.instanceStateCache.get(instanceName), Date.now());
3765
+ this.logger[log.level](log.fields, log.message);
3718
3766
  }
3719
3767
  const sent = await this.sendWhenConnected(instanceName, payload, deliveryEpoch, stillCurrent);
3720
3768
  if (!sent)
@@ -3832,6 +3880,10 @@ export class FleetManager {
3832
3880
  // #2305: the copies' own message ids, so an instance's reaction to the web message is put on them too.
3833
3881
  if (copies.length)
3834
3882
  this.rememberWebEchoCopies(messageId, copies);
3883
+ // #2281: and where they are, for a later web reply to this message to be a native reply there.
3884
+ const posts = copies.flatMap(c => c.adapterId ? [{ adapterId: c.adapterId, chatId: c.chatId, ...(c.threadId ? { threadId: c.threadId } : {}), messageId: c.messageId }] : []);
3885
+ if (posts.length)
3886
+ this.webChatHistory.addPosts(instance, messageId, posts);
3835
3887
  const m = this.webChatHistory.markChannel(instance, messageId);
3836
3888
  if (m)
3837
3889
  broadcastToGuests(this.sseClients, "message", m, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
@@ -3882,7 +3934,11 @@ export class FleetManager {
3882
3934
  return { ok: true, mirrored };
3883
3935
  }
3884
3936
  /** #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) {
3937
+ /**
3938
+ * `reply` (#2281): where the message a web reply quotes is on a platform. A room that has it gets the reply as a
3939
+ * native reply to it (`reply.text`: without the quoted line); every other room gets `text`, as before.
3940
+ */
3941
+ async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK, copies, reply) {
3886
3942
  const guard = this.webChannelEchoGuards.get(instance);
3887
3943
  const handled = new Set();
3888
3944
  let posted = 0;
@@ -3912,9 +3968,11 @@ export class FleetManager {
3912
3968
  try {
3913
3969
  if (!adapter?.sendText)
3914
3970
  continue;
3915
- const sent = await adapter.sendText(entry.channelId, text, { format: "text", allowedMentions: { parse: [] } });
3971
+ // A Telegram room is its chat; a Discord room is the guild's channel (an inbound's thread there).
3972
+ const native = reply?.posts.find(p => p.adapterId === adapter.id && (p.chatId === entry.channelId || p.threadId === entry.channelId));
3973
+ const sent = await adapter.sendText(entry.channelId, native ? reply.text : text, { format: "text", allowedMentions: { parse: [] }, ...(native ? { replyTo: native.messageId } : {}) });
3916
3974
  if (copies && sent && typeof sent.messageId === "string" && sent.messageId)
3917
- copies.push({ adapter, chatId: entry.channelId, messageId: sent.messageId });
3975
+ copies.push({ adapter, adapterId: adapter.id, chatId: entry.channelId, messageId: sent.messageId });
3918
3976
  ClassicChannelManager.logMessage(instance, sender, text, new Date());
3919
3977
  posted++;
3920
3978
  }
@@ -4952,7 +5010,7 @@ export class FleetManager {
4952
5010
  return;
4953
5011
  }
4954
5012
  if (this.lifecycle.isPaused(name)) {
4955
- this.logger.info({ name }, "Persisted paused instance — skipping startup");
5013
+ this.logger.debug({ name }, "Persisted paused instance — skipping startup"); // #2482: every start and reload
4956
5014
  return;
4957
5015
  }
4958
5016
  if (this.lifecycle.daemons.has(name)) {
@@ -4971,66 +5029,92 @@ export class FleetManager {
4971
5029
  this.logger.info({ name }, "Instance already running, skipping");
4972
5030
  return;
4973
5031
  }
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"));
5032
+ await this.trackStartupInstance(name, async () => {
5033
+ // An explicit start (CLI/API) begins a fresh idle window; boot and
5034
+ // reconcile starts keep the persisted last-inbound seed (Phase 2a).
5035
+ if (resumePaused)
5036
+ this.lifecycle.markActivitySeedNow(name);
5037
+ const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
5038
+ if (config.general_topic) {
5039
+ this.ensureGeneralInstructions(config.working_directory, backend, name);
4989
5040
  }
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");
5041
+ else if (kind === "fleet-topic") {
5042
+ // Workers get only role-eligible on-demand skills. Classic instances are
5043
+ // deliberately excluded: their workspace and conversation lifecycle are
5044
+ // managed independently from fleet-topic workers.
5045
+ try {
5046
+ const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
5047
+ measureSyncWork("fleet.workerSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "worker"));
5048
+ }
5049
+ catch (err) {
5050
+ // Skill publishing is additive. A read-only or temporarily unavailable
5051
+ // workspace must not turn an otherwise valid worker startup into a
5052
+ // fleet outage.
5053
+ this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
5054
+ }
4995
5055
  }
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");
5056
+ else if (kind === "classic") {
5057
+ // #2163: a Classic bot gets only the skills marked `classic` (its own look, the dashboard) — never the fleet's
5058
+ // coordination playbooks (#557 kept those out of end-user bots) — and only into a workspace AgEnD manages: the
5059
+ // room's own folder under <AGEND_HOME>/workspaces/. Anywhere else, nothing is written; `agend-agent skill <name>`
5060
+ // reads any bundled skill on demand.
5061
+ try {
5062
+ const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
5063
+ if (insideManagedWorkspaces(skillsWorkDir))
5064
+ measureSyncWork("fleet.classicSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "classic"));
5065
+ else
5066
+ this.logger.debug({ name, skillsWorkDir }, "Classic workspace is not AgEnD-managed — no skills published");
5067
+ }
5068
+ catch (err) {
5069
+ this.logger.warn({ err, name, backend }, "Failed to sync classic skills — continuing startup");
5070
+ }
5008
5071
  }
5009
- catch (err) {
5010
- this.logger.warn({ err, name, backend }, "Failed to sync classic skills — continuing startup");
5072
+ const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
5073
+ const identity = { kind, backend, model: this.resolveInstanceModel(name).display };
5074
+ if (receipt) {
5075
+ await this.lifecycle.start(name, config, topicMode, identity, transition, execution, daemon => {
5076
+ // Only this transition's actual publication can acquire its replacement.
5077
+ receipt.daemon = daemon;
5078
+ });
5011
5079
  }
5080
+ else
5081
+ await this.lifecycle.start(name, config, topicMode, identity, transition, execution);
5082
+ execution?.assert();
5083
+ // Only clear a stale process status after a real start succeeded. Clearing
5084
+ // it before lifecycle.start() can turn a crash-loop daemon's dead pane into
5085
+ // a falsely running instance when lifecycle.start() returns early.
5086
+ this.instanceProcessStatus.delete(name);
5087
+ // Recovery intent belongs to Daemon.start. lifecycle.start can return
5088
+ // before reaching it, so an await here is not proof the marker was read.
5089
+ // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
5090
+ await this.connectIpcToInstance(name);
5091
+ execution?.assert();
5092
+ this.requestDiscordUsagePresenceRefresh();
5093
+ });
5094
+ }
5095
+ /** A boot's observation cannot settle a replacement boot or a newer attempt of the same instance. */
5096
+ async trackStartupInstance(name, start) {
5097
+ const boot = this.startupDashboard;
5098
+ const owner = boot?.begin(name);
5099
+ if (owner)
5100
+ this.publishStartupStatus();
5101
+ let ready = false;
5102
+ try {
5103
+ await start();
5104
+ ready = this.daemons.has(name);
5012
5105
  }
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
- });
5106
+ finally {
5107
+ if (boot && owner && this.startupDashboard === boot && boot.finish(name, owner, ready))
5108
+ this.publishStartupStatus();
5109
+ }
5110
+ }
5111
+ publishStartupStatus() {
5112
+ try {
5113
+ this.emitSseEvent("status", this.getUiStatus());
5114
+ }
5115
+ catch (err) {
5116
+ this.logger.debug({ err }, "Startup status unavailable; the next status frame retries");
5020
5117
  }
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
5118
  }
5035
5119
  /** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
5036
5120
  async startPersistedPausedInstance(name, transition) {
@@ -5972,6 +6056,11 @@ export class FleetManager {
5972
6056
  revoke: id => {
5973
6057
  this.webLoginCodes?.revokeAudience(id);
5974
6058
  this.webGuestLoginCodes?.revokeAudience(id);
6059
+ // #2453 (§0.2): the link's sessions end, and the authorizations kept for them go with them (housekeeping: their
6060
+ // handles can never match again).
6061
+ const ended = new Set(this.webSessions?.list(undefined, r => r.exposureId === id).map(r => r.handle) ?? []);
6062
+ if (ended.size)
6063
+ this.sessionAuthorizationStore()?.prune(a => !ended.has(a.handle));
5975
6064
  const result = this.webSessions?.revokeExposure(id);
5976
6065
  if (result && !result.durable)
5977
6066
  this.logger.warn("Public session revocation could not be saved; exposure remains closed");
@@ -5992,6 +6081,66 @@ export class FleetManager {
5992
6081
  },
5993
6082
  });
5994
6083
  }
6084
+ publicLinkAutoOpen = null;
6085
+ /**
6086
+ * #named-link-ttl: the admin who last opened a named-tunnel link from /web, with the live binding — only while they
6087
+ * still may own one (publicOwnerCurrent: a fleet admin of a live bot, the same General). Else null.
6088
+ */
6089
+ rememberedPublicOwner() {
6090
+ const saved = readPublicLinkOwner(this.dataDir);
6091
+ if (!saved)
6092
+ return null;
6093
+ const owner = { ...saved, binding: this.adapters.get(saved.adapterId) };
6094
+ return this.publicOwnerCurrent(owner) ? owner : null;
6095
+ }
6096
+ /** A named-tunnel link was privately delivered: auto_open reopens it for this owner after a restart. */
6097
+ rememberPublicLinkOwner(owner) {
6098
+ if (!publicLinkNamedHostname(this.fleetConfig?.web))
6099
+ return;
6100
+ try {
6101
+ writePublicLinkOwner(this.dataDir, owner);
6102
+ }
6103
+ catch (err) {
6104
+ this.logger.warn({ err: err?.message }, "Could not remember the public link's owner; auto_open will not reopen it");
6105
+ }
6106
+ }
6107
+ /** Closed on purpose (Close, /web revoke): a restart must not reopen it. The next /web remembers again. */
6108
+ forgetPublicLinkOwner() {
6109
+ try {
6110
+ clearPublicLinkOwner(this.dataDir);
6111
+ }
6112
+ catch (err) {
6113
+ this.logger.warn({ err: err?.message }, "Could not forget the public link's owner");
6114
+ }
6115
+ }
6116
+ /**
6117
+ * #named-link-ttl: `web.public_link.auto_open` — once per start, after the web listener and the chat connections are
6118
+ * up. Opens the named link with nothing posted (sign-in still needs a code from /web or a returning device); a final
6119
+ * failure goes to its owner once, by DM, drawn as a failed /web is.
6120
+ */
6121
+ startPublicLinkAutoOpen() {
6122
+ this.publicLinkAutoOpen?.stop();
6123
+ const runner = this.publicLinkAutoOpen = new PublicLinkAutoOpen({
6124
+ web: () => this.fleetConfig?.web,
6125
+ ready: () => !this.shuttingDown && this.healthServerListening && this.publicLinkAutoOpen === runner,
6126
+ owner: () => this.rememberedPublicOwner(),
6127
+ open: (owner, onProgress) => this.getPublicWebLink().openUnattended(owner, onProgress),
6128
+ notifyFailed: async (owner, last) => {
6129
+ const adapter = owner.binding;
6130
+ if (this.shuttingDown || !adapter?.sendDirect || !this.publicOwnerCurrent(owner))
6131
+ return;
6132
+ const outcome = t("dashboard.public_auto_failed");
6133
+ try {
6134
+ await withinBudget(adapter.sendDirect(owner.userId, last ? renderPublicLinkProgress(last, performance.now(), outcome) : outcome, { disablePreview: true }), performance.now() + 5_000);
6135
+ }
6136
+ catch (err) {
6137
+ this.logDashboardDmFailure(err);
6138
+ }
6139
+ },
6140
+ log: (event, fields) => this.logger.info({ event, ...fields }, "Public web link"),
6141
+ });
6142
+ runner.start();
6143
+ }
5995
6144
  getPublicWebStatus() {
5996
6145
  return this.publicWebLink?.status() ?? { state: "closed" };
5997
6146
  }
@@ -6175,8 +6324,9 @@ export class FleetManager {
6175
6324
  // Show the action before any link exists. TG's Start instructions belong to confirmed DM refusal below.
6176
6325
  const menuText = this.topicCommands.getDashboardMenuText()
6177
6326
  + (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)) : "") : "");
6327
+ + (status.state === "closed" ? "" : status.noExpiry ? "\n" + t("dashboard.public_status_no_expiry", status.state)
6328
+ : "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
6329
+ + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : ""));
6180
6330
  const choices = [{ action: "local", label: t("dashboard.local_signin") },
6181
6331
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
6182
6332
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
@@ -6223,6 +6373,7 @@ export class FleetManager {
6223
6373
  if (!entry.publicExposureId)
6224
6374
  return true;
6225
6375
  const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
6376
+ this.forgetPublicLinkOwner();
6226
6377
  if (result?.confirmed === false)
6227
6378
  data.ack?.(t("dashboard.public_cleanup"));
6228
6379
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"), data.editClicked);
@@ -6253,10 +6404,11 @@ export class FleetManager {
6253
6404
  return false;
6254
6405
  this.initializeWebSessions();
6255
6406
  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.
6407
+ // Only a public link has a link expiry (null: none — a named tunnel's ttl_minutes 0); the code's five-minute
6408
+ // lifetime applies to either packet.
6257
6409
  const minutes = Math.round(LOGIN_CODE_TTL_MS / 60_000);
6258
6410
  const text = expiresAt !== undefined
6259
- ? t("dashboard.private_public", url, issued.display, minutes, formatFleetTime(expiresAt))
6411
+ ? t("dashboard.private_public", url, issued.display, minutes, expiresAt === null ? t("dashboard.public_no_expiry") : formatFleetTime(expiresAt))
6260
6412
  : t("dashboard.private_local", url, issued.display, minutes);
6261
6413
  const deadline = performance.now() + 10_000;
6262
6414
  const direct = owner.binding;
@@ -6319,6 +6471,8 @@ export class FleetManager {
6319
6471
  // Still closes the step editor below and tells the clicker: a throw must not leave the menu refreshing.
6320
6472
  this.logger.warn({ err }, "Dashboard sign-in delivery failed");
6321
6473
  }
6474
+ if (ok && action === "public")
6475
+ this.rememberPublicLinkOwner(owner);
6322
6476
  // Only safe, static words enter General; never link, code or platform error text.
6323
6477
  // #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
6324
6478
  // with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
@@ -6343,6 +6497,7 @@ export class FleetManager {
6343
6497
  revokeWebSessions() {
6344
6498
  this.initializeWebSessions();
6345
6499
  void this.publicWebLink?.close("dashboard revoke");
6500
+ this.forgetPublicLinkOwner();
6346
6501
  this.webLoginCodes.revoke();
6347
6502
  this.webGuestLoginCodes.revokeAll();
6348
6503
  this.guestRevokes++; // and every guest /web still on its way
@@ -6944,7 +7099,7 @@ export class FleetManager {
6944
7099
  if (this.updateProgressTimer)
6945
7100
  clearInterval(this.updateProgressTimer);
6946
7101
  const tick = async () => {
6947
- if (this.updateProgressEditRunning)
7102
+ if (this.updateProgressEditRunning || this.shuttingDown)
6948
7103
  return;
6949
7104
  const marker = readUpdateProgress(this.dataDir);
6950
7105
  if (!marker) {
@@ -6995,6 +7150,11 @@ export class FleetManager {
6995
7150
  return;
6996
7151
  }
6997
7152
  }
7153
+ // #2476: from "starting" onward RestartProgress in the NEW process owns this
7154
+ // message (resume → N/M → ✅ finish). A late CLI-stage edit of "installed" is
7155
+ // what made "✅ 安裝完成" land after "啟動中 N/M". Failure still edits here.
7156
+ if (marker.progress.stage === "starting")
7157
+ return;
6998
7158
  const target = marker.progress.target;
6999
7159
  const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
7000
7160
  if (!adapter)
@@ -7030,11 +7190,46 @@ export class FleetManager {
7030
7190
  }
7031
7191
  /** Start all instances from fleet config */
7032
7192
  async startAll(configPath) {
7193
+ this.startupMemoryDeadline?.stop();
7194
+ const deadline = new StartupMemoryDeadline({
7195
+ current: () => this.startupMemoryDeadline === deadline && !this.shuttingDown,
7196
+ waiting: () => this.spawnGate.isStartupMemoryDeferred(),
7197
+ notify: sdNotify,
7198
+ log: message => this.logger.info(message),
7199
+ });
7200
+ this.startupMemoryDeadline = deadline;
7201
+ // A held first General must not postpone event-loop liveness monitoring.
7202
+ // No independent heartbeat: a genuinely frozen main loop still stops pinging.
7203
+ setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
7204
+ if (this.watchdogTimer)
7205
+ clearInterval(this.watchdogTimer);
7206
+ this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
7207
+ this.eventLoopWatch?.stop();
7208
+ this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
7209
+ try {
7210
+ await this.startAllWithinDeadline(configPath, deadline);
7211
+ }
7212
+ finally {
7213
+ deadline.stop();
7214
+ if (this.startupMemoryDeadline === deadline) {
7215
+ this.startupMemoryDeadline = null;
7216
+ if (!this.startupComplete) {
7217
+ if (this.watchdogTimer)
7218
+ clearInterval(this.watchdogTimer);
7219
+ this.watchdogTimer = null;
7220
+ this.eventLoopWatch?.stop();
7221
+ this.eventLoopWatch = null;
7222
+ }
7223
+ }
7224
+ }
7225
+ }
7226
+ async startAllWithinDeadline(configPath, deadline) {
7033
7227
  this.loginWindow.reopen(); // a stopAll → startAll restart must accept login windows again
7034
7228
  this.loginController?.reopen();
7035
7229
  const startupStartedAt = Date.now();
7036
7230
  FleetManager.signalTarget = this;
7037
7231
  this.startupComplete = false;
7232
+ this.startupDashboard = null;
7038
7233
  // Cleared here, not at the end of doStopAll: a stop has an async tail, and
7039
7234
  // anything arriving during it is still part of the stop.
7040
7235
  this.shuttingDown = false;
@@ -7286,7 +7481,7 @@ export class FleetManager {
7286
7481
  continue;
7287
7482
  this.logger.warn({ adapter: need.adapterId, name }, "No general instance for adapter — auto-creating");
7288
7483
  const generalDir = join(getAgendHome(), name);
7289
- mkdirSync(generalDir, { recursive: true });
7484
+ mkdirSync(generalDir, { recursive: true, mode: 0o700 });
7290
7485
  const backendName = fleet.defaults.backend ?? "claude-code";
7291
7486
  this.ensureGeneralInstructions(generalDir, backendName, name);
7292
7487
  fleet.instances[name] = {
@@ -7337,6 +7532,13 @@ export class FleetManager {
7337
7532
  const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
7338
7533
  const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingProgressOperation === "full-restart" ? "reload" : pendingUpdateProgress ? "update" : "restart" });
7339
7534
  this.startupProgress = startupProgress;
7535
+ // Auth, restored chat, Classic roster and the task DB are ready. Listening is not service readiness:
7536
+ // the dashboard observes startup, while /health and READY retain their existing completion boundary.
7537
+ this.startupDashboard = new FleetStartup(this.configuredStartupInstanceNames(fleet, topicMode)
7538
+ .filter(name => !this.lifecycle.isPaused(name)));
7539
+ await this.startHealthServer(fleet.health_port ?? 19280);
7540
+ if (this.shuttingDown)
7541
+ return;
7340
7542
  if (generals.length > 0) {
7341
7543
  for (const [name, cfg] of generals) {
7342
7544
  try {
@@ -7401,22 +7603,6 @@ export class FleetManager {
7401
7603
  return startupProgress.start(this.restartProgressTarget());
7402
7604
  });
7403
7605
  }
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
7606
  // EventLog.prune() existed but was never called, so `events` and `activity`
7421
7607
  // grew without bound for the life of the install. Prune once at startup and
7422
7608
  // daily after that; the timer is unref'd so it never holds the loop open.
@@ -7592,9 +7778,9 @@ export class FleetManager {
7592
7778
  }
7593
7779
  // After "fleet ready", so the notice is not the first thing people see of a restart.
7594
7780
  void this.announceWebChatOnce(agendVersion);
7781
+ // #named-link-ttl: a named link with auto_open comes back now — after the web listener and the chat connections.
7782
+ this.startPublicLinkAutoOpen();
7595
7783
  }
7596
- // Health HTTP endpoint
7597
- this.startHealthServer(fleet.health_port ?? 19280);
7598
7784
  // #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
7599
7785
  // #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
7600
7786
  void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
@@ -7611,12 +7797,26 @@ export class FleetManager {
7611
7797
  // A SIGHUP may arrive after the PID/general is available but before the
7612
7798
  // rest of startup finishes. Replay one coalesced reload only after all
7613
7799
  // startup-owned lifecycle work and signal handlers are in place.
7800
+ if (this.shuttingDown || this.startupMemoryDeadline !== deadline)
7801
+ return;
7614
7802
  this.finishStartup(bootUpdateMarker);
7803
+ this.startupDashboard = null;
7804
+ this.publishStartupStatus();
7615
7805
  // Tell systemd we are ready only now. This used to fire right after the
7616
7806
  // generals started — before adapters, classic instances, topic creation and the
7617
7807
  // health server — so `systemctl start` returned success while the fleet was
7618
7808
  // still deaf: no path existed for a user message to arrive.
7619
7809
  sdNotify("READY=1");
7810
+ // #2481: this version runs — the CLI's start-failure banner stops, and this is the version to go back to later.
7811
+ try {
7812
+ const { ownPackage, recordStarted } = await import("./start-failure.js");
7813
+ const version = ownPackage().version;
7814
+ if (version)
7815
+ recordStarted(this.dataDir, version, process.pid, Date.now());
7816
+ }
7817
+ catch (err) {
7818
+ this.logger.debug({ err }, "fleet-started record not written");
7819
+ }
7620
7820
  const health = this.getFleetHealth();
7621
7821
  if (health.status !== "ok") {
7622
7822
  this.logger.warn({ health }, "Fleet started with problems — see /health");
@@ -8035,12 +8235,12 @@ export class FleetManager {
8035
8235
  return;
8036
8236
  }
8037
8237
  const accessDir = join(this.dataDir, "access");
8038
- mkdirSync(accessDir, { recursive: true });
8238
+ mkdirSync(accessDir, { recursive: true, mode: 0o700 });
8039
8239
  const accessStatePath = join(accessDir, "access.json");
8040
8240
  const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
8041
8241
  this.warnIfAccessModeOverridden(accessManager, accessStatePath);
8042
8242
  const inboxDir = join(this.dataDir, "inbox");
8043
- mkdirSync(inboxDir, { recursive: true });
8243
+ mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
8044
8244
  const adapterId = channelConfig.id ?? channelConfig.type;
8045
8245
  const adapter = await createAdapter(channelConfig, {
8046
8246
  id: adapterId,
@@ -8186,12 +8386,12 @@ export class FleetManager {
8186
8386
  return;
8187
8387
  }
8188
8388
  const accessDir = join(this.dataDir, "access");
8189
- mkdirSync(accessDir, { recursive: true });
8389
+ mkdirSync(accessDir, { recursive: true, mode: 0o700 });
8190
8390
  const accessStatePath = join(accessDir, `access-${adapterId}.json`);
8191
8391
  const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
8192
8392
  this.warnIfAccessModeOverridden(accessManager, accessStatePath);
8193
8393
  const inboxDir = join(this.dataDir, "inbox");
8194
- mkdirSync(inboxDir, { recursive: true });
8394
+ mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
8195
8395
  const adapter = await createAdapter(channelConfig, {
8196
8396
  id: adapterId,
8197
8397
  botToken,
@@ -9623,9 +9823,14 @@ export class FleetManager {
9623
9823
  * history (#1565). #1920 §5.2: said in the instance's topic, General or room, so it is that channel's, by that account.
9624
9824
  */
9625
9825
  emitPlatformInbound(instance, msg, text) {
9826
+ // #2281: where it is on the platform — a web reply to it is then a native reply there. The platform thread it came
9827
+ // from (#1085: a ClassicBot's `threadId` may be its registry key).
9828
+ const thread = msg.transportThreadId ?? msg.threadId;
9829
+ const posts = msg.adapterId && msg.chatId && msg.messageId
9830
+ ? [{ adapterId: msg.adapterId, chatId: msg.chatId, ...(thread && thread !== msg.chatId ? { threadId: thread } : {}), messageId: msg.messageId }] : undefined;
9626
9831
  this.emitSseEvent("message", {
9627
9832
  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),
9833
+ channel: true, from: webChatFrom(msg.adapterId, msg.userId), ...(posts ? { posts } : {}),
9629
9834
  });
9630
9835
  }
9631
9836
  /** Handle outbound tool calls from a daemon instance */
@@ -9735,6 +9940,13 @@ export class FleetManager {
9735
9940
  this.logger.warn({ sink, instance: instanceName, profile, tool, enforced: true }, "tool-permissions: refused");
9736
9941
  return { allowed: false, message: toolRefusedMessage(profile, tool) };
9737
9942
  }
9943
+ // The person's dashboard switches (dashboard-permissions.ts), on top of the profile, as configured now.
9944
+ // Its own switches, else the defaults' — a ClassicBot identity has no instances entry and runs on the defaults (#2391 review).
9945
+ const switchedOff = dashboardRefusalIn(this.fleetConfig, instanceName, tool);
9946
+ if (switchedOff) {
9947
+ this.logger.warn({ sink, instance: instanceName, profile, tool, enforced: true, dashboard: true }, "tool-permissions: refused");
9948
+ return { allowed: false, message: switchedOff };
9949
+ }
9738
9950
  this.logger.debug({ sink, instance: instanceName, profile, tool, enforced: true }, "tool-permissions: allowed");
9739
9951
  return { allowed: true, message: "" };
9740
9952
  }
@@ -9742,6 +9954,9 @@ export class FleetManager {
9742
9954
  this.touchActivity(instanceName);
9743
9955
  this.setTopicIcon(instanceName, "green");
9744
9956
  const tool = msg.tool;
9957
+ // #1774: who sends this — taken before anything here awaits (sticker checks, the platform send): a reply that waits
9958
+ // must never be read as a same-name replacement's (removed and re-registered meanwhile).
9959
+ const sentBy = tool === "reply" ? this.webChatIdentity(instanceName) : undefined;
9745
9960
  const args = (msg.args ?? {});
9746
9961
  const requestId = msg.requestId;
9747
9962
  const fleetRequestId = msg.fleetRequestId;
@@ -9921,6 +10136,7 @@ export class FleetManager {
9921
10136
  args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
9922
10137
  }
9923
10138
  }
10139
+ const replyIdentity = sentBy; // #1774: taken at the handler's entry
9924
10140
  const original = respond;
9925
10141
  const respondAndRecord = (result, error) => {
9926
10142
  let buttonsView = null;
@@ -9952,12 +10168,13 @@ export class FleetManager {
9952
10168
  try {
9953
10169
  // #1920 §5.2: shown to the instance's guests only when it landed in the instance's own topic or room.
9954
10170
  let inChannel = false;
10171
+ const replyAdapterId = contextAdapterId ?? this.getInstanceAdapterId(instanceName);
9955
10172
  try {
9956
10173
  inChannel = outAdapter !== WEB_ONLY_REPLY_SINK
9957
- && this.landedInOwnChannel(instanceName, contextAdapterId ?? this.getInstanceAdapterId(instanceName), result);
10174
+ && this.landedInOwnChannel(instanceName, replyAdapterId, result);
9958
10175
  }
9959
10176
  catch { /* not known: not the channel's (fail closed), and the bookkeeping below still runs */ }
9960
- void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel);
10177
+ void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel, replyAdapterId ? { adapterId: replyAdapterId, result } : undefined, replyIdentity);
9961
10178
  }
9962
10179
  catch (err) {
9963
10180
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -10034,7 +10251,12 @@ export class FleetManager {
10034
10251
  return false;
10035
10252
  }
10036
10253
  }
10037
- afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false) {
10254
+ afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false, sentAs, identity = this.webChatIdentity(instanceName)) {
10255
+ // #2281: where the reply is, when it landed in the instance's own place — a web reply to it is then a native reply
10256
+ // there. A reply elsewhere (another chat, the web-only sink) records none.
10257
+ const sent = (sentAs?.result && typeof sentAs.result === "object" ? sentAs.result : {});
10258
+ const post = inChannel && sentAs && typeof sent.messageId === "string" && typeof sent.chatId === "string"
10259
+ ? { adapterId: sentAs.adapterId, chatId: sent.chatId, ...(typeof sent.threadId === "string" && sent.threadId ? { threadId: sent.threadId } : {}), messageId: sent.messageId } : undefined;
10038
10260
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
10039
10261
  // more work either. Split the difference: an instance that is clearly
10040
10262
  // idle loses the button now; one that looks busy keeps it (re-posted
@@ -10061,7 +10283,16 @@ export class FleetManager {
10061
10283
  // copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
10062
10284
  const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
10063
10285
  const ts = new Date().toISOString();
10064
- const show = (attachments) => {
10286
+ // #1774: the instance this reply is for — removed while its files were being copied (a same-name replacement
10287
+ // registered meanwhile included), the reply is no longer its to show: no chat made again, none of the new one's
10288
+ // touched, and the copies made for it go. A replacement's own replies are its own.
10289
+ // Its identity was taken when the reply was sent (identity), never re-read after the platform answered.
10290
+ const current = () => this.webChatIdentityCurrent(instanceName, identity);
10291
+ const show = (attachments, reserved) => {
10292
+ if (!current()) {
10293
+ this.webFiles.dropServed(attachments.map(a => a.id));
10294
+ return;
10295
+ }
10065
10296
  // The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
10066
10297
  // role that may get HTML preview cards. Never inferred from the sender name or the text.
10067
10298
  this.emitSseEvent("message", {
@@ -10071,18 +10302,25 @@ export class FleetManager {
10071
10302
  ...(attachments.length ? { attachments } : {}),
10072
10303
  ...(buttons ? { buttons } : {}), // #1266
10073
10304
  ...(inChannel ? { channel: true } : {}), // #1920 §5.2
10305
+ ...(post ? { posts: [post] } : {}), // #2281
10306
+ ...(reserved !== undefined ? { reserved } : {}), // #1774
10074
10307
  });
10075
10308
  };
10076
10309
  const before = this.webReplyOrder.get(instanceName);
10077
- if (!replyFiles.length && !before) {
10310
+ // #1774: a removal of this instance still going decides whether a reply sent before it began is shown — it waits.
10311
+ const removal = this.webChatRemovalPending(instanceName, identity);
10312
+ if (!replyFiles.length && !before && !removal) {
10078
10313
  show([]);
10079
10314
  return Promise.resolve();
10080
10315
  }
10316
+ // #1774: its place in the chat is now — a person's message sent while the files are copied comes after it.
10317
+ const reserved = this.webChatHistory.reserve();
10081
10318
  const done = (before ?? Promise.resolve())
10082
- .then(() => this.webReplyAttachments(instanceName, replyFiles))
10319
+ .then(() => this.webReplyAttachments(instanceName, replyFiles, current))
10320
+ .then(async (attachments) => { await this.webChatRemovalPending(instanceName, identity); return attachments; })
10083
10321
  .catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
10084
10322
  .then((attachments) => { try {
10085
- show(attachments);
10323
+ show(attachments, reserved);
10086
10324
  }
10087
10325
  catch (err) {
10088
10326
  this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
@@ -10097,11 +10335,13 @@ export class FleetManager {
10097
10335
  * the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
10098
10336
  * one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
10099
10337
  */
10100
- async webReplyAttachments(instanceName, paths) {
10338
+ async webReplyAttachments(instanceName, paths, current = () => true) {
10101
10339
  const home = getAgendHome();
10102
10340
  const dir = webFilesDir(home, instanceName);
10103
10341
  const out = [];
10104
10342
  for (const path of paths) {
10343
+ if (!current())
10344
+ break; // #1774: removed meanwhile — copy no more for it
10105
10345
  const id = randomBytes(16).toString("hex");
10106
10346
  const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
10107
10347
  if (got && "evicted" in got && got.evicted.length)
@@ -11353,6 +11593,7 @@ export class FleetManager {
11353
11593
  saveFleetConfig(explicitPatches = []) {
11354
11594
  this.publicWebLink?.refresh();
11355
11595
  this.enforceGuestSwitch(); // #1920: a Settings save turning guests off ends their sessions
11596
+ this.observeWebAuthority(); // #2453 review: so is a web-authority switch (off is always seen)
11356
11597
  if (!this.fleetConfig || !this.configPath)
11357
11598
  return;
11358
11599
  if (!this.savedFleetConfigSnapshot)
@@ -11643,10 +11884,27 @@ export class FleetManager {
11643
11884
  // Capture instance dir BEFORE lifecycle.remove, which deletes the instance
11644
11885
  // from fleetConfig. (The backend would be unresolvable afterwards.)
11645
11886
  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);
11887
+ // #1774: replies sent from here on are a later instance's (a replacement registered while this one goes); the ones
11888
+ // sent before wait for the outcome before they are shown.
11889
+ const removedBelow = this.webChatGeneration(name) + 1, removedDaemon = this.daemons.get(name);
11890
+ this.webChatGenerations.set(name, removedBelow);
11891
+ let settleRemoval;
11892
+ const removalPending = { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}), done: new Promise(r => { settleRemoval = r; }) };
11893
+ this.webChatRemovalsPending.set(name, removalPending);
11894
+ try {
11895
+ if (execution)
11896
+ await this.lifecycle.remove(name, authorization, execution, transition);
11897
+ else
11898
+ await this.lifecycle.remove(name, authorization);
11899
+ // #1774: removed — its replies still copying their files are no one's to show, replaced or not (a failed removal
11900
+ // throws above: its replies stay its own).
11901
+ this.webChatRemovals.set(name, { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}) });
11902
+ }
11903
+ finally {
11904
+ if (this.webChatRemovalsPending.get(name) === removalPending)
11905
+ this.webChatRemovalsPending.delete(name);
11906
+ settleRemoval();
11907
+ }
11650
11908
  // A later same-name registration owns its files and caches.
11651
11909
  // Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
11652
11910
  // re-registration) — #1677 review.
@@ -12342,7 +12600,7 @@ export class FleetManager {
12342
12600
  /** A preview under the inbox, named by a hash of the id (a Telegram file_id is long), pruned after a day. */
12343
12601
  storeStickerPreview(id, picture) {
12344
12602
  const dir = join(this.dataDir, "inbox", "sticker-previews");
12345
- mkdirSync(dir, { recursive: true });
12603
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
12346
12604
  const now = Date.now();
12347
12605
  for (const name of readdirSync(dir)) {
12348
12606
  try {
@@ -12408,7 +12666,7 @@ export class FleetManager {
12408
12666
  if (!/^\d{15,25}$/.test(id))
12409
12667
  throw new Error("not a Discord emoji id");
12410
12668
  const dir = join(this.dataDir, "inbox", "emoji-previews");
12411
- mkdirSync(dir, { recursive: true });
12669
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
12412
12670
  const now = Date.now();
12413
12671
  for (const name of readdirSync(dir)) {
12414
12672
  try {
@@ -13043,7 +13301,7 @@ export class FleetManager {
13043
13301
  const board = this.scheduler?.db.board;
13044
13302
  if (!board)
13045
13303
  return;
13046
- const read = board.summaryRead(since.toISOString(), until.toISOString());
13304
+ const read = board.summaryRead(since.toISOString(), until.toISOString(), this.dispatcherGone);
13047
13305
  const projects = board.listProjects(true);
13048
13306
  const stats = board.projectStats();
13049
13307
  const names = new Map(projects.map((p) => [p.id, p.name]));
@@ -13823,9 +14081,9 @@ export class FleetManager {
13823
14081
  return classic ? { chatId: classic } : null;
13824
14082
  }
13825
14083
  /**
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.
14084
+ * #2352: the instance whose own channel lists a waiting card — its project's lead — when that channel can be used now
14085
+ * (a fleet instance of a live world, with a topic or room). undefined: its assignee's world's General, as before.
14086
+ * #2434: only a card the person handed out waits for them, so the instance that handed one out is never its lane.
13829
14087
  */
13830
14088
  needsYouLane(card, leads) {
13831
14089
  const usable = (name) => {
@@ -13834,9 +14092,6 @@ export class FleetManager {
13834
14092
  const world = this.getInstanceAdapterId(name);
13835
14093
  return world !== undefined && this.worlds.has(world) && this.instanceChannelTarget(name) !== null;
13836
14094
  };
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
14095
  const lead = card.projectId != null ? leads.get(card.projectId) : null;
13841
14096
  return usable(lead) ? lead : undefined;
13842
14097
  }
@@ -13844,12 +14099,24 @@ export class FleetManager {
13844
14099
  * #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
14100
  * or a reply, never from the last render. null: it no longer waits for the person.
13846
14101
  */
14102
+ /**
14103
+ * #2425 follow-up: an instance that handed out a card and is no longer in the fleet — neither a configured instance nor
14104
+ * a ClassicBot identity (deleted). Its blocked and in-review cards fall back to the person (Needs you, the summary).
14105
+ * Not known before the config and the ClassicBot rooms are loaded: then no one is gone (a live dispatcher's cards never
14106
+ * flash into the person's list at startup).
14107
+ */
14108
+ dispatcherGone = (name) => {
14109
+ const instances = this.fleetConfig?.instances, classic = this.classicChannels;
14110
+ if (!instances || !classic)
14111
+ return false;
14112
+ return !Object.hasOwn(instances, name) && !classic.getAll().some((ch) => ch.instanceName === name);
14113
+ };
13847
14114
  needsYouRoute(code) {
13848
14115
  const board = this.scheduler?.db.board;
13849
14116
  const card = board?.findCard(code);
13850
14117
  // #2425 (Fable): the same rule as the list — a button rendered before the fix (or before the opt-in went off) for a
13851
14118
  // card that no longer waits for the person answers "no longer waits".
13852
- if (!board || !card || this.fleetConfig?.needs_you?.task_board !== true || !waitsForPerson(card))
14119
+ if (!board || !card || this.fleetConfig?.needs_you?.task_board !== true || !waitsForPerson(card, this.dispatcherGone))
13853
14120
  return null;
13854
14121
  const leads = new Map(board.listProjects(true).map((p) => [p.id, p.leadInstance]));
13855
14122
  const lane = this.needsYouLane(card, leads);
@@ -13857,7 +14124,7 @@ export class FleetManager {
13857
14124
  }
13858
14125
  /**
13859
14126
  * #2246 step 3: the task board's cards that wait for the person, for Needs you — under each card's assignee, listed in
13860
- * its lane's channel (#2352: the instance that handed it out, else the project's lead), else in General.
14127
+ * its lane's channel (#2352: its project's lead; #2434: never the instance that handed it out), else in General.
13861
14128
  */
13862
14129
  needsYouTaskItems() {
13863
14130
  // #2425: the task board's cards join Needs you only when the fleet asks for them (needs_you.task_board, off by
@@ -13869,7 +14136,7 @@ export class FleetManager {
13869
14136
  if (!board)
13870
14137
  return [];
13871
14138
  const leads = new Map(board.listProjects(true).map((p) => [p.id, p.leadInstance]));
13872
- return board.waitingForPerson().map(({ card, since }) => {
14139
+ return board.waitingForPerson(this.dispatcherGone).map(({ card, since }) => {
13873
14140
  const gap = card.status === "review" ? wrapUpGap(card) : null;
13874
14141
  const lane = this.needsYouLane(card, leads);
13875
14142
  return {
@@ -14304,7 +14571,7 @@ export class FleetManager {
14304
14571
  ...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
14305
14572
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
14306
14573
  authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
14307
- policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now: Date.now(),
14574
+ policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now: Date.now(),
14308
14575
  });
14309
14576
  }
14310
14577
  sessionAuthorizations = null;
@@ -14329,7 +14596,7 @@ export class FleetManager {
14329
14596
  session, gateway, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
14330
14597
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
14331
14598
  authorization: { handle: session.handle, owner: session.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
14332
- policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
14599
+ policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
14333
14600
  });
14334
14601
  if (check.refusal)
14335
14602
  return { ok: false, refusal: check.refusal };
@@ -14344,9 +14611,30 @@ export class FleetManager {
14344
14611
  */
14345
14612
  webAuthorityState() {
14346
14613
  if (!this.dataDir)
14347
- return { enabled: false, loopbackOnly: true, epoch: 0 };
14614
+ return { enabled: false, loopbackOnly: true, namedLink: false, epoch: 0 };
14348
14615
  this.authorityStateStore ??= new AuthorityStateStore(join(this.dataDir, "web-authority.json"), this.logger, undefined, undefined, () => { this.ensureDeliveryOutbox?.(); return this.deliveryOutbox?.maxWebAuthorityEpoch() ?? 0; });
14349
- return this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
14616
+ const state = this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
14617
+ // #2453 (§0.2): the named-link option seen going off — the authority of every session over the link ends now, for
14618
+ // good: their authorizations go (a later "on" can never bring one back without its account's Confirm).
14619
+ if (state.namedLinkClosed)
14620
+ this.sessionAuthorizationStore()?.prune(a => this.webSessions?.findByHandle(a.handle)?.surface === "local");
14621
+ return state;
14622
+ }
14623
+ /**
14624
+ * #2453 review: the web-authority state read on every configuration change (a Settings save, the host's confirm, a
14625
+ * reload) — so a switch turned off and on again between two requests is still seen off: its epoch moves, and the
14626
+ * named-link sessions' authorizations are dropped. Reads that only happen on a request would miss it.
14627
+ */
14628
+ observeWebAuthority() {
14629
+ // Only once the state is in use: any authorization was given through a read that made it (and a first read at
14630
+ // start-up still sees what changed while the fleet was down). Nothing new is opened during a load.
14631
+ try {
14632
+ if (this.dataDir && this.authorityStateStore)
14633
+ this.webAuthorityState();
14634
+ }
14635
+ catch (err) {
14636
+ this.logger.warn({ err: err?.message }, "Web authority state could not be read after a configuration change");
14637
+ }
14350
14638
  }
14351
14639
  /**
14352
14640
  * #1939 S2a: Settings → Web → Web authority — the state in force (the switch counts as off while its state cannot be
@@ -14361,8 +14649,10 @@ export class FleetManager {
14361
14649
  const authorized = state.enabled ? (this.sessionAuthorizationStore()?.list() ?? [])
14362
14650
  .filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
14363
14651
  .map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
14364
- until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry) })) : [];
14365
- return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized };
14652
+ until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry),
14653
+ ...(sessions.get(a.handle).surface === "gateway" ? { named: true } : {}) })) : [];
14654
+ const host = this.fleetConfig?.web?.public_link?.hostname;
14655
+ return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized, namedHost: typeof host === "string" && host ? host : null };
14366
14656
  }
14367
14657
  authorizationRequests = null;
14368
14658
  /**
@@ -14375,7 +14665,9 @@ export class FleetManager {
14375
14665
  const adapterOf = (adapterId) => this.adapters.get(adapterId) ?? this.worlds.get(adapterId)?.adapter;
14376
14666
  // The host's clock, with its zone: the person may read the DM on a phone in another one.
14377
14667
  const when = (ms) => new Date(ms).toLocaleString(undefined, { timeZoneName: "short" });
14378
- const where = (peer) => t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
14668
+ const where = (peer) => peer === "named_link"
14669
+ ? t("web_auth.peer_named_link", String(this.fleetConfig?.web?.public_link?.hostname ?? "?")) // §0.2: the internet
14670
+ : t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
14379
14671
  return this.authorizationRequests = new SessionAuthorizationRequests({
14380
14672
  now: () => performance.now(), wallNow: () => Date.now(),
14381
14673
  maxSessionMs: () => webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs,
@@ -14409,7 +14701,8 @@ export class FleetManager {
14409
14701
  const record = this.webSessions?.findByHandle(handle);
14410
14702
  if (!record)
14411
14703
  return { ok: false, refusal: "session_gone" };
14412
- const r = this.authorizeWebSession(record, peer, false);
14704
+ // §0.2: a named-link session is judged as the gateway request it is (its exposure still the current one).
14705
+ const r = this.authorizeWebSession(record, peer, peer === "named_link");
14413
14706
  // What the card says: the end of the authorization, or the session's own end when that comes first.
14414
14707
  return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
14415
14708
  },
@@ -14455,7 +14748,7 @@ export class FleetManager {
14455
14748
  session: s, gateway: input.gateway, peer: input.peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
14456
14749
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
14457
14750
  authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
14458
- policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
14751
+ policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
14459
14752
  });
14460
14753
  return r.refusal;
14461
14754
  }
@@ -15211,7 +15504,7 @@ export class FleetManager {
15211
15504
  }
15212
15505
  }
15213
15506
  this.persistCancelButtons();
15214
- this.logger.info({ instanceName, messageId: sent.messageId }, "Cancel button sent");
15507
+ this.logger.debug({ instanceName, messageId: sent.messageId }, "Cancel button sent"); // #2482: per-turn bookkeeping
15215
15508
  // Retirement is normally edge-triggered, but the edge (or an explicit
15216
15509
  // reply/cancel clear) may have happened while notifyAlert was in flight.
15217
15510
  // Reconcile the level after publication so a late button cannot resurrect
@@ -15477,7 +15770,8 @@ export class FleetManager {
15477
15770
  this.deleteButtonMessage(entry)
15478
15771
  .then(() => {
15479
15772
  this.discardButton(entry);
15480
- this.logger.info({ instanceName: entry.instanceName, messageId: entry.messageId, historyPreserved: Boolean(entry.toolProgress) }, "Cancel button retired");
15773
+ this.logger.debug(// #2482: per-turn bookkeeping
15774
+ { instanceName: entry.instanceName, messageId: entry.messageId, historyPreserved: Boolean(entry.toolProgress) }, "Cancel button retired");
15481
15775
  })
15482
15776
  .catch((err) => this.scheduleButtonRetry(entry, err));
15483
15777
  }
@@ -16566,8 +16860,12 @@ export class FleetManager {
16566
16860
  from: m.from,
16567
16861
  replyTo: m.replyTo,
16568
16862
  cards: m.cards,
16863
+ posts: m.posts, // #2281: kept beside the row, never broadcast (not a row field)
16864
+ reserved: m.reserved, // #1774: an agent reply's place, taken when it was sent
16569
16865
  });
16570
- broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
16866
+ // A sending page's own id for its pending bubble rides on the live copy only: never recorded, never replayed.
16867
+ const clientId = typeof data.clientId === "string" ? data.clientId : "";
16868
+ broadcastSseEvent(this.sseClients, event, clientId ? { ...recorded, clientId } : recorded, onError, this.webChatHistory.cursorOf(recorded));
16571
16869
  this.searchIndex?.record(chatDoc(recorded));
16572
16870
  // #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
16573
16871
  if (recorded.role === "agent")
@@ -16603,7 +16901,8 @@ export class FleetManager {
16603
16901
  if (this.fleetConfig?.needs_you?.live_message === false)
16604
16902
  return false;
16605
16903
  const card = db.board.findCard(uuid);
16606
- if (!card)
16904
+ // #2434: only while it waits for the person — a card that does not is in no Needs you, so no buttons anywhere.
16905
+ if (!card || !waitsForPerson(card, this.dispatcherGone))
16607
16906
  return false;
16608
16907
  const leads = new Map(db.board.listProjects(true).map((p) => [p.id, p.leadInstance]));
16609
16908
  return this.needsYouLane(card, leads) === instance;
@@ -16952,6 +17251,47 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16952
17251
  * migration exception is the pre-manifest Kiro General layout explicitly
16953
17252
  * selected by adoptLegacyUnmanaged.
16954
17253
  */
17254
+ /**
17255
+ * #2482: the user's own skills that shadow bundled ones, said once per directory and change — not once per name on
17256
+ * every start (11 lines a start on the host). The record (`skipped-skills.json` in the AgEnD home) keeps what was said
17257
+ * across restarts; a new name or an edited SKILL.md is said again.
17258
+ */
17259
+ reportSkippedSkills(destSkills, names) {
17260
+ const signature = names.map(name => {
17261
+ let content = "";
17262
+ try {
17263
+ content = readFileSync(join(destSkills, name, "SKILL.md"), "utf-8");
17264
+ }
17265
+ catch { /* gone: its name alone */ }
17266
+ return `${name}:${createHash("sha256").update(content).digest("hex").slice(0, 16)}`;
17267
+ }).join(",");
17268
+ if (this.skippedSkillsSaid.get(destSkills) === signature)
17269
+ return;
17270
+ this.skippedSkillsSaid.set(destSkills, signature);
17271
+ const path = join(this.dataDir, "skipped-skills.json");
17272
+ // Only a log's memory: whatever is in the file (missing, corrupt, any other shape) never stands in the sync's way
17273
+ // (#2493 review) — a record that cannot be read is no record, and the warning is said.
17274
+ const record = Object.create(null);
17275
+ try {
17276
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
17277
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
17278
+ for (const [key, value] of Object.entries(parsed))
17279
+ if (typeof value === "string")
17280
+ record[key] = value;
17281
+ }
17282
+ }
17283
+ catch { /* none yet, or unreadable */ }
17284
+ if (Object.hasOwn(record, destSkills) && record[destSkills] === signature) {
17285
+ this.logger.debug({ skills: names, destSkills }, "Skipping bundled skills — unchanged since it was last said");
17286
+ return;
17287
+ }
17288
+ record[destSkills] = signature;
17289
+ try {
17290
+ writeFileSync(path, JSON.stringify(record) + "\n");
17291
+ }
17292
+ catch { /* said this time; said again next start */ }
17293
+ this.logger.warn({ skills: names, destSkills }, "Skipping bundled skills — skills of these names exist but were not published by AgEnD");
17294
+ }
16955
17295
  syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false, confined) {
16956
17296
  if (!existsSync(srcSkills))
16957
17297
  return;
@@ -17001,16 +17341,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17001
17341
  return role === "general" || roles.includes(role);
17002
17342
  });
17003
17343
  const managed = [];
17344
+ const skipped = [];
17004
17345
  for (const name of eligible) {
17005
17346
  const destDir = join(destSkills, name);
17006
17347
  const dest = join(destDir, "SKILL.md");
17007
17348
  const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
17008
17349
  if (!isOurs) {
17009
- // The user's skill still wins on every sync; only the warning is deduped.
17010
- if (!this.skippedBundledSkills.has(name)) {
17011
- this.skippedBundledSkills.add(name);
17012
- this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
17013
- }
17350
+ // The user's skill still wins on every sync; only the warning is deduped (#2482: one line per directory, and
17351
+ // only when what is skipped there changed since it was last said — across restarts too).
17352
+ skipped.push(name);
17014
17353
  continue;
17015
17354
  }
17016
17355
  managed.push(name);
@@ -17025,6 +17364,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17025
17364
  mkdirSync(destDir, { recursive: true });
17026
17365
  writeFileSync(dest, newContent);
17027
17366
  }
17367
+ if (skipped.length)
17368
+ this.reportSkippedSkills(destSkills, skipped);
17028
17369
  // Remove managed skills that are no longer bundled OR no longer eligible
17029
17370
  // for this role. This makes a shared → general-only metadata change take
17030
17371
  // effect on the next worker startup instead of leaving stale capability.
@@ -17356,7 +17697,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17356
17697
  try {
17357
17698
  const tmpPath = await dlAdapter.downloadAttachment(att.fileId);
17358
17699
  const inboxDir = join(getAgendHome(), "workspaces", instanceName, "inbox");
17359
- mkdirSync(inboxDir, { recursive: true });
17700
+ mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
17360
17701
  const dest = join(inboxDir, basename(tmpPath));
17361
17702
  // Copy to destination — failure means this attachment is skipped
17362
17703
  try {
@@ -17768,7 +18109,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17768
18109
  }
17769
18110
  }
17770
18111
  const path = this.cliEnvPath(backend, profile);
17771
- mkdirSync(dirname(path), { recursive: true });
18112
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
17772
18113
  writeFileSync(path, JSON.stringify(env, null, 2));
17773
18114
  return env;
17774
18115
  }
@@ -19467,19 +19808,43 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19467
19808
  }
19468
19809
  tmuxExecHelperTimer = null;
19469
19810
  stopAllInFlight = null;
19811
+ /** Stops release memory. Reassess between batches without waiting for recovery. */
19812
+ async stopInstancesInBatches(entries, stop) {
19813
+ let index = 0;
19814
+ while (index < entries.length) {
19815
+ // Linux procfs can still be read after the periodic sampler is stopped.
19816
+ // Do not revive a stopped Darwin probe/worker just to size a quit batch;
19817
+ // its full shutdown uses the conservative batch size instead.
19818
+ const level = this.memoryPressure.platform === "darwin"
19819
+ ? this.shuttingDown ? "elevated" : this.memoryPressure.snapshot().level
19820
+ : this.memoryPressure.sample().level;
19821
+ const size = fleetStopConcurrency(entries.length, level);
19822
+ const batch = entries.slice(index, index + size);
19823
+ index += batch.length;
19824
+ await Promise.allSettled(batch.map(stop));
19825
+ }
19826
+ }
19470
19827
  async doStopAll() {
19828
+ this.startupMemoryDeadline?.stop();
19829
+ this.startupMemoryDeadline = null;
19471
19830
  this.startupComplete = false;
19831
+ this.startupDashboard = null;
19472
19832
  this.reloadPending = false;
19473
19833
  // Before anything is stopped: everything that dies from here on dies
19474
19834
  // because we asked it to. Set synchronously — doStopAll runs to its first
19475
19835
  // await in the same tick as the signal handler, so no event can slip in.
19476
19836
  this.shuttingDown = true;
19837
+ if (this.updateProgressTimer) {
19838
+ clearInterval(this.updateProgressTimer);
19839
+ this.updateProgressTimer = null;
19840
+ }
19477
19841
  this.settingsGeneration++;
19478
19842
  this.settingsConfirmation?.store.close();
19479
19843
  this.settingsConfirmation = null;
19480
19844
  const settingsControl = this.settingsControl;
19481
19845
  this.settingsControl = null;
19482
19846
  const settingsControlStopped = settingsControl?.close();
19847
+ this.publicLinkAutoOpen?.stop();
19483
19848
  const publicStopped = this.publicWebLink?.close("fleet shutdown");
19484
19849
  // #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
19485
19850
  // adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
@@ -19630,21 +19995,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19630
19995
  // tmux ops, while small fleets stay conservative to avoid overwhelming the
19631
19996
  // tmux server.
19632
19997
  const entries = [...this.daemons.entries()];
19633
- const STOP_CONCURRENCY = entries.length > 30 ? 15 : entries.length >= 10 ? 10 : 5;
19634
19998
  for (const [name] of entries)
19635
19999
  this.ipcStoppingInstances.add(name);
19636
- for (let i = 0; i < entries.length; i += STOP_CONCURRENCY) {
19637
- const batch = entries.slice(i, i + STOP_CONCURRENCY);
19638
- await Promise.all(batch.map(async ([name, daemon]) => {
19639
- try {
19640
- await daemon.stop();
19641
- }
19642
- catch (err) {
19643
- this.logger.warn({ name, err }, "Stop failed");
19644
- }
19645
- this.daemons.delete(name);
19646
- }));
19647
- }
20000
+ await this.stopInstancesInBatches(entries, async ([name, daemon]) => {
20001
+ try {
20002
+ await daemon.stop();
20003
+ }
20004
+ catch (err) {
20005
+ this.logger.warn({ name, err }, "Stop failed");
20006
+ }
20007
+ this.daemons.delete(name);
20008
+ });
19648
20009
  // Close IPC clients in parallel — serial close over a large fleet adds
19649
20010
  // noticeable latency.
19650
20011
  await Promise.all([...this.instanceIpcClients.values()].map(ipc => Promise.resolve(ipc.close()).catch(() => { })));
@@ -19855,6 +20216,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19855
20216
  this.rawFleetConfig = previousRawConfig;
19856
20217
  this.rawFleetDocument = previousRawDocument;
19857
20218
  this.savedFleetConfigSnapshot = previousSavedSnapshot;
20219
+ this.observeWebAuthority(); // #2453 review: the configuration back in force is the one seen
19858
20220
  this.logger.error({
19859
20221
  oldCount,
19860
20222
  newCount,
@@ -19953,8 +20315,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19953
20315
  // so an old/new diff alone can miss the live daemon's stale value.
19954
20316
  for (const [name, config] of Object.entries(newInstances)) {
19955
20317
  if (!this.daemons.has(name)) {
19956
- // New instance — startInstance already calls connectIpcToInstance
19957
- this.logger.info({ name }, "New instance in config — starting");
20318
+ // New instance — startInstance already calls connectIpcToInstance. #2482: a paused one is not started (startInstance
20319
+ // returns at its pause); say so, quietly, instead of "starting" on every reload.
20320
+ if (this.lifecycle.isPaused(name))
20321
+ this.logger.debug({ name }, "Paused instance in config — not started");
20322
+ else
20323
+ this.logger.info({ name }, "New instance in config — starting");
19958
20324
  observe?.(name, "restart", "running");
19959
20325
  await this.startInstanceUnattended(name, config, topicMode, "new instance");
19960
20326
  observe?.(name, "restart", "done");
@@ -19963,6 +20329,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19963
20329
  const daemon = this.daemons.get(name);
19964
20330
  const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
19965
20331
  const change = classifyInstanceChange(runtimeConfig, config);
20332
+ // (b) The person switched its task board on: tell it (its tool list shows the `task` tool at once).
20333
+ if (taskBoardSwitchedOn(runtimeConfig.dashboard, config.dashboard)) {
20334
+ this.admitTaskBoardNotice(name, TASK_BOARD_ON_NOTICE, `task-board-on:${name}:${Date.now()}`);
20335
+ }
19966
20336
  if (change === "restart") {
19967
20337
  this.logger.info({ name }, "Instance config changed — restarting");
19968
20338
  observe?.(name, "restart", "running");
@@ -21436,7 +21806,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21436
21806
  await ipc.close();
21437
21807
  }
21438
21808
  this.instanceIpcClients.clear();
21439
- await Promise.allSettled(instanceNames.map(name => this.stopInstance(name)));
21809
+ await this.stopInstancesInBatches(instanceNames, name => this.stopInstance(name));
21440
21810
  // Kill remaining orphan windows to prevent stale state on restart
21441
21811
  try {
21442
21812
  const agendNames = new Set(instanceNames);
@@ -21757,6 +22127,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21757
22127
  if (this.guestScope(owner).size)
21758
22128
  return "ok";
21759
22129
  if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
22130
+ // Before adapters initialize, topic scope is unknown, not revoked. Keep the returning cookie but grant no
22131
+ // request until its configured world exists. Removed connections and initialized denied access stay final.
22132
+ const channels = this.fleetConfig.channels ?? (this.fleetConfig.channel ? [this.fleetConfig.channel] : []);
22133
+ if (this.startupDashboard && !this.worlds.has(owner.adapterId)
22134
+ && channels.some(channel => (channel.id ?? channel.type) === owner.adapterId))
22135
+ return "pending";
21760
22136
  const members = this.guestRooms(), rooms = guestGroupRooms(this.classicChannels?.getAll() ?? [], owner);
21761
22137
  if (rooms.some((room) => members.pending(room, owner.userId))) {
21762
22138
  void this.refreshGuestRooms(owner, { mode: "triggered" }); // rate-limited per room (30 s), single-flight
@@ -21855,7 +22231,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21855
22231
  res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
21856
22232
  return;
21857
22233
  }
21858
- if (closing) {
22234
+ if (closing || this.shuttingDown) {
21859
22235
  res.writeHead(503, { Connection: "close", "Retry-After": "10" });
21860
22236
  res.end(JSON.stringify({ error: "fleet stopping" }));
21861
22237
  return;
@@ -21903,6 +22279,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21903
22279
  // #1920: a guest reaches only GUEST_ROUTES, within the scope derived for it now — default deny, before any
21904
22280
  // handler, on this listener and the public link's gateway alike (both come through here).
21905
22281
  if (decision.via === "session" && decision.session.tier === "guest") {
22282
+ // A browser opening a page gets a page in its language, not the JSON an API caller gets (#2399 smoke).
22283
+ const navigating = req.method === "GET" && String(req.headers.accept ?? "").includes("text/html") && isWebPageNavigation(requestPath);
21906
22284
  const owner = decision.session.owner;
21907
22285
  const admitted = decision.session;
21908
22286
  const admission = this.guestAdmission(admitted);
@@ -21913,12 +22291,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21913
22291
  return;
21914
22292
  }
21915
22293
  if (admission === "gone") {
22294
+ if (navigating) {
22295
+ serveGuestRefusedPage(res, 401, "ended");
22296
+ return;
22297
+ }
21916
22298
  res.writeHead(401);
21917
22299
  res.end(JSON.stringify({ error: "guest_scope_empty" }));
21918
22300
  return;
21919
22301
  }
21920
22302
  const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
21921
22303
  if (verdict.kind === "deny") {
22304
+ if (navigating) {
22305
+ serveGuestRefusedPage(res, verdict.status, "members");
22306
+ return;
22307
+ }
21922
22308
  res.writeHead(verdict.status);
21923
22309
  res.end(JSON.stringify({ error: verdict.error }));
21924
22310
  return;
@@ -21951,6 +22337,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21951
22337
  res.end(JSON.stringify(health));
21952
22338
  return;
21953
22339
  }
22340
+ // Threat: lifecycle/config/chat writes racing boot-owned starts and reconciliation. Reads stay live;
22341
+ // authentication keeps its own checks so a browser can sign in or sign out while agents start.
22342
+ if (this.startupDashboard && !["GET", "HEAD", "OPTIONS"].includes(req.method ?? "GET")
22343
+ && !requestPath.startsWith("/auth/")) {
22344
+ res.writeHead(503, { "Retry-After": "2", "Cache-Control": "no-store", Connection: "close" });
22345
+ res.end(JSON.stringify({ code: "fleet_starting", error: t("fleet.starting_retry") }));
22346
+ return;
22347
+ }
21954
22348
  if (req.method === "GET" && req.url === "/status") {
21955
22349
  const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {
21956
22350
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
@@ -22221,6 +22615,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22221
22615
  }
22222
22616
  }
22223
22617
  startHealthServer(port) {
22618
+ let settled;
22619
+ const listening = new Promise(resolve => { settled = resolve; });
22224
22620
  this.startedAt = Date.now();
22225
22621
  this.healthServerListening = false;
22226
22622
  this.healthPortRetried = false;
@@ -22235,6 +22631,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22235
22631
  const drain = trackWebServer(server);
22236
22632
  this.healthServer = server;
22237
22633
  this.healthServerDrain = drain;
22634
+ server.once("close", settled);
22238
22635
  const markListening = (afterTakeover = false) => {
22239
22636
  this.healthServerListening = true;
22240
22637
  this.logger.info({ port }, afterTakeover
@@ -22248,6 +22645,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22248
22645
  // #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
22249
22646
  const bound = this.healthServer?.address();
22250
22647
  this.startPreviewListener(port, bound && typeof bound === "object" ? bound.port : port);
22648
+ settled();
22251
22649
  };
22252
22650
  this.healthServer.on("error", (err) => {
22253
22651
  this.healthServerListening = false;
@@ -22255,6 +22653,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22255
22653
  if (this.healthPortRetried) {
22256
22654
  this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
22257
22655
  this.notifyFleetError(t("dashboard.port_in_use", port));
22656
+ settled();
22258
22657
  return;
22259
22658
  }
22260
22659
  this.healthPortRetried = true;
@@ -22290,16 +22689,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22290
22689
  this.logger.debug({ err }, "Old fleet process kill skipped (already gone or no permission)");
22291
22690
  }
22292
22691
  setTimeout(() => {
22293
- if (!this.healthServer)
22692
+ if (this.healthServer !== server || this.shuttingDown) {
22693
+ settled();
22294
22694
  return;
22295
- this.healthServer.listen(port, "127.0.0.1", () => markListening(true));
22695
+ }
22696
+ server.listen(port, "127.0.0.1", () => markListening(true));
22296
22697
  }, 1500);
22297
22698
  return;
22298
22699
  }
22299
22700
  this.logger.error({ err, port }, "Health server error");
22300
22701
  this.notifyFleetError(t("dashboard.server_failed", err.message));
22702
+ settled();
22301
22703
  });
22302
22704
  this.healthServer.listen(port, "127.0.0.1", () => markListening());
22705
+ return listening;
22303
22706
  }
22304
22707
  /**
22305
22708
  * #1306: the preview listener, beside the web listener and closed with it. When it cannot listen (the port is
@@ -22528,6 +22931,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22528
22931
  return {
22529
22932
  canvas_worker: this.canvasWorkerEnabled(),
22530
22933
  instances,
22934
+ startup: this.startupDashboard?.snapshot() ?? null,
22531
22935
  publicLink: this.getPublicWebStatus(),
22532
22936
  uptime: Math.floor((Date.now() - this.startedAt) / 1000),
22533
22937
  // #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.