@songsid/agend 2.3.0-beta.9 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (723) hide show
  1. package/README.md +16 -8
  2. package/README.zh-TW.md +16 -8
  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-instructions.md +7 -1
  8. package/dist/agent-cli.js +41 -0
  9. package/dist/agent-cli.js.map +1 -1
  10. package/dist/agent-endpoint.d.ts +9 -0
  11. package/dist/agent-endpoint.js +23 -0
  12. package/dist/agent-endpoint.js.map +1 -1
  13. package/dist/agent-file-read.js +1 -1
  14. package/dist/agent-file-read.js.map +1 -1
  15. package/dist/agent-whats-new.d.ts +16 -0
  16. package/dist/agent-whats-new.js +53 -12
  17. package/dist/agent-whats-new.js.map +1 -1
  18. package/dist/apply-job.js.map +1 -1
  19. package/dist/atomic-write.js.map +1 -1
  20. package/dist/auth-api.d.ts +12 -0
  21. package/dist/auth-api.js +77 -22
  22. package/dist/auth-api.js.map +1 -1
  23. package/dist/authority/authority-state.d.ts +4 -0
  24. package/dist/authority/authority-state.js +17 -8
  25. package/dist/authority/authority-state.js.map +1 -1
  26. package/dist/authority/session-authorizations.js.map +1 -1
  27. package/dist/authority/web-authority.d.ts +16 -8
  28. package/dist/authority/web-authority.js +19 -5
  29. package/dist/authority/web-authority.js.map +1 -1
  30. package/dist/backend/antigravity.d.ts +2 -2
  31. package/dist/backend/antigravity.js +2 -1
  32. package/dist/backend/antigravity.js.map +1 -1
  33. package/dist/backend/claude-code.js +13 -4
  34. package/dist/backend/claude-code.js.map +1 -1
  35. package/dist/backend/codex-metadata.d.ts +8 -0
  36. package/dist/backend/codex-metadata.js +74 -17
  37. package/dist/backend/codex-metadata.js.map +1 -1
  38. package/dist/backend/codex-package.d.ts +30 -0
  39. package/dist/backend/codex-package.js +133 -0
  40. package/dist/backend/codex-package.js.map +1 -0
  41. package/dist/backend/codex-session-lookup.js.map +1 -1
  42. package/dist/backend/codex.d.ts +2 -0
  43. package/dist/backend/codex.js +25 -5
  44. package/dist/backend/codex.js.map +1 -1
  45. package/dist/backend/credential-profile.d.ts +2 -0
  46. package/dist/backend/credential-profile.js +2 -0
  47. package/dist/backend/credential-profile.js.map +1 -1
  48. package/dist/backend/effort-metadata.js.map +1 -1
  49. package/dist/backend/grok.js +58 -3
  50. package/dist/backend/grok.js.map +1 -1
  51. package/dist/backend/kiro-engine-ledger.js.map +1 -1
  52. package/dist/backend/kiro-kas-store.js.map +1 -1
  53. package/dist/backend/kiro-v2-store.js.map +1 -1
  54. package/dist/backend/kiro-v3-identity.js.map +1 -1
  55. package/dist/backend/kiro.d.ts +9 -1
  56. package/dist/backend/kiro.js +102 -1
  57. package/dist/backend/kiro.js.map +1 -1
  58. package/dist/backend/launch-effort.js.map +1 -1
  59. package/dist/backend/mock.js.map +1 -1
  60. package/dist/backend/muse.js.map +1 -1
  61. package/dist/backend/opencode-permission.js +1 -1
  62. package/dist/backend/opencode-permission.js.map +1 -1
  63. package/dist/backend/opencode.js +2 -1
  64. package/dist/backend/opencode.js.map +1 -1
  65. package/dist/backend/session-signals.js.map +1 -1
  66. package/dist/backend-availability.js.map +1 -1
  67. package/dist/backend-command.d.ts +2 -0
  68. package/dist/backend-command.js +3 -1
  69. package/dist/backend-command.js.map +1 -1
  70. package/dist/backend-outage.js.map +1 -1
  71. package/dist/binary-probe.js +1 -1
  72. package/dist/binary-probe.js.map +1 -1
  73. package/dist/bug-report-cli.d.ts +30 -0
  74. package/dist/bug-report-cli.js +139 -0
  75. package/dist/bug-report-cli.js.map +1 -0
  76. package/dist/bug-report.d.ts +47 -0
  77. package/dist/bug-report.js +137 -0
  78. package/dist/bug-report.js.map +1 -0
  79. package/dist/bundled-skills.d.ts +18 -0
  80. package/dist/bundled-skills.js +38 -0
  81. package/dist/bundled-skills.js.map +1 -0
  82. package/dist/cache-service.js +1 -1
  83. package/dist/cache-service.js.map +1 -1
  84. package/dist/channel/adapters/discord-client-retirement.d.ts +5 -0
  85. package/dist/channel/adapters/discord-client-retirement.js +57 -0
  86. package/dist/channel/adapters/discord-client-retirement.js.map +1 -0
  87. package/dist/channel/adapters/discord.d.ts +4 -0
  88. package/dist/channel/adapters/discord.js +57 -12
  89. package/dist/channel/adapters/discord.js.map +1 -1
  90. package/dist/channel/adapters/telegram.d.ts +6 -0
  91. package/dist/channel/adapters/telegram.js +54 -14
  92. package/dist/channel/adapters/telegram.js.map +1 -1
  93. package/dist/channel/mcp-server.js +21 -4
  94. package/dist/channel/mcp-server.js.map +1 -1
  95. package/dist/channel/mcp-tools.d.ts +3 -1
  96. package/dist/channel/mcp-tools.js +41 -4
  97. package/dist/channel/mcp-tools.js.map +1 -1
  98. package/dist/channel/message-queue.js.map +1 -1
  99. package/dist/channel/sticker-download.js.map +1 -1
  100. package/dist/channel/tool-router.js.map +1 -1
  101. package/dist/child-process.d.ts +21 -0
  102. package/dist/child-process.js +54 -0
  103. package/dist/child-process.js.map +1 -0
  104. package/dist/classic-channel-manager.js +3 -2
  105. package/dist/classic-channel-manager.js.map +1 -1
  106. package/dist/cli-entry.js.map +1 -1
  107. package/dist/cli-flags.js.map +1 -1
  108. package/dist/cli.d.ts +1 -0
  109. package/dist/cli.js +443 -311
  110. package/dist/cli.js.map +1 -1
  111. package/dist/command-table.d.ts +1 -1
  112. package/dist/command-table.js +1 -1
  113. package/dist/command-table.js.map +1 -1
  114. package/dist/compact-watch.js.map +1 -1
  115. package/dist/completion-install.js.map +1 -1
  116. package/dist/completion.js.map +1 -1
  117. package/dist/config-validator.d.ts +6 -0
  118. package/dist/config-validator.js +81 -8
  119. package/dist/config-validator.js.map +1 -1
  120. package/dist/config.d.ts +15 -3
  121. package/dist/config.js +41 -10
  122. package/dist/config.js.map +1 -1
  123. package/dist/connection-secrets.js.map +1 -1
  124. package/dist/context-guardian.js.map +1 -1
  125. package/dist/coredump-filter.js.map +1 -1
  126. package/dist/cpu-profile.js.map +1 -1
  127. package/dist/cross-instance-envelope.js.map +1 -1
  128. package/dist/cross-instance-notice.d.ts +7 -0
  129. package/dist/cross-instance-notice.js +10 -0
  130. package/dist/cross-instance-notice.js.map +1 -1
  131. package/dist/daemon.d.ts +39 -1
  132. package/dist/daemon.js +234 -15
  133. package/dist/daemon.js.map +1 -1
  134. package/dist/daily-summary.js.map +1 -1
  135. package/dist/darwin-memory.js +1 -1
  136. package/dist/darwin-memory.js.map +1 -1
  137. package/dist/dashboard-live.d.ts +8 -0
  138. package/dist/dashboard-live.js +35 -0
  139. package/dist/dashboard-live.js.map +1 -0
  140. package/dist/dashboard-permissions.d.ts +69 -0
  141. package/dist/dashboard-permissions.js +138 -0
  142. package/dist/dashboard-permissions.js.map +1 -0
  143. package/dist/decision-list.js.map +1 -1
  144. package/dist/delivery-consumed-watch.js.map +1 -1
  145. package/dist/delivery-outbox.js +5 -0
  146. package/dist/delivery-outbox.js.map +1 -1
  147. package/dist/delivery-outbox.test.js.map +1 -1
  148. package/dist/delivery-reconciliation.test.js.map +1 -1
  149. package/dist/diagnostic-redaction.d.ts +10 -0
  150. package/dist/diagnostic-redaction.js +167 -0
  151. package/dist/diagnostic-redaction.js.map +1 -0
  152. package/dist/doctor.d.ts +8 -0
  153. package/dist/doctor.js +52 -0
  154. package/dist/doctor.js.map +1 -1
  155. package/dist/e2e-isolation.js.map +1 -1
  156. package/dist/event-log.js.map +1 -1
  157. package/dist/export-import.js +7 -5
  158. package/dist/export-import.js.map +1 -1
  159. package/dist/fleet-control-audit.js.map +1 -1
  160. package/dist/fleet-label.js.map +1 -1
  161. package/dist/fleet-level-config.d.ts +1 -1
  162. package/dist/fleet-level-config.js +2 -0
  163. package/dist/fleet-level-config.js.map +1 -1
  164. package/dist/fleet-lock.js +3 -2
  165. package/dist/fleet-lock.js.map +1 -1
  166. package/dist/fleet-manager.d.ts +249 -13
  167. package/dist/fleet-manager.js +1561 -292
  168. package/dist/fleet-manager.js.map +1 -1
  169. package/dist/fleet-process-handlers.d.ts +18 -0
  170. package/dist/fleet-process-handlers.js +70 -0
  171. package/dist/fleet-process-handlers.js.map +1 -0
  172. package/dist/fleet-startup.d.ts +14 -0
  173. package/dist/fleet-startup.js +30 -0
  174. package/dist/fleet-startup.js.map +1 -0
  175. package/dist/fleet-traffic.js.map +1 -1
  176. package/dist/forged-envelope-scan.d.ts +0 -20
  177. package/dist/forged-envelope-scan.js +3 -2
  178. package/dist/forged-envelope-scan.js.map +1 -1
  179. package/dist/full-restart.d.ts +5 -5
  180. package/dist/full-restart.js +5 -5
  181. package/dist/full-restart.js.map +1 -1
  182. package/dist/general-knowledge/skills/bug-report/SKILL.md +31 -0
  183. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +52 -11
  184. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +5 -1
  185. package/dist/general-knowledge/skills/fleet-config/SKILL.md +3 -1
  186. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +8 -9
  187. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  188. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
  189. package/dist/general-knowledge/skills/scheduling/SKILL.md +4 -0
  190. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +321 -0
  191. package/dist/general-knowledge/skills/web-appearance/SKILL.md +24 -4
  192. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +553 -0
  193. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +154 -31
  194. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  195. package/dist/general-knowledge/steering/core-rules.md +1 -1
  196. package/dist/host-memory.js.map +1 -1
  197. package/dist/idle-gate-log.d.ts +19 -0
  198. package/dist/idle-gate-log.js +15 -0
  199. package/dist/idle-gate-log.js.map +1 -0
  200. package/dist/instance-config-impact.js +25 -4
  201. package/dist/instance-config-impact.js.map +1 -1
  202. package/dist/instance-lifecycle.d.ts +10 -1
  203. package/dist/instance-lifecycle.js +86 -25
  204. package/dist/instance-lifecycle.js.map +1 -1
  205. package/dist/instructions.d.ts +12 -6
  206. package/dist/instructions.js +19 -10
  207. package/dist/instructions.js.map +1 -1
  208. package/dist/kiro-compact-pane.js.map +1 -1
  209. package/dist/kiro-db-reader.js.map +1 -1
  210. package/dist/kiro-engine-status.js.map +1 -1
  211. package/dist/kiro-log-gate.js.map +1 -1
  212. package/dist/kiro-transcript-lane.js.map +1 -1
  213. package/dist/locale.js +428 -20
  214. package/dist/locale.js.map +1 -1
  215. package/dist/log-service.js.map +1 -1
  216. package/dist/log-steps.d.ts +1 -0
  217. package/dist/log-steps.js +15 -0
  218. package/dist/log-steps.js.map +1 -1
  219. package/dist/logger.js +3 -2
  220. package/dist/logger.js.map +1 -1
  221. package/dist/login-controller.d.ts +45 -0
  222. package/dist/login-controller.js +101 -5
  223. package/dist/login-controller.js.map +1 -1
  224. package/dist/login-flows.js +1 -1
  225. package/dist/login-manager.js.map +1 -1
  226. package/dist/login-shell.js +3 -1
  227. package/dist/login-shell.js.map +1 -1
  228. package/dist/ls-attach-resolve.d.ts +96 -0
  229. package/dist/ls-attach-resolve.js +180 -0
  230. package/dist/ls-attach-resolve.js.map +1 -0
  231. package/dist/ls-rows.d.ts +12 -0
  232. package/dist/ls-rows.js +83 -4
  233. package/dist/ls-rows.js.map +1 -1
  234. package/dist/memory-pressure.js.map +1 -1
  235. package/dist/muse-usage-relay.js.map +1 -1
  236. package/dist/needs-you-hub.d.ts +98 -1
  237. package/dist/needs-you-hub.js +313 -44
  238. package/dist/needs-you-hub.js.map +1 -1
  239. package/dist/needs-you-live.d.ts +5 -2
  240. package/dist/needs-you-live.js +6 -3
  241. package/dist/needs-you-live.js.map +1 -1
  242. package/dist/needs-you.d.ts +68 -3
  243. package/dist/needs-you.js +38 -4
  244. package/dist/needs-you.js.map +1 -1
  245. package/dist/network-family.js.map +1 -1
  246. package/dist/operator-control-paths.js +3 -4
  247. package/dist/operator-control-paths.js.map +1 -1
  248. package/dist/outbound-handlers.d.ts +3 -0
  249. package/dist/outbound-handlers.js +76 -19
  250. package/dist/outbound-handlers.js.map +1 -1
  251. package/dist/outbound-schemas.d.ts +106 -60
  252. package/dist/outbound-schemas.js +33 -10
  253. package/dist/outbound-schemas.js.map +1 -1
  254. package/dist/outbox-open-error.js.map +1 -1
  255. package/dist/pane-context-cache.js.map +1 -1
  256. package/dist/paths.js.map +1 -1
  257. package/dist/private-dir.js.map +1 -1
  258. package/dist/probe-worker-pool.js.map +1 -1
  259. package/dist/profile-control.js.map +1 -1
  260. package/dist/prompt-file-ref.js.map +1 -1
  261. package/dist/provider-probe.js.map +1 -1
  262. package/dist/provider-secret-registry.d.ts +5 -3
  263. package/dist/provider-secret-registry.js +8 -2
  264. package/dist/provider-secret-registry.js.map +1 -1
  265. package/dist/public-link-auto-open.d.ts +32 -0
  266. package/dist/public-link-auto-open.js +86 -0
  267. package/dist/public-link-auto-open.js.map +1 -0
  268. package/dist/public-link-owner.d.ts +10 -0
  269. package/dist/public-link-owner.js +36 -0
  270. package/dist/public-link-owner.js.map +1 -0
  271. package/dist/public-link-progress.d.ts +12 -1
  272. package/dist/public-link-progress.js +5 -10
  273. package/dist/public-link-progress.js.map +1 -1
  274. package/dist/public-preview.js.map +1 -1
  275. package/dist/public-tunnel-config.d.ts +11 -0
  276. package/dist/public-tunnel-config.js +68 -0
  277. package/dist/public-tunnel-config.js.map +1 -0
  278. package/dist/public-web-gateway.d.ts +2 -0
  279. package/dist/public-web-gateway.js +19 -6
  280. package/dist/public-web-gateway.js.map +1 -1
  281. package/dist/public-web-link.d.ts +48 -3
  282. package/dist/public-web-link.js +177 -51
  283. package/dist/public-web-link.js.map +1 -1
  284. package/dist/quickstart-api.d.ts +9 -0
  285. package/dist/quickstart-api.js +82 -0
  286. package/dist/quickstart-api.js.map +1 -1
  287. package/dist/quickstart.d.ts +5 -0
  288. package/dist/quickstart.js +40 -25
  289. package/dist/quickstart.js.map +1 -1
  290. package/dist/reply-buttons-controller.js.map +1 -1
  291. package/dist/reply-dedup.js.map +1 -1
  292. package/dist/resource-report.js +1 -1
  293. package/dist/resource-report.js.map +1 -1
  294. package/dist/restart-guard.js.map +1 -1
  295. package/dist/restart-progress.d.ts +80 -1
  296. package/dist/restart-progress.js +306 -21
  297. package/dist/restart-progress.js.map +1 -1
  298. package/dist/rollout-index.js.map +1 -1
  299. package/dist/runtime-cpu-profile.js.map +1 -1
  300. package/dist/scheduler/db.d.ts +40 -13
  301. package/dist/scheduler/db.js +123 -89
  302. package/dist/scheduler/db.js.map +1 -1
  303. package/dist/scheduler/scheduler.js.map +1 -1
  304. package/dist/scheduler/task-board-text.d.ts +26 -0
  305. package/dist/scheduler/task-board-text.js +140 -0
  306. package/dist/scheduler/task-board-text.js.map +1 -0
  307. package/dist/scheduler/task-board.d.ts +730 -0
  308. package/dist/scheduler/task-board.js +2179 -0
  309. package/dist/scheduler/task-board.js.map +1 -0
  310. package/dist/scheduler/types.d.ts +6 -1
  311. package/dist/sd-notify.js +2 -1
  312. package/dist/sd-notify.js.map +1 -1
  313. package/dist/search/search-docs.d.ts +58 -0
  314. package/dist/search/search-docs.js +63 -0
  315. package/dist/search/search-docs.js.map +1 -0
  316. package/dist/search/search-index.d.ts +141 -0
  317. package/dist/search/search-index.js +440 -0
  318. package/dist/search/search-index.js.map +1 -0
  319. package/dist/search/search-store.d.ts +122 -0
  320. package/dist/search/search-store.js +236 -0
  321. package/dist/search/search-store.js.map +1 -0
  322. package/dist/search/search-worker.d.ts +1 -0
  323. package/dist/search/search-worker.js +163 -0
  324. package/dist/search/search-worker.js.map +1 -0
  325. package/dist/secret-file.js.map +1 -1
  326. package/dist/secret-store.d.ts +2 -0
  327. package/dist/secret-store.js +21 -0
  328. package/dist/secret-store.js.map +1 -1
  329. package/dist/service-installer.d.ts +75 -0
  330. package/dist/service-installer.js +206 -0
  331. package/dist/service-installer.js.map +1 -1
  332. package/dist/service-owner.d.ts +44 -0
  333. package/dist/service-owner.js +48 -0
  334. package/dist/service-owner.js.map +1 -0
  335. package/dist/service-plan.d.ts +39 -2
  336. package/dist/service-plan.js +188 -6
  337. package/dist/service-plan.js.map +1 -1
  338. package/dist/session-oom.d.ts +21 -0
  339. package/dist/session-oom.js +139 -0
  340. package/dist/session-oom.js.map +1 -0
  341. package/dist/settings-api.d.ts +13 -0
  342. package/dist/settings-api.js +73 -12
  343. package/dist/settings-api.js.map +1 -1
  344. package/dist/settings-baseline.js.map +1 -1
  345. package/dist/settings-change.d.ts +5 -0
  346. package/dist/settings-change.js +167 -6
  347. package/dist/settings-change.js.map +1 -1
  348. package/dist/settings-confirmation.d.ts +9 -1
  349. package/dist/settings-confirmation.js +18 -8
  350. package/dist/settings-confirmation.js.map +1 -1
  351. package/dist/settings-control.js +2 -1
  352. package/dist/settings-control.js.map +1 -1
  353. package/dist/settings-effect.js +29 -4
  354. package/dist/settings-effect.js.map +1 -1
  355. package/dist/settings-http-confirmation.d.ts +13 -0
  356. package/dist/settings-http-confirmation.js +15 -3
  357. package/dist/settings-http-confirmation.js.map +1 -1
  358. package/dist/settings-instance-patch.js +13 -1
  359. package/dist/settings-instance-patch.js.map +1 -1
  360. package/dist/settings-request-capability.d.ts +4 -1
  361. package/dist/settings-request-capability.js +11 -3
  362. package/dist/settings-request-capability.js.map +1 -1
  363. package/dist/settings-transaction.js.map +1 -1
  364. package/dist/setup-auth.d.ts +1 -0
  365. package/dist/setup-auth.js +2 -1
  366. package/dist/setup-auth.js.map +1 -1
  367. package/dist/setup-form.d.ts +1 -1
  368. package/dist/setup-form.js +23 -7
  369. package/dist/setup-form.js.map +1 -1
  370. package/dist/setup-host.d.ts +6 -1
  371. package/dist/setup-host.js +15 -4
  372. package/dist/setup-host.js.map +1 -1
  373. package/dist/setup-wizard.js +6 -3
  374. package/dist/setup-wizard.js.map +1 -1
  375. package/dist/spawn-gate.d.ts +5 -0
  376. package/dist/spawn-gate.js +28 -8
  377. package/dist/spawn-gate.js.map +1 -1
  378. package/dist/start-attempt.d.ts +1 -0
  379. package/dist/start-attempt.js +18 -0
  380. package/dist/start-attempt.js.map +1 -0
  381. package/dist/start-failure-notice.d.ts +4 -0
  382. package/dist/start-failure-notice.js +20 -0
  383. package/dist/start-failure-notice.js.map +1 -0
  384. package/dist/start-failure.d.ts +147 -0
  385. package/dist/start-failure.js +281 -0
  386. package/dist/start-failure.js.map +1 -0
  387. package/dist/startup-memory-deadline.d.ts +23 -0
  388. package/dist/startup-memory-deadline.js +55 -0
  389. package/dist/startup-memory-deadline.js.map +1 -0
  390. package/dist/statusline-watcher.js.map +1 -1
  391. package/dist/storm-window.js.map +1 -1
  392. package/dist/systemd-peer-bus.js.map +1 -1
  393. package/dist/target-queue-worker.js.map +1 -1
  394. package/dist/task-board-summary.d.ts +101 -0
  395. package/dist/task-board-summary.js +177 -0
  396. package/dist/task-board-summary.js.map +1 -0
  397. package/dist/task-board-tool.d.ts +25 -0
  398. package/dist/task-board-tool.js +155 -0
  399. package/dist/task-board-tool.js.map +1 -0
  400. package/dist/task-board-waker.d.ts +51 -0
  401. package/dist/task-board-waker.js +141 -0
  402. package/dist/task-board-waker.js.map +1 -0
  403. package/dist/tips.js.map +1 -1
  404. package/dist/tmux-control.js +1 -1
  405. package/dist/tmux-control.js.map +1 -1
  406. package/dist/tmux-exec-helper.d.ts +104 -0
  407. package/dist/tmux-exec-helper.js +412 -0
  408. package/dist/tmux-exec-helper.js.map +1 -0
  409. package/dist/tmux-manager.d.ts +1 -0
  410. package/dist/tmux-manager.js +29 -13
  411. package/dist/tmux-manager.js.map +1 -1
  412. package/dist/tmux-read.js +2 -2
  413. package/dist/tmux-read.js.map +1 -1
  414. package/dist/tool-permissions.js.map +1 -1
  415. package/dist/tool-progress.js.map +1 -1
  416. package/dist/topic-archiver.js.map +1 -1
  417. package/dist/topic-commands.d.ts +13 -0
  418. package/dist/topic-commands.js +56 -33
  419. package/dist/topic-commands.js.map +1 -1
  420. package/dist/topic-link.js.map +1 -1
  421. package/dist/transcript-delta-reader.js.map +1 -1
  422. package/dist/transcript-jsonl.js.map +1 -1
  423. package/dist/transcript-monitor.js +5 -0
  424. package/dist/transcript-monitor.js.map +1 -1
  425. package/dist/transcript-sources.d.ts +2 -0
  426. package/dist/transcript-sources.js +4 -0
  427. package/dist/transcript-sources.js.map +1 -1
  428. package/dist/tunnel/cloudflared-install.js +3 -1
  429. package/dist/tunnel/cloudflared-install.js.map +1 -1
  430. package/dist/tunnel/cloudflared.d.ts +7 -16
  431. package/dist/tunnel/cloudflared.js +100 -13
  432. package/dist/tunnel/cloudflared.js.map +1 -1
  433. package/dist/tunnel/lease.js +3 -2
  434. package/dist/tunnel/lease.js.map +1 -1
  435. package/dist/tunnel/manager.js.map +1 -1
  436. package/dist/tunnel/named-diagnostics.d.ts +13 -0
  437. package/dist/tunnel/named-diagnostics.js +52 -0
  438. package/dist/tunnel/named-diagnostics.js.map +1 -0
  439. package/dist/tunnel/public-fetch.js.map +1 -1
  440. package/dist/tunnel/purpose-lane.js.map +1 -1
  441. package/dist/tunnel/types.d.ts +4 -1
  442. package/dist/tunnel/types.js.map +1 -1
  443. package/dist/types.d.ts +55 -2
  444. package/dist/ui/app-effects.js +23 -5
  445. package/dist/ui/app.html +2 -0
  446. package/dist/ui/backend-signin.js +297 -0
  447. package/dist/ui/chat-effects.js +10 -3
  448. package/dist/ui/chat-msg-actions.js +176 -0
  449. package/dist/ui/chat-render.js +62 -18
  450. package/dist/ui/chat-store.js +218 -48
  451. package/dist/ui/chat-strings.js +40 -8
  452. package/dist/ui/chat-thread.js +119 -10
  453. package/dist/ui/effect-trial-offer.js +2 -31
  454. package/dist/ui/effects-waker.js +2 -0
  455. package/dist/ui/fleet-canvas-layout.js +13 -4
  456. package/dist/ui/fleet-canvas.js +69 -28
  457. package/dist/ui/fleet-office-editor.js +149 -0
  458. package/dist/ui/fleet-office-model.js +358 -0
  459. package/dist/ui/fleet-office-pack.js +199 -0
  460. package/dist/ui/fleet-office-skin.js +474 -0
  461. package/dist/ui/fleet-office-store.js +49 -0
  462. package/dist/ui/fleet-office-zones.js +177 -0
  463. package/dist/ui/fleet-office.js +434 -0
  464. package/dist/ui/fleet-org.js +18 -5
  465. package/dist/ui/fleet-route-scene.js +272 -0
  466. package/dist/ui/fleet-route-worker.js +21 -0
  467. package/dist/ui/fleet-route.js +88 -0
  468. package/dist/ui/fleet-task-card.js +436 -0
  469. package/dist/ui/fleet-task-common.js +482 -0
  470. package/dist/ui/fleet-task-work.js +107 -0
  471. package/dist/ui/fleet-tasks.js +569 -0
  472. package/dist/ui/fleet-traffic.js +81 -18
  473. package/dist/ui/fleet-usage.js +36 -2
  474. package/dist/ui/guest-refused.html +41 -0
  475. package/dist/ui/icons/icon-192.png +0 -0
  476. package/dist/ui/icons/icon-512.png +0 -0
  477. package/dist/ui/image-lightbox.js +45 -1
  478. package/dist/ui/image-zoom.js +242 -0
  479. package/dist/ui/panel-chat.js +200 -56
  480. package/dist/ui/panel-details.js +2 -0
  481. package/dist/ui/panel-fleet.js +32 -71
  482. package/dist/ui/panel-needs.js +23 -6
  483. package/dist/ui/panel-settings.js +158 -32
  484. package/dist/ui/preview-panel.js +2 -1
  485. package/dist/ui/search-dialog.js +350 -0
  486. package/dist/ui/settings-appearance.js +170 -10
  487. package/dist/ui/settings-apply.js +3 -3
  488. package/dist/ui/settings-confirm.js +7 -2
  489. package/dist/ui/settings-dialogs.js +46 -21
  490. package/dist/ui/settings-discord.js +6 -2
  491. package/dist/ui/settings-guests.js +2 -1
  492. package/dist/ui/settings-learn.js +1 -0
  493. package/dist/ui/settings-model.js +51 -18
  494. package/dist/ui/settings-mods.js +8 -3
  495. package/dist/ui/settings-owner-effects.js +6 -4
  496. package/dist/ui/settings-strings.js +180 -6
  497. package/dist/ui/settings-token.js +3 -1
  498. package/dist/ui/settings-web-authority.js +13 -5
  499. package/dist/ui/settings-wizard.js +13 -7
  500. package/dist/ui/shared/agend-auth.js +2 -0
  501. package/dist/ui/shared/app-i18n.js +12 -5
  502. package/dist/ui/shared/app-needs.js +2 -2
  503. package/dist/ui/shared/app-prefs.js +15 -0
  504. package/dist/ui/shared/app-route.js +18 -0
  505. package/dist/ui/shared/app-shell.js +172 -28
  506. package/dist/ui/shared/app-store.js +3 -2
  507. package/dist/ui/shared/app-stream.js +5 -1
  508. package/dist/ui/shared/app-themes.js +116 -1
  509. package/dist/ui/shared/app.css +662 -48
  510. package/dist/ui/shared/app.js +9 -1
  511. package/dist/ui/shared/card-strings.js +4 -0
  512. package/dist/ui/shared/effect-app-runtime.js +7 -5
  513. package/dist/ui/shared/effect-chat-runtime.js +2 -7
  514. package/dist/ui/shared/effect-cursor.js +3 -2
  515. package/dist/ui/shared/effect-display-metadata.js +39 -0
  516. package/dist/ui/shared/effect-owner-source.js +8 -1
  517. package/dist/ui/shared/effect-registry.js +14 -4
  518. package/dist/ui/shared/effect-runtime-loader.js +3 -1
  519. package/dist/ui/shared/effect-stock-trust.js +5 -5
  520. package/dist/ui/shared/feature-flags.js +127 -0
  521. package/dist/ui/shared/guest-refused.js +35 -0
  522. package/dist/ui/shared/header-tools.js +2 -2
  523. package/dist/ui/shared/identity-card.js +26 -8
  524. package/dist/ui/shared/identity-color.js +17 -10
  525. package/dist/ui/shared/instance-nav.js +183 -28
  526. package/dist/ui/shared/owner-css.js +30 -1
  527. package/dist/ui/shared/panel-view.js +14 -29
  528. package/dist/ui/shared/text-undo.js +210 -0
  529. package/dist/ui/shared/tokens.css +4 -0
  530. package/dist/ui/shared/ui-dialog.js +5 -4
  531. package/dist/ui/shared/ui-icons.js +4 -0
  532. package/dist/ui/shared/ui-menu.js +20 -7
  533. package/dist/ui/shared/ui-toast.js +16 -3
  534. package/dist/ui/shared/usage-dialog.js +206 -16
  535. package/dist/ui/shared/view-strings.js +68 -4
  536. package/dist/ui/shared/web-authorize.js +50 -1
  537. package/dist/ui/signin.html +2 -0
  538. package/dist/ui/web-terminal/terminal.html +10 -10
  539. package/dist/ui/web-terminal/terminal.js +58 -12
  540. package/dist/update-check.js.map +1 -1
  541. package/dist/update-dispatch.js +1 -1
  542. package/dist/update-dispatch.js.map +1 -1
  543. package/dist/update-install.js.map +1 -1
  544. package/dist/update-launch.d.ts +10 -1
  545. package/dist/update-launch.js +39 -14
  546. package/dist/update-launch.js.map +1 -1
  547. package/dist/update-marker.js.map +1 -1
  548. package/dist/update-preflight.js +1 -1
  549. package/dist/update-preflight.js.map +1 -1
  550. package/dist/update-progress.d.ts +3 -1
  551. package/dist/update-progress.js +3 -1
  552. package/dist/update-progress.js.map +1 -1
  553. package/dist/usage/format-rich.js +6 -3
  554. package/dist/usage/format-rich.js.map +1 -1
  555. package/dist/usage/i18n-keys.d.ts +1 -1
  556. package/dist/usage/i18n-keys.js +2 -1
  557. package/dist/usage/i18n-keys.js.map +1 -1
  558. package/dist/usage/i18n.d.ts +13 -1
  559. package/dist/usage/i18n.js +29 -0
  560. package/dist/usage/i18n.js.map +1 -1
  561. package/dist/usage/providers.d.ts +89 -2
  562. package/dist/usage/providers.js +247 -41
  563. package/dist/usage/providers.js.map +1 -1
  564. package/dist/usage/statusline-usage.js.map +1 -1
  565. package/dist/usage/usage-api.d.ts +5 -1
  566. package/dist/usage/usage-api.js +185 -58
  567. package/dist/usage/usage-api.js.map +1 -1
  568. package/dist/usage-report.js.map +1 -1
  569. package/dist/usage-rollup.js +1 -1
  570. package/dist/usage-rollup.js.map +1 -1
  571. package/dist/user-systemd.d.ts +24 -0
  572. package/dist/user-systemd.js +75 -0
  573. package/dist/user-systemd.js.map +1 -0
  574. package/dist/view-api.d.ts +7 -0
  575. package/dist/view-api.js +16 -4
  576. package/dist/view-api.js.map +1 -1
  577. package/dist/wake-coordinator.js.map +1 -1
  578. package/dist/web-ambient.js.map +1 -1
  579. package/dist/web-api.d.ts +57 -10
  580. package/dist/web-api.js +1103 -70
  581. package/dist/web-api.js.map +1 -1
  582. package/dist/web-app-icon-worker.d.ts +1 -0
  583. package/dist/web-app-icon-worker.js +16 -0
  584. package/dist/web-app-icon-worker.js.map +1 -0
  585. package/dist/web-app-icon.d.ts +109 -0
  586. package/dist/web-app-icon.js +257 -0
  587. package/dist/web-app-icon.js.map +1 -0
  588. package/dist/web-appearance-policy.js.map +1 -1
  589. package/dist/web-appearance-tools.d.ts +1 -0
  590. package/dist/web-appearance-tools.js +7 -3
  591. package/dist/web-appearance-tools.js.map +1 -1
  592. package/dist/web-appearance-view.js.map +1 -1
  593. package/dist/web-appearance.d.ts +4 -10
  594. package/dist/web-appearance.js +7 -4
  595. package/dist/web-appearance.js.map +1 -1
  596. package/dist/web-auth.d.ts +8 -4
  597. package/dist/web-auth.js +33 -21
  598. package/dist/web-auth.js.map +1 -1
  599. package/dist/web-backend-login.d.ts +130 -0
  600. package/dist/web-backend-login.js +225 -0
  601. package/dist/web-backend-login.js.map +1 -0
  602. package/dist/web-card.js.map +1 -1
  603. package/dist/web-channel-echo.js.map +1 -1
  604. package/dist/web-chat-history.d.ts +118 -8
  605. package/dist/web-chat-history.js +220 -25
  606. package/dist/web-chat-history.js.map +1 -1
  607. package/dist/web-commands.d.ts +5 -1
  608. package/dist/web-commands.js +1 -1
  609. package/dist/web-commands.js.map +1 -1
  610. package/dist/web-cookie-names.d.ts +5 -0
  611. package/dist/web-cookie-names.js +12 -0
  612. package/dist/web-cookie-names.js.map +1 -0
  613. package/dist/web-effect-bundle.d.ts +2 -2
  614. package/dist/web-effect-library.js.map +1 -1
  615. package/dist/web-effect-playback.js.map +1 -1
  616. package/dist/web-effect-profiles.d.ts +16 -16
  617. package/dist/web-effect-profiles.js +3 -2
  618. package/dist/web-effect-profiles.js.map +1 -1
  619. package/dist/web-effect-service.js.map +1 -1
  620. package/dist/web-effect-tools.js.map +1 -1
  621. package/dist/web-effect-trials.js.map +1 -1
  622. package/dist/web-effect-upload.js.map +1 -1
  623. package/dist/web-effect.d.ts +6 -6
  624. package/dist/web-effect.js.map +1 -1
  625. package/dist/web-experimental-http.d.ts +2 -0
  626. package/dist/web-experimental-http.js +12 -0
  627. package/dist/web-experimental-http.js.map +1 -0
  628. package/dist/web-experimental.d.ts +11 -0
  629. package/dist/web-experimental.js +49 -0
  630. package/dist/web-experimental.js.map +1 -0
  631. package/dist/web-guest-access.d.ts +4 -2
  632. package/dist/web-guest-access.js +22 -10
  633. package/dist/web-guest-access.js.map +1 -1
  634. package/dist/web-guest-rooms.js.map +1 -1
  635. package/dist/web-host-guard.d.ts +11 -0
  636. package/dist/web-host-guard.js +11 -0
  637. package/dist/web-host-guard.js.map +1 -1
  638. package/dist/web-icons.d.ts +33 -1
  639. package/dist/web-icons.js +83 -11
  640. package/dist/web-icons.js.map +1 -1
  641. package/dist/web-identity-color.js.map +1 -1
  642. package/dist/web-image-meta.d.ts +13 -0
  643. package/dist/web-image-meta.js +62 -0
  644. package/dist/web-image-meta.js.map +1 -1
  645. package/dist/web-images.js.map +1 -1
  646. package/dist/web-libavoid.d.ts +14 -0
  647. package/dist/web-libavoid.js +46 -0
  648. package/dist/web-libavoid.js.map +1 -0
  649. package/dist/web-login.js.map +1 -1
  650. package/dist/web-mentions.js.map +1 -1
  651. package/dist/web-mod-agent.js.map +1 -1
  652. package/dist/web-mod-store.d.ts +2 -0
  653. package/dist/web-mod-store.js +10 -0
  654. package/dist/web-mod-store.js.map +1 -1
  655. package/dist/web-office-store.d.ts +104 -0
  656. package/dist/web-office-store.js +236 -0
  657. package/dist/web-office-store.js.map +1 -0
  658. package/dist/web-owner-css-store.d.ts +44 -1
  659. package/dist/web-owner-css-store.js +70 -9
  660. package/dist/web-owner-css-store.js.map +1 -1
  661. package/dist/web-owner-css.js.map +1 -1
  662. package/dist/web-png.d.ts +33 -0
  663. package/dist/web-png.js +329 -0
  664. package/dist/web-png.js.map +1 -0
  665. package/dist/web-prefs.d.ts +6 -0
  666. package/dist/web-prefs.js +29 -3
  667. package/dist/web-prefs.js.map +1 -1
  668. package/dist/web-preview.d.ts +2 -0
  669. package/dist/web-preview.js +2 -0
  670. package/dist/web-preview.js.map +1 -1
  671. package/dist/web-profile.js.map +1 -1
  672. package/dist/web-reactions.d.ts +35 -0
  673. package/dist/web-reactions.js +47 -0
  674. package/dist/web-reactions.js.map +1 -0
  675. package/dist/web-request-context.d.ts +5 -0
  676. package/dist/web-request-context.js.map +1 -1
  677. package/dist/web-returning-device.d.ts +4 -2
  678. package/dist/web-returning-device.js +13 -6
  679. package/dist/web-returning-device.js.map +1 -1
  680. package/dist/web-schedules.js.map +1 -1
  681. package/dist/web-send-dedupe.d.ts +36 -0
  682. package/dist/web-send-dedupe.js +69 -0
  683. package/dist/web-send-dedupe.js.map +1 -0
  684. package/dist/web-session-authorization.js.map +1 -1
  685. package/dist/web-session.js +1 -1
  686. package/dist/web-session.js.map +1 -1
  687. package/dist/web-settings-patch.d.ts +3 -1
  688. package/dist/web-settings-patch.js +11 -5
  689. package/dist/web-settings-patch.js.map +1 -1
  690. package/dist/web-settings-tools.d.ts +6 -0
  691. package/dist/web-settings-tools.js +52 -17
  692. package/dist/web-settings-tools.js.map +1 -1
  693. package/dist/web-terminal-http.js +15 -2
  694. package/dist/web-terminal-http.js.map +1 -1
  695. package/dist/web-terminal.js +1 -1
  696. package/dist/web-terminal.js.map +1 -1
  697. package/dist/web-theme-store.d.ts +2 -2
  698. package/dist/web-theme-store.js +6 -3
  699. package/dist/web-theme-store.js.map +1 -1
  700. package/dist/web-theme.d.ts +29 -0
  701. package/dist/web-theme.js +89 -1
  702. package/dist/web-theme.js.map +1 -1
  703. package/dist/web-upload.d.ts +18 -1
  704. package/dist/web-upload.js +45 -7
  705. package/dist/web-upload.js.map +1 -1
  706. package/dist/web-widget.js.map +1 -1
  707. package/dist/webhook-emitter.js.map +1 -1
  708. package/dist/windows-shortcuts.d.ts +78 -0
  709. package/dist/windows-shortcuts.js +203 -0
  710. package/dist/windows-shortcuts.js.map +1 -0
  711. package/dist/workflow-templates/default.md +3 -0
  712. package/dist/ws-server.js.map +1 -1
  713. package/dist/yaml-shared-nodes.d.ts +25 -0
  714. package/dist/yaml-shared-nodes.js +185 -0
  715. package/dist/yaml-shared-nodes.js.map +1 -0
  716. package/launcher/agend-install.cjs +828 -0
  717. package/launcher/install-admission.cjs +4 -39
  718. package/launcher/postinstall.cjs +14 -12
  719. package/launcher/private-home.cjs +43 -0
  720. package/package.json +16 -13
  721. package/scripts/preinstall-guard.cjs +2 -5
  722. package/templates/windows-shortcuts.ps1 +181 -0
  723. package/launcher/old-updater-note.cjs +0 -48
