@songsid/agend 2.2.0-beta.9 → 2.3.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (422) hide show
  1. package/README.md +40 -24
  2. package/README.zh-TW.md +40 -24
  3. package/dist/agent-cli.js +248 -2
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.d.ts +2 -0
  6. package/dist/agent-endpoint.js +28 -0
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-file-read.d.ts +30 -0
  9. package/dist/agent-file-read.js +129 -0
  10. package/dist/agent-file-read.js.map +1 -0
  11. package/dist/agent-whats-new.js +20 -1
  12. package/dist/agent-whats-new.js.map +1 -1
  13. package/dist/atomic-write.js +9 -1
  14. package/dist/atomic-write.js.map +1 -1
  15. package/dist/auth-api.d.ts +3 -1
  16. package/dist/auth-api.js +32 -1
  17. package/dist/auth-api.js.map +1 -1
  18. package/dist/backend/antigravity.d.ts +33 -0
  19. package/dist/backend/antigravity.js +129 -21
  20. package/dist/backend/antigravity.js.map +1 -1
  21. package/dist/backend/claude-code.d.ts +3 -0
  22. package/dist/backend/claude-code.js +9 -0
  23. package/dist/backend/claude-code.js.map +1 -1
  24. package/dist/backend/codex-metadata.d.ts +33 -0
  25. package/dist/backend/codex-metadata.js +122 -18
  26. package/dist/backend/codex-metadata.js.map +1 -1
  27. package/dist/backend/codex.d.ts +3 -0
  28. package/dist/backend/codex.js +17 -1
  29. package/dist/backend/codex.js.map +1 -1
  30. package/dist/backend/effort-metadata.d.ts +37 -1
  31. package/dist/backend/effort-metadata.js +86 -15
  32. package/dist/backend/effort-metadata.js.map +1 -1
  33. package/dist/backend/grok.d.ts +3 -0
  34. package/dist/backend/grok.js +9 -0
  35. package/dist/backend/grok.js.map +1 -1
  36. package/dist/backend/kiro.d.ts +3 -1
  37. package/dist/backend/kiro.js +7 -3
  38. package/dist/backend/kiro.js.map +1 -1
  39. package/dist/backend/launch-effort.d.ts +61 -0
  40. package/dist/backend/launch-effort.js +170 -0
  41. package/dist/backend/launch-effort.js.map +1 -0
  42. package/dist/backend/muse.js +10 -2
  43. package/dist/backend/muse.js.map +1 -1
  44. package/dist/backend/types.d.ts +24 -0
  45. package/dist/backend/types.js +12 -1
  46. package/dist/backend/types.js.map +1 -1
  47. package/dist/backend-command.d.ts +91 -0
  48. package/dist/backend-command.js +122 -0
  49. package/dist/backend-command.js.map +1 -0
  50. package/dist/channel/adapters/discord.d.ts +14 -0
  51. package/dist/channel/adapters/discord.js +66 -4
  52. package/dist/channel/adapters/discord.js.map +1 -1
  53. package/dist/channel/adapters/telegram.d.ts +26 -1
  54. package/dist/channel/adapters/telegram.js +98 -7
  55. package/dist/channel/adapters/telegram.js.map +1 -1
  56. package/dist/channel/mcp-tools.js +36 -0
  57. package/dist/channel/mcp-tools.js.map +1 -1
  58. package/dist/channel/tool-router.d.ts +2 -0
  59. package/dist/channel/tool-router.js +1 -1
  60. package/dist/channel/tool-router.js.map +1 -1
  61. package/dist/channel/types.d.ts +31 -0
  62. package/dist/cli.js +124 -24
  63. package/dist/cli.js.map +1 -1
  64. package/dist/command-table.d.ts +2 -2
  65. package/dist/command-table.js +5 -2
  66. package/dist/command-table.js.map +1 -1
  67. package/dist/compact-watch.d.ts +86 -0
  68. package/dist/compact-watch.js +288 -0
  69. package/dist/compact-watch.js.map +1 -0
  70. package/dist/config-validator.js +55 -0
  71. package/dist/config-validator.js.map +1 -1
  72. package/dist/config.d.ts +1 -0
  73. package/dist/config.js +2 -1
  74. package/dist/config.js.map +1 -1
  75. package/dist/daemon.d.ts +9 -0
  76. package/dist/daemon.js +394 -29
  77. package/dist/daemon.js.map +1 -1
  78. package/dist/delivery-outbox.d.ts +8 -1
  79. package/dist/delivery-outbox.js +6 -2
  80. package/dist/delivery-outbox.js.map +1 -1
  81. package/dist/doctor.js +7 -0
  82. package/dist/doctor.js.map +1 -1
  83. package/dist/fleet-context.d.ts +12 -0
  84. package/dist/fleet-manager.d.ts +247 -9
  85. package/dist/fleet-manager.js +1227 -142
  86. package/dist/fleet-manager.js.map +1 -1
  87. package/dist/forged-envelope-scan.d.ts +4 -3
  88. package/dist/forged-envelope-scan.js +4 -3
  89. package/dist/forged-envelope-scan.js.map +1 -1
  90. package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
  91. package/dist/general-knowledge/skills/web-appearance/SKILL.md +258 -0
  92. package/dist/install-lock.d.ts +18 -1
  93. package/dist/install-lock.js +30 -3
  94. package/dist/install-lock.js.map +1 -1
  95. package/dist/instance-avatar.d.ts +5 -4
  96. package/dist/instance-avatar.js +15 -8
  97. package/dist/instance-avatar.js.map +1 -1
  98. package/dist/instance-config-impact.js +3 -0
  99. package/dist/instance-config-impact.js.map +1 -1
  100. package/dist/instance-lifecycle.js +9 -4
  101. package/dist/instance-lifecycle.js.map +1 -1
  102. package/dist/instructions.js +2 -2
  103. package/dist/instructions.js.map +1 -1
  104. package/dist/kiro-compact-pane.d.ts +38 -0
  105. package/dist/kiro-compact-pane.js +57 -0
  106. package/dist/kiro-compact-pane.js.map +1 -0
  107. package/dist/launchd-handoff.d.ts +43 -0
  108. package/dist/launchd-handoff.js +56 -0
  109. package/dist/launchd-handoff.js.map +1 -0
  110. package/dist/locale.js +202 -30
  111. package/dist/locale.js.map +1 -1
  112. package/dist/log-service.js +10 -2
  113. package/dist/log-service.js.map +1 -1
  114. package/dist/login-shell.d.ts +21 -0
  115. package/dist/login-shell.js +98 -0
  116. package/dist/login-shell.js.map +1 -0
  117. package/dist/needs-you-hub.d.ts +4 -0
  118. package/dist/needs-you-hub.js +31 -6
  119. package/dist/needs-you-hub.js.map +1 -1
  120. package/dist/needs-you.d.ts +1 -13
  121. package/dist/needs-you.js +3 -37
  122. package/dist/needs-you.js.map +1 -1
  123. package/dist/outbound-handlers.d.ts +32 -0
  124. package/dist/outbound-handlers.js +150 -45
  125. package/dist/outbound-handlers.js.map +1 -1
  126. package/dist/outbound-schemas.d.ts +76 -0
  127. package/dist/outbound-schemas.js +65 -4
  128. package/dist/outbound-schemas.js.map +1 -1
  129. package/dist/provider-probe.d.ts +29 -0
  130. package/dist/provider-probe.js +198 -2
  131. package/dist/provider-probe.js.map +1 -1
  132. package/dist/public-web-gateway.d.ts +2 -0
  133. package/dist/public-web-gateway.js +25 -1
  134. package/dist/public-web-gateway.js.map +1 -1
  135. package/dist/quickstart-api.d.ts +1 -1
  136. package/dist/quickstart-api.js +8 -6
  137. package/dist/quickstart-api.js.map +1 -1
  138. package/dist/quickstart.d.ts +12 -0
  139. package/dist/quickstart.js +36 -6
  140. package/dist/quickstart.js.map +1 -1
  141. package/dist/reply-buttons-controller.d.ts +86 -5
  142. package/dist/reply-buttons-controller.js +271 -7
  143. package/dist/reply-buttons-controller.js.map +1 -1
  144. package/dist/reply-buttons.d.ts +48 -3
  145. package/dist/reply-buttons.js +105 -10
  146. package/dist/reply-buttons.js.map +1 -1
  147. package/dist/reply-polls.d.ts +85 -0
  148. package/dist/reply-polls.js +154 -0
  149. package/dist/reply-polls.js.map +1 -0
  150. package/dist/repo-worktree-guard.d.ts +36 -0
  151. package/dist/repo-worktree-guard.js +143 -0
  152. package/dist/repo-worktree-guard.js.map +1 -0
  153. package/dist/restart-guard.d.ts +6 -3
  154. package/dist/restart-guard.js +6 -3
  155. package/dist/restart-guard.js.map +1 -1
  156. package/dist/restart-progress.d.ts +0 -2
  157. package/dist/restart-progress.js +0 -2
  158. package/dist/restart-progress.js.map +1 -1
  159. package/dist/scheduler/scheduler.d.ts +58 -1
  160. package/dist/scheduler/scheduler.js +176 -10
  161. package/dist/scheduler/scheduler.js.map +1 -1
  162. package/dist/sd-notify.d.ts +22 -1
  163. package/dist/sd-notify.js +63 -10
  164. package/dist/sd-notify.js.map +1 -1
  165. package/dist/service-activation.d.ts +15 -7
  166. package/dist/service-activation.js +42 -26
  167. package/dist/service-activation.js.map +1 -1
  168. package/dist/service-installer.d.ts +24 -0
  169. package/dist/service-installer.js +49 -1
  170. package/dist/service-installer.js.map +1 -1
  171. package/dist/service-plan.d.ts +11 -0
  172. package/dist/service-plan.js +23 -0
  173. package/dist/service-plan.js.map +1 -1
  174. package/dist/settings-api.js +74 -1
  175. package/dist/settings-api.js.map +1 -1
  176. package/dist/settings-change.js +8 -0
  177. package/dist/settings-change.js.map +1 -1
  178. package/dist/settings-effect.js +16 -2
  179. package/dist/settings-effect.js.map +1 -1
  180. package/dist/settings-http-confirmation.js +5 -0
  181. package/dist/settings-http-confirmation.js.map +1 -1
  182. package/dist/systemd-peer-bus.d.ts +95 -0
  183. package/dist/systemd-peer-bus.js +430 -0
  184. package/dist/systemd-peer-bus.js.map +1 -0
  185. package/dist/systemd-runtime.d.ts +27 -1
  186. package/dist/systemd-runtime.js +69 -7
  187. package/dist/systemd-runtime.js.map +1 -1
  188. package/dist/tool-permissions.js +53 -3
  189. package/dist/tool-permissions.js.map +1 -1
  190. package/dist/topic-commands.d.ts +72 -2
  191. package/dist/topic-commands.js +156 -38
  192. package/dist/topic-commands.js.map +1 -1
  193. package/dist/topic-link.d.ts +128 -0
  194. package/dist/topic-link.js +269 -0
  195. package/dist/topic-link.js.map +1 -0
  196. package/dist/transcript-jsonl.d.ts +7 -2
  197. package/dist/transcript-jsonl.js +13 -7
  198. package/dist/transcript-jsonl.js.map +1 -1
  199. package/dist/transcript-sources.js +6 -1
  200. package/dist/transcript-sources.js.map +1 -1
  201. package/dist/types.d.ts +21 -0
  202. package/dist/ui/app-effects.js +187 -0
  203. package/dist/ui/chat-commands.js +1 -0
  204. package/dist/ui/chat-effect-posters.js +42 -0
  205. package/dist/ui/chat-effects.js +72 -0
  206. package/dist/ui/chat-live-replies.js +58 -0
  207. package/dist/ui/chat-render.js +59 -14
  208. package/dist/ui/chat-store.js +78 -8
  209. package/dist/ui/chat-strings.js +43 -16
  210. package/dist/ui/chat-thread.js +175 -31
  211. package/dist/ui/chat-tour.js +18 -15
  212. package/dist/ui/effect-trial-controller.js +119 -0
  213. package/dist/ui/effect-trial-offer.js +31 -0
  214. package/dist/ui/effect-trial-panel.js +37 -0
  215. package/dist/ui/image-lightbox.js +1 -1
  216. package/dist/ui/panel-chat.js +101 -27
  217. package/dist/ui/panel-details.js +18 -5
  218. package/dist/ui/panel-fleet.js +18 -5
  219. package/dist/ui/panel-log.js +10 -10
  220. package/dist/ui/panel-needs.js +13 -10
  221. package/dist/ui/panel-settings.js +66 -12
  222. package/dist/ui/preview-panel.js +25 -9
  223. package/dist/ui/settings-appearance.js +325 -0
  224. package/dist/ui/settings-dialogs.js +11 -6
  225. package/dist/ui/settings-effects.js +201 -0
  226. package/dist/ui/settings-model.js +3 -1
  227. package/dist/ui/settings-mods.js +257 -0
  228. package/dist/ui/settings-owner-effects.js +80 -0
  229. package/dist/ui/settings-strings.js +269 -10
  230. package/dist/ui/settings-token.js +7 -0
  231. package/dist/ui/settings-wizard.js +5 -3
  232. package/dist/ui/shared/agend-auth.js +27 -3
  233. package/dist/ui/shared/agent-widget.js +248 -0
  234. package/dist/ui/shared/app-i18n.js +3 -1
  235. package/dist/ui/shared/app-prefs.js +30 -10
  236. package/dist/ui/shared/app-route.js +23 -1
  237. package/dist/ui/shared/app-session.js +1 -1
  238. package/dist/ui/shared/app-shell.js +29 -16
  239. package/dist/ui/shared/app-store.js +2 -0
  240. package/dist/ui/shared/app-stream.js +15 -7
  241. package/dist/ui/shared/app-themes.js +188 -0
  242. package/dist/ui/shared/app.css +266 -11
  243. package/dist/ui/shared/app.js +6 -0
  244. package/dist/ui/shared/card-strings.js +28 -0
  245. package/dist/ui/shared/effect-app-runtime.js +248 -0
  246. package/dist/ui/shared/effect-baseline.js +64 -0
  247. package/dist/ui/shared/effect-chat-runtime.js +154 -0
  248. package/dist/ui/shared/effect-cursor.js +125 -0
  249. package/dist/ui/shared/effect-frame.js +127 -0
  250. package/dist/ui/shared/effect-geometry.js +122 -0
  251. package/dist/ui/shared/effect-host-style.js +10 -0
  252. package/dist/ui/shared/effect-owner-regions.js +74 -0
  253. package/dist/ui/shared/effect-owner-source.js +115 -0
  254. package/dist/ui/shared/effect-policy.js +137 -0
  255. package/dist/ui/shared/effect-poster-reader.js +98 -0
  256. package/dist/ui/shared/effect-posters.js +117 -0
  257. package/dist/ui/shared/effect-profiler.js +162 -0
  258. package/dist/ui/shared/effect-profiles.js +65 -0
  259. package/dist/ui/shared/effect-registry.js +496 -0
  260. package/dist/ui/shared/effect-runtime-loader.js +234 -0
  261. package/dist/ui/shared/effect-safety.js +153 -0
  262. package/dist/ui/shared/effect-stock-snapshot.js +34 -0
  263. package/dist/ui/shared/effect-stock-trust.js +165 -0
  264. package/dist/ui/shared/effect-trial-source.js +103 -0
  265. package/dist/ui/shared/effect-trial-stage.js +147 -0
  266. package/dist/ui/shared/header-tools.js +35 -15
  267. package/dist/ui/shared/identity-card.js +202 -0
  268. package/dist/ui/shared/identity-color.js +261 -0
  269. package/dist/ui/shared/instance-nav.js +22 -14
  270. package/dist/ui/shared/instance-switch.js +5 -2
  271. package/dist/ui/shared/owner-css.js +221 -0
  272. package/dist/ui/shared/owner-effect-prefs.js +117 -0
  273. package/dist/ui/shared/panel-view.js +96 -12
  274. package/dist/ui/shared/signin.js +17 -9
  275. package/dist/ui/shared/ui-dialog.js +1 -1
  276. package/dist/ui/shared/ui-icons.js +3 -0
  277. package/dist/ui/shared/ui-menu.js +1 -1
  278. package/dist/ui/shared/ui-toast.js +1 -1
  279. package/dist/ui/shared/view-strings.js +40 -2
  280. package/dist/ui/signin.html +9 -3
  281. package/dist/update-check.d.ts +31 -0
  282. package/dist/update-check.js +47 -0
  283. package/dist/update-check.js.map +1 -1
  284. package/dist/update-launch.d.ts +48 -1
  285. package/dist/update-launch.js +159 -5
  286. package/dist/update-launch.js.map +1 -1
  287. package/dist/usage/providers.d.ts +28 -8
  288. package/dist/usage/providers.js +96 -41
  289. package/dist/usage/providers.js.map +1 -1
  290. package/dist/view-api.d.ts +29 -0
  291. package/dist/view-api.js +214 -17
  292. package/dist/view-api.js.map +1 -1
  293. package/dist/web-api.d.ts +86 -2
  294. package/dist/web-api.js +1160 -92
  295. package/dist/web-api.js.map +1 -1
  296. package/dist/web-appearance-limits.d.ts +82 -0
  297. package/dist/web-appearance-limits.js +83 -0
  298. package/dist/web-appearance-limits.js.map +1 -0
  299. package/dist/web-appearance-policy.d.ts +66 -0
  300. package/dist/web-appearance-policy.js +94 -0
  301. package/dist/web-appearance-policy.js.map +1 -0
  302. package/dist/web-appearance-tools.d.ts +50 -0
  303. package/dist/web-appearance-tools.js +105 -0
  304. package/dist/web-appearance-tools.js.map +1 -0
  305. package/dist/web-appearance-view.d.ts +99 -0
  306. package/dist/web-appearance-view.js +163 -0
  307. package/dist/web-appearance-view.js.map +1 -0
  308. package/dist/web-appearance.d.ts +79 -0
  309. package/dist/web-appearance.js +298 -0
  310. package/dist/web-appearance.js.map +1 -0
  311. package/dist/web-asset-parts.d.ts +98 -0
  312. package/dist/web-asset-parts.js +133 -0
  313. package/dist/web-asset-parts.js.map +1 -0
  314. package/dist/web-auth.js +17 -1
  315. package/dist/web-auth.js.map +1 -1
  316. package/dist/web-card.d.ts +96 -0
  317. package/dist/web-card.js +232 -0
  318. package/dist/web-card.js.map +1 -0
  319. package/dist/web-chat-history.d.ts +39 -1
  320. package/dist/web-chat-history.js +69 -9
  321. package/dist/web-chat-history.js.map +1 -1
  322. package/dist/web-commands.d.ts +39 -3
  323. package/dist/web-commands.js +22 -5
  324. package/dist/web-commands.js.map +1 -1
  325. package/dist/web-effect-bundle.d.ts +28 -0
  326. package/dist/web-effect-bundle.js +126 -0
  327. package/dist/web-effect-bundle.js.map +1 -0
  328. package/dist/web-effect-canonical.d.ts +8 -0
  329. package/dist/web-effect-canonical.js +27 -0
  330. package/dist/web-effect-canonical.js.map +1 -0
  331. package/dist/web-effect-library.d.ts +114 -0
  332. package/dist/web-effect-library.js +530 -0
  333. package/dist/web-effect-library.js.map +1 -0
  334. package/dist/web-effect-poster.d.ts +8 -0
  335. package/dist/web-effect-poster.js +52 -0
  336. package/dist/web-effect-poster.js.map +1 -0
  337. package/dist/web-effect-profiles.d.ts +118 -0
  338. package/dist/web-effect-profiles.js +180 -0
  339. package/dist/web-effect-profiles.js.map +1 -0
  340. package/dist/web-effect-service.d.ts +86 -0
  341. package/dist/web-effect-service.js +312 -0
  342. package/dist/web-effect-service.js.map +1 -0
  343. package/dist/web-effect-tools.d.ts +47 -0
  344. package/dist/web-effect-tools.js +126 -0
  345. package/dist/web-effect-tools.js.map +1 -0
  346. package/dist/web-effect-trials.d.ts +86 -0
  347. package/dist/web-effect-trials.js +204 -0
  348. package/dist/web-effect-trials.js.map +1 -0
  349. package/dist/web-effect-upload.d.ts +29 -0
  350. package/dist/web-effect-upload.js +190 -0
  351. package/dist/web-effect-upload.js.map +1 -0
  352. package/dist/web-effect-worker.d.ts +8 -0
  353. package/dist/web-effect-worker.js +170 -0
  354. package/dist/web-effect-worker.js.map +1 -0
  355. package/dist/web-effect.d.ts +89 -0
  356. package/dist/web-effect.js +132 -0
  357. package/dist/web-effect.js.map +1 -0
  358. package/dist/web-identity-color.d.ts +87 -0
  359. package/dist/web-identity-color.js +209 -0
  360. package/dist/web-identity-color.js.map +1 -0
  361. package/dist/web-identity-palette.d.ts +113 -0
  362. package/dist/web-identity-palette.js +84 -0
  363. package/dist/web-identity-palette.js.map +1 -0
  364. package/dist/web-image-meta.d.ts +25 -0
  365. package/dist/web-image-meta.js +200 -0
  366. package/dist/web-image-meta.js.map +1 -0
  367. package/dist/web-login.d.ts +14 -0
  368. package/dist/web-login.js +4 -2
  369. package/dist/web-login.js.map +1 -1
  370. package/dist/web-mod-agent.d.ts +50 -0
  371. package/dist/web-mod-agent.js +203 -0
  372. package/dist/web-mod-agent.js.map +1 -0
  373. package/dist/web-mod-bundle.d.ts +144 -0
  374. package/dist/web-mod-bundle.js +464 -0
  375. package/dist/web-mod-bundle.js.map +1 -0
  376. package/dist/web-mod-export.d.ts +59 -0
  377. package/dist/web-mod-export.js +79 -0
  378. package/dist/web-mod-export.js.map +1 -0
  379. package/dist/web-mod-store.d.ts +283 -0
  380. package/dist/web-mod-store.js +624 -0
  381. package/dist/web-mod-store.js.map +1 -0
  382. package/dist/web-org.d.ts +3 -3
  383. package/dist/web-org.js +11 -17
  384. package/dist/web-org.js.map +1 -1
  385. package/dist/web-owner-css-store.d.ts +180 -0
  386. package/dist/web-owner-css-store.js +384 -0
  387. package/dist/web-owner-css-store.js.map +1 -0
  388. package/dist/web-owner-css.d.ts +58 -0
  389. package/dist/web-owner-css.js +552 -0
  390. package/dist/web-owner-css.js.map +1 -0
  391. package/dist/web-prefs.d.ts +66 -0
  392. package/dist/web-prefs.js +243 -35
  393. package/dist/web-prefs.js.map +1 -1
  394. package/dist/web-preview.d.ts +9 -1
  395. package/dist/web-preview.js +24 -7
  396. package/dist/web-preview.js.map +1 -1
  397. package/dist/web-profile.d.ts +32 -0
  398. package/dist/web-profile.js +132 -0
  399. package/dist/web-profile.js.map +1 -0
  400. package/dist/web-session.d.ts +14 -0
  401. package/dist/web-session.js +10 -0
  402. package/dist/web-session.js.map +1 -1
  403. package/dist/web-settings-patch.d.ts +18 -0
  404. package/dist/web-settings-patch.js +51 -0
  405. package/dist/web-settings-patch.js.map +1 -1
  406. package/dist/web-shell-routes.d.ts +1 -1
  407. package/dist/web-shell-routes.js +1 -1
  408. package/dist/web-shell-routes.js.map +1 -1
  409. package/dist/web-theme-store.d.ts +218 -0
  410. package/dist/web-theme-store.js +516 -0
  411. package/dist/web-theme-store.js.map +1 -0
  412. package/dist/web-theme.d.ts +121 -0
  413. package/dist/web-theme.js +407 -0
  414. package/dist/web-theme.js.map +1 -0
  415. package/dist/web-upload.d.ts +119 -4
  416. package/dist/web-upload.js +252 -39
  417. package/dist/web-upload.js.map +1 -1
  418. package/dist/web-widget.d.ts +72 -0
  419. package/dist/web-widget.js +245 -0
  420. package/dist/web-widget.js.map +1 -0
  421. package/launcher/install-admission.cjs +61 -6
  422. package/package.json +3 -3