@@ -1,6 +1,7 @@
1
+ import { PUBLIC_TUNNEL_TOKEN_ENV, PUBLIC_TUNNEL_TOKEN_HINT, normalizePublicTunnelToken } from "./public-tunnel-config.js";
1
2
  import { humanOwnerObjectId, webHumanOrigin } from "./human-delivery-receipts.js";
2
3
  import { SettingsBaselines } from "./settings-baseline.js";
3
- import { SettingsConfirmationStore } from "./settings-confirmation.js";
4
+ import { SettingsConfirmationStore, settingsSourceLabel } from "./settings-confirmation.js";
4
5
  import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
5
6
  import { SettingsControlServer } from "./settings-control.js";
6
7
  import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileResource, assertSettingsLease, trySettingsLease, waitSettingsLease, settingsUndo, undoSettingsPaths } from "./settings-transaction.js";
@@ -8,10 +9,13 @@ import { performance } from "node:perf_hooks";
8
9
  import { withSpawnHoldReply } from "./spawn-hold-reply.js";
9
10
  import { gatewayRequestContext } from "./web-request-context.js";
10
11
  import { createPublicWebGateway, PUBLIC_GATEWAY_RECEIVE_MS } from "./public-web-gateway.js";
11
- import { handleWebIconRequest } from "./web-icons.js";
12
+ import { handleWebIconRequest, useAppIconStore } from "./web-icons.js";
13
+ import { AppIconStore } from "./web-app-icon.js";
12
14
  import { trackWebServer } from "./web-server-drain.js";
13
15
  import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
14
- 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";
15
19
  import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
16
20
  import { CloudflaredProvider } from "./tunnel/cloudflared.js";
17
21
  import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
@@ -20,24 +24,27 @@ import { withinBudget } from "./monotonic-budget.js";
20
24
  import { measureSyncWork } from "./sync-work-attribution.js";
21
25
  import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
22
26
  import { ProfileControlServer } from "./profile-control.js";
23
- import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants, closeSync } from "node:fs";
27
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, lstatSync, constants as fsConstants, closeSync } from "node:fs";
24
28
  import { atomicWriteFileSync } from "./atomic-write.js";
25
29
  import { createHash, randomBytes, randomUUID } from "node:crypto";
26
30
  import { freemem, totalmem, cpus, homedir } from "node:os";
27
31
  import { createServer } from "node:http";
28
32
  import { Worker } from "node:worker_threads";
29
33
  import { ProbeWorkerPool } from "./probe-worker-pool.js";
30
- import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath } from "node:path";
34
+ import { join, dirname, basename, delimiter, isAbsolute, relative, resolve as resolvePath } from "node:path";
31
35
  import { fileURLToPath } from "node:url";
32
36
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
33
37
  import { fleetLabel } from "./fleet-label.js";
34
38
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
35
39
  import { formatUpdateProgress } from "./update-progress.js";
36
40
  import { sdNotify, sdNotifyBlocking, setSdNotifyWarn } from "./sd-notify.js";
41
+ import { StartupMemoryDeadline, fleetStopConcurrency } from "./startup-memory-deadline.js";
37
42
  import { readFleetMemory } from "./process-memory.js";
38
43
  import { MemoryPressure } from "./memory-pressure.js";
39
44
  import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
40
45
  import { isMap, isScalar, parseDocument } from "yaml";
46
+ import { changedOutside, detachForWrite } from "./yaml-shared-nodes.js";
47
+ import { tmuxExecHelper } from "./tmux-exec-helper.js";
41
48
  import { kiroLogGate } from "./kiro-log-gate.js";
42
49
  const __filename = fileURLToPath(import.meta.url);
43
50
  const __dirname = dirname(__filename);
@@ -71,10 +78,14 @@ import { createLogger, rotateLogIfNeeded } from "./logger.js";
71
78
  import { processAttachments } from "./channel/attachment-handler.js";
72
79
  import { routeToolCall } from "./channel/tool-router.js";
73
80
  import { Scheduler } from "./scheduler/index.js";
81
+ import { boardActivity, boardCardLimit, boardToolAction } from "./task-board-tool.js";
82
+ import { TaskBoardWaker } from "./task-board-waker.js";
83
+ import { TaskBoardError, waitsForPerson, wrapUpGap } from "./scheduler/task-board.js";
74
84
  import { escapeTelegramHtml, scheduleClock, scheduleRetryLabel } from "./scheduler/retry-label.js";
75
85
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
76
- import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, parseCompactCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineModel } from "./topic-commands.js";
86
+ import { TopicCommands, saveCommandForBackend, parseSaveFilename, parsePauseWakeCommand, parseCompactCommand, SAVE_FILENAME_RE, resolveInstanceContext, forgetInstanceContext, readStatuslineJson, statuslineModel } from "./topic-commands.js";
77
87
  import { DailySummary } from "./daily-summary.js";
88
+ import { TaskBoardSummaryClock, buildSummary, isQuiet, renderSummary, summaryMarkFile, summaryTime } from "./task-board-summary.js";
78
89
  import { WebhookEmitter } from "./webhook-emitter.js";
79
90
  import { TmuxControlClient } from "./tmux-control.js";
80
91
  import { safeHandler } from "./safe-async.js";
@@ -101,7 +112,8 @@ export const TASK_LIST_CAP = 100;
101
112
  * #1336: the non-terminal statuses a bare `list` returns by default. `done`
102
113
  * and `cancelled` are excluded unless the caller passes `filter_status`.
103
114
  */
104
- export const LIVE_TASK_STATUSES = ["open", "claimed", "blocked"];
115
+ /** Not finished: `review` is done work waiting for the person (the task board), still live. */
116
+ export const LIVE_TASK_STATUSES = ["open", "claimed", "blocked", "review"];
105
117
  /**
106
118
  * #1336 P2: shared normalization for filter_status across both task handlers
107
119
  * and the cap helper. Trims whitespace before dropping empties, so
@@ -135,15 +147,17 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
135
147
  }
136
148
  return tasks;
137
149
  }
138
- import { handleWebRequest, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
150
+ import { handleWebRequest, webPeerOf, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
139
151
  import { LogService } from "./log-service.js";
140
152
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId, webChatFrom } from "./web-chat-history.js";
153
+ import { WEB_ECHO_COPIES_MAX, WEB_MIRROR_TIMEOUT_MS, webReactionEmoji, webReactionProblem } from "./web-reactions.js";
141
154
  import { WebChatDiskStore } from "./web-chat-store.js";
142
155
  import { SeenUsers, resolveMentions } from "./web-mentions.js";
143
156
  import { OPERATOR, WebPrefsStore } from "./web-prefs.js";
144
157
  import { agendVersion } from "./agent-whats-new.js";
145
158
  import { OwnerCssStore } from "./web-owner-css-store.js";
146
159
  import { ThemeStore } from "./web-theme-store.js";
160
+ import { OfficeStore, OfficeWriteLimiter } from "./web-office-store.js";
147
161
  import { WebAppearance } from "./web-appearance.js";
148
162
  import { WebEffectLibrary } from "./web-effect-library.js";
149
163
  import { WebEffectTrials } from "./web-effect-trials.js";
@@ -159,7 +173,7 @@ import { ReplyButtonsController } from "./reply-buttons-controller.js";
159
173
  import { parsePoll } from "./reply-polls.js";
160
174
  import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
161
175
  import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
162
- import { cardPicture, handleViewRequest, humanCards, identityColors, isViewPath, personBannerFocus, personIdentityColour, profileIdentities, resolveInstanceIdentity, setPersonBannerFocus, setPersonIdentityColour } from "./view-api.js";
176
+ import { cardPicture, handleViewRequest, humanCards, identityColors, instanceTeams, isViewPath, personBannerFocus, personIdentityColour, profileIdentities, resolveInstanceIdentity, setPersonBannerFocus, setPersonIdentityColour } from "./view-api.js";
163
177
  import { autoIdentityColor } from "./web-identity-color.js";
164
178
  import { AppearanceTools } from "./web-appearance-tools.js";
165
179
  import { cardVersion, resolveCard } from "./web-card.js";
@@ -179,7 +193,9 @@ import { runOnLoginShell } from "./login-shell.js";
179
193
  import { tightenInstanceDirs } from "./private-dir.js";
180
194
  import { decideSlash } from "./slash-authz.js";
181
195
  import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
182
- import { crossInstanceVisibility, runVisibilityCommand } from "./cross-instance-notice.js";
196
+ import { crossInstanceVisibility, runVisibilityCommand, xmsgVisibility } from "./cross-instance-notice.js";
197
+ import { SEARCH_RETENTION_DAYS, SearchIndex } from "./search/search-index.js";
198
+ import { chatDoc, taskDoc, xmsgDoc } from "./search/search-docs.js";
183
199
  import { WebSettingsTools } from "./web-settings-tools.js";
184
200
  import { bindGuestRequest, guestRouteDecision } from "./web-guest-access.js";
185
201
  import { GUEST_ROOM_TICK_MS, GuestRoomMembership, guestGroupRooms } from "./web-guest-rooms.js";
@@ -188,6 +204,7 @@ import { installedChannel, updateNoticeKey } from "./update-check.js";
188
204
  import { resolveInstalledAgend } from "./update-dispatch.js";
189
205
  import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
190
206
  import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
207
+ import { WebBackendLogins, dashboardSignInSupported } from "./web-backend-login.js";
191
208
  import { runBeforeDeadline } from "./deadline.js";
192
209
  import { LoginWindowLock } from "./login-window-lock.js";
193
210
  import { handleSettingsRequest } from "./settings-api.js";
@@ -200,6 +217,7 @@ import { assertExplicitInstanceRemoval } from "./instance-removal.js";
200
217
  import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
201
218
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
202
219
  import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
220
+ import { idleGateTimeoutLog } from "./idle-gate-log.js";
203
221
  import { DEFAULT_STUCK_TIMEOUT_MS, readLastInboundAt } from "./daemon.js";
204
222
  import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
205
223
  import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
@@ -222,9 +240,10 @@ import { CompactWatch, launchIdentity } from "./compact-watch.js";
222
240
  import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
223
241
  import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
224
242
  import { sharedRolloutIndex } from "./rollout-index.js";
243
+ import { TASK_BOARD_ON_NOTICE, dashboardRefusalIn, taskBoardSwitchedOn } from "./dashboard-permissions.js";
225
244
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
226
- import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
227
- import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
245
+ import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken, sessionCookieUpgrade } from "./web-auth.js";
246
+ import { bypassesWebGate, handleAuthRequest, serveGuestRefusedPage, serveSigninPage } from "./auth-api.js";
228
247
  import { isWebPageNavigation } from "./web-shell-routes.js";
229
248
  import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
230
249
  import { authorityNow, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
@@ -236,22 +255,27 @@ import { decisionListAnswer } from "./decision-list.js";
236
255
  import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
237
256
  import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, LOOPBACK_HOST_NAMES, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
238
257
  import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
258
+ import { canvasWorkerEnabled, isCanvasWorkerTool, CANVAS_WORKER_EXPERIMENTAL_MESSAGE } from "./web-experimental.js";
259
+ import { isCanvasWorkerHttpPath } from "./web-experimental-http.js";
260
+ import { bundledSkillContents } from "./bundled-skills.js";
239
261
  import { createEffectBootstrap } from "./web-effect-bootstrap.js";
240
262
  import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
241
263
  import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
242
264
  import { SecretStore } from "./secret-store.js";
243
265
  import { opaqueId, safeSecretError, SECRET_CHALLENGE_TTL_MS, } from "./connection-secrets.js";
244
266
  import { verifyDiscordToken, verifyTelegramToken } from "./provider-probe.js";
245
- import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isReservedProviderEnvKey, verifyProviderSecret, } from "./provider-secret-registry.js";
267
+ import { PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC, providerSecretSpec, providerRegistryEnvKeys, isReservedProviderEnvKey, verifyProviderSecret, } from "./provider-secret-registry.js";
246
268
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
247
269
  const SELF_RESTART_DEADLINE_MS = 300_000;
248
270
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
249
- import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
271
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileLoginCommand, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
250
272
  import { carryClaudeConversation } from "./backend/claude-carry.js";
251
273
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
252
274
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
253
- import { cleanupWatchVerdict, formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
275
+ import { cleanupWatchVerdict, formatRestartProgressCompletion, RestartProgress, } from "./restart-progress.js";
276
+ import { FleetStartup } from "./fleet-startup.js";
254
277
  import { launchFullRestartHelper } from "./full-restart.js";
278
+ import { fullReloadExitCode } from "./service-owner.js";
255
279
  import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
256
280
  import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
257
281
  import { StormWindow } from "./storm-window.js";
@@ -259,6 +283,14 @@ import { SpawnGate } from "./spawn-gate.js";
259
283
  import { BackendOutageTracker } from "./backend-outage.js";
260
284
  import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
261
285
  import { getTmuxSession } from "./config.js";
286
+ // Closed migration cohort: these 11 names existed immediately before the first
287
+ // managed-skill publisher (a1ac499f; parent e267e684). Never grow this set when
288
+ // adding a bundled skill: its new name is not proof of old AgEnD ownership.
289
+ const PRE_MANIFEST_KIRO_SKILLS = new Set([
290
+ "backend-providers", "cross-instance-messaging", "fleet-config", "fleet-health",
291
+ "fleet-restart", "instance-lifecycle", "model-discovery", "multi-channel",
292
+ "scheduling", "session-management", "tui-effort",
293
+ ]);
262
294
  export function resolveReplyThreadId(argsThreadId, instanceConfig) {
263
295
  if (typeof argsThreadId === "string" && argsThreadId.length > 0) {
264
296
  return argsThreadId;
@@ -295,6 +327,41 @@ export function selectLruEvictions(warm, cap, opts) {
295
327
  * fleet.yaml. Keep in sync with `uniqueInstanceName` — every name it emits
296
328
  * must match here, or a deleted instance's CLI is left running (#1305 P2-3).
297
329
  */
330
+ /**
331
+ * #2163: where `p` really is — symlinks resolved. A path that does not exist yet is resolved through its deepest
332
+ * existing ancestor (a missing child of a link elsewhere is elsewhere too).
333
+ */
334
+ function realPathThroughAncestors(p) {
335
+ const missing = [];
336
+ let at = resolvePath(p);
337
+ for (;;) {
338
+ try {
339
+ return join(realpathSync(at), ...missing.reverse());
340
+ }
341
+ catch { /* not there yet: try its parent */ }
342
+ try {
343
+ lstatSync(at);
344
+ return null;
345
+ }
346
+ catch { /* truly absent — not a broken link whose target is unknown */ }
347
+ const up = dirname(at);
348
+ if (up === at)
349
+ return resolvePath(p);
350
+ missing.push(basename(at));
351
+ at = up;
352
+ }
353
+ }
354
+ /**
355
+ * #2163: whether `dir` is inside AgEnD's own workspaces (<AGEND_HOME>/workspaces/<name>/…), compared after resolving
356
+ * symlinks on both sides — including a missing folder's existing ancestors — so a link elsewhere is not inside, nor is a broken link.
357
+ */
358
+ export function insideManagedWorkspaces(dir, agendHome = getAgendHome()) {
359
+ const root = realPathThroughAncestors(join(agendHome, "workspaces")), real = realPathThroughAncestors(dir);
360
+ if (root === null || real === null)
361
+ return false;
362
+ const rel = relative(root, real);
363
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
364
+ }
298
365
  export function isOrphanInstanceWindowName(name) {
299
366
  return /-t\d+$/.test(name) || /^classic-/.test(name);
300
367
  }
@@ -554,6 +621,8 @@ export class FleetManager {
554
621
  fleetConfig = null;
555
622
  rawFleetConfig = {};
556
623
  rawFleetDocument = null;
624
+ /** #2208: the paths a saveFleetConfig() wrote, for its check that nothing else changed. */
625
+ fleetDocumentWrites = null;
557
626
  savedFleetConfigSnapshot = null;
558
627
  adapter = null;
559
628
  worlds = new Map();
@@ -609,7 +678,8 @@ export class FleetManager {
609
678
  ]);
610
679
  logger = createLogger("info");
611
680
  /** Report each bundled skill collision once per fleet, across instance starts. */
612
- 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();
613
683
  topicCommands;
614
684
  // sessionName → instanceName mapping for external sessions
615
685
  sessionRegistry = new Map();