@@ -7,7 +7,7 @@ import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileRes
7
7
  import { performance } from "node:perf_hooks";
8
8
  import { withSpawnHoldReply } from "./spawn-hold-reply.js";
9
9
  import { gatewayRequestContext } from "./web-request-context.js";
10
- import { createPublicWebGateway } from "./public-web-gateway.js";
10
+ import { createPublicWebGateway, PUBLIC_GATEWAY_RECEIVE_MS } from "./public-web-gateway.js";
11
11
  import { handleWebIconRequest } from "./web-icons.js";
12
12
  import { trackWebServer } from "./web-server-drain.js";
13
13
  import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
@@ -20,10 +20,9 @@ import { withinBudget } from "./monotonic-budget.js";
20
20
  import { measureSyncWork } from "./sync-work-attribution.js";
21
21
  import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
22
22
  import { ProfileControlServer } from "./profile-control.js";
23
- import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants } from "node:fs";
23
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants, closeSync } from "node:fs";
24
24
  import { atomicWriteFileSync } from "./atomic-write.js";
25
25
  import { createHash, randomBytes, randomUUID } from "node:crypto";
26
- import { spawnSync } from "node:child_process";
27
26
  import { freemem, totalmem, cpus, homedir } from "node:os";
28
27
  import { createServer } from "node:http";
29
28
  import { Worker } from "node:worker_threads";
@@ -34,7 +33,7 @@ import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
34
33
  import { fleetLabel } from "./fleet-label.js";
35
34
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
36
35
  import { formatUpdateProgress } from "./update-progress.js";
37
- import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
36
+ import { sdNotify, sdNotifyBlocking, setSdNotifyWarn } from "./sd-notify.js";
38
37
  import { readFleetMemory } from "./process-memory.js";
39
38
  import { MemoryPressure } from "./memory-pressure.js";
40
39
  import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
@@ -46,7 +45,7 @@ const __dirname = dirname(__filename);
46
45
  const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
47
46
  import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS, TELEGRAM_REACTION_EMOJIS, builtinStatusEmojis, customEmojiValue, emojiImageUrl, normalizeEmoji, previewStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusEmojiProblem, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
48
47
  import { isProbeableRouteTarget } from "./fleet-context.js";
49
- import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
48
+ import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
50
49
  import { EventLog } from "./event-log.js";
51
50
  import { binaryProbe } from "./binary-probe.js";
52
51
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
@@ -64,7 +63,9 @@ import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
64
63
  import { TelegramAdapter } from "./channel/adapters/telegram.js";
65
64
  import { createBackend } from "./backend/factory.js";
66
65
  import { readEffortMetadata } from "./backend/effort-metadata.js";
67
- import { CLI_ENV_TTL_MS, isModelCompatible, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
66
+ import { codexEffort, flagEffort } from "./backend/launch-effort.js";
67
+ import { codexMetadataHomes, readCodexModelEffortLevels, resolveCodexLaunchModel } from "./backend/codex-metadata.js";
68
+ import { CLI_ENV_TTL_MS, isModelCompatible, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
68
69
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
69
70
  import { processAttachments } from "./channel/attachment-handler.js";
70
71
  import { routeToolCall } from "./channel/tool-router.js";
@@ -80,7 +81,8 @@ import { RoutingEngine } from "./routing-engine.js";
80
81
  import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
81
82
  import { TopicArchiver } from "./topic-archiver.js";
82
83
  import { StatuslineWatcher } from "./statusline-watcher.js";
83
- import { outboundHandlers } from "./outbound-handlers.js";
84
+ import { applyInstanceConfigPatch, outboundHandlers } from "./outbound-handlers.js";
85
+ import { backendArgs, backendStep, joinBackendArgs, modelNameCarried, planDisplay } from "./backend-command.js";
84
86
  import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
85
87
  // The target Daemon can legitimately wait 30 minutes for a busy pane before
86
88
  // deciding to defer before submission. This is an alert threshold only: the
@@ -137,12 +139,31 @@ import { LogService } from "./log-service.js";
137
139
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
138
140
  import { WebChatDiskStore } from "./web-chat-store.js";
139
141
  import { SeenUsers, resolveMentions } from "./web-mentions.js";
140
- import { WebPrefsStore } from "./web-prefs.js";
142
+ import { OPERATOR, WebPrefsStore } from "./web-prefs.js";
143
+ import { agendVersion } from "./agent-whats-new.js";
144
+ import { OwnerCssStore } from "./web-owner-css-store.js";
145
+ import { ThemeStore } from "./web-theme-store.js";
146
+ import { WebAppearance } from "./web-appearance.js";
147
+ import { WebEffectLibrary } from "./web-effect-library.js";
148
+ import { WebEffectTrials } from "./web-effect-trials.js";
149
+ import { WebEffectService } from "./web-effect-service.js";
150
+ import { WebEffectProfiles } from "./web-effect-profiles.js";
151
+ import { WebEffectTools, WEB_EFFECT_TOOLS } from "./web-effect-tools.js";
152
+ import { resolveEffectParams } from "./web-effect.js";
153
+ import { cw1EffectChecker } from "./web-mod-bundle.js";
154
+ import { effectiveAppearanceLimits } from "./web-appearance-limits.js";
155
+ import { WEB_NAME_FALLBACK, safeWebName, validateWebName, webNameInUse, webPersonKey } from "./web-profile.js";
141
156
  import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
142
157
  import { ReplyButtonsController } from "./reply-buttons-controller.js";
143
- import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
158
+ import { parsePoll } from "./reply-polls.js";
159
+ import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
144
160
  import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
145
- import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
161
+ import { cardPicture, handleViewRequest, humanCards, identityColors, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
162
+ import { autoIdentityColor } from "./web-identity-color.js";
163
+ import { AppearanceTools } from "./web-appearance-tools.js";
164
+ import { cardVersion, resolveCard } from "./web-card.js";
165
+ import { ModStore } from "./web-mod-store.js";
166
+ import { AgentMods } from "./web-mod-agent.js";
146
167
  import { downloadStickerImage } from "./channel/sticker-download.js";
147
168
  import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
148
169
  import { accentColor, avatarFor, ownBotConnections } from "./instance-avatar.js";
@@ -153,13 +174,14 @@ import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permis
153
174
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
154
175
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
155
176
  import { LoginSession } from "./login-manager.js";
177
+ import { runOnLoginShell } from "./login-shell.js";
156
178
  import { tightenInstanceDirs } from "./private-dir.js";
157
179
  import { decideSlash } from "./slash-authz.js";
158
180
  import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
159
181
  import { runVisibilityCommand } from "./cross-instance-notice.js";
160
- import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
182
+ import { installedChannel, updateNoticeKey } from "./update-check.js";
161
183
  import { resolveInstalledAgend } from "./update-dispatch.js";
162
- import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
184
+ import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
163
185
  import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
164
186
  import { runBeforeDeadline } from "./deadline.js";
165
187
  import { LoginWindowLock } from "./login-window-lock.js";
@@ -170,10 +192,10 @@ import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet
170
192
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
171
193
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
172
194
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
173
- import { validateFleetConfig } from "./config-validator.js";
195
+ import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
174
196
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
175
197
  import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
176
- import { readLastInboundAt } from "./daemon.js";
198
+ import { DEFAULT_STUCK_TIMEOUT_MS, readLastInboundAt } from "./daemon.js";
177
199
  import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
178
200
  import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
179
201
  import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
@@ -184,11 +206,14 @@ import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
184
206
  import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
185
207
  import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
186
208
  import { NeedsYouHub } from "./needs-you-hub.js";
187
- import { WEB_CHAT_NOTICE, WEB_REMOTE_DOCS_URL, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
209
+ import { WEB_CHAT_NOTICE, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
188
210
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
189
211
  import { buildOrgChart } from "./web-org.js";
212
+ import { GENERAL_POINTING_REFUSALS, SNOWFLAKE, hasPlaceRefs, placeRef, webRoute, webTextLinks, placeSuffix, renderPlaceRefs, stripPlaceRefs, topicUrl, worldPlaceOf } from "./topic-link.js";
190
213
  import { CacheService, WINDOWS } from "./cache-service.js";
191
214
  import { runWebCommand } from "./web-commands.js";
215
+ import { CompactWatch, launchIdentity } from "./compact-watch.js";
216
+ import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
192
217
  import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
193
218
  import { sharedRolloutIndex } from "./rollout-index.js";
194
219
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
@@ -353,6 +378,7 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
353
378
  const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
354
379
  const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
355
380
  const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
381
+ const BACKEND_SELECT_CALLBACK_PREFIX = "backend-select:";
356
382
  const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
357
383
  const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
358
384
  /** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
@@ -376,6 +402,7 @@ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
376
402
  const WEB_ONLY_REPLY_SINK = {
377
403
  type: "web",
378
404
  supportsReplyButtons: true, // #1266: the web chat shows a reply's buttons itself
405
+ supportsPolls: true, // #1823: …and a poll (its pages are told; there is nothing to edit)
379
406
  sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
380
407
  sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
381
408
  };
@@ -672,6 +699,8 @@ export class FleetManager {
672
699
  /** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
673
700
  pendingEffortSelects = new Map();
674
701
  pendingModelSelects = new Map();
702
+ /** #1806: in-flight /backend steps, keyed by nonce (see handleBackendSelection). */
703
+ pendingBackendSelects = new Map();
675
704
  /** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
676
705
  pendingNonceButtons = new Map();
677
706
  /** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
@@ -753,7 +782,6 @@ export class FleetManager {
753
782
  updateProgressTimer = null;
754
783
  updateProgressEditRunning = false;
755
784
  lastUpdateProgressText = null;
756
- updateCompletionTipText = null;
757
785
  /**
758
786
  * #1660: an adopted update whose retire outcome is still owed. The
759
787
  * completion summary was (or will be) posted; this watch appends the
@@ -792,7 +820,8 @@ export class FleetManager {
792
820
  /** #1565: the web chat history's files (set at fleet start, once the home and the instances are known). */
793
821
  webChatStore = null;
794
822
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
795
- webFiles = new WebFileLedger();
823
+ // #1937: the upload limits are the config's, read on each use (agend reload applies a change to the next upload).
824
+ webFiles = new WebFileLedger({ limits: () => uploadLimits(this.fleetConfig?.web) });
796
825
  /** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
797
826
  webReplyOrder = new Map();
798
827
  /** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
@@ -801,6 +830,255 @@ export class FleetManager {
801
830
  seenUsers = new SeenUsers();
802
831
  webPrefsStore;
803
832
  /** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
833
+ /**
834
+ * #1827: a web person as agents and chats see them. Signed in as an account (a sign-in code DMed to it): that
835
+ * account's user id — still `via web`, and only for recognition — and the name they chose, else their account's name
836
+ * at sign-in (made safe). Otherwise (an `agend web` session): "web-user", as before, under a name the operator may set.
837
+ * Names are checked again now: one an instance or bot has taken since reads as the next choice down.
838
+ */
839
+ webPerson(owner) {
840
+ const key = webPersonKey(owner);
841
+ let profile = null;
842
+ try {
843
+ profile = this.webPrefs.profile(key);
844
+ }
845
+ catch (err) {
846
+ this.logger.warn({ err: err.message }, "Web profile unavailable");
847
+ }
848
+ const reserved = (n) => this.webNameReserved(n);
849
+ const own = profile?.name !== undefined ? webNameInUse(profile.name, reserved) : WEB_NAME_FALLBACK;
850
+ const chosen = own !== WEB_NAME_FALLBACK;
851
+ const platformName = owner ? safeWebName(profile?.platformName, reserved) : undefined;
852
+ return {
853
+ key, bound: !!owner,
854
+ userId: owner ? owner.userId : WEB_NAME_FALLBACK,
855
+ // A poll ballot is the account's — the same key its votes on the platform have — or the operator's.
856
+ voterKey: owner ? `${owner.adapterId}:${owner.userId}` : "web:operator",
857
+ name: chosen ? own : platformName ?? WEB_NAME_FALLBACK,
858
+ set: chosen,
859
+ ...(platformName !== undefined ? { platformName } : {}),
860
+ };
861
+ }
862
+ /** #1827: set (null: clear — back to their account's name) a web person's own name; validated in full. */
863
+ setWebPersonName(owner, raw) {
864
+ let name = null;
865
+ if (raw !== null) {
866
+ const v = validateWebName(raw, (n) => this.webNameReserved(n));
867
+ if (!v.ok)
868
+ return { ok: false, status: 400, error: v.error };
869
+ name = v.name;
870
+ }
871
+ const w = this.webPrefs.setProfileName(webPersonKey(owner), name);
872
+ return w.ok ? { ok: true, person: this.webPerson(owner) } : { ok: false, status: w.status, error: w.error };
873
+ }
874
+ /** #1827 (auth-api): a sign-in as this account — its display name then is the web name it starts with. */
875
+ noteWebSignIn(account) {
876
+ if (account.username)
877
+ this.webPrefs.setPlatformName(webPersonKey(account), account.username);
878
+ }
879
+ /**
880
+ * #1827: a name that belongs to something here — an instance (or ClassicBot room), its display name, a bot. On the web
881
+ * send path: it never throws (a lookup that fails reserves nothing; the name was checked in full when it was set).
882
+ */
883
+ webNameReserved(lowered) {
884
+ try {
885
+ return this.webNameTaken(lowered);
886
+ }
887
+ catch {
888
+ return false;
889
+ }
890
+ }
891
+ webNameTaken(lowered) {
892
+ const low = (s) => (typeof s === "string" ? s.normalize("NFC").toLocaleLowerCase("en-US") : "");
893
+ for (const [name, cfg] of Object.entries(this.fleetConfig?.instances ?? {})) {
894
+ if (low(name) === lowered || low(cfg.display_name) === lowered)
895
+ return true;
896
+ }
897
+ for (const ch of this.classicChannels?.getAll() ?? [])
898
+ if (low(ch.instanceName) === lowered || low(ch.displayName) === lowered)
899
+ return true;
900
+ for (const w of this.worlds.values())
901
+ if (w.botUsername && low(w.botUsername) === lowered)
902
+ return true;
903
+ return false;
904
+ }
905
+ themeStoreInstance;
906
+ webAppearanceService;
907
+ /** #1629: every instance's theme, placements, assets and lock (web-themes.json). */
908
+ get themeStore() {
909
+ // r4.1 Q3 (b): the card's limits are read from fleet.yaml at each upload (a change applies without a restart).
910
+ return (this.themeStoreInstance ??= new ThemeStore(this.dataDir, { limits: () => effectiveAppearanceLimits(this.fleetConfig?.web?.appearance).limits, effectsDropped: ids => this.effectLibrary.collectUnreferenced(ids) }));
911
+ }
912
+ /** #1629: the theme tools, a person's reset and lock, and the one appearance rate limiter (shared with effects). */
913
+ get webAppearance() {
914
+ return (this.webAppearanceService ??= new WebAppearance(this.themeStore, () => this.fleetConfig?.web ?? null, () => { try {
915
+ this.emitSseEvent("status", this.getUiStatus());
916
+ }
917
+ catch { /* the next frame carries the rev */ } }));
918
+ }
919
+ effectLibraryInstance;
920
+ effectTrialsInstance;
921
+ /** Lazy, single-owner storage. Register every host reference source before any operation can collect. */
922
+ get effectLibrary() {
923
+ if (!this.effectLibraryInstance) {
924
+ const library = new WebEffectLibrary(this.dataDir);
925
+ // Providers are lazy; publish only after every registration succeeds.
926
+ library.registerReferenceSource("themes", () => {
927
+ if (!this.themeStore.readable())
928
+ throw new Error("themes_unreadable");
929
+ return this.themeStore.placedEffects().flatMap(row => row.ids.map(id => ({ id, holder: `instance:${row.name}` })));
930
+ });
931
+ library.registerReferenceSource("prefs", () => this.webPrefs.ownerEffectReferences());
932
+ library.registerReferenceSource("mods", () => {
933
+ const mods = this.mods.list();
934
+ if (mods.unreadable)
935
+ throw new Error("mods_unreadable");
936
+ return mods.mods.flatMap(mod => mod.effects.map(effect => ({ id: effect.id, holder: `mod:${mod.id}` })));
937
+ });
938
+ this.effectTrialsInstance = new WebEffectTrials({ library, known: name => this.isKnownInstance(name) });
939
+ this.effectLibraryInstance = library;
940
+ }
941
+ return this.effectLibraryInstance;
942
+ }
943
+ /** Volatile Try offers and session-bound runs share this library. */
944
+ get effectTrials() { if (!this.effectTrialsInstance)
945
+ void this.effectLibrary; return this.effectTrialsInstance; }
946
+ effectProfilesInstance;
947
+ get effectProfiles() { return (this.effectProfilesInstance ??= new WebEffectProfiles(this.dataDir)); }
948
+ effectServiceInstance;
949
+ get effectService() {
950
+ return (this.effectServiceInstance ??= new WebEffectService({ library: this.effectLibrary, themes: this.themeStore, profiles: this.effectProfiles, prefs: this.webPrefs, trials: this.effectTrials,
951
+ known: name => this.isKnownInstance(name), changed: () => { try {
952
+ this.emitSseEvent("status", this.getUiStatus());
953
+ }
954
+ catch { /* next status carries revision */ } } }));
955
+ }
956
+ effectToolsService;
957
+ /** Caller identity is supplied only by the owned IPC connection / verified agent endpoint token. */
958
+ get effectTools() {
959
+ return (this.effectToolsService ??= new WebEffectTools({
960
+ library: this.effectLibrary, limiter: this.webAppearance.limiter,
961
+ authorize: (name, buckets) => !this.isKnownInstance(name) ? { error: "unknown_instance" } : this.webAppearance.authorizeEffect(name, buckets),
962
+ placed: name => this.themeStore.get(name).placements.effects,
963
+ place: (name, effects) => {
964
+ const before = this.themeStore.get(name).placements.effects;
965
+ const explicit = new Set(effects.filter(e => typeof e !== "string").map(e => e.id));
966
+ const w = this.themeStore.setPlacements(name, { effects }, "agent", what => {
967
+ // An unchanged human/private placement may be retained, never exposed or newly acquired by an agent.
968
+ const r = explicit.has(what.id) ? this.effectLibrary.read(what.id, { kind: "agent", id: name })
969
+ : this.effectLibrary.readReferenced(what.id, `instance:${name}`);
970
+ if (!r.ok)
971
+ return r.error;
972
+ const params = explicit.has(what.id) ? what.params : before.find(e => e.id === what.id)?.params;
973
+ return resolveEffectParams(r.entry.manifest, params ?? {}).ok ? null : "effect_params";
974
+ });
975
+ return w.ok ? { ok: true, rev: w.rev } : { ok: false, error: w.error };
976
+ },
977
+ changed: () => { try {
978
+ this.emitSseEvent("status", this.getUiStatus());
979
+ }
980
+ catch { /* later status carries placements */ } },
981
+ // A nonmutating own/shared offer is not an appearance write. Only an explicit local-admin click may start it.
982
+ preview: async (instance, id) => {
983
+ const result = this.effectTrials.offer(instance, id);
984
+ if (!result.ok)
985
+ return { error: "not_found" };
986
+ const present = this.webChatHistory.list(instance).some(row => row.effectTrial?.offer === result.offer.offer);
987
+ if (!present && !this.publishEffectTrial(instance, result.offer))
988
+ return { error: "trial_unavailable" };
989
+ return { id, status: "offered", expiresAt: result.offer.expiresAt, message: "A trial card is in your web chat. A signed-in local admin can start it; nothing was applied." };
990
+ },
991
+ profiles: id => this.effectProfiles.read(id),
992
+ profileSummary: id => this.effectService.profileSummary(id),
993
+ profileSummaries: ids => this.effectService.profileSummaries(ids),
994
+ }));
995
+ }
996
+ appearanceToolsService;
997
+ /** #1629 r4 §1: get_web_appearance / preview_web_appearance — the caller's own look, resolved as the page resolves it. */
998
+ get appearanceTools() {
999
+ return (this.appearanceToolsService ??= new AppearanceTools({
1000
+ frame: (name) => (this.getUiStatus().instances.find(i => i.name === name) ?? { name }),
1001
+ theme: (name) => { const r = this.themeStore.get(name); return { theme: r.theme, locked: r.locked, widgets: r.placements.widgets }; },
1002
+ card: (name) => resolveCard(name, humanCards(this.dataDir).get(name), this.themeStore.get(name).theme.card),
1003
+ // #1958: the host's identity-colour settings, as the page reads them (an unreadable prefs file: the defaults).
1004
+ colorPrefs: () => { try {
1005
+ const v = this.webPrefs.read(OPERATOR).values;
1006
+ return { show: v["color.show"], off: v["color.off"] === true };
1007
+ }
1008
+ catch {
1009
+ return {};
1010
+ } },
1011
+ cardPicture: (name, slot) => cardPicture(this, name, slot),
1012
+ themeAsset: (name, id) => this.themeStore.asset(name, id),
1013
+ profileAvatar: (name) => this.webImage("avatar", name),
1014
+ botAvatar: (_name, key) => this.webImage("bot", key),
1015
+ themeAnswer: (name) => this.webAppearance.getTheme(name),
1016
+ }));
1017
+ }
1018
+ /** #1958: a profile, card or colour write (view-api.ts) landed: every open page gets a fresh status frame. */
1019
+ identityChanged() {
1020
+ try {
1021
+ this.emitSseEvent("status", this.getUiStatus());
1022
+ }
1023
+ catch { /* the next frame carries it */ }
1024
+ }
1025
+ /** A fleet.yaml instance (own key) or a ClassicBot room, now (#1629: a person's reset/lock names one). */
1026
+ isKnownInstance(name) {
1027
+ return Object.hasOwn(this.fleetConfig?.instances ?? {}, name) || (this.classicChannels?.getAll() ?? []).some((ch) => ch.instanceName === name);
1028
+ }
1029
+ modStore;
1030
+ /**
1031
+ * #1629 r4-5 / B2: installed mods use the same validated library and scoped holder claims as direct uploads.
1032
+ */
1033
+ get mods() {
1034
+ return (this.modStore ??= new ModStore(this.dataDir, {
1035
+ themes: this.themeStore,
1036
+ prefs: this.webPrefs,
1037
+ ownerCss: this.ownerCss,
1038
+ effects: this.effectLibrary,
1039
+ context: () => ({ agendVersion: agendVersion(), limits: this.themeStore.limits(), effects: cw1EffectChecker() }),
1040
+ isKnownInstance: (name) => this.isKnownInstance(name),
1041
+ prefsIdentity: OPERATOR,
1042
+ }));
1043
+ }
1044
+ agentModsService;
1045
+ /** #1629 r4-5b: export_web_mod / propose_web_mod — the caller's own look as a file, and a proposal for a person. */
1046
+ get agentMods() {
1047
+ return (this.agentModsService ??= new AgentMods({
1048
+ themes: this.themeStore, mods: this.mods, limiter: this.webAppearance.limiter,
1049
+ webConfig: () => this.fleetConfig?.web ?? null,
1050
+ // The mod store's own: a proposal is checked exactly as the person's import of it will be.
1051
+ context: () => this.mods.checkContext(),
1052
+ readOwnEffect: (instance, id) => { const r = this.effectLibrary.read(id, { kind: "agent", id: instance }); return r.ok ? { name: r.entry.manifest.name, files: r.entry.files } : null; },
1053
+ notice: (instance, text) => { try {
1054
+ this.emitSseEvent("message", { instance, sender: instance, role: "status", text, ts: new Date().toISOString() });
1055
+ }
1056
+ catch { /* the page reads it from Settings too */ } },
1057
+ changed: () => { try {
1058
+ this.emitSseEvent("status", this.getUiStatus());
1059
+ }
1060
+ catch { /* the next frame */ } },
1061
+ }));
1062
+ }
1063
+ /**
1064
+ * #1629 r4-5c: an effect as this person may carry it away in an export — their own claim, or one a person shared —
1065
+ * read with their principal (Sol's ACL). Being able to view or run a placed effect never grants packaging someone
1066
+ * else's private claim: the library's reference read is runtime access, not an export path. Null: not theirs.
1067
+ */
1068
+ modPlacedEffect(person, id) {
1069
+ const r = this.effectLibrary.read(id, { kind: "human", id: person });
1070
+ return r.ok ? { name: r.entry.manifest.name, files: r.entry.files } : null;
1071
+ }
1072
+ /** #1629 r4-5: an instance's card text as every viewer sees it (a person's fields over the agent's). */
1073
+ modCardText(name) {
1074
+ const card = resolveCard(name, humanCards(this.dataDir).get(name), this.themeStore.get(name).theme.card);
1075
+ return { ...(card.tagline ? { tagline: card.tagline.text } : {}), ...(card.bio ? { bio: card.bio.text } : {}) };
1076
+ }
1077
+ ownerCssStore;
1078
+ /** #1629 r4 §3: each person's own CSS; which version is in force is their `css.active` in the web preferences. */
1079
+ get ownerCss() {
1080
+ return (this.ownerCssStore ??= new OwnerCssStore(this.dataDir, this.webPrefs));
1081
+ }
804
1082
  get webPrefs() {
805
1083
  // #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
806
1084
  return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
@@ -1149,6 +1427,8 @@ export class FleetManager {
1149
1427
  mayClick: (set, userId) => this.mayAnswerReplyButtons(set.instance, set.adapterId, userId),
1150
1428
  deliver: (set, button, by) => this.deliverReplyButtonChoice(set, button, by),
1151
1429
  publish: (instance, view) => this.emitSseEvent("reply_buttons", { instance, buttons: view }),
1430
+ admitResults: (set, key, text) => this.admitPollResults(set, key, text),
1431
+ monotonic: () => performance.now(),
1152
1432
  logger: this.logger,
1153
1433
  setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
1154
1434
  clearTimer: (h) => clearTimeout(h),
@@ -1157,6 +1437,21 @@ export class FleetManager {
1157
1437
  return this.replyButtonsCtl;
1158
1438
  }
1159
1439
  replyButtonsStore = null;
1440
+ /**
1441
+ * #1823: a closed poll's results go to its instance through the delivery outbox, as a system notice keyed by the
1442
+ * poll: durable (held until the instance can take it) and admitted once, however often this is asked. Addressed to
1443
+ * the poll's chat, so the instance's answer (the right option, the scores) goes there.
1444
+ */
1445
+ admitPollResults(set, key, text) {
1446
+ this.ensureDeliveryOutbox();
1447
+ const outbox = this.deliveryOutbox;
1448
+ if (!outbox)
1449
+ return false;
1450
+ outbox.admitSystemNotice(set.instance, key, text, new Date(), {
1451
+ chatId: set.chatId, threadId: set.threadId || undefined, adapterId: set.adapterId === "web" ? undefined : set.adapterId,
1452
+ });
1453
+ return true;
1454
+ }
1160
1455
  /**
1161
1456
  * #1266: who may answer a reply's buttons on a platform — whoever may message that instance there: in a ClassicBot
1162
1457
  * room anyone there (as a typed message), elsewhere someone the sending connection lets speak (#754/#1148), never a
@@ -1220,8 +1515,10 @@ export class FleetManager {
1220
1515
  return false;
1221
1516
  reactions.consume();
1222
1517
  this.afterUserInboundDelivered(set.instance, inbound, deliveryEpoch);
1518
+ // The web chat shows the click as its button's label (a chip); `text` stays what the agent was given.
1223
1519
  this.emitSseEvent("message", {
1224
1520
  instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
1521
+ click: { label: button.label, of: set.id },
1225
1522
  });
1226
1523
  return true;
1227
1524
  }
@@ -1564,6 +1861,7 @@ export class FleetManager {
1564
1861
  content: payload.content,
1565
1862
  delivery_id: claimed.deliveryId,
1566
1863
  delivery_attempt: String(claimed.attemptNo),
1864
+ ...(isSilentScheduleDelivery(claimed) ? { silent_schedule: true } : {}),
1567
1865
  }, { waitForIdle: false, noInlineWake: this.deliveryWorkerMode(target) !== "off" });
1568
1866
  }
1569
1867
  else {
@@ -2203,7 +2501,7 @@ export class FleetManager {
2203
2501
  async handleUpdateSlash(data, adapterId) {
2204
2502
  const gate = this.fleetAdminGate(data.userId, adapterId);
2205
2503
  if (gate !== "ok") {
2206
- await data.respond(t(gate === "disabled" ? "update.disabled" : "not_authorized"));
2504
+ await data.respond(gate === "disabled" ? t("update.disabled", this.adminListKey(adapterId)) : t("not_authorized"));
2207
2505
  return;
2208
2506
  }
2209
2507
  const messageId = await data.respond(t("update.progress.preparing", 0));
@@ -2218,24 +2516,29 @@ export class FleetManager {
2218
2516
  // "not a beta" and sent a beta install to @latest. #1450 C5: the INSTALLED agend, verified, by absolute path.
2219
2517
  const installed = await resolveInstalledAgend();
2220
2518
  if (!installed.ok) {
2221
- this.failUpdateProgress(`/update cannot verify the installed AgEnD (${installed.reason}). Run \`agend update\` from a shell.`);
2519
+ this.failUpdateProgress(`${t("update.failure.shell")} ${t("update.failure.installed", installed.reason)}`);
2222
2520
  return;
2223
2521
  }
2224
2522
  const origin = `slash /update by ${adapterId}:${data.userId}`;
2225
2523
  recordInternalRequest(this.dataDir, "update", origin);
2226
2524
  const launch = await resolveUpdateLaunch(installed.agend);
2227
2525
  if (!launch.ok) {
2228
- this.failUpdateProgress(`/update cannot prepare an independent updater (${launch.reason}). Run ` + "`agend update` from a host shell.");
2526
+ this.failUpdateProgress(`${t("update.failure.shell")} ${t("update.failure.independent", launch.reason)}`);
2229
2527
  return;
2230
2528
  }
2231
2529
  if (this.fleetAdminGate(data.userId, adapterId) !== "ok" || this.shuttingDown) {
2232
2530
  this.failUpdateProgress(t("not_authorized"));
2233
2531
  return;
2234
2532
  }
2533
+ const logPath = updateLogPath(this.dataDir);
2534
+ const ts = new Date().toISOString();
2535
+ const logFd = openUpdateLog(logPath, `--- /update ${ts} ---`);
2235
2536
  const child = spawn(launch.command, launch.args, {
2236
- detached: true, stdio: "ignore", env: withOrigin(origin),
2537
+ detached: true, stdio: ["ignore", logFd ? logFd.fd : "ignore", logFd ? logFd.fd : "ignore"], env: withOrigin(origin),
2237
2538
  });
2238
- watchUpdateLaunch(child, message => this.failUpdateProgress(message));
2539
+ if (logFd)
2540
+ closeSync(logFd.fd); // parent closes its copy; the child's fd stays open
2541
+ watchUpdateLaunch(child, message => this.failUpdateProgress(message), logFd ? logPath : undefined, logFd?.offset, installed.agend);
2239
2542
  child.unref();
2240
2543
  }
2241
2544
  async handleProfileSlash(data, adapterId) {
@@ -2708,6 +3011,32 @@ export class FleetManager {
2708
3011
  emit: (event, data) => this.emitSseEvent(event, data),
2709
3012
  });
2710
3013
  }
3014
+ /** Board 003648ac: a web /compact followed to its end, from the transcript the Log reads (none: nothing to follow). */
3015
+ compactWatchService = null;
3016
+ get compactWatch() {
3017
+ return this.compactWatchService ??= new CompactWatch({
3018
+ source: (name) => { const src = this.logSource(name); return { path: src.path, format: src.format }; },
3019
+ // The launch that runs it now (the same value the daemon sends with raw_paste_written).
3020
+ owner: (name) => {
3021
+ const daemon = this.lifecycle.daemons.get(name);
3022
+ try {
3023
+ return daemon ? launchIdentity(daemon.getInteractionSnapshot().owner) : null;
3024
+ }
3025
+ catch {
3026
+ return null;
3027
+ }
3028
+ },
3029
+ paneFollow: (name) => {
3030
+ try {
3031
+ return this.lifecycle.daemons.get(name)?.compactPaneUi() != null;
3032
+ }
3033
+ catch {
3034
+ return false;
3035
+ }
3036
+ },
3037
+ changed: () => this.emitSseEvent("status", this.getUiStatus()),
3038
+ });
3039
+ }
2711
3040
  /** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
2712
3041
  logSteps(name, q) { return this.log.steps(name, q); }
2713
3042
  /**
@@ -3117,7 +3446,8 @@ export class FleetManager {
3117
3446
  * or a rebound adapter is never posted through a stale route. A copy
3118
3447
  * already in flight may still land late — the contract permits that.
3119
3448
  */
3120
- async sendClassicWebEcho(instance, text) {
3449
+ /** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
3450
+ async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
3121
3451
  const guard = this.webChannelEchoGuards.get(instance);
3122
3452
  const handled = new Set();
3123
3453
  let posted = 0;
@@ -3148,7 +3478,7 @@ export class FleetManager {
3148
3478
  if (!adapter?.sendText)
3149
3479
  continue;
3150
3480
  await adapter.sendText(entry.channelId, text, { format: "text", allowedMentions: { parse: [] } });
3151
- ClassicChannelManager.logMessage(instance, "web-user", text, new Date());
3481
+ ClassicChannelManager.logMessage(instance, sender, text, new Date());
3152
3482
  posted++;
3153
3483
  }
3154
3484
  catch (err) {
@@ -3475,6 +3805,20 @@ export class FleetManager {
3475
3805
  hasFleetAdmins(adapterId) {
3476
3806
  return (this.adminListOf(adapterId)?.length ?? 0) > 0;
3477
3807
  }
3808
+ /**
3809
+ * Where this adapter's fleet-admin list is in fleet.yaml, as the operator wrote it, for the "disabled" replies:
3810
+ * `channel.access.allowed_users` (the single-channel form), else the `channels:` entry — by its `id:`, or its
3811
+ * `type:` when it has none. Read from the raw file: loading turns `channel:` into `channels` and fills in ids.
3812
+ */
3813
+ adminListKey(adapterId) {
3814
+ const raw = this.rawFleetConfig;
3815
+ if (!Array.isArray(raw.channels) && raw.channel)
3816
+ return "channel.access.allowed_users";
3817
+ const channels = Array.isArray(raw.channels) ? raw.channels : this.fleetConfig?.channels ?? [];
3818
+ const id = adapterId ?? this.getPrimaryAdapterId();
3819
+ const entry = channels.find(ch => (ch.id ?? ch.type) === id);
3820
+ return `channels[${entry?.id ? `id: ${entry.id}` : `type: ${entry?.type ?? id}`}].access.allowed_users`;
3821
+ }
3478
3822
  /**
3479
3823
  * The one question the privileged commands (/update, /doctor, /dashboard, /collab) ask: may THIS caller,
3480
3824
  * through THIS adapter, run it. `disabled` is "nobody can" — an empty list — and is told apart from
@@ -3552,7 +3896,14 @@ export class FleetManager {
3552
3896
  if (!decision.allow) {
3553
3897
  this.logger.info({ command: data.command, adapterId, scope, reply: decision.reply[0] }, "Slash command refused by the command table");
3554
3898
  const [key, ...args] = decision.reply;
3555
- await data.respond(t(key, ...args)).catch(() => { });
3899
+ // A command off for want of fleet admins names where they go (`{0}` in its disabled reply).
3900
+ if (decision.reply === spec.disabled)
3901
+ args.push(this.adminListKey(adapterId));
3902
+ // #1990: a refusal that sends the person to General links to it — not in a ClassicBot room, whose readers need
3903
+ // not be in the fleet's guild.
3904
+ const toGeneral = GENERAL_POINTING_REFUSALS.has(key) && scope !== "classic" && scope !== "none";
3905
+ const reply = toGeneral ? this.renderPlaces(adapterId, t(key, ...args) + placeSuffix({ kind: "general" }, "General")).text : t(key, ...args);
3906
+ await data.respond(reply).catch(() => { });
3556
3907
  return;
3557
3908
  }
3558
3909
  if (data.command === "start") {
@@ -3611,6 +3962,9 @@ export class FleetManager {
3611
3962
  else if (data.command === "model") {
3612
3963
  await this.handleModelSlash(data, adapterId);
3613
3964
  }
3965
+ else if (data.command === "backend") {
3966
+ await this.handleBackendSlash(data, adapterId);
3967
+ }
3614
3968
  else if (data.command === "clear") {
3615
3969
  await this.handleClearSlash(data, adapterId);
3616
3970
  }
@@ -3704,7 +4058,7 @@ export class FleetManager {
3704
4058
  await data.respond(result.durable ? t("dashboard.revoked", result.count) : t("dashboard.revoked_not_durable", result.count));
3705
4059
  }
3706
4060
  else {
3707
- const owner = this.dashboardOwner(data.userId, adapterId, data.channelId);
4061
+ const owner = this.dashboardOwner(data.userId, adapterId, data.channelId, undefined, data.username);
3708
4062
  if (!owner) {
3709
4063
  await this.sendLocalDashboard(data, adapterId);
3710
4064
  return;
@@ -5026,7 +5380,7 @@ export class FleetManager {
5026
5380
  && cfg.topic_id?.toString() === owner.threadId);
5027
5381
  }
5028
5382
  /** Exact owning General. No primary-adapter or name fallback participates in authorization. */
5029
- dashboardOwner(userId, adapterId, address, threadId) {
5383
+ dashboardOwner(userId, adapterId, address, threadId, username) {
5030
5384
  const world = this.worlds.get(adapterId);
5031
5385
  if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
5032
5386
  return null;
@@ -5041,7 +5395,7 @@ export class FleetManager {
5041
5395
  const matched = dc ? topic === address || (group === address && topic === threadId)
5042
5396
  : group === address && ((topic === "1" && (threadId === undefined || threadId === "1")) || topic === threadId);
5043
5397
  if (group && matched)
5044
- return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter };
5398
+ return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter, ...(username ? { username } : {}) };
5045
5399
  }
5046
5400
  return null;
5047
5401
  }
@@ -5050,7 +5404,7 @@ export class FleetManager {
5050
5404
  * — this bot's configured group and its one General topic. No address to match in a DM, so it must be unambiguous:
5051
5405
  * no General, or more than one on this bot and group, and there is no owner (nothing is guessed).
5052
5406
  */
5053
- dmDashboardOwner(userId, adapterId) {
5407
+ dmDashboardOwner(userId, adapterId, username) {
5054
5408
  const world = this.worlds.get(adapterId);
5055
5409
  if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
5056
5410
  return null;
@@ -5064,7 +5418,7 @@ export class FleetManager {
5064
5418
  .map(([, cfg]) => cfg.topic_id.toString());
5065
5419
  if (topics.length !== 1)
5066
5420
  return null;
5067
- const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter };
5421
+ const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter, ...(username ? { username } : {}) };
5068
5422
  return this.publicOwnerCurrent(owner) ? owner : null;
5069
5423
  }
5070
5424
  /**
@@ -5091,7 +5445,7 @@ export class FleetManager {
5091
5445
  async startWebInDm(msg, adapter) {
5092
5446
  if (!adapter)
5093
5447
  return;
5094
- const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "") : null;
5448
+ const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "", msg.username) : null;
5095
5449
  if (!owner) {
5096
5450
  await adapter.sendText(msg.chatId, t("dashboard.start_web_ack"));
5097
5451
  return;
@@ -5144,10 +5498,11 @@ export class FleetManager {
5144
5498
  /** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
5145
5499
  async dashboardMenu(msg) {
5146
5500
  const id = msg.adapterId;
5147
- const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId) : null;
5501
+ const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId, msg.username) : null;
5148
5502
  if (!owner) {
5149
5503
  const adapter = id ? this.adapters.get(id) : undefined;
5150
- await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
5504
+ const out = this.renderPlaces(id, t("dashboard.public_general") + placeSuffix({ kind: "general" }, "General"), { threadId: msg.threadId });
5505
+ await adapter?.sendText(msg.chatId, out.text, out.opts);
5151
5506
  return;
5152
5507
  }
5153
5508
  if (!await this.showDashboardMenu(owner)) {
@@ -5177,7 +5532,9 @@ export class FleetManager {
5177
5532
  return;
5178
5533
  }
5179
5534
  this.initializeWebSessions();
5180
- const login = this.webLoginCodes.issue({ epoch: tokenEpoch(token) });
5535
+ // #1827: the code goes privately to this account (a DM, or else this slash command's ephemeral answer): whoever
5536
+ // redeems it is recognised as that account. Recognition only — no owner (this is not an owning General's code).
5537
+ const login = this.webLoginCodes.issue({ epoch: tokenEpoch(token), recipient: { adapterId, userId: data.userId, ...(data.username ? { username: data.username } : {}) } });
5181
5538
  const current = () => !this.shuttingDown && this.adapters.get(adapterId) === adapter
5182
5539
  && this.webToken === token && this.hasFleetAdmins(adapterId) && this.isFleetAdmin(data.userId, adapterId);
5183
5540
  const privateText = `${text}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
@@ -5289,13 +5646,12 @@ export class FleetManager {
5289
5646
  const adapter = owner.binding;
5290
5647
  const status = this.getPublicWebStatus();
5291
5648
  const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
5292
- const tgHint = adapter.type === "telegram" && dmChatId === undefined ? this.tgStartWebUrl(owner.adapterId) : null;
5293
- const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
5294
- + (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
5295
- + "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
5296
- + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
5297
- + (tgHint ? "\n" + t("dashboard.menu_tg_hint", tgHint) : "");
5298
- const choices = [{ action: "local", label: t("dashboard.local") },
5649
+ // Show the action before any link exists. TG's Start instructions belong to confirmed DM refusal below.
5650
+ const menuText = this.topicCommands.getDashboardMenuText()
5651
+ + (publicAllowed ? "\n" + t("dashboard.public_risk") : "")
5652
+ + (status.state !== "closed" ? "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
5653
+ + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "") : "");
5654
+ const choices = [{ action: "local", label: t("dashboard.local_signin") },
5299
5655
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
5300
5656
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
5301
5657
  const posted = await this.postNonceButtonPrompt({
@@ -5371,9 +5727,11 @@ export class FleetManager {
5371
5727
  return false;
5372
5728
  this.initializeWebSessions();
5373
5729
  const issued = this.webLoginCodes.issue({ epoch: tokenEpoch(token), audience: exposureId, owner });
5374
- // #1586: the expiry in the fleet's zone with its offset, never a bare UTC ISO string.
5375
- const text = t("dashboard.private_link", url, issued.display, Math.round(LOGIN_CODE_TTL_MS / 60_000), expiresAt ? formatFleetTime(expiresAt) : t("dashboard.local"))
5376
- + "\n" + t("dashboard.code_next");
5730
+ // Only a public link has a link expiry; the code's five-minute lifetime applies to either packet.
5731
+ const minutes = Math.round(LOGIN_CODE_TTL_MS / 60_000);
5732
+ const text = expiresAt !== undefined
5733
+ ? t("dashboard.private_public", url, issued.display, minutes, formatFleetTime(expiresAt))
5734
+ : t("dashboard.private_local", url, issued.display, minutes);
5377
5735
  const deadline = performance.now() + 10_000;
5378
5736
  const direct = owner.binding;
5379
5737
  // The route the sign-in message took: the code-only follow-up takes the same one (#1586).
@@ -5562,7 +5920,6 @@ export class FleetManager {
5562
5920
  messageId,
5563
5921
  });
5564
5922
  this.lastUpdateProgressText = null;
5565
- this.updateCompletionTipText = null;
5566
5923
  this.startUpdateProgressMonitor(adapter);
5567
5924
  }
5568
5925
  /** Persist the public response, wait for idle, then start the canonical service restart. */
@@ -5593,7 +5950,6 @@ export class FleetManager {
5593
5950
  return false;
5594
5951
  }
5595
5952
  this.lastUpdateProgressText = null;
5596
- this.updateCompletionTipText = null;
5597
5953
  this.startUpdateProgressMonitor(adapter);
5598
5954
  await this.waitForFullRestartIdleGrace();
5599
5955
  // An update can begin while the idle wait yields. Never launch a second
@@ -5833,17 +6189,8 @@ export class FleetManager {
5833
6189
  const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
5834
6190
  if (!adapter)
5835
6191
  return;
5836
- let text = formatUpdateProgress(marker);
5837
- // No-restart updates complete in the old fleet process, so they never
5838
- // reach RestartProgress.finish(). Append the same optional tip here.
5839
- if (marker.progress.stage === "complete" && this.tipsEnabled()) {
5840
- if (this.updateCompletionTipText === null) {
5841
- const tip = this.pickAvailableTip();
5842
- this.updateCompletionTipText = tip ? this.formatTip(tip) : "";
5843
- }
5844
- if (this.updateCompletionTipText)
5845
- text += `\n\n${this.updateCompletionTipText}`;
5846
- }
6192
+ // #1854: no tip on an update's completion (either path); tips come on their own schedule and from /tips.
6193
+ const text = formatUpdateProgress(marker);
5847
6194
  if (text === this.lastUpdateProgressText)
5848
6195
  return;
5849
6196
  this.updateProgressEditRunning = true;
@@ -5894,6 +6241,7 @@ export class FleetManager {
5894
6241
  this.rotateFleetLogs();
5895
6242
  const fleet = this.loadConfig(configPath);
5896
6243
  this.slimFleetConfigAtStartup();
6244
+ this.logConfigWarningsAtStartup();
5897
6245
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
5898
6246
  // #1737 review: the marker this boot adopts — the startup clear may only ever clear this one.
5899
6247
  const bootUpdateMarker = updateMarkerIdentity(this.dataDir);
@@ -6125,7 +6473,7 @@ export class FleetManager {
6125
6473
  ...DEFAULT_SCHEDULER_CONFIG,
6126
6474
  ...this.fleetConfig?.defaults.scheduler,
6127
6475
  };
6128
- 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));
6476
+ 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);
6129
6477
  this.scheduler.init();
6130
6478
  this.logger.info("Scheduler initialized");
6131
6479
  // Tips share the daily-report clock but remain an independent internal
@@ -6229,6 +6577,7 @@ export class FleetManager {
6229
6577
  // into the same broken state, and a user who has legitimately stopped every
6230
6578
  // instance would get a restart loop. Those conditions surface through /health
6231
6579
  // (which now returns 503) and through the General-topic notifications instead.
6580
+ setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
6232
6581
  this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
6233
6582
  // #1231: a stalled loop is what turns a slash command into "did not respond";
6234
6583
  // put each long stall in the log instead of leaving it to be guessed at.
@@ -6347,12 +6696,6 @@ export class FleetManager {
6347
6696
  pausedNames,
6348
6697
  failedNames,
6349
6698
  backgroundWorkEndedNames,
6350
- tipText: pendingProgressOperation === "update" && this.tipsEnabled()
6351
- ? (() => {
6352
- const tip = this.pickAvailableTip();
6353
- return tip ? this.formatTip(tip) : undefined;
6354
- })()
6355
- : undefined,
6356
6699
  ...(cleanupOutcome && "render" in cleanupOutcome
6357
6700
  ? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
6358
6701
  : {}),
@@ -6403,6 +6746,7 @@ export class FleetManager {
6403
6746
  // Health HTTP endpoint
6404
6747
  this.startHealthServer(fleet.health_port ?? 19280);
6405
6748
  // #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
6749
+ // #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
6406
6750
  void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
6407
6751
  // Daily update check — first check after 1 hour, then every 24 hours
6408
6752
  this.updateCheckTimer = setTimeout(() => {
@@ -7144,6 +7488,16 @@ export class FleetManager {
7144
7488
  });
7145
7489
  }
7146
7490
  }
7491
+ else if (msg.type === "raw_paste_written") {
7492
+ // Board 003648ac: a web /compact's paste reached the pane (or never will): its watch starts, or ends as lost.
7493
+ if (typeof msg.id === "string")
7494
+ this.compactWatchService?.pasted(name, msg.id, msg.outcome === "written", { owner: msg.owner, checkpoint: msg.checkpoint, pane: msg.pane });
7495
+ }
7496
+ else if (msg.type === "compact_pane_result") {
7497
+ // Board 907c7899: kiro's pane said how that /compact ended.
7498
+ if (typeof msg.id === "string")
7499
+ this.compactWatchService?.paneResult(name, msg.id, msg.owner, msg.outcome);
7500
+ }
7147
7501
  else if (msg.type === "runtime_switch_result") {
7148
7502
  // #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
7149
7503
  const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
@@ -7351,6 +7705,48 @@ export class FleetManager {
7351
7705
  this.adapterRestarting.delete(id);
7352
7706
  }
7353
7707
  }
7708
+ /**
7709
+ * #1990: how a reader in world `worldId`'s group reaches a place token's target — undefined (plain text) for another
7710
+ * world's instance, an instance without a fleet topic (a ClassicBot room may be anywhere), or an id no link is built
7711
+ * from. Discord: the channel mention; Telegram: the topic URL (General's topic included).
7712
+ */
7713
+ placeLink(worldId, target) {
7714
+ const world = this.worlds.get(worldId ?? "");
7715
+ if (!world)
7716
+ return undefined;
7717
+ let topic;
7718
+ if (target.kind === "general") {
7719
+ const notice = this.fleetNoticeTarget(world.id);
7720
+ if (!notice || notice.chatId !== world.groupId || notice.opts.threadId === undefined)
7721
+ return undefined;
7722
+ topic = String(notice.opts.threadId);
7723
+ }
7724
+ else {
7725
+ if (this.getInstanceAdapterId(target.name) !== world.id)
7726
+ return undefined;
7727
+ const topicId = this.fleetConfig?.instances[target.name]?.topic_id;
7728
+ if (topicId == null)
7729
+ return undefined;
7730
+ topic = String(topicId);
7731
+ }
7732
+ if (world.type === "discord")
7733
+ return SNOWFLAKE.test(topic) && SNOWFLAKE.test(world.groupId) ? { mention: `<#${topic}>` } : undefined;
7734
+ const url = topicUrl(worldPlaceOf(world), topic, { general: target.kind === "general" });
7735
+ return url ? { url } : undefined;
7736
+ }
7737
+ /** #1990: a message for world `worldId`'s group with its place tokens rendered (links as text or Telegram entities). */
7738
+ renderPlaces(worldId, text, opts) {
7739
+ if (!hasPlaceRefs(text))
7740
+ return { text, opts };
7741
+ const type = this.worlds.get(worldId ?? "")?.type;
7742
+ const surface = type === "discord" ? "discord" : type === "telegram" ? "telegram" : "plain";
7743
+ const out = renderPlaceRefs(text, surface, target => this.placeLink(worldId, target));
7744
+ return { text: out.text, opts: out.textLinks.length ? { ...opts, textLinks: out.textLinks } : opts };
7745
+ }
7746
+ /** #1990: renderPlaces for the world that owns `instanceName`'s topic. */
7747
+ renderPlacesFor(instanceName, text, opts) {
7748
+ return this.renderPlaces(this.getInstanceAdapterId(instanceName), text, opts);
7749
+ }
7354
7750
  /** Handle inbound message — transcribe voice if present, then route */
7355
7751
  findGeneralInstance(adapterId) {
7356
7752
  if (!this.fleetConfig)
@@ -7938,6 +8334,21 @@ export class FleetManager {
7938
8334
  }
7939
8335
  return;
7940
8336
  }
8337
+ // #1806: /backend — channel admin, as /model. A ClassicBot agent is not switched by it (it says so).
8338
+ if (text === "/backend" || text.startsWith("/backend ") || text.startsWith("/backend@")) {
8339
+ if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
8340
+ await msgAdapter?.sendText(chatId, t("permission.denied"));
8341
+ return;
8342
+ }
8343
+ const backendInstance = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
8344
+ if (!backendInstance) {
8345
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
8346
+ return;
8347
+ }
8348
+ if (msgAdapter)
8349
+ await this.runBackendCommand(backendInstance, text, msg.userId, chatId, msgAdapter, chatId, undefined, msg.adapterId);
8350
+ return;
8351
+ }
7941
8352
  // Handle /compact command (admin only)
7942
8353
  const classicCompact = parseCompactCommand(text);
7943
8354
  if (classicCompact) {
@@ -8337,7 +8748,7 @@ export class FleetManager {
8337
8748
  if (!claimNotice(path, WEB_CHAT_NOTICE, adapterId))
8338
8749
  continue;
8339
8750
  try {
8340
- await world.adapter.sendText(target.chatId, t("upgrade.web_chat", WEB_REMOTE_DOCS_URL), target.opts);
8751
+ await world.adapter.sendText(target.chatId, t("upgrade.web_chat"), target.opts);
8341
8752
  this.logger.info({ adapterId, notice: WEB_CHAT_NOTICE }, "Announced web chat in General");
8342
8753
  }
8343
8754
  catch (err) {
@@ -8500,6 +8911,17 @@ export class FleetManager {
8500
8911
  respond(null, `reply: ${parsedButtons.error}`);
8501
8912
  return;
8502
8913
  }
8914
+ // #1823: a poll is checked with its buttons. Where it cannot be shown it is refused, never sent as text: text
8915
+ // options would never come back as results.
8916
+ const parsedPoll = parsePoll(args.poll, parsedButtons && "buttons" in parsedButtons ? parsedButtons.buttons : null, args.text);
8917
+ if (parsedPoll && "error" in parsedPoll) {
8918
+ respond(null, `reply: ${parsedPoll.error}`);
8919
+ return;
8920
+ }
8921
+ if (parsedPoll && (!outAdapter.supportsPolls || !this.replyButtons())) {
8922
+ respond(null, `reply: a poll cannot be shown here${outAdapter.supportsPolls ? " (reply buttons are unavailable until AgEnD restarts)" : " — polls work in Discord and Telegram chats and the web chat"}; send the question without poll`);
8923
+ return;
8924
+ }
8503
8925
  // Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
8504
8926
  const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
8505
8927
  if (stickerProblem) {
@@ -8519,7 +8941,7 @@ export class FleetManager {
8519
8941
  const buttons = outAdapter.supportsReplyButtons ? this.replyButtons() : null;
8520
8942
  if (buttons) {
8521
8943
  const outId = outAdapter === WEB_ONLY_REPLY_SINK ? "web" : outAdapter.id ?? contextAdapterId ?? "";
8522
- replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons);
8944
+ replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons, parsedPoll && "poll" in parsedPoll ? parsedPoll.poll : null);
8523
8945
  }
8524
8946
  else {
8525
8947
  args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
@@ -8539,6 +8961,9 @@ export class FleetManager {
8539
8961
  this.replyButtonsCtl?.discard(replySet.id);
8540
8962
  }
8541
8963
  }
8964
+ // #1823: the agent learns its poll's id (the results name it).
8965
+ if (replySet?.poll && !error && result && typeof result === "object")
8966
+ result = { ...result, poll_id: replySet.id };
8542
8967
  ticket.complete(result, error);
8543
8968
  // Return the platform outcome first. Bookkeeping below must never turn
8544
8969
  // a confirmed Discord/Telegram POST into a tool error if a secondary
@@ -8566,7 +8991,7 @@ export class FleetManager {
8566
8991
  });
8567
8992
  }
8568
8993
  };
8569
- if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks } : {})) {
8994
+ if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks, ...(replySet.poll ? { replyPoll: replySet.poll } : {}) } : {})) {
8570
8995
  return;
8571
8996
  }
8572
8997
  if (replySet)
@@ -8994,6 +9419,19 @@ export class FleetManager {
8994
9419
  }
8995
9420
  dispatchTypedIpc(name, msg) {
8996
9421
  const type = String(msg.type);
9422
+ if (type.startsWith("fleet_") && WEB_EFFECT_TOOLS.has(type.slice(6))) {
9423
+ const ipc = this.instanceIpcClients.get(name), fleetRequestId = msg.fleetRequestId;
9424
+ if (!ipc || typeof fleetRequestId !== "string")
9425
+ return;
9426
+ void this.handleWebThemeHttp(name, type.slice(6), (msg.payload ?? {})).then(result => {
9427
+ if (this.instanceIpcClients.get(name) === ipc)
9428
+ ipc.send({ type: "fleet_web_theme_response", fleetRequestId, result });
9429
+ }, err => {
9430
+ if (this.instanceIpcClients.get(name) === ipc)
9431
+ ipc.send({ type: "fleet_web_theme_response", fleetRequestId, error: err instanceof Error ? err.message : "Effect operation failed" });
9432
+ }).catch(err => this.logger.debug({ err }, "Effect tool response could not be written"));
9433
+ return;
9434
+ }
8997
9435
  if (type.startsWith("fleet_schedule_")) {
8998
9436
  this.handleScheduleCrud(name, msg);
8999
9437
  return;
@@ -9019,6 +9457,36 @@ export class FleetManager {
9019
9457
  this.handlePersonaEmoji(name, msg);
9020
9458
  return;
9021
9459
  }
9460
+ if (type === "fleet_get_web_theme" || type === "fleet_set_web_theme" || type === "fleet_set_web_theme_asset" || type === "fleet_reset_web_theme"
9461
+ || type === "fleet_get_web_appearance" || type === "fleet_preview_web_appearance"
9462
+ || type === "fleet_set_web_widget" || type === "fleet_remove_web_widget"
9463
+ || type === "fleet_export_web_mod" || type === "fleet_propose_web_mod") {
9464
+ this.handleWebTheme(name, msg);
9465
+ return;
9466
+ }
9467
+ }
9468
+ /**
9469
+ * #1629: the caller's own web theme over IPC (the agent endpoint calls the same service). `name` is the instance this
9470
+ * IPC connection belongs to — never anything the message says. A refusal is a result the agent reads (its `error`
9471
+ * and what to do), not a transport error.
9472
+ */
9473
+ handleWebTheme(instanceName, msg) {
9474
+ const fleetRequestId = msg.fleetRequestId;
9475
+ const ipc = this.instanceIpcClients.get(instanceName);
9476
+ if (!ipc)
9477
+ return;
9478
+ const tool = String(msg.type).replace(/^fleet_/, "");
9479
+ try {
9480
+ const args = (msg.payload ?? {});
9481
+ const result = tool === "get_web_appearance" || tool === "preview_web_appearance"
9482
+ ? this.appearanceTools.handle(instanceName, tool, args)
9483
+ : tool === "export_web_mod" || tool === "propose_web_mod" ? this.agentMods.handle(instanceName, tool, args)
9484
+ : this.webAppearance.handleTool(instanceName, tool, args);
9485
+ ipc.send({ type: "fleet_web_theme_response", fleetRequestId, result });
9486
+ }
9487
+ catch (err) {
9488
+ ipc.send({ type: "fleet_web_theme_response", fleetRequestId, error: err.message });
9489
+ }
9022
9490
  }
9023
9491
  /** `list_emojis` / `set_persona_emoji` / `preview_emojis` / `list_stickers` / `preview_stickers` over IPC; the agent endpoint calls the same methods. */
9024
9492
  handlePersonaEmoji(instanceName, msg) {
@@ -9423,6 +9891,19 @@ export class FleetManager {
9423
9891
  async handleSetPersonaEmojiHttp(instance, args) {
9424
9892
  return this.setPersonaEmoji(instance, args);
9425
9893
  }
9894
+ /** #1629: `agend-agent theme …` — the same service as the tools; `instance` is the token's. */
9895
+ async handleWebThemeHttp(instance, tool, args) {
9896
+ if (WEB_EFFECT_TOOLS.has(tool)) {
9897
+ if (!this.isKnownInstance(instance))
9898
+ return { error: "unknown_instance" };
9899
+ return this.effectTools.handle(instance, tool, args);
9900
+ }
9901
+ if (tool === "get_web_appearance" || tool === "preview_web_appearance")
9902
+ return this.appearanceTools.handle(instance, tool, args);
9903
+ if (tool === "export_web_mod" || tool === "propose_web_mod")
9904
+ return this.agentMods.handle(instance, tool, args);
9905
+ return this.webAppearance.handleTool(instance, tool, args);
9906
+ }
9426
9907
  async handlePreviewEmojisHttp(instance, args) {
9427
9908
  return this.previewEmojis(instance, args);
9428
9909
  }
@@ -9929,6 +10410,21 @@ export class FleetManager {
9929
10410
  ? `Refusing to save fleet.yaml: it would be invalid (${introduced.map(e => `${e.path}: ${e.message}`).join("; ")})`
9930
10411
  : null;
9931
10412
  }
10413
+ /**
10414
+ * #1874 follow-up: the validator's warnings, once per start, in daemon.log — a setting fleet.yaml has that AgEnD
10415
+ * ignores (e.g. `web.access`) is otherwise silent unless someone runs `agend validate`.
10416
+ */
10417
+ logConfigWarningsAtStartup() {
10418
+ let warnings = [];
10419
+ try {
10420
+ warnings = validateFleetConfig(this.rawFleetConfig).warnings;
10421
+ }
10422
+ catch (err) {
10423
+ this.logger.debug({ err }, "fleet.yaml validation failed (startup warnings)");
10424
+ }
10425
+ for (const w of warnings)
10426
+ this.logger.warn({ path: w.path }, `fleet.yaml: ${w.path ? `${w.path}: ` : ""}${w.message}`);
10427
+ }
9932
10428
  /** One-time upgrade migration; invalid YAML is never rewritten. */
9933
10429
  slimFleetConfigAtStartup() {
9934
10430
  const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
@@ -10102,6 +10598,7 @@ export class FleetManager {
10102
10598
  // must not be shown the old one's conversation, and deleted names must not pile up.
10103
10599
  this.webChatHistory.forget(name);
10104
10600
  this.webFiles.forget(name);
10601
+ this.compactWatchService?.forget(name);
10105
10602
  // #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
10106
10603
  try {
10107
10604
  this.webPrefs.forgetInstance(name);
@@ -10109,6 +10606,17 @@ export class FleetManager {
10109
10606
  catch (err) {
10110
10607
  this.logger.warn({ err: err.message, name }, "Dropping a removed instance from web favorites failed");
10111
10608
  }
10609
+ try {
10610
+ this.themeStore.forget(name);
10611
+ this.webAppearance.forget(name);
10612
+ this.appearanceTools.forget(name);
10613
+ this.effectToolsService?.forget(name);
10614
+ this.mods.forgetInstance(name);
10615
+ this.agentMods.forget(name);
10616
+ }
10617
+ catch (err) {
10618
+ this.logger.warn({ err: err.message, name }, "Dropping a removed instance's web theme failed");
10619
+ }
10112
10620
  // Clean up statusline watcher + instance directory
10113
10621
  this.statuslineWatcher.unwatch(name);
10114
10622
  // instanceDirToClean and effectiveBackend are captured before lifecycle.remove
@@ -10309,10 +10817,12 @@ export class FleetManager {
10309
10817
  return this.webImageCaches[kind];
10310
10818
  }
10311
10819
  /**
10312
- * instance → the connection that is its own bot: configured now (a connection removed from fleet.yaml is nobody's,
10313
- * whatever an instance still names — #1635 review), not the primary, and answering for it alone.
10820
+ * The bots instances and ClassicBot rooms speak through, by connections configured now (a connection removed from
10821
+ * fleet.yaml is nobody's, whatever an instance still names — #1635 review). `own`: instance → its own bot (answering
10822
+ * for it alone; the primary too, when it serves one instance only). `speaking` (#1867): instance → the bot it speaks through — its own, a shared one,
10823
+ * or, with none configured for it, the primary.
10314
10824
  */
10315
- ownBots() {
10825
+ webBots() {
10316
10826
  const instances = this.fleetConfig?.instances ?? {};
10317
10827
  const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
10318
10828
  const configured = new Set(channels.map(ch => ch.id ?? ch.type).filter((id) => typeof id === "string"));
@@ -10322,8 +10832,17 @@ export class FleetManager {
10322
10832
  if (!Object.hasOwn(instances, room.instanceName))
10323
10833
  assignments.push([room.instanceName, assigned(room.instanceName)]);
10324
10834
  }
10325
- return ownBotConnections(this.getPrimaryAdapterId(), assignments);
10835
+ const primary = this.getPrimaryAdapterId(), fallback = primary && configured.has(primary) ? primary : undefined;
10836
+ const speaking = new Map();
10837
+ for (const [instance, connection] of assignments) {
10838
+ const c = connection ?? fallback;
10839
+ if (c)
10840
+ speaking.set(instance, c);
10841
+ }
10842
+ return { own: ownBotConnections(assignments), speaking };
10326
10843
  }
10844
+ /** instance → the connection that is its own bot (see webBots). */
10845
+ ownBots() { return this.webBots().own; }
10327
10846
  /**
10328
10847
  * The bot a connection is now: its running adapter and that bot's own user id. Everything cached for an own bot is
10329
10848
  * keyed by both, so a bot put in its place (a token replaced under the same connection id) never shows the old one's
@@ -10366,8 +10885,11 @@ export class FleetManager {
10366
10885
  },
10367
10886
  };
10368
10887
  }
10369
- /** One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. */
10370
- webIdentityImage(name, displayName, profileAvatar, own) {
10888
+ /**
10889
+ * One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. The bot
10890
+ * is its own, else the one it speaks through (#1867) — one cache entry per bot, so a shared bot is fetched once.
10891
+ */
10892
+ webIdentityImage(name, displayName, profileAvatar, bots) {
10371
10893
  let profileVersion = null;
10372
10894
  if (profileAvatar && imageMimeForFile(profileAvatar)) {
10373
10895
  try {
@@ -10375,22 +10897,41 @@ export class FleetManager {
10375
10897
  }
10376
10898
  catch { /* removed */ }
10377
10899
  }
10378
- const connection = own.get(name), owner = connection ? this.botOwner(connection) : null;
10900
+ const ownConnection = bots.own.get(name), connection = ownConnection ?? bots.speaking.get(name);
10901
+ const owner = connection ? this.botOwner(connection) : null;
10379
10902
  const source = connection && owner?.adapter.fetchBotAvatar ? this.botAvatarSource(connection, owner) : null;
10380
10903
  const bot = source ? this.webImageCache("avatars").peek(source.key, source.load) : null;
10381
10904
  const cfg = this.ownInstanceConfig(name);
10382
10905
  const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
10383
10906
  personaEmoji: cfg?.status_emojis?.delivered });
10384
- // The ring belongs to the bot's own face: only when that is what is shown.
10385
- const accent = !profileVersion && bot && connection && owner
10386
- ? this.webAccent(connection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
10907
+ // The ring belongs to an own bot's face: only when that is what is shown (a shared bot's would ring everyone alike).
10908
+ // #1958: the own bot's colour is also the identity colour's role tier — whatever face is shown (never a shared bot's).
10909
+ const roleColor = ownConnection && owner
10910
+ ? this.webAccent(ownConnection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
10387
10911
  : null;
10388
- return accent ? { avatar, accent } : { avatar };
10912
+ const accent = !profileVersion && bot && ownConnection && owner ? roleColor : null;
10913
+ // #1629 (first design §5, r2 (d)): the agent's theme may name its own picture and ring colour. Both come below a
10914
+ // person's upload (never when there is one) and ride beside the theme-free face: a viewer who turned agents'
10915
+ // themes off (fx.show), or a safe-mode load, shows `avatar` and `accent` as before.
10916
+ let theme = { v: 1 };
10917
+ try {
10918
+ theme = this.themeStore.get(name).theme;
10919
+ }
10920
+ catch { /* unreadable themes: none */ }
10921
+ const themeAvatar = !profileVersion && theme.avatar ? { kind: "image", src: `/ui/img/theme/${encodeURIComponent(name)}/${theme.avatar}` } : undefined;
10922
+ const themeAccent = !profileVersion && theme.accent ? theme.accent : undefined;
10923
+ return { avatar, ...(accent ? { accent } : {}), ...(themeAvatar ? { themeAvatar } : {}), ...(themeAccent ? { themeAccent } : {}),
10924
+ // #1958: the agent's own identity colour (theme.identity.color, else its accent) and where it would show it.
10925
+ ...(roleColor ? { roleColor } : {}), ...(theme.identity?.color ?? theme.accent ? { agentColor: theme.identity?.color ?? theme.accent } : {}),
10926
+ ...(theme.identity?.show ? { agentShow: theme.identity.show } : {}) };
10389
10927
  }
10390
- /** GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only an own-bot connection, or a known instance's upload. */
10928
+ /**
10929
+ * GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only a connection some instance or room speaks
10930
+ * through (#1867), or a known instance's upload.
10931
+ */
10391
10932
  webImage(kind, key) {
10392
10933
  if (kind === "bot") {
10393
- if (![...this.ownBots().values()].includes(key))
10934
+ if (![...this.webBots().speaking.values()].includes(key))
10394
10935
  return null;
10395
10936
  const owner = this.botOwner(key);
10396
10937
  return owner ? this.webImageCache("avatars").read(this.botAvatarSource(key, owner).key) : null;
@@ -11469,7 +12010,8 @@ export class FleetManager {
11469
12010
  // Discord, so every such fallback failed inside the catch handler.
11470
12011
  const target = this.fleetNoticeTarget();
11471
12012
  if (this.adapter && target) {
11472
- this.adapter.sendText(target.chatId, body, target.opts)
12013
+ const out = this.renderPlaces(this.getPrimaryAdapterId(), body, target.opts);
12014
+ this.adapter.sendText(target.chatId, out.text, out.opts)
11473
12015
  .catch(err => this.logger.warn({ err }, "Failed to send fleet error notification"));
11474
12016
  dispatched = true;
11475
12017
  }
@@ -11481,7 +12023,7 @@ export class FleetManager {
11481
12023
  // also suppressed the next ten minutes of identical ones. Leaving the key
11482
12024
  // unspent lets the next caller, once an adapter is up, actually get
11483
12025
  // through. The counter is not reset either: nothing was shown.
11484
- this.logger.warn({ text: body }, "Fleet error could not be delivered (no adapter or no target yet) — not consuming the throttle window");
12026
+ this.logger.warn({ text: stripPlaceRefs(body) }, "Fleet error could not be delivered (no adapter or no target yet) — not consuming the throttle window");
11485
12027
  return false;
11486
12028
  }
11487
12029
  if (!throttled)
@@ -11519,24 +12061,28 @@ export class FleetManager {
11519
12061
  const groupId = channelCfg?.group_id;
11520
12062
  // Fleet topic instance
11521
12063
  const threadId = this.fleetConfig?.instances[instanceName]?.topic_id;
12064
+ const worldId = this.getInstanceAdapterId(instanceName);
11522
12065
  if (threadId != null && groupId) {
11523
- adapter.sendText(String(groupId), text, { threadId: String(threadId), ...extraOpts })
12066
+ const out = this.renderPlaces(worldId, text, { threadId: String(threadId), ...extraOpts });
12067
+ adapter.sendText(String(groupId), out.text, out.opts)
11524
12068
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send instance topic notification"));
11525
12069
  return true;
11526
12070
  }
11527
12071
  // Classic instance: find its channelId from the classic manager
11528
12072
  const classicChatId = this.classicChannels?.getChannelIdByInstance(instanceName);
11529
12073
  if (classicChatId) {
11530
- adapter.sendText(classicChatId, text, extraOpts)
12074
+ // #1990: a ClassicBot room's readers need not be in the fleet's group, so its notices name places as plain text.
12075
+ adapter.sendText(classicChatId, stripPlaceRefs(text), extraOpts)
11531
12076
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send classic notification"));
11532
12077
  return true;
11533
12078
  }
11534
12079
  // Fallback: the instance has neither a topic nor a classic channel, so post
11535
12080
  // where fleet-wide notices go. Not the bare group id: on Discord that is a
11536
12081
  // guild, and the send fails inside the catch handler.
11537
- const target = this.fleetNoticeTarget(this.getInstanceAdapterId(instanceName));
12082
+ const target = this.fleetNoticeTarget(worldId);
11538
12083
  if (target) {
11539
- adapter.sendText(target.chatId, text, { ...target.opts, ...extraOpts })
12084
+ const out = this.renderPlaces(worldId, text, { ...target.opts, ...extraOpts });
12085
+ adapter.sendText(target.chatId, out.text, out.opts)
11540
12086
  .catch(e => this.logger.warn({ err: e, instanceName }, "Failed to send notification (no topic)"));
11541
12087
  return true;
11542
12088
  }
@@ -11607,12 +12153,14 @@ export class FleetManager {
11607
12153
  this.pendingNonceButtons.set(nonce, entry);
11608
12154
  try {
11609
12155
  const choices = opts.choices.map(c => ({ id: `${opts.prefix}${nonce}:${c.action}`, label: c.label }));
12156
+ // #1990: place names link for the group the prompt is posted in; the dashboard's copy is plain (for now).
12157
+ const posted = this.renderPlaces(opts.adapterId, opts.message, opts.threadId ? { threadId: opts.threadId } : undefined);
11610
12158
  const sent = opts.deliver ? await opts.deliver(choices) : await opts.adapter.notifyAlert(opts.chatId, {
11611
12159
  type: opts.alertType,
11612
12160
  instanceName: opts.instanceName,
11613
- message: opts.message,
12161
+ message: posted.text,
11614
12162
  choices,
11615
- }, opts.threadId ? { threadId: opts.threadId } : undefined);
12163
+ }, posted.opts);
11616
12164
  // Bind the nonce to the provider's canonical delivery address, not the
11617
12165
  // logical routing input. Telegram, for example, represents General as
11618
12166
  // AgEnD topic "1" on input but omits message_thread_id on the wire and in
@@ -11630,8 +12178,15 @@ export class FleetManager {
11630
12178
  // Offered on the dashboard only once it is live on the platform (a failed post is disarmed above),
11631
12179
  // and only if nothing claimed or expired it meanwhile.
11632
12180
  if (WEB_MIRRORED_PROMPT_PREFIXES.has(opts.prefix) && this.pendingNonceButtons.get(nonce) === entry) {
12181
+ // #1990: on the dashboard an instance's name opens its terminal View (where the prompt is answered by hand).
12182
+ const web = renderPlaceRefs(opts.message, "web", target => {
12183
+ const route = target.kind === "instance" && Object.hasOwn(this.fleetConfig?.instances ?? {}, target.name) ? webRoute("view", target.name) : undefined;
12184
+ return route ? { url: route } : undefined;
12185
+ });
12186
+ const links = webTextLinks(web.textLinks, web.text);
11633
12187
  entry.web = {
11634
- text: opts.message,
12188
+ text: web.text,
12189
+ ...(links ? { links } : {}),
11635
12190
  actions: opts.choices.map(c => ({ id: c.action, label: c.label })),
11636
12191
  expiresAt: Date.now() + (opts.timeoutMs ?? NONCE_BUTTON_TIMEOUT_MS),
11637
12192
  };
@@ -11827,6 +12382,8 @@ export class FleetManager {
11827
12382
  return true;
11828
12383
  if (await this.handleEffortSelection(data, adapterId))
11829
12384
  return true;
12385
+ if (await this.handleBackendSelection(data, adapterId))
12386
+ return true;
11830
12387
  if (await this.handleHangPrompt(data, adapterId, adapter))
11831
12388
  return true;
11832
12389
  if (data.callbackData.startsWith("cancel:")) {
@@ -11881,7 +12438,7 @@ export class FleetManager {
11881
12438
  isClassic: name => classic.has(name),
11882
12439
  place: name => {
11883
12440
  const world = this.worlds.get(this.getInstanceAdapterId(name) ?? "");
11884
- return world ? { type: world.type, ...(world.groupId ? { groupId: world.groupId } : {}) } : undefined;
12441
+ return world ? worldPlaceOf(world) : undefined;
11885
12442
  },
11886
12443
  // fleet.yaml's topic only. A ClassicBot room has none here, so no link: its channel may sit in any allowed
11887
12444
  // guild, not necessarily its world's group.
@@ -12046,9 +12603,7 @@ export class FleetManager {
12046
12603
  finishedAt: Date.parse(d.finishedAt) || 0,
12047
12604
  })),
12048
12605
  ownerOf,
12049
- worlds: () => [...this.worlds.values()].map((w) => ({
12050
- id: w.id, adapter: w.adapter, place: { type: w.type, ...(w.groupId ? { groupId: w.groupId } : {}) },
12051
- })),
12606
+ worlds: () => [...this.worlds.values()].map((w) => ({ id: w.id, adapter: w.adapter, place: worldPlaceOf(w) })),
12052
12607
  noticeTarget: world => {
12053
12608
  const target = this.fleetNoticeTarget(world);
12054
12609
  return target ? { chatId: target.chatId, ...(target.opts.threadId !== undefined ? { threadId: String(target.opts.threadId) } : {}) } : null;
@@ -12094,10 +12649,18 @@ export class FleetManager {
12094
12649
  * The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
12095
12650
  * on the dashboard, about the instance the page named, and the action one of its own buttons.
12096
12651
  */
12097
- /** #1266: a click on a reply's button in the web chat (the /ui gate is passed: a session, or the public link). */
12098
- clickWebReplyButton(instance, id, index) {
12652
+ /** #1823: close_poll — `instance` closes one of its own polls before the deadline. */
12653
+ closePollEarly(instance, id) {
12654
+ const buttons = this.replyButtons();
12655
+ return buttons ? buttons.closeEarly(instance, id) : Promise.resolve({ ok: false, error: "polls are unavailable until AgEnD restarts" });
12656
+ }
12657
+ /**
12658
+ * #1266: a click on a reply's button in the web chat (the /ui gate is passed: a session, or the public link). On a
12659
+ * poll (#1823) it is `voter`'s vote: the page's session.
12660
+ */
12661
+ clickWebReplyButton(instance, id, index, voter) {
12099
12662
  const buttons = this.replyButtons();
12100
- return buttons ? buttons.clickWeb(instance, id, index) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
12663
+ return buttons ? buttons.clickWeb(instance, id, index, voter) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
12101
12664
  }
12102
12665
  async clickWebPrompt(instance, nonce, action) {
12103
12666
  if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
@@ -12450,6 +13013,8 @@ export class FleetManager {
12450
13013
  }
12451
13014
  };
12452
13015
  }
13016
+ /** #1806: the web's /backend confirmations — one per question, used once, for the arguments and backend asked about. */
13017
+ webBackendTokens = new Map();
12453
13018
  /** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
12454
13019
  webClearTokens = new Map();
12455
13020
  /** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
@@ -12457,7 +13022,9 @@ export class FleetManager {
12457
13022
  const webMeta = (instance) => {
12458
13023
  const topicId = this.fleetConfig?.instances[instance]?.topic_id;
12459
13024
  return {
12460
- chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(), username: "web-user", userId: "web-user",
13025
+ // #1827: a /steer or /btw reaches the agent as the web person who wrote it.
13026
+ chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(),
13027
+ username: opts.person?.name ?? WEB_NAME_FALLBACK, userId: opts.person?.userId ?? WEB_NAME_FALLBACK,
12461
13028
  threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
12462
13029
  };
12463
13030
  };
@@ -12477,7 +13044,22 @@ export class FleetManager {
12477
13044
  return this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? "classic" : null;
12478
13045
  },
12479
13046
  ctx: (instance) => this.topicCommands.getCtxText(instance),
12480
- compact: (instance, instructions) => this.topicCommands.sendCompact(instance, instructions),
13047
+ // Board 003648ac: a watch for the paste, armed when the daemon says it was written (raw_paste_written); one
13048
+ // for a command that was not handed over is dropped.
13049
+ compact: async (instance, instructions) => {
13050
+ let watch = null;
13051
+ try {
13052
+ const r = this.topicCommands.compactInstance(instance, instructions, () => (watch = this.compactWatch.begin(instance)));
13053
+ if (!r.sent && watch)
13054
+ this.compactWatch.cancel(instance, watch);
13055
+ return { ...r, watch: r.sent ? watch : null };
13056
+ }
13057
+ catch (err) {
13058
+ if (watch)
13059
+ this.compactWatch.cancel(instance, watch);
13060
+ throw err;
13061
+ }
13062
+ },
12481
13063
  applyModel: (instance, name) => this.applyModel(instance, name),
12482
13064
  modelChoices: choicesFromCache,
12483
13065
  applyEffort: (instance, level) => this.applyEffort(instance, level),
@@ -12488,6 +13070,41 @@ export class FleetManager {
12488
13070
  const current = this.resolveInstanceEffort(instance).effort;
12489
13071
  return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
12490
13072
  },
13073
+ backendStep: async (instance, args) => {
13074
+ const refusal = this.backendCommandRefusal(instance);
13075
+ if (refusal)
13076
+ return { error: refusal };
13077
+ // The cached model lists only, as the web's /model: never a live probe on this path.
13078
+ const step = await backendStep(this.backendCommandDeps(instance, { live: false }), instance, backendArgs(args));
13079
+ if (step.kind === "refuse")
13080
+ return step.noop ? { text: step.text } : { error: step.text };
13081
+ if (step.kind === "choose") {
13082
+ const id = (token) => joinBackendArgs([...step.args, token]);
13083
+ 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 })) } };
13084
+ }
13085
+ const now = performance.now();
13086
+ for (const [k, v] of this.webBackendTokens)
13087
+ if (v.deadline < now)
13088
+ this.webBackendTokens.delete(k);
13089
+ const token = randomBytes(16).toString("hex");
13090
+ this.webBackendTokens.set(token, {
13091
+ instance, asked: joinBackendArgs(backendArgs(args)), args: step.args, from: step.from,
13092
+ target: this.fleetConfig.instances[instance], deadline: now + CLASSIC_BACKEND_SELECTION_TIMEOUT_MS,
13093
+ });
13094
+ return { confirm: { token, message: step.text } };
13095
+ },
13096
+ backendConfirm: async (instance, args, token) => {
13097
+ const entry = this.webBackendTokens.get(token);
13098
+ this.webBackendTokens.delete(token); // used once, whatever happens next
13099
+ if (!entry || entry.instance !== instance || entry.asked !== joinBackendArgs(backendArgs(args)) || performance.now() > entry.deadline) {
13100
+ return { status: 409, text: t("backend.selection_expired") };
13101
+ }
13102
+ // The instance it was asked about: one deleted and created again under the name, stopped or not, is another.
13103
+ const stillCurrent = () => this.backendTargetIs(instance, entry.target);
13104
+ if (!stillCurrent())
13105
+ return { status: 409, text: t("menu.click_stale") };
13106
+ return { status: 200, text: await this.applyBackendSwitch(instance, entry.args, entry.from, stillCurrent) };
13107
+ },
12491
13108
  cancel: (instance) => this.cancelInstance(instance),
12492
13109
  steer: (instance, text, originalBody) => this.topicCommands.sendSteer(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
12493
13110
  btw: (instance, text, originalBody) => this.topicCommands.sendBtw(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
@@ -12516,7 +13133,8 @@ export class FleetManager {
12516
13133
  // sendClear sends its first IPC synchronously: no await separates this check from the effect.
12517
13134
  if (this.shuttingDown || !entry.fence())
12518
13135
  return { status: 409, text: t("menu.click_stale") };
12519
- return { status: 200, text: await this.topicCommands.sendClear(instance) };
13136
+ const r = this.topicCommands.clearInstance(instance);
13137
+ return r.sent ? { status: 200, text: r.text, sent: true } : { status: 409, text: r.text };
12520
13138
  },
12521
13139
  t: (key, ...args) => t(key, ...args),
12522
13140
  }, input, opts);
@@ -12637,12 +13255,12 @@ export class FleetManager {
12637
13255
  * the operator's. Keep the instance notice passive and put the action in the
12638
13256
  * same-world General topic where an administrator can make the choice. */
12639
13257
  async notifyNormalExit(instanceName) {
12640
- this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName));
13258
+ this.notifyInstanceTopic(instanceName, t("exit.instance_notice", instanceName) + placeSuffix({ kind: "general" }, "General"));
12641
13259
  const web = this.webOnlyPromptPlace();
12642
13260
  if (web) {
12643
13261
  await this.postNonceButtonPrompt({
12644
13262
  prefix: EXIT_RESTART_CALLBACK_PREFIX, alertType: "exit_restart", instanceName, ...web,
12645
- message: t("exit.general_notice", instanceName),
13263
+ message: t("exit.general_notice", placeRef({ kind: "instance", name: instanceName }, instanceName)),
12646
13264
  choices: [{ action: "restart", label: t("exit.restart") }, { action: "ignore", label: t("exit.ignore") }],
12647
13265
  expiredText: t("exit.expired", instanceName),
12648
13266
  });
@@ -12671,7 +13289,7 @@ export class FleetManager {
12671
13289
  adapterId,
12672
13290
  chatId,
12673
13291
  threadId,
12674
- message: t("exit.general_notice", instanceName),
13292
+ message: t("exit.general_notice", placeRef({ kind: "instance", name: instanceName }, instanceName)),
12675
13293
  choices: [
12676
13294
  { action: "restart", label: t("exit.restart") },
12677
13295
  { action: "ignore", label: t("exit.ignore") },
@@ -12718,7 +13336,7 @@ export class FleetManager {
12718
13336
  * contain usernames or secret-adjacent text and stays in daemon.log.
12719
13337
  */
12720
13338
  async notifyInteractivePrompt(instanceName, kind) {
12721
- this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName));
13339
+ this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName) + placeSuffix({ kind: "general" }, "General"));
12722
13340
  // Multi-world fleets can have one General per bot/platform. Route the assist
12723
13341
  // request to the General bound to the same adapter as the blocked instance.
12724
13342
  const generalName = this.findGeneralInstance(this.getInstanceAdapterId(instanceName));
@@ -12729,7 +13347,7 @@ export class FleetManager {
12729
13347
  const label = this.interactivePromptLabel(kind);
12730
13348
  const prompt = {
12731
13349
  prefix: INTERACTIVE_ASSIST_CALLBACK_PREFIX, alertType: "interactive_prompt", instanceName,
12732
- message: t("interactive.general_notice", instanceName, label),
13350
+ message: t("interactive.general_notice", placeRef({ kind: "instance", name: instanceName }, instanceName), label),
12733
13351
  choices: [{ action: "confirm", label: t("interactive.confirm") }, { action: "cancel", label: t("interactive.cancel") }],
12734
13352
  expiredText: t("interactive.expired", instanceName),
12735
13353
  extra: { generalName, promptKind: kind, assistFor: this.interactionWaitKey(instanceName) },
@@ -14099,10 +14717,18 @@ export class FleetManager {
14099
14717
  }
14100
14718
  return;
14101
14719
  }
14720
+ // Between the install ending and the sign-in starting there is no
14721
+ // session to cancel; this hand-off is what `/login cancel` stops — from
14722
+ // here on, as the login-shell lookup below is an await.
14723
+ const handoff = { backend, cancelled: false };
14724
+ this.installHandoff = handoff;
14102
14725
  // The installer may only have added the binary to a profile PATH; a
14103
14726
  // fresh login shell sees that, the fleet process's PATH may not.
14104
- const installedAt = this.locateBinaryOnLoginShell(info.binary) ?? this.locateInInstallerBinDirs(info);
14727
+ const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? this.locateInInstallerBinDirs(info);
14728
+ if (handoff.cancelled || this.installHandoff !== handoff)
14729
+ return;
14105
14730
  if (!installedAt) {
14731
+ this.installHandoff = null;
14106
14732
  await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
14107
14733
  return;
14108
14734
  }
@@ -14112,6 +14738,7 @@ export class FleetManager {
14112
14738
  // installed, reported as verified, and then offered nowhere.
14113
14739
  this.adoptBinaryDirectory(installedAt, backend);
14114
14740
  if (!LOGIN_FLOWS[backend]) {
14741
+ this.installHandoff = null;
14115
14742
  await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
14116
14743
  return;
14117
14744
  }
@@ -14120,10 +14747,6 @@ export class FleetManager {
14120
14747
  // publish a durable completion line first so a successful install can
14121
14748
  // never look like it silently disappeared. Include the binary that
14122
14749
  // passed the fresh-login-shell verification and the exact next step.
14123
- // Between the install ending and the sign-in starting there is no
14124
- // session to cancel; this hand-off is what `/login cancel` stops.
14125
- const handoff = { backend, cancelled: false };
14126
- this.installHandoff = handoff;
14127
14750
  await chat.adapter.sendText(chat.chatId, t("install.success", backend, info.binary), { threadId: chat.threadId }).catch(err => this.logger.warn({ err, backend }, "Failed to send durable install success notification"));
14128
14751
  if (handoff.cancelled || this.installHandoff !== handoff)
14129
14752
  return;
@@ -14182,16 +14805,14 @@ export class FleetManager {
14182
14805
  * The absolute path a fresh login shell resolves `binary` to, or null. Only
14183
14806
  * a real executable file counts: `command -v` also answers with an alias
14184
14807
  * definition or a function name, neither of which a spawn could run.
14808
+ * Async (runOnLoginShell): a login file that backgrounds a process holding the shell's output once froze the fleet loop here.
14185
14809
  */
14186
- locateBinaryOnLoginShell(binary) {
14187
- return measureSyncWork("fleet.installLookup", () => this.locateBinaryOnLoginShellSync(binary));
14188
- }
14189
- locateBinaryOnLoginShellSync(binary) {
14810
+ async locateBinaryOnLoginShell(binary) {
14190
14811
  try {
14191
- const result = measureSyncWork("fleet.installLoginShell", () => spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe", encoding: "utf8" }));
14812
+ const result = await runOnLoginShell(`command -v ${binary}`);
14192
14813
  if (result.status !== 0)
14193
14814
  return null;
14194
- const path = String(result.stdout ?? "").trim().split("\n").pop()?.trim() ?? "";
14815
+ const path = result.stdout.trim().split("\n").pop()?.trim() ?? "";
14195
14816
  if (!isAbsolute(path))
14196
14817
  return null;
14197
14818
  accessSync(path, fsConstants.X_OK);
@@ -14325,6 +14946,14 @@ export class FleetManager {
14325
14946
  const room = this.classicChannels?.getAll().find(c => c.instanceName === name);
14326
14947
  return room ? `room:${room.channelId}:${room.adapterId ?? ""}` : null;
14327
14948
  }
14949
+ /** The only Try-card publisher: no arbitrary SSE sender/role can manufacture this metadata. */
14950
+ publishEffectTrial(instance, offer) {
14951
+ const row = this.webChatHistory.recordEffectTrial(instance, offer, new Date().toISOString());
14952
+ if (!row)
14953
+ return false;
14954
+ broadcastSseEvent(this.sseClients, "message", row, err => this.logger.debug({ err }, "SSE client write failed; evicting"), this.webChatHistory.cursorOf(row));
14955
+ return true;
14956
+ }
14328
14957
  /** Push an SSE event to all connected Web UI clients. */
14329
14958
  emitSseEvent(event, data) {
14330
14959
  // #1386: a prompt opened or closed, or an instance's state moved — "Needs you" may have changed.
@@ -14340,8 +14969,13 @@ export class FleetManager {
14340
14969
  messageId: typeof m.messageId === "string" ? m.messageId : undefined,
14341
14970
  role: typeof m.role === "string" ? m.role : undefined,
14342
14971
  buttons: m.buttons,
14972
+ click: m.click,
14973
+ links: m.links,
14343
14974
  });
14344
14975
  broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
14976
+ // #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
14977
+ if (recorded.role === "agent")
14978
+ broadcastSseEvent(this.sseClients, "agent_reply_live", { v: 1, boot: recorded.boot, id: recorded.id, instance: recorded.instance }, onError);
14345
14979
  return;
14346
14980
  }
14347
14981
  // #1266: a reply's buttons ended — the history shows it too (a later load, the public link's poll).
@@ -14391,7 +15025,7 @@ export class FleetManager {
14391
15025
  const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
14392
15026
  const configuredMinutes = instanceHangConfig?.timeout_minutes
14393
15027
  ?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
14394
- ?? 15;
15028
+ ?? DEFAULT_STUCK_TIMEOUT_MS / 60_000; // #1854: the detector's own default (10 min), not a stale 15
14395
15029
  const unchangedMinutes = unchangedForMs == null
14396
15030
  ? configuredMinutes
14397
15031
  : Math.max(1, Math.floor(unchangedForMs / 60_000));
@@ -14449,12 +15083,12 @@ export class FleetManager {
14449
15083
  "codex": "AGENTS.md",
14450
15084
  "opencode": "AGENTS.md",
14451
15085
  "kiro-cli": ".kiro/steering/project.md",
14452
- // Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
15086
+ // Grok reads AGENTS.md project docs; agy reads .agents/AGENTS.md (board 0340ec89: not the lowercase name) — the
14453
15087
  // same files their writeConfig() appends fleet instructions to.
14454
15088
  "grok": "AGENTS.md",
14455
15089
  // muse init scaffolds AGENTS.md and the binary reads it as project rules.
14456
15090
  "muse": "AGENTS.md",
14457
- "antigravity": ".agents/agents.md",
15091
+ "antigravity": ".agents/AGENTS.md",
14458
15092
  "mock": "CLAUDE.md",
14459
15093
  };
14460
15094
  static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
@@ -14494,10 +15128,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14494
15128
  ensureGeneralInstructionsSync(workDir, backendName, instanceName) {
14495
15129
  const backend = backendName ?? "claude-code";
14496
15130
  workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
15131
+ // Board 0340ec89: General's file from before, under the lowercase name agy never read, becomes the one it reads.
15132
+ if (backend === "antigravity")
15133
+ migrateAgyAgentsFile(workDir);
14497
15134
  const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
14498
15135
  const filePath = join(workDir, filename);
14499
15136
  mkdirSync(dirname(filePath), { recursive: true });
14500
- if (!existsSync(filePath)) {
15137
+ if (backend === "antigravity" && danglingSymlink(filePath)) {
15138
+ // #1914 review: a person's dangling symlink there is kept and not followed (nothing created where it points).
15139
+ this.logger.warn({ filePath }, "General's agy instructions file is a dangling symlink; left as it is, not written");
15140
+ }
15141
+ else if (!existsSync(filePath)) {
14501
15142
  writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
14502
15143
  this.logger.info({ filePath }, "Created general instance instructions file");
14503
15144
  }
@@ -14594,6 +15235,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14594
15235
  }
14595
15236
  const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
14596
15237
  const instructionsPath = join(workDir, filename);
15238
+ if (backend === "antigravity" && danglingSymlink(instructionsPath))
15239
+ return; // kept, not followed (#1914 review; logged above)
14597
15240
  const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
14598
15241
  const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
14599
15242
  let existing = "";
@@ -14614,6 +15257,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14614
15257
  mkdirSync(dirname(instructionsPath), { recursive: true });
14615
15258
  writeFileSync(instructionsPath, next);
14616
15259
  }
15260
+ // Board 0340ec89: a legacy agy file that stays beside the new one (a person's own) loses only AgEnD's block.
15261
+ const legacy = backend === "antigravity" ? agyLegacyAgentsFile(workDir) : null;
15262
+ if (legacy) {
15263
+ try {
15264
+ const old = readFileSync(legacy, "utf-8");
15265
+ const b = old.indexOf(FleetManager.STEERING_BLOCK_BEGIN), e = old.indexOf(FleetManager.STEERING_BLOCK_END);
15266
+ if (b !== -1 && e > b) {
15267
+ const rest = (old.slice(0, b).trimEnd() + "\n" + old.slice(e + FleetManager.STEERING_BLOCK_END.length).trimStart()).trim();
15268
+ if (rest)
15269
+ writeFileSync(legacy, rest + "\n");
15270
+ else
15271
+ rmSync(legacy, { force: true });
15272
+ }
15273
+ }
15274
+ catch { /* best effort: the new file already has the block */ }
15275
+ }
14617
15276
  }
14618
15277
  static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
14619
15278
  static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
@@ -15149,10 +15808,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15149
15808
  * accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
15150
15809
  * the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
15151
15810
  */
15152
- claudeModelKnown(instanceName, model) {
15811
+ claudeModelKnown(instanceName, model,
15812
+ /** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
15813
+ configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
15153
15814
  const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
15154
15815
  const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
15155
- const configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model;
15156
15816
  const lower = model.trim().toLowerCase();
15157
15817
  const bare = lower.replace(/\[1m\]$/, "");
15158
15818
  const known = aliases.some(a => a.toLowerCase() === bare)
@@ -16131,7 +16791,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16131
16791
  /** How this instance's backend applies an effort change. */
16132
16792
  effortStrategyFor(instanceName) {
16133
16793
  try {
16134
- const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
16794
+ const { strategy, levels } = this.instanceEffortMetadata(instanceName);
16135
16795
  return strategy !== "unsupported" && levels.length > 0 ? strategy : "unsupported";
16136
16796
  }
16137
16797
  catch {
@@ -16141,13 +16801,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16141
16801
  /** Effort levels this instance's backend actually accepts (empty = unsupported). */
16142
16802
  effortLevelsFor(instanceName) {
16143
16803
  try {
16144
- const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
16804
+ const { strategy, levels } = this.instanceEffortMetadata(instanceName);
16145
16805
  return strategy === "unsupported" ? [] : levels;
16146
16806
  }
16147
16807
  catch {
16148
16808
  return [];
16149
16809
  }
16150
16810
  }
16811
+ /** The instance's backend's effort metadata; agy's levels are its model's (#1825), the one the instance runs. */
16812
+ instanceEffortMetadata(instanceName) {
16813
+ const backend = this.backendNameForInstance(instanceName);
16814
+ let model = null;
16815
+ if (backend === "antigravity") {
16816
+ const resolved = this.resolveInstanceModel(instanceName);
16817
+ model = resolved.source === "unresolved" ? null : resolved.model;
16818
+ }
16819
+ return readEffortMetadata(backend, this.getInstanceDir(instanceName), model);
16820
+ }
16151
16821
  /** Configured effort for an instance: per-instance, else fleet default, else none. */
16152
16822
  resolveInstanceEffort(instanceName) {
16153
16823
  const own = this.fleetConfig?.instances[instanceName]?.effort;
@@ -16299,6 +16969,377 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16299
16969
  ? `\n${t("effort.current_fleet", effort)}`
16300
16970
  : `\n${t("effort.current", effort)}`;
16301
16971
  }
16972
+ // ── #1806: /backend — another CLI backend, with its model and effort, in one write and one relaunch ──
16973
+ /** The steps' view of this fleet (backend-command.ts). `live`: a missing or stale model list may be probed (not on the web). */
16974
+ backendCommandDeps(instanceName, opts) {
16975
+ return {
16976
+ currentBackend: (name) => this.backendNameForInstance(name),
16977
+ currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
16978
+ installedBackends: () => KNOWN_BACKENDS.filter(b => b !== "mock" && this.backendInstalledHere(b)),
16979
+ models: (backend) => this.backendModelOptions(backend, opts),
16980
+ // #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
16981
+ modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
16982
+ effortLevels: (backend, model) => this.backendEffortLevels(instanceName, backend, model),
16983
+ inherited: (key) => {
16984
+ const value = getEffectiveInstanceDefaults(this.fleetConfig?.defaults)[key];
16985
+ return typeof value === "string" && value ? value : undefined;
16986
+ },
16987
+ modelCompatible: (backend, model) => isModelCompatible(backend, model),
16988
+ state: (name) => {
16989
+ const status = this.getInstanceStatus(name);
16990
+ return { running: status === "running", busy: status === "running" && !this.getInstanceIdle(name), status };
16991
+ },
16992
+ t: (key, ...args) => t(key, ...args),
16993
+ };
16994
+ }
16995
+ /**
16996
+ * `/effort`'s levels for a backend this instance may move to, for the model that will run there (codex's levels are
16997
+ * per model; null: its configured default). Empty: it has no effort setting. A file read, never a CLI.
16998
+ */
16999
+ backendEffortLevels(instanceName, backend, model = null) {
17000
+ // codex: the model the NEXT launch runs, for the levels and the gate alike (#1807 review r2).
17001
+ let runs = model;
17002
+ if (backend === "codex") {
17003
+ const next = this.prospectiveCodexModel(instanceName, model);
17004
+ if (!next.proven)
17005
+ return [];
17006
+ runs = next.model;
17007
+ }
17008
+ let levels;
17009
+ try {
17010
+ const metadata = readEffortMetadata(backend, this.getInstanceDir(instanceName), runs);
17011
+ levels = metadata.strategy === "unsupported" ? [] : metadata.levels;
17012
+ }
17013
+ catch {
17014
+ return [];
17015
+ }
17016
+ // #1811: only the levels this host's launch of that backend will pass (launch-effort.ts, the gate the launch
17017
+ // itself applies) — an effort chosen here reaches the new CLI with the one relaunch, or is not offered.
17018
+ const takes = this.launchEffortGate(instanceName, backend, runs);
17019
+ return levels.filter(takes);
17020
+ }
17021
+ /**
17022
+ * #1808: whether a backend takes this model name — the one rule for /backend and update_instance_config's switch.
17023
+ * The name must be one its launch line can carry (modelNameCarried); then its offered list (the cached CLI env), and
17024
+ * claude-code's known names (without this instance's configured model, which belongs to the backend being left).
17025
+ * With no list known, the backend's usual name pattern decides.
17026
+ */
17027
+ backendModelVerdict(instanceName, backend, model,
17028
+ /** The provider the backend will run on (default: this instance's own, else the fleet's). */
17029
+ opts = { provider: this.customProviderFor(instanceName, backend) }) {
17030
+ // A custom provider has its own catalog, which the CLI reads at launch and AgEnD cannot see before one (#1815
17031
+ // review): only the launch-line rule applies — never the account's list or the default provider's naming pattern.
17032
+ if (opts.provider)
17033
+ return modelNameCarried(backend, model) ? { ok: true } : { ok: false, choices: [] };
17034
+ const listed = this.readCliEnv(backend)?.models ?? [];
17035
+ const choices = listed.slice(0, 24).map(o => o.id);
17036
+ if (!modelNameCarried(backend, model))
17037
+ return { ok: false, choices };
17038
+ if (listed.some(o => o.id === model))
17039
+ return { ok: true };
17040
+ if (backend === "claude-code") {
17041
+ const check = this.claudeModelKnown(instanceName, model, null);
17042
+ return check.known ? { ok: true } : { ok: false, choices: check.names };
17043
+ }
17044
+ return listed.length === 0 && isModelCompatible(backend, model) ? { ok: true } : { ok: false, choices };
17045
+ }
17046
+ /**
17047
+ * #1807 review r2: the model codex's next launch of this instance runs. Every launch's writeConfig copies the SHARED
17048
+ * config.toml into the instance's codex home first, so that source decides — not the home's copy from an earlier
17049
+ * launch. Read-only; a model chosen here (`-c model=…`) outranks it; a project file that could change it, unproven.
17050
+ */
17051
+ prospectiveCodexModel(instanceName, model) {
17052
+ const { sharedHome } = codexMetadataHomes(this.getInstanceDir(instanceName));
17053
+ return resolveCodexLaunchModel({
17054
+ isolatedHome: sharedHome, sharedHome, model,
17055
+ workingDirectory: this.fleetConfig?.instances[instanceName]?.working_directory,
17056
+ });
17057
+ }
17058
+ /** #1811: whether a launch of `backend` here would pass a level: the same gate its builder applies. */
17059
+ launchEffortGate(instanceName, backend, model) {
17060
+ if (backend === "claude-code" || backend === "grok" || backend === "antigravity") {
17061
+ const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
17062
+ if (!binary)
17063
+ return () => false;
17064
+ const path = resolveBinary(binary);
17065
+ return level => flagEffort(backend, level, path).arg !== null;
17066
+ }
17067
+ if (backend === "codex") {
17068
+ // `model` is the next launch's, proven by the caller (prospectiveCodexModel); the catalog is codex's own cache.
17069
+ if (!model)
17070
+ return () => false;
17071
+ const catalog = readCodexModelEffortLevels({ ...codexMetadataHomes(this.getInstanceDir(instanceName)), model });
17072
+ return level => codexEffort(level, catalog).arg !== null;
17073
+ }
17074
+ // kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
17075
+ return () => true;
17076
+ }
17077
+ /** Installed the way the launch finds a CLI (resolveBinary): on PATH or in a known install directory. */
17078
+ backendInstalledHere(backend) {
17079
+ const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
17080
+ return !!binary && resolveBinary(binary) !== binary;
17081
+ }
17082
+ /** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
17083
+ async backendModelOptions(backend, opts) {
17084
+ const cached = this.readCliEnv(backend);
17085
+ if (!opts.live || (cached?.models.length && !this.cliEnvNeedsRefresh(cached)))
17086
+ return cached?.models ?? [];
17087
+ opts.onLiveProbe?.();
17088
+ const env = await this.probeBackendBounded(backend).catch(() => null);
17089
+ return env?.models.length ? env.models : cached?.models ?? [];
17090
+ }
17091
+ /**
17092
+ * #1806 review: the instance a `/backend` was asked about is still this config entry — not one deleted and created
17093
+ * again under the same name, or replaced by a Settings edit (both put a new object there), stopped or not.
17094
+ */
17095
+ backendTargetIs(instanceName, target) {
17096
+ const instances = this.fleetConfig?.instances;
17097
+ return !!target && !!instances && Object.prototype.hasOwnProperty.call(instances, instanceName) && instances[instanceName] === target;
17098
+ }
17099
+ /** `/backend` is for fleet.yaml instances: update_instance_config does not manage a ClassicBot agent. */
17100
+ backendCommandRefusal(instanceName) {
17101
+ return this.fleetConfig?.instances && Object.prototype.hasOwnProperty.call(this.fleetConfig.instances, instanceName)
17102
+ ? null : t("backend.classic_unsupported", instanceName);
17103
+ }
17104
+ /**
17105
+ * Apply a confirmed `/backend`: judged again first (a menu lives a minute — the backend may have moved, a CLI been
17106
+ * removed), then update_instance_config's write: backend, model and effort at once, refused and rolled back whole
17107
+ * (its credential-profile login gate included), then one fresh relaunch of a running instance.
17108
+ */
17109
+ async applyBackendSwitch(instanceName, args, from, stillCurrent) {
17110
+ if (this.backendNameForInstance(instanceName) !== from)
17111
+ return t("backend.changed", instanceName, from);
17112
+ if (!stillCurrent())
17113
+ return t("menu.click_stale");
17114
+ const deps = this.backendCommandDeps(instanceName, { live: false });
17115
+ const step = await backendStep(deps, instanceName, args);
17116
+ if (step.kind !== "confirm")
17117
+ return step.kind === "refuse" ? step.text : t("backend.usage");
17118
+ // Asked again after the await, with nothing awaited between here and the write (applyInstanceConfigPatch writes
17119
+ // synchronously): two switches confirmed together, or an instance changed meanwhile, never both succeed.
17120
+ if (this.backendNameForInstance(instanceName) !== from)
17121
+ return t("backend.changed", instanceName, from);
17122
+ if (!stillCurrent())
17123
+ return t("menu.click_stale");
17124
+ const { plan } = step;
17125
+ const shown = planDisplay(deps, plan);
17126
+ const { value, error } = await new Promise(resolve => {
17127
+ applyInstanceConfigPatch(this, instanceName, { backend: plan.backend, model: plan.model, effort: plan.effort }, (value, error) => resolve({ value, error }));
17128
+ });
17129
+ if (error)
17130
+ return t("backend.refused", instanceName, error);
17131
+ const result = (value ?? {});
17132
+ if (result.warning)
17133
+ return t("backend.restart_failed", instanceName, plan.backend, result.warning);
17134
+ if (result.restarted)
17135
+ return t("backend.switched", instanceName, plan.backend, shown.model, shown.effort);
17136
+ return t("backend.switched_not_running", instanceName, plan.backend, shown.model, shown.effort, this.getInstanceStatus(instanceName));
17137
+ }
17138
+ /** A step's buttons: index callbacks (a model id can be longer than Telegram's 64-byte callback data allows). */
17139
+ backendStepButtons(instanceName, nonce, step) {
17140
+ if (step.kind === "choose")
17141
+ return step.choices.map((c, i) => ({ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:${i}`, label: c.label }));
17142
+ if (step.kind !== "confirm")
17143
+ return [];
17144
+ const running = this.getInstanceStatus(instanceName) === "running";
17145
+ return [
17146
+ { id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:0`, label: t(running ? "backend.confirm_button" : "backend.confirm_button_stopped") },
17147
+ { id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:1`, label: t("backend.cancel_button") },
17148
+ ];
17149
+ }
17150
+ /**
17151
+ * Show a `/backend` step (a choice, or the confirmation) where the command was given: Discord edits its interaction
17152
+ * reply; Telegram retires the previous step's keyboard and posts the next. One pending entry per step, under a fresh
17153
+ * nonce: a click on an older step finds nothing.
17154
+ */
17155
+ async presentBackendStep(place, step) {
17156
+ const nonce = randomBytes(6).toString("hex");
17157
+ const buttons = this.backendStepButtons(place.instanceName, nonce, step);
17158
+ const timer = setTimeout(() => {
17159
+ const p = this.pendingBackendSelects.get(nonce);
17160
+ if (!p)
17161
+ return;
17162
+ this.pendingBackendSelects.delete(nonce);
17163
+ void this.endBackendMenu(p, t("backend.selection_expired")).catch(() => { });
17164
+ }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
17165
+ timer.unref?.();
17166
+ const pending = { ...place, step, timer };
17167
+ this.pendingBackendSelects.set(nonce, pending);
17168
+ try {
17169
+ if (step.kind === "confirm" && place.respondButtons) {
17170
+ await place.respondButtons(step.text, buttons);
17171
+ return;
17172
+ }
17173
+ if (place.respondChoices) {
17174
+ await place.respondChoices(step.text, buttons);
17175
+ return;
17176
+ }
17177
+ if (!place.adapter || !place.adapterChatId)
17178
+ throw new Error("no place to show the menu");
17179
+ if (place.menuMessageId && place.adapter.editMessageRemoveButtons) {
17180
+ const chosen = t("backend.chosen", joinBackendArgs(step.args));
17181
+ await place.adapter.editMessageRemoveButtons(place.adapterChatId, place.menuMessageId, chosen, place.adapterThreadId).catch(() => { });
17182
+ }
17183
+ const menuMessageId = await place.adapter.promptUser(place.adapterChatId, step.text, buttons, { threadId: place.adapterThreadId });
17184
+ const fresh = this.pendingBackendSelects.get(nonce);
17185
+ if (fresh)
17186
+ fresh.menuMessageId = menuMessageId;
17187
+ }
17188
+ catch (err) {
17189
+ this.pendingBackendSelects.delete(nonce);
17190
+ clearTimeout(timer);
17191
+ this.logger.warn({ err, instanceName: place.instanceName }, "backend menu failed");
17192
+ await place.respond(t("backend.usage")).catch(() => { });
17193
+ }
17194
+ }
17195
+ /** Close a `/backend` menu with a last word: its buttons go (Telegram edits them away), the text stays. Returns where it was said. */
17196
+ async endBackendMenu(pending, text) {
17197
+ if (pending.adapter && pending.adapterChatId) {
17198
+ if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
17199
+ try {
17200
+ await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, text, pending.adapterThreadId);
17201
+ return pending.menuMessageId;
17202
+ }
17203
+ catch { /* say it in a new message */ }
17204
+ }
17205
+ return (await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId })).messageId;
17206
+ }
17207
+ if (pending.retireButtons)
17208
+ await pending.retireButtons(text);
17209
+ else
17210
+ await pending.respond(text);
17211
+ return undefined;
17212
+ }
17213
+ /** Start `/backend` for a fleet instance from Discord or Telegram; the caller has checked the channel admin. */
17214
+ async beginBackendCommand(place, args) {
17215
+ const refusal = this.backendCommandRefusal(place.instanceName);
17216
+ if (refusal) {
17217
+ await place.respond(refusal);
17218
+ return;
17219
+ }
17220
+ if (!place.respondChoices && !(place.adapter && place.adapterChatId)) {
17221
+ await place.respond(t("backend.usage"));
17222
+ return;
17223
+ }
17224
+ place = { ...place, target: this.fleetConfig.instances[place.instanceName] };
17225
+ const deps = this.backendCommandDeps(place.instanceName, {
17226
+ live: true,
17227
+ onLiveProbe: () => { void place.respond(t("model.refreshing")).catch(() => { }); },
17228
+ });
17229
+ const step = await backendStep(deps, place.instanceName, args);
17230
+ if (step.kind === "refuse") {
17231
+ await place.respond(step.text);
17232
+ return;
17233
+ }
17234
+ await this.presentBackendStep(place, step);
17235
+ }
17236
+ /** `/backend` slash handler: channel admin, as `/model`. Options or typed text give the direct form. */
17237
+ async handleBackendSlash(data, adapterId) {
17238
+ if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
17239
+ await data.respond(t("permission.denied"));
17240
+ return;
17241
+ }
17242
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
17243
+ if (!name) {
17244
+ await data.respond(t("classic.no_agent"));
17245
+ return;
17246
+ }
17247
+ const option = (key) => typeof data.options?.[key] === "string" ? data.options[key].trim() : "";
17248
+ const given = [option("backend"), option("model"), option("effort")];
17249
+ // A later option without the one before it says nothing that can be judged in order.
17250
+ if (given.some((value, i) => value && given.slice(0, i).some(before => !before))) {
17251
+ await data.respond(t("backend.usage"));
17252
+ return;
17253
+ }
17254
+ const args = given[0] ? given.filter(Boolean) : backendArgs(data.text ?? "");
17255
+ await this.beginBackendCommand({ instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, respond: data.respond, respondChoices: data.respondChoices,
17256
+ respondButtons: data.respondButtons, retireButtons: data.retireButtons }, args);
17257
+ }
17258
+ /** Telegram `/backend` in a fleet topic (topic-commands) or a ClassicBot chat; the caller has checked the admin. */
17259
+ async runBackendCommand(instanceName, text, userId, channelId, adapter, chatId, threadId, adapterId) {
17260
+ const respond = async (reply) => (await adapter.sendText(chatId, reply, { threadId })).messageId;
17261
+ await this.beginBackendCommand({ instanceName, userId, channelId, adapterId, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId }, backendArgs(text.replace(/^\/backend(@\S+)?/, "")));
17262
+ }
17263
+ /** Consume a `/backend` click: the next step, or on the confirmation the switch itself. True for every backend-select id. */
17264
+ async handleBackendSelection(data, adapterId) {
17265
+ if (!data.callbackData.startsWith(BACKEND_SELECT_CALLBACK_PREFIX))
17266
+ return false;
17267
+ const match = data.callbackData.match(/^backend-select:([0-9a-f]+):(\d{1,2})$/);
17268
+ if (!match)
17269
+ return true;
17270
+ const pending = this.pendingBackendSelects.get(match[1]);
17271
+ if (!pending)
17272
+ return true;
17273
+ // As a /model click (#754 audit): the admin who opened it, through the same bot, in the same channel, still admin.
17274
+ const cbChannel = data.threadId ?? data.chatId;
17275
+ if (!data.userId || data.userId !== pending.userId
17276
+ || (pending.adapterId !== undefined && pending.adapterId !== adapterId)
17277
+ || (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
17278
+ || !this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)) {
17279
+ data.ack?.(t("buttons.admin_only"));
17280
+ return true;
17281
+ }
17282
+ this.pendingBackendSelects.delete(match[1]);
17283
+ clearTimeout(pending.timer);
17284
+ if (!pending.menuMessageId && data.messageId)
17285
+ pending.menuMessageId = data.messageId;
17286
+ const index = Number(match[2]);
17287
+ const { step } = pending;
17288
+ if (step.kind === "choose") {
17289
+ const choice = step.choices[index];
17290
+ if (!choice)
17291
+ return true;
17292
+ if (!this.backendTargetIs(pending.instanceName, pending.target)) {
17293
+ await this.endBackendMenu(pending, t("menu.click_stale")).catch(() => { });
17294
+ return true;
17295
+ }
17296
+ const next = await backendStep(this.backendCommandDeps(pending.instanceName, { live: true }), pending.instanceName, [...step.args, choice.token]);
17297
+ if (next.kind === "refuse") {
17298
+ await this.endBackendMenu(pending, next.text).catch(() => { });
17299
+ return true;
17300
+ }
17301
+ await this.presentBackendStep(pending, next);
17302
+ return true;
17303
+ }
17304
+ if (index === 1) {
17305
+ await this.endBackendMenu(pending, t("backend.cancelled")).catch(() => { });
17306
+ return true;
17307
+ }
17308
+ if (index !== 0)
17309
+ return true;
17310
+ // Confirmed: say so at once, then switch in the background (the relaunch takes a while) and say how it went.
17311
+ const progressId = await this.endBackendMenu(pending, t("backend.switching", pending.instanceName, step.plan.backend)).catch(() => undefined);
17312
+ const say = async (text) => {
17313
+ if (pending.adapter && pending.adapterChatId) {
17314
+ if (progressId)
17315
+ await pending.adapter.editMessage(pending.adapterChatId, progressId, text, pending.adapterThreadId);
17316
+ else
17317
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
17318
+ }
17319
+ else
17320
+ await pending.respond(text);
17321
+ };
17322
+ void (async () => {
17323
+ let result;
17324
+ try {
17325
+ // Asked again after the awaits above: the instance may have moved, or the admin lost admin.
17326
+ const stillCurrent = () => this.backendTargetIs(pending.instanceName, pending.target)
17327
+ && this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId);
17328
+ result = stillCurrent()
17329
+ ? await withSpawnHoldReply(say, () => this.applyBackendSwitch(pending.instanceName, step.args, step.from, stillCurrent))
17330
+ : t("menu.click_stale");
17331
+ }
17332
+ catch (err) {
17333
+ this.logger.error({ err, instance: pending.instanceName, backend: step.plan.backend }, "Backend switch failed");
17334
+ result = t("backend.failed", pending.instanceName, step.plan.backend, err instanceof Error ? err.message : String(err));
17335
+ }
17336
+ await say(result).catch(() => {
17337
+ if (pending.adapter && pending.adapterChatId)
17338
+ pending.adapter.sendText(pending.adapterChatId, result, { threadId: pending.adapterThreadId }).catch(() => { });
17339
+ });
17340
+ })();
17341
+ return true;
17342
+ }
16302
17343
  /** Read recent chat log for agent context */
16303
17344
  getRecentChatLog(instanceName, maxLines = 10) {
16304
17345
  const logDir = ClassicChannelManager.chatLogDir(instanceName);
@@ -16602,6 +17643,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16602
17643
  catch (err) {
16603
17644
  this.logger.warn({ err: err.message, instanceName: ch.instanceName }, "Dropping a removed room from web favorites failed");
16604
17645
  }
17646
+ try {
17647
+ this.themeStore.forget(ch.instanceName);
17648
+ this.webAppearance.limiter.forget(ch.instanceName);
17649
+ this.effectToolsService?.forget(ch.instanceName);
17650
+ }
17651
+ catch (err) {
17652
+ this.logger.warn({ err: err.message, instanceName: ch.instanceName }, "Dropping a removed room's web theme failed");
17653
+ }
16605
17654
  }
16606
17655
  this.instanceWorldBinding.delete(ch.instanceName);
16607
17656
  // #1716: say so in the reply when background work was ended with the stop.
@@ -16711,6 +17760,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16711
17760
  this.eventLogPruneTimer = null;
16712
17761
  }
16713
17762
  this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
17763
+ this.compactWatchService?.stopAll();
16714
17764
  if (this.outboxPruneTimer) {
16715
17765
  clearInterval(this.outboxPruneTimer);
16716
17766
  this.outboxPruneTimer = null;
@@ -18694,37 +19744,41 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18694
19744
  const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0";
18695
19745
  const latest = await npmVersion("@songsid/agend");
18696
19746
  let target = latest;
19747
+ let fromChannel = false;
18697
19748
  // The same channel rule as `agend update` (#1259): an alpha follows @alpha, any other prerelease @beta.
18698
19749
  const channel = installedChannel(currentVersion);
18699
19750
  if (channel !== "latest") {
18700
- // Prerelease users track their own channel (never fall back to @latest,
18701
- // which is older, and an alpha is never offered @beta), but should also
18702
- // hear when a newer STABLE ships — pick whichever is the newest.
18703
19751
  let pre = "";
18704
19752
  try {
18705
19753
  pre = await npmVersion(`@songsid/agend@${channel}`);
18706
19754
  }
18707
19755
  catch { /* no such tag */ }
18708
- target = pre || latest;
18709
- if (latest && this.semverGt(latest, target))
19756
+ // #1926: a beta install hears about @beta only: beta users stay on the beta line after a stable ships, so
19757
+ // @latest is not their target. Taking the higher of the two made a same-core stable (2.2.0 over beta.16) the
19758
+ // target, which the same-core guard then dropped, so the newer beta.17 was never announced.
19759
+ target = pre;
19760
+ fromChannel = true;
19761
+ // An alpha is still told about a stable that passed its own minor (with --stable, #1259): only a stable of
19762
+ // another core that is newer than its channel's release. A same-core @latest (2.2.0 for 2.2.0-alpha.3) is not
19763
+ // taken from @latest and no longer hides a newer alpha; once the release points @alpha at it, it comes from
19764
+ // @alpha like any channel release.
19765
+ const core = (v) => v.replace(/^v/, "").split("-")[0];
19766
+ if (channel === "alpha" && latest && core(latest) !== core(currentVersion) && (!pre || this.semverGt(latest, pre))) {
18710
19767
  target = latest;
19768
+ fromChannel = false;
19769
+ }
18711
19770
  }
18712
- // A beta already at/ahead of its matching stable must NOT be told to
18713
- // "update" to that stable — e.g. 2.0.11-beta.41 already contains everything
18714
- // in stable 2.0.11, so semverGt(2.0.11, 2.0.11-beta.41) being true (a stable
18715
- // outranks a prerelease of the same core) is a false positive here. Suppress
18716
- // only that same-core stable-vs-my-beta case; a higher stable core (2.0.12)
18717
- // or a newer beta (2.0.11-beta.50) still notifies via semverGt below.
18718
- const core = (v) => v.replace(/^v/, "").split("-")[0];
18719
- const betaSupersedesStable = isPrereleaseVersion(currentVersion) && !isPrereleaseVersion(target) && core(target) === core(currentVersion);
18720
19771
  // Only notify when target is genuinely newer (semver), so a beta user on
18721
19772
  // 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
18722
- if (target && !betaSupersedesStable && this.semverGt(target, currentVersion)) {
19773
+ if (target && this.semverGt(target, currentVersion)) {
18723
19774
  const generalId = this.findGeneralInstance();
18724
19775
  if (generalId) {
18725
19776
  // No release URL — Discord's SuppressEmbeds proved unreliable and the
18726
19777
  // link preview looked bad. Version + /update instruction is enough.
18727
- this.notifyInstanceTopic(generalId, t(updateNoticeKey(currentVersion, target), `v${target}`, `v${currentVersion}`));
19778
+ // From the install's own channel tag, this is what /update installs — even a stable the release pointed the
19779
+ // tag at (2.2.0 on @alpha / @beta): the plain /update text, never "/update stays on … use --stable" (#1928).
19780
+ const key = fromChannel ? "update.available_current" : updateNoticeKey(currentVersion, target);
19781
+ this.notifyInstanceTopic(generalId, t(key, `v${target}`, `v${currentVersion}`));
18728
19782
  }
18729
19783
  }
18730
19784
  }
@@ -18791,6 +19845,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18791
19845
  /** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
18792
19846
  dispatchWebHttp(req, res, port, closing = false) {
18793
19847
  try {
19848
+ // #1937: the listeners allow an upload long enough for a slow link; every other request keeps its old bound on
19849
+ // arriving whole — 10 s through the public link's gateway, Node's 300 s here.
19850
+ if (!isUploadRequest(req))
19851
+ limitRequestReceive(req, gatewayRequestContext(req) ? PUBLIC_GATEWAY_RECEIVE_MS : 300_000);
18794
19852
  res.setHeader("Content-Type", "application/json");
18795
19853
  // No Referer to a tunnel host, an upstream proxy, or any page linked from
18796
19854
  // the panel — the dashboard URL is itself a credential-bearing address.
@@ -19132,6 +20190,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19132
20190
  if (!this.webToken || !this.webSessions)
19133
20191
  this.initializeWebAuthTokens();
19134
20192
  const server = createServer((req, res) => this.dispatchWebHttp(req, res, port, drain.closing));
20193
+ // #1937: Node's default (300 s to receive a request) would cut a large upload on a slow link. The listener allows
20194
+ // more; the upload route keeps its own clocks (no byte for a minute, or 30 minutes in all), and every other request
20195
+ // keeps the old 300 s (limitRequestReceive in dispatchWebHttp).
20196
+ server.requestTimeout = UPLOAD_REQUEST_TIMEOUT_MS;
19135
20197
  const drain = trackWebServer(server);
19136
20198
  this.healthServer = server;
19137
20199
  this.healthServerDrain = drain;
@@ -19318,13 +20380,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19318
20380
  // /api/profiles (resolveInstanceIdentity) — the sidebar groups, searches and labels with it on every page.
19319
20381
  const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
19320
20382
  const profiles = profileIdentities(this.dataDir);
20383
+ // #1629 r4 §1: the identity card's version per instance (the page reads the card when it moves).
20384
+ const cards = humanCards(this.dataDir);
20385
+ const colors = identityColors(this.dataDir);
19321
20386
  // #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
19322
- let ownBots = new Map();
20387
+ let bots = { own: new Map(), speaking: new Map() };
19323
20388
  try {
19324
- ownBots = this.ownBots();
20389
+ bots = this.webBots();
19325
20390
  }
19326
20391
  catch (err) {
19327
- this.logger.debug({ err }, "own bots unreadable (getUiStatus)");
20392
+ this.logger.debug({ err }, "bots unreadable (getUiStatus)");
19328
20393
  }
19329
20394
  const instances = names.map(name => {
19330
20395
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
@@ -19363,9 +20428,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19363
20428
  const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
19364
20429
  const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
19365
20430
  const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
20431
+ const compact = this.compactWatchService?.view(name) ?? null;
19366
20432
  let image;
19367
20433
  try {
19368
- image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, ownBots);
20434
+ image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, bots);
19369
20435
  }
19370
20436
  catch (err) {
19371
20437
  this.logger.debug({ err, name }, "avatar unreadable (getUiStatus)");
@@ -19376,6 +20442,21 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19376
20442
  display_name: identity.display_name || undefined,
19377
20443
  avatar: image.avatar,
19378
20444
  ...(image.accent ? { accent: image.accent } : {}),
20445
+ // #1629: the agent's theme's picture and ring colour, for viewers who show agents' themes.
20446
+ ...(image.themeAvatar ? { theme_avatar: image.themeAvatar } : {}),
20447
+ ...(image.themeAccent ? { theme_accent: image.themeAccent } : {}),
20448
+ ...(() => { try {
20449
+ const v = cardVersion(resolveCard(name, cards.get(name), this.themeStore.get(name).theme.card));
20450
+ return v ? { card_v: v } : {};
20451
+ }
20452
+ catch {
20453
+ return {};
20454
+ } })(),
20455
+ // #1958: the identity colour's tiers (the page resolves them: the agent's only while it shows agents' themes).
20456
+ color: { ...(colors.get(name)?.color ? { person: colors.get(name).color } : {}), ...(image.agentColor ? { agent: image.agentColor } : {}),
20457
+ ...(image.roleColor ? { role: image.roleColor } : {}), auto: autoIdentityColor(name) },
20458
+ // #1958 decision 2: where it shows — a person's choice for this instance, the agent's; else the viewer's host default.
20459
+ color_show: { ...(colors.get(name)?.show ? { person: colors.get(name).show } : {}), ...(image.agentShow ? { agent: image.agentShow } : {}) },
19379
20460
  description: identity.description || undefined,
19380
20461
  role: identity.role || undefined,
19381
20462
  tags: identity.tags,
@@ -19390,6 +20471,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19390
20471
  effort,
19391
20472
  effort_source,
19392
20473
  ...this.instancePresentation(name),
20474
+ // Board 003648ac: the last web /compact, while it runs and briefly after (the chat's card follows it).
20475
+ ...(compact ? { compact } : {}),
19393
20476
  };
19394
20477
  });
19395
20478
  return {
@@ -19400,6 +20483,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19400
20483
  connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
19401
20484
  // #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
19402
20485
  prefs_rev: this.webPrefs.rev(),
20486
+ // #1629: the themes' revision — the page re-reads GET /ui/themes when it moves.
20487
+ themes_rev: this.themeStore.rev(),
19403
20488
  // #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
19404
20489
  // shown here rather than in chat). null when normal, unknown or advisory.
19405
20490
  host_memory: this.hostMemoryIndicator(),