@@ -617,7 +687,11 @@ export class FleetManager {
617
687
  costGuard = null;
618
688
  statuslineWatcher;
619
689
  dailySummary = null;
690
+ /** #2246 step 3: the task board's daily summary for the person (task-board-summary.ts). */
691
+ taskBoardSummary = null;
620
692
  dailyTipScheduler = null;
693
+ /** The task board's wakes (step 2): an instance hears when someone else changes a card it owns or handed out. */
694
+ taskBoardWaker = null;
621
695
  webhookEmitter = null;
622
696
  // Topic icon + auto-archive state
623
697
  topicIcons = {};
@@ -746,7 +820,11 @@ export class FleetManager {
746
820
  // Adapter restart: prevents re-entrant restart attempts
747
821
  adapterRestarting = new Set();
748
822
  // Adapter isolation: track state per adapter for retry + visibility
823
+ /** #2175: this boot's progress message, whose completion may still be owed by its background retry. */
824
+ startupProgress = null;
825
+ startupDashboard = null;
749
826
  adapterState = new Map();
827
+ adapterRetryInFlight = new Map();
750
828
  /** Web Settings secret rotation is deliberately separate from reconnect
751
829
  * recovery: a rotation must build a fresh provider client with the new token,
752
830
  * and stale callbacks from the old client must not win. */
@@ -788,6 +866,7 @@ export class FleetManager {
788
866
  effectBootstrap = createEffectBootstrap();
789
867
  /** Fixed trusted document coherence, independent of the HTML preview listener; never a resource grant. */
790
868
  get effectBoot() { return this.effectBootstrap.boot; }
869
+ canvasWorkerEnabled() { return canvasWorkerEnabled(this.fleetConfig?.web); }
791
870
  healthServerDrain = null;
792
871
  /** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
793
872
  previewListener = null;
@@ -825,6 +904,7 @@ export class FleetManager {
825
904
  /** Days of event/activity history to keep. */
826
905
  static EVENT_LOG_RETENTION_DAYS = 30;
827
906
  watchdogTimer = null;
907
+ startupMemoryDeadline = null;
828
908
  eventLoopWatch = null;
829
909
  startedAt = 0;
830
910
  stormOpenNotifyTimer = null;
@@ -853,11 +933,35 @@ export class FleetManager {
853
933
  webChatHistory = new WebChatHistory();
854
934
  /** #1565: the web chat history's files (set at fleet start, once the home and the instances are known). */
855
935
  webChatStore = null;
936
+ /** #2293: the dashboard's global search (null: web.search.enabled is false, or the fleet has not started). */
937
+ searchIndex = null;
856
938
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
857
939
  // #1937: the upload limits are the config's, read on each use (agend reload applies a change to the next upload).
858
940
  webFiles = new WebFileLedger({ limits: () => uploadLimits(this.fleetConfig?.web) });
859
941
  /** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
860
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
+ }
861
965
  /** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
862
966
  webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
863
967
  /** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
@@ -943,6 +1047,11 @@ export class FleetManager {
943
1047
  // r4.1 Q3 (b): the card's limits are read from fleet.yaml at each upload (a change applies without a restart).
944
1048
  return (this.themeStoreInstance ??= new ThemeStore(this.dataDir, { limits: () => effectiveAppearanceLimits(this.fleetConfig?.web?.appearance).limits, effectsDropped: ids => this.effectLibrary.collectUnreferenced(ids) }));
945
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();
946
1055
  /** #1629: the theme tools, a person's reset and lock, and the one appearance rate limiter (shared with effects). */
947
1056
  get webAppearance() {
948
1057
  return (this.webAppearanceService ??= new WebAppearance(this.themeStore, () => this.fleetConfig?.web ?? null, () => { try {
@@ -1032,6 +1141,7 @@ export class FleetManager {
1032
1141
  /** #1629 r4 §1: get_web_appearance / preview_web_appearance — the caller's own look, resolved as the page resolves it. */
1033
1142
  get appearanceTools() {
1034
1143
  return (this.appearanceToolsService ??= new AppearanceTools({
1144
+ canvasWorkerEnabled: () => this.canvasWorkerEnabled(),
1035
1145
  frame: (name) => (this.getUiStatus().instances.find(i => i.name === name) ?? { name }),
1036
1146
  theme: (name) => {
1037
1147
  const r = this.themeStore.get(name);
@@ -1076,6 +1186,7 @@ export class FleetManager {
1076
1186
  prefs: this.webPrefs,
1077
1187
  ownerCss: this.ownerCss,
1078
1188
  effects: this.effectLibrary,
1189
+ canvasWorkerEnabled: () => this.canvasWorkerEnabled(),
1079
1190
  context: () => ({ agendVersion: agendVersion(), limits: this.themeStore.limits(), effects: cw1EffectChecker() }),
1080
1191
  isKnownInstance: (name) => this.isKnownInstance(name),
1081
1192
  prefsIdentity: OPERATOR,
@@ -1114,6 +1225,11 @@ export class FleetManager {
1114
1225
  const card = resolveCard(name, humanCards(this.dataDir).get(name), this.themeStore.get(name).theme.card);
1115
1226
  return { ...(card.tagline ? { tagline: card.tagline.text } : {}), ...(card.bio ? { bio: card.bio.text } : {}) };
1116
1227
  }
1228
+ appIconStore;
1229
+ /** #2308: the person's own app icon (Settings → Web appearance), under the state dir; none set: the bundled one. */
1230
+ get appIcon() {
1231
+ return (this.appIconStore ??= new AppIconStore(this.dataDir));
1232
+ }
1117
1233
  ownerCssStore;
1118
1234
  /** #1629 r4 §3: each person's own CSS; which version is in force is their `css.active` in the web preferences. */
1119
1235
  get ownerCss() {
@@ -1126,6 +1242,7 @@ export class FleetManager {
1126
1242
  */
1127
1243
  get webSettings() {
1128
1244
  return (this.webSettingsToolsService ??= new WebSettingsTools({
1245
+ canvasWorkerEnabled: () => this.canvasWorkerEnabled(),
1129
1246
  prefs: this.webPrefs, ownerCss: this.ownerCss, mods: this.mods, effects: this.effectService,
1130
1247
  instances: () => [...Object.keys(this.fleetConfig?.instances ?? {}),
1131
1248
  ...(this.classicChannels?.getAll() ?? []).map((ch) => ch.instanceName).filter((n) => !Object.hasOwn(this.fleetConfig?.instances ?? {}, n))],
@@ -1243,9 +1360,11 @@ export class FleetManager {
1243
1360
  return null;
1244
1361
  const record = auth.session, sessions = this.webSessions, exposure = gatewayRequestContext(req);
1245
1362
  return { id: record.idHash, label: record.label, source: exposure ? "public_link" : "web_session",
1363
+ localAdmin: () => record.tier === "admin" && record.surface === "local" && !exposure,
1246
1364
  current: () => current() && sessions === this.webSessions && !!this.webToken
1247
1365
  && sessions.isCurrent(record, tokenEpoch(this.webToken)) && (!exposure || exposure.isCurrent()) };
1248
1366
  }, baseline: () => baselines.read(), snapshot: () => baselines.snapshot(),
1367
+ selfConfirm: (req, id) => this.settingsSelfConfirmation(store, req, id),
1249
1368
  job: async (id) => {
1250
1369
  const settlement = this.settingsJobSettlements.get(id);
1251
1370
  if (!settlement)
@@ -1260,6 +1379,93 @@ export class FleetManager {
1260
1379
  this.settingsConfirmation = gate;
1261
1380
  return gate;
1262
1381
  }
1382
+ /**
1383
+ * #2170: a held change confirmed by the request itself — only when ALL hold, else the chat card or the host CLI, as
1384
+ * before:
1385
+ * - the session is web-verified (#1939: its account confirmed this browser on Discord or Telegram), asked now;
1386
+ * - the change's effect owners are known and on one platform (settingsChatAuthority — the chat card's own rule), the
1387
+ * verified account is on that platform and an admin of every one of them;
1388
+ * - a chat card could have been asked: a connected General on that platform the change does not affect (else the
1389
+ * change goes to the host CLI, as before) — the General that then hears of it;
1390
+ * - and, at execution (store.decide asks `current()` again): still web-verified under the same session and account
1391
+ * (not expired, revoked, signed out or the switch turned off), still those owners' admin, still a General a card
1392
+ * could go to, the fleet unchanged.
1393
+ * What it rests on: the account's one confirmation of this browser (#1939), kept for the session until it is revoked,
1394
+ * expires or signs out — not a fresh confirmation per Apply. Whoever uses that verified session (a browser left signed
1395
+ * in, its cookie) confirms the same way. A public link never qualifies (evaluateWebAuthority refuses it). General
1396
+ * still hears it: a notice, not a step.
1397
+ */
1398
+ settingsSelfConfirmation(store, req, id) {
1399
+ const auth = authorizeSession(req, this.webToken, this.webSessions, { touch: false });
1400
+ if (auth.kind !== "ok")
1401
+ return null;
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);
1409
+ const verified = () => {
1410
+ const now = this.webAuthority({ session: record, gateway, peer });
1411
+ // A refusal carries no authority (kind "none"); the handle pins the session, and with it its one account.
1412
+ return now.authority.kind === "web-verified" && now.authority.actor && now.authority.session?.handle === record.handle ? now.authority.actor : null;
1413
+ };
1414
+ const actor = verified();
1415
+ if (!actor)
1416
+ return null;
1417
+ const chat = this.settingsChatAuthority(store, id);
1418
+ if (!chat)
1419
+ return null;
1420
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
1421
+ const own = channels.filter(ch => (ch.id ?? ch.type) === actor.adapter_id);
1422
+ if (own.length !== 1 || own[0].type !== chat.platform || !chat.admin(actor.user_id))
1423
+ return null;
1424
+ const affected = [...store.affectedConnections(id)]; // read now: a finished change no longer has them
1425
+ if (!this.settingsNoticeGenerals(affected, chat.platform).length)
1426
+ return null;
1427
+ const name = this.webPerson({ adapterId: actor.adapter_id, userId: actor.user_id }).name;
1428
+ return {
1429
+ label: t("settings_confirm.web_verified_label", name),
1430
+ // admin() asks chat.current() too; a General a card could go to must still be there (else the host CLI's).
1431
+ current: () => !!verified() && chat.admin(actor.user_id) && this.settingsNoticeGenerals(affected, chat.platform).length > 0,
1432
+ done: view => this.settingsWebConfirmedNotice(view, affected, name, chat.platform),
1433
+ };
1434
+ }
1435
+ /** #2170: the Generals a chat card could be posted in (promptSettingsChange's rule): connected, on the platform, unaffected. */
1436
+ settingsNoticeGenerals(affected, platform) {
1437
+ const excluded = new Set(affected), found = [];
1438
+ for (const [name, config] of Object.entries(this.fleetConfig?.instances ?? {})) {
1439
+ if (!config.general_topic || !this.daemons.get(name))
1440
+ continue;
1441
+ const adapterId = this.getInstanceAdapterId(name), adapter = this.getAdapterForInstance(name);
1442
+ if (!adapterId || !adapter || excluded.has(adapterId) || adapter.type !== platform || this.adapterState.get(adapterId)?.status !== "connected")
1443
+ continue;
1444
+ const chatId = this.getGroupIdForInstance(name), topic = String(config.topic_id ?? "");
1445
+ if (chatId && topic)
1446
+ found.push({ adapterId, adapter, chatId, topic });
1447
+ }
1448
+ return found;
1449
+ }
1450
+ /**
1451
+ * #2170: General's notice of a change confirmed from the web — the first General a chat card could have been posted
1452
+ * in, plain text, no mentions. Information only: nothing waits on it.
1453
+ */
1454
+ async settingsWebConfirmedNotice(view, affected, who, platform) {
1455
+ const outcome = view.outcome ? view.outcome.message : view.state;
1456
+ const text = [t("settings_confirm.web_confirmed", who), t("settings_confirm.source", settingsSourceLabel(view.source)),
1457
+ t("settings_confirm.requester", view.requested_by), ...view.summary, t("settings_confirm.outcome", outcome)].join("\n");
1458
+ for (const { adapterId, adapter, chatId, topic } of this.settingsNoticeGenerals(affected, platform)) {
1459
+ try {
1460
+ await adapter.sendText(chatId, text, { threadId: topic, allowedMentions: { parse: [] } });
1461
+ return;
1462
+ }
1463
+ catch {
1464
+ this.logger.info({ id: view.id, adapterId }, "Settings web-confirmation notice unavailable; trying another General");
1465
+ }
1466
+ }
1467
+ this.logger.info({ id: view.id }, "Settings change confirmed from the web: no General reachable for the notice");
1468
+ }
1263
1469
  /** Exact configured effect owners; this does not depend on prompt location. */
1264
1470
  settingsChatAuthority(store, id) {
1265
1471
  const scope = store.authorityOf(id);
@@ -1330,9 +1536,10 @@ export class FleetManager {
1330
1536
  try {
1331
1537
  const posted = this.postNonceButtonPromptOrThrow({ prefix: "settings-confirm:", alertType: "clear_confirm",
1332
1538
  instanceName: name, adapterId, adapter, chatId, threadId: topic,
1333
- message: `🔒 Settings change awaiting fleet-admin confirmation\nSource: ${view.source}\nRequester: ${view.requested_by}\n${view.summary.join("\n")}`,
1334
- choices: [{ action: "confirm", label: "Confirm" }, { action: "reject", label: "Reject" }],
1335
- expiredText: "Settings confirmation is no longer pending.", timeoutMs: view.remaining_ms,
1539
+ message: [t("settings_confirm.prompt_title"), t("settings_confirm.source", settingsSourceLabel(view.source)),
1540
+ t("settings_confirm.requester", view.requested_by), ...view.summary].join("\n"),
1541
+ choices: [{ action: "confirm", label: t("settings_confirm.confirm_button") }, { action: "reject", label: t("settings_confirm.reject_button") }],
1542
+ expiredText: t("settings_confirm.no_longer_pending"), timeoutMs: view.remaining_ms,
1336
1543
  extra: { pendingChangeId: view.id, confirmationCurrent: current, confirmationAdmin: userId => current() && authority.admin(userId) },
1337
1544
  });
1338
1545
  void posted.then(nonce => {
@@ -1343,7 +1550,7 @@ export class FleetManager {
1343
1550
  return;
1344
1551
  this.pendingNonceButtons.delete(nonce);
1345
1552
  clearTimeout(entry.timer);
1346
- void this.retireNonceButtons(entry, entry.messageId ?? "", "Settings confirmation delivery expired.");
1553
+ void this.retireNonceButtons(entry, entry.messageId ?? "", t("settings_confirm.delivery_expired"));
1347
1554
  }, () => { });
1348
1555
  const nonce = await withinBudget(posted, postingDeadline);
1349
1556
  attached = true;
@@ -1353,7 +1560,7 @@ export class FleetManager {
1353
1560
  return;
1354
1561
  this.pendingNonceButtons.delete(nonce);
1355
1562
  clearTimeout(entry.timer);
1356
- void this.retireNonceButtons(entry, entry.messageId ?? "", "Settings confirmation is no longer pending.");
1563
+ void this.retireNonceButtons(entry, entry.messageId ?? "", t("settings_confirm.no_longer_pending"));
1357
1564
  };
1358
1565
  if (!current()) {
1359
1566
  retire();
@@ -1379,14 +1586,14 @@ export class FleetManager {
1379
1586
  const { entry, action } = result;
1380
1587
  try {
1381
1588
  const view = await this.settingsGate().store.decide(entry.pendingChangeId, action, {
1382
- label: `fleet admin ${data.userId}`,
1589
+ label: t("settings_confirm.admin_label", data.userId ?? ""),
1383
1590
  current: () => entry.confirmationCurrent?.() === true && this.getAdapterForInstance(entry.instanceName) === entry.adapter
1384
1591
  && !!data.userId && entry.confirmationAdmin?.(data.userId) === true,
1385
1592
  });
1386
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? "Settings change is applying.");
1593
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, view.outcome?.message ?? t("settings_confirm.applying"));
1387
1594
  }
1388
1595
  catch {
1389
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, "Settings confirmation rejected or expired.");
1596
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("settings_confirm.rejected_or_expired"));
1390
1597
  }
1391
1598
  return true;
1392
1599
  }
@@ -1465,6 +1672,7 @@ export class FleetManager {
1465
1672
  this.spawnGate = new SpawnGate({
1466
1673
  storm: this.stormWindow,
1467
1674
  memoryPressure: this.memoryPressure,
1675
+ onStartupMemoryWait: () => this.startupMemoryDeadline?.check(),
1468
1676
  concurrency: () => this.spawnConcurrency(),
1469
1677
  staggerMs: () => this.fleetConfig?.defaults?.startup?.stagger_delay_ms ?? 500,
1470
1678
  });
@@ -1655,7 +1863,10 @@ export class FleetManager {
1655
1863
  : t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
1656
1864
  this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state,
1657
1865
  cause: row.lastError, ...(silent ? { silentSchedule: true } : {}) }, notice);
1658
- this.notifyFleetError(notice);
1866
+ // A self-scheduled silent heartbeat stays log-only, including the
1867
+ // operator alert; another source still receives the normal notice.
1868
+ if (!silent || row.sourceInstance !== row.targetInstance)
1869
+ this.notifyFleetError(notice);
1659
1870
  }
1660
1871
  }
1661
1872
  });
@@ -2507,6 +2718,11 @@ export class FleetManager {
2507
2718
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
2508
2719
  this.warnAboutRemovedLoginMode(loaded);
2509
2720
  this.enforceGuestSwitch();
2721
+ this.observeWebAuthority(); // #2453 review: a reload's web-authority switches are seen at once
2722
+ // Child tool/guidance readers must follow this fleet's actual config, including --config.
2723
+ // Scope the path to its owning data directory so another inherited home's override cannot enable CW.
2724
+ process.env.AGEND_FLEET_DATA_DIR = resolvePath(this.dataDir);
2725
+ process.env.AGEND_FLEET_CONFIG_PATH = resolvePath(configPath);
2510
2726
  return this.fleetConfig;
2511
2727
  }
2512
2728
  relayLoginModeWarned = false;
@@ -2686,8 +2902,17 @@ export class FleetManager {
2686
2902
  this.logger.info(`Received SIGUSR1, initiating full restart (process reload)... ${describeSignalSource(this.dataDir, "SIGUSR1")}`);
2687
2903
  this.gracefulShutdownForReload()
2688
2904
  .then(() => {
2689
- this.logger.info("Full restart: shutdown complete, exiting for reload");
2690
- process.exit(0);
2905
+ // #2356: under an AgEnD systemd unit a clean exit is a stop (Restart=on-failure); exit so the unit comes back.
2906
+ let ownCgroup = null;
2907
+ try {
2908
+ ownCgroup = readFileSync("/proc/self/cgroup", "utf-8");
2909
+ }
2910
+ catch { /* not Linux */ }
2911
+ const code = fullReloadExitCode(ownCgroup);
2912
+ this.logger.info(code === 0
2913
+ ? "Full restart: shutdown complete, exiting for reload"
2914
+ : `Full restart: shutdown complete, exiting ${code} so the systemd unit restarts the fleet (#2356)`);
2915
+ process.exit(code);
2691
2916
  })
2692
2917
  .catch(err => {
2693
2918
  this.logger.error({ err }, "Full restart: graceful shutdown failed");
@@ -2706,7 +2931,7 @@ export class FleetManager {
2706
2931
  const chatId = String(this.getChannelConfig(adapterId)?.group_id ?? data.channelId);
2707
2932
  this.beginUpdateProgress(adapter, chatId, data.channelId, messageId);
2708
2933
  }
2709
- const { spawn } = await import("node:child_process");
2934
+ const { spawn } = await import("./child-process.js");
2710
2935
  // Plain `agend update`: the CLI picks the channel from the version it is about to replace (a beta install
2711
2936
  // stays on beta). Deciding here, from the package.json next to THIS code, read a source checkout's 1.22.0 as
2712
2937
  // "not a beta" and sent a beta install to @latest. #1450 C5: the INSTALLED agend, verified, by absolute path.
@@ -3100,7 +3325,8 @@ export class FleetManager {
3100
3325
  instancePresentation(name) {
3101
3326
  const execution_state = this.getInstanceExecutionState(name);
3102
3327
  const interaction = this.getInstanceInteraction(name);
3103
- 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,
3104
3330
  interaction, interaction_summary: interactionSummary(interaction) };
3105
3331
  }
3106
3332
  /**
@@ -3534,7 +3760,9 @@ export class FleetManager {
3534
3760
  if (this.shuttingDown || !this.deliveryCurrent(instanceName, deliveryEpoch, stillCurrent))
3535
3761
  return false;
3536
3762
  if (!idle) {
3537
- 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);
3538
3766
  }
3539
3767
  const sent = await this.sendWhenConnected(instanceName, payload, deliveryEpoch, stillCurrent);
3540
3768
  if (!sent)
@@ -3648,12 +3876,69 @@ export class FleetManager {
3648
3876
  * #1920 §5.2: a web message's channel copy was sent — it is now what the channel saw. Kept so (and on disk), and sent
3649
3877
  * to the guests' streams only: an admin's page already has it, and nothing about it changed for them.
3650
3878
  */
3651
- webMessageInChannel(instance, messageId) {
3879
+ webMessageInChannel(instance, messageId, copies = []) {
3880
+ // #2305: the copies' own message ids, so an instance's reaction to the web message is put on them too.
3881
+ if (copies.length)
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);
3652
3887
  const m = this.webChatHistory.markChannel(instance, messageId);
3653
3888
  if (m)
3654
3889
  broadcastToGuests(this.sseClients, "message", m, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
3655
3890
  }
3656
- async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
3891
+ /** #2305: a web message's channel copies (topic, ClassicBot rooms), newest WEB_ECHO_COPIES_MAX, this process only. */
3892
+ webEchoCopies = new Map();
3893
+ rememberWebEchoCopies(messageId, copies) {
3894
+ const list = [...(this.webEchoCopies.get(messageId) ?? []), ...copies.filter(c => c && typeof c.adapter?.react === "function" && c.messageId)];
3895
+ this.webEchoCopies.delete(messageId);
3896
+ this.webEchoCopies.set(messageId, list);
3897
+ while (this.webEchoCopies.size > WEB_ECHO_COPIES_MAX)
3898
+ this.webEchoCopies.delete(this.webEchoCopies.keys().next().value);
3899
+ }
3900
+ /**
3901
+ * #2305: an instance's reaction to a web chat message (`messageId`, the id it was handed): its minimal reply, as on
3902
+ * the platforms. Kept on the message's row (and on disk), shown live (`reactions` on the stream), and put on the
3903
+ * message's channel copies when this process sent them — in the background, each within WEB_MIRROR_TIMEOUT_MS, so a
3904
+ * slow or rate-limited platform never holds the tool's answer (best effort: a copy's failure or timeout is logged,
3905
+ * never the tool's). `mirrored`: how many copies took it, when that is known.
3906
+ * The tool's errors: an emoji outside the web set or one of the instance's delivery marks (web-reactions.ts), or a
3907
+ * message the chat does not have. An emoji the message already has is a success that changes nothing.
3908
+ */
3909
+ async reactToWebMessage(instance, messageId, raw) {
3910
+ const problem = webReactionProblem(raw, this.statusEmojiAvoidList(instance));
3911
+ if (problem)
3912
+ return { ok: false, error: problem };
3913
+ const emoji = webReactionEmoji(raw);
3914
+ const r = this.webChatHistory.addReaction(instance, messageId, emoji);
3915
+ if (!r)
3916
+ return { ok: false, error: `react: ${messageId} is not a message in ${instance}'s web chat now (it left the history, or it is from before AgEnD restarted) — answer with reply instead` };
3917
+ if (!r.added)
3918
+ return { ok: true, mirrored: Promise.resolve(0) };
3919
+ this.emitSseEvent("reactions", { instance, boot: r.message.boot, id: r.message.id, reactions: r.message.reactions ?? [] });
3920
+ const mirrorOne = (copy) => {
3921
+ let timer;
3922
+ const timeout = new Promise((_, reject) => {
3923
+ timer = setTimeout(() => reject(new Error(`no answer in ${WEB_MIRROR_TIMEOUT_MS} ms`)), WEB_MIRROR_TIMEOUT_MS);
3924
+ timer.unref?.();
3925
+ });
3926
+ return Promise.race([Promise.resolve().then(() => copy.adapter.react(copy.chatId, copy.messageId, reactionForm(copy.adapter.type, emoji), copy.threadId)), timeout])
3927
+ .then(() => 1, (err) => {
3928
+ this.logger.warn({ err, instance, adapter: copy.adapter.type }, "Reaction on a web message's channel copy failed (shown on the web)");
3929
+ return 0;
3930
+ })
3931
+ .finally(() => clearTimeout(timer));
3932
+ };
3933
+ const mirrored = Promise.all((this.webEchoCopies.get(messageId) ?? []).map(mirrorOne)).then(n => n.reduce((a, b) => a + b, 0));
3934
+ return { ok: true, mirrored };
3935
+ }
3936
+ /** #2305: `copies` collects each room's copy (its message id), for a reaction to the web message. */
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) {
3657
3942
  const guard = this.webChannelEchoGuards.get(instance);
3658
3943
  const handled = new Set();
3659
3944
  let posted = 0;
@@ -3683,7 +3968,11 @@ export class FleetManager {
3683
3968
  try {
3684
3969
  if (!adapter?.sendText)
3685
3970
  continue;
3686
- 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 } : {}) });
3974
+ if (copies && sent && typeof sent.messageId === "string" && sent.messageId)
3975
+ copies.push({ adapter, adapterId: adapter.id, chatId: entry.channelId, messageId: sent.messageId });
3687
3976
  ClassicChannelManager.logMessage(instance, sender, text, new Date());
3688
3977
  posted++;
3689
3978
  }
@@ -4101,6 +4390,18 @@ export class FleetManager {
4101
4390
  async dispatchSlashCore(slash, adapterId, adapter) {
4102
4391
  // #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
4103
4392
  const data = { ...slash, command: canonicalCommand(slash.command) };
4393
+ // #2359: /dashboard stays listed; prefix its reply with the deprecation notice, then run /web's handler.
4394
+ const noteDeprecatedDashboard = () => {
4395
+ if (slash.command !== "dashboard")
4396
+ return;
4397
+ const prefix = `${t("dashboard.deprecated")}\n\n`;
4398
+ const respond = data.respond;
4399
+ data.respond = async (text) => respond(prefix + text);
4400
+ if (data.respondButtons) {
4401
+ const respondButtons = data.respondButtons;
4402
+ data.respondButtons = async (text, choices) => respondButtons(prefix + text, choices);
4403
+ }
4404
+ };
4104
4405
  const door = this.slashDoor(data, adapterId);
4105
4406
  if ("refusal" in door) {
4106
4407
  await data.respond(t(door.refusal)).catch(() => { });
@@ -4111,6 +4412,7 @@ export class FleetManager {
4111
4412
  // — before the table, which keeps /web for fleet admins. Per account: it touches no instance binding.
4112
4413
  // Decided synchronously, so an admin's /web still reaches the table with no await after the door (#1399).
4113
4414
  if (data.command === "dashboard" && this.isGuestWebAsk(adapterId, data.userId)) {
4415
+ noteDeprecatedDashboard();
4114
4416
  await this.guestWebCommand({ adapterId, userId: data.userId, username: data.username,
4115
4417
  arg: String(data.options?.action ?? "").trim().toLowerCase(), replyHere: text => data.respond(text), replyPrivately: text => data.respond(text) });
4116
4418
  return;
@@ -4139,6 +4441,7 @@ export class FleetManager {
4139
4441
  await data.respond(reply).catch(() => { });
4140
4442
  return;
4141
4443
  }
4444
+ noteDeprecatedDashboard();
4142
4445
  if (data.command === "start") {
4143
4446
  await this.handleClassicStartSlash(data, adapterId);
4144
4447
  }
@@ -4707,7 +5010,7 @@ export class FleetManager {
4707
5010
  return;
4708
5011
  }
4709
5012
  if (this.lifecycle.isPaused(name)) {
4710
- this.logger.info({ name }, "Persisted paused instance — skipping startup");
5013
+ this.logger.debug({ name }, "Persisted paused instance — skipping startup"); // #2482: every start and reload
4711
5014
  return;
4712
5015
  }
4713
5016
  if (this.lifecycle.daemons.has(name)) {
@@ -4726,50 +5029,92 @@ export class FleetManager {
4726
5029
  this.logger.info({ name }, "Instance already running, skipping");
4727
5030
  return;
4728
5031
  }
4729
- // An explicit start (CLI/API) begins a fresh idle window; boot and
4730
- // reconcile starts keep the persisted last-inbound seed (Phase 2a).
4731
- if (resumePaused)
4732
- this.lifecycle.markActivitySeedNow(name);
4733
- const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
4734
- if (config.general_topic) {
4735
- this.ensureGeneralInstructions(config.working_directory, backend, name);
4736
- }
4737
- else if (kind === "fleet-topic") {
4738
- // Workers get only role-eligible on-demand skills. Classic instances are
4739
- // deliberately excluded: their workspace and conversation lifecycle are
4740
- // managed independently from fleet-topic workers.
4741
- try {
4742
- const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
4743
- 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);
4744
5040
  }
4745
- catch (err) {
4746
- // Skill publishing is additive. A read-only or temporarily unavailable
4747
- // workspace must not turn an otherwise valid worker startup into a
4748
- // fleet outage.
4749
- 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
+ }
4750
5055
  }
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
+ }
5071
+ }
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
+ });
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);
4751
5105
  }
4752
- const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
4753
- const identity = { kind, backend, model: this.resolveInstanceModel(name).display };
4754
- if (receipt) {
4755
- await this.lifecycle.start(name, config, topicMode, identity, transition, execution, daemon => {
4756
- // Only this transition's actual publication can acquire its replacement.
4757
- receipt.daemon = daemon;
4758
- });
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");
4759
5117
  }
4760
- else
4761
- await this.lifecycle.start(name, config, topicMode, identity, transition, execution);
4762
- execution?.assert();
4763
- // Only clear a stale process status after a real start succeeded. Clearing
4764
- // it before lifecycle.start() can turn a crash-loop daemon's dead pane into
4765
- // a falsely running instance when lifecycle.start() returns early.
4766
- this.instanceProcessStatus.delete(name);
4767
- // Recovery intent belongs to Daemon.start. lifecycle.start can return
4768
- // before reaching it, so an await here is not proof the marker was read.
4769
- // Auto-connect IPC — daemon.start() ensures socket is ready before resolving
4770
- await this.connectIpcToInstance(name);
4771
- execution?.assert();
4772
- this.requestDiscordUsagePresenceRefresh();
4773
5118
  }
4774
5119
  /** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
4775
5120
  async startPersistedPausedInstance(name, transition) {
@@ -4787,8 +5132,8 @@ export class FleetManager {
4787
5132
  }
4788
5133
  /**
4789
5134
  * Start instances with configurable concurrency and stagger delay.
4790
- * Instances sharing the same working_directory are serialized within a group
4791
- * to avoid config file races. Stagger delay is group-to-group, not instance-to-instance.
5135
+ * Instances sharing the same working_directory are serialized to avoid
5136
+ * config file races. Stagger applies between outer lifecycle admissions.
4792
5137
  * TODO: per-instance startup timeout (existing issue, not introduced here)
4793
5138
  */
4794
5139
  async startInstancesWithConcurrency(entries, topicMode, onReady) {
@@ -4810,15 +5155,30 @@ export class FleetManager {
4810
5155
  totalInstances: runnableEntries.length,
4811
5156
  }, "Adaptive startup concurrency");
4812
5157
  }
5158
+ const startedAt = performance.now();
5159
+ let active = 0, peakConcurrentStarts = 0, readyInstances = 0;
4813
5160
  await Promise.all(runnableEntries.map(([name, config]) => this.spawnGate.run({
4814
5161
  instanceName: name,
4815
5162
  workingDirectory: config.working_directory,
4816
5163
  reason: "startup",
4817
5164
  stage: "lifecycle",
4818
5165
  }, async () => {
4819
- if (await this.startInstanceUnattended(name, config, topicMode, "instance"))
4820
- onReady?.(name);
5166
+ peakConcurrentStarts = Math.max(peakConcurrentStarts, ++active);
5167
+ try {
5168
+ if (await this.startInstanceUnattended(name, config, topicMode, "instance")) {
5169
+ readyInstances++;
5170
+ onReady?.(name);
5171
+ }
5172
+ }
5173
+ finally {
5174
+ active--;
5175
+ }
4821
5176
  })));
5177
+ this.logger.info({
5178
+ totalInstances: runnableEntries.length, readyInstances,
5179
+ elapsedSeconds: Math.round((performance.now() - startedAt) / 100) / 10,
5180
+ peakConcurrentStarts,
5181
+ }, "Fleet startup batch completed");
4822
5182
  }
4823
5183
  /**
4824
5184
  * Start from an UNATTENDED path (fleet startup, full restart, config
@@ -5244,34 +5604,18 @@ export class FleetManager {
5244
5604
  return this.adapterState.get(adapterId)?.status === "connected" ? adapter : undefined;
5245
5605
  }
5246
5606
  /** Last-resort completion after RestartProgress could not deliver to its
5247
- * adopted target. It stays bounded and never wakes a not-ready gateway. */
5607
+ * adopted target (or had no message: a small fleet, a failed first post). It
5608
+ * never wakes a not-ready gateway. #2213: an outage at fleet start no longer
5609
+ * loses it — bounded at startup, then retried in the background once the
5610
+ * adapter is ready again, never posted twice after an uncertain attempt. */
5248
5611
  async sendFleetStartCompletionFallback(chatId, text, threadId) {
5249
5612
  const adapterId = this.getPrimaryAdapterId();
5250
- const adapter = adapterId ? this.readyProgressAdapter(adapterId) : undefined;
5251
- if (!adapter) {
5252
- this.logger.error({ adapterId }, "Fleet start completion fallback skipped because the primary adapter is not ready");
5613
+ if (!adapterId) {
5614
+ this.logger.error({ adapterId }, "Fleet start completion fallback skipped because there is no primary adapter");
5253
5615
  return false;
5254
5616
  }
5255
- let timer;
5256
- const timeout = new Promise(resolve => {
5257
- timer = setTimeout(() => resolve({ status: "timeout" }), RESTART_PROGRESS_TERMINAL_TIMEOUT_MS);
5258
- timer.unref?.();
5259
- });
5260
- const delivery = Promise.resolve()
5261
- .then(() => adapter.sendText(chatId, text, { threadId }))
5262
- .then(() => ({ status: "sent" }), err => ({ status: "failed", err }));
5263
- const result = await Promise.race([delivery, timeout]);
5264
- if (timer)
5265
- clearTimeout(timer);
5266
- if (result.status === "sent")
5267
- return true;
5268
- if (result.status === "failed") {
5269
- this.logger.error({ err: result.err }, "Failed to send fleet start completion fallback");
5270
- }
5271
- else {
5272
- this.logger.error({ timeout_ms: RESTART_PROGRESS_TERMINAL_TIMEOUT_MS }, "Timed out sending fleet start completion fallback");
5273
- }
5274
- return false;
5617
+ const poster = new RestartProgress(0, Date.now(), this.logger);
5618
+ return poster.postCompletion({ resolveAdapter: () => this.readyProgressAdapter(adapterId), chatId, threadId }, text);
5275
5619
  }
5276
5620
  async stopInstance(name, transition) {
5277
5621
  this.explicitStopGeneration.set(name, (this.explicitStopGeneration.get(name) ?? 0) + 1);
@@ -5707,11 +6051,16 @@ export class FleetManager {
5707
6051
  dataDir: this.dataDir, web: () => this.fleetConfig?.web,
5708
6052
  permitted: owner => this.publicOwnerCurrent(owner),
5709
6053
  reserve: id => this.getTunnelLane().reserve("dashboard", id),
5710
- createGateway: (exposureId, current, open, failed) => createPublicWebGateway({ exposureId, isCurrent: current, isOpen: open, onError: failed,
6054
+ createGateway: (exposureId, current, open, failed, named) => createPublicWebGateway({ ...named, exposureId, isCurrent: current, isOpen: open, onError: failed,
5711
6055
  dispatch: (req, res) => this.dispatchWebHttp(req, res, this.fleetConfig?.health_port ?? 19280) }),
5712
6056
  revoke: id => {
5713
6057
  this.webLoginCodes?.revokeAudience(id);
5714
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));
5715
6064
  const result = this.webSessions?.revokeExposure(id);
5716
6065
  if (result && !result.durable)
5717
6066
  this.logger.warn("Public session revocation could not be saved; exposure remains closed");
@@ -5732,6 +6081,66 @@ export class FleetManager {
5732
6081
  },
5733
6082
  });
5734
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
+ }
5735
6144
  getPublicWebStatus() {
5736
6145
  return this.publicWebLink?.status() ?? { state: "closed" };
5737
6146
  }
@@ -5915,8 +6324,9 @@ export class FleetManager {
5915
6324
  // Show the action before any link exists. TG's Start instructions belong to confirmed DM refusal below.
5916
6325
  const menuText = this.topicCommands.getDashboardMenuText()
5917
6326
  + (publicAllowed ? "\n" + t("dashboard.public_risk") : "")
5918
- + (status.state !== "closed" ? "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
5919
- + (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)) : ""));
5920
6330
  const choices = [{ action: "local", label: t("dashboard.local_signin") },
5921
6331
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
5922
6332
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
@@ -5963,6 +6373,7 @@ export class FleetManager {
5963
6373
  if (!entry.publicExposureId)
5964
6374
  return true;
5965
6375
  const result = await this.publicWebLink?.close("admin close", entry.publicExposureId);
6376
+ this.forgetPublicLinkOwner();
5966
6377
  if (result?.confirmed === false)
5967
6378
  data.ack?.(t("dashboard.public_cleanup"));
5968
6379
  await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("dashboard.public_closed"), data.editClicked);
@@ -5993,10 +6404,11 @@ export class FleetManager {
5993
6404
  return false;
5994
6405
  this.initializeWebSessions();
5995
6406
  const issued = this.webLoginCodes.issue({ epoch: tokenEpoch(token), audience: exposureId, owner });
5996
- // 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.
5997
6409
  const minutes = Math.round(LOGIN_CODE_TTL_MS / 60_000);
5998
6410
  const text = expiresAt !== undefined
5999
- ? 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))
6000
6412
  : t("dashboard.private_local", url, issued.display, minutes);
6001
6413
  const deadline = performance.now() + 10_000;
6002
6414
  const direct = owner.binding;
@@ -6059,6 +6471,8 @@ export class FleetManager {
6059
6471
  // Still closes the step editor below and tells the clicker: a throw must not leave the menu refreshing.
6060
6472
  this.logger.warn({ err }, "Dashboard sign-in delivery failed");
6061
6473
  }
6474
+ if (ok && action === "public")
6475
+ this.rememberPublicLinkOwner(owner);
6062
6476
  // Only safe, static words enter General; never link, code or platform error text.
6063
6477
  // #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
6064
6478
  // with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
@@ -6083,6 +6497,7 @@ export class FleetManager {
6083
6497
  revokeWebSessions() {
6084
6498
  this.initializeWebSessions();
6085
6499
  void this.publicWebLink?.close("dashboard revoke");
6500
+ this.forgetPublicLinkOwner();
6086
6501
  this.webLoginCodes.revoke();
6087
6502
  this.webGuestLoginCodes.revokeAll();
6088
6503
  this.guestRevokes++; // and every guest /web still on its way
@@ -6633,11 +7048,17 @@ export class FleetManager {
6633
7048
  * landed mid-edit) must not be cleared or disturbed (Prism b).
6634
7049
  */
6635
7050
  async appendCleanupWarning(target, watch, warning, cleanupCommand) {
6636
- const text = formatRestartProgressCompletion("update", {
6637
- ...watch.summary,
6638
- warningText: warning,
6639
- warningCommand: cleanupCommand,
6640
- }, watch.receipt.startedAt);
7051
+ // #2175: one text per outcome, frozen at its first tick. The completion carries elapsed seconds, so a text made
7052
+ // anew each tick would never equal the one already handed over and landed (terminalLanded compares exactly).
7053
+ const key = JSON.stringify([warning, cleanupCommand ?? null]);
7054
+ if (watch.frozen?.key !== key) {
7055
+ watch.frozen = { key, text: formatRestartProgressCompletion("update", {
7056
+ ...watch.summary,
7057
+ warningText: warning,
7058
+ warningCommand: cleanupCommand,
7059
+ }, watch.receipt.startedAt) };
7060
+ }
7061
+ const text = watch.frozen.text;
6641
7062
  const stopWatching = () => {
6642
7063
  this.updateCleanupWatch = null;
6643
7064
  if (this.updateProgressTimer)
@@ -6645,10 +7066,22 @@ export class FleetManager {
6645
7066
  this.updateProgressTimer = null;
6646
7067
  };
6647
7068
  if (text !== this.lastUpdateProgressText) {
6648
- const adapter = this.adapters.get(target.adapterId);
6649
- if (!adapter)
6650
- return; // retry on a later tick
6651
- await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
7069
+ // #2175: while the completion is still owed by the background retry, that retry is the message's only
7070
+ // writer — this text (the completion plus the warning) goes to it rather than racing it. Handing it over is not
7071
+ // delivery: the receipt and this watch stay until the retry reports this exact text landed (a later tick).
7072
+ const progress = this.startupProgress;
7073
+ if (progress?.terminalLanded(text)) {
7074
+ // landed through the retry
7075
+ }
7076
+ else if (progress?.replaceTerminalText(text)) {
7077
+ return;
7078
+ }
7079
+ else {
7080
+ const adapter = this.adapters.get(target.adapterId);
7081
+ if (!adapter)
7082
+ return; // retry on a later tick
7083
+ await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
7084
+ }
6652
7085
  this.lastUpdateProgressText = text;
6653
7086
  }
6654
7087
  const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
@@ -6666,7 +7099,7 @@ export class FleetManager {
6666
7099
  if (this.updateProgressTimer)
6667
7100
  clearInterval(this.updateProgressTimer);
6668
7101
  const tick = async () => {
6669
- if (this.updateProgressEditRunning)
7102
+ if (this.updateProgressEditRunning || this.shuttingDown)
6670
7103
  return;
6671
7104
  const marker = readUpdateProgress(this.dataDir);
6672
7105
  if (!marker) {
@@ -6717,6 +7150,11 @@ export class FleetManager {
6717
7150
  return;
6718
7151
  }
6719
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;
6720
7158
  const target = marker.progress.target;
6721
7159
  const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
6722
7160
  if (!adapter)
@@ -6752,11 +7190,46 @@ export class FleetManager {
6752
7190
  }
6753
7191
  /** Start all instances from fleet config */
6754
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) {
6755
7227
  this.loginWindow.reopen(); // a stopAll → startAll restart must accept login windows again
6756
7228
  this.loginController?.reopen();
6757
7229
  const startupStartedAt = Date.now();
6758
7230
  FleetManager.signalTarget = this;
6759
7231
  this.startupComplete = false;
7232
+ this.startupDashboard = null;
6760
7233
  // Cleared here, not at the end of doStopAll: a stop has an async tail, and
6761
7234
  // anything arriving during it is still part of the stop.
6762
7235
  this.shuttingDown = false;
@@ -6770,6 +7243,12 @@ export class FleetManager {
6770
7243
  envLease.release();
6771
7244
  }
6772
7245
  this.ensureDeliveryOutbox();
7246
+ // #1956: the spawn helper the fleet's tmux/stty commands are started by (its fork is cheap; the fleet's is not).
7247
+ // Started here and by the fleet's own timer — never by a tool handler — and stopped with the fleet.
7248
+ tmuxExecHelper.reopen();
7249
+ tmuxExecHelper.start();
7250
+ this.tmuxExecHelperTimer ??= setInterval(() => tmuxExecHelper.maintain(), 5_000);
7251
+ this.tmuxExecHelperTimer.unref?.();
6773
7252
  this.rotateFleetLogs();
6774
7253
  const fleet = this.loadConfig(configPath);
6775
7254
  this.slimFleetConfigAtStartup();
@@ -6866,6 +7345,7 @@ export class FleetManager {
6866
7345
  // #1565: the web chat as it was before this process — the configured instances' only (fleet.yaml and ClassicBot
6867
7346
  // rooms), before the dashboard serves anything; from now on every change is written back (off the loop).
6868
7347
  this.restoreWebChat(fleet);
7348
+ this.startSearchIndex(fleet);
6869
7349
  // Poll classicBot.yaml for external changes every 30s
6870
7350
  this.classicReloadTimer = setInterval(() => {
6871
7351
  // Attributes the synchronous part, up to the reload's first await (#1235).
@@ -6915,6 +7395,17 @@ export class FleetManager {
6915
7395
  return DailySummary.generateText(this.eventLog, instances, costMap, this.costGuard?.getFleetTotalCents() ?? 0);
6916
7396
  });
6917
7397
  this.dailySummary.start();
7398
+ // #2246 step 3: the task board's daily summary — its own time (defaults.task_board.summary_at, read at every look),
7399
+ // in the fleet's zone; a summary missed while the fleet was down goes out when it is back.
7400
+ this.taskBoardSummary = new TaskBoardSummaryClock({
7401
+ at: () => summaryTime(this.fleetConfig?.defaults?.task_board?.summary_at) ?? null,
7402
+ zone: () => this.usageZone(),
7403
+ now: () => new Date(),
7404
+ mark: summaryMarkFile(join(this.dataDir, "task-board-summary.json")),
7405
+ send: (since, until) => this.postTaskBoardSummary(since, until),
7406
+ failed: (err) => this.logger.warn({ err }, "The task board's daily summary failed; the next minute tries again"),
7407
+ });
7408
+ this.taskBoardSummary.start();
6918
7409
  // Rotate classic channel chat logs daily (piggyback on daily summary timer)
6919
7410
  this.classicChannels?.rotateLogs();
6920
7411
  this.rotateInboxes();
@@ -6990,7 +7481,7 @@ export class FleetManager {
6990
7481
  continue;
6991
7482
  this.logger.warn({ adapter: need.adapterId, name }, "No general instance for adapter — auto-creating");
6992
7483
  const generalDir = join(getAgendHome(), name);
6993
- mkdirSync(generalDir, { recursive: true });
7484
+ mkdirSync(generalDir, { recursive: true, mode: 0o700 });
6994
7485
  const backendName = fleet.defaults.backend ?? "claude-code";
6995
7486
  this.ensureGeneralInstructions(generalDir, backendName, name);
6996
7487
  fleet.instances[name] = {
@@ -7010,7 +7501,9 @@ export class FleetManager {
7010
7501
  };
7011
7502
  this.scheduler = new Scheduler(join(this.dataDir, "scheduler.db"), (schedule, runId, retry) => this.handleScheduleTrigger(schedule, runId, retry), schedulerConfig, (name) => this.fleetConfig?.instances?.[name] != null || !!this.classicChannels?.getAll().some(ch => ch.instanceName === name), (schedule, retry, reason) => this.scheduleRetryDropped(schedule, retry, reason), this.logger);
7012
7503
  this.scheduler.init();
7504
+ this.scheduler.db.onTaskChange = (task) => this.searchIndex?.record(taskDoc(task));
7013
7505
  this.logger.info("Scheduler initialized");
7506
+ this.attachTaskBoard(this.scheduler.db);
7014
7507
  // Tips share the daily-report clock but remain an independent internal
7015
7508
  // job: disabling daily_summary must not disable tips (and vice versa).
7016
7509
  // The callback checks defaults.tips at fire time, so /tips on|off is hot.
@@ -7038,6 +7531,14 @@ export class FleetManager {
7038
7531
  const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
7039
7532
  const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
7040
7533
  const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingProgressOperation === "full-restart" ? "reload" : pendingUpdateProgress ? "update" : "restart" });
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;
7041
7542
  if (generals.length > 0) {
7042
7543
  for (const [name, cfg] of generals) {
7043
7544
  try {
@@ -7102,22 +7603,6 @@ export class FleetManager {
7102
7603
  return startupProgress.start(this.restartProgressTarget());
7103
7604
  });
7104
7605
  }
7105
- // The systemd watchdog answers exactly one question: is this process still
7106
- // turning its event loop? Pinging from a timer proves that, and after the
7107
- // blocking child-process calls were made async it is a meaningful signal —
7108
- // a deadlocked or frozen fleet stops pinging and systemd restarts it.
7109
- //
7110
- // It deliberately does NOT gate on fleet health. "No adapter connected" or
7111
- // "an instance crashed" must not kill the process: the fleet would be restarted
7112
- // into the same broken state, and a user who has legitimately stopped every
7113
- // instance would get a restart loop. Those conditions surface through /health
7114
- // (which now returns 503) and through the General-topic notifications instead.
7115
- setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
7116
- this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
7117
- // #1231: a stalled loop is what turns a slash command into "did not respond";
7118
- // put each long stall in the log instead of leaving it to be guessed at.
7119
- this.eventLoopWatch?.stop();
7120
- this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
7121
7606
  // EventLog.prune() existed but was never called, so `events` and `activity`
7122
7607
  // grew without bound for the life of the install. Prune once at startup and
7123
7608
  // daily after that; the timer is unref'd so it never holds the loop open.
@@ -7236,6 +7721,10 @@ export class FleetManager {
7236
7721
  // operation so an older stop never reports. No pending operation means
7237
7722
  // a plain boot — nothing was stopped by an operation, report nothing.
7238
7723
  const backgroundWorkEndedNames = this.collectBackgroundWorkEndedNames(configuredNames, pendingUpdateProgress?.startedAt ?? null);
7724
+ // #2264: an update whose fleet comes back on another version did not land — say so in its final message (the
7725
+ // version it aimed for, the one running, and the one repair), never just "updated".
7726
+ const aimedAt = pendingProgressOperation === "update" ? pendingUpdateProgress?.progress.version : undefined;
7727
+ const notLanded = aimedAt && agendVersion !== "unknown" && aimedAt !== agendVersion ? t("update.not_landed", aimedAt, agendVersion) : null;
7239
7728
  const completionSummary = {
7240
7729
  running: started,
7241
7730
  total,
@@ -7245,7 +7734,7 @@ export class FleetManager {
7245
7734
  backgroundWorkEndedNames,
7246
7735
  ...(cleanupOutcome && "render" in cleanupOutcome
7247
7736
  ? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
7248
- : {}),
7737
+ : notLanded ? { warningText: notLanded } : {}),
7249
7738
  };
7250
7739
  const progressCompleted = await startupProgress.finish(completionSummary);
7251
7740
  // Armed only after the completion posted: an append racing ahead of it would be overwritten by it.
@@ -7289,9 +7778,9 @@ export class FleetManager {
7289
7778
  }
7290
7779
  // After "fleet ready", so the notice is not the first thing people see of a restart.
7291
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();
7292
7783
  }
7293
- // Health HTTP endpoint
7294
- this.startHealthServer(fleet.health_port ?? 19280);
7295
7784
  // #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
7296
7785
  // #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
7297
7786
  void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
@@ -7302,17 +7791,32 @@ export class FleetManager {
7302
7791
  }, 60 * 60 * 1000);
7303
7792
  const onRestart = () => this.onGracefulRestartSignal(() => process.once("SIGUSR2", onRestart));
7304
7793
  process.once("SIGUSR2", onRestart);
7305
- // SIGUSR1: full process reload (graceful stop → exit → CLI restarts)
7794
+ // SIGUSR1: full process reload (graceful stop → exit). Nothing in this AgEnD sends it any more (#2356); an older
7795
+ // CLI or a hand-sent signal still may, and the exit code then lets the owning manager start the next fleet.
7306
7796
  process.once("SIGUSR1", () => this.onFullRestartSignal());
7307
7797
  // A SIGHUP may arrive after the PID/general is available but before the
7308
7798
  // rest of startup finishes. Replay one coalesced reload only after all
7309
7799
  // startup-owned lifecycle work and signal handlers are in place.
7800
+ if (this.shuttingDown || this.startupMemoryDeadline !== deadline)
7801
+ return;
7310
7802
  this.finishStartup(bootUpdateMarker);
7803
+ this.startupDashboard = null;
7804
+ this.publishStartupStatus();
7311
7805
  // Tell systemd we are ready only now. This used to fire right after the
7312
7806
  // generals started — before adapters, classic instances, topic creation and the
7313
7807
  // health server — so `systemctl start` returned success while the fleet was
7314
7808
  // still deaf: no path existed for a user message to arrive.
7315
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
+ }
7316
7820
  const health = this.getFleetHealth();
7317
7821
  if (health.status !== "ok") {
7318
7822
  this.logger.warn({ health }, "Fleet started with problems — see /health");
@@ -7465,48 +7969,109 @@ export class FleetManager {
7465
7969
  const err = results[i].reason;
7466
7970
  this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
7467
7971
  this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
7468
- this.scheduleAdapterRetry(adapterId, channelConfigs[i], i === 0 ? fleet : undefined);
7972
+ this.scheduleAdapterRetry(adapterId);
7469
7973
  // Notify admin via whichever adapter is already up
7470
7974
  this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
7471
7975
  }
7472
7976
  }
7473
7977
  }
7474
- /** Exponential backoff retry for a single failed adapter (background, non-blocking). */
7475
- scheduleAdapterRetry(adapterId, channelConfig, fleet) {
7476
- const MAX_RETRIES = 10;
7978
+ /** Keep trying while the fleet runs, with a five-minute cap even after a long outage. */
7979
+ scheduleAdapterRetry(adapterId) {
7477
7980
  const INITIAL_DELAY_MS = 5_000;
7478
7981
  const MAX_DELAY_MS = 5 * 60_000;
7479
7982
  const state = this.adapterState.get(adapterId);
7480
- if (!state || state.retryCount >= MAX_RETRIES) {
7481
- if (state) {
7482
- state.status = "failed";
7483
- this.logger.error({ adapterId, retries: state.retryCount }, "Adapter retry exhausted — giving up");
7484
- this.notifyAdapterFailure(adapterId, `Retry exhausted after ${state.retryCount} attempts. Check token/network and restart fleet.`);
7485
- }
7983
+ if (this.shuttingDown || !state || state.retryTimer || this.adapterRetryInFlight.has(adapterId))
7486
7984
  return;
7487
- }
7488
- const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, state.retryCount), MAX_DELAY_MS);
7985
+ const delay = Math.min(INITIAL_DELAY_MS * Math.pow(2, Math.min(state.retryCount, 6)), MAX_DELAY_MS);
7489
7986
  this.logger.info({ adapterId, attempt: state.retryCount + 1, delay_ms: delay }, "Scheduling adapter retry");
7490
- state.retryTimer = setTimeout(async () => {
7987
+ const timer = setTimeout(() => {
7988
+ const current = this.adapterState.get(adapterId);
7989
+ if (this.shuttingDown || current?.retryTimer !== timer)
7990
+ return;
7991
+ current.retryTimer = undefined;
7992
+ void this.attemptAdapterRetry(adapterId);
7993
+ }, delay);
7994
+ state.retryTimer = timer;
7995
+ timer.unref?.();
7996
+ }
7997
+ /** One login per connection, shared by the background timer and the operator's Retry now. */
7998
+ attemptAdapterRetry(adapterId) {
7999
+ const pending = this.adapterRetryInFlight.get(adapterId);
8000
+ if (pending)
8001
+ return pending;
8002
+ if (this.shuttingDown || !this.secureConnectionChannel(adapterId))
8003
+ return Promise.resolve();
8004
+ const attempt = Promise.resolve().then(async () => {
8005
+ const channel = this.secureConnectionChannel(adapterId);
8006
+ if (this.shuttingDown || !channel)
8007
+ return;
8008
+ const generation = this.settingsGeneration;
8009
+ const binding = JSON.stringify([channel, this.getPrimaryAdapterId()]);
8010
+ const token = process.env[channel.bot_token_env];
8011
+ const operation = { owner: Symbol("adapter-retry"), assert: () => {
8012
+ if (this.shuttingDown || generation !== this.settingsGeneration
8013
+ || JSON.stringify([this.secureConnectionChannel(adapterId), this.getPrimaryAdapterId()]) !== binding
8014
+ || process.env[channel.bot_token_env] !== token)
8015
+ throw new Error("adapter retry superseded");
8016
+ } };
8017
+ const state = this.adapterState.get(adapterId) ?? { status: "retrying", retryCount: 0 };
8018
+ state.status = "retrying";
7491
8019
  state.retryCount++;
8020
+ this.adapterState.set(adapterId, state);
7492
8021
  try {
7493
- if (fleet) {
7494
- await this.startSingleAdapter(fleet, channelConfig);
7495
- }
7496
- else {
7497
- await this.startAdditionalAdapter(channelConfig);
7498
- }
7499
- state.status = "connected";
7500
- state.lastError = undefined;
8022
+ if (this.getPrimaryAdapterId() === adapterId)
8023
+ await this.startSingleAdapter(this.fleetConfig, channel, undefined, operation);
8024
+ else
8025
+ await this.startAdditionalAdapter(channel, true, undefined, operation);
8026
+ if (this.shuttingDown || generation !== this.settingsGeneration)
8027
+ return;
8028
+ if (!this.adapters.has(adapterId))
8029
+ throw new Error("Adapter token is not configured");
8030
+ // Health events can replace the state object during login. Update its current owner.
8031
+ const current = this.adapterState.get(adapterId);
8032
+ current.status = "connected";
8033
+ current.lastError = undefined;
7501
8034
  this.logger.info({ adapterId, attempts: state.retryCount }, "Adapter reconnected on retry");
7502
8035
  this.notifyAdapterRecovery(adapterId, state.retryCount);
7503
8036
  }
7504
8037
  catch (err) {
7505
- state.lastError = err?.message ?? String(err);
7506
- this.logger.warn({ adapterId, attempt: state.retryCount, err: state.lastError }, "Adapter retry failed");
7507
- this.scheduleAdapterRetry(adapterId, channelConfig, fleet);
8038
+ if (this.shuttingDown || generation !== this.settingsGeneration)
8039
+ return;
8040
+ const current = this.adapterState.get(adapterId);
8041
+ current.status = "retrying";
8042
+ current.lastError = err?.message ?? String(err);
8043
+ this.logger.warn({ adapterId, attempt: state.retryCount, err: current.lastError }, "Adapter retry failed");
7508
8044
  }
7509
- }, delay);
8045
+ }).finally(() => {
8046
+ if (this.adapterRetryInFlight.get(adapterId) !== attempt)
8047
+ return;
8048
+ this.adapterRetryInFlight.delete(adapterId);
8049
+ const channel = this.secureConnectionChannel(adapterId);
8050
+ if (!this.shuttingDown && channel && this.adapterState.get(adapterId)?.status !== "connected") {
8051
+ this.scheduleAdapterRetry(adapterId);
8052
+ }
8053
+ });
8054
+ this.adapterRetryInFlight.set(adapterId, attempt);
8055
+ return attempt;
8056
+ }
8057
+ retryAdapterNow(adapterId) {
8058
+ if (this.shuttingDown)
8059
+ throw new Error("The fleet is stopping");
8060
+ if (!this.secureConnectionChannel(adapterId))
8061
+ throw new Error("Connection not found");
8062
+ if (this.adapterRetryInFlight.has(adapterId) || this.adapterRestarting.has(adapterId))
8063
+ return { queued: true, already_running: true };
8064
+ const state = this.adapterState.get(adapterId);
8065
+ if (state?.retryTimer) {
8066
+ clearTimeout(state.retryTimer);
8067
+ state.retryTimer = undefined;
8068
+ }
8069
+ const adapter = this.adapters.get(adapterId);
8070
+ if (adapter && this.connectionStatus(adapterId, state, this.worlds.get(adapterId)).status === "connected")
8071
+ return { queued: false };
8072
+ const run = adapter ? this.restartAdapter(adapter, adapterId) : this.attemptAdapterRetry(adapterId);
8073
+ void run.catch(err => this.logger.warn({ adapterId, err }, "Requested adapter retry failed"));
8074
+ return { queued: true };
7510
8075
  }
7511
8076
  /** Notify admin about adapter failure (uses any available adapter). */
7512
8077
  notifyAdapterFailure(adapterId, error) {
@@ -7561,6 +8126,7 @@ export class FleetManager {
7561
8126
  : snapshot.status === "stopped" ? "failed"
7562
8127
  : "retrying";
7563
8128
  this.adapterState.set(adapterId, {
8129
+ ...previous,
7564
8130
  status,
7565
8131
  retryCount: previous?.retryCount ?? 0,
7566
8132
  lastError: status === "connected" ? undefined : snapshot.lastReconnectReason ?? previous?.lastError,
@@ -7669,12 +8235,12 @@ export class FleetManager {
7669
8235
  return;
7670
8236
  }
7671
8237
  const accessDir = join(this.dataDir, "access");
7672
- mkdirSync(accessDir, { recursive: true });
8238
+ mkdirSync(accessDir, { recursive: true, mode: 0o700 });
7673
8239
  const accessStatePath = join(accessDir, "access.json");
7674
8240
  const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
7675
8241
  this.warnIfAccessModeOverridden(accessManager, accessStatePath);
7676
8242
  const inboxDir = join(this.dataDir, "inbox");
7677
- mkdirSync(inboxDir, { recursive: true });
8243
+ mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
7678
8244
  const adapterId = channelConfig.id ?? channelConfig.type;
7679
8245
  const adapter = await createAdapter(channelConfig, {
7680
8246
  id: adapterId,
@@ -7820,12 +8386,12 @@ export class FleetManager {
7820
8386
  return;
7821
8387
  }
7822
8388
  const accessDir = join(this.dataDir, "access");
7823
- mkdirSync(accessDir, { recursive: true });
8389
+ mkdirSync(accessDir, { recursive: true, mode: 0o700 });
7824
8390
  const accessStatePath = join(accessDir, `access-${adapterId}.json`);
7825
8391
  const accessManager = new AccessManager(channelConfig.access ?? DEFAULT_OPEN_ACCESS, accessStatePath);
7826
8392
  this.warnIfAccessModeOverridden(accessManager, accessStatePath);
7827
8393
  const inboxDir = join(this.dataDir, "inbox");
7828
- mkdirSync(inboxDir, { recursive: true });
8394
+ mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
7829
8395
  const adapter = await createAdapter(channelConfig, {
7830
8396
  id: adapterId,
7831
8397
  botToken,
@@ -8131,7 +8697,7 @@ export class FleetManager {
8131
8697
  // means we cannot verify the pane, which is treated as dead (the same
8132
8698
  // conclusion the old code reached only by throwing).
8133
8699
  try {
8134
- const { execFile } = await import("node:child_process");
8700
+ const { execFile } = await import("./child-process.js");
8135
8701
  const { promisify } = await import("node:util");
8136
8702
  const { getTmuxSocketName } = await import("./paths.js");
8137
8703
  // Honour socket isolation: without -L this queried the user's default
@@ -8681,6 +9247,22 @@ export class FleetManager {
8681
9247
  this.logger.debug({ adapterId: msg.adapterId, threadId: threadId ?? null, messageId: msg.messageId, reason: drop }, "Inbound message dropped before the dedup claim — key not consumed");
8682
9248
  return;
8683
9249
  }
9250
+ // #2246 step 3 (#2341 review r2): a reply to a Needs you Send back / Answer prompt is the card's reason or answer, not
9251
+ // input for an instance — judged by the PROMPT's world (its admins, its instance, its room as it is now; the hub's
9252
+ // handleReply), before the room's generic access below: in a shared group the room may be another bot's, whose
9253
+ // policy has no say over that world's prompt. Only that exact reply, by the admin who clicked, while the prompt
9254
+ // stands. Taken once: another bot's copy of a taken reply is dropped here. All else flows on.
9255
+ if (!msg.isBotMessage && msg.replyTo && this.needsYou) {
9256
+ if (this.needsYou.wasTakenReply(msg.chatId, msg.messageId)) {
9257
+ this.logger.debug({ adapterId: msg.adapterId, messageId: msg.messageId }, "Another bot's copy of a Needs you answer already taken — dropped");
9258
+ return;
9259
+ }
9260
+ if (this.needsYou.handleReply({
9261
+ adapterId: msg.adapterId, chatId: msg.chatId, ...(threadId !== undefined ? { threadId } : {}), replyTo: msg.replyTo,
9262
+ messageId: msg.messageId, userId: msg.userId, ...(msg.username ? { username: msg.username } : {}), text: msg.text ?? "",
9263
+ }))
9264
+ return;
9265
+ }
8684
9266
  // Access control — classic channels are open to all, others require an allowed
8685
9267
  // user. This runs before the dedup claim, and is judged by the adapter that
8686
9268
  // owns the topic rather than the one that happened to receive the message:
@@ -8791,6 +9373,10 @@ export class FleetManager {
8791
9373
  // #1920 step 4b-3: /web typed in a ClassicBot room (a group: `/web@ThisBot`) by someone who is not a fleet admin,
8792
9374
  // while guest sign-in is on: their guest code, privately — as anywhere else. An admin's goes on as before.
8793
9375
  if (/^\/(?:web|dashboard)(?:\s|$)/i.test(text) && msgAdapter && this.isGuestWebAsk(msg.adapterId, msg.userId)) {
9376
+ // #2359: this door must not skip the deprecation notice TopicCommands shows on /dashboard.
9377
+ if (/^\/dashboard(?:\s|$)/i.test(text)) {
9378
+ await msgAdapter.sendText(chatId, t("dashboard.deprecated")).catch(() => { });
9379
+ }
8794
9380
  await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username,
8795
9381
  arg: text.replace(/^\/(?:web|dashboard)/i, "").trim().toLowerCase(), replyHere: body => msgAdapter.sendText(chatId, body) });
8796
9382
  return;
@@ -9237,9 +9823,14 @@ export class FleetManager {
9237
9823
  * history (#1565). #1920 §5.2: said in the instance's topic, General or room, so it is that channel's, by that account.
9238
9824
  */
9239
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;
9240
9831
  this.emitSseEvent("message", {
9241
9832
  instance, sender: msg.username, role: "user", text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
9242
- channel: true, from: webChatFrom(msg.adapterId, msg.userId),
9833
+ channel: true, from: webChatFrom(msg.adapterId, msg.userId), ...(posts ? { posts } : {}),
9243
9834
  });
9244
9835
  }
9245
9836
  /** Handle outbound tool calls from a daemon instance */
@@ -9341,12 +9932,21 @@ export class FleetManager {
9341
9932
  }
9342
9933
  }
9343
9934
  checkToolPermission(sink, instanceName, tool) {
9935
+ if (isCanvasWorkerTool(tool) && !this.canvasWorkerEnabled())
9936
+ return { allowed: false, message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE };
9344
9937
  const profile = resolveToolSet(this.fleetConfig?.instances[instanceName], instanceName);
9345
9938
  const allowed = mayUseTool(profile, tool);
9346
9939
  if (!allowed) {
9347
9940
  this.logger.warn({ sink, instance: instanceName, profile, tool, enforced: true }, "tool-permissions: refused");
9348
9941
  return { allowed: false, message: toolRefusedMessage(profile, tool) };
9349
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
+ }
9350
9950
  this.logger.debug({ sink, instance: instanceName, profile, tool, enforced: true }, "tool-permissions: allowed");
9351
9951
  return { allowed: true, message: "" };
9352
9952
  }
@@ -9354,6 +9954,9 @@ export class FleetManager {
9354
9954
  this.touchActivity(instanceName);
9355
9955
  this.setTopicIcon(instanceName, "green");
9356
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;
9357
9960
  const args = (msg.args ?? {});
9358
9961
  const requestId = msg.requestId;
9359
9962
  const fleetRequestId = msg.fleetRequestId;
@@ -9386,6 +9989,27 @@ export class FleetManager {
9386
9989
  respond(null, permitted.message);
9387
9990
  return;
9388
9991
  }
9992
+ // Fleet tools resolve their own targets. A missing chat world must not prevent local
9993
+ // delivery, inspection or receipt lookup; only channel tools need reply routing below.
9994
+ const handler = outboundHandlers.get(tool);
9995
+ if (handler && !usesChannelAddress(tool)) {
9996
+ this.eventLog?.logActivity("tool_call", senderSessionName ?? instanceName, this.summarizeToolCall(tool, args));
9997
+ await handler(this, args, respond, {
9998
+ instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
9999
+ });
10000
+ return;
10001
+ }
10002
+ // #2305: a reaction to a web chat message is the web chat's own (any fleet, web-only included): never an adapter's.
10003
+ if (tool === "react" && typeof args.message_id === "string" && isWebMessageId(args.message_id)) {
10004
+ // The socket's own instance, never a session name the caller wrote itself (as checkToolPermission: it was not
10005
+ // identified) — a channel tool's call from a daemon carries none anyway (#2310 review).
10006
+ const r = await this.reactToWebMessage(instanceName, args.message_id, args.emoji);
10007
+ if (r.ok)
10008
+ respond("ok");
10009
+ else
10010
+ respond(null, r.error);
10011
+ return;
10012
+ }
9389
10013
  // A fleet with no chat platform at all is driven from the web dashboard alone: a reply has nowhere
9390
10014
  // else to go, and "retry shortly" would have the agent retry forever. It goes to the web chat.
9391
10015
  const webOnlyReply = tool === "reply" && this.worlds.size === 0 && this.isWebOnlyFleet();
@@ -9512,6 +10136,7 @@ export class FleetManager {
9512
10136
  args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
9513
10137
  }
9514
10138
  }
10139
+ const replyIdentity = sentBy; // #1774: taken at the handler's entry
9515
10140
  const original = respond;
9516
10141
  const respondAndRecord = (result, error) => {
9517
10142
  let buttonsView = null;
@@ -9543,12 +10168,13 @@ export class FleetManager {
9543
10168
  try {
9544
10169
  // #1920 §5.2: shown to the instance's guests only when it landed in the instance's own topic or room.
9545
10170
  let inChannel = false;
10171
+ const replyAdapterId = contextAdapterId ?? this.getInstanceAdapterId(instanceName);
9546
10172
  try {
9547
10173
  inChannel = outAdapter !== WEB_ONLY_REPLY_SINK
9548
- && this.landedInOwnChannel(instanceName, contextAdapterId ?? this.getInstanceAdapterId(instanceName), result);
10174
+ && this.landedInOwnChannel(instanceName, replyAdapterId, result);
9549
10175
  }
9550
10176
  catch { /* not known: not the channel's (fail closed), and the bookkeeping below still runs */ }
9551
- void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel);
10177
+ void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel, replyAdapterId ? { adapterId: replyAdapterId, result } : undefined, replyIdentity);
9552
10178
  }
9553
10179
  catch (err) {
9554
10180
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -9577,19 +10203,14 @@ export class FleetManager {
9577
10203
  if (routeToolCall(outAdapter, tool, args, threadId, respond)) {
9578
10204
  return;
9579
10205
  }
9580
- // Log tool calls for activity visualization
9581
- const senderLabel = senderSessionName ?? instanceName;
9582
- this.eventLog?.logActivity("tool_call", senderLabel, this.summarizeToolCall(tool, args));
9583
- // Dispatch fleet-specific tools via handler map
9584
- const handler = outboundHandlers.get(tool);
9585
10206
  if (handler) {
10207
+ this.eventLog?.logActivity("tool_call", senderSessionName ?? instanceName, this.summarizeToolCall(tool, args));
9586
10208
  await handler(this, args, respond, {
9587
10209
  instanceName, requestId, fleetRequestId, senderSessionName, operationId, sourceDaemonBootId,
9588
10210
  });
9589
10211
  }
9590
- else {
10212
+ else
9591
10213
  respond(null, `Unknown tool: ${tool}`);
9592
- }
9593
10214
  }
9594
10215
  /** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
9595
10216
  /** No chat platform is configured at all: the web dashboard is the fleet's only surface. */
@@ -9630,7 +10251,12 @@ export class FleetManager {
9630
10251
  return false;
9631
10252
  }
9632
10253
  }
9633
- 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;
9634
10260
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
9635
10261
  // more work either. Split the difference: an instance that is clearly
9636
10262
  // idle loses the button now; one that looks busy keeps it (re-posted
@@ -9657,7 +10283,16 @@ export class FleetManager {
9657
10283
  // copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
9658
10284
  const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
9659
10285
  const ts = new Date().toISOString();
9660
- 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
+ }
9661
10296
  // The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
9662
10297
  // role that may get HTML preview cards. Never inferred from the sender name or the text.
9663
10298
  this.emitSseEvent("message", {
@@ -9667,18 +10302,25 @@ export class FleetManager {
9667
10302
  ...(attachments.length ? { attachments } : {}),
9668
10303
  ...(buttons ? { buttons } : {}), // #1266
9669
10304
  ...(inChannel ? { channel: true } : {}), // #1920 §5.2
10305
+ ...(post ? { posts: [post] } : {}), // #2281
10306
+ ...(reserved !== undefined ? { reserved } : {}), // #1774
9670
10307
  });
9671
10308
  };
9672
10309
  const before = this.webReplyOrder.get(instanceName);
9673
- 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) {
9674
10313
  show([]);
9675
10314
  return Promise.resolve();
9676
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();
9677
10318
  const done = (before ?? Promise.resolve())
9678
- .then(() => this.webReplyAttachments(instanceName, replyFiles))
10319
+ .then(() => this.webReplyAttachments(instanceName, replyFiles, current))
10320
+ .then(async (attachments) => { await this.webChatRemovalPending(instanceName, identity); return attachments; })
9679
10321
  .catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
9680
10322
  .then((attachments) => { try {
9681
- show(attachments);
10323
+ show(attachments, reserved);
9682
10324
  }
9683
10325
  catch (err) {
9684
10326
  this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
@@ -9693,11 +10335,13 @@ export class FleetManager {
9693
10335
  * the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
9694
10336
  * one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
9695
10337
  */
9696
- async webReplyAttachments(instanceName, paths) {
10338
+ async webReplyAttachments(instanceName, paths, current = () => true) {
9697
10339
  const home = getAgendHome();
9698
10340
  const dir = webFilesDir(home, instanceName);
9699
10341
  const out = [];
9700
10342
  for (const path of paths) {
10343
+ if (!current())
10344
+ break; // #1774: removed meanwhile — copy no more for it
9701
10345
  const id = randomBytes(16).toString("hex");
9702
10346
  const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
9703
10347
  if (got && "evicted" in got && got.evicted.length)
@@ -10009,13 +10653,14 @@ export class FleetManager {
10009
10653
  if (!ipc || !fleetRequestId)
10010
10654
  return;
10011
10655
  const type = String(msg.type);
10012
- const responseType = type.startsWith("fleet_schedule_") ? "fleet_schedule_response"
10013
- : type.startsWith("fleet_decision_") ? "fleet_decision_response"
10014
- : type === "fleet_task" ? "fleet_task_response"
10015
- : type === "fleet_set_display_name" ? "fleet_display_name_response"
10016
- : type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis"
10017
- || type === "fleet_list_stickers" || type === "fleet_preview_stickers" ? "fleet_persona_emoji_response"
10018
- : "fleet_description_response";
10656
+ const responseType = isCanvasWorkerTool(type.replace(/^fleet_/, "")) ? "fleet_web_theme_response"
10657
+ : type.startsWith("fleet_schedule_") ? "fleet_schedule_response"
10658
+ : type.startsWith("fleet_decision_") ? "fleet_decision_response"
10659
+ : type === "fleet_task" ? "fleet_task_response"
10660
+ : type === "fleet_set_display_name" ? "fleet_display_name_response"
10661
+ : type === "fleet_list_emojis" || type === "fleet_set_persona_emoji" || type === "fleet_preview_emojis"
10662
+ || type === "fleet_list_stickers" || type === "fleet_preview_stickers" ? "fleet_persona_emoji_response"
10663
+ : "fleet_description_response";
10019
10664
  ipc.send({ type: responseType, fleetRequestId, error: message });
10020
10665
  }
10021
10666
  dispatchTypedIpc(name, msg) {
@@ -10407,14 +11052,20 @@ export class FleetManager {
10407
11052
  return (v === "low" || v === "normal" || v === "high" || v === "urgent") ? v : undefined;
10408
11053
  };
10409
11054
  const asStatus = (v) => {
10410
- return (v === "open" || v === "claimed" || v === "done" || v === "blocked" || v === "cancelled") ? v : undefined;
11055
+ return (v === "open" || v === "claimed" || v === "review" || v === "done" || v === "blocked" || v === "cancelled") ? v : undefined;
10411
11056
  };
10412
11057
  // #1336 P2: use the shared normalizer — trims whitespace AND drops empties,
10413
11058
  // so " \t " / [" ","\t"] are treated identically to undefined.
10414
11059
  const asStatusFilter = normalizeStatusFilter;
10415
11060
  // #1336: small write ack — identifying fields only.
10416
11061
  const ack = (t) => ({ id: t.id, status: t.status, updated_at: t.updated_at });
11062
+ // The board's notes for the write (a send-back reason, a comment the assignee had not seen): with the ack when any.
11063
+ const noted = (a) => { const notes = db.board.takeNotes(); return notes.length ? { ...a, notes } : a; };
10417
11064
  try {
11065
+ // The task board's own actions and fields (task-board-tool.ts); the rest is the tool's plain task, below.
11066
+ const onBoard = boardToolAction(db, instance, args);
11067
+ if (onBoard !== undefined)
11068
+ return onBoard;
10418
11069
  switch (action) {
10419
11070
  case "create": {
10420
11071
  const title = asStr(args.title);
@@ -10453,24 +11104,24 @@ export class FleetManager {
10453
11104
  const id = asStr(args.id);
10454
11105
  if (!id)
10455
11106
  return { error: "id is required" };
10456
- return ack(db.claimTask(db.getTaskByPrefix(id).id, instance));
11107
+ return noted(ack(db.claimTask(db.getTaskByPrefix(id).id, instance, { kind: "instance", name: instance })));
10457
11108
  }
10458
11109
  case "done": {
10459
11110
  const id = asStr(args.id);
10460
11111
  if (!id)
10461
11112
  return { error: "id is required" };
10462
- return ack(db.completeTask(db.getTaskByPrefix(id).id, asStr(args.result)));
11113
+ return noted(ack(db.completeTask(db.getTaskByPrefix(id).id, asStr(args.result), { kind: "instance", name: instance })));
10463
11114
  }
10464
11115
  case "update": {
10465
11116
  const id = asStr(args.id);
10466
11117
  if (!id)
10467
11118
  return { error: "id is required" };
10468
- return ack(db.updateTask(db.getTaskByPrefix(id).id, {
11119
+ return noted(ack(db.updateTask(db.getTaskByPrefix(id).id, {
10469
11120
  status: asStatus(args.status),
10470
11121
  assignee: asStr(args.assignee),
10471
11122
  result: asStr(args.result),
10472
11123
  priority: asPriority(args.priority),
10473
- }));
11124
+ }, { kind: "instance", name: instance })));
10474
11125
  }
10475
11126
  default: return { error: `Unknown task action: ${action}` };
10476
11127
  }
@@ -10502,6 +11153,8 @@ export class FleetManager {
10502
11153
  }
10503
11154
  /** #1629: `agend-agent theme …` — the same service as the tools; `instance` is the token's. */
10504
11155
  async handleWebThemeHttp(instance, tool, args) {
11156
+ if (isCanvasWorkerTool(tool) && !this.canvasWorkerEnabled())
11157
+ return { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE };
10505
11158
  if (WEB_EFFECT_TOOLS.has(tool)) {
10506
11159
  if (!this.isKnownInstance(instance))
10507
11160
  return { error: "unknown_instance" };
@@ -10564,7 +11217,18 @@ export class FleetManager {
10564
11217
  // so " \t " / [" ","\t"] are treated identically to undefined.
10565
11218
  const asStatusFilter = normalizeStatusFilter;
10566
11219
  const ack = (t) => ({ id: t.id, status: t.status, updated_at: t.updated_at });
11220
+ const noted = (a) => { const notes = db.board.takeNotes(); return notes.length ? { ...a, notes } : a; };
10567
11221
  try {
11222
+ // The task board's own actions and fields (task-board-tool.ts); the rest is the tool's plain task, below.
11223
+ const onBoard = boardToolAction(db, meta.instance_name || instanceName, payload);
11224
+ if (onBoard !== undefined) {
11225
+ ipc.send({ type: "fleet_task_response", fleetRequestId, result: onBoard });
11226
+ // The activity log hears of board work as it does of plain tasks.
11227
+ const what = boardActivity(payload);
11228
+ if (what)
11229
+ this.eventLog?.logActivity("task_update", instanceName, what);
11230
+ return;
11231
+ }
10568
11232
  let result;
10569
11233
  // Full task kept for activity logging; the IPC reply uses the compact ack.
10570
11234
  let logTask;
@@ -10600,12 +11264,12 @@ export class FleetManager {
10600
11264
  result = db.getTaskByPrefix(payload.id);
10601
11265
  break;
10602
11266
  case "claim":
10603
- logTask = db.claimTask(db.getTaskByPrefix(payload.id).id, meta.instance_name || instanceName);
10604
- result = ack(logTask);
11267
+ logTask = db.claimTask(db.getTaskByPrefix(payload.id).id, meta.instance_name || instanceName, { kind: "instance", name: meta.instance_name || instanceName });
11268
+ result = noted(ack(logTask));
10605
11269
  break;
10606
11270
  case "done":
10607
- logTask = db.completeTask(db.getTaskByPrefix(payload.id).id, payload.result);
10608
- result = ack(logTask);
11271
+ logTask = db.completeTask(db.getTaskByPrefix(payload.id).id, payload.result, { kind: "instance", name: meta.instance_name || instanceName });
11272
+ result = noted(ack(logTask));
10609
11273
  break;
10610
11274
  case "update":
10611
11275
  logTask = db.updateTask(db.getTaskByPrefix(payload.id).id, {
@@ -10613,8 +11277,8 @@ export class FleetManager {
10613
11277
  assignee: payload.assignee,
10614
11278
  result: payload.result,
10615
11279
  priority: payload.priority,
10616
- });
10617
- result = ack(logTask);
11280
+ }, { kind: "instance", name: meta.instance_name || instanceName });
11281
+ result = noted(ack(logTask));
10618
11282
  break;
10619
11283
  default:
10620
11284
  throw new Error(`Unknown task action: ${action}`);
@@ -10929,6 +11593,7 @@ export class FleetManager {
10929
11593
  saveFleetConfig(explicitPatches = []) {
10930
11594
  this.publicWebLink?.refresh();
10931
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)
10932
11597
  if (!this.fleetConfig || !this.configPath)
10933
11598
  return;
10934
11599
  if (!this.savedFleetConfigSnapshot)
@@ -10941,6 +11606,10 @@ export class FleetManager {
10941
11606
  throw new Error(`Refusing to overwrite invalid fleet.yaml: ${this.rawFleetDocument.errors[0].message}`);
10942
11607
  }
10943
11608
  this.rawFleetConfig = loadRawFleetConfig(this.configPath);
11609
+ // #2208: what the file says before this save, aliases expanded — every write below is recorded, and anything that
11610
+ // changes outside them (a place sharing a node with one written) refuses the save.
11611
+ const fileBefore = this.rawFleetDocument.toJS({ maxAliasCount: -1 });
11612
+ const writes = this.fleetDocumentWrites = []; // a throw below leaves it to the next save
10944
11613
  this.patchFleetDocument(this.rawFleetDocument, [], this.savedFleetConfigSnapshot, this.fleetConfig);
10945
11614
  // Settings edits are expressed against the raw config, so apply them before
10946
11615
  // canonicalization. A non-identity value equal to its inherited default is
@@ -10951,6 +11620,7 @@ export class FleetManager {
10951
11620
  // (or a legacy scalar occupies that parent). Removing an override which
10952
11621
  // is already absent is an idempotent no-op, not a failed Settings save.
10953
11622
  if (this.rawFleetDocument.hasIn(patch.path)) {
11623
+ this.writingFleetDocument(this.rawFleetDocument, patch.path);
10954
11624
  this.rawFleetDocument.deleteIn(patch.path);
10955
11625
  }
10956
11626
  }
@@ -10962,8 +11632,10 @@ export class FleetManager {
10962
11632
  const rawAfterPatches = this.rawFleetDocument.toJS();
10963
11633
  const redundantPaths = collectRedundantInstanceDefaultPaths(rawAfterPatches);
10964
11634
  for (const path of redundantPaths) {
10965
- if (this.rawFleetDocument.hasIn(path))
11635
+ if (this.rawFleetDocument.hasIn(path)) {
11636
+ this.writingFleetDocument(this.rawFleetDocument, path);
10966
11637
  this.rawFleetDocument.deleteIn(path);
11638
+ }
10967
11639
  // Avoid leaving empty operational maps such as `terminal: {}` while
10968
11640
  // preserving the instance mapping itself and all surrounding comments.
10969
11641
  for (let depth = path.length - 1; depth > 2; depth--) {
@@ -10971,9 +11643,14 @@ export class FleetManager {
10971
11643
  const parent = this.rawFleetDocument.getIn(parentPath, true);
10972
11644
  if (!isMap(parent) || parent.items.length > 0)
10973
11645
  break;
11646
+ this.writingFleetDocument(this.rawFleetDocument, parentPath);
10974
11647
  this.rawFleetDocument.deleteIn(parentPath);
10975
11648
  }
10976
11649
  }
11650
+ this.fleetDocumentWrites = null;
11651
+ const stray = changedOutside(fileBefore, this.rawFleetDocument.toJS({ maxAliasCount: -1 }), writes);
11652
+ if (stray)
11653
+ throw new Error(`Refusing to save fleet.yaml: this change would also change ${stray}, which shares a value with what it edits. Give each its own value in fleet.yaml.`);
10977
11654
  noteSettingsWrite(this.configPath, this.savedFleetConfigSnapshot, this.fleetConfig);
10978
11655
  const output = String(this.rawFleetDocument);
10979
11656
  if (redundantPaths.length > 0)
@@ -11084,11 +11761,15 @@ export class FleetManager {
11084
11761
  // Remove from the end so YAML sequence indexes do not shift underneath us.
11085
11762
  for (let i = before.length - 1; i >= after.length; i--) {
11086
11763
  const itemPath = [...path, i];
11087
- if (document.hasIn(itemPath))
11764
+ if (document.hasIn(itemPath)) {
11765
+ this.writingFleetDocument(document, itemPath);
11088
11766
  document.deleteIn(itemPath);
11767
+ }
11089
11768
  }
11090
- for (let i = shared; i < after.length; i++)
11769
+ for (let i = shared; i < after.length; i++) {
11770
+ this.writingFleetDocument(document, [...path, i]);
11091
11771
  document.setIn([...path, i], after[i]);
11772
+ }
11092
11773
  return;
11093
11774
  }
11094
11775
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -11104,6 +11785,7 @@ export class FleetManager {
11104
11785
  if (isMap(fresh)) {
11105
11786
  const list = document.createNode([]);
11106
11787
  list.items.push(fresh.clone());
11788
+ this.writingFleetDocument(document, ["channels"]);
11107
11789
  document.setIn(["channels"], list);
11108
11790
  }
11109
11791
  }
@@ -11125,13 +11807,17 @@ export class FleetManager {
11125
11807
  // Effective config contains inherited objects that may not exist in the
11126
11808
  // raw YAML at all. yaml.deleteIn() is not idempotent for a missing nested
11127
11809
  // parent, so guard it explicitly.
11128
- if (document.hasIn(path))
11810
+ if (document.hasIn(path)) {
11811
+ this.writingFleetDocument(document, path);
11129
11812
  document.deleteIn(path);
11813
+ }
11130
11814
  }
11131
11815
  else if (path.length === 0) {
11816
+ this.writingFleetDocument(document, path);
11132
11817
  document.contents = document.createNode(after);
11133
11818
  }
11134
11819
  else {
11820
+ this.writingFleetDocument(document, path);
11135
11821
  const currentNode = document.getIn(path, true);
11136
11822
  if (isScalar(currentNode) && (after === null || typeof after !== "object")) {
11137
11823
  currentNode.value = after;
@@ -11146,6 +11832,15 @@ export class FleetManager {
11146
11832
  }
11147
11833
  }
11148
11834
  }
11835
+ /**
11836
+ * #2208: before every write to the raw document — the place is unshared first (an alias on the way becomes its own
11837
+ * copy; an anchor's other aliases get theirs), so the write changes that place only; and it is recorded for the
11838
+ * save's check that nothing else changed.
11839
+ */
11840
+ writingFleetDocument(document, path) {
11841
+ detachForWrite(document, path);
11842
+ this.fleetDocumentWrites?.push([...path]);
11843
+ }
11149
11844
  getSettingsConfigPath() { return this.configPath; }
11150
11845
  async removeInstance(name, authorization, execution) {
11151
11846
  if (execution)
@@ -11189,10 +11884,27 @@ export class FleetManager {
11189
11884
  // Capture instance dir BEFORE lifecycle.remove, which deletes the instance
11190
11885
  // from fleetConfig. (The backend would be unresolvable afterwards.)
11191
11886
  const instanceDirToClean = this.getInstanceDir(name);
11192
- if (execution)
11193
- await this.lifecycle.remove(name, authorization, execution, transition);
11194
- else
11195
- 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
+ }
11196
11908
  // A later same-name registration owns its files and caches.
11197
11909
  // Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
11198
11910
  // re-registration) — #1677 review.
@@ -11207,6 +11919,7 @@ export class FleetManager {
11207
11919
  // Its web chat goes with it (only after the removal succeeded): a later instance of the same name
11208
11920
  // must not be shown the old one's conversation, and deleted names must not pile up.
11209
11921
  this.webChatHistory.forget(name);
11922
+ this.searchIndex?.removeInstance(name);
11210
11923
  this.webFiles.forget(name);
11211
11924
  this.compactWatchService?.forget(name);
11212
11925
  // #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
@@ -11887,7 +12600,7 @@ export class FleetManager {
11887
12600
  /** A preview under the inbox, named by a hash of the id (a Telegram file_id is long), pruned after a day. */
11888
12601
  storeStickerPreview(id, picture) {
11889
12602
  const dir = join(this.dataDir, "inbox", "sticker-previews");
11890
- mkdirSync(dir, { recursive: true });
12603
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
11891
12604
  const now = Date.now();
11892
12605
  for (const name of readdirSync(dir)) {
11893
12606
  try {
@@ -11953,7 +12666,7 @@ export class FleetManager {
11953
12666
  if (!/^\d{15,25}$/.test(id))
11954
12667
  throw new Error("not a Discord emoji id");
11955
12668
  const dir = join(this.dataDir, "inbox", "emoji-previews");
11956
- mkdirSync(dir, { recursive: true });
12669
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
11957
12670
  const now = Date.now();
11958
12671
  for (const name of readdirSync(dir)) {
11959
12672
  try {
@@ -12352,7 +13065,7 @@ export class FleetManager {
12352
13065
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "");
12353
13066
  const backend = this.fleetConfig?.defaults?.backend || "claude-code";
12354
13067
  try {
12355
- const { execFile } = await import("node:child_process");
13068
+ const { execFile } = await import("./child-process.js");
12356
13069
  const { promisify } = await import("node:util");
12357
13070
  // execFile with an argv array — no shell, so the backend name cannot be
12358
13071
  // interpreted as a command even if config is malformed.
@@ -12578,6 +13291,51 @@ export class FleetManager {
12578
13291
  }
12579
13292
  return { chatId, opts: {} };
12580
13293
  }
13294
+ /**
13295
+ * #2246 step 3: the task board's daily summary of [since, until) (task-board-summary.ts). Each chat world's General
13296
+ * gets its own instances' cards — a card that no world's instance holds (nobody's, a person's) goes to the primary
13297
+ * one — and each of that world's Generals shows the same text in the web chat. A world with nothing to say gets
13298
+ * nothing. A web-only fleet: everything, in the web chat.
13299
+ */
13300
+ postTaskBoardSummary(since, until) {
13301
+ const board = this.scheduler?.db.board;
13302
+ if (!board)
13303
+ return;
13304
+ const read = board.summaryRead(since.toISOString(), until.toISOString(), this.dispatcherGone);
13305
+ const projects = board.listProjects(true);
13306
+ const stats = board.projectStats();
13307
+ const names = new Map(projects.map((p) => [p.id, p.name]));
13308
+ const configured = this.getPrimaryAdapterId();
13309
+ const primary = configured !== undefined && this.worlds.has(configured) ? configured : this.worlds.keys().next().value;
13310
+ const worldOf = (assignee) => {
13311
+ const owner = assignee ? this.getInstanceAdapterId(assignee) : undefined;
13312
+ return owner !== undefined && this.worlds.has(owner) ? owner : primary;
13313
+ };
13314
+ const zone = this.usageZone();
13315
+ for (const world of this.worlds.size ? [...this.worlds.keys()] : [undefined]) {
13316
+ const summary = buildSummary(read, since, until, { inChat: (assignee) => worldOf(assignee) === world, projects, stats });
13317
+ if (isQuiet(summary))
13318
+ continue;
13319
+ const text = renderSummary(summary, { zone, t: (key, ...args) => t(key, ...args), projectOf: (c) => (c.projectId === null ? null : names.get(c.projectId) ?? null) });
13320
+ const cards = [...new Set([...summary.review, ...summary.blocked, ...summary.done, ...summary.created].map((c) => c.code))];
13321
+ if (world !== undefined) {
13322
+ const adapter = this.worlds.get(world)?.adapter;
13323
+ const target = this.fleetNoticeTarget(world);
13324
+ if (adapter && target) {
13325
+ adapter.sendText(target.chatId, text, target.opts).catch(e => this.logger.warn({ err: e, world }, "Failed to send the task board's daily summary"));
13326
+ }
13327
+ else {
13328
+ this.logger.warn({ world }, "The task board's daily summary has no postable target — set a General topic or channel.options.general_channel_id");
13329
+ }
13330
+ }
13331
+ for (const [name, cfg] of Object.entries(this.fleetConfig?.instances ?? {})) {
13332
+ if (cfg.general_topic !== true || (world !== undefined && this.getInstanceAdapterId(name) !== world))
13333
+ continue;
13334
+ // #2341 review: with the cards it names, so the public link withholds it while one of them is hidden there.
13335
+ this.emitSseEvent("message", { instance: name, sender: name, role: "status", text, ts: new Date().toISOString(), cards });
13336
+ }
13337
+ }
13338
+ }
12581
13339
  /**
12582
13340
  * Post the daily summary where fleet-wide notices go.
12583
13341
  *
@@ -13224,6 +13982,7 @@ export class FleetManager {
13224
13982
  interactionSummary: p.interaction_summary ?? null,
13225
13983
  pauseReason, pausedAt,
13226
13984
  crashedAt: crashed ? (this.needsCrashedAt.get(name) ?? null) : null,
13985
+ signIn: pauseReason === "auth" ? this.signInFor(name) : null,
13227
13986
  });
13228
13987
  }
13229
13988
  return out;
@@ -13285,6 +14044,8 @@ export class FleetManager {
13285
14044
  return String(topic);
13286
14045
  return this.classicChannels?.getChannelIdByInstance(instance) ?? undefined;
13287
14046
  },
14047
+ laneTarget: instance => this.instanceChannelTarget(instance),
14048
+ taskRoute: code => this.needsYouRoute(code),
13288
14049
  isFleetAdmin: (userId, world) => this.isFleetAdmin(userId, world),
13289
14050
  fleetAdmins: world => (this.getChannelConfig(world)?.access?.allowed_users ?? []).map(String),
13290
14051
  emitSse: (event, data) => this.emitSseEvent(event, data),
@@ -13293,6 +14054,8 @@ export class FleetManager {
13293
14054
  throw new Error("the delivery outbox is not open");
13294
14055
  return this.deliveryOutbox.acknowledge(deliveryId, by);
13295
14056
  },
14057
+ tasks: () => this.needsYouTaskItems(),
14058
+ taskAction: action => this.taskActionFromChat(action),
13296
14059
  settings: () => ({
13297
14060
  liveMessage: this.fleetConfig?.needs_you?.live_message !== false,
13298
14061
  dm: this.fleetConfig?.needs_you?.dm === true,
@@ -13303,6 +14066,114 @@ export class FleetManager {
13303
14066
  });
13304
14067
  this.needsYou.start();
13305
14068
  }
14069
+ /**
14070
+ * #2352: where an instance's own notices go — its topic in its world's group, or its ClassicBot room — as a Needs you
14071
+ * lane's place; null when it has neither (or its world has no group).
14072
+ */
14073
+ instanceChannelTarget(instance) {
14074
+ const cfg = this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, instance) ? this.fleetConfig.instances[instance] : undefined;
14075
+ if (!cfg)
14076
+ return null;
14077
+ const groupId = this.getChannelConfig(this.getInstanceAdapterId(instance))?.group_id;
14078
+ if (cfg.topic_id != null && groupId != null)
14079
+ return { chatId: String(groupId), threadId: String(cfg.topic_id) };
14080
+ const classic = this.classicChannels?.getChannelIdByInstance(instance);
14081
+ return classic ? { chatId: classic } : null;
14082
+ }
14083
+ /**
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.
14087
+ */
14088
+ needsYouLane(card, leads) {
14089
+ const usable = (name) => {
14090
+ if (!name || !this.fleetConfig?.instances || !Object.hasOwn(this.fleetConfig.instances, name))
14091
+ return false;
14092
+ const world = this.getInstanceAdapterId(name);
14093
+ return world !== undefined && this.worlds.has(world) && this.instanceChannelTarget(name) !== null;
14094
+ };
14095
+ const lead = card.projectId != null ? leads.get(card.projectId) : null;
14096
+ return usable(lead) ? lead : undefined;
14097
+ }
14098
+ /**
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
14100
+ * or a reply, never from the last render. null: it no longer waits for the person.
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
+ };
14114
+ needsYouRoute(code) {
14115
+ const board = this.scheduler?.db.board;
14116
+ const card = board?.findCard(code);
14117
+ // #2425 (Fable): the same rule as the list — a button rendered before the fix (or before the opt-in went off) for a
14118
+ // card that no longer waits for the person answers "no longer waits".
14119
+ if (!board || !card || this.fleetConfig?.needs_you?.task_board !== true || !waitsForPerson(card, this.dispatcherGone))
14120
+ return null;
14121
+ const leads = new Map(board.listProjects(true).map((p) => [p.id, p.leadInstance]));
14122
+ const lane = this.needsYouLane(card, leads);
14123
+ return lane ? { lane } : {};
14124
+ }
14125
+ /**
14126
+ * #2246 step 3: the task board's cards that wait for the person, for Needs you — under each card's assignee, listed in
14127
+ * its lane's channel (#2352: its project's lead; #2434: never the instance that handed it out), else in General.
14128
+ */
14129
+ needsYouTaskItems() {
14130
+ // #2425: the task board's cards join Needs you only when the fleet asks for them (needs_you.task_board, off by
14131
+ // default since 2.3.0-beta.25); prompts, deliveries and the rest are unchanged.
14132
+ return this.fleetConfig?.needs_you?.task_board === true ? this.needsYouTasks() : [];
14133
+ }
14134
+ needsYouTasks() {
14135
+ const board = this.scheduler?.db.board;
14136
+ if (!board)
14137
+ return [];
14138
+ const leads = new Map(board.listProjects(true).map((p) => [p.id, p.leadInstance]));
14139
+ return board.waitingForPerson(this.dispatcherGone).map(({ card, since }) => {
14140
+ const gap = card.status === "review" ? wrapUpGap(card) : null;
14141
+ const lane = this.needsYouLane(card, leads);
14142
+ return {
14143
+ uuid: card.uuid, code: card.code, title: card.title, status: card.status, version: card.version,
14144
+ assignee: card.assignee, since: Date.parse(since) || 0, ...(gap ? { gap } : {}), ...(lane ? { lane } : {}),
14145
+ note: card.status === "blocked" ? card.blockedReason : gap ? t("needs.task_gap", [
14146
+ gap.unchecked ? t("needs.task_gap_unchecked", gap.unchecked) : "", gap.noResult ? t("needs.task_gap_no_result") : "",
14147
+ ].filter(Boolean).join(t("needs.task_gap_join"))) : "",
14148
+ };
14149
+ });
14150
+ }
14151
+ /**
14152
+ * #2246 step 3: the person's move on a card from a chat's Needs you list — a fleet admin of that world (checked by the
14153
+ * hub), acting as the person, through the board's rules, at the version the button showed.
14154
+ */
14155
+ taskActionFromChat(action) {
14156
+ const board = this.scheduler?.db.board;
14157
+ if (!board)
14158
+ return { ok: false, message: t("needs.task_board_closed") };
14159
+ const person = { kind: "person", name: action.by.username || `${action.by.platform}:${action.by.userId}` };
14160
+ try {
14161
+ if (action.verb === "confirm")
14162
+ board.moveCard(person, action.code, "done", { version: action.version });
14163
+ else if (action.verb === "back")
14164
+ board.moveCard(person, action.code, "claimed", { version: action.version, reason: action.text });
14165
+ else
14166
+ board.moveCard(person, action.code, "claimed", { version: action.version, comment: action.text });
14167
+ const done = { confirm: "needs.task_confirmed", back: "needs.task_sent_back", answer: "needs.task_answered" }[action.verb];
14168
+ this.logger.info({ code: action.code, verb: action.verb, by: `${action.by.platform}:${action.by.userId}` }, "Task board: the person moved a card from the chat");
14169
+ return { ok: true, message: t(done, action.code) };
14170
+ }
14171
+ catch (err) {
14172
+ if (err instanceof TaskBoardError && err.code === "conflict")
14173
+ return { ok: false, message: t("needs.task_changed", action.code) };
14174
+ return { ok: false, message: t("needs.task_refused", action.code, err.message) };
14175
+ }
14176
+ }
13306
14177
  /** The prompts open on the dashboard right now (a page that loads after one was posted asks for them). */
13307
14178
  listWebPrompts() {
13308
14179
  const open = [];
@@ -13700,7 +14571,7 @@ export class FleetManager {
13700
14571
  ...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
13701
14572
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
13702
14573
  authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
13703
- 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(),
13704
14575
  });
13705
14576
  }
13706
14577
  sessionAuthorizations = null;
@@ -13725,7 +14596,7 @@ export class FleetManager {
13725
14596
  session, gateway, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
13726
14597
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
13727
14598
  authorization: { handle: session.handle, owner: session.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
13728
- 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,
13729
14600
  });
13730
14601
  if (check.refusal)
13731
14602
  return { ok: false, refusal: check.refusal };
@@ -13740,9 +14611,30 @@ export class FleetManager {
13740
14611
  */
13741
14612
  webAuthorityState() {
13742
14613
  if (!this.dataDir)
13743
- return { enabled: false, loopbackOnly: true, epoch: 0 };
14614
+ return { enabled: false, loopbackOnly: true, namedLink: false, epoch: 0 };
13744
14615
  this.authorityStateStore ??= new AuthorityStateStore(join(this.dataDir, "web-authority.json"), this.logger, undefined, undefined, () => { this.ensureDeliveryOutbox?.(); return this.deliveryOutbox?.maxWebAuthorityEpoch() ?? 0; });
13745
- 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
+ }
13746
14638
  }
13747
14639
  /**
13748
14640
  * #1939 S2a: Settings → Web → Web authority — the state in force (the switch counts as off while its state cannot be
@@ -13757,8 +14649,10 @@ export class FleetManager {
13757
14649
  const authorized = state.enabled ? (this.sessionAuthorizationStore()?.list() ?? [])
13758
14650
  .filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
13759
14651
  .map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
13760
- until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry) })) : [];
13761
- 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 };
13762
14656
  }
13763
14657
  authorizationRequests = null;
13764
14658
  /**
@@ -13771,7 +14665,9 @@ export class FleetManager {
13771
14665
  const adapterOf = (adapterId) => this.adapters.get(adapterId) ?? this.worlds.get(adapterId)?.adapter;
13772
14666
  // The host's clock, with its zone: the person may read the DM on a phone in another one.
13773
14667
  const when = (ms) => new Date(ms).toLocaleString(undefined, { timeZoneName: "short" });
13774
- 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");
13775
14671
  return this.authorizationRequests = new SessionAuthorizationRequests({
13776
14672
  now: () => performance.now(), wallNow: () => Date.now(),
13777
14673
  maxSessionMs: () => webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs,
@@ -13805,7 +14701,8 @@ export class FleetManager {
13805
14701
  const record = this.webSessions?.findByHandle(handle);
13806
14702
  if (!record)
13807
14703
  return { ok: false, refusal: "session_gone" };
13808
- 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");
13809
14706
  // What the card says: the end of the authorization, or the session's own end when that comes first.
13810
14707
  return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
13811
14708
  },
@@ -13851,7 +14748,7 @@ export class FleetManager {
13851
14748
  session: s, gateway: input.gateway, peer: input.peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
13852
14749
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
13853
14750
  authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
13854
- 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,
13855
14752
  });
13856
14753
  return r.refusal;
13857
14754
  }
@@ -13934,7 +14831,7 @@ export class FleetManager {
13934
14831
  const current = this.resolveInstanceEffort(instance).effort;
13935
14832
  return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
13936
14833
  },
13937
- backendStep: async (instance, args) => {
14834
+ backendStep: async (instance, args, stepOpts) => {
13938
14835
  const refusal = this.backendCommandRefusal(instance);
13939
14836
  if (refusal)
13940
14837
  return { error: refusal };
@@ -13944,7 +14841,13 @@ export class FleetManager {
13944
14841
  return step.noop ? { text: step.text } : { error: step.text };
13945
14842
  if (step.kind === "choose") {
13946
14843
  const id = (token) => joinBackendArgs([...step.args, token]);
13947
- return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label, ...(c.disabled ? { disabled: c.disabled } : {}) })) } };
14844
+ return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => {
14845
+ // #2233: the web offers Sign in beside it, so its reason is no longer "use /login".
14846
+ // Not over the public link: Sign in is refused there, so the choice keeps its "(use /login)" (#2262 review).
14847
+ const signIn = c.signIn && !stepOpts?.publicLink && dashboardSignInSupported(c.signIn) ? c.signIn : null;
14848
+ const disabled = signIn ? t("backend.reason_not_signed_in_web") : c.disabled;
14849
+ return { id: id(c.token), label: signIn ? `${c.token} — ${disabled}` : c.label, ...(disabled ? { disabled } : {}), ...(signIn ? { signIn } : {}) };
14850
+ }) } };
13948
14851
  }
13949
14852
  const now = performance.now();
13950
14853
  for (const [k, v] of this.webBackendTokens)
@@ -14601,7 +15504,7 @@ export class FleetManager {
14601
15504
  }
14602
15505
  }
14603
15506
  this.persistCancelButtons();
14604
- 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
14605
15508
  // Retirement is normally edge-triggered, but the edge (or an explicit
14606
15509
  // reply/cancel clear) may have happened while notifyAlert was in flight.
14607
15510
  // Reconcile the level after publication so a late button cannot resurrect
@@ -14867,7 +15770,8 @@ export class FleetManager {
14867
15770
  this.deleteButtonMessage(entry)
14868
15771
  .then(() => {
14869
15772
  this.discardButton(entry);
14870
- 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");
14871
15775
  })
14872
15776
  .catch((err) => this.scheduleButtonRetry(entry, err));
14873
15777
  }
@@ -15108,6 +16012,86 @@ export class FleetManager {
15108
16012
  }
15109
16013
  return this.loginController;
15110
16014
  }
16015
+ /** #2233: sign-ins started from the web dashboard (local admin sessions), on the same controller as chat's. */
16016
+ webBackendLoginsInstance = null;
16017
+ get webBackendLogins() {
16018
+ this.webBackendLoginsInstance ??= new WebBackendLogins({
16019
+ isLocalAdmin: handle => this.isLocalAdminSession(handle),
16020
+ start: (backend, chat, opts) => this.startWebDashboardLogin(backend, chat, opts),
16021
+ accessToken: sink => this.loginController?.webAccessToken(sink) ?? null,
16022
+ isActiveFor: sink => this.loginController?.isActiveFor(sink) ?? false,
16023
+ cancelFor: sink => this.loginController?.cancelFor(sink) ?? Promise.resolve(false),
16024
+ cancel: () => this.cancelLoginSession(),
16025
+ notice: ({ shown, label, handle }) => this.notifyFleetError(t("login.web_dashboard_notice", shown, label, handle.slice(0, 8))),
16026
+ // No details in the event: each page reads its own view (GET /ui/backend-login), as its session may see it.
16027
+ changed: () => broadcastSseEvent(this.sseClients, "backend_login", {}, err => this.logger.debug({ err }, "backend_login event failed")),
16028
+ });
16029
+ return this.webBackendLoginsInstance;
16030
+ }
16031
+ /** #2233: the login a paused-for-sign-in instance needs, when the dashboard can sign it in. */
16032
+ signInFor(name) {
16033
+ const backend = this.backendNameForInstance(name);
16034
+ const profile = this.credentialProfileOf(name, backend);
16035
+ return dashboardSignInSupported(backend, profile) ? { backend, ...(profile ? { profile } : {}) } : null;
16036
+ }
16037
+ /**
16038
+ * #2233: Settings → Agents' sign-in list — every backend's default login as the availability snapshot has it, then
16039
+ * each credential profile an agent uses (its own login state, read offline).
16040
+ */
16041
+ backendSignInTargets() {
16042
+ const out = [];
16043
+ for (const row of this.backendAvailabilityRows()) {
16044
+ const flow = LOGIN_FLOWS[row.backend];
16045
+ const install = BACKEND_INSTALLATION_INFO[row.backend]?.install;
16046
+ out.push({
16047
+ // Not installed or too old: signing in would not help — the row says how to install (or update) it instead.
16048
+ backend: row.backend, state: row.state, signIn: row.state !== "not_installed" && row.state !== "too_old" && dashboardSignInSupported(row.backend),
16049
+ ...(flow?.remoteLogin === "unsupported" ? { hostCommand: flow.command } : {}),
16050
+ ...(row.state === "not_installed" && install ? { install } : {}),
16051
+ ...(row.state === "too_old" && install ? { update: install } : {}),
16052
+ });
16053
+ }
16054
+ const seen = new Set();
16055
+ for (const name of Object.keys(this.fleetConfig?.instances ?? {}).sort()) {
16056
+ const backend = this.backendNameForInstance(name);
16057
+ const profile = this.credentialProfileOf(name, backend);
16058
+ if (!profile || seen.has(`${backend}\0${profile}`))
16059
+ continue;
16060
+ seen.add(`${backend}\0${profile}`);
16061
+ let login = "unknown";
16062
+ try {
16063
+ login = credentialProfileLogin(this.dataDir, backend, profile).state;
16064
+ }
16065
+ catch { /* unknown */ }
16066
+ const signIn = dashboardSignInSupported(backend, profile);
16067
+ const hostCommand = signIn ? null : credentialProfileLoginCommand(this.dataDir, backend, profile);
16068
+ out.push({ backend, profile, state: login === "logged-in" ? "usable" : login === "signed-out" ? "not_signed_in" : "unknown", signIn,
16069
+ ...(hostCommand ? { hostCommand } : {}) });
16070
+ }
16071
+ return out;
16072
+ }
16073
+ /** #2233: the session with this handle is live, admin, local and of the current dashboard token. */
16074
+ isLocalAdminSession(handle) {
16075
+ const sessions = this.webSessions, token = this.webToken;
16076
+ const record = sessions?.findByHandle(handle);
16077
+ return !!record && !!token && record.tier === "admin" && record.surface === "local" && sessions.isCurrent(record, tokenEpoch(token));
16078
+ }
16079
+ /**
16080
+ * #2233: `/login` for a dashboard requester. The same controller and checks; a CLI that is not installed is refused
16081
+ * with its install command (chat's install-first runs a terminal of its own, which the dashboard does not offer).
16082
+ */
16083
+ startWebDashboardLogin(backendArg, chat, opts) {
16084
+ const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
16085
+ if (isRemovedBackend(wanted))
16086
+ return Promise.resolve(removedBackendMessage(wanted));
16087
+ const info = BACKEND_INSTALLATION_INFO[wanted];
16088
+ if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !this.isCliInstalled(wanted)) {
16089
+ return Promise.resolve(t("login.web_dashboard_not_installed", wanted, info.install));
16090
+ }
16091
+ if (!opts.skipAuthCheck)
16092
+ this.recordLoginFlow(wanted, "login", chat.userId);
16093
+ return this.webLogin.start(backendArg, chat, opts);
16094
+ }
15111
16095
  /**
15112
16096
  * Post the backend chooser for a bare `/login`. Caller enforces admin.
15113
16097
  * Returns why the chooser could not be posted, for the caller to tell the
@@ -15750,6 +16734,36 @@ export class FleetManager {
15750
16734
  const failure = await this.promptLoginBackends(chat);
15751
16735
  await data.respond(failure ?? t("login.chooser_posted"));
15752
16736
  }
16737
+ /** #2293: whose cross-instance messages search leaves out now (web-api /ui/search). */
16738
+ searchVisibility() { return xmsgVisibility(this.fleetConfig); }
16739
+ /** #2293: a cross-instance message for the search index (outbound-handlers, after it was admitted). */
16740
+ indexCrossInstance(record) {
16741
+ this.searchIndex?.record(xmsgDoc(record));
16742
+ }
16743
+ /**
16744
+ * #2293: the global search index, at fleet start — after the web chat is restored (the backfill reads it). Its worker
16745
+ * owns search.db; see search-index.ts. Off with web.search.enabled: false.
16746
+ */
16747
+ startSearchIndex(fleet) {
16748
+ const config = fleet.web?.search;
16749
+ if (config?.enabled === false || this.searchIndex)
16750
+ return;
16751
+ const history = this.webChatHistory;
16752
+ this.searchIndex = new SearchIndex({
16753
+ path: join(this.dataDir, "search.db"),
16754
+ retentionDays: config?.retention_days ?? SEARCH_RETENTION_DAYS,
16755
+ outboxPath: join(this.dataDir, "delivery-outbox.db"),
16756
+ schedulerPath: join(this.dataDir, "scheduler.db"),
16757
+ chatDocs: function* () { for (const name of history.instances())
16758
+ for (const m of history.list(name)) {
16759
+ const doc = chatDoc(m);
16760
+ if (doc)
16761
+ yield doc;
16762
+ } },
16763
+ log: (level, msg) => this.logger[level](msg),
16764
+ });
16765
+ this.searchIndex.start();
16766
+ }
15753
16767
  queueMirrorMessage(text) {
15754
16768
  const mirrorTopicId = this.fleetConfig?.channel?.mirror_topic_id;
15755
16769
  if (mirrorTopicId == null || !this.adapter)
@@ -15844,8 +16858,15 @@ export class FleetManager {
15844
16858
  links: m.links,
15845
16859
  channel: m.channel,
15846
16860
  from: m.from,
16861
+ replyTo: m.replyTo,
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
15847
16865
  });
15848
- 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));
16869
+ this.searchIndex?.record(chatDoc(recorded));
15849
16870
  // #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
15850
16871
  if (recorded.role === "agent")
15851
16872
  broadcastSseEvent(this.sseClients, "agent_reply_live", { v: 1, boot: recorded.boot, id: recorded.id, instance: recorded.instance }, onError);
@@ -15858,6 +16879,53 @@ export class FleetManager {
15858
16879
  }
15859
16880
  broadcastSseEvent(this.sseClients, event, data, onError);
15860
16881
  }
16882
+ /**
16883
+ * The task board's live side: every committed change goes to the open pages (never to a guest) — the board's new
16884
+ * revision and the card numbers it touched; not who touched them (the page does not need it, and a public-link page
16885
+ * must not learn a hidden instance's name from it). Its news goes to the instances whose cards someone else changed:
16886
+ * one "[Task board]" notice per instance, gathered for 10 s, held by the delivery outbox until it is taken
16887
+ * (task-board-waker.ts).
16888
+ */
16889
+ attachTaskBoard(db) {
16890
+ this.taskBoardWaker?.stop();
16891
+ // #2303: each instance's cards at once, read at every claim (a fleet.yaml edit applies to the next one).
16892
+ db.board.setCardLimit((name) => boardCardLimit(this.fleetConfig, name));
16893
+ const waker = this.taskBoardWaker = new TaskBoardWaker({
16894
+ deliver: (instance, message, key) => this.admitTaskBoardNotice(instance, message, key),
16895
+ isInstance: (name) => this.fleetConfig?.instances?.[name] != null,
16896
+ holds: (wake) => db.board.wakeHolds(wake),
16897
+ card: (uuid) => db.board.findCard(uuid),
16898
+ // #2352: the card's Needs you buttons are in this instance's own channel (it is the card's lane).
16899
+ buttonsHere: (instance, uuid) => {
16900
+ // #2355 review: no live message, no buttons — the old words then.
16901
+ if (this.fleetConfig?.needs_you?.live_message === false)
16902
+ return false;
16903
+ const card = db.board.findCard(uuid);
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))
16906
+ return false;
16907
+ const leads = new Map(db.board.listProjects(true).map((p) => [p.id, p.leadInstance]));
16908
+ return this.needsYouLane(card, leads) === instance;
16909
+ },
16910
+ log: (level, message, extra) => this.logger[level](extra ?? {}, message),
16911
+ });
16912
+ db.board.onChange(({ rev, ids, projects, wakes }) => {
16913
+ this.emitSseEvent("tasks", { rev, ids, projects });
16914
+ waker.note(wakes);
16915
+ // step 3: cards waiting for the person are Needs you items; #2355 review: a project's lead routes them too.
16916
+ if (ids.length || projects)
16917
+ this.needsYou?.poke();
16918
+ });
16919
+ }
16920
+ /** A task board notice, as AgEnD's own (not a person's words): through the delivery outbox, held until delivered. */
16921
+ admitTaskBoardNotice(instance, message, key) {
16922
+ this.ensureDeliveryOutbox();
16923
+ const outbox = this.deliveryOutbox;
16924
+ if (!outbox)
16925
+ return false;
16926
+ outbox.admitSystemNotice(instance, key, message, new Date());
16927
+ return true;
16928
+ }
15861
16929
  listClaimedTasks(assignee) {
15862
16930
  try {
15863
16931
  return this.scheduler?.db.listTasks({ assignee, status: "claimed" }) ?? [];
@@ -16060,6 +17128,27 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16060
17128
  this.syncRoleSkills(workDir, backend, "general", knowledgeDir);
16061
17129
  this.logger.debug({ knowledgeDir, workDir, backend }, "Synced general knowledge files");
16062
17130
  }
17131
+ /** Update only AgEnD-managed skill copies; no launch or restart is needed for an experimental switch. */
17132
+ canvasWorkerSettingChanged() {
17133
+ this.refreshExperimentalSkills();
17134
+ this.emitSseEvent("status", this.getUiStatus());
17135
+ }
17136
+ refreshExperimentalSkills() {
17137
+ for (const [name, daemon] of this.daemons) {
17138
+ try {
17139
+ const cfg = daemon.getConfigSnapshot();
17140
+ const backend = cfg.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
17141
+ const workDir = this.resolveKnowledgeWorkDir(cfg.working_directory, backend, name);
17142
+ const classic = this.classicChannels?.getAll().some(ch => ch.instanceName === name) === true;
17143
+ if (classic && !insideManagedWorkspaces(workDir))
17144
+ continue;
17145
+ this.syncRoleSkills(workDir, backend, classic ? "classic" : cfg.general_topic ? "general" : "worker");
17146
+ }
17147
+ catch (err) {
17148
+ this.logger.warn({ name, err }, "Could not refresh experimental skill visibility");
17149
+ }
17150
+ }
17151
+ }
16063
17152
  /** Publish only the bundled skills eligible for an instance role. */
16064
17153
  syncRoleSkills(workDir, backend, role, knowledgeDir) {
16065
17154
  const skillSegments = FleetManager.SKILLS_DIR_SEGMENTS[backend];
@@ -16073,7 +17162,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16073
17162
  // can keep those copies current; other backends never had unmanaged
16074
17163
  // AgEnD-published skills and must retain the normal user-ownership guard.
16075
17164
  const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
16076
- this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged);
17165
+ this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged,
17166
+ // #2163: a Classic bot's skills folder (or a skill in it) may be a link out of its workspace; every path is
17167
+ // checked where it really lands, just before it is written.
17168
+ role === "classic" ? (path) => insideManagedWorkspaces(path) : undefined);
16077
17169
  this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
16078
17170
  }
16079
17171
  /**
@@ -16159,11 +17251,63 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16159
17251
  * migration exception is the pre-manifest Kiro General layout explicitly
16160
17252
  * selected by adoptLegacyUnmanaged.
16161
17253
  */
16162
- syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
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
+ }
17295
+ syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false, confined) {
16163
17296
  if (!existsSync(srcSkills))
16164
17297
  return;
17298
+ const refuse = (path) => {
17299
+ if (!confined || confined(path))
17300
+ return false;
17301
+ this.logger.warn({ path, destSkills }, "Not publishing skills — the path leads out of the managed workspace");
17302
+ return true;
17303
+ };
17304
+ if (refuse(destSkills))
17305
+ return;
16165
17306
  mkdirSync(destSkills, { recursive: true });
16166
17307
  const manifestPath = join(destSkills, ".agend-managed-skills.json");
17308
+ // Its ownership claims decide what is replaced or removed below: one that lives elsewhere is never believed.
17309
+ if (refuse(manifestPath))
17310
+ return;
16167
17311
  const hadManifest = existsSync(manifestPath);
16168
17312
  let previouslyManaged = [];
16169
17313
  try {
@@ -16176,41 +17320,42 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16176
17320
  .filter(name => existsSync(join(srcSkills, name, "SKILL.md")))
16177
17321
  .sort();
16178
17322
  // Pre-manifest Kiro General copied bundled skills directly into
16179
- // .kiro/skills. If every existing skill is still a bundled name, this is
16180
- // the unambiguous legacy layout: adopt it once, update it below, and write
16181
- // the ownership manifest. Any extra skill name keeps the directory fully
17323
+ // .kiro/skills. If every existing skill is still bundled AND belongs to
17324
+ // the closed pre-manifest cohort, adopt it once, update it below, and
17325
+ // write the ownership manifest. Any extra skill name keeps the directory fully
16182
17326
  // on the user-owned/collision path.
16183
17327
  if (!hadManifest && adoptLegacyUnmanaged) {
16184
17328
  const existing = readdirSync(destSkills, { withFileTypes: true })
16185
17329
  .filter(entry => entry.isDirectory() && existsSync(join(destSkills, entry.name, "SKILL.md")))
16186
17330
  .map(entry => entry.name)
16187
17331
  .sort();
16188
- if (existing.length > 0 && existing.every(name => bundled.includes(name))) {
17332
+ if (existing.length > 0 && existing.every(name => bundled.includes(name) && PRE_MANIFEST_KIRO_SKILLS.has(name))) {
16189
17333
  previouslyManaged = existing;
16190
17334
  this.logger.info({ skills: existing, destSkills }, "Adopted legacy AgEnD skills into managed ownership");
16191
17335
  }
16192
17336
  }
16193
17337
  const eligible = bundled.filter(name => {
16194
17338
  const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
16195
- // General is the coordinator and receives both coordinator and worker
16196
- // playbooks. Workers receive only skills explicitly marked for workers.
16197
- return role === "general" || roles.includes("worker");
17339
+ // General is the coordinator and receives every playbook. Workers and Classic bots receive only skills
17340
+ // explicitly marked for their role (#2163).
17341
+ return role === "general" || roles.includes(role);
16198
17342
  });
16199
17343
  const managed = [];
17344
+ const skipped = [];
16200
17345
  for (const name of eligible) {
16201
17346
  const destDir = join(destSkills, name);
16202
17347
  const dest = join(destDir, "SKILL.md");
16203
17348
  const isOurs = previouslyManaged.includes(name) || !existsSync(destDir);
16204
17349
  if (!isOurs) {
16205
- // The user's skill still wins on every sync; only the warning is deduped.
16206
- if (!this.skippedBundledSkills.has(name)) {
16207
- this.skippedBundledSkills.add(name);
16208
- this.logger.warn({ skill: name, destSkills }, "Skipping bundled skill — a skill of this name exists but was not published by AgEnD");
16209
- }
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);
16210
17353
  continue;
16211
17354
  }
16212
17355
  managed.push(name);
16213
- const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
17356
+ const newContent = bundledSkillContents(join(srcSkills, name), this.canvasWorkerEnabled());
17357
+ if (refuse(dest))
17358
+ continue;
16214
17359
  try {
16215
17360
  if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
16216
17361
  continue;
@@ -16219,6 +17364,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16219
17364
  mkdirSync(destDir, { recursive: true });
16220
17365
  writeFileSync(dest, newContent);
16221
17366
  }
17367
+ if (skipped.length)
17368
+ this.reportSkippedSkills(destSkills, skipped);
16222
17369
  // Remove managed skills that are no longer bundled OR no longer eligible
16223
17370
  // for this role. This makes a shared → general-only metadata change take
16224
17371
  // effect on the next worker startup instead of leaving stale capability.
@@ -16257,9 +17404,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16257
17404
  if (!Array.isArray(frontmatter.roles))
16258
17405
  throw new Error("roles must be an array");
16259
17406
  const roles = [...new Set(frontmatter.roles)]
16260
- .filter((value) => value === "general" || value === "worker");
17407
+ .filter((value) => value === "general" || value === "worker" || value === "classic");
16261
17408
  if (roles.length !== frontmatter.roles.length || roles.length === 0) {
16262
- throw new Error("roles must contain only general and/or worker");
17409
+ throw new Error("roles must contain only general, worker and/or classic");
16263
17410
  }
16264
17411
  return roles;
16265
17412
  }
@@ -16550,7 +17697,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16550
17697
  try {
16551
17698
  const tmpPath = await dlAdapter.downloadAttachment(att.fileId);
16552
17699
  const inboxDir = join(getAgendHome(), "workspaces", instanceName, "inbox");
16553
- mkdirSync(inboxDir, { recursive: true });
17700
+ mkdirSync(inboxDir, { recursive: true, mode: 0o700 });
16554
17701
  const dest = join(inboxDir, basename(tmpPath));
16555
17702
  // Copy to destination — failure means this attachment is skipped
16556
17703
  try {
@@ -16962,7 +18109,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16962
18109
  }
16963
18110
  }
16964
18111
  const path = this.cliEnvPath(backend, profile);
16965
- mkdirSync(dirname(path), { recursive: true });
18112
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
16966
18113
  writeFileSync(path, JSON.stringify(env, null, 2));
16967
18114
  return env;
16968
18115
  }
@@ -18648,23 +19795,56 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18648
19795
  * settles, so a later genuine stop (after a restart) still does the work.
18649
19796
  */
18650
19797
  stopAll() {
18651
- this.stopAllInFlight ??= this.doStopAll().finally(() => { this.stopAllInFlight = null; });
19798
+ this.stopAllInFlight ??= this.doStopAll().finally(() => {
19799
+ // #1956: after the stop's own tmux work; anything later goes direct. A later startAll starts it again.
19800
+ if (this.tmuxExecHelperTimer) {
19801
+ clearInterval(this.tmuxExecHelperTimer);
19802
+ this.tmuxExecHelperTimer = null;
19803
+ }
19804
+ // #2364: and the stop waits (bounded) for the helper to leave on its own, before the caller exits the process.
19805
+ return tmuxExecHelper.stop();
19806
+ }).finally(() => { this.stopAllInFlight = null; });
18652
19807
  return this.stopAllInFlight;
18653
19808
  }
19809
+ tmuxExecHelperTimer = null;
18654
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
+ }
18655
19827
  async doStopAll() {
19828
+ this.startupMemoryDeadline?.stop();
19829
+ this.startupMemoryDeadline = null;
18656
19830
  this.startupComplete = false;
19831
+ this.startupDashboard = null;
18657
19832
  this.reloadPending = false;
18658
19833
  // Before anything is stopped: everything that dies from here on dies
18659
19834
  // because we asked it to. Set synchronously — doStopAll runs to its first
18660
19835
  // await in the same tick as the signal handler, so no event can slip in.
18661
19836
  this.shuttingDown = true;
19837
+ if (this.updateProgressTimer) {
19838
+ clearInterval(this.updateProgressTimer);
19839
+ this.updateProgressTimer = null;
19840
+ }
18662
19841
  this.settingsGeneration++;
18663
19842
  this.settingsConfirmation?.store.close();
18664
19843
  this.settingsConfirmation = null;
18665
19844
  const settingsControl = this.settingsControl;
18666
19845
  this.settingsControl = null;
18667
19846
  const settingsControlStopped = settingsControl?.close();
19847
+ this.publicLinkAutoOpen?.stop();
18668
19848
  const publicStopped = this.publicWebLink?.close("fleet shutdown");
18669
19849
  // #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
18670
19850
  // adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
@@ -18722,6 +19902,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18722
19902
  this.clearStatuslineWatchers();
18723
19903
  this.costGuard?.stop();
18724
19904
  this.dailySummary?.stop();
19905
+ this.taskBoardSummary?.stop();
18725
19906
  this.dailyTipScheduler?.stop();
18726
19907
  this.dailyTipScheduler = null;
18727
19908
  if (this.updateCheckTimer) {
@@ -18802,27 +19983,29 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18802
19983
  // this is the last moment the prompts can be collapsed.
18803
19984
  await this.retirePendingNoncePrompts();
18804
19985
  this.topicArchiver.stop();
19986
+ this.taskBoardWaker?.stop();
19987
+ this.appIconStore?.close();
18805
19988
  this.scheduler?.shutdown();
19989
+ // What it was sent is written first (bounded: search-index.ts close()).
19990
+ const search = this.searchIndex;
19991
+ this.searchIndex = null;
19992
+ await search?.close();
18806
19993
  // Stop instances in parallel batches to avoid long sequential waits.
18807
19994
  // Concurrency scales with fleet size — larger fleets tolerate more parallel
18808
19995
  // tmux ops, while small fleets stay conservative to avoid overwhelming the
18809
19996
  // tmux server.
18810
19997
  const entries = [...this.daemons.entries()];
18811
- const STOP_CONCURRENCY = entries.length > 30 ? 15 : entries.length >= 10 ? 10 : 5;
18812
19998
  for (const [name] of entries)
18813
19999
  this.ipcStoppingInstances.add(name);
18814
- for (let i = 0; i < entries.length; i += STOP_CONCURRENCY) {
18815
- const batch = entries.slice(i, i + STOP_CONCURRENCY);
18816
- await Promise.all(batch.map(async ([name, daemon]) => {
18817
- try {
18818
- await daemon.stop();
18819
- }
18820
- catch (err) {
18821
- this.logger.warn({ name, err }, "Stop failed");
18822
- }
18823
- this.daemons.delete(name);
18824
- }));
18825
- }
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
+ });
18826
20009
  // Close IPC clients in parallel — serial close over a large fleet adds
18827
20010
  // noticeable latency.
18828
20011
  await Promise.all([...this.instanceIpcClients.values()].map(ipc => Promise.resolve(ipc.close()).catch(() => { })));
@@ -19033,6 +20216,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19033
20216
  this.rawFleetConfig = previousRawConfig;
19034
20217
  this.rawFleetDocument = previousRawDocument;
19035
20218
  this.savedFleetConfigSnapshot = previousSavedSnapshot;
20219
+ this.observeWebAuthority(); // #2453 review: the configuration back in force is the one seen
19036
20220
  this.logger.error({
19037
20221
  oldCount,
19038
20222
  newCount,
@@ -19052,6 +20236,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19052
20236
  // mark every row done and tell the user "changes applied".
19053
20237
  return { rejected: why };
19054
20238
  }
20239
+ if (canvasWorkerEnabled(oldConfig?.web) !== this.canvasWorkerEnabled()) {
20240
+ this.canvasWorkerSettingChanged();
20241
+ }
19055
20242
  // Classic behavior settings share the fleet defaults but are not entries
19056
20243
  // in fleet.yaml. Snapshot the old effective chain before reloading the
19057
20244
  // Classic file so SIGHUP can hot-apply either source without waiting for
@@ -19128,8 +20315,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19128
20315
  // so an old/new diff alone can miss the live daemon's stale value.
19129
20316
  for (const [name, config] of Object.entries(newInstances)) {
19130
20317
  if (!this.daemons.has(name)) {
19131
- // New instance — startInstance already calls connectIpcToInstance
19132
- 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");
19133
20324
  observe?.(name, "restart", "running");
19134
20325
  await this.startInstanceUnattended(name, config, topicMode, "new instance");
19135
20326
  observe?.(name, "restart", "done");
@@ -19138,6 +20329,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19138
20329
  const daemon = this.daemons.get(name);
19139
20330
  const runtimeConfig = daemon.getConfigSnapshot?.() ?? oldConfig.instances[name];
19140
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
+ }
19141
20336
  if (change === "restart") {
19142
20337
  this.logger.info({ name }, "Instance config changed — restarting");
19143
20338
  observe?.(name, "restart", "running");
@@ -19301,6 +20496,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19301
20496
  return false;
19302
20497
  }
19303
20498
  /** Non-null when startup found the running config and fleet.yaml disagreeing. */
20499
+ /**
20500
+ * #2346 (settings schema): whether a connection added now would start at once — what canHotAddConnections would decide
20501
+ * for the current config with one more connection appended: another one is running, and nothing else this process took
20502
+ * at startup has changed since (a pending locale or access change, say, needs AgEnD restarted anyway).
20503
+ */
20504
+ connectionsStartLive() {
20505
+ if (!this.fleetConfig)
20506
+ return false;
20507
+ const current = this.fleetConfig.channels ?? (this.fleetConfig.channel ? [this.fleetConfig.channel] : []);
20508
+ const probe = { id: "\u0000probe", type: "telegram", mode: "topic", bot_token_env: "\u0000PROBE" };
20509
+ const next = { ...this.fleetConfig, channels: [...current, probe] };
20510
+ delete next.channel;
20511
+ return this.canHotAddConnections(next);
20512
+ }
19304
20513
  fleetSignatureMismatchKeys() {
19305
20514
  return this.fleetSignatureMismatch;
19306
20515
  }
@@ -19519,12 +20728,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19519
20728
  return this.fleetConfig?.web?.provider_secrets === true;
19520
20729
  }
19521
20730
  listProviderSecrets() {
19522
- return PROVIDER_SECRET_SPECS.map(spec => ({
20731
+ return [...PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC].map(spec => ({
19523
20732
  id: spec.id,
19524
20733
  display_name: spec.displayName,
19525
20734
  kind: spec.kind,
19526
20735
  token_present: !!process.env[spec.envKey],
19527
- verifier: spec.verifier ? "available" : "unsupported",
20736
+ verifier: spec.kind === "tunnel_token" ? "local_format" : spec.verifier ? "available" : "unsupported",
19528
20737
  activation: spec.activation,
19529
20738
  stale_consumers: this.providerSecretStaleConsumers(spec.envKey),
19530
20739
  }));
@@ -19534,7 +20743,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19534
20743
  // a child process's private environment safely, so report the conservative
19535
20744
  // set of already-running children; the UI can then say "restart these"
19536
20745
  // rather than claiming an existing process reloaded.
19537
- if (envKey === "GROQ_API_KEY")
20746
+ if (envKey === "GROQ_API_KEY" || envKey === PUBLIC_TUNNEL_TOKEN_ENV)
19538
20747
  return [];
19539
20748
  return [...this.children.keys()].sort();
19540
20749
  }
@@ -19552,10 +20761,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19552
20761
  if (!spec || !this.providerSecretEnvAllowed(spec.envKey)) {
19553
20762
  return { ok: false, status: "invalid", error: "provider secret is not configured" };
19554
20763
  }
19555
- if (!spec.verifier)
20764
+ const tunnel = spec.kind === "tunnel_token";
20765
+ if (!tunnel && !spec.verifier)
19556
20766
  return { ok: false, status: "unsupported_verifier", error: "this provider has no supported verifier" };
19557
- if (!input.secret || input.secret.length > 4096 || /[\r\n\0]/.test(input.secret) || /[^\x20-\x7e]/.test(input.secret)) {
19558
- return { ok: false, status: "invalid", error: "secret is invalid" };
20767
+ const secret = tunnel && !input.clear ? normalizePublicTunnelToken(input.secret) : input.secret;
20768
+ if (input.clear && (!tunnel || input.secret !== "")
20769
+ || !input.clear && (!secret || secret.length > 4096 || /[\r\n\0]/.test(secret) || /[^\x20-\x7e]/.test(secret))) {
20770
+ return { ok: false, status: "invalid", error: tunnel ? PUBLIC_TUNNEL_TOKEN_HINT : "secret is invalid" };
19559
20771
  }
19560
20772
  const challengeKey = `${input.sessionBinding}:api_key:${spec.id}:${spec.envKey}:${input.idempotencyKey}`;
19561
20773
  const existingId = this.providerSecretChallengesByKey.get(challengeKey);
@@ -19565,7 +20777,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19565
20777
  }
19566
20778
  if (existingId)
19567
20779
  this.providerSecretChallengesByKey.delete(challengeKey);
19568
- const result = await verifyProviderSecret(spec, input.secret, this.providerSecretHttpClient);
20780
+ const result = tunnel ? { ok: true } : await verifyProviderSecret(spec, input.secret, this.providerSecretHttpClient);
19569
20781
  if (!result.ok) {
19570
20782
  // Do not log provider detail: the HTTP verifier already redacted it and
19571
20783
  // this endpoint has no need to disclose whether a key was close to valid.
@@ -19583,7 +20795,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19583
20795
  operation: "provider-secret.apply",
19584
20796
  idempotencyKey: input.idempotencyKey,
19585
20797
  expiresAt, deadline: performance.now() + SECRET_CHALLENGE_TTL_MS,
19586
- secret: input.secret,
20798
+ secret: secret,
19587
20799
  };
19588
20800
  this.providerSecretChallenges.set(challenge.id, challenge);
19589
20801
  this.providerSecretChallengesByKey.set(challengeKey, challenge.id);
@@ -19653,6 +20865,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19653
20865
  async runProviderSecretApply(job, secret, execution) {
19654
20866
  const spec = providerSecretSpec(job.specId);
19655
20867
  const allowed = new Set([
20868
+ PUBLIC_TUNNEL_TOKEN_ENV,
19656
20869
  ...PROVIDER_SECRET_SPECS.map(item => item.envKey),
19657
20870
  ...(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).map(channel => channel.bot_token_env),
19658
20871
  ]);
@@ -19666,7 +20879,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19666
20879
  // Construct inside the transaction: symlink/permission refusal must
19667
20880
  // settle the job as a safe failure, not escape the queued microtask.
19668
20881
  store = new SecretStore(join(this.dataDir, ".env"), allowed, { owner: execution?.owner });
19669
- const write = () => { before = store.write(job.envKey, secret); wrote = true; process.env[job.envKey] = secret; };
20882
+ const write = () => {
20883
+ before = spec.kind === "tunnel_token" && !secret ? store.clear(job.envKey) : store.write(job.envKey, secret);
20884
+ wrote = true;
20885
+ if (secret)
20886
+ process.env[job.envKey] = secret;
20887
+ else
20888
+ delete process.env[job.envKey];
20889
+ };
19670
20890
  if (execution)
19671
20891
  execution.mutate(write);
19672
20892
  else
@@ -19688,12 +20908,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19688
20908
  job.finishedAt = Date.now();
19689
20909
  }
19690
20910
  catch (err) {
19691
- const safe = safeSecretError(err, secret);
20911
+ const safe = spec?.kind === "tunnel_token" ? "connector token change failed" : safeSecretError(err, secret);
19692
20912
  this.logger.warn({ specId: job.specId, reason: safe }, "Provider API-key apply failed");
19693
20913
  try {
19694
20914
  // Memory and disk have separate receipts: a host-edited file must
19695
20915
  // remain, but cannot strand our owned process value or hook snapshot.
19696
- if (wrote && process.env[job.envKey] === secret) {
20916
+ if (wrote && (process.env[job.envKey] ?? "") === secret) {
19697
20917
  if (previousProcessValue === undefined)
19698
20918
  delete process.env[job.envKey];
19699
20919
  else
@@ -19733,6 +20953,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19733
20953
  * hot activation contract explicit and gives tests a failure seam; no generic
19734
20954
  * SIGHUP or caller-provided hook is ever executed. */
19735
20955
  async runProviderSecretReloadHook(hookId, _next, _previous) {
20956
+ if (hookId === "cloudflare.public_link") {
20957
+ await this.publicWebLink?.close("connector token changed");
20958
+ return;
20959
+ }
19736
20960
  if (hookId !== "groq.voice")
19737
20961
  throw new Error("unknown provider secret reload hook");
19738
20962
  const before = this.providerSecretHotSnapshots.get(hookId);
@@ -20405,7 +21629,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20405
21629
  all = false;
20406
21630
  const message = err?.message ?? String(err);
20407
21631
  this.adapterState.set(id, { status: "retrying", retryCount: 0, lastError: message });
20408
- this.scheduleAdapterRetry(id, channel);
21632
+ this.scheduleAdapterRetry(id);
20409
21633
  }
20410
21634
  }
20411
21635
  this.classicChannels?.configureAdapters(this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []));
@@ -20582,7 +21806,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20582
21806
  await ipc.close();
20583
21807
  }
20584
21808
  this.instanceIpcClients.clear();
20585
- await Promise.allSettled(instanceNames.map(name => this.stopInstance(name)));
21809
+ await this.stopInstancesInBatches(instanceNames, name => this.stopInstance(name));
20586
21810
  // Kill remaining orphan windows to prevent stale state on restart
20587
21811
  try {
20588
21812
  const agendNames = new Set(instanceNames);
@@ -20715,7 +21939,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20715
21939
  // up to 15s each, and on a beta build BOTH ran — 30s with no WATCHDOG ping,
20716
21940
  // past WatchdogSec's half-interval and enough for systemd to SIGABRT the fleet
20717
21941
  // for a background version check.
20718
- const { execFile } = await import("node:child_process");
21942
+ const { execFile } = await import("./child-process.js");
20719
21943
  const { promisify } = await import("node:util");
20720
21944
  const execFileP = promisify(execFile);
20721
21945
  const npmVersion = async (spec) => {
@@ -20903,6 +22127,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20903
22127
  if (this.guestScope(owner).size)
20904
22128
  return "ok";
20905
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";
20906
22136
  const members = this.guestRooms(), rooms = guestGroupRooms(this.classicChannels?.getAll() ?? [], owner);
20907
22137
  if (rooms.some((room) => members.pending(room, owner.userId))) {
20908
22138
  void this.refreshGuestRooms(owner, { mode: "triggered" }); // rate-limited per room (30 s), single-flight
@@ -21001,16 +22231,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21001
22231
  res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
21002
22232
  return;
21003
22233
  }
21004
- if (closing) {
22234
+ if (closing || this.shuttingDown) {
21005
22235
  res.writeHead(503, { Connection: "close", "Retry-After": "10" });
21006
22236
  res.end(JSON.stringify({ error: "fleet stopping" }));
21007
22237
  return;
21008
22238
  }
21009
22239
  const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
22240
+ if (!this.canvasWorkerEnabled() && isCanvasWorkerHttpPath(requestPath)) {
22241
+ res.writeHead(403, { "Content-Type": "application/json", "Cache-Control": "no-store" });
22242
+ res.end(JSON.stringify({ error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE }));
22243
+ return;
22244
+ }
21010
22245
  if (this.effectBootstrap.handle(req, res))
21011
22246
  return;
21012
22247
  // Branding is public, after Host admission: automatic favicon requests
21013
22248
  // must not become noisy web-token 401s in the browser console.
22249
+ // #2308: the icon routes and the pages' icon links follow the app icon set in Settings.
22250
+ useAppIconStore(this.appIcon);
21014
22251
  if (handleWebIconRequest(req, res, requestPath))
21015
22252
  return;
21016
22253
  // Public: the health probe, /agent (instance-token auth of its own), the
@@ -21042,6 +22279,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21042
22279
  // #1920: a guest reaches only GUEST_ROUTES, within the scope derived for it now — default deny, before any
21043
22280
  // handler, on this listener and the public link's gateway alike (both come through here).
21044
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);
21045
22284
  const owner = decision.session.owner;
21046
22285
  const admitted = decision.session;
21047
22286
  const admission = this.guestAdmission(admitted);
@@ -21052,12 +22291,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21052
22291
  return;
21053
22292
  }
21054
22293
  if (admission === "gone") {
22294
+ if (navigating) {
22295
+ serveGuestRefusedPage(res, 401, "ended");
22296
+ return;
22297
+ }
21055
22298
  res.writeHead(401);
21056
22299
  res.end(JSON.stringify({ error: "guest_scope_empty" }));
21057
22300
  return;
21058
22301
  }
21059
22302
  const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
21060
22303
  if (verdict.kind === "deny") {
22304
+ if (navigating) {
22305
+ serveGuestRefusedPage(res, verdict.status, "members");
22306
+ return;
22307
+ }
21061
22308
  res.writeHead(verdict.status);
21062
22309
  res.end(JSON.stringify({ error: verdict.error }));
21063
22310
  return;
@@ -21073,6 +22320,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21073
22320
  // #1920 §5.1: guests chat unless the host made them read-only (web.guests.chat: false).
21074
22321
  chat: () => this.fleetConfig?.web?.guests?.chat !== false });
21075
22322
  }
22323
+ if (decision.via === "session" && this.webToken) {
22324
+ const upgrade = sessionCookieUpgrade(req, this.webToken, decision.session);
22325
+ // No cookie header is set before this gate; auth routes own their headers separately.
22326
+ if (upgrade)
22327
+ res.setHeader("Set-Cookie", upgrade);
22328
+ }
21076
22329
  }
21077
22330
  if (req.method === "GET" && req.url === "/health") {
21078
22331
  const health = this.getFleetHealth();
@@ -21084,6 +22337,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21084
22337
  res.end(JSON.stringify(health));
21085
22338
  return;
21086
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
+ }
21087
22348
  if (req.method === "GET" && req.url === "/status") {
21088
22349
  const instances = Object.keys(this.fleetConfig?.instances ?? {}).map(name => {
21089
22350
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
@@ -21354,6 +22615,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21354
22615
  }
21355
22616
  }
21356
22617
  startHealthServer(port) {
22618
+ let settled;
22619
+ const listening = new Promise(resolve => { settled = resolve; });
21357
22620
  this.startedAt = Date.now();
21358
22621
  this.healthServerListening = false;
21359
22622
  this.healthPortRetried = false;
@@ -21368,6 +22631,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21368
22631
  const drain = trackWebServer(server);
21369
22632
  this.healthServer = server;
21370
22633
  this.healthServerDrain = drain;
22634
+ server.once("close", settled);
21371
22635
  const markListening = (afterTakeover = false) => {
21372
22636
  this.healthServerListening = true;
21373
22637
  this.logger.info({ port }, afterTakeover
@@ -21381,6 +22645,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21381
22645
  // #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
21382
22646
  const bound = this.healthServer?.address();
21383
22647
  this.startPreviewListener(port, bound && typeof bound === "object" ? bound.port : port);
22648
+ settled();
21384
22649
  };
21385
22650
  this.healthServer.on("error", (err) => {
21386
22651
  this.healthServerListening = false;
@@ -21388,6 +22653,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21388
22653
  if (this.healthPortRetried) {
21389
22654
  this.logger.error({ err, port }, "Health port still in use after takeover — dashboard disabled");
21390
22655
  this.notifyFleetError(t("dashboard.port_in_use", port));
22656
+ settled();
21391
22657
  return;
21392
22658
  }
21393
22659
  this.healthPortRetried = true;
@@ -21423,16 +22689,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21423
22689
  this.logger.debug({ err }, "Old fleet process kill skipped (already gone or no permission)");
21424
22690
  }
21425
22691
  setTimeout(() => {
21426
- if (!this.healthServer)
22692
+ if (this.healthServer !== server || this.shuttingDown) {
22693
+ settled();
21427
22694
  return;
21428
- this.healthServer.listen(port, "127.0.0.1", () => markListening(true));
22695
+ }
22696
+ server.listen(port, "127.0.0.1", () => markListening(true));
21429
22697
  }, 1500);
21430
22698
  return;
21431
22699
  }
21432
22700
  this.logger.error({ err, port }, "Health server error");
21433
22701
  this.notifyFleetError(t("dashboard.server_failed", err.message));
22702
+ settled();
21434
22703
  });
21435
22704
  this.healthServer.listen(port, "127.0.0.1", () => markListening());
22705
+ return listening;
21436
22706
  }
21437
22707
  /**
21438
22708
  * #1306: the preview listener, beside the web listener and closed with it. When it cannot listen (the port is
@@ -21454,6 +22724,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21454
22724
  return;
21455
22725
  }
21456
22726
  const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
22727
+ canvasWorkerEnabled: () => this.canvasWorkerEnabled(),
21457
22728
  // #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
21458
22729
  extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
21459
22730
  this.previewListener = listener;
@@ -21576,15 +22847,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21576
22847
  this.logger.debug({ err }, "bots unreadable (getUiStatus)");
21577
22848
  }
21578
22849
  const instances = names.map(name => {
21579
- const statusFile = join(this.getInstanceDir(name), "statusline.json");
21580
- let cost = 0;
21581
- try {
21582
- const data = JSON.parse(readFileSync(statusFile, "utf-8"));
21583
- cost = data.cost?.total_cost_usd ?? 0;
21584
- }
21585
- catch (err) {
21586
- this.logger.debug({ err, name }, "statusline.json read failed (getUiStatus)");
21587
- }
22850
+ // #1956: read once for this instance; its cost, context and model come from it.
22851
+ const statusline = readStatuslineJson(this.dataDir, name);
22852
+ const cost = statusline?.cost?.total_cost_usd ?? 0;
21588
22853
  // Align with /ctx: statusline for claude-code, pane scrape for kiro/grok/codex.
21589
22854
  const classic = classicOnly.includes(name);
21590
22855
  const backend = classic
@@ -21592,14 +22857,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21592
22857
  : (this.fleetConfig?.instances[name]?.backend
21593
22858
  ?? this.fleetConfig?.defaults?.backend
21594
22859
  ?? "claude-code");
21595
- const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name) });
22860
+ const { context } = resolveInstanceContext(this.dataDir, name, backend, { source: this.getPaneContextSource(name), statusline });
21596
22861
  // context_pct: null when unavailable, not 0
21597
22862
  const context_pct = context ?? null;
21598
22863
  // Model: Only Claude Code has live statusline; others use the effective resolver.
21599
- // readStatuslineModel provides the /ctx-aligned display_name+id combo for Claude.
22864
+ // statuslineModel gives the /ctx-aligned display_name+id combo for Claude (readStatuslineModel's rule).
21600
22865
  // Non-Claude backends must NOT read statusline.json model (may be stale from previous Claude run).
21601
22866
  const resolved = this.resolveInstanceModel(name);
21602
- const liveModel = backend === "claude-code" ? readStatuslineModel(this.dataDir, name) : null;
22867
+ const liveModel = backend === "claude-code" ? statuslineModel(statusline) : null;
21603
22868
  // Display value: live model for Claude, resolved.display for others (includes "auto (default)" for Kiro)
21604
22869
  const model = liveModel ?? resolved.display;
21605
22870
  // model_source: "live" when Claude statusline succeeded, else the resolver's source
@@ -21637,7 +22902,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21637
22902
  return {};
21638
22903
  } })(),
21639
22904
  // #2118: only this Chat's placement content; guest projection drops global revisions.
21640
- effect_chat_v: createHash("sha256").update(JSON.stringify(this.themeStore.get(name).placements.effects)).digest("hex").slice(0, 32),
22905
+ effect_chat_v: this.canvasWorkerEnabled() ? createHash("sha256").update(JSON.stringify(this.themeStore.get(name).placements.effects)).digest("hex").slice(0, 32) : null,
21641
22906
  // #1958: the identity colour's tiers (the page resolves them: the agent's only while it shows agents' themes).
21642
22907
  color: { ...(colors.get(name)?.color ? { person: colors.get(name).color } : {}), ...(image.agentColor ? { agent: image.agentColor } : {}),
21643
22908
  ...(image.roleColor ? { role: image.roleColor } : {}), auto: autoIdentityColor(name) },
@@ -21646,6 +22911,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21646
22911
  description: identity.description || undefined,
21647
22912
  role: identity.role || undefined,
21648
22913
  tags: identity.tags,
22914
+ teams: instanceTeams(this.fleetConfig?.teams, name), // #2172: the sidebar groups a team's members under it
22915
+ last_reply: this.webChatHistory.lastAgentReply(name) ?? undefined, // #2179: its last reply to a person (ISO)
21649
22916
  status: this.getInstanceStatus(name),
21650
22917
  // `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
21651
22918
  // what the dashboard's activity events carry) come from instancePresentation (#1212).
@@ -21662,7 +22929,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21662
22929
  };
21663
22930
  });
21664
22931
  return {
22932
+ canvas_worker: this.canvasWorkerEnabled(),
21665
22933
  instances,
22934
+ startup: this.startupDashboard?.snapshot() ?? null,
21666
22935
  publicLink: this.getPublicWebStatus(),
21667
22936
  uptime: Math.floor((Date.now() - this.startedAt) / 1000),
21668
22937
  // #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.