@songsid/agend 2.2.0-beta.2 → 2.2.0-beta.20

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 (389) hide show
  1. package/README.md +40 -24
  2. package/README.zh-TW.md +40 -24
  3. package/dist/agent-cli-instructions.md +6 -2
  4. package/dist/agent-cli.js +15 -7
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.js +3 -0
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-whats-new.js +16 -4
  9. package/dist/agent-whats-new.js.map +1 -1
  10. package/dist/auth-api.d.ts +3 -1
  11. package/dist/auth-api.js +17 -1
  12. package/dist/auth-api.js.map +1 -1
  13. package/dist/backend/antigravity.d.ts +33 -0
  14. package/dist/backend/antigravity.js +126 -18
  15. package/dist/backend/antigravity.js.map +1 -1
  16. package/dist/backend/claude-carry.d.ts +22 -0
  17. package/dist/backend/claude-carry.js +170 -0
  18. package/dist/backend/claude-carry.js.map +1 -0
  19. package/dist/backend/claude-code.d.ts +65 -2
  20. package/dist/backend/claude-code.js +226 -33
  21. package/dist/backend/claude-code.js.map +1 -1
  22. package/dist/backend/codex-metadata.d.ts +33 -0
  23. package/dist/backend/codex-metadata.js +122 -18
  24. package/dist/backend/codex-metadata.js.map +1 -1
  25. package/dist/backend/codex.d.ts +3 -0
  26. package/dist/backend/codex.js +17 -1
  27. package/dist/backend/codex.js.map +1 -1
  28. package/dist/backend/credential-profile.d.ts +40 -3
  29. package/dist/backend/credential-profile.js +113 -5
  30. package/dist/backend/credential-profile.js.map +1 -1
  31. package/dist/backend/effort-metadata.d.ts +37 -1
  32. package/dist/backend/effort-metadata.js +86 -15
  33. package/dist/backend/effort-metadata.js.map +1 -1
  34. package/dist/backend/grok.d.ts +4 -0
  35. package/dist/backend/grok.js +14 -0
  36. package/dist/backend/grok.js.map +1 -1
  37. package/dist/backend/kiro.d.ts +27 -1
  38. package/dist/backend/kiro.js +63 -10
  39. package/dist/backend/kiro.js.map +1 -1
  40. package/dist/backend/launch-effort.d.ts +61 -0
  41. package/dist/backend/launch-effort.js +170 -0
  42. package/dist/backend/launch-effort.js.map +1 -0
  43. package/dist/backend/muse.d.ts +84 -0
  44. package/dist/backend/muse.js +449 -53
  45. package/dist/backend/muse.js.map +1 -1
  46. package/dist/backend/types.d.ts +48 -2
  47. package/dist/backend/types.js +12 -1
  48. package/dist/backend/types.js.map +1 -1
  49. package/dist/backend-command.d.ts +91 -0
  50. package/dist/backend-command.js +122 -0
  51. package/dist/backend-command.js.map +1 -0
  52. package/dist/background-work-ended.d.ts +18 -0
  53. package/dist/background-work-ended.js +30 -0
  54. package/dist/background-work-ended.js.map +1 -0
  55. package/dist/cache-service.d.ts +2 -0
  56. package/dist/cache-service.js +2 -1
  57. package/dist/cache-service.js.map +1 -1
  58. package/dist/channel/adapters/discord.d.ts +26 -1
  59. package/dist/channel/adapters/discord.js +103 -3
  60. package/dist/channel/adapters/discord.js.map +1 -1
  61. package/dist/channel/adapters/telegram.d.ts +35 -3
  62. package/dist/channel/adapters/telegram.js +122 -23
  63. package/dist/channel/adapters/telegram.js.map +1 -1
  64. package/dist/channel/mcp-tools.js +5 -3
  65. package/dist/channel/mcp-tools.js.map +1 -1
  66. package/dist/channel/message-edit-errors.d.ts +5 -0
  67. package/dist/channel/message-edit-errors.js +24 -0
  68. package/dist/channel/message-edit-errors.js.map +1 -0
  69. package/dist/channel/telegram-markdown.d.ts +6 -0
  70. package/dist/channel/telegram-markdown.js +104 -0
  71. package/dist/channel/telegram-markdown.js.map +1 -0
  72. package/dist/channel/tool-router.d.ts +2 -0
  73. package/dist/channel/tool-router.js +6 -5
  74. package/dist/channel/tool-router.js.map +1 -1
  75. package/dist/channel/types.d.ts +46 -4
  76. package/dist/cli.js +184 -34
  77. package/dist/cli.js.map +1 -1
  78. package/dist/command-table.d.ts +2 -2
  79. package/dist/command-table.js +5 -2
  80. package/dist/command-table.js.map +1 -1
  81. package/dist/compact-watch.d.ts +86 -0
  82. package/dist/compact-watch.js +288 -0
  83. package/dist/compact-watch.js.map +1 -0
  84. package/dist/config-validator.js +55 -5
  85. package/dist/config-validator.js.map +1 -1
  86. package/dist/config.d.ts +1 -0
  87. package/dist/config.js +2 -1
  88. package/dist/config.js.map +1 -1
  89. package/dist/cross-instance-envelope.js +2 -0
  90. package/dist/cross-instance-envelope.js.map +1 -1
  91. package/dist/daemon.d.ts +129 -1
  92. package/dist/daemon.js +754 -94
  93. package/dist/daemon.js.map +1 -1
  94. package/dist/delivery-outbox.d.ts +64 -8
  95. package/dist/delivery-outbox.js +315 -24
  96. package/dist/delivery-outbox.js.map +1 -1
  97. package/dist/delivery-reconciliation.d.ts +18 -1
  98. package/dist/delivery-reconciliation.js +67 -2
  99. package/dist/delivery-reconciliation.js.map +1 -1
  100. package/dist/doctor.js +7 -0
  101. package/dist/doctor.js.map +1 -1
  102. package/dist/fleet-context.d.ts +12 -1
  103. package/dist/fleet-manager.d.ts +392 -19
  104. package/dist/fleet-manager.js +2030 -275
  105. package/dist/fleet-manager.js.map +1 -1
  106. package/dist/forged-envelope-scan.d.ts +4 -3
  107. package/dist/forged-envelope-scan.js +4 -3
  108. package/dist/forged-envelope-scan.js.map +1 -1
  109. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +84 -16
  110. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +16 -1
  111. package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
  112. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +4 -0
  113. package/dist/host-memory.d.ts +10 -0
  114. package/dist/host-memory.js +33 -1
  115. package/dist/host-memory.js.map +1 -1
  116. package/dist/human-delivery-receipts.d.ts +83 -0
  117. package/dist/human-delivery-receipts.js +194 -0
  118. package/dist/human-delivery-receipts.js.map +1 -0
  119. package/dist/install-lock.d.ts +18 -1
  120. package/dist/install-lock.js +30 -3
  121. package/dist/install-lock.js.map +1 -1
  122. package/dist/instance-avatar.d.ts +5 -4
  123. package/dist/instance-avatar.js +12 -8
  124. package/dist/instance-avatar.js.map +1 -1
  125. package/dist/instance-config-impact.js +1 -0
  126. package/dist/instance-config-impact.js.map +1 -1
  127. package/dist/instance-lifecycle.d.ts +25 -6
  128. package/dist/instance-lifecycle.js +76 -27
  129. package/dist/instance-lifecycle.js.map +1 -1
  130. package/dist/instructions.d.ts +1 -1
  131. package/dist/instructions.js +3 -3
  132. package/dist/instructions.js.map +1 -1
  133. package/dist/kiro-compact-pane.d.ts +38 -0
  134. package/dist/kiro-compact-pane.js +57 -0
  135. package/dist/kiro-compact-pane.js.map +1 -0
  136. package/dist/kiro-log-gate.d.ts +42 -0
  137. package/dist/kiro-log-gate.js +39 -0
  138. package/dist/kiro-log-gate.js.map +1 -0
  139. package/dist/launchd-handoff.d.ts +43 -0
  140. package/dist/launchd-handoff.js +56 -0
  141. package/dist/launchd-handoff.js.map +1 -0
  142. package/dist/locale.js +268 -42
  143. package/dist/locale.js.map +1 -1
  144. package/dist/log-service.d.ts +99 -0
  145. package/dist/log-service.js +243 -0
  146. package/dist/log-service.js.map +1 -0
  147. package/dist/log-steps.d.ts +169 -0
  148. package/dist/log-steps.js +858 -0
  149. package/dist/log-steps.js.map +1 -0
  150. package/dist/login-controller.d.ts +20 -3
  151. package/dist/login-controller.js +71 -19
  152. package/dist/login-controller.js.map +1 -1
  153. package/dist/login-flows.d.ts +5 -2
  154. package/dist/login-flows.js +5 -4
  155. package/dist/login-flows.js.map +1 -1
  156. package/dist/login-shell.d.ts +21 -0
  157. package/dist/login-shell.js +98 -0
  158. package/dist/login-shell.js.map +1 -0
  159. package/dist/memory-pressure.d.ts +13 -1
  160. package/dist/memory-pressure.js +50 -10
  161. package/dist/memory-pressure.js.map +1 -1
  162. package/dist/needs-you-hub.js +2 -0
  163. package/dist/needs-you-hub.js.map +1 -1
  164. package/dist/needs-you-live.d.ts +6 -0
  165. package/dist/needs-you-live.js +23 -3
  166. package/dist/needs-you-live.js.map +1 -1
  167. package/dist/outbound-handlers.d.ts +100 -0
  168. package/dist/outbound-handlers.js +277 -93
  169. package/dist/outbound-handlers.js.map +1 -1
  170. package/dist/outbound-schemas.d.ts +30 -15
  171. package/dist/outbound-schemas.js +23 -5
  172. package/dist/outbound-schemas.js.map +1 -1
  173. package/dist/provider-probe.d.ts +29 -0
  174. package/dist/provider-probe.js +198 -2
  175. package/dist/provider-probe.js.map +1 -1
  176. package/dist/public-link-progress.d.ts +1 -1
  177. package/dist/public-link-progress.js +8 -2
  178. package/dist/public-link-progress.js.map +1 -1
  179. package/dist/public-preview.d.ts +101 -0
  180. package/dist/public-preview.js +236 -0
  181. package/dist/public-preview.js.map +1 -0
  182. package/dist/public-web-gateway.d.ts +2 -0
  183. package/dist/public-web-gateway.js +20 -6
  184. package/dist/public-web-gateway.js.map +1 -1
  185. package/dist/public-web-link.d.ts +24 -1
  186. package/dist/public-web-link.js +63 -9
  187. package/dist/public-web-link.js.map +1 -1
  188. package/dist/quickstart-api.d.ts +1 -1
  189. package/dist/quickstart-api.js +8 -6
  190. package/dist/quickstart-api.js.map +1 -1
  191. package/dist/quickstart.d.ts +12 -0
  192. package/dist/quickstart.js +36 -6
  193. package/dist/quickstart.js.map +1 -1
  194. package/dist/reply-buttons-controller.d.ts +89 -5
  195. package/dist/reply-buttons-controller.js +273 -8
  196. package/dist/reply-buttons-controller.js.map +1 -1
  197. package/dist/reply-buttons.d.ts +48 -3
  198. package/dist/reply-buttons.js +105 -10
  199. package/dist/reply-buttons.js.map +1 -1
  200. package/dist/reply-polls.d.ts +85 -0
  201. package/dist/reply-polls.js +154 -0
  202. package/dist/reply-polls.js.map +1 -0
  203. package/dist/restart-guard.d.ts +6 -3
  204. package/dist/restart-guard.js +6 -3
  205. package/dist/restart-guard.js.map +1 -1
  206. package/dist/restart-progress.d.ts +33 -2
  207. package/dist/restart-progress.js +30 -2
  208. package/dist/restart-progress.js.map +1 -1
  209. package/dist/sd-notify.d.ts +22 -1
  210. package/dist/sd-notify.js +63 -10
  211. package/dist/sd-notify.js.map +1 -1
  212. package/dist/service-activation.d.ts +15 -7
  213. package/dist/service-activation.js +42 -26
  214. package/dist/service-activation.js.map +1 -1
  215. package/dist/service-installer.d.ts +55 -0
  216. package/dist/service-installer.js +90 -3
  217. package/dist/service-installer.js.map +1 -1
  218. package/dist/service-plan.d.ts +11 -0
  219. package/dist/service-plan.js +23 -0
  220. package/dist/service-plan.js.map +1 -1
  221. package/dist/settings-api.js +14 -4
  222. package/dist/settings-api.js.map +1 -1
  223. package/dist/settings-change.js +4 -2
  224. package/dist/settings-change.js.map +1 -1
  225. package/dist/settings-effect.js +2 -0
  226. package/dist/settings-effect.js.map +1 -1
  227. package/dist/spawn-gate.js +5 -1
  228. package/dist/spawn-gate.js.map +1 -1
  229. package/dist/spawn-hold-reply.d.ts +12 -0
  230. package/dist/spawn-hold-reply.js +39 -0
  231. package/dist/spawn-hold-reply.js.map +1 -0
  232. package/dist/systemd-peer-bus.d.ts +95 -0
  233. package/dist/systemd-peer-bus.js +430 -0
  234. package/dist/systemd-peer-bus.js.map +1 -0
  235. package/dist/systemd-runtime.d.ts +27 -1
  236. package/dist/systemd-runtime.js +69 -7
  237. package/dist/systemd-runtime.js.map +1 -1
  238. package/dist/tips.js +1 -1
  239. package/dist/tips.js.map +1 -1
  240. package/dist/tool-permissions.js +3 -3
  241. package/dist/tool-permissions.js.map +1 -1
  242. package/dist/tool-progress.js +3 -2
  243. package/dist/tool-progress.js.map +1 -1
  244. package/dist/topic-commands.d.ts +71 -4
  245. package/dist/topic-commands.js +167 -34
  246. package/dist/topic-commands.js.map +1 -1
  247. package/dist/transcript-delta-reader.d.ts +3 -3
  248. package/dist/transcript-delta-reader.js +8 -8
  249. package/dist/transcript-delta-reader.js.map +1 -1
  250. package/dist/transcript-jsonl.d.ts +85 -3
  251. package/dist/transcript-jsonl.js +156 -13
  252. package/dist/transcript-jsonl.js.map +1 -1
  253. package/dist/transcript-monitor.d.ts +11 -0
  254. package/dist/transcript-monitor.js +61 -13
  255. package/dist/transcript-monitor.js.map +1 -1
  256. package/dist/transcript-sources.d.ts +24 -0
  257. package/dist/transcript-sources.js +211 -56
  258. package/dist/transcript-sources.js.map +1 -1
  259. package/dist/tunnel/cloudflared.d.ts +7 -15
  260. package/dist/tunnel/cloudflared.js +36 -1
  261. package/dist/tunnel/cloudflared.js.map +1 -1
  262. package/dist/tunnel/purpose-lane.d.ts +5 -1
  263. package/dist/tunnel/purpose-lane.js +14 -7
  264. package/dist/tunnel/purpose-lane.js.map +1 -1
  265. package/dist/tunnel/types.d.ts +5 -1
  266. package/dist/tunnel/types.js.map +1 -1
  267. package/dist/turn-resume.d.ts +2 -0
  268. package/dist/turn-resume.js +1 -1
  269. package/dist/turn-resume.js.map +1 -1
  270. package/dist/types.d.ts +22 -0
  271. package/dist/ui/chat-commands.js +1 -0
  272. package/dist/ui/chat-mentions.js +63 -0
  273. package/dist/ui/chat-render.js +80 -10
  274. package/dist/ui/chat-store.js +70 -6
  275. package/dist/ui/chat-strings.js +62 -32
  276. package/dist/ui/chat-thread.js +199 -24
  277. package/dist/ui/chat-time.js +80 -0
  278. package/dist/ui/chat-tour.js +17 -14
  279. package/dist/ui/panel-chat.js +45 -21
  280. package/dist/ui/panel-details.js +2 -0
  281. package/dist/ui/panel-fleet.js +213 -58
  282. package/dist/ui/panel-log.js +428 -0
  283. package/dist/ui/panel-needs.js +14 -10
  284. package/dist/ui/panel-settings.js +82 -17
  285. package/dist/ui/preview-panel.js +55 -15
  286. package/dist/ui/preview-status.js +28 -0
  287. package/dist/ui/preview.js +44 -3
  288. package/dist/ui/settings-dialogs.js +11 -6
  289. package/dist/ui/settings-model.js +3 -1
  290. package/dist/ui/settings-strings.js +32 -12
  291. package/dist/ui/settings-token.js +8 -1
  292. package/dist/ui/settings-wizard.js +5 -3
  293. package/dist/ui/shared/app-i18n.js +41 -15
  294. package/dist/ui/shared/app-prefs.js +250 -0
  295. package/dist/ui/shared/app-route.js +6 -1
  296. package/dist/ui/shared/app-shell.js +165 -8
  297. package/dist/ui/shared/app-store.js +5 -1
  298. package/dist/ui/shared/app-stream.js +26 -5
  299. package/dist/ui/shared/app.css +170 -30
  300. package/dist/ui/shared/app.js +11 -1
  301. package/dist/ui/shared/fonts/README.md +1 -1
  302. package/dist/ui/shared/header-tools.js +83 -13
  303. package/dist/ui/shared/instance-nav.js +178 -51
  304. package/dist/ui/shared/instance-switch.js +8 -5
  305. package/dist/ui/shared/panel-view.js +21 -4
  306. package/dist/ui/shared/signin.js +15 -9
  307. package/dist/ui/shared/theme.js +39 -7
  308. package/dist/ui/shared/tokens.css +1 -1
  309. package/dist/ui/shared/ui-icons.js +2 -0
  310. package/dist/ui/shared/usage-dialog.js +23 -5
  311. package/dist/ui/shared/view-strings.js +40 -4
  312. package/dist/ui/signin.html +8 -3
  313. package/dist/unrecognised-prompt.d.ts +23 -0
  314. package/dist/unrecognised-prompt.js +74 -0
  315. package/dist/unrecognised-prompt.js.map +1 -0
  316. package/dist/update-check.d.ts +31 -0
  317. package/dist/update-check.js +47 -0
  318. package/dist/update-check.js.map +1 -1
  319. package/dist/update-install.d.ts +13 -1
  320. package/dist/update-install.js +16 -4
  321. package/dist/update-install.js.map +1 -1
  322. package/dist/update-launch.d.ts +48 -1
  323. package/dist/update-launch.js +159 -5
  324. package/dist/update-launch.js.map +1 -1
  325. package/dist/update-marker.d.ts +51 -0
  326. package/dist/update-marker.js +61 -0
  327. package/dist/update-marker.js.map +1 -1
  328. package/dist/usage/i18n-keys.d.ts +1 -1
  329. package/dist/usage/i18n-keys.js +1 -1
  330. package/dist/usage/i18n-keys.js.map +1 -1
  331. package/dist/usage/providers.d.ts +62 -13
  332. package/dist/usage/providers.js +256 -68
  333. package/dist/usage/providers.js.map +1 -1
  334. package/dist/usage/statusline-usage.d.ts +6 -2
  335. package/dist/usage/statusline-usage.js +10 -4
  336. package/dist/usage/statusline-usage.js.map +1 -1
  337. package/dist/view-api.js +31 -9
  338. package/dist/view-api.js.map +1 -1
  339. package/dist/web-api.d.ts +82 -4
  340. package/dist/web-api.js +632 -88
  341. package/dist/web-api.js.map +1 -1
  342. package/dist/web-auth.js +5 -2
  343. package/dist/web-auth.js.map +1 -1
  344. package/dist/web-chat-history.d.ts +26 -1
  345. package/dist/web-chat-history.js +47 -8
  346. package/dist/web-chat-history.js.map +1 -1
  347. package/dist/web-commands.d.ts +41 -5
  348. package/dist/web-commands.js +25 -7
  349. package/dist/web-commands.js.map +1 -1
  350. package/dist/web-login.d.ts +14 -0
  351. package/dist/web-login.js +4 -2
  352. package/dist/web-login.js.map +1 -1
  353. package/dist/web-mentions.d.ts +44 -0
  354. package/dist/web-mentions.js +75 -0
  355. package/dist/web-mentions.js.map +1 -0
  356. package/dist/web-prefs.d.ts +81 -0
  357. package/dist/web-prefs.js +297 -0
  358. package/dist/web-prefs.js.map +1 -0
  359. package/dist/web-preview.d.ts +14 -1
  360. package/dist/web-preview.js +35 -13
  361. package/dist/web-preview.js.map +1 -1
  362. package/dist/web-profile.d.ts +32 -0
  363. package/dist/web-profile.js +132 -0
  364. package/dist/web-profile.js.map +1 -0
  365. package/dist/web-reply-files.d.ts +45 -0
  366. package/dist/web-reply-files.js +223 -0
  367. package/dist/web-reply-files.js.map +1 -0
  368. package/dist/web-schedules.d.ts +12 -0
  369. package/dist/web-schedules.js +34 -0
  370. package/dist/web-schedules.js.map +1 -0
  371. package/dist/web-server-drain.d.ts +10 -0
  372. package/dist/web-server-drain.js +50 -0
  373. package/dist/web-server-drain.js.map +1 -0
  374. package/dist/web-session.d.ts +14 -0
  375. package/dist/web-session.js +11 -1
  376. package/dist/web-session.js.map +1 -1
  377. package/dist/web-settings-patch.d.ts +3 -1
  378. package/dist/web-settings-patch.js +4 -1
  379. package/dist/web-settings-patch.js.map +1 -1
  380. package/dist/web-shell-routes.d.ts +4 -0
  381. package/dist/web-shell-routes.js +9 -0
  382. package/dist/web-shell-routes.js.map +1 -1
  383. package/dist/web-upload.d.ts +135 -7
  384. package/dist/web-upload.js +315 -51
  385. package/dist/web-upload.js.map +1 -1
  386. package/launcher/install-admission.cjs +61 -6
  387. package/package.json +3 -3
  388. package/templates/launchd.plist.ejs +3 -1
  389. package/templates/systemd.service.ejs +2 -1
@@ -1,24 +1,28 @@
1
+ import { humanOwnerObjectId, webHumanOrigin } from "./human-delivery-receipts.js";
1
2
  import { SettingsBaselines } from "./settings-baseline.js";
2
3
  import { SettingsConfirmationStore } from "./settings-confirmation.js";
3
4
  import { SettingsHttpConfirmation } from "./settings-http-confirmation.js";
4
5
  import { SettingsControlServer } from "./settings-control.js";
5
6
  import { SettingsExecution, settingsRevision, noteSettingsWrite, settingsFileResource, assertSettingsLease, trySettingsLease, waitSettingsLease, settingsUndo, undoSettingsPaths } from "./settings-transaction.js";
6
7
  import { performance } from "node:perf_hooks";
8
+ import { withSpawnHoldReply } from "./spawn-hold-reply.js";
7
9
  import { gatewayRequestContext } from "./web-request-context.js";
8
- import { createPublicWebGateway } from "./public-web-gateway.js";
10
+ import { createPublicWebGateway, PUBLIC_GATEWAY_RECEIVE_MS } from "./public-web-gateway.js";
9
11
  import { handleWebIconRequest } from "./web-icons.js";
12
+ import { trackWebServer } from "./web-server-drain.js";
10
13
  import { renderPublicLinkProgress, ThrottledMessageEditor } from "./public-link-progress.js";
11
14
  import { PublicWebLink, publicLinkSettings } from "./public-web-link.js";
15
+ import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
16
+ import { CloudflaredProvider } from "./tunnel/cloudflared.js";
12
17
  import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
13
18
  import { ManagedTunnel } from "./tunnel/manager.js";
14
19
  import { withinBudget } from "./monotonic-budget.js";
15
20
  import { measureSyncWork } from "./sync-work-attribution.js";
16
21
  import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
17
22
  import { ProfileControlServer } from "./profile-control.js";
18
- 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";
19
24
  import { atomicWriteFileSync } from "./atomic-write.js";
20
25
  import { createHash, randomBytes, randomUUID } from "node:crypto";
21
- import { spawnSync } from "node:child_process";
22
26
  import { freemem, totalmem, cpus, homedir } from "node:os";
23
27
  import { createServer } from "node:http";
24
28
  import { Worker } from "node:worker_threads";
@@ -27,20 +31,21 @@ import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath
27
31
  import { fileURLToPath } from "node:url";
28
32
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
29
33
  import { fleetLabel } from "./fleet-label.js";
30
- import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
34
+ import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
31
35
  import { formatUpdateProgress } from "./update-progress.js";
32
- import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
36
+ import { sdNotify, sdNotifyBlocking, setSdNotifyWarn } from "./sd-notify.js";
33
37
  import { readFleetMemory } from "./process-memory.js";
34
38
  import { MemoryPressure } from "./memory-pressure.js";
35
39
  import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
36
40
  import { isMap, isScalar, parseDocument } from "yaml";
41
+ import { kiroLogGate } from "./kiro-log-gate.js";
37
42
  const __filename = fileURLToPath(import.meta.url);
38
43
  const __dirname = dirname(__filename);
39
44
  /** Fallback access policy for a channel with no `access:` block — open (no gate). */
40
45
  const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
41
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";
42
47
  import { isProbeableRouteTarget } from "./fleet-context.js";
43
- 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";
44
49
  import { EventLog } from "./event-log.js";
45
50
  import { binaryProbe } from "./binary-probe.js";
46
51
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
@@ -58,7 +63,9 @@ import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
58
63
  import { TelegramAdapter } from "./channel/adapters/telegram.js";
59
64
  import { createBackend } from "./backend/factory.js";
60
65
  import { readEffortMetadata } from "./backend/effort-metadata.js";
61
- 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";
62
69
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
63
70
  import { processAttachments } from "./channel/attachment-handler.js";
64
71
  import { routeToolCall } from "./channel/tool-router.js";
@@ -74,8 +81,9 @@ import { RoutingEngine } from "./routing-engine.js";
74
81
  import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
75
82
  import { TopicArchiver } from "./topic-archiver.js";
76
83
  import { StatuslineWatcher } from "./statusline-watcher.js";
77
- import { outboundHandlers } from "./outbound-handlers.js";
78
- import { DeliveryOutbox } from "./delivery-outbox.js";
84
+ import { applyInstanceConfigPatch, outboundHandlers } from "./outbound-handlers.js";
85
+ import { backendArgs, backendStep, joinBackendArgs, modelNameCarried, planDisplay } from "./backend-command.js";
86
+ import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
79
87
  // The target Daemon can legitimately wait 30 minutes for a busy pane before
80
88
  // deciding to defer before submission. This is an alert threshold only: the
81
89
  // active manager/target generation pair owns the lane until the daemon reports
@@ -127,11 +135,17 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
127
135
  return tasks;
128
136
  }
129
137
  import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
138
+ import { LogService } from "./log-service.js";
130
139
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
131
140
  import { WebChatDiskStore } from "./web-chat-store.js";
141
+ import { SeenUsers, resolveMentions } from "./web-mentions.js";
142
+ import { WebPrefsStore } from "./web-prefs.js";
143
+ import { WEB_NAME_FALLBACK, safeWebName, validateWebName, webNameInUse, webPersonKey } from "./web-profile.js";
132
144
  import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
133
145
  import { ReplyButtonsController } from "./reply-buttons-controller.js";
134
- import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
146
+ import { parsePoll } from "./reply-polls.js";
147
+ import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
148
+ import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
135
149
  import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
136
150
  import { downloadStickerImage } from "./channel/sticker-download.js";
137
151
  import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
@@ -143,14 +157,15 @@ import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permis
143
157
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
144
158
  import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
145
159
  import { LoginSession } from "./login-manager.js";
160
+ import { runOnLoginShell } from "./login-shell.js";
146
161
  import { tightenInstanceDirs } from "./private-dir.js";
147
162
  import { decideSlash } from "./slash-authz.js";
148
163
  import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
149
164
  import { runVisibilityCommand } from "./cross-instance-notice.js";
150
- import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
165
+ import { installedChannel, updateNoticeKey } from "./update-check.js";
151
166
  import { resolveInstalledAgend } from "./update-dispatch.js";
152
- import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
153
- import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
167
+ import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
168
+ import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
154
169
  import { runBeforeDeadline } from "./deadline.js";
155
170
  import { LoginWindowLock } from "./login-window-lock.js";
156
171
  import { handleSettingsRequest } from "./settings-api.js";
@@ -160,11 +175,12 @@ import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet
160
175
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
161
176
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
162
177
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
163
- import { validateFleetConfig } from "./config-validator.js";
178
+ import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
164
179
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
165
180
  import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
166
- import { readLastInboundAt } from "./daemon.js";
181
+ import { DEFAULT_STUCK_TIMEOUT_MS, readLastInboundAt } from "./daemon.js";
167
182
  import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
183
+ import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
168
184
  import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
169
185
  import { TargetQueueWorker } from "./target-queue-worker.js";
170
186
  import { resolveDeliveryWorkerMode } from "./types.js";
@@ -173,12 +189,14 @@ import { isSetupComplete, markSetupComplete } from "./setup-marker.js";
173
189
  import { manualCleanupMessage, reapStaleTunnel } from "./tunnel/lease.js";
174
190
  import { buildToolPermissionsNotice } from "./tool-permissions-notice.js";
175
191
  import { NeedsYouHub } from "./needs-you-hub.js";
176
- import { WEB_CHAT_NOTICE, WEB_REMOTE_DOCS_URL, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
192
+ import { WEB_CHAT_NOTICE, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
177
193
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
178
194
  import { buildOrgChart } from "./web-org.js";
179
195
  import { CacheService, WINDOWS } from "./cache-service.js";
180
196
  import { runWebCommand } from "./web-commands.js";
181
- import { claudeProjectKey } from "./backend/claude-code.js";
197
+ import { CompactWatch, launchIdentity } from "./compact-watch.js";
198
+ import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
199
+ import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
182
200
  import { sharedRolloutIndex } from "./rollout-index.js";
183
201
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
184
202
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
@@ -188,7 +206,7 @@ import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.j
188
206
  import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
189
207
  import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
190
208
  import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
191
- import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
209
+ import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
192
210
  import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
193
211
  import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
194
212
  import { SecretStore } from "./secret-store.js";
@@ -198,10 +216,11 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
198
216
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
199
217
  const SELF_RESTART_DEADLINE_MS = 300_000;
200
218
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
201
- import { credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
219
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
220
+ import { carryClaudeConversation } from "./backend/claude-carry.js";
202
221
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
203
222
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
204
- import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
223
+ import { cleanupWatchVerdict, formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
205
224
  import { launchFullRestartHelper } from "./full-restart.js";
206
225
  import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
207
226
  import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
@@ -252,6 +271,10 @@ export function isOrphanInstanceWindowName(name) {
252
271
  /** Retry cadence for retiring a cancel button whose delete failed (e.g. a DC
253
272
  * forum thread the bot momentarily can't reach). 3 retries × 5min = 15min. */
254
273
  const CANCEL_BTN_RETRY_INTERVAL_MS = 5 * 60_000;
274
+ /** #1753: a carried conversation this large is logged as a warning (it is still carried). */
275
+ const CARRY_LARGE_BYTES = 256 * 1024 * 1024;
276
+ /** #1742: how long after a Telegram /web that could not reach an admin privately a bare /start counts as /start web. */
277
+ const TG_WEB_START_WINDOW_MS = 10 * 60_000;
255
278
  const CANCEL_BTN_MAX_RETRIES = 3;
256
279
  /** Backstop: every 5min, retire a button whose instance has gone idle. Catches
257
280
  * buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
@@ -337,6 +360,7 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
337
360
  const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
338
361
  const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
339
362
  const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
363
+ const BACKEND_SELECT_CALLBACK_PREFIX = "backend-select:";
340
364
  const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
341
365
  const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
342
366
  /** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
@@ -360,6 +384,7 @@ const WEB_MIRRORED_PROMPT_PREFIXES = new Set([
360
384
  const WEB_ONLY_REPLY_SINK = {
361
385
  type: "web",
362
386
  supportsReplyButtons: true, // #1266: the web chat shows a reply's buttons itself
387
+ supportsPolls: true, // #1823: …and a poll (its pages are told; there is nothing to edit)
363
388
  sendText: async () => ({ chatId: "web", messageId: newWebMessageId() }),
364
389
  sendFile: async () => ({ chatId: "web", messageId: newWebMessageId() }),
365
390
  };
@@ -427,6 +452,8 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
427
452
  * and hides exactly the newly released model the user opened `/model` to find.
428
453
  * The wait is announced before it starts, so it reads as progress, not a stall.
429
454
  */
455
+ /** #1690: how long `/model` waits for the daemon's word on our own runtime switch (its dialog wait plus settling). */
456
+ export const RUNTIME_SWITCH_RESULT_MS = 12_000;
430
457
  export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
431
458
  /**
432
459
  * How many CLIs may cold-start at once, from BOTH memory and cores.
@@ -614,6 +641,11 @@ export class FleetManager {
614
641
  webChannelEchoGuards = new Map();
615
642
  /** instanceName → restart currently executing; concurrent callers join it. */
616
643
  restartsInFlight = new Map();
644
+ /**
645
+ * #1753: each in-flight restart's stop → start gap — the conversation carry it will run there (its own, or one a later
646
+ * caller joined with before the gap), and whether the gap has been reached.
647
+ */
648
+ restartGaps = new Map();
617
649
  /**
618
650
  * Delayed automatic retries for instances whose startup failed. Before this a
619
651
  * failed start was logged once and the instance stayed `stopped` until an
@@ -649,6 +681,8 @@ export class FleetManager {
649
681
  /** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
650
682
  pendingEffortSelects = new Map();
651
683
  pendingModelSelects = new Map();
684
+ /** #1806: in-flight /backend steps, keyed by nonce (see handleBackendSelection). */
685
+ pendingBackendSelects = new Map();
652
686
  /** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
653
687
  pendingNonceButtons = new Map();
654
688
  /** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
@@ -718,6 +752,7 @@ export class FleetManager {
718
752
  collabInstances = new Set();
719
753
  // Health endpoint
720
754
  healthServer = null;
755
+ healthServerDrain = null;
721
756
  /** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
722
757
  previewListener = null;
723
758
  previewListening = false;
@@ -729,7 +764,13 @@ export class FleetManager {
729
764
  updateProgressTimer = null;
730
765
  updateProgressEditRunning = false;
731
766
  lastUpdateProgressText = null;
732
- updateCompletionTipText = null;
767
+ /**
768
+ * #1660: an adopted update whose retire outcome is still owed. The
769
+ * completion summary was (or will be) posted; this watch appends the
770
+ * cleanup outcome to the same receipt instead of letting stage edits
771
+ * clobber it — and refuses a receipt that no longer matches (checklist 3/5).
772
+ */
773
+ updateCleanupWatch = null;
733
774
  /** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
734
775
  fullRestartLauncher = launchFullRestartHelper;
735
776
  eventLogPruneTimer = null;
@@ -749,8 +790,8 @@ export class FleetManager {
749
790
  memoryLogAt = null;
750
791
  memoryDebugAt = null;
751
792
  memoryLogLevel = null;
752
- memoryNoticeAt = null;
753
- memoryNoticeLevel = null;
793
+ /** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
794
+ memoryIndicatorShown = null;
754
795
  // Mirror topic: buffer cross-instance messages, flush every 3s
755
796
  mirrorBuffer = [];
756
797
  mirrorTimer = null;
@@ -761,7 +802,95 @@ export class FleetManager {
761
802
  /** #1565: the web chat history's files (set at fleet start, once the home and the instances are known). */
762
803
  webChatStore = null;
763
804
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
764
- webFiles = new WebFileLedger();
805
+ // #1937: the upload limits are the config's, read on each use (agend reload applies a change to the next upload).
806
+ webFiles = new WebFileLedger({ limits: () => uploadLimits(this.fleetConfig?.web) });
807
+ /** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
808
+ webReplyOrder = new Map();
809
+ /** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
810
+ webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
811
+ /** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
812
+ seenUsers = new SeenUsers();
813
+ webPrefsStore;
814
+ /** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
815
+ /**
816
+ * #1827: a web person as agents and chats see them. Signed in as an account (a sign-in code DMed to it): that
817
+ * account's user id — still `via web`, and only for recognition — and the name they chose, else their account's name
818
+ * at sign-in (made safe). Otherwise (an `agend web` session): "web-user", as before, under a name the operator may set.
819
+ * Names are checked again now: one an instance or bot has taken since reads as the next choice down.
820
+ */
821
+ webPerson(owner) {
822
+ const key = webPersonKey(owner);
823
+ let profile = null;
824
+ try {
825
+ profile = this.webPrefs.profile(key);
826
+ }
827
+ catch (err) {
828
+ this.logger.warn({ err: err.message }, "Web profile unavailable");
829
+ }
830
+ const reserved = (n) => this.webNameReserved(n);
831
+ const own = profile?.name !== undefined ? webNameInUse(profile.name, reserved) : WEB_NAME_FALLBACK;
832
+ const chosen = own !== WEB_NAME_FALLBACK;
833
+ const platformName = owner ? safeWebName(profile?.platformName, reserved) : undefined;
834
+ return {
835
+ key, bound: !!owner,
836
+ userId: owner ? owner.userId : WEB_NAME_FALLBACK,
837
+ // A poll ballot is the account's — the same key its votes on the platform have — or the operator's.
838
+ voterKey: owner ? `${owner.adapterId}:${owner.userId}` : "web:operator",
839
+ name: chosen ? own : platformName ?? WEB_NAME_FALLBACK,
840
+ set: chosen,
841
+ ...(platformName !== undefined ? { platformName } : {}),
842
+ };
843
+ }
844
+ /** #1827: set (null: clear — back to their account's name) a web person's own name; validated in full. */
845
+ setWebPersonName(owner, raw) {
846
+ let name = null;
847
+ if (raw !== null) {
848
+ const v = validateWebName(raw, (n) => this.webNameReserved(n));
849
+ if (!v.ok)
850
+ return { ok: false, status: 400, error: v.error };
851
+ name = v.name;
852
+ }
853
+ const w = this.webPrefs.setProfileName(webPersonKey(owner), name);
854
+ return w.ok ? { ok: true, person: this.webPerson(owner) } : { ok: false, status: w.status, error: w.error };
855
+ }
856
+ /** #1827 (auth-api): a sign-in as this account — its display name then is the web name it starts with. */
857
+ noteWebSignIn(account) {
858
+ if (account.username)
859
+ this.webPrefs.setPlatformName(webPersonKey(account), account.username);
860
+ }
861
+ /**
862
+ * #1827: a name that belongs to something here — an instance (or ClassicBot room), its display name, a bot. On the web
863
+ * send path: it never throws (a lookup that fails reserves nothing; the name was checked in full when it was set).
864
+ */
865
+ webNameReserved(lowered) {
866
+ try {
867
+ return this.webNameTaken(lowered);
868
+ }
869
+ catch {
870
+ return false;
871
+ }
872
+ }
873
+ webNameTaken(lowered) {
874
+ const low = (s) => (typeof s === "string" ? s.normalize("NFC").toLocaleLowerCase("en-US") : "");
875
+ for (const [name, cfg] of Object.entries(this.fleetConfig?.instances ?? {})) {
876
+ if (low(name) === lowered || low(cfg.display_name) === lowered)
877
+ return true;
878
+ }
879
+ for (const ch of this.classicChannels?.getAll() ?? [])
880
+ if (low(ch.instanceName) === lowered || low(ch.displayName) === lowered)
881
+ return true;
882
+ for (const w of this.worlds.values())
883
+ if (w.botUsername && low(w.botUsername) === lowered)
884
+ return true;
885
+ return false;
886
+ }
887
+ get webPrefs() {
888
+ // #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
889
+ return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
890
+ exists: (name) => Object.hasOwn(this.fleetConfig?.instances ?? {}, name)
891
+ || (this.classicChannels?.getAll() ?? []).some((ch) => ch.instanceName === name),
892
+ }));
893
+ }
765
894
  /**
766
895
  * Read from disk on every access rather than cached at startup: `agend
767
896
  * web-token rotate` runs in a separate process, and a cached copy would keep
@@ -1103,6 +1232,8 @@ export class FleetManager {
1103
1232
  mayClick: (set, userId) => this.mayAnswerReplyButtons(set.instance, set.adapterId, userId),
1104
1233
  deliver: (set, button, by) => this.deliverReplyButtonChoice(set, button, by),
1105
1234
  publish: (instance, view) => this.emitSseEvent("reply_buttons", { instance, buttons: view }),
1235
+ admitResults: (set, key, text) => this.admitPollResults(set, key, text),
1236
+ monotonic: () => performance.now(),
1106
1237
  logger: this.logger,
1107
1238
  setTimer: (fn, ms) => { const h = setTimeout(fn, ms); h.unref?.(); return h; },
1108
1239
  clearTimer: (h) => clearTimeout(h),
@@ -1111,6 +1242,21 @@ export class FleetManager {
1111
1242
  return this.replyButtonsCtl;
1112
1243
  }
1113
1244
  replyButtonsStore = null;
1245
+ /**
1246
+ * #1823: a closed poll's results go to its instance through the delivery outbox, as a system notice keyed by the
1247
+ * poll: durable (held until the instance can take it) and admitted once, however often this is asked. Addressed to
1248
+ * the poll's chat, so the instance's answer (the right option, the scores) goes there.
1249
+ */
1250
+ admitPollResults(set, key, text) {
1251
+ this.ensureDeliveryOutbox();
1252
+ const outbox = this.deliveryOutbox;
1253
+ if (!outbox)
1254
+ return false;
1255
+ outbox.admitSystemNotice(set.instance, key, text, new Date(), {
1256
+ chatId: set.chatId, threadId: set.threadId || undefined, adapterId: set.adapterId === "web" ? undefined : set.adapterId,
1257
+ });
1258
+ return true;
1259
+ }
1114
1260
  /**
1115
1261
  * #1266: who may answer a reply's buttons on a platform — whoever may message that instance there: in a ClassicBot
1116
1262
  * room anyone there (as a typed message), elsewhere someone the sending connection lets speak (#754/#1148), never a
@@ -1151,7 +1297,7 @@ export class FleetManager {
1151
1297
  const reactions = this.pendingReactionsMeta(set.instance);
1152
1298
  let accepted = false;
1153
1299
  try {
1154
- accepted = (await this.deliverToInstance(set.instance, {
1300
+ accepted = (await this.deliverHumanToInstance(set.instance, {
1155
1301
  type: "fleet_inbound",
1156
1302
  content,
1157
1303
  targetSession: set.instance,
@@ -1160,7 +1306,7 @@ export class FleetManager {
1160
1306
  thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
1161
1307
  ...reactions.meta,
1162
1308
  },
1163
- })) !== false;
1309
+ }, by.humanOrigin, button.label)) !== false;
1164
1310
  }
1165
1311
  finally {
1166
1312
  // Both false and throw reopen the choice in the controller. Finish the
@@ -1174,8 +1320,10 @@ export class FleetManager {
1174
1320
  return false;
1175
1321
  reactions.consume();
1176
1322
  this.afterUserInboundDelivered(set.instance, inbound, deliveryEpoch);
1323
+ // The web chat shows the click as its button's label (a chip); `text` stays what the agent was given.
1177
1324
  this.emitSseEvent("message", {
1178
1325
  instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
1326
+ click: { label: button.label, of: set.id },
1179
1327
  });
1180
1328
  return true;
1181
1329
  }
@@ -1219,8 +1367,13 @@ export class FleetManager {
1219
1367
  if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
1220
1368
  const row = outbox.get(event.deliveryId);
1221
1369
  if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
1222
- const notice = t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
1223
- this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state }, notice);
1370
+ // #1647: the cause goes in the log (the notice points there), and a silent schedule's paste says what it is.
1371
+ const silent = isSilentScheduleDelivery(row);
1372
+ const notice = silent
1373
+ ? t(`delivery.fleet_${row.state}_silent_schedule`, row.targetInstance, row.operationId, row.deliveryId, row.lastError ?? "unknown")
1374
+ : t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
1375
+ this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state,
1376
+ cause: row.lastError, ...(silent ? { silentSchedule: true } : {}) }, notice);
1224
1377
  this.notifyFleetError(notice);
1225
1378
  }
1226
1379
  }
@@ -1274,6 +1427,7 @@ export class FleetManager {
1274
1427
  deliveryId: admitted.delivery.deliveryId,
1275
1428
  state: admitted.delivery.state,
1276
1429
  duplicate: !admitted.inserted,
1430
+ kind: admitted.delivery.kind,
1277
1431
  };
1278
1432
  }
1279
1433
  queryDurableDeliveryStatus(callerInstance, selector) {
@@ -1315,7 +1469,9 @@ export class FleetManager {
1315
1469
  // #926: a restart kills the turn that was working on a request, and a
1316
1470
  // resumed CLI does not pick it back up. Remind the owner once per restart.
1317
1471
  try {
1318
- outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, since: new Date() });
1472
+ const now = new Date();
1473
+ this.expireReplyObligations(now);
1474
+ outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, now, since: now, restartId: daemonBootId });
1319
1475
  }
1320
1476
  catch (err) {
1321
1477
  this.logger.error({ err, owner: name }, "Could not persist the post-restart reply reminder");
@@ -1333,6 +1489,7 @@ export class FleetManager {
1333
1489
  return;
1334
1490
  const stateOf = (name) => this.daemons.get(name)?.getInstanceState();
1335
1491
  try {
1492
+ this.expireReplyObligations(now);
1336
1493
  for (const name of this.daemons.keys()) {
1337
1494
  if (stateOf(name) === "idle")
1338
1495
  outbox.remindReplyObligations(name, { now, graceMs: REPLY_REMINDER_GRACE_MS, reason: "turn-ended" });
@@ -1344,6 +1501,15 @@ export class FleetManager {
1344
1501
  this.logger.error({ err }, "Reply-obligation sweep failed");
1345
1502
  }
1346
1503
  }
1504
+ expireReplyObligations(now) {
1505
+ const hours = this.fleetConfig?.defaults?.reply_obligation_ttl_hours;
1506
+ const duration = typeof hours === "number" && hours > 0 && Number.isFinite(hours * 3_600_000)
1507
+ ? hours * 3_600_000 : DEFAULT_REPLY_OBLIGATION_TTL_MS;
1508
+ for (const row of this.deliveryOutbox.expireReplyObligations({ now, ttlMs: duration })) {
1509
+ this.logger.info({ owner: row.ownerInstance, requester: row.requesterInstance,
1510
+ correlationId: row.correlationId, lastAskedAt: row.lastAskedAt, expiredAt: row.expiredAt }, "Reply obligation expired");
1511
+ }
1512
+ }
1347
1513
  scheduleDeliveryOutboxPump(delayMs = 0) {
1348
1514
  if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
1349
1515
  return;
@@ -1500,6 +1666,7 @@ export class FleetManager {
1500
1666
  content: payload.content,
1501
1667
  delivery_id: claimed.deliveryId,
1502
1668
  delivery_attempt: String(claimed.attemptNo),
1669
+ ...(isSilentScheduleDelivery(claimed) ? { silent_schedule: true } : {}),
1503
1670
  }, { waitForIdle: false, noInlineWake: this.deliveryWorkerMode(target) !== "off" });
1504
1671
  }
1505
1672
  else {
@@ -1583,6 +1750,14 @@ export class FleetManager {
1583
1750
  return Math.max(1, Math.min(20, explicit));
1584
1751
  return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
1585
1752
  }
1753
+ /** #1685: "elevated" or "critical" while host memory pressure slows or holds new agents; otherwise null (an
1754
+ * unknown macOS reading — the advisory case — is unknown, so null too). */
1755
+ hostMemoryIndicator() {
1756
+ if (!this.memoryPressure)
1757
+ return null;
1758
+ const level = this.memoryPressure.snapshot().level;
1759
+ return level === "elevated" || level === "critical" ? level : null;
1760
+ }
1586
1761
  /** Separate cooldown: changing measurements must not create new text-throttle keys. */
1587
1762
  reportMemoryPressure(snapshot) {
1588
1763
  if (this.shuttingDown)
@@ -1593,6 +1768,12 @@ export class FleetManager {
1593
1768
  this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
1594
1769
  }
1595
1770
  const changed = snapshot.level !== this.memoryLogLevel;
1771
+ // #1685: the dashboard's indicator (the status frame's host_memory) is re-sent when it changes.
1772
+ const indicator = this.hostMemoryIndicator();
1773
+ if (indicator !== this.memoryIndicatorShown) {
1774
+ this.memoryIndicatorShown = indicator;
1775
+ this.emitSseEvent("status", this.getUiStatus());
1776
+ }
1596
1777
  if (this.memoryPressure.advisoryOnly()) {
1597
1778
  // The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
1598
1779
  this.memoryLogLevel = snapshot.level;
@@ -1613,20 +1794,6 @@ export class FleetManager {
1613
1794
  : "Host memory sample unavailable — slowing new CLI spawns");
1614
1795
  }
1615
1796
  this.memoryLogLevel = snapshot.level;
1616
- if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
1617
- return;
1618
- const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
1619
- if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
1620
- return;
1621
- const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
1622
- const action = t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing");
1623
- const text = this.memoryPressure.platform === "darwin"
1624
- ? t("memory.kernel_pressure", snapshot.memory.darwinPressureLevel ?? t("memory.unknown"), size(snapshot.memory.availableBytes), size(snapshot.memory.swapFreeBytes), action)
1625
- : t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), action);
1626
- if (this.notifyFleetError(text, { throttle: false })) {
1627
- this.memoryNoticeAt = now;
1628
- this.memoryNoticeLevel = snapshot.level;
1629
- }
1630
1797
  }
1631
1798
  /** Wire the one fleet-wide storm into notification and recovery surfaces. */
1632
1799
  bindStormWindowEvents() {
@@ -1747,7 +1914,7 @@ export class FleetManager {
1747
1914
  isPlannedRestart() {
1748
1915
  return this.shuttingDown || isUpdateInProgress(this.dataDir);
1749
1916
  }
1750
- finishStartup() {
1917
+ finishStartup(bootUpdateMarker = null) {
1751
1918
  this.startupComplete = true;
1752
1919
  this.startNeedsYou();
1753
1920
  // Resolve whatever a previous run — or a setup host that crashed — left
@@ -1757,14 +1924,17 @@ export class FleetManager {
1757
1924
  // the next tunnel and says so, it does not block the fleet.
1758
1925
  this.announceToolPermissionsChange();
1759
1926
  this.tightenInstanceDirectories();
1760
- void reapStaleTunnel(this.dataDir)
1761
- .then(outcome => {
1762
- if (outcome.kind === "manual")
1763
- this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
1764
- else if (outcome.kind === "reaped")
1765
- this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
1766
- })
1767
- .catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
1927
+ // #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way.
1928
+ for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR)]) {
1929
+ void reapStaleTunnel(leaseDir)
1930
+ .then(outcome => {
1931
+ if (outcome.kind === "manual")
1932
+ this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
1933
+ else if (outcome.kind === "reaped")
1934
+ this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
1935
+ })
1936
+ .catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
1937
+ }
1768
1938
  // An existing installation has never written the setup marker — it predates
1769
1939
  // it — so `agend setup` would open a pre-fleet form for a fleet that plainly
1770
1940
  // exists. A fleet that just came up on a config with agents in it is proof
@@ -1784,14 +1954,33 @@ export class FleetManager {
1784
1954
  for (const settled of this.applyJobs.settleAfterRestart()) {
1785
1955
  this.logger.info({ jobId: settled.id }, "Settings apply job settled by fleet restart");
1786
1956
  }
1787
- // We are the post-update fleet: the update is over by definition. Clearing
1788
- // it here (rather than in the update command, which exits before the new
1789
- // fleet is up) is what keeps the quiet window from outliving the restart.
1790
- clearUpdateMarker(this.dataDir);
1957
+ this.clearSettledUpdateMarker(bootUpdateMarker);
1791
1958
  if (this.reloadPending)
1792
1959
  this.scheduleReconcile();
1793
1960
  void this.sweepOrphanedCancelButtons();
1794
1961
  }
1962
+ /**
1963
+ * We are the post-update fleet: the update is over by definition. Clearing it here (rather than in the update
1964
+ * command, which exits before the new fleet is up) is what keeps the quiet window from outliving the restart.
1965
+ * #1660 exception: an update still owing its retire outcome keeps its receipt until the cleanup watch resolves it —
1966
+ * deleting it here is what lost the warning. Terminal stages are still cleared unconditionally.
1967
+ * #1735: and while the watch is armed it owns the marker — even if the outcome landed after the completion posted
1968
+ * (a launchd kickstart or a detached restart returns at once, so it can land at any point of the boot): the watch
1969
+ * appends it, then clears.
1970
+ * #1737 review: and only the marker this boot adopted. One written since — a `/restart full` or a new `/update`
1971
+ * landing while this boot's completion was still being edited — belongs to its writer and is left alone.
1972
+ */
1973
+ clearSettledUpdateMarker(bootUpdateMarker) {
1974
+ if (this.updateCleanupWatch)
1975
+ return;
1976
+ const current = updateMarkerIdentity(this.dataDir);
1977
+ if (current && !sameUpdateMarker(current, bootUpdateMarker))
1978
+ return;
1979
+ const leftover = readUpdateProgress(this.dataDir)?.progress;
1980
+ if (leftover?.cleanup === "pending" && leftover.stage !== "failed" && leftover.stage !== "complete")
1981
+ return;
1982
+ clearUpdateMarker(this.dataDir);
1983
+ }
1795
1984
  // ── ArchiverContext bridge ────────────────────────────────────────────
1796
1985
  lastActivityMs(name) {
1797
1986
  return this.lastActivity.get(name) ?? 0;
@@ -2117,7 +2306,7 @@ export class FleetManager {
2117
2306
  async handleUpdateSlash(data, adapterId) {
2118
2307
  const gate = this.fleetAdminGate(data.userId, adapterId);
2119
2308
  if (gate !== "ok") {
2120
- await data.respond(t(gate === "disabled" ? "update.disabled" : "not_authorized"));
2309
+ await data.respond(gate === "disabled" ? t("update.disabled", this.adminListKey(adapterId)) : t("not_authorized"));
2121
2310
  return;
2122
2311
  }
2123
2312
  const messageId = await data.respond(t("update.progress.preparing", 0));
@@ -2132,24 +2321,29 @@ export class FleetManager {
2132
2321
  // "not a beta" and sent a beta install to @latest. #1450 C5: the INSTALLED agend, verified, by absolute path.
2133
2322
  const installed = await resolveInstalledAgend();
2134
2323
  if (!installed.ok) {
2135
- this.failUpdateProgress(`/update cannot verify the installed AgEnD (${installed.reason}). Run \`agend update\` from a shell.`);
2324
+ this.failUpdateProgress(`${t("update.failure.shell")} ${t("update.failure.installed", installed.reason)}`);
2136
2325
  return;
2137
2326
  }
2138
2327
  const origin = `slash /update by ${adapterId}:${data.userId}`;
2139
2328
  recordInternalRequest(this.dataDir, "update", origin);
2140
2329
  const launch = await resolveUpdateLaunch(installed.agend);
2141
2330
  if (!launch.ok) {
2142
- this.failUpdateProgress(`/update cannot prepare an independent updater (${launch.reason}). Run ` + "`agend update` from a host shell.");
2331
+ this.failUpdateProgress(`${t("update.failure.shell")} ${t("update.failure.independent", launch.reason)}`);
2143
2332
  return;
2144
2333
  }
2145
2334
  if (this.fleetAdminGate(data.userId, adapterId) !== "ok" || this.shuttingDown) {
2146
2335
  this.failUpdateProgress(t("not_authorized"));
2147
2336
  return;
2148
2337
  }
2338
+ const logPath = updateLogPath(this.dataDir);
2339
+ const ts = new Date().toISOString();
2340
+ const logFd = openUpdateLog(logPath, `--- /update ${ts} ---`);
2149
2341
  const child = spawn(launch.command, launch.args, {
2150
- detached: true, stdio: "ignore", env: withOrigin(origin),
2342
+ detached: true, stdio: ["ignore", logFd ? logFd.fd : "ignore", logFd ? logFd.fd : "ignore"], env: withOrigin(origin),
2151
2343
  });
2152
- watchUpdateLaunch(child, message => this.failUpdateProgress(message));
2344
+ if (logFd)
2345
+ closeSync(logFd.fd); // parent closes its copy; the child's fd stays open
2346
+ watchUpdateLaunch(child, message => this.failUpdateProgress(message), logFd ? logPath : undefined, logFd?.offset, installed.agend);
2153
2347
  child.unref();
2154
2348
  }
2155
2349
  async handleProfileSlash(data, adapterId) {
@@ -2575,6 +2769,8 @@ export class FleetManager {
2575
2769
  startsFresh: !backendChanged && credentialSwitchStartsFresh(backend),
2576
2770
  from: profileKey(launched),
2577
2771
  to: profileKey(next),
2772
+ ...(!backendChanged && launched.state !== "invalid" && next.state !== "invalid"
2773
+ ? { carry: { backend, from: profileName(launched), to: profileName(next) } } : {}),
2578
2774
  };
2579
2775
  }
2580
2776
  /** `[instance, effective backend, credential profile]` for everything that is
@@ -2609,6 +2805,81 @@ export class FleetManager {
2609
2805
  return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
2610
2806
  }
2611
2807
  /** #1523 N2: a ClassicBot room's own summary for its Details page (classicBot.yaml), or null. */
2808
+ /** #1218: the Log tab's on-demand service — made on first use; with `web.log_tab` off it answers off and does nothing. */
2809
+ logService = null;
2810
+ get log() {
2811
+ return this.logService ??= new LogService({
2812
+ enabled: () => this.fleetConfig?.web?.log_tab === true,
2813
+ known: (name) => Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, name) || this.classicRoomFor(name) !== null,
2814
+ source: (name) => this.logSource(name),
2815
+ events: (name, limit) => (this.eventLog?.query({ instance: name, limit }) ?? []),
2816
+ emit: (event, data) => this.emitSseEvent(event, data),
2817
+ });
2818
+ }
2819
+ /** Board 003648ac: a web /compact followed to its end, from the transcript the Log reads (none: nothing to follow). */
2820
+ compactWatchService = null;
2821
+ get compactWatch() {
2822
+ return this.compactWatchService ??= new CompactWatch({
2823
+ source: (name) => { const src = this.logSource(name); return { path: src.path, format: src.format }; },
2824
+ // The launch that runs it now (the same value the daemon sends with raw_paste_written).
2825
+ owner: (name) => {
2826
+ const daemon = this.lifecycle.daemons.get(name);
2827
+ try {
2828
+ return daemon ? launchIdentity(daemon.getInteractionSnapshot().owner) : null;
2829
+ }
2830
+ catch {
2831
+ return null;
2832
+ }
2833
+ },
2834
+ paneFollow: (name) => {
2835
+ try {
2836
+ return this.lifecycle.daemons.get(name)?.compactPaneUi() != null;
2837
+ }
2838
+ catch {
2839
+ return false;
2840
+ }
2841
+ },
2842
+ changed: () => this.emitSseEvent("status", this.getUiStatus()),
2843
+ });
2844
+ }
2845
+ /** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
2846
+ logSteps(name, q) { return this.log.steps(name, q); }
2847
+ /**
2848
+ * #1218: where an instance's steps are read from, resolved only when its Log asks: the transcript its daemon follows
2849
+ * now; for claude-code, also the path its statusline last named (so a stopped instance's Log still shows its last
2850
+ * work). kiro: fleet events with the reason its history is not read (kiro-log-gate.ts); other backends have no
2851
+ * transcript AgEnD can read.
2852
+ */
2853
+ logSource(name) {
2854
+ const instances = this.fleetConfig?.instances ?? {};
2855
+ const config = Object.prototype.hasOwnProperty.call(instances, name) ? instances[name] : undefined;
2856
+ const backend = config?.backend ?? (config ? null : this.classicRoomFor(name)?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
2857
+ const live = this.lifecycle.daemons.get(name)?.logTranscriptPath() ?? null;
2858
+ if (backend === "claude-code") {
2859
+ let path = live;
2860
+ if (!path) {
2861
+ try {
2862
+ path = JSON.parse(readFileSync(join(this.getInstanceDir(name), "statusline.json"), "utf-8")).transcript_path ?? null;
2863
+ }
2864
+ catch {
2865
+ path = null;
2866
+ }
2867
+ if (typeof path !== "string" || !path)
2868
+ path = null;
2869
+ }
2870
+ return { backend, path, format: "claude", ...(path ? {} : { reason: "no_transcript" }) };
2871
+ }
2872
+ if (backend === "codex")
2873
+ return { backend, path: live, format: "codex", ...(live ? {} : { reason: "not_running" }) };
2874
+ if (backend === "kiro-cli") {
2875
+ // #1218 PR4: events only until the launch's root and the current conversation are both proven (kiro-log-gate.ts);
2876
+ // the reason names which proof is missing. With today's proofs the gate never opens, and nothing is read for kiro:
2877
+ // the reader is bound to an admitted launch when the proofs arrive (#849 root, #1789 current conversation).
2878
+ const gate = kiroLogGate(this.lifecycle.daemons.get(name)?.logLaunch() ?? null);
2879
+ return { backend, path: null, format: null, reason: gate.open ? "kiro_session_unobservable" : gate.reason };
2880
+ }
2881
+ return { backend, path: null, format: null, reason: "unsupported" };
2882
+ }
2612
2883
  classicRoomFor(name) {
2613
2884
  const ch = this.classicChannels?.getAll().find(channel => channel.instanceName === name);
2614
2885
  if (!ch)
@@ -2910,7 +3181,7 @@ export class FleetManager {
2910
3181
  const queued = this.ipcWaitTails.get(instanceName);
2911
3182
  if (!queued) {
2912
3183
  const ipc = this.instanceIpcClients.get(instanceName);
2913
- if (ipc?.connected && ipc.send(payload))
3184
+ if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
2914
3185
  return true;
2915
3186
  }
2916
3187
  const attempt = (queued ?? Promise.resolve())
@@ -2945,7 +3216,7 @@ export class FleetManager {
2945
3216
  // Re-read every round: a reconnect replaces the IpcClient object entirely,
2946
3217
  // so a cached reference would stay dead forever.
2947
3218
  const ipc = this.instanceIpcClients.get(instanceName);
2948
- if (ipc?.connected && ipc.send(payload))
3219
+ if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
2949
3220
  return true;
2950
3221
  if (Date.now() >= deadline) {
2951
3222
  throw new Error(`Instance '${instanceName}' IPC is unavailable`);
@@ -2980,7 +3251,8 @@ export class FleetManager {
2980
3251
  * or a rebound adapter is never posted through a stale route. A copy
2981
3252
  * already in flight may still land late — the contract permits that.
2982
3253
  */
2983
- async sendClassicWebEcho(instance, text) {
3254
+ /** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
3255
+ async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
2984
3256
  const guard = this.webChannelEchoGuards.get(instance);
2985
3257
  const handled = new Set();
2986
3258
  let posted = 0;
@@ -3011,7 +3283,7 @@ export class FleetManager {
3011
3283
  if (!adapter?.sendText)
3012
3284
  continue;
3013
3285
  await adapter.sendText(entry.channelId, text, { format: "text", allowedMentions: { parse: [] } });
3014
- ClassicChannelManager.logMessage(instance, "web-user", text, new Date());
3286
+ ClassicChannelManager.logMessage(instance, sender, text, new Date());
3015
3287
  posted++;
3016
3288
  }
3017
3289
  catch (err) {
@@ -3020,13 +3292,38 @@ export class FleetManager {
3020
3292
  }
3021
3293
  return posted;
3022
3294
  }
3023
- reserveWebChannelEcho(instanceName, sendEcho) {
3295
+ /**
3296
+ * #1666: a delivered web chat message starts the same cancel/progress lifecycle in the instance's topic as a platform
3297
+ * inbound (afterUserInboundDelivered): the same bubble, ticker and retire rules. Not its ✅ tracking — the web message
3298
+ * id is no platform message, and the echo is the fleet's own post. The web send calls this on the echo's lane.
3299
+ */
3300
+ startWebChatProgress(instanceName) {
3301
+ if (this.shuttingDown)
3302
+ return Promise.resolve();
3303
+ return this.sendCancelButton(instanceName);
3304
+ }
3305
+ /**
3306
+ * `copy: false` (#1666/#1668): an entry that is not a copy of a message — the web send's Cancel/progress bubble. It
3307
+ * keeps its place in the order, but the lane's copy rules do not apply to it: its 5 s budget starts when its turn
3308
+ * starts (waiting behind a slow echo is not its delay), its own timeout only releases the lane (the copies queued
3309
+ * behind it are not "behind an ambiguous copy"), and another entry's timeout never drops it. A copy reserved while
3310
+ * any non-copy is still open starts its own budget when the last of them finishes: a slow bubble is no copy's delay,
3311
+ * however far back the copy is. And a timeout only ever drops copies reserved AFTER the entry that timed out — with
3312
+ * deferred budgets, deadlines no longer grow with the order, so that rule is stated, not implied (#1668 r3).
3313
+ */
3314
+ reserveWebChannelEcho(instanceName, sendEcho, opts = {}) {
3315
+ const copy = opts.copy !== false;
3024
3316
  const epoch = this.getDeliveryEpoch(instanceName);
3025
- const deadlineAt = performance.now() + 5_000;
3317
+ const queued = this.webChannelEchoTails.get(instanceName);
3318
+ // The last open non-copy ahead of this copy: its budget starts when that one finishes.
3319
+ const nonCopyAhead = copy && (queued?.nonCopiesOpen ?? 0) > 0 ? queued?.lastNonCopyDone : undefined;
3320
+ let deadlineAt = copy && !nonCopyAhead ? performance.now() + 5_000 : Number.POSITIVE_INFINITY;
3026
3321
  let decide;
3027
3322
  const admission = new Promise(resolve => { decide = resolve; });
3028
- const queue = this.webChannelEchoTails.get(instanceName)
3323
+ const queue = queued
3029
3324
  ?? { tail: Promise.resolve(), pending: new Set() };
3325
+ const seq = queue.nextSeq ?? 0;
3326
+ queue.nextSeq = seq + 1;
3030
3327
  const previous = queue.tail;
3031
3328
  let resolveDone;
3032
3329
  const tail = new Promise(resolve => { resolveDone = resolve; });
@@ -3037,6 +3334,8 @@ export class FleetManager {
3037
3334
  if (done)
3038
3335
  return;
3039
3336
  done = true;
3337
+ if (!copy)
3338
+ queue.nonCopiesOpen = Math.max(0, (queue.nonCopiesOpen ?? 0) - 1);
3040
3339
  clearTimeout(timer);
3041
3340
  queue.pending.delete(entry);
3042
3341
  if (guard && this.webChannelEchoGuards.get(instanceName) === guard) {
@@ -3044,7 +3343,7 @@ export class FleetManager {
3044
3343
  }
3045
3344
  resolveDone();
3046
3345
  };
3047
- const entry = { started: false, drop: () => {
3346
+ const entry = { seq, started: false, copy, drop: () => {
3048
3347
  this.logger.warn({ instanceName }, "Queued web channel echo dropped after ordering timeout");
3049
3348
  finish();
3050
3349
  } };
@@ -3054,14 +3353,16 @@ export class FleetManager {
3054
3353
  expired = true;
3055
3354
  this.logger.warn({ instanceName, inFlight: entry.started }, "Web channel echo ordering timed out");
3056
3355
  finish();
3057
- // Do not post copies that were queued behind an ambiguous platform send.
3058
- for (const pending of queue.pending)
3059
- if (!pending.started)
3060
- pending.drop();
3356
+ // Do not post copies that were queued behind an ambiguous platform send. A non-copy that timed out was no copy
3357
+ // at all (nothing behind it is ambiguous), and a non-copy is never dropped as one.
3358
+ if (copy)
3359
+ for (const pending of queue.pending)
3360
+ if (pending.seq > seq && !pending.started && pending.copy)
3361
+ pending.drop();
3061
3362
  };
3062
3363
  const checkDeadline = () => {
3063
- if (done)
3064
- return;
3364
+ if (done || deadlineAt === Number.POSITIVE_INFINITY)
3365
+ return; // a non-copy's budget has not started yet
3065
3366
  const remaining = deadlineAt - performance.now();
3066
3367
  if (remaining <= 0)
3067
3368
  expire();
@@ -3070,8 +3371,19 @@ export class FleetManager {
3070
3371
  };
3071
3372
  queue.pending.add(entry);
3072
3373
  queue.tail = tail;
3374
+ if (!copy) {
3375
+ queue.nonCopiesOpen = (queue.nonCopiesOpen ?? 0) + 1;
3376
+ queue.lastNonCopyDone = tail;
3377
+ }
3073
3378
  this.webChannelEchoTails.set(instanceName, queue);
3074
3379
  checkDeadline();
3380
+ // Registered before the admission below (which waits on `previous`, itself after the non-copy ahead), so the budget
3381
+ // is set by the time that runs.
3382
+ if (nonCopyAhead)
3383
+ void nonCopyAhead.then(() => { if (!done) {
3384
+ deadlineAt = performance.now() + 5_000;
3385
+ checkDeadline();
3386
+ } });
3075
3387
  void Promise.all([previous, admission]).then(([, accepted]) => {
3076
3388
  if (done)
3077
3389
  return;
@@ -3080,11 +3392,15 @@ export class FleetManager {
3080
3392
  return;
3081
3393
  }
3082
3394
  // A delayed timer callback must not admit an already expired copy.
3083
- if (performance.now() >= deadlineAt) {
3395
+ if (copy && performance.now() >= deadlineAt) {
3084
3396
  expire();
3085
3397
  return;
3086
3398
  }
3087
3399
  entry.started = true;
3400
+ if (!copy) {
3401
+ deadlineAt = performance.now() + 5_000;
3402
+ checkDeadline();
3403
+ }
3088
3404
  guard = { epoch, deadlineAt };
3089
3405
  this.webChannelEchoGuards.set(instanceName, guard);
3090
3406
  let request;
@@ -3119,6 +3435,101 @@ export class FleetManager {
3119
3435
  instanceLaunchSupportsSteer(instanceName) {
3120
3436
  return this.daemons.get(instanceName)?.launchSupportsSteer();
3121
3437
  }
3438
+ humanReceiptDaemons = new WeakSet();
3439
+ /** Cached FM + lifecycle + Daemon + tmux owner; unknown ownership grants nothing. */
3440
+ humanReceiptOwner(instanceName, daemon = this.daemons.get(instanceName)) {
3441
+ try {
3442
+ if (this.shuttingDown || !daemon || this.daemons.get(instanceName) !== daemon)
3443
+ return null;
3444
+ const epoch = this.lifecycle.epochOf(instanceName);
3445
+ const owner = daemon.captureHumanReceiptOwner();
3446
+ if (!Number.isSafeInteger(epoch) || !owner)
3447
+ return null;
3448
+ return JSON.stringify([this.managerBootId, epoch, this.getDeliveryEpoch(instanceName), humanOwnerObjectId(daemon), owner]);
3449
+ }
3450
+ catch {
3451
+ return null;
3452
+ }
3453
+ }
3454
+ listenForHumanReceipt(instanceName, daemon) {
3455
+ // Lazy initialization also supports narrow prototype-based integration fixtures.
3456
+ this.humanReceiptDaemons ??= new WeakSet();
3457
+ if (this.humanReceiptDaemons.has(daemon))
3458
+ return;
3459
+ this.humanReceiptDaemons.add(daemon);
3460
+ daemon.on("human_receipt_result", (result) => {
3461
+ try {
3462
+ this.deliveryOutbox?.settleHumanReceipt(result.receipt_id, instanceName, result.owner_key, result.proof, this.humanReceiptOwner(instanceName, daemon));
3463
+ }
3464
+ catch (err) {
3465
+ this.logger.error({ err, instanceName }, "Could not persist human delivery evidence");
3466
+ }
3467
+ });
3468
+ }
3469
+ /** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
3470
+ humanReceiptPayload(instanceName, payload, origin, originalBody) {
3471
+ if (!origin || !this.deliveryOutbox)
3472
+ return payload;
3473
+ const row = this.deliveryOutbox.createHumanReceipt({ targetInstance: instanceName, originalBody,
3474
+ origin: { ...origin }, inputKind: String(payload.type), managerBootId: this.managerBootId });
3475
+ return { ...payload, meta: { ...(payload.meta ?? {}),
3476
+ human_receipt_id: row.receipt_id, verification_id: row.message_id } };
3477
+ }
3478
+ /** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
3479
+ bindHumanReceiptPayload(instanceName, payload) {
3480
+ const meta = payload.meta;
3481
+ if (!meta?.human_receipt_id || !this.deliveryOutbox)
3482
+ return payload;
3483
+ const daemon = this.daemons.get(instanceName);
3484
+ const owner = this.humanReceiptOwner(instanceName, daemon);
3485
+ if (!daemon || !owner) {
3486
+ this.deliveryOutbox.markHumanReceiptUncertain(meta.human_receipt_id, instanceName);
3487
+ return payload;
3488
+ }
3489
+ this.listenForHumanReceipt(instanceName, daemon);
3490
+ const daemonOwner = daemon.captureHumanReceiptOwner();
3491
+ const wire = daemon.humanReceiptWire(String(payload.content ?? ""), meta, String(payload.type));
3492
+ const row = this.deliveryOutbox.bindHumanReceipt(meta.human_receipt_id, instanceName, owner, wire, this.managerBootId);
3493
+ if (!daemonOwner || this.humanReceiptOwner(instanceName, daemon) !== owner)
3494
+ throw new Error("Human delivery ownership changed");
3495
+ // /raw retains exact bytes and never acquires a positive verification capability.
3496
+ if (payload.type === "raw_paste" || String(payload.content ?? "").startsWith("/raw ")) {
3497
+ this.deliveryOutbox.markHumanReceiptUncertain(row.receipt_id, instanceName);
3498
+ return payload;
3499
+ }
3500
+ return { ...payload, meta: { ...meta, human_owner_key: owner, human_daemon_owner: daemonOwner, human_wire_sha256: row.wire_sha256 } };
3501
+ }
3502
+ async deliverHumanToInstance(instanceName, payload, origin, originalBody, options) {
3503
+ const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
3504
+ const id = admitted.meta?.human_receipt_id;
3505
+ let sent = false;
3506
+ try {
3507
+ const result = options ? await this.deliverToInstance(instanceName, admitted, options) : await this.deliverToInstance(instanceName, admitted);
3508
+ sent = result !== false;
3509
+ return result;
3510
+ }
3511
+ finally {
3512
+ if (!sent && id)
3513
+ this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
3514
+ }
3515
+ }
3516
+ /** Synchronous steer/btw handoff, using the same evidence and owner binding. */
3517
+ sendHumanControl(instanceName, payload, origin, originalBody) {
3518
+ const ipc = this.instanceIpcClients.get(instanceName);
3519
+ if (this.shuttingDown || !ipc?.connected)
3520
+ return false;
3521
+ const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
3522
+ const id = admitted.meta?.human_receipt_id;
3523
+ let sent = false;
3524
+ try {
3525
+ sent = ipc.send(this.bindHumanReceiptPayload(instanceName, admitted));
3526
+ return sent;
3527
+ }
3528
+ finally {
3529
+ if (!sent && id)
3530
+ this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
3531
+ }
3532
+ }
3122
3533
  /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
3123
3534
  async deliverToInstance(instanceName, payload, options = {}) {
3124
3535
  const deliveryEpoch = this.getDeliveryEpoch(instanceName);
@@ -3199,6 +3610,20 @@ export class FleetManager {
3199
3610
  hasFleetAdmins(adapterId) {
3200
3611
  return (this.adminListOf(adapterId)?.length ?? 0) > 0;
3201
3612
  }
3613
+ /**
3614
+ * Where this adapter's fleet-admin list is in fleet.yaml, as the operator wrote it, for the "disabled" replies:
3615
+ * `channel.access.allowed_users` (the single-channel form), else the `channels:` entry — by its `id:`, or its
3616
+ * `type:` when it has none. Read from the raw file: loading turns `channel:` into `channels` and fills in ids.
3617
+ */
3618
+ adminListKey(adapterId) {
3619
+ const raw = this.rawFleetConfig;
3620
+ if (!Array.isArray(raw.channels) && raw.channel)
3621
+ return "channel.access.allowed_users";
3622
+ const channels = Array.isArray(raw.channels) ? raw.channels : this.fleetConfig?.channels ?? [];
3623
+ const id = adapterId ?? this.getPrimaryAdapterId();
3624
+ const entry = channels.find(ch => (ch.id ?? ch.type) === id);
3625
+ return `channels[${entry?.id ? `id: ${entry.id}` : `type: ${entry?.type ?? id}`}].access.allowed_users`;
3626
+ }
3202
3627
  /**
3203
3628
  * The one question the privileged commands (/update, /doctor, /dashboard, /collab) ask: may THIS caller,
3204
3629
  * through THIS adapter, run it. `disabled` is "nobody can" — an empty list — and is told apart from
@@ -3251,6 +3676,9 @@ export class FleetManager {
3251
3676
  * the check and the act, so the channel's instance cannot be rebound to another bot in between.
3252
3677
  */
3253
3678
  async dispatchSlash(slash, adapterId, adapter) {
3679
+ return withSpawnHoldReply(text => slash.respond(text), () => this.dispatchSlashCore(slash, adapterId, adapter));
3680
+ }
3681
+ async dispatchSlashCore(slash, adapterId, adapter) {
3254
3682
  // #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
3255
3683
  const data = { ...slash, command: canonicalCommand(slash.command) };
3256
3684
  const door = this.slashDoor(data, adapterId);
@@ -3273,6 +3701,9 @@ export class FleetManager {
3273
3701
  if (!decision.allow) {
3274
3702
  this.logger.info({ command: data.command, adapterId, scope, reply: decision.reply[0] }, "Slash command refused by the command table");
3275
3703
  const [key, ...args] = decision.reply;
3704
+ // A command off for want of fleet admins names where they go (`{0}` in its disabled reply).
3705
+ if (decision.reply === spec.disabled)
3706
+ args.push(this.adminListKey(adapterId));
3276
3707
  await data.respond(t(key, ...args)).catch(() => { });
3277
3708
  return;
3278
3709
  }
@@ -3308,7 +3739,7 @@ export class FleetManager {
3308
3739
  userId: data.userId,
3309
3740
  username,
3310
3741
  source: "discord",
3311
- timestamp: new Date(),
3742
+ timestamp: new Date(), adapterId, humanOrigin: data.humanOrigin, humanOriginalBody: text,
3312
3743
  });
3313
3744
  }
3314
3745
  else if (data.command === "save") {
@@ -3332,6 +3763,9 @@ export class FleetManager {
3332
3763
  else if (data.command === "model") {
3333
3764
  await this.handleModelSlash(data, adapterId);
3334
3765
  }
3766
+ else if (data.command === "backend") {
3767
+ await this.handleBackendSlash(data, adapterId);
3768
+ }
3335
3769
  else if (data.command === "clear") {
3336
3770
  await this.handleClearSlash(data, adapterId);
3337
3771
  }
@@ -3425,7 +3859,7 @@ export class FleetManager {
3425
3859
  await data.respond(result.durable ? t("dashboard.revoked", result.count) : t("dashboard.revoked_not_durable", result.count));
3426
3860
  }
3427
3861
  else {
3428
- const owner = this.dashboardOwner(data.userId, adapterId, data.channelId);
3862
+ const owner = this.dashboardOwner(data.userId, adapterId, data.channelId, undefined, data.username);
3429
3863
  if (!owner) {
3430
3864
  await this.sendLocalDashboard(data, adapterId);
3431
3865
  return;
@@ -3453,7 +3887,8 @@ export class FleetManager {
3453
3887
  await data.respond(t("classic.no_agent"));
3454
3888
  return;
3455
3889
  }
3456
- const steerText = String(data.options?.message ?? "").trim();
3890
+ const originalSteerText = String(data.options?.message ?? "");
3891
+ const steerText = originalSteerText.trim();
3457
3892
  if (!steerText) {
3458
3893
  await data.respond(t("steer.usage"));
3459
3894
  return;
@@ -3463,7 +3898,7 @@ export class FleetManager {
3463
3898
  // rerouting the instance's replies to the slash context.
3464
3899
  const result = this.topicCommands.sendSteer(name, steerText, {
3465
3900
  chatId: "", messageId: "", username: data.username ?? "user",
3466
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
3901
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalSteerText,
3467
3902
  });
3468
3903
  await data.respond(result);
3469
3904
  }
@@ -3473,14 +3908,15 @@ export class FleetManager {
3473
3908
  await data.respond(t("classic.no_agent"));
3474
3909
  return;
3475
3910
  }
3476
- const btwText = String(data.options?.message ?? "").trim();
3911
+ const originalBtwText = String(data.options?.message ?? "");
3912
+ const btwText = originalBtwText.trim();
3477
3913
  if (!btwText) {
3478
3914
  await data.respond(t("btw.usage"));
3479
3915
  return;
3480
3916
  }
3481
3917
  const result = this.topicCommands.sendBtw(name, btwText, {
3482
3918
  chatId: "", messageId: "", username: data.username ?? "user",
3483
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
3919
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalBtwText,
3484
3920
  });
3485
3921
  await data.respond(result);
3486
3922
  }
@@ -3780,12 +4216,16 @@ export class FleetManager {
3780
4216
  const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
3781
4217
  // #1220: a running CLI keeps the login it was launched with, so a new
3782
4218
  // credential_profile only takes effect through a restart.
3783
- const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo } = this.classicProfileChange(ch.instanceName, backendChanged);
4219
+ const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo, carry: profileCarry } = this.classicProfileChange(ch.instanceName, backendChanged);
3784
4220
  if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged || profileChanged)) {
3785
4221
  this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel,
3786
4222
  ...(profileChanged ? { profileFrom, profileTo } : {}) }, "Backend/model/profile changed — restarting");
4223
+ const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
3787
4224
  await this.stopInstance(ch.instanceName).catch(() => { });
3788
- if (profileSwitchStartsFresh)
4225
+ // #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
4226
+ if (profileCarry)
4227
+ await this.switchConversation(ch.instanceName, { ...profileCarry, workingDirectory: launchedIn });
4228
+ else if (profileSwitchStartsFresh)
3789
4229
  this.writeFreshStartMarker(ch.instanceName);
3790
4230
  // Small delay to let tmux window clean up
3791
4231
  await new Promise(r => setTimeout(r, 2000));
@@ -4418,6 +4858,9 @@ export class FleetManager {
4418
4858
  // Published so the outage hand-off can wait for an explicit stop that began
4419
4859
  // BEFORE it (the generation fence alone only catches stops that begin after
4420
4860
  // the hand-off snapshotted it).
4861
+ // #1716: scope the marker to this stop — a marker older than beganAt is
4862
+ // from an earlier stop and never reports.
4863
+ const beganAt = Date.now();
4421
4864
  const run = (async () => {
4422
4865
  try {
4423
4866
  await this.lifecycle.stop(name, transition);
@@ -4425,6 +4868,10 @@ export class FleetManager {
4425
4868
  finally {
4426
4869
  this.clearNoncePromptsForInstance(name);
4427
4870
  }
4871
+ const backgroundWorkEnded = readBackgroundWorkEndedMarker(this.getInstanceDir(name), beganAt) !== null;
4872
+ if (backgroundWorkEnded)
4873
+ this.notifyInstanceTopic(name, t("stop.background_work_ended", name));
4874
+ return { backgroundWorkEnded };
4428
4875
  })().finally(() => { if (this.stopsInFlight.get(name) === run)
4429
4876
  this.stopsInFlight.delete(name); });
4430
4877
  this.stopsInFlight.set(name, run);
@@ -4439,6 +4886,16 @@ export class FleetManager {
4439
4886
  const inFlight = this.restartsInFlight.get(name);
4440
4887
  if (inFlight) {
4441
4888
  this.logger.info({ name }, "restartSingleInstance: joining the restart already in flight");
4889
+ // #1753: a credential-profile switch joining it still needs its conversation carried in that restart's stop →
4890
+ // start gap: handed to the restart while it has not reached the gap (and carries nothing else); otherwise not
4891
+ // carried, and said so — the restart has already started, or will start, the CLI without it.
4892
+ if (opts?.carry) {
4893
+ const gap = this.restartGaps.get(name);
4894
+ if (gap && !gap.reached && !gap.carry)
4895
+ gap.carry = opts.carry;
4896
+ else
4897
+ opts.carry.result = { carried: false, reason: gap?.carry ? "a restart in flight carries another switch" : "joined a restart in flight past its stop" };
4898
+ }
4442
4899
  return inFlight;
4443
4900
  }
4444
4901
  const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
@@ -4447,6 +4904,8 @@ export class FleetManager {
4447
4904
  // spawn gate is taken inside the transition, never the other way round, so
4448
4905
  // the start being waited for can still get its own gate slot.
4449
4906
  this.lifecycle.invalidate(name);
4907
+ const gap = { carry: opts?.carry, reached: false };
4908
+ this.restartGaps.set(name, gap);
4450
4909
  const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
4451
4910
  instanceName: name,
4452
4911
  workingDirectory,
@@ -4455,6 +4914,8 @@ export class FleetManager {
4455
4914
  }, () => this.doRestartSingleInstance(name, opts, transition)))
4456
4915
  .finally(() => {
4457
4916
  this.restartsInFlight.delete(name);
4917
+ if (this.restartGaps.get(name) === gap)
4918
+ this.restartGaps.delete(name);
4458
4919
  this.scheduleDeliveryOutboxPump();
4459
4920
  });
4460
4921
  this.restartsInFlight.set(name, run);
@@ -4524,10 +4985,41 @@ export class FleetManager {
4524
4985
  async doRestartWithSlot(name, opts, transition, config, wasPaused) {
4525
4986
  const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
4526
4987
  const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
4988
+ // #1753: the directory the CLI being stopped was launched in — its conversation is under that directory's key.
4989
+ const launchedIn = this.daemons.get(name)?.getConfigSnapshot?.()?.working_directory;
4990
+ // Between the stop and the start: the old CLI's transcript is complete, the new one has not looked yet.
4991
+ /** `launching`: the login the start will use — read only when a switch is to be carried. */
4992
+ const betweenStopAndStart = async (launching) => {
4993
+ // #1753: this restart's own carry, or one a caller joined it with before this point (restartSingleInstance).
4994
+ const gap = this.restartGaps.get(name);
4995
+ const carry = gap?.carry ?? opts?.carry;
4996
+ if (gap)
4997
+ gap.reached = true;
4998
+ if (!carry) {
4999
+ if (opts?.freshStart)
5000
+ this.writeFreshStartMarker(name);
5001
+ return;
5002
+ }
5003
+ // Only into the login this start really launches: a restart that read its configuration before the switch was
5004
+ // saved (one a switch joined) launches the old one — a copy into the new would be reported as carried, falsely.
5005
+ const launch = launching();
5006
+ if (launch.backend !== carry.backend || launch.profile !== carry.to) {
5007
+ const login = (p) => (p ? `profile "${p}"` : "the default login");
5008
+ carry.result = { carried: false, reason: `the restart launches ${launch.backend !== carry.backend ? launch.backend : login(launch.profile)}, not ${login(carry.to)}` };
5009
+ this.logger.info({ name, launches: launch, carry: { backend: carry.backend, from: carry.from, to: carry.to } }, "Conversation not carried: the restart launches another login");
5010
+ // The old login resumes its own conversation; any other starts fresh, as an uncarried switch does.
5011
+ if (launch.backend !== carry.backend || launch.profile !== carry.from)
5012
+ this.writeFreshStartMarker(name);
5013
+ return;
5014
+ }
5015
+ carry.result = await this.switchConversation(name, { ...carry, workingDirectory: launchedIn });
5016
+ };
4527
5017
  if (config) {
4528
5018
  await this.stopInstance(name, transition);
4529
- if (opts?.freshStart)
4530
- this.writeFreshStartMarker(name);
5019
+ await betweenStopAndStart(() => {
5020
+ const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
5021
+ return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, this.fleetConfig?.defaults, backend) : null };
5022
+ });
4531
5023
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
4532
5024
  await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
4533
5025
  this.stormWindow.markRecovered(name);
@@ -4540,8 +5032,10 @@ export class FleetManager {
4540
5032
  const adapterId = this.classicChannels.getAdapterIdByInstance(name);
4541
5033
  await this.stopInstance(name, transition);
4542
5034
  await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
4543
- if (opts?.freshStart)
4544
- this.writeFreshStartMarker(name);
5035
+ await betweenStopAndStart(() => {
5036
+ const backend = this.classicChannels.getBackendByInstance(name, fleetBackend);
5037
+ return { backend, profile: credentialHomeSpec(backend) ? profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults)) : null };
5038
+ });
4545
5039
  await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startClassicInstance(name, this.classicChannels.getBackendByInstance(name, fleetBackend), this.classicChannels.getPreTaskCommand(channelId, adapterId), this.classicChannels.getModel(channelId, adapterId, this.fleetConfig?.defaults?.model), this.classicChannels.getAutoPauseAfter(channelId, adapterId, this.fleetConfig?.defaults?.auto_pause_after), transition));
4546
5040
  this.stormWindow.markRecovered(name);
4547
5041
  return;
@@ -4562,6 +5056,43 @@ export class FleetManager {
4562
5056
  this.logger.warn({ err, name }, "freshStart: failed to write crash-state marker");
4563
5057
  }
4564
5058
  }
5059
+ /**
5060
+ * #1753: a credential-profile switch, between the old CLI's stop and the new one's start. Where the backend can take
5061
+ * the conversation along (claude-code), copy it into the new profile and make it the one `--continue` picks; when that
5062
+ * cannot be done, or the backend cannot, start fresh as before. Null when nothing was tried.
5063
+ */
5064
+ async switchConversation(name, sw) {
5065
+ if (!credentialSwitchCarriesConversation(sw.backend)) {
5066
+ if (credentialSwitchStartsFresh(sw.backend))
5067
+ this.writeFreshStartMarker(name);
5068
+ return null;
5069
+ }
5070
+ let sessionId = null;
5071
+ try {
5072
+ sessionId = readFileSync(join(this.getInstanceDir(name), "session-id"), "utf-8");
5073
+ }
5074
+ catch { /* none: nothing to carry */ }
5075
+ const dir = (profile) => claudeConfigDirFor(profile ? credentialProfileHome(this.dataDir, sw.backend, profile) : null);
5076
+ let result;
5077
+ try {
5078
+ result = await carryClaudeConversation({ sessionId, workingDirectory: sw.workingDirectory ?? "", fromConfigDir: dir(sw.from), toConfigDir: dir(sw.to) });
5079
+ }
5080
+ catch (err) {
5081
+ result = { carried: false, reason: `copy failed: ${err.message}` };
5082
+ }
5083
+ if (result.carried) {
5084
+ const fields = { name, from: sw.from ?? "default", to: sw.to ?? "default", bytes: result.bytes, ms: result.ms, replacedOlderCopy: result.replacedOlderCopy };
5085
+ if (result.bytes > CARRY_LARGE_BYTES)
5086
+ this.logger.warn(fields, "Conversation carried to the new credential profile (a large transcript)");
5087
+ else
5088
+ this.logger.info(fields, "Conversation carried to the new credential profile");
5089
+ }
5090
+ else {
5091
+ this.logger.info({ name, from: sw.from ?? "default", to: sw.to ?? "default", reason: result.reason }, "Conversation not carried to the new credential profile; starting fresh");
5092
+ this.writeFreshStartMarker(name);
5093
+ }
5094
+ return result;
5095
+ }
4565
5096
  /** Load .env file from data dir into process.env */
4566
5097
  loadEnvFile(owner) {
4567
5098
  const envPath = join(this.dataDir, ".env");
@@ -4625,7 +5156,13 @@ export class FleetManager {
4625
5156
  return { display: issued.display, expiresAt: issued.expiresAt, ttlMinutes: Math.round(LOGIN_CODE_TTL_MS / 60_000) };
4626
5157
  }
4627
5158
  tunnelPurposeLane = null;
5159
+ previewTunnel = null;
4628
5160
  publicWebLink = null;
5161
+ /** #1723: the public link's preview tunnel — a manager and lease of its own, never the fleet's one tunnel lane. */
5162
+ getPreviewTunnel() {
5163
+ return this.previewTunnel ??= new ManagedTunnel({ dataDir: join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR),
5164
+ log: () => this.logger.warn("Public preview tunnel cleanup requires attention") });
5165
+ }
4629
5166
  getTunnelLane() {
4630
5167
  return this.tunnelPurposeLane ??= new TunnelPurposeLane(new ManagedTunnel({ dataDir: this.dataDir,
4631
5168
  log: () => this.logger.warn("Managed tunnel cleanup requires attention") }));
@@ -4644,7 +5181,7 @@ export class FleetManager {
4644
5181
  && cfg.topic_id?.toString() === owner.threadId);
4645
5182
  }
4646
5183
  /** Exact owning General. No primary-adapter or name fallback participates in authorization. */
4647
- dashboardOwner(userId, adapterId, address, threadId) {
5184
+ dashboardOwner(userId, adapterId, address, threadId, username) {
4648
5185
  const world = this.worlds.get(adapterId);
4649
5186
  if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
4650
5187
  return null;
@@ -4659,10 +5196,65 @@ export class FleetManager {
4659
5196
  const matched = dc ? topic === address || (group === address && topic === threadId)
4660
5197
  : group === address && ((topic === "1" && (threadId === undefined || threadId === "1")) || topic === threadId);
4661
5198
  if (group && matched)
4662
- return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter };
5199
+ return { userId, adapterId, chatId: group, threadId: topic, binding: world.adapter, ...(username ? { username } : {}) };
4663
5200
  }
4664
5201
  return null;
4665
5202
  }
5203
+ /**
5204
+ * #1742: the General a fleet admin's DM speaks for (`/start web`): the same owner `/web` in that General would make
5205
+ * — this bot's configured group and its one General topic. No address to match in a DM, so it must be unambiguous:
5206
+ * no General, or more than one on this bot and group, and there is no owner (nothing is guessed).
5207
+ */
5208
+ dmDashboardOwner(userId, adapterId, username) {
5209
+ const world = this.worlds.get(adapterId);
5210
+ if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
5211
+ return null;
5212
+ const configured = (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).find(ch => (ch.id ?? ch.type) === adapterId);
5213
+ const group = String(configured?.group_id ?? "");
5214
+ if (!group)
5215
+ return null;
5216
+ const topics = Object.entries(this.fleetConfig?.instances ?? {})
5217
+ .filter(([name, cfg]) => cfg.general_topic && cfg.topic_id != null && this.getInstanceAdapterId(name) === adapterId
5218
+ && String(this.getGroupIdForInstance(name) ?? "") === group)
5219
+ .map(([, cfg]) => cfg.topic_id.toString());
5220
+ if (topics.length !== 1)
5221
+ return null;
5222
+ const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter, ...(username ? { username } : {}) };
5223
+ return this.publicOwnerCurrent(owner) ? owner : null;
5224
+ }
5225
+ /**
5226
+ * #1742: a fleet admin whose Telegram `/web` could not reach them privately may next open the bot's DM and press
5227
+ * Telegram's START, which sends a bare `/start` — read as `/start web` (the DM menu) for 10 minutes, once, for that
5228
+ * admin on that bot. Monotonic: a wall-clock jump neither opens nor shuts it.
5229
+ */
5230
+ tgWebStartWindows = new Map();
5231
+ openTgWebStartWindow(adapterId, userId) {
5232
+ const now = performance.now();
5233
+ for (const [key, until] of this.tgWebStartWindows)
5234
+ if (until <= now)
5235
+ this.tgWebStartWindows.delete(key);
5236
+ this.tgWebStartWindows.set(`${adapterId}\0${userId}`, now + TG_WEB_START_WINDOW_MS);
5237
+ }
5238
+ /** Uses the window up: true when it was open. */
5239
+ takeTgWebStartWindow(adapterId, userId) {
5240
+ const key = `${adapterId}\0${userId}`;
5241
+ const until = this.tgWebStartWindows.get(key);
5242
+ this.tgWebStartWindows.delete(key);
5243
+ return until !== undefined && performance.now() < until;
5244
+ }
5245
+ /** #1742: `/start web` from a fleet admin: the /web menu right here in the DM, for the General this bot serves. */
5246
+ async startWebInDm(msg, adapter) {
5247
+ if (!adapter)
5248
+ return;
5249
+ const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "", msg.username) : null;
5250
+ if (!owner) {
5251
+ await adapter.sendText(msg.chatId, t("dashboard.start_web_ack"));
5252
+ return;
5253
+ }
5254
+ if (!await this.showDashboardMenu(owner, undefined, undefined, msg.chatId)) {
5255
+ await adapter.sendText(msg.chatId, t("dashboard.menu_failed")).catch(() => undefined);
5256
+ }
5257
+ }
4666
5258
  getPublicWebLink() {
4667
5259
  this.initializeWebSessions();
4668
5260
  return this.publicWebLink ??= new PublicWebLink({
@@ -4677,7 +5269,14 @@ export class FleetManager {
4677
5269
  if (result && !result.durable)
4678
5270
  this.logger.warn("Public session revocation could not be saved; exposure remains closed");
4679
5271
  },
4680
- log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
5272
+ log: (event, exposureId, detail) => this.logger.info({ event, exposureId, ...(detail ? { detail } : {}) }, "Public web link"),
5273
+ createPreview: ({ id, expiresAt, binary, protocol, dashboardOrigin, isCurrent }) => {
5274
+ if (!publicPreviewWanted(this.fleetConfig?.web))
5275
+ return null;
5276
+ return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
5277
+ provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
5278
+ log: (event, detail) => this.logger.info({ event, exposureId: id, ...(detail ? { detail } : {}) }, "Public web link") });
5279
+ },
4681
5280
  onCleanupUnconfirmed: (result, owner) => {
4682
5281
  this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
4683
5282
  const world = this.worlds.get(owner.adapterId);
@@ -4700,7 +5299,7 @@ export class FleetManager {
4700
5299
  /** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
4701
5300
  async dashboardMenu(msg) {
4702
5301
  const id = msg.adapterId;
4703
- const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId) : null;
5302
+ const owner = id ? this.dashboardOwner(msg.userId, id, msg.chatId, msg.threadId, msg.username) : null;
4704
5303
  if (!owner) {
4705
5304
  const adapter = id ? this.adapters.get(id) : undefined;
4706
5305
  await adapter?.sendText(msg.chatId, t("dashboard.public_general"), { threadId: msg.threadId });
@@ -4710,6 +5309,16 @@ export class FleetManager {
4710
5309
  await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
4711
5310
  }
4712
5311
  }
5312
+ /**
5313
+ * #1686: build the Telegram deep-link that opens a DM with this bot pre-seeded with `/start web`.
5314
+ * Returns null when no botUsername is known or the username fails Telegram's format validation.
5315
+ */
5316
+ tgStartWebUrl(adapterId) {
5317
+ const username = this.worlds.get(adapterId)?.botUsername;
5318
+ if (!username || !/^[A-Za-z0-9_]{5,32}$/.test(username))
5319
+ return null;
5320
+ return `https://t.me/${username}?start=web`;
5321
+ }
4713
5322
  async sendLocalDashboard(data, adapterId) {
4714
5323
  if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
4715
5324
  await data.respond(t("not_authorized"));
@@ -4723,7 +5332,9 @@ export class FleetManager {
4723
5332
  return;
4724
5333
  }
4725
5334
  this.initializeWebSessions();
4726
- const login = this.webLoginCodes.issue({ epoch: tokenEpoch(token) });
5335
+ // #1827: the code goes privately to this account (a DM, or else this slash command's ephemeral answer): whoever
5336
+ // redeems it is recognised as that account. Recognition only — no owner (this is not an owning General's code).
5337
+ const login = this.webLoginCodes.issue({ epoch: tokenEpoch(token), recipient: { adapterId, userId: data.userId, ...(data.username ? { username: data.username } : {}) } });
4727
5338
  const current = () => !this.shuttingDown && this.adapters.get(adapterId) === adapter
4728
5339
  && this.webToken === token && this.hasFleetAdmins(adapterId) && this.isFleetAdmin(data.userId, adapterId);
4729
5340
  const privateText = `${text}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
@@ -4823,7 +5434,11 @@ export class FleetManager {
4823
5434
  this.logger.info({ code: typeof code === "string" || typeof code === "number" ? code : undefined, err: err?.message }, "Dashboard DM not delivered; answering privately where the command was used");
4824
5435
  }
4825
5436
  /** False when the menu was not shown (the caller still owes the user an answer). */
4826
- async showDashboardMenu(owner, respondButtons, retireButtons) {
5437
+ /**
5438
+ * `dmChatId` (#1742): show it in the owner's own private chat with the bot instead of the General — the owner, its
5439
+ * gates and the code's route are the same; only where the menu sits (and its clicks are good) changes.
5440
+ */
5441
+ async showDashboardMenu(owner, respondButtons, retireButtons, dmChatId) {
4827
5442
  if (!this.publicOwnerCurrent(owner)) {
4828
5443
  this.logger.info("Dashboard menu not shown: the General owner changed");
4829
5444
  return false;
@@ -4831,16 +5446,17 @@ export class FleetManager {
4831
5446
  const adapter = owner.binding;
4832
5447
  const status = this.getPublicWebStatus();
4833
5448
  const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
4834
- const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
4835
- + (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
4836
- + "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
4837
- + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "");
4838
- const choices = [{ action: "local", label: t("dashboard.local") },
5449
+ // Show the action before any link exists. TG's Start instructions belong to confirmed DM refusal below.
5450
+ const menuText = this.topicCommands.getDashboardMenuText()
5451
+ + (publicAllowed ? "\n" + t("dashboard.public_risk") : "")
5452
+ + (status.state !== "closed" ? "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
5453
+ + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "") : "");
5454
+ const choices = [{ action: "local", label: t("dashboard.local_signin") },
4839
5455
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
4840
5456
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
4841
5457
  const posted = await this.postNonceButtonPrompt({
4842
5458
  prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
4843
- chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
5459
+ chatId: dmChatId ?? owner.chatId, threadId: dmChatId === undefined ? owner.threadId : undefined, timeoutMs: 5 * 60_000,
4844
5460
  message: menuText,
4845
5461
  choices, expiredText: t("buttons.stale"), extra: { requesterUserId: owner.userId, dashboardOwner: owner, publicExposureId: this.publicWebLink?.exposureId },
4846
5462
  ...(respondButtons ? { deliver: async (c) => {
@@ -4903,15 +5519,19 @@ export class FleetManager {
4903
5519
  }) : null;
4904
5520
  let lastSteps;
4905
5521
  let deliveredByDm = false;
5522
+ // #1742: a DM was tried and did not go — only then is "open the private chat" the way out.
5523
+ let dmRefused = false;
4906
5524
  const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
4907
5525
  const token = this.webToken;
4908
5526
  if (!token || !current())
4909
5527
  return false;
4910
5528
  this.initializeWebSessions();
4911
5529
  const issued = this.webLoginCodes.issue({ epoch: tokenEpoch(token), audience: exposureId, owner });
4912
- // #1586: the expiry in the fleet's zone with its offset, never a bare UTC ISO string.
4913
- const text = t("dashboard.private_link", url, issued.display, Math.round(LOGIN_CODE_TTL_MS / 60_000), expiresAt ? formatFleetTime(expiresAt) : t("dashboard.local"))
4914
- + "\n" + t("dashboard.code_next");
5530
+ // Only a public link has a link expiry; the code's five-minute lifetime applies to either packet.
5531
+ const minutes = Math.round(LOGIN_CODE_TTL_MS / 60_000);
5532
+ const text = expiresAt !== undefined
5533
+ ? t("dashboard.private_public", url, issued.display, minutes, formatFleetTime(expiresAt))
5534
+ : t("dashboard.private_local", url, issued.display, minutes);
4915
5535
  const deadline = performance.now() + 10_000;
4916
5536
  const direct = owner.binding;
4917
5537
  // The route the sign-in message took: the code-only follow-up takes the same one (#1586).
@@ -4928,6 +5548,7 @@ export class FleetManager {
4928
5548
  }
4929
5549
  catch (err) {
4930
5550
  this.logDashboardDmFailure(err);
5551
+ dmRefused = true;
4931
5552
  if (direct.type !== "discord" || !data.respondPrivate || !current())
4932
5553
  throw new Error("private delivery unavailable");
4933
5554
  const sent = await withinBudget(data.respondPrivate(text, c), deadline);
@@ -4973,11 +5594,23 @@ export class FleetManager {
4973
5594
  this.logger.warn({ err }, "Dashboard sign-in delivery failed");
4974
5595
  }
4975
5596
  // Only safe, static words enter General; never link, code or platform error text.
4976
- const outcome = t(!ok ? "dashboard.private_failed"
4977
- : owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
5597
+ // #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
5598
+ // with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
5599
+ // /start web. A menu shown in the DM itself says the details are right below it. Only when a DM was tried and did
5600
+ // not go: a link that never got that far (the tunnel lane held, the owner gone) says so instead.
5601
+ const telegram = owner.binding.type === "telegram";
5602
+ const dmSteps = !ok && telegram && dmRefused;
5603
+ const tgLink = dmSteps ? this.tgStartWebUrl(adapterId) : null;
5604
+ if (dmSteps)
5605
+ this.openTgWebStartWindow(adapterId, owner.userId);
5606
+ const outcome = t(!ok ? (!telegram ? "dashboard.private_failed" : !dmSteps ? "dashboard.link_failed" : tgLink ? "dashboard.private_failed_tg" : "dashboard.private_failed_tg_nolink")
5607
+ : telegram && entry.chatId === owner.userId ? "dashboard.private_sent_below"
5608
+ : owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
4978
5609
  await progress;
4979
5610
  await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
4980
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked);
5611
+ // #1686: attach the deep-link button on the failure outcome (Telegram only; Discord and the editClicked path ignore urlButtons).
5612
+ const failUrlButtons = tgLink ? [{ label: t("dashboard.start_web_label"), url: tgLink }] : undefined;
5613
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked, failUrlButtons);
4981
5614
  return true;
4982
5615
  }
4983
5616
  /** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
@@ -5087,7 +5720,6 @@ export class FleetManager {
5087
5720
  messageId,
5088
5721
  });
5089
5722
  this.lastUpdateProgressText = null;
5090
- this.updateCompletionTipText = null;
5091
5723
  this.startUpdateProgressMonitor(adapter);
5092
5724
  }
5093
5725
  /** Persist the public response, wait for idle, then start the canonical service restart. */
@@ -5118,7 +5750,6 @@ export class FleetManager {
5118
5750
  return false;
5119
5751
  }
5120
5752
  this.lastUpdateProgressText = null;
5121
- this.updateCompletionTipText = null;
5122
5753
  this.startUpdateProgressMonitor(adapter);
5123
5754
  await this.waitForFullRestartIdleGrace();
5124
5755
  // An update can begin while the idle wait yields. Never launch a second
@@ -5219,6 +5850,86 @@ export class FleetManager {
5219
5850
  setUpdateProgressStage(this.dataDir, "failed", { error: message });
5220
5851
  }
5221
5852
  /** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
5853
+ /**
5854
+ * #1660: arm the post-completion cleanup watch and make sure its monitor
5855
+ * actually runs. The new fleet adopts the receipt at boot but nothing else
5856
+ * starts this timer on that path (Prism a) — without this, the owed
5857
+ * outcome would sit in the marker unwatched. READY never waits for it.
5858
+ */
5859
+ /**
5860
+ * #1735: what the completion of an adopted update does about its retire outcome, read from the marker as it is now.
5861
+ * Already landed with a warning (a detached restart returns before this fleet boots, so the updater retires first)
5862
+ * → render it in the completion. Still owed (`cleanup: "pending"` — a service restart waits for READY, which comes
5863
+ * after the completion) → watch for it. Anything else (no retire on this update, done silently, another receipt)
5864
+ * → nothing.
5865
+ */
5866
+ /**
5867
+ * #1716: background work this restart/update ended, scoped to the operation
5868
+ * so an older stop never reports (`sinceAt` null = plain boot, report
5869
+ * nothing). A method — not a module function — because the #1737
5870
+ * extracted-completion harness runs this region verbatim with only
5871
+ * `this`-methods plus a fixed context.
5872
+ */
5873
+ collectBackgroundWorkEndedNames(names, sinceAt) {
5874
+ if (sinceAt === null)
5875
+ return [];
5876
+ return names.filter(n => readBackgroundWorkEndedMarker(this.getInstanceDir(n), sinceAt) !== null);
5877
+ }
5878
+ updateCleanupAtCompletion(pending, operation) {
5879
+ if (!pending || operation !== "update")
5880
+ return null;
5881
+ const receipt = updateReceiptOf(pending);
5882
+ if (!receipt)
5883
+ return null;
5884
+ const current = readUpdateProgress(this.dataDir);
5885
+ const verdict = cleanupWatchVerdict(current, receipt);
5886
+ if (verdict.action === "append")
5887
+ return { render: { warning: verdict.warning, cleanupCommand: verdict.cleanupCommand } };
5888
+ if (verdict.action === "wait" && current?.progress.cleanup === "pending")
5889
+ return { watch: receipt };
5890
+ return null;
5891
+ }
5892
+ armCleanupWatch(receipt, summary) {
5893
+ this.updateCleanupWatch = { receipt, summary };
5894
+ if (!this.updateProgressTimer)
5895
+ this.startUpdateProgressMonitor();
5896
+ }
5897
+ /**
5898
+ * #1660: append a recorded cleanup outcome to the adopted update message,
5899
+ * then release the receipt — but only if this receipt still owns the
5900
+ * marker. The await yields, so a newer receipt (e.g. a full-restart that
5901
+ * landed mid-edit) must not be cleared or disturbed (Prism b).
5902
+ */
5903
+ async appendCleanupWarning(target, watch, warning, cleanupCommand) {
5904
+ const text = formatRestartProgressCompletion("update", {
5905
+ ...watch.summary,
5906
+ warningText: warning,
5907
+ warningCommand: cleanupCommand,
5908
+ }, watch.receipt.startedAt);
5909
+ const stopWatching = () => {
5910
+ this.updateCleanupWatch = null;
5911
+ if (this.updateProgressTimer)
5912
+ clearInterval(this.updateProgressTimer);
5913
+ this.updateProgressTimer = null;
5914
+ };
5915
+ if (text !== this.lastUpdateProgressText) {
5916
+ const adapter = this.adapters.get(target.adapterId);
5917
+ if (!adapter)
5918
+ return; // retry on a later tick
5919
+ await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
5920
+ this.lastUpdateProgressText = text;
5921
+ }
5922
+ const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
5923
+ if (verdict.action === "drop") {
5924
+ // Someone else owns the marker now — leave it entirely alone.
5925
+ this.updateCleanupWatch = null;
5926
+ return;
5927
+ }
5928
+ if (verdict.action === "wait")
5929
+ return; // not terminal; keep watching
5930
+ clearUpdateMarker(this.dataDir);
5931
+ stopWatching();
5932
+ }
5222
5933
  startUpdateProgressMonitor(initialAdapter) {
5223
5934
  if (this.updateProgressTimer)
5224
5935
  clearInterval(this.updateProgressTimer);
@@ -5232,21 +5943,54 @@ export class FleetManager {
5232
5943
  this.updateProgressTimer = null;
5233
5944
  return;
5234
5945
  }
5946
+ // #1660: the completion summary was already posted; only watch for
5947
+ // the retire outcome owed to this exact receipt. Never stage-edit
5948
+ // here (that would clobber the posted completion), and never touch a
5949
+ // receipt that no longer matches (checklist 3/5).
5950
+ const watch = this.updateCleanupWatch;
5951
+ if (watch) {
5952
+ const stopWatching = () => {
5953
+ this.updateCleanupWatch = null;
5954
+ if (this.updateProgressTimer)
5955
+ clearInterval(this.updateProgressTimer);
5956
+ this.updateProgressTimer = null;
5957
+ };
5958
+ const verdict = cleanupWatchVerdict(marker, watch.receipt);
5959
+ if (verdict.action === "drop") {
5960
+ // Receipt gone or replaced (e.g. a later full-restart adopted the
5961
+ // marker): not ours — leave it to its own lifecycle, drop the watch.
5962
+ this.logger.info("[update] cleanup watch: marker receipt changed, dropping watch");
5963
+ this.updateCleanupWatch = null;
5964
+ }
5965
+ else if (verdict.action === "release") {
5966
+ // Done silent, or the updater died past the max age: release it.
5967
+ clearUpdateMarker(this.dataDir);
5968
+ stopWatching();
5969
+ return;
5970
+ }
5971
+ else if (verdict.action === "wait") {
5972
+ return;
5973
+ }
5974
+ else {
5975
+ this.updateProgressEditRunning = true;
5976
+ try {
5977
+ await this.appendCleanupWarning(marker.progress.target, watch, verdict.warning, verdict.cleanupCommand);
5978
+ }
5979
+ catch (err) {
5980
+ this.logger.warn({ err }, "[update] cleanup watch: failed to append warning, will retry");
5981
+ }
5982
+ finally {
5983
+ this.updateProgressEditRunning = false;
5984
+ }
5985
+ return;
5986
+ }
5987
+ }
5235
5988
  const target = marker.progress.target;
5236
5989
  const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
5237
5990
  if (!adapter)
5238
5991
  return;
5239
- let text = formatUpdateProgress(marker);
5240
- // No-restart updates complete in the old fleet process, so they never
5241
- // reach RestartProgress.finish(). Append the same optional tip here.
5242
- if (marker.progress.stage === "complete" && this.tipsEnabled()) {
5243
- if (this.updateCompletionTipText === null) {
5244
- const tip = this.pickAvailableTip();
5245
- this.updateCompletionTipText = tip ? this.formatTip(tip) : "";
5246
- }
5247
- if (this.updateCompletionTipText)
5248
- text += `\n\n${this.updateCompletionTipText}`;
5249
- }
5992
+ // #1854: no tip on an update's completion (either path); tips come on their own schedule and from /tips.
5993
+ const text = formatUpdateProgress(marker);
5250
5994
  if (text === this.lastUpdateProgressText)
5251
5995
  return;
5252
5996
  this.updateProgressEditRunning = true;
@@ -5297,7 +6041,10 @@ export class FleetManager {
5297
6041
  this.rotateFleetLogs();
5298
6042
  const fleet = this.loadConfig(configPath);
5299
6043
  this.slimFleetConfigAtStartup();
6044
+ this.logConfigWarningsAtStartup();
5300
6045
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
6046
+ // #1737 review: the marker this boot adopts — the startup clear may only ever clear this one.
6047
+ const bootUpdateMarker = updateMarkerIdentity(this.dataDir);
5301
6048
  const savedUpdateProgress = readUpdateProgress(this.dataDir);
5302
6049
  const pendingUpdateProgress = savedUpdateProgress
5303
6050
  && savedUpdateProgress.progress.stage !== "failed"
@@ -5322,7 +6069,11 @@ export class FleetManager {
5322
6069
  // Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
5323
6070
  // before instances spawn, so a CLI that comes up on its sign-in screen gets
5324
6071
  // an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
5325
- this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => config.backend ?? fleet.defaults?.backend ?? "claude-code"));
6072
+ this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => {
6073
+ const backend = config.backend ?? fleet.defaults?.backend ?? "claude-code";
6074
+ // #1699 follow-up: each instance's own login — a profile is probed with its own environment.
6075
+ return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, fleet.defaults, backend) : null };
6076
+ }));
5326
6077
  // Start tmux control mode client for idle detection
5327
6078
  if (!this.controlClient) {
5328
6079
  this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
@@ -5626,6 +6377,7 @@ export class FleetManager {
5626
6377
  // into the same broken state, and a user who has legitimately stopped every
5627
6378
  // instance would get a restart loop. Those conditions surface through /health
5628
6379
  // (which now returns 503) and through the General-topic notifications instead.
6380
+ setSdNotifyWarn((fields, message) => this.logger.warn(fields, message));
5629
6381
  this.watchdogTimer = setInterval(() => sdNotify("WATCHDOG=1"), 30_000);
5630
6382
  // #1231: a stalled loop is what turns a slash command into "did not respond";
5631
6383
  // put each long stall in the log instead of leaving it to be guessed at.
@@ -5727,19 +6479,31 @@ export class FleetManager {
5727
6479
  const _require = createRequire(import.meta.url);
5728
6480
  const agendVersion = _require("../package.json").version ?? "unknown";
5729
6481
  await progressStart;
5730
- const progressCompleted = await startupProgress.finish({
6482
+ // #1660/#1735: the nvm route's retire outcome, read NOW (not from the boot snapshot). Either order happens:
6483
+ // a detached fleet's `agend restart` returns before this fleet boots, so the outcome may already be here —
6484
+ // render it in the completion itself; a service restart waits for READY (after this), so it may still be owed
6485
+ // — post without it and arm the watch, which appends it to the same receipt once it lands. READY never waits
6486
+ // for cleanup (that would deadlock with an updater that retires after its restart returns).
6487
+ const cleanupOutcome = this.updateCleanupAtCompletion(pendingUpdateProgress, pendingProgressOperation);
6488
+ // #1716: background work this restart/update ended, scoped to the
6489
+ // operation so an older stop never reports. No pending operation means
6490
+ // a plain boot — nothing was stopped by an operation, report nothing.
6491
+ const backgroundWorkEndedNames = this.collectBackgroundWorkEndedNames(configuredNames, pendingUpdateProgress?.startedAt ?? null);
6492
+ const completionSummary = {
5731
6493
  running: started,
5732
6494
  total,
5733
6495
  version: agendVersion,
5734
6496
  pausedNames,
5735
6497
  failedNames,
5736
- tipText: pendingProgressOperation === "update" && this.tipsEnabled()
5737
- ? (() => {
5738
- const tip = this.pickAvailableTip();
5739
- return tip ? this.formatTip(tip) : undefined;
5740
- })()
5741
- : undefined,
5742
- });
6498
+ backgroundWorkEndedNames,
6499
+ ...(cleanupOutcome && "render" in cleanupOutcome
6500
+ ? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
6501
+ : {}),
6502
+ };
6503
+ const progressCompleted = await startupProgress.finish(completionSummary);
6504
+ // Armed only after the completion posted: an append racing ahead of it would be overwritten by it.
6505
+ if (cleanupOutcome && "watch" in cleanupOutcome)
6506
+ this.armCleanupWatch(cleanupOutcome.watch, completionSummary);
5743
6507
  if (!progressCompleted && fleet.channel?.group_id) {
5744
6508
  let text;
5745
6509
  if (pendingProgressOperation === "full-restart") {
@@ -5749,6 +6513,7 @@ export class FleetManager {
5749
6513
  version: agendVersion,
5750
6514
  pausedNames,
5751
6515
  failedNames,
6516
+ backgroundWorkEndedNames,
5752
6517
  }, pendingUpdateProgress.startedAt);
5753
6518
  }
5754
6519
  else if (failedNames.length === 0 && pausedNames.length === 0) {
@@ -5761,6 +6526,18 @@ export class FleetManager {
5761
6526
  text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
5762
6527
  + (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
5763
6528
  }
6529
+ // #1716: the fallback carries the same background-work names as the
6530
+ // completion (inline: this block is executed verbatim by the #1737
6531
+ // extracted-completion harness, which resolves `this`-methods plus a
6532
+ // fixed context — no new bare module references here).
6533
+ if (backgroundWorkEndedNames.length) {
6534
+ text += `\n⚠️ Background work was running and was ended: ${backgroundWorkEndedNames.join(", ")}`;
6535
+ }
6536
+ // #1735: an outcome that already landed goes in this message too; one still owed is the watch's.
6537
+ if (cleanupOutcome && "render" in cleanupOutcome) {
6538
+ text += `\n\n⚠️ ${cleanupOutcome.render.warning}`
6539
+ + (cleanupOutcome.render.cleanupCommand ? `\n\`$ ${cleanupOutcome.render.cleanupCommand}\`` : "");
6540
+ }
5764
6541
  await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
5765
6542
  }
5766
6543
  // After "fleet ready", so the notice is not the first thing people see of a restart.
@@ -5769,6 +6546,7 @@ export class FleetManager {
5769
6546
  // Health HTTP endpoint
5770
6547
  this.startHealthServer(fleet.health_port ?? 19280);
5771
6548
  // #1266: buttons that ended while AgEnD was down (expired, or chosen before a restart) are shown as ended now.
6549
+ // #1823: and polls: a deadline that passed meanwhile closes now (results once); an open one's countdown is redrawn.
5772
6550
  void this.replyButtons()?.sweep().catch(err => this.logger.warn({ err }, "Reply-button sweep failed"));
5773
6551
  // Daily update check — first check after 1 hour, then every 24 hours
5774
6552
  this.updateCheckTimer = setTimeout(() => {
@@ -5782,7 +6560,7 @@ export class FleetManager {
5782
6560
  // A SIGHUP may arrive after the PID/general is available but before the
5783
6561
  // rest of startup finishes. Replay one coalesced reload only after all
5784
6562
  // startup-owned lifecycle work and signal handlers are in place.
5785
- this.finishStartup();
6563
+ this.finishStartup(bootUpdateMarker);
5786
6564
  // Tell systemd we are ready only now. This used to fire right after the
5787
6565
  // generals started — before adapters, classic instances, topic creation and the
5788
6566
  // health server — so `systemctl start` returned success while the fleet was
@@ -6510,6 +7288,24 @@ export class FleetManager {
6510
7288
  });
6511
7289
  }
6512
7290
  }
7291
+ else if (msg.type === "raw_paste_written") {
7292
+ // Board 003648ac: a web /compact's paste reached the pane (or never will): its watch starts, or ends as lost.
7293
+ if (typeof msg.id === "string")
7294
+ this.compactWatchService?.pasted(name, msg.id, msg.outcome === "written", { owner: msg.owner, checkpoint: msg.checkpoint, pane: msg.pane });
7295
+ }
7296
+ else if (msg.type === "compact_pane_result") {
7297
+ // Board 907c7899: kiro's pane said how that /compact ended.
7298
+ if (typeof msg.id === "string")
7299
+ this.compactWatchService?.paneResult(name, msg.id, msg.owner, msg.outcome);
7300
+ }
7301
+ else if (msg.type === "runtime_switch_result") {
7302
+ // #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
7303
+ const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
7304
+ if (waiter) {
7305
+ this.runtimeSwitchWaiters.delete(msg.id);
7306
+ waiter(msg);
7307
+ }
7308
+ }
6513
7309
  else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
6514
7310
  this.cacheInstanceExecutionState(name, msg);
6515
7311
  if (msg.type === "instance_state_response") {
@@ -7022,6 +7818,24 @@ export class FleetManager {
7022
7818
  });
7023
7819
  }
7024
7820
  async handleInboundMessage(msg) {
7821
+ msg = { ...msg, humanOriginalBody: msg.humanOriginalBody ?? msg.text, humanOrigin: msg.isBotMessage ? undefined : msg.humanOrigin && { ...msg.humanOrigin } };
7822
+ if (msg.isBotMessage)
7823
+ return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
7824
+ return withSpawnHoldReply(text => this.inboundNoticeAdapter(msg)?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
7825
+ }
7826
+ /**
7827
+ * #1752: the bot that speaks back about an inbound message — in a fleet topic the owning instance's adapter, never
7828
+ * the sibling that happened to win the dedup race; elsewhere (ClassicBot rooms, no routed instance) the receiver.
7829
+ */
7830
+ inboundNoticeAdapter(msg) {
7831
+ const id = this.governingAccess(msg, this.inboundRouteThreadId(msg)).adapterId ?? "";
7832
+ return this.worlds.get(id)?.adapter ?? this.adapters.get(id) ?? this.adapter;
7833
+ }
7834
+ async handleInboundMessageCore(msg) {
7835
+ // #1682: who wrote, for the web chat's mention pills (a person, never one of the fleet's bots: those are named by
7836
+ // the instance they belong to).
7837
+ if (!msg.isBotMessage && msg.userId && msg.username)
7838
+ this.seenUsers.record(msg.userId, msg.username);
7025
7839
  // Platform author identity is the authority. This runs before routing,
7026
7840
  // collab/commands/access/dedup and does not depend on any echo setting/ACK.
7027
7841
  const fleetBotIds = new Set();
@@ -7049,6 +7863,15 @@ export class FleetManager {
7049
7863
  return;
7050
7864
  }
7051
7865
  const threadId = this.inboundRouteThreadId(msg);
7866
+ // #1752: a post by one of this fleet's own bots is never input to a fleet topic's instance. They speak there only
7867
+ // for an instance (its replies, notices, warnings) — taking that as a user turn makes one bot's notice another
7868
+ // agent's "user" message, and a loop. Fleet /collab and open mode admit OTHER bots and webhooks; they never meant
7869
+ // our own. Exempt: ClassicBot rooms, where bots this fleet runs deliberately talk to each other by @mention
7870
+ // (same-channel multi-bot collab), and no-thread Telegram (classic candidates; Telegram bots never see each other).
7871
+ if (msg.userId && fleetBotIds.has(msg.userId) && threadId && !this.classicChannels?.hasChannel(threadId)) {
7872
+ this.logger.debug({ adapterId: msg.adapterId, threadId, messageId: msg.messageId, author: msg.userId }, "Inbound dropped: authored by one of this fleet's own bots in a fleet topic");
7873
+ return;
7874
+ }
7052
7875
  const rootCommand = this.telegramRootCommandTarget(msg, threadId);
7053
7876
  this.logger.debug({ source: msg.source, chatId: msg.chatId, threadId, userId: msg.userId, isBotMessage: msg.isBotMessage, textLen: (msg.text ?? "").length, text: (msg.text ?? "").slice(0, 80) }, "handleInboundMessage entry");
7054
7877
  // Ownership and per-adapter filtering both run before the dedup claim below.
@@ -7177,6 +8000,23 @@ export class FleetManager {
7177
8000
  }
7178
8001
  // Handle /start command
7179
8002
  if (text === "/start" || text.startsWith("/start ")) {
8003
+ // #1686: intercept /start web before the ClassicBot admission flow runs.
8004
+ // /start alone already opens the DM for future bot-to-user messages — no further action needed.
8005
+ const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
8006
+ // #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
8007
+ // (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
8008
+ const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
8009
+ const webStart = isPrivateChat && (requestedBackend === "web"
8010
+ || (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
8011
+ if (webStart) {
8012
+ if (requestedBackend === "web")
8013
+ this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
8014
+ // #1742: the /web menu right here; the code comes to this DM.
8015
+ if (admin)
8016
+ await this.startWebInDm(msg, msgAdapter ?? undefined);
8017
+ // Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
8018
+ return;
8019
+ }
7180
8020
  // All new starts, including explicit backend and later chooser callbacks,
7181
8021
  // share validateClassicStart. Existing channels are recognized first.
7182
8022
  const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
@@ -7185,7 +8025,6 @@ export class FleetManager {
7185
8025
  return;
7186
8026
  }
7187
8027
  const channelName = msg.username || chatId;
7188
- const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
7189
8028
  if (requestedBackend) {
7190
8029
  // handleClassicStart binds the instance to this adapter authoritatively.
7191
8030
  const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
@@ -7253,6 +8092,21 @@ export class FleetManager {
7253
8092
  }
7254
8093
  return;
7255
8094
  }
8095
+ // #1806: /backend — channel admin, as /model. A ClassicBot agent is not switched by it (it says so).
8096
+ if (text === "/backend" || text.startsWith("/backend ") || text.startsWith("/backend@")) {
8097
+ if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
8098
+ await msgAdapter?.sendText(chatId, t("permission.denied"));
8099
+ return;
8100
+ }
8101
+ const backendInstance = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
8102
+ if (!backendInstance) {
8103
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
8104
+ return;
8105
+ }
8106
+ if (msgAdapter)
8107
+ await this.runBackendCommand(backendInstance, text, msg.userId, chatId, msgAdapter, chatId, undefined, msg.adapterId);
8108
+ return;
8109
+ }
7256
8110
  // Handle /compact command (admin only)
7257
8111
  const classicCompact = parseCompactCommand(text);
7258
8112
  if (classicCompact) {
@@ -7438,7 +8292,7 @@ export class FleetManager {
7438
8292
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
7439
8293
  const generalReactions = this.pendingReactionsMeta(generalInstance);
7440
8294
  try {
7441
- await this.deliverToInstance(generalInstance, {
8295
+ await this.deliverHumanToInstance(generalInstance, {
7442
8296
  type: "fleet_inbound",
7443
8297
  content: text,
7444
8298
  targetSession: generalInstance,
@@ -7459,7 +8313,7 @@ export class FleetManager {
7459
8313
  ...generalReactions.meta,
7460
8314
  ...extraMeta,
7461
8315
  },
7462
- });
8316
+ }, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
7463
8317
  generalReactions.consume();
7464
8318
  this.afterUserInboundDelivered(generalInstance, msg);
7465
8319
  this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
@@ -7539,7 +8393,7 @@ export class FleetManager {
7539
8393
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
7540
8394
  const reactions = this.pendingReactionsMeta(instanceName);
7541
8395
  try {
7542
- await this.deliverToInstance(instanceName, {
8396
+ await this.deliverHumanToInstance(instanceName, {
7543
8397
  type: "fleet_inbound",
7544
8398
  content: text,
7545
8399
  targetSession: instanceName, // Channel messages → instance's own session
@@ -7558,7 +8412,7 @@ export class FleetManager {
7558
8412
  ...reactions.meta,
7559
8413
  ...extraMeta,
7560
8414
  },
7561
- });
8415
+ }, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
7562
8416
  // Only after the message actually went out. A failed delivery keeps the
7563
8417
  // reactions queued for the retry / the next message.
7564
8418
  reactions.consume();
@@ -7601,9 +8455,14 @@ export class FleetManager {
7601
8455
  if (Date.now() - lastWarn < 30 * 60_000)
7602
8456
  return;
7603
8457
  this.rateLimitWarnedAt.set(instanceName, Date.now());
7604
- const warnAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
7605
- if (warnAdapter && msg.chatId) {
7606
- warnAdapter.sendText(msg.chatId, warning, { threadId: msg.threadId ?? undefined }).catch(() => { });
8458
+ // #1752: the instance's own bot, in the instance's own topic. `msg.adapterId` is whichever sibling bot won the
8459
+ // inbound dedup race; a warning from that bot is a stranger's post in this topic (and, to the owning adapter, a
8460
+ // bot message it would take as input).
8461
+ const warnAdapter = this.getAdapterForInstance(instanceName);
8462
+ const dest = this.cancelButtonDestination(instanceName);
8463
+ const { chatId, threadId } = dest?.threadId ? dest : { chatId: msg.chatId, threadId: msg.threadId ?? undefined };
8464
+ if (warnAdapter && chatId) {
8465
+ warnAdapter.sendText(chatId, warning, { threadId }).catch(() => { });
7607
8466
  }
7608
8467
  }
7609
8468
  /** Handle outbound tool calls from a daemon instance */
@@ -7647,7 +8506,7 @@ export class FleetManager {
7647
8506
  if (!claimNotice(path, WEB_CHAT_NOTICE, adapterId))
7648
8507
  continue;
7649
8508
  try {
7650
- await world.adapter.sendText(target.chatId, t("upgrade.web_chat", WEB_REMOTE_DOCS_URL), target.opts);
8509
+ await world.adapter.sendText(target.chatId, t("upgrade.web_chat"), target.opts);
7651
8510
  this.logger.info({ adapterId, notice: WEB_CHAT_NOTICE }, "Announced web chat in General");
7652
8511
  }
7653
8512
  catch (err) {
@@ -7810,6 +8669,17 @@ export class FleetManager {
7810
8669
  respond(null, `reply: ${parsedButtons.error}`);
7811
8670
  return;
7812
8671
  }
8672
+ // #1823: a poll is checked with its buttons. Where it cannot be shown it is refused, never sent as text: text
8673
+ // options would never come back as results.
8674
+ const parsedPoll = parsePoll(args.poll, parsedButtons && "buttons" in parsedButtons ? parsedButtons.buttons : null, args.text);
8675
+ if (parsedPoll && "error" in parsedPoll) {
8676
+ respond(null, `reply: ${parsedPoll.error}`);
8677
+ return;
8678
+ }
8679
+ if (parsedPoll && (!outAdapter.supportsPolls || !this.replyButtons())) {
8680
+ 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`);
8681
+ return;
8682
+ }
7813
8683
  // Stickers (#1226) are checked before anything is sent: a refused one is the reply's error, not a gap.
7814
8684
  const stickerProblem = await this.replyStickerProblem(outAdapter, args, threadId, contextAdapterId ?? this.getInstanceAdapterId(senderInstanceName ?? instanceName));
7815
8685
  if (stickerProblem) {
@@ -7829,7 +8699,7 @@ export class FleetManager {
7829
8699
  const buttons = outAdapter.supportsReplyButtons ? this.replyButtons() : null;
7830
8700
  if (buttons) {
7831
8701
  const outId = outAdapter === WEB_ONLY_REPLY_SINK ? "web" : outAdapter.id ?? contextAdapterId ?? "";
7832
- replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons);
8702
+ replySet = buttons.prepare({ instance: instanceName, adapterId: outId, chatId: String(args.chat_id ?? ""), threadId }, parsedButtons.buttons, parsedPoll && "poll" in parsedPoll ? parsedPoll.poll : null);
7833
8703
  }
7834
8704
  else {
7835
8705
  args.text = `${String(args.text)}\n\n${replyButtonsFallbackText(parsedButtons.buttons)}`;
@@ -7849,6 +8719,9 @@ export class FleetManager {
7849
8719
  this.replyButtonsCtl?.discard(replySet.id);
7850
8720
  }
7851
8721
  }
8722
+ // #1823: the agent learns its poll's id (the results name it).
8723
+ if (replySet?.poll && !error && result && typeof result === "object")
8724
+ result = { ...result, poll_id: replySet.id };
7852
8725
  ticket.complete(result, error);
7853
8726
  // Return the platform outcome first. Bookkeeping below must never turn
7854
8727
  // a confirmed Discord/Telegram POST into a tool error if a secondary
@@ -7861,7 +8734,7 @@ export class FleetManager {
7861
8734
  // an agent cannot invent it to suppress the normal completion marker.
7862
8735
  if (!error && result != null && msg.statusOnly !== true) {
7863
8736
  try {
7864
- this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
8737
+ void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
7865
8738
  }
7866
8739
  catch (err) {
7867
8740
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -7876,7 +8749,7 @@ export class FleetManager {
7876
8749
  });
7877
8750
  }
7878
8751
  };
7879
- if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks } : {})) {
8752
+ if (routeToolCall(outAdapter, tool, args, threadId, respondAndRecord, replySet ? { replyButtons: replySet.callbacks, ...(replySet.poll ? { replyPoll: replySet.poll } : {}) } : {})) {
7880
8753
  return;
7881
8754
  }
7882
8755
  if (replySet)
@@ -7914,6 +8787,7 @@ export class FleetManager {
7914
8787
  webOnlyPromptPlace() {
7915
8788
  return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
7916
8789
  }
8790
+ /** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
7917
8791
  afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
7918
8792
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
7919
8793
  // more work either. Split the difference: an instance that is clearly
@@ -7931,27 +8805,74 @@ export class FleetManager {
7931
8805
  this.reactDone(instanceName);
7932
8806
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
7933
8807
  this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
7934
- // Files the agent attached are shown in the web chat too: registered for fetching by id (the path
7935
- // already passed the reply tool's sendability check, and is resolved once more here), never by path.
7936
- const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
7937
- const attachments = replyFiles
7938
- .map(path => this.webFiles.registerServed({ path, instance: instanceName }))
7939
- .filter((f) => f !== null)
7940
- .map(publicAttachment);
7941
- // The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
7942
- // role that may get HTML preview cards. Never inferred from the sender name or the text.
7943
- this.emitSseEvent("message", {
7944
- instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
7945
- text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
7946
- ts: new Date().toISOString(),
7947
- ...(attachments.length ? { attachments } : {}),
7948
- ...(buttons ? { buttons } : {}), // #1266
7949
- });
7950
8808
  // Log bot reply to classic instance chat-log
7951
8809
  const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
7952
8810
  if (isClassic) {
7953
8811
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
7954
8812
  }
8813
+ // Files the agent attached are shown in the web chat too, fetched by id, never by path. #1770: each is copied
8814
+ // first (web-reply-files.ts), and the copy is what the chat serves and keeps — so the message is shown once the
8815
+ // copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
8816
+ const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
8817
+ const ts = new Date().toISOString();
8818
+ const show = (attachments) => {
8819
+ // The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
8820
+ // role that may get HTML preview cards. Never inferred from the sender name or the text.
8821
+ this.emitSseEvent("message", {
8822
+ instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
8823
+ text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
8824
+ ts,
8825
+ ...(attachments.length ? { attachments } : {}),
8826
+ ...(buttons ? { buttons } : {}), // #1266
8827
+ });
8828
+ };
8829
+ const before = this.webReplyOrder.get(instanceName);
8830
+ if (!replyFiles.length && !before) {
8831
+ show([]);
8832
+ return Promise.resolve();
8833
+ }
8834
+ const done = (before ?? Promise.resolve())
8835
+ .then(() => this.webReplyAttachments(instanceName, replyFiles))
8836
+ .catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
8837
+ .then((attachments) => { try {
8838
+ show(attachments);
8839
+ }
8840
+ catch (err) {
8841
+ this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
8842
+ } })
8843
+ .finally(() => { if (this.webReplyOrder.get(instanceName) === done)
8844
+ this.webReplyOrder.delete(instanceName); });
8845
+ this.webReplyOrder.set(instanceName, done);
8846
+ return done;
8847
+ }
8848
+ /**
8849
+ * #1770: a reply's files as the web chat shows them. Each is copied into the instance's web-files/ and served from
8850
+ * the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
8851
+ * one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
8852
+ */
8853
+ async webReplyAttachments(instanceName, paths) {
8854
+ const home = getAgendHome();
8855
+ const dir = webFilesDir(home, instanceName);
8856
+ const out = [];
8857
+ for (const path of paths) {
8858
+ const id = randomBytes(16).toString("hex");
8859
+ const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
8860
+ if (got && "evicted" in got && got.evicted.length)
8861
+ this.webFiles.dropServed(got.evicted);
8862
+ let file = null;
8863
+ if (got?.ok)
8864
+ file = this.webFiles.registerServed({ id, path: got.path, name: basename(path), instance: instanceName, snapshot: true });
8865
+ else if (got?.refused)
8866
+ this.logger.info({ instanceName, file: basename(path), reason: got.reason }, "Reply file not shown in the web chat");
8867
+ else {
8868
+ if (got)
8869
+ this.logger.warn({ instanceName, file: basename(path), reason: got.reason }, "Reply file could not be copied for the web chat; served from its own path until a restart");
8870
+ file = this.webFiles.registerServed({ path, instance: instanceName });
8871
+ }
8872
+ if (file)
8873
+ out.push(publicAttachment(file));
8874
+ }
8875
+ return out;
7955
8876
  }
7956
8877
  // ===================== Scheduler =====================
7957
8878
  async handleScheduleTrigger(schedule, stableRunId = randomUUID(), retry) {
@@ -9191,6 +10112,21 @@ export class FleetManager {
9191
10112
  ? `Refusing to save fleet.yaml: it would be invalid (${introduced.map(e => `${e.path}: ${e.message}`).join("; ")})`
9192
10113
  : null;
9193
10114
  }
10115
+ /**
10116
+ * #1874 follow-up: the validator's warnings, once per start, in daemon.log — a setting fleet.yaml has that AgEnD
10117
+ * ignores (e.g. `web.access`) is otherwise silent unless someone runs `agend validate`.
10118
+ */
10119
+ logConfigWarningsAtStartup() {
10120
+ let warnings = [];
10121
+ try {
10122
+ warnings = validateFleetConfig(this.rawFleetConfig).warnings;
10123
+ }
10124
+ catch (err) {
10125
+ this.logger.debug({ err }, "fleet.yaml validation failed (startup warnings)");
10126
+ }
10127
+ for (const w of warnings)
10128
+ this.logger.warn({ path: w.path }, `fleet.yaml: ${w.path ? `${w.path}: ` : ""}${w.message}`);
10129
+ }
9194
10130
  /** One-time upgrade migration; invalid YAML is never rewritten. */
9195
10131
  slimFleetConfigAtStartup() {
9196
10132
  const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
@@ -9350,7 +10286,9 @@ export class FleetManager {
9350
10286
  else
9351
10287
  await this.lifecycle.remove(name, authorization);
9352
10288
  // A later same-name registration owns its files and caches.
9353
- if (this.fleetConfig?.instances[name] || this.daemons.has(name))
10289
+ // Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
10290
+ // re-registration) — #1677 review.
10291
+ if (Object.hasOwn(this.fleetConfig?.instances ?? {}, name) || this.daemons.has(name))
9354
10292
  return;
9355
10293
  execution?.assert();
9356
10294
  // Clean up per-instance tracking maps so they don't grow unbounded
@@ -9362,6 +10300,14 @@ export class FleetManager {
9362
10300
  // must not be shown the old one's conversation, and deleted names must not pile up.
9363
10301
  this.webChatHistory.forget(name);
9364
10302
  this.webFiles.forget(name);
10303
+ this.compactWatchService?.forget(name);
10304
+ // #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
10305
+ try {
10306
+ this.webPrefs.forgetInstance(name);
10307
+ }
10308
+ catch (err) {
10309
+ this.logger.warn({ err: err.message, name }, "Dropping a removed instance from web favorites failed");
10310
+ }
9365
10311
  // Clean up statusline watcher + instance directory
9366
10312
  this.statuslineWatcher.unwatch(name);
9367
10313
  // instanceDirToClean and effectiveBackend are captured before lifecycle.remove
@@ -9562,10 +10508,12 @@ export class FleetManager {
9562
10508
  return this.webImageCaches[kind];
9563
10509
  }
9564
10510
  /**
9565
- * instance → the connection that is its own bot: configured now (a connection removed from fleet.yaml is nobody's,
9566
- * whatever an instance still names — #1635 review), not the primary, and answering for it alone.
10511
+ * The bots instances and ClassicBot rooms speak through, by connections configured now (a connection removed from
10512
+ * fleet.yaml is nobody's, whatever an instance still names — #1635 review). `own`: instance → its own bot (answering
10513
+ * 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,
10514
+ * or, with none configured for it, the primary.
9567
10515
  */
9568
- ownBots() {
10516
+ webBots() {
9569
10517
  const instances = this.fleetConfig?.instances ?? {};
9570
10518
  const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
9571
10519
  const configured = new Set(channels.map(ch => ch.id ?? ch.type).filter((id) => typeof id === "string"));
@@ -9575,8 +10523,17 @@ export class FleetManager {
9575
10523
  if (!Object.hasOwn(instances, room.instanceName))
9576
10524
  assignments.push([room.instanceName, assigned(room.instanceName)]);
9577
10525
  }
9578
- return ownBotConnections(this.getPrimaryAdapterId(), assignments);
10526
+ const primary = this.getPrimaryAdapterId(), fallback = primary && configured.has(primary) ? primary : undefined;
10527
+ const speaking = new Map();
10528
+ for (const [instance, connection] of assignments) {
10529
+ const c = connection ?? fallback;
10530
+ if (c)
10531
+ speaking.set(instance, c);
10532
+ }
10533
+ return { own: ownBotConnections(assignments), speaking };
9579
10534
  }
10535
+ /** instance → the connection that is its own bot (see webBots). */
10536
+ ownBots() { return this.webBots().own; }
9580
10537
  /**
9581
10538
  * The bot a connection is now: its running adapter and that bot's own user id. Everything cached for an own bot is
9582
10539
  * keyed by both, so a bot put in its place (a token replaced under the same connection id) never shows the old one's
@@ -9619,8 +10576,11 @@ export class FleetManager {
9619
10576
  },
9620
10577
  };
9621
10578
  }
9622
- /** One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. */
9623
- webIdentityImage(name, displayName, profileAvatar, own) {
10579
+ /**
10580
+ * One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. The bot
10581
+ * is its own, else the one it speaks through (#1867) — one cache entry per bot, so a shared bot is fetched once.
10582
+ */
10583
+ webIdentityImage(name, displayName, profileAvatar, bots) {
9624
10584
  let profileVersion = null;
9625
10585
  if (profileAvatar && imageMimeForFile(profileAvatar)) {
9626
10586
  try {
@@ -9628,22 +10588,26 @@ export class FleetManager {
9628
10588
  }
9629
10589
  catch { /* removed */ }
9630
10590
  }
9631
- const connection = own.get(name), owner = connection ? this.botOwner(connection) : null;
10591
+ const ownConnection = bots.own.get(name), connection = ownConnection ?? bots.speaking.get(name);
10592
+ const owner = connection ? this.botOwner(connection) : null;
9632
10593
  const source = connection && owner?.adapter.fetchBotAvatar ? this.botAvatarSource(connection, owner) : null;
9633
10594
  const bot = source ? this.webImageCache("avatars").peek(source.key, source.load) : null;
9634
10595
  const cfg = this.ownInstanceConfig(name);
9635
10596
  const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
9636
10597
  personaEmoji: cfg?.status_emojis?.delivered });
9637
- // The ring belongs to the bot's own face: only when that is what is shown.
9638
- const accent = !profileVersion && bot && connection && owner
9639
- ? this.webAccent(connection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
10598
+ // The ring belongs to an own bot's face: only when that is what is shown (a shared bot's would ring everyone alike).
10599
+ const accent = !profileVersion && bot && ownConnection && owner
10600
+ ? this.webAccent(ownConnection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
9640
10601
  : null;
9641
10602
  return accent ? { avatar, accent } : { avatar };
9642
10603
  }
9643
- /** GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only an own-bot connection, or a known instance's upload. */
10604
+ /**
10605
+ * GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only a connection some instance or room speaks
10606
+ * through (#1867), or a known instance's upload.
10607
+ */
9644
10608
  webImage(kind, key) {
9645
10609
  if (kind === "bot") {
9646
- if (![...this.ownBots().values()].includes(key))
10610
+ if (![...this.webBots().speaking.values()].includes(key))
9647
10611
  return null;
9648
10612
  const owner = this.botOwner(key);
9649
10613
  return owner ? this.webImageCache("avatars").read(this.botAvatarSource(key, owner).key) : null;
@@ -9663,6 +10627,45 @@ export class FleetManager {
9663
10627
  return null;
9664
10628
  }
9665
10629
  }
10630
+ /**
10631
+ * #1682 GET /ui/mentions: names for the mention ids a page found in what it rendered — never a Discord request; in order:
10632
+ * the fleet's bots (an instance's own bot is that instance's label: fleet.yaml, and the View profiles read from
10633
+ * profiles.db, as the status frame does), the people seen on inbound messages, and the Discord adapters' caches
10634
+ * (members, users, roles, channels).
10635
+ */
10636
+ webMentionNames(query) {
10637
+ const bots = (q) => {
10638
+ const u = Object.create(null);
10639
+ if (!q.u.length)
10640
+ return { u };
10641
+ let own = new Map();
10642
+ try {
10643
+ own = this.ownBots();
10644
+ }
10645
+ catch { /* none known */ }
10646
+ const profiles = profileIdentities(this.dataDir);
10647
+ const rooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
10648
+ for (const [instance, connection] of own) {
10649
+ const id = this.adapters.get(connection)?.getBotUserId?.();
10650
+ if (!id || !q.u.includes(id))
10651
+ continue;
10652
+ const cfg = Object.hasOwn(this.fleetConfig?.instances ?? {}, instance) ? this.fleetConfig.instances[instance] : undefined;
10653
+ u[id] = resolveInstanceIdentity({ cfg, classic: rooms.get(instance), profile: profiles.get(instance) }).display_name || instance;
10654
+ }
10655
+ return { u };
10656
+ };
10657
+ const seen = (q) => {
10658
+ const u = Object.create(null);
10659
+ for (const id of q.u) {
10660
+ const name = this.seenUsers.get(id);
10661
+ if (name)
10662
+ u[id] = name;
10663
+ }
10664
+ return { u };
10665
+ };
10666
+ const caches = [...this.adapters.values()].filter(a => typeof a.mentionNames === "function").map(a => (q) => a.mentionNames(q));
10667
+ return resolveMentions(query, [bots, seen, ...caches]);
10668
+ }
9666
10669
  /** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
9667
10670
  async webEmojiList() {
9668
10671
  const adopted = [];
@@ -11041,6 +12044,8 @@ export class FleetManager {
11041
12044
  return true;
11042
12045
  if (await this.handleEffortSelection(data, adapterId))
11043
12046
  return true;
12047
+ if (await this.handleBackendSelection(data, adapterId))
12048
+ return true;
11044
12049
  if (await this.handleHangPrompt(data, adapterId, adapter))
11045
12050
  return true;
11046
12051
  if (data.callbackData.startsWith("cancel:")) {
@@ -11106,6 +12111,48 @@ export class FleetManager {
11106
12111
  });
11107
12112
  }
11108
12113
  cacheService = null;
12114
+ /**
12115
+ * #1699: the credential profile an instance's CLI signs in with on `backend` — fleet.yaml's (with its defaults), or a
12116
+ * ClassicBot room's launched or would-be login when the room runs that backend. Null: the backend's default login
12117
+ * (also for a backend that has no profiles, where the option is ignored).
12118
+ */
12119
+ credentialProfileOf(name, backend) {
12120
+ if (!credentialHomeSpec(backend))
12121
+ return null;
12122
+ const cfg = this.fleetConfig?.instances?.[name];
12123
+ if (cfg)
12124
+ return instanceCredentialProfile(cfg, this.fleetConfig?.defaults, backend);
12125
+ const launched = this.daemons.get(name)?.getConfigSnapshot?.();
12126
+ if (launched)
12127
+ return (launched.backend ?? "claude-code") === backend ? profileName(profileOf(launched.backend_options, backend)) : null;
12128
+ if (!this.classicChannels || this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend) !== backend)
12129
+ return null;
12130
+ return profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults));
12131
+ }
12132
+ /**
12133
+ * #1699: the claude config dir an instance's CLI uses — its credential profile's, else exactly as before
12134
+ * (CLAUDE_CONFIG_DIR or ~/.claude).
12135
+ */
12136
+ claudeConfigDirOf(name) {
12137
+ const profile = this.credentialProfileOf(name, "claude-code");
12138
+ return claudeConfigDirFor(profile ? credentialProfileHome(getAgendHome(), "claude-code", profile) : null);
12139
+ }
12140
+ /** The instances the cache analysis reads: fleet.yaml's, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend). */
12141
+ cacheInstances() {
12142
+ const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
12143
+ name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
12144
+ claudeConfigDir: this.claudeConfigDirOf(name),
12145
+ }));
12146
+ const taken = new Set(fleet.map(i => i.name));
12147
+ const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
12148
+ name: ch.instanceName,
12149
+ backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
12150
+ workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
12151
+ ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
12152
+ claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
12153
+ }));
12154
+ return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
12155
+ }
11109
12156
  /**
11110
12157
  * #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
11111
12158
  * ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
@@ -11114,20 +12161,7 @@ export class FleetManager {
11114
12161
  cacheReport(window) {
11115
12162
  const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
11116
12163
  this.cacheService ??= new CacheService({
11117
- // fleet.yaml's instances, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend).
11118
- instances: () => {
11119
- const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
11120
- name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
11121
- }));
11122
- const taken = new Set(fleet.map(i => i.name));
11123
- const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
11124
- name: ch.instanceName,
11125
- backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
11126
- workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
11127
- ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
11128
- }));
11129
- return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
11130
- },
12164
+ instances: () => this.cacheInstances(),
11131
12165
  claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
11132
12166
  claudeKey: claudeProjectKey,
11133
12167
  codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
@@ -11279,10 +12313,18 @@ export class FleetManager {
11279
12313
  * The caller has passed the /ui gate (session, same origin, CSRF). Here: the prompt must be one offered
11280
12314
  * on the dashboard, about the instance the page named, and the action one of its own buttons.
11281
12315
  */
11282
- /** #1266: a click on a reply's button in the web chat (the /ui gate is passed: a session, or the public link). */
11283
- clickWebReplyButton(instance, id, index) {
12316
+ /** #1823: close_poll — `instance` closes one of its own polls before the deadline. */
12317
+ closePollEarly(instance, id) {
12318
+ const buttons = this.replyButtons();
12319
+ return buttons ? buttons.closeEarly(instance, id) : Promise.resolve({ ok: false, error: "polls are unavailable until AgEnD restarts" });
12320
+ }
12321
+ /**
12322
+ * #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
12323
+ * poll (#1823) it is `voter`'s vote: the page's session.
12324
+ */
12325
+ clickWebReplyButton(instance, id, index, voter) {
11284
12326
  const buttons = this.replyButtons();
11285
- return buttons ? buttons.clickWeb(instance, id, index) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
12327
+ return buttons ? buttons.clickWeb(instance, id, index, voter) : Promise.resolve({ status: 409, error: t("reply_buttons.closed") });
11286
12328
  }
11287
12329
  async clickWebPrompt(instance, nonce, action) {
11288
12330
  if (!/^[0-9a-f]{32}$/.test(nonce) || !/^[a-z][a-z-]{0,23}$/.test(action))
@@ -11314,7 +12356,9 @@ export class FleetManager {
11314
12356
  }
11315
12357
  async retireNonceButtons(pending, messageId, text,
11316
12358
  /** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
11317
- editClicked) {
12359
+ editClicked,
12360
+ /** #1686: URL buttons to attach when editing the outcome (Telegram only; ignored when editClicked or retire is set). */
12361
+ urlButtons) {
11318
12362
  this.webPromptGone(pending, text);
11319
12363
  try {
11320
12364
  const retire = editClicked ?? pending.retire;
@@ -11324,7 +12368,12 @@ export class FleetManager {
11324
12368
  }
11325
12369
  if (!pending.adapter.editMessageRemoveButtons)
11326
12370
  throw new Error("adapter cannot remove prompt buttons");
11327
- await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
12371
+ if (urlButtons) {
12372
+ await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId, urlButtons);
12373
+ }
12374
+ else {
12375
+ await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
12376
+ }
11328
12377
  }
11329
12378
  catch (err) {
11330
12379
  // The action was already atomically consumed. An edit failure must not
@@ -11332,7 +12381,8 @@ export class FleetManager {
11332
12381
  // only place some outcomes are told (cancel, later, a resend's result),
11333
12382
  // so the text goes out as a message instead (#1133).
11334
12383
  this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons; posting the outcome instead");
11335
- await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId })
12384
+ // #1742: with its link button, if it had one — the outcome may be the only way to the DM.
12385
+ await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId, ...(urlButtons ? { urlButtons } : {}) })
11336
12386
  .catch(sendErr => this.logger.warn({ err: sendErr, instanceName: pending.instanceName }, "Could not post a prompt's outcome"));
11337
12387
  }
11338
12388
  }
@@ -11627,6 +12677,8 @@ export class FleetManager {
11627
12677
  }
11628
12678
  };
11629
12679
  }
12680
+ /** #1806: the web's /backend confirmations — one per question, used once, for the arguments and backend asked about. */
12681
+ webBackendTokens = new Map();
11630
12682
  /** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
11631
12683
  webClearTokens = new Map();
11632
12684
  /** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
@@ -11634,8 +12686,10 @@ export class FleetManager {
11634
12686
  const webMeta = (instance) => {
11635
12687
  const topicId = this.fleetConfig?.instances[instance]?.topic_id;
11636
12688
  return {
11637
- chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(), username: "web-user", userId: "web-user",
11638
- threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web",
12689
+ // #1827: a /steer or /btw reaches the agent as the web person who wrote it.
12690
+ chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(),
12691
+ username: opts.person?.name ?? WEB_NAME_FALLBACK, userId: opts.person?.userId ?? WEB_NAME_FALLBACK,
12692
+ threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
11639
12693
  };
11640
12694
  };
11641
12695
  const choicesFromCache = (instance) => {
@@ -11654,7 +12708,22 @@ export class FleetManager {
11654
12708
  return this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? "classic" : null;
11655
12709
  },
11656
12710
  ctx: (instance) => this.topicCommands.getCtxText(instance),
11657
- compact: (instance, instructions) => this.topicCommands.sendCompact(instance, instructions),
12711
+ // Board 003648ac: a watch for the paste, armed when the daemon says it was written (raw_paste_written); one
12712
+ // for a command that was not handed over is dropped.
12713
+ compact: async (instance, instructions) => {
12714
+ let watch = null;
12715
+ try {
12716
+ const r = this.topicCommands.compactInstance(instance, instructions, () => (watch = this.compactWatch.begin(instance)));
12717
+ if (!r.sent && watch)
12718
+ this.compactWatch.cancel(instance, watch);
12719
+ return { ...r, watch: r.sent ? watch : null };
12720
+ }
12721
+ catch (err) {
12722
+ if (watch)
12723
+ this.compactWatch.cancel(instance, watch);
12724
+ throw err;
12725
+ }
12726
+ },
11658
12727
  applyModel: (instance, name) => this.applyModel(instance, name),
11659
12728
  modelChoices: choicesFromCache,
11660
12729
  applyEffort: (instance, level) => this.applyEffort(instance, level),
@@ -11665,9 +12734,44 @@ export class FleetManager {
11665
12734
  const current = this.resolveInstanceEffort(instance).effort;
11666
12735
  return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
11667
12736
  },
12737
+ backendStep: async (instance, args) => {
12738
+ const refusal = this.backendCommandRefusal(instance);
12739
+ if (refusal)
12740
+ return { error: refusal };
12741
+ // The cached model lists only, as the web's /model: never a live probe on this path.
12742
+ const step = await backendStep(this.backendCommandDeps(instance, { live: false }), instance, backendArgs(args));
12743
+ if (step.kind === "refuse")
12744
+ return step.noop ? { text: step.text } : { error: step.text };
12745
+ if (step.kind === "choose") {
12746
+ const id = (token) => joinBackendArgs([...step.args, token]);
12747
+ 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 })) } };
12748
+ }
12749
+ const now = performance.now();
12750
+ for (const [k, v] of this.webBackendTokens)
12751
+ if (v.deadline < now)
12752
+ this.webBackendTokens.delete(k);
12753
+ const token = randomBytes(16).toString("hex");
12754
+ this.webBackendTokens.set(token, {
12755
+ instance, asked: joinBackendArgs(backendArgs(args)), args: step.args, from: step.from,
12756
+ target: this.fleetConfig.instances[instance], deadline: now + CLASSIC_BACKEND_SELECTION_TIMEOUT_MS,
12757
+ });
12758
+ return { confirm: { token, message: step.text } };
12759
+ },
12760
+ backendConfirm: async (instance, args, token) => {
12761
+ const entry = this.webBackendTokens.get(token);
12762
+ this.webBackendTokens.delete(token); // used once, whatever happens next
12763
+ if (!entry || entry.instance !== instance || entry.asked !== joinBackendArgs(backendArgs(args)) || performance.now() > entry.deadline) {
12764
+ return { status: 409, text: t("backend.selection_expired") };
12765
+ }
12766
+ // The instance it was asked about: one deleted and created again under the name, stopped or not, is another.
12767
+ const stillCurrent = () => this.backendTargetIs(instance, entry.target);
12768
+ if (!stillCurrent())
12769
+ return { status: 409, text: t("menu.click_stale") };
12770
+ return { status: 200, text: await this.applyBackendSwitch(instance, entry.args, entry.from, stillCurrent) };
12771
+ },
11668
12772
  cancel: (instance) => this.cancelInstance(instance),
11669
- steer: (instance, text) => this.topicCommands.sendSteer(instance, text, webMeta(instance)),
11670
- btw: (instance, text) => this.topicCommands.sendBtw(instance, text, webMeta(instance)),
12773
+ steer: (instance, text, originalBody) => this.topicCommands.sendSteer(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
12774
+ btw: (instance, text, originalBody) => this.topicCommands.sendBtw(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
11671
12775
  pauseWake: (instance, action) => this.topicCommands.runPauseWake(instance, action),
11672
12776
  save: (instance, filename) => this.topicCommands.sendSave(instance, filename),
11673
12777
  clearAsk: (instance) => {
@@ -11693,7 +12797,8 @@ export class FleetManager {
11693
12797
  // sendClear sends its first IPC synchronously: no await separates this check from the effect.
11694
12798
  if (this.shuttingDown || !entry.fence())
11695
12799
  return { status: 409, text: t("menu.click_stale") };
11696
- return { status: 200, text: await this.topicCommands.sendClear(instance) };
12800
+ const r = this.topicCommands.clearInstance(instance);
12801
+ return r.sent ? { status: 200, text: r.text, sent: true } : { status: 409, text: r.text };
11697
12802
  },
11698
12803
  t: (key, ...args) => t(key, ...args),
11699
12804
  }, input, opts);
@@ -11938,9 +13043,12 @@ export class FleetManager {
11938
13043
  * Backends with no remote login flow (opencode logs in from a terminal) get
11939
13044
  * no button — the alert's own wording already tells them what to run.
11940
13045
  */
11941
- async offerBackendLogin(targetInstance, backend) {
13046
+ async offerBackendLogin(targetInstance, backend, profile) {
11942
13047
  if (!LOGIN_FLOWS[backend])
11943
13048
  return;
13049
+ if (profile && !CHAT_PROFILE_LOGIN_BACKENDS.has(backend))
13050
+ return; // that profile signs in on the host
13051
+ const shown = profile ? t("login.profile_label", backend, profile) : backend;
11944
13052
  const adapterId = this.getInstanceAdapterId(targetInstance);
11945
13053
  const adapter = this.getAdapterForInstance(targetInstance);
11946
13054
  const chatId = this.getGroupIdForInstance(targetInstance);
@@ -11958,9 +13066,10 @@ export class FleetManager {
11958
13066
  adapterId,
11959
13067
  chatId,
11960
13068
  threadId,
11961
- message: t("login.offer", backend),
11962
- choices: [{ action: backend, label: t("login.offer_action", backend) }],
13069
+ message: t("login.offer", shown),
13070
+ choices: [{ action: backend, label: t("login.offer_action", shown) }],
11963
13071
  expiredText: t("buttons.stale"),
13072
+ ...(profile ? { extra: { loginProfile: profile } } : {}),
11964
13073
  });
11965
13074
  }
11966
13075
  /** Consume a General assist button exactly once. */
@@ -12773,7 +13882,7 @@ export class FleetManager {
12773
13882
  fleetConfig: () => this.fleetConfig,
12774
13883
  isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
12775
13884
  eventLog: () => this.eventLog,
12776
- recoverBackendInstances: backend => this.recoverBackendInstances(backend),
13885
+ recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
12777
13886
  claimWindow: backend => this.loginWindow.tryClaim("web", backend),
12778
13887
  releaseWindow: claim => { this.loginWindow.release(claim); },
12779
13888
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
@@ -12782,11 +13891,12 @@ export class FleetManager {
12782
13891
  reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
12783
13892
  // Throws when the prompt cannot be posted: the controller tells the
12784
13893
  // user so instead of leaving "Starting…" as the last word (#1133).
12785
- postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
13894
+ postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText, profile }) => {
12786
13895
  await this.postNonceButtonPromptOrThrow({
12787
13896
  prefix, alertType: "login", instanceName,
12788
13897
  adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
12789
13898
  message, choices, expiredText,
13899
+ ...(profile ? { extra: { loginProfile: profile } } : {}),
12790
13900
  });
12791
13901
  },
12792
13902
  });
@@ -13000,10 +14110,20 @@ export class FleetManager {
13000
14110
  // A removed backend (#1280) is neither installed nor signed into: say what replaces it.
13001
14111
  if (isRemovedBackend(wanted))
13002
14112
  return removedBackendMessage(wanted);
14113
+ // #1699 part 2: a profile is admitted before anything is installed for it — a name that is not one, or a backend
14114
+ // whose profiles sign in on the host, is refused here, not after an install it did not need.
14115
+ if (opts.profile !== undefined) {
14116
+ const admitted = admitProfileLogin(wanted, opts.profile);
14117
+ if ("refusal" in admitted)
14118
+ return admitted.refusal;
14119
+ opts = { ...opts, profile: admitted.profile };
14120
+ }
13003
14121
  if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
13004
14122
  const flow = LOGIN_FLOWS[wanted];
13005
14123
  this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
13006
- return this.startInstallSession(wanted, chat);
14124
+ return opts.profile !== undefined
14125
+ ? this.startInstallSession(wanted, chat, { profile: opts.profile })
14126
+ : this.startInstallSession(wanted, chat);
13007
14127
  }
13008
14128
  this.recordLoginFlow(wanted, "login", chat.userId);
13009
14129
  return this.launchSignIn(backendArg, chat, opts);
@@ -13092,7 +14212,9 @@ export class FleetManager {
13092
14212
  * cancelled: they are still coming back, and are reported as pending so the
13093
14213
  * message can say so instead of implying failure.
13094
14214
  */
13095
- async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS) {
14215
+ async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS,
14216
+ /** #1699 part 2: the login just signed in — a profile, or null for the backend's default one. */
14217
+ profile = null) {
13096
14218
  const woken = [];
13097
14219
  const restarted = [];
13098
14220
  const pending = [];
@@ -13100,6 +14222,9 @@ export class FleetManager {
13100
14222
  for (const name of this.configuredBackendInstanceNames()) {
13101
14223
  if (this.backendNameOf(name) !== backend)
13102
14224
  continue;
14225
+ // Only the instances on the login that changed: another profile's (or the default's) credentials did not.
14226
+ if (this.credentialProfileOf(name, backend) !== profile)
14227
+ continue;
13103
14228
  const status = this.getInstanceStatus(name);
13104
14229
  if (status !== "paused" && status !== "running")
13105
14230
  continue;
@@ -13137,14 +14262,16 @@ export class FleetManager {
13137
14262
  if (claimed === "consumed")
13138
14263
  return true;
13139
14264
  const { entry, action: backend } = claimed;
13140
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
13141
- const text = await this.startLoginSession(backend, {
14265
+ const profile = entry.loginProfile;
14266
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", profile ? t("login.profile_label", backend, profile) : backend));
14267
+ const chat = {
13142
14268
  adapter: entry.adapter,
13143
14269
  adapterId: entry.adapterId,
13144
14270
  chatId: entry.chatId,
13145
14271
  threadId: entry.threadId,
13146
14272
  userId: data.userId,
13147
- });
14273
+ };
14274
+ const text = profile ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
13148
14275
  if (text)
13149
14276
  await this.postPromptOutcome(entry, text);
13150
14277
  return true;
@@ -13158,11 +14285,13 @@ export class FleetManager {
13158
14285
  return true;
13159
14286
  const { entry, action } = claimed;
13160
14287
  const backend = entry.instanceName;
14288
+ const profile = entry.loginProfile;
14289
+ const shown = profile ? t("login.profile_label", backend, profile) : backend;
13161
14290
  if (action === "cancel") {
13162
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", backend));
14291
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", shown));
13163
14292
  return true;
13164
14293
  }
13165
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
14294
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", shown));
13166
14295
  const text = await this.startLoginSession(backend, {
13167
14296
  adapter: entry.adapter,
13168
14297
  adapterId: entry.adapterId,
@@ -13174,6 +14303,7 @@ export class FleetManager {
13174
14303
  tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
13175
14304
  // The consent is the button itself: only the explicit "Open public link" actions carry it.
13176
14305
  tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
14306
+ ...(profile ? { profile } : {}),
13177
14307
  });
13178
14308
  if (text)
13179
14309
  await this.postPromptOutcome(entry, text);
@@ -13201,7 +14331,7 @@ export class FleetManager {
13201
14331
  /** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
13202
14332
  installHandoff = null;
13203
14333
  /** Start a CLI install session. Caller enforces admin. */
13204
- async startInstallSession(backendArg, chat) {
14334
+ async startInstallSession(backendArg, chat, signIn = {}) {
13205
14335
  const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
13206
14336
  const info = BACKEND_INSTALLATION_INFO[backend];
13207
14337
  if (!info)
@@ -13251,10 +14381,18 @@ export class FleetManager {
13251
14381
  }
13252
14382
  return;
13253
14383
  }
14384
+ // Between the install ending and the sign-in starting there is no
14385
+ // session to cancel; this hand-off is what `/login cancel` stops — from
14386
+ // here on, as the login-shell lookup below is an await.
14387
+ const handoff = { backend, cancelled: false };
14388
+ this.installHandoff = handoff;
13254
14389
  // The installer may only have added the binary to a profile PATH; a
13255
14390
  // fresh login shell sees that, the fleet process's PATH may not.
13256
- const installedAt = this.locateBinaryOnLoginShell(info.binary) ?? this.locateInInstallerBinDirs(info);
14391
+ const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? this.locateInInstallerBinDirs(info);
14392
+ if (handoff.cancelled || this.installHandoff !== handoff)
14393
+ return;
13257
14394
  if (!installedAt) {
14395
+ this.installHandoff = null;
13258
14396
  await chat.adapter.sendText(chat.chatId, t("install.verify_failed", backend, info.binary), { threadId: chat.threadId }).catch(() => { });
13259
14397
  return;
13260
14398
  }
@@ -13264,6 +14402,7 @@ export class FleetManager {
13264
14402
  // installed, reported as verified, and then offered nowhere.
13265
14403
  this.adoptBinaryDirectory(installedAt, backend);
13266
14404
  if (!LOGIN_FLOWS[backend]) {
14405
+ this.installHandoff = null;
13267
14406
  await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
13268
14407
  return;
13269
14408
  }
@@ -13272,10 +14411,6 @@ export class FleetManager {
13272
14411
  // publish a durable completion line first so a successful install can
13273
14412
  // never look like it silently disappeared. Include the binary that
13274
14413
  // passed the fresh-login-shell verification and the exact next step.
13275
- // Between the install ending and the sign-in starting there is no
13276
- // session to cancel; this hand-off is what `/login cancel` stops.
13277
- const handoff = { backend, cancelled: false };
13278
- this.installHandoff = handoff;
13279
14414
  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"));
13280
14415
  if (handoff.cancelled || this.installHandoff !== handoff)
13281
14416
  return;
@@ -13285,7 +14420,8 @@ export class FleetManager {
13285
14420
  // without a click.
13286
14421
  // launchSignIn, not startLoginSession: if the new binary were still
13287
14422
  // not visible, startLoginSession would route straight back to an install.
13288
- const next = await this.launchSignIn(backend, chat, {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
14423
+ // A `/login <backend> <profile>` that had to install first signs that profile in, not the default login.
14424
+ const next = await this.launchSignIn(backend, chat, signIn.profile !== undefined ? { profile: signIn.profile } : {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
13289
14425
  if (next) {
13290
14426
  await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
13291
14427
  .catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
@@ -13333,16 +14469,14 @@ export class FleetManager {
13333
14469
  * The absolute path a fresh login shell resolves `binary` to, or null. Only
13334
14470
  * a real executable file counts: `command -v` also answers with an alias
13335
14471
  * definition or a function name, neither of which a spawn could run.
14472
+ * Async (runOnLoginShell): a login file that backgrounds a process holding the shell's output once froze the fleet loop here.
13336
14473
  */
13337
- locateBinaryOnLoginShell(binary) {
13338
- return measureSyncWork("fleet.installLookup", () => this.locateBinaryOnLoginShellSync(binary));
13339
- }
13340
- locateBinaryOnLoginShellSync(binary) {
14474
+ async locateBinaryOnLoginShell(binary) {
13341
14475
  try {
13342
- const result = measureSyncWork("fleet.installLoginShell", () => spawnSync("bash", ["-lc", `command -v ${binary}`], { timeout: 10_000, stdio: "pipe", encoding: "utf8" }));
14476
+ const result = await runOnLoginShell(`command -v ${binary}`);
13343
14477
  if (result.status !== 0)
13344
14478
  return null;
13345
- const path = String(result.stdout ?? "").trim().split("\n").pop()?.trim() ?? "";
14479
+ const path = result.stdout.trim().split("\n").pop()?.trim() ?? "";
13346
14480
  if (!isAbsolute(path))
13347
14481
  return null;
13348
14482
  accessSync(path, fsConstants.X_OK);
@@ -13393,8 +14527,16 @@ export class FleetManager {
13393
14527
  return;
13394
14528
  }
13395
14529
  const backend = String(data.options?.backend ?? "").trim();
14530
+ // #1699 part 2: a supplied profile is never dropped — a blank one is refused by the admission like any bad name,
14531
+ // and one without a backend is refused rather than becoming the default login's picker.
14532
+ const profileSupplied = data.options?.profile !== undefined && data.options?.profile !== null;
14533
+ const profile = profileSupplied ? String(data.options?.profile).trim() : undefined;
14534
+ if (profileSupplied && !backend) {
14535
+ await data.respond(t("login.profile_needs_backend"));
14536
+ return;
14537
+ }
13396
14538
  if (backend) {
13397
- const text = await this.startLoginSession(backend, chat);
14539
+ const text = profile !== undefined ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
13398
14540
  await data.respond(text ?? t("login.confirm_posted"));
13399
14541
  return;
13400
14542
  }
@@ -13429,16 +14571,26 @@ export class FleetManager {
13429
14571
  try {
13430
14572
  const store = new WebChatDiskStore({ home: getAgendHome(), logger: this.logger, ownerOf: (name) => this.webChatOwner(name) });
13431
14573
  const names = new Set([...Object.keys(fleet.instances ?? {}), ...(this.classicChannels?.getAll().map(c => c.instanceName) ?? [])]);
13432
- // A kept message's files are served again under their ids only from the instance's own inbox, by the ledger's
13433
- // checks (web-upload.ts restoreServed); they leave the ledger with their message.
14574
+ // A kept message's files are served again under their ids only from the instance's own inbox or (#1770) its reply
14575
+ // snapshots, by the ledger's checks (web-upload.ts restoreServed); they leave the ledger with their message.
13434
14576
  this.webChatHistory.setFiles({
13435
14577
  storablePath: (id) => this.webFiles.storablePath(id),
13436
- restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox")),
14578
+ restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox"), webFilesDir(getAgendHome(), instance) ?? undefined),
13437
14579
  drop: (ids) => this.webFiles.dropServed(ids),
13438
14580
  });
13439
14581
  let restored = 0;
13440
14582
  for (const name of names)
13441
14583
  restored += this.webChatHistory.restore(name, store.load(name));
14584
+ // #1770: before any instance replies, the snapshots no restored message holds (it left the history while the
14585
+ // fleet was down, or a copy stopped midway) are removed.
14586
+ let swept = 0;
14587
+ for (const name of names) {
14588
+ const dir = webFilesDir(getAgendHome(), name);
14589
+ if (dir)
14590
+ swept += sweepReplySnapshots(dir, this.webFiles.heldSnapshots(name));
14591
+ }
14592
+ if (swept)
14593
+ this.logger.info({ swept }, "Removed web reply snapshots no message holds");
13442
14594
  this.webChatStore = store;
13443
14595
  this.webChatHistory.setStore(store);
13444
14596
  if (restored)
@@ -13473,6 +14625,7 @@ export class FleetManager {
13473
14625
  messageId: typeof m.messageId === "string" ? m.messageId : undefined,
13474
14626
  role: typeof m.role === "string" ? m.role : undefined,
13475
14627
  buttons: m.buttons,
14628
+ click: m.click,
13476
14629
  });
13477
14630
  broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
13478
14631
  return;
@@ -13524,7 +14677,7 @@ export class FleetManager {
13524
14677
  const instanceHangConfig = this.fleetConfig?.instances[instanceName]?.hang_detector;
13525
14678
  const configuredMinutes = instanceHangConfig?.timeout_minutes
13526
14679
  ?? this.fleetConfig?.defaults?.hang_detector?.timeout_minutes
13527
- ?? 15;
14680
+ ?? DEFAULT_STUCK_TIMEOUT_MS / 60_000; // #1854: the detector's own default (10 min), not a stale 15
13528
14681
  const unchangedMinutes = unchangedForMs == null
13529
14682
  ? configuredMinutes
13530
14683
  : Math.max(1, Math.floor(unchangedForMs / 60_000));
@@ -13582,12 +14735,12 @@ export class FleetManager {
13582
14735
  "codex": "AGENTS.md",
13583
14736
  "opencode": "AGENTS.md",
13584
14737
  "kiro-cli": ".kiro/steering/project.md",
13585
- // Grok reads AGENTS.md project docs; agy reads .agents/agents.md — the
14738
+ // Grok reads AGENTS.md project docs; agy reads .agents/AGENTS.md (board 0340ec89: not the lowercase name) — the
13586
14739
  // same files their writeConfig() appends fleet instructions to.
13587
14740
  "grok": "AGENTS.md",
13588
14741
  // muse init scaffolds AGENTS.md and the binary reads it as project rules.
13589
14742
  "muse": "AGENTS.md",
13590
- "antigravity": ".agents/agents.md",
14743
+ "antigravity": ".agents/AGENTS.md",
13591
14744
  "mock": "CLAUDE.md",
13592
14745
  };
13593
14746
  static GENERAL_INSTRUCTIONS = `# Fleet Coordinator
@@ -13627,10 +14780,17 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13627
14780
  ensureGeneralInstructionsSync(workDir, backendName, instanceName) {
13628
14781
  const backend = backendName ?? "claude-code";
13629
14782
  workDir = this.resolveKnowledgeWorkDir(workDir, backend, instanceName);
14783
+ // Board 0340ec89: General's file from before, under the lowercase name agy never read, becomes the one it reads.
14784
+ if (backend === "antigravity")
14785
+ migrateAgyAgentsFile(workDir);
13630
14786
  const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
13631
14787
  const filePath = join(workDir, filename);
13632
14788
  mkdirSync(dirname(filePath), { recursive: true });
13633
- if (!existsSync(filePath)) {
14789
+ if (backend === "antigravity" && danglingSymlink(filePath)) {
14790
+ // #1914 review: a person's dangling symlink there is kept and not followed (nothing created where it points).
14791
+ this.logger.warn({ filePath }, "General's agy instructions file is a dangling symlink; left as it is, not written");
14792
+ }
14793
+ else if (!existsSync(filePath)) {
13634
14794
  writeFileSync(filePath, FleetManager.GENERAL_INSTRUCTIONS, "utf-8");
13635
14795
  this.logger.info({ filePath }, "Created general instance instructions file");
13636
14796
  }
@@ -13727,6 +14887,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13727
14887
  }
13728
14888
  const filename = FleetManager.INSTRUCTIONS_FILENAME[backend] ?? "CLAUDE.md";
13729
14889
  const instructionsPath = join(workDir, filename);
14890
+ if (backend === "antigravity" && danglingSymlink(instructionsPath))
14891
+ return; // kept, not followed (#1914 review; logged above)
13730
14892
  const body = files.map(f => readFileSync(join(srcSteering, f), "utf-8").trim()).join("\n\n");
13731
14893
  const block = `${FleetManager.STEERING_BLOCK_BEGIN}\n${body}\n${FleetManager.STEERING_BLOCK_END}`;
13732
14894
  let existing = "";
@@ -13747,6 +14909,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
13747
14909
  mkdirSync(dirname(instructionsPath), { recursive: true });
13748
14910
  writeFileSync(instructionsPath, next);
13749
14911
  }
14912
+ // Board 0340ec89: a legacy agy file that stays beside the new one (a person's own) loses only AgEnD's block.
14913
+ const legacy = backend === "antigravity" ? agyLegacyAgentsFile(workDir) : null;
14914
+ if (legacy) {
14915
+ try {
14916
+ const old = readFileSync(legacy, "utf-8");
14917
+ const b = old.indexOf(FleetManager.STEERING_BLOCK_BEGIN), e = old.indexOf(FleetManager.STEERING_BLOCK_END);
14918
+ if (b !== -1 && e > b) {
14919
+ const rest = (old.slice(0, b).trimEnd() + "\n" + old.slice(e + FleetManager.STEERING_BLOCK_END.length).trimStart()).trim();
14920
+ if (rest)
14921
+ writeFileSync(legacy, rest + "\n");
14922
+ else
14923
+ rmSync(legacy, { force: true });
14924
+ }
14925
+ }
14926
+ catch { /* best effort: the new file already has the block */ }
14927
+ }
13750
14928
  }
13751
14929
  static STEERING_BLOCK_BEGIN = "<!-- >>> agend:core-rules — managed by AgEnD; edits inside this block are overwritten -->";
13752
14930
  static STEERING_BLOCK_END = "<!-- <<< agend:core-rules -->";
@@ -14229,12 +15407,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14229
15407
  const reactions = this.pendingReactionsMeta(instanceName);
14230
15408
  Object.assign(meta, reactions.meta);
14231
15409
  try {
14232
- await this.deliverToInstance(instanceName, {
15410
+ await this.deliverHumanToInstance(instanceName, {
14233
15411
  type: "fleet_inbound",
14234
15412
  content: fullText,
14235
15413
  targetSession: instanceName,
14236
15414
  meta,
14237
- });
15415
+ }, msg.humanOrigin, msg.humanOriginalBody ?? text);
14238
15416
  reactions.consume();
14239
15417
  }
14240
15418
  catch (err) {
@@ -14251,6 +15429,50 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14251
15429
  });
14252
15430
  }
14253
15431
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
15432
+ /** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
15433
+ runtimeSwitchWaiters = new Map();
15434
+ /**
15435
+ * #1690: type `/model <model>` and wait for what it came to: the daemon answers claude's "Switch model?" for us (Yes
15436
+ * when it names this model, No otherwise) or sees claude's own result. Null when it could not be sent; a timeout is
15437
+ * "not confirmed".
15438
+ */
15439
+ runRuntimeModelSwitch(instanceName, model) {
15440
+ const ipc = this.instanceIpcClients.get(instanceName);
15441
+ if (!ipc)
15442
+ return Promise.resolve({ outcome: "not_sent" });
15443
+ const id = `switch-${randomUUID()}`;
15444
+ return new Promise(resolve => {
15445
+ const timer = setTimeout(() => { this.runtimeSwitchWaiters.delete(id); resolve({ outcome: "not_confirmed" }); }, RUNTIME_SWITCH_RESULT_MS);
15446
+ timer.unref?.();
15447
+ this.runtimeSwitchWaiters.set(id, result => { clearTimeout(timer); resolve(result); });
15448
+ const sent = ipc.send({ type: "raw_paste", content: `/model ${model}`, runtime_switch: { id, kind: "model", requested: model } });
15449
+ if (!sent) {
15450
+ clearTimeout(timer);
15451
+ this.runtimeSwitchWaiters.delete(id);
15452
+ resolve({ outcome: "not_sent" });
15453
+ return;
15454
+ }
15455
+ this.logger.info({ instanceName, model }, "Runtime model switch sent");
15456
+ });
15457
+ }
15458
+ /**
15459
+ * #1690: the names claude-code is known to accept, for checking `/model <name>` BEFORE it is typed — claude itself
15460
+ * accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
15461
+ * the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
15462
+ */
15463
+ claudeModelKnown(instanceName, model,
15464
+ /** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
15465
+ configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
15466
+ const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
15467
+ const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
15468
+ const lower = model.trim().toLowerCase();
15469
+ const bare = lower.replace(/\[1m\]$/, "");
15470
+ const known = aliases.some(a => a.toLowerCase() === bare)
15471
+ || api.some(id => id.toLowerCase() === lower || `${id.toLowerCase()}[1m]` === lower)
15472
+ || (typeof configured === "string" && configured.toLowerCase() === lower)
15473
+ || (api.length === 0 && /^claude-[a-z0-9][a-z0-9.-]*(\[1m\])?$/.test(lower));
15474
+ return { known, names: [...aliases, ...api.filter(id => !id.endsWith("[1m]")).slice(0, 12)] };
15475
+ }
14254
15476
  pasteRawToClassicInstance(instanceName, text) {
14255
15477
  const ipc = this.instanceIpcClients.get(instanceName);
14256
15478
  if (!ipc) {
@@ -14850,7 +16072,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14850
16072
  try {
14851
16073
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
14852
16074
  result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
14853
- ? await this.applyEffort(pending.instanceName, level) : t("menu.click_stale");
16075
+ ? await withSpawnHoldReply(async (text) => {
16076
+ if (pending.adapter && pending.adapterChatId) {
16077
+ if (progressMsgId)
16078
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
16079
+ else
16080
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
16081
+ }
16082
+ else
16083
+ await pending.respond(text);
16084
+ }, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
14854
16085
  }
14855
16086
  catch (err) {
14856
16087
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
@@ -15175,7 +16406,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15175
16406
  try {
15176
16407
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
15177
16408
  result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
15178
- ? await this.applyModel(pending.instanceName, model) : t("menu.click_stale");
16409
+ ? await withSpawnHoldReply(async (text) => {
16410
+ if (pending.adapter && pending.adapterChatId) {
16411
+ if (progressMsgId)
16412
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
16413
+ else
16414
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
16415
+ }
16416
+ else
16417
+ await pending.respond(text);
16418
+ }, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
15179
16419
  }
15180
16420
  catch (err) {
15181
16421
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
@@ -15203,7 +16443,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15203
16443
  /** How this instance's backend applies an effort change. */
15204
16444
  effortStrategyFor(instanceName) {
15205
16445
  try {
15206
- const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
16446
+ const { strategy, levels } = this.instanceEffortMetadata(instanceName);
15207
16447
  return strategy !== "unsupported" && levels.length > 0 ? strategy : "unsupported";
15208
16448
  }
15209
16449
  catch {
@@ -15213,13 +16453,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15213
16453
  /** Effort levels this instance's backend actually accepts (empty = unsupported). */
15214
16454
  effortLevelsFor(instanceName) {
15215
16455
  try {
15216
- const { strategy, levels } = readEffortMetadata(this.backendNameForInstance(instanceName), this.getInstanceDir(instanceName));
16456
+ const { strategy, levels } = this.instanceEffortMetadata(instanceName);
15217
16457
  return strategy === "unsupported" ? [] : levels;
15218
16458
  }
15219
16459
  catch {
15220
16460
  return [];
15221
16461
  }
15222
16462
  }
16463
+ /** The instance's backend's effort metadata; agy's levels are its model's (#1825), the one the instance runs. */
16464
+ instanceEffortMetadata(instanceName) {
16465
+ const backend = this.backendNameForInstance(instanceName);
16466
+ let model = null;
16467
+ if (backend === "antigravity") {
16468
+ const resolved = this.resolveInstanceModel(instanceName);
16469
+ model = resolved.source === "unresolved" ? null : resolved.model;
16470
+ }
16471
+ return readEffortMetadata(backend, this.getInstanceDir(instanceName), model);
16472
+ }
15223
16473
  /** Configured effort for an instance: per-instance, else fleet default, else none. */
15224
16474
  resolveInstanceEffort(instanceName) {
15225
16475
  const own = this.fleetConfig?.instances[instanceName]?.effort;
@@ -15309,21 +16559,34 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15309
16559
  }
15310
16560
  catch { /* default restart */ }
15311
16561
  const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
16562
+ // #1690: claude takes any name; a mistyped one would be "switched" to and fail the next turn. Checked first —
16563
+ // nothing is typed or saved for an unknown one.
16564
+ if (backendName === "claude-code") {
16565
+ const check = this.claudeModelKnown(instanceName, model);
16566
+ if (!check.known)
16567
+ return t("model.unknown_name", model, check.names.join(", "));
16568
+ }
15312
16569
  if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
15313
16570
  return `${warn}${t("effort.not_running", instanceName)}`;
15314
16571
  }
16572
+ // #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
16573
+ if (strategy === "runtime" && backendName === "claude-code") {
16574
+ const result = await this.runRuntimeModelSwitch(instanceName, model);
16575
+ if (result.outcome === "mismatch")
16576
+ return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
16577
+ if (result.outcome === "not_sent")
16578
+ return `${warn}${t("model.switch_not_sent", instanceName, model)}`;
16579
+ const persistedNow = this.persistInstanceModel(instanceName, model);
16580
+ if (!persistedNow)
16581
+ return `${warn}${t("model.persist_failed", instanceName)}`;
16582
+ const head = result.outcome === "switched"
16583
+ ? (result.confirmed ? t("model.switch_confirmed", instanceName, model) : t("model.runtime_success", instanceName, model))
16584
+ : t("model.switch_unconfirmed", instanceName, model, Math.round(RUNTIME_SWITCH_RESULT_MS / 1000));
16585
+ return `${warn}${head}${this.effortSuffix(instanceName)}`;
16586
+ }
15315
16587
  // Persist either way: a runtime switch must survive the next respawn too,
15316
16588
  // or the instance silently reverts to the CLI default after a fleet restart.
15317
- let persisted = false;
15318
- if (this.fleetConfig?.instances[instanceName]) {
15319
- this.fleetConfig.instances[instanceName].model = model;
15320
- this.saveFleetConfig();
15321
- persisted = true;
15322
- }
15323
- else if (this.classicChannels?.setModelByInstance(instanceName, model)) {
15324
- persisted = true;
15325
- }
15326
- if (!persisted)
16589
+ if (!this.persistInstanceModel(instanceName, model))
15327
16590
  return `${warn}${t("model.persist_failed", instanceName)}`;
15328
16591
  if (strategy === "runtime") {
15329
16592
  this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
@@ -15332,6 +16595,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15332
16595
  await this.restartSingleInstance(instanceName);
15333
16596
  return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
15334
16597
  }
16598
+ /** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
16599
+ persistInstanceModel(instanceName, model) {
16600
+ if (this.fleetConfig?.instances[instanceName]) {
16601
+ this.fleetConfig.instances[instanceName].model = model;
16602
+ this.saveFleetConfig();
16603
+ return true;
16604
+ }
16605
+ return this.classicChannels?.setModelByInstance(instanceName, model) === true;
16606
+ }
15335
16607
  /**
15336
16608
  * The trailing "Current effort: …" line for a /model reply.
15337
16609
  *
@@ -15349,6 +16621,377 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15349
16621
  ? `\n${t("effort.current_fleet", effort)}`
15350
16622
  : `\n${t("effort.current", effort)}`;
15351
16623
  }
16624
+ // ── #1806: /backend — another CLI backend, with its model and effort, in one write and one relaunch ──
16625
+ /** The steps' view of this fleet (backend-command.ts). `live`: a missing or stale model list may be probed (not on the web). */
16626
+ backendCommandDeps(instanceName, opts) {
16627
+ return {
16628
+ currentBackend: (name) => this.backendNameForInstance(name),
16629
+ currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
16630
+ installedBackends: () => KNOWN_BACKENDS.filter(b => b !== "mock" && this.backendInstalledHere(b)),
16631
+ models: (backend) => this.backendModelOptions(backend, opts),
16632
+ // #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
16633
+ modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
16634
+ effortLevels: (backend, model) => this.backendEffortLevels(instanceName, backend, model),
16635
+ inherited: (key) => {
16636
+ const value = getEffectiveInstanceDefaults(this.fleetConfig?.defaults)[key];
16637
+ return typeof value === "string" && value ? value : undefined;
16638
+ },
16639
+ modelCompatible: (backend, model) => isModelCompatible(backend, model),
16640
+ state: (name) => {
16641
+ const status = this.getInstanceStatus(name);
16642
+ return { running: status === "running", busy: status === "running" && !this.getInstanceIdle(name), status };
16643
+ },
16644
+ t: (key, ...args) => t(key, ...args),
16645
+ };
16646
+ }
16647
+ /**
16648
+ * `/effort`'s levels for a backend this instance may move to, for the model that will run there (codex's levels are
16649
+ * per model; null: its configured default). Empty: it has no effort setting. A file read, never a CLI.
16650
+ */
16651
+ backendEffortLevels(instanceName, backend, model = null) {
16652
+ // codex: the model the NEXT launch runs, for the levels and the gate alike (#1807 review r2).
16653
+ let runs = model;
16654
+ if (backend === "codex") {
16655
+ const next = this.prospectiveCodexModel(instanceName, model);
16656
+ if (!next.proven)
16657
+ return [];
16658
+ runs = next.model;
16659
+ }
16660
+ let levels;
16661
+ try {
16662
+ const metadata = readEffortMetadata(backend, this.getInstanceDir(instanceName), runs);
16663
+ levels = metadata.strategy === "unsupported" ? [] : metadata.levels;
16664
+ }
16665
+ catch {
16666
+ return [];
16667
+ }
16668
+ // #1811: only the levels this host's launch of that backend will pass (launch-effort.ts, the gate the launch
16669
+ // itself applies) — an effort chosen here reaches the new CLI with the one relaunch, or is not offered.
16670
+ const takes = this.launchEffortGate(instanceName, backend, runs);
16671
+ return levels.filter(takes);
16672
+ }
16673
+ /**
16674
+ * #1808: whether a backend takes this model name — the one rule for /backend and update_instance_config's switch.
16675
+ * The name must be one its launch line can carry (modelNameCarried); then its offered list (the cached CLI env), and
16676
+ * claude-code's known names (without this instance's configured model, which belongs to the backend being left).
16677
+ * With no list known, the backend's usual name pattern decides.
16678
+ */
16679
+ backendModelVerdict(instanceName, backend, model,
16680
+ /** The provider the backend will run on (default: this instance's own, else the fleet's). */
16681
+ opts = { provider: this.customProviderFor(instanceName, backend) }) {
16682
+ // A custom provider has its own catalog, which the CLI reads at launch and AgEnD cannot see before one (#1815
16683
+ // review): only the launch-line rule applies — never the account's list or the default provider's naming pattern.
16684
+ if (opts.provider)
16685
+ return modelNameCarried(backend, model) ? { ok: true } : { ok: false, choices: [] };
16686
+ const listed = this.readCliEnv(backend)?.models ?? [];
16687
+ const choices = listed.slice(0, 24).map(o => o.id);
16688
+ if (!modelNameCarried(backend, model))
16689
+ return { ok: false, choices };
16690
+ if (listed.some(o => o.id === model))
16691
+ return { ok: true };
16692
+ if (backend === "claude-code") {
16693
+ const check = this.claudeModelKnown(instanceName, model, null);
16694
+ return check.known ? { ok: true } : { ok: false, choices: check.names };
16695
+ }
16696
+ return listed.length === 0 && isModelCompatible(backend, model) ? { ok: true } : { ok: false, choices };
16697
+ }
16698
+ /**
16699
+ * #1807 review r2: the model codex's next launch of this instance runs. Every launch's writeConfig copies the SHARED
16700
+ * config.toml into the instance's codex home first, so that source decides — not the home's copy from an earlier
16701
+ * launch. Read-only; a model chosen here (`-c model=…`) outranks it; a project file that could change it, unproven.
16702
+ */
16703
+ prospectiveCodexModel(instanceName, model) {
16704
+ const { sharedHome } = codexMetadataHomes(this.getInstanceDir(instanceName));
16705
+ return resolveCodexLaunchModel({
16706
+ isolatedHome: sharedHome, sharedHome, model,
16707
+ workingDirectory: this.fleetConfig?.instances[instanceName]?.working_directory,
16708
+ });
16709
+ }
16710
+ /** #1811: whether a launch of `backend` here would pass a level: the same gate its builder applies. */
16711
+ launchEffortGate(instanceName, backend, model) {
16712
+ if (backend === "claude-code" || backend === "grok" || backend === "antigravity") {
16713
+ const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
16714
+ if (!binary)
16715
+ return () => false;
16716
+ const path = resolveBinary(binary);
16717
+ return level => flagEffort(backend, level, path).arg !== null;
16718
+ }
16719
+ if (backend === "codex") {
16720
+ // `model` is the next launch's, proven by the caller (prospectiveCodexModel); the catalog is codex's own cache.
16721
+ if (!model)
16722
+ return () => false;
16723
+ const catalog = readCodexModelEffortLevels({ ...codexMetadataHomes(this.getInstanceDir(instanceName)), model });
16724
+ return level => codexEffort(level, catalog).arg !== null;
16725
+ }
16726
+ // kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
16727
+ return () => true;
16728
+ }
16729
+ /** Installed the way the launch finds a CLI (resolveBinary): on PATH or in a known install directory. */
16730
+ backendInstalledHere(backend) {
16731
+ const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
16732
+ return !!binary && resolveBinary(binary) !== binary;
16733
+ }
16734
+ /** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
16735
+ async backendModelOptions(backend, opts) {
16736
+ const cached = this.readCliEnv(backend);
16737
+ if (!opts.live || (cached?.models.length && !this.cliEnvNeedsRefresh(cached)))
16738
+ return cached?.models ?? [];
16739
+ opts.onLiveProbe?.();
16740
+ const env = await this.probeBackendBounded(backend).catch(() => null);
16741
+ return env?.models.length ? env.models : cached?.models ?? [];
16742
+ }
16743
+ /**
16744
+ * #1806 review: the instance a `/backend` was asked about is still this config entry — not one deleted and created
16745
+ * again under the same name, or replaced by a Settings edit (both put a new object there), stopped or not.
16746
+ */
16747
+ backendTargetIs(instanceName, target) {
16748
+ const instances = this.fleetConfig?.instances;
16749
+ return !!target && !!instances && Object.prototype.hasOwnProperty.call(instances, instanceName) && instances[instanceName] === target;
16750
+ }
16751
+ /** `/backend` is for fleet.yaml instances: update_instance_config does not manage a ClassicBot agent. */
16752
+ backendCommandRefusal(instanceName) {
16753
+ return this.fleetConfig?.instances && Object.prototype.hasOwnProperty.call(this.fleetConfig.instances, instanceName)
16754
+ ? null : t("backend.classic_unsupported", instanceName);
16755
+ }
16756
+ /**
16757
+ * Apply a confirmed `/backend`: judged again first (a menu lives a minute — the backend may have moved, a CLI been
16758
+ * removed), then update_instance_config's write: backend, model and effort at once, refused and rolled back whole
16759
+ * (its credential-profile login gate included), then one fresh relaunch of a running instance.
16760
+ */
16761
+ async applyBackendSwitch(instanceName, args, from, stillCurrent) {
16762
+ if (this.backendNameForInstance(instanceName) !== from)
16763
+ return t("backend.changed", instanceName, from);
16764
+ if (!stillCurrent())
16765
+ return t("menu.click_stale");
16766
+ const deps = this.backendCommandDeps(instanceName, { live: false });
16767
+ const step = await backendStep(deps, instanceName, args);
16768
+ if (step.kind !== "confirm")
16769
+ return step.kind === "refuse" ? step.text : t("backend.usage");
16770
+ // Asked again after the await, with nothing awaited between here and the write (applyInstanceConfigPatch writes
16771
+ // synchronously): two switches confirmed together, or an instance changed meanwhile, never both succeed.
16772
+ if (this.backendNameForInstance(instanceName) !== from)
16773
+ return t("backend.changed", instanceName, from);
16774
+ if (!stillCurrent())
16775
+ return t("menu.click_stale");
16776
+ const { plan } = step;
16777
+ const shown = planDisplay(deps, plan);
16778
+ const { value, error } = await new Promise(resolve => {
16779
+ applyInstanceConfigPatch(this, instanceName, { backend: plan.backend, model: plan.model, effort: plan.effort }, (value, error) => resolve({ value, error }));
16780
+ });
16781
+ if (error)
16782
+ return t("backend.refused", instanceName, error);
16783
+ const result = (value ?? {});
16784
+ if (result.warning)
16785
+ return t("backend.restart_failed", instanceName, plan.backend, result.warning);
16786
+ if (result.restarted)
16787
+ return t("backend.switched", instanceName, plan.backend, shown.model, shown.effort);
16788
+ return t("backend.switched_not_running", instanceName, plan.backend, shown.model, shown.effort, this.getInstanceStatus(instanceName));
16789
+ }
16790
+ /** A step's buttons: index callbacks (a model id can be longer than Telegram's 64-byte callback data allows). */
16791
+ backendStepButtons(instanceName, nonce, step) {
16792
+ if (step.kind === "choose")
16793
+ return step.choices.map((c, i) => ({ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:${i}`, label: c.label }));
16794
+ if (step.kind !== "confirm")
16795
+ return [];
16796
+ const running = this.getInstanceStatus(instanceName) === "running";
16797
+ return [
16798
+ { id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:0`, label: t(running ? "backend.confirm_button" : "backend.confirm_button_stopped") },
16799
+ { id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:1`, label: t("backend.cancel_button") },
16800
+ ];
16801
+ }
16802
+ /**
16803
+ * Show a `/backend` step (a choice, or the confirmation) where the command was given: Discord edits its interaction
16804
+ * reply; Telegram retires the previous step's keyboard and posts the next. One pending entry per step, under a fresh
16805
+ * nonce: a click on an older step finds nothing.
16806
+ */
16807
+ async presentBackendStep(place, step) {
16808
+ const nonce = randomBytes(6).toString("hex");
16809
+ const buttons = this.backendStepButtons(place.instanceName, nonce, step);
16810
+ const timer = setTimeout(() => {
16811
+ const p = this.pendingBackendSelects.get(nonce);
16812
+ if (!p)
16813
+ return;
16814
+ this.pendingBackendSelects.delete(nonce);
16815
+ void this.endBackendMenu(p, t("backend.selection_expired")).catch(() => { });
16816
+ }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
16817
+ timer.unref?.();
16818
+ const pending = { ...place, step, timer };
16819
+ this.pendingBackendSelects.set(nonce, pending);
16820
+ try {
16821
+ if (step.kind === "confirm" && place.respondButtons) {
16822
+ await place.respondButtons(step.text, buttons);
16823
+ return;
16824
+ }
16825
+ if (place.respondChoices) {
16826
+ await place.respondChoices(step.text, buttons);
16827
+ return;
16828
+ }
16829
+ if (!place.adapter || !place.adapterChatId)
16830
+ throw new Error("no place to show the menu");
16831
+ if (place.menuMessageId && place.adapter.editMessageRemoveButtons) {
16832
+ const chosen = t("backend.chosen", joinBackendArgs(step.args));
16833
+ await place.adapter.editMessageRemoveButtons(place.adapterChatId, place.menuMessageId, chosen, place.adapterThreadId).catch(() => { });
16834
+ }
16835
+ const menuMessageId = await place.adapter.promptUser(place.adapterChatId, step.text, buttons, { threadId: place.adapterThreadId });
16836
+ const fresh = this.pendingBackendSelects.get(nonce);
16837
+ if (fresh)
16838
+ fresh.menuMessageId = menuMessageId;
16839
+ }
16840
+ catch (err) {
16841
+ this.pendingBackendSelects.delete(nonce);
16842
+ clearTimeout(timer);
16843
+ this.logger.warn({ err, instanceName: place.instanceName }, "backend menu failed");
16844
+ await place.respond(t("backend.usage")).catch(() => { });
16845
+ }
16846
+ }
16847
+ /** Close a `/backend` menu with a last word: its buttons go (Telegram edits them away), the text stays. Returns where it was said. */
16848
+ async endBackendMenu(pending, text) {
16849
+ if (pending.adapter && pending.adapterChatId) {
16850
+ if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
16851
+ try {
16852
+ await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, text, pending.adapterThreadId);
16853
+ return pending.menuMessageId;
16854
+ }
16855
+ catch { /* say it in a new message */ }
16856
+ }
16857
+ return (await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId })).messageId;
16858
+ }
16859
+ if (pending.retireButtons)
16860
+ await pending.retireButtons(text);
16861
+ else
16862
+ await pending.respond(text);
16863
+ return undefined;
16864
+ }
16865
+ /** Start `/backend` for a fleet instance from Discord or Telegram; the caller has checked the channel admin. */
16866
+ async beginBackendCommand(place, args) {
16867
+ const refusal = this.backendCommandRefusal(place.instanceName);
16868
+ if (refusal) {
16869
+ await place.respond(refusal);
16870
+ return;
16871
+ }
16872
+ if (!place.respondChoices && !(place.adapter && place.adapterChatId)) {
16873
+ await place.respond(t("backend.usage"));
16874
+ return;
16875
+ }
16876
+ place = { ...place, target: this.fleetConfig.instances[place.instanceName] };
16877
+ const deps = this.backendCommandDeps(place.instanceName, {
16878
+ live: true,
16879
+ onLiveProbe: () => { void place.respond(t("model.refreshing")).catch(() => { }); },
16880
+ });
16881
+ const step = await backendStep(deps, place.instanceName, args);
16882
+ if (step.kind === "refuse") {
16883
+ await place.respond(step.text);
16884
+ return;
16885
+ }
16886
+ await this.presentBackendStep(place, step);
16887
+ }
16888
+ /** `/backend` slash handler: channel admin, as `/model`. Options or typed text give the direct form. */
16889
+ async handleBackendSlash(data, adapterId) {
16890
+ if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
16891
+ await data.respond(t("permission.denied"));
16892
+ return;
16893
+ }
16894
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
16895
+ if (!name) {
16896
+ await data.respond(t("classic.no_agent"));
16897
+ return;
16898
+ }
16899
+ const option = (key) => typeof data.options?.[key] === "string" ? data.options[key].trim() : "";
16900
+ const given = [option("backend"), option("model"), option("effort")];
16901
+ // A later option without the one before it says nothing that can be judged in order.
16902
+ if (given.some((value, i) => value && given.slice(0, i).some(before => !before))) {
16903
+ await data.respond(t("backend.usage"));
16904
+ return;
16905
+ }
16906
+ const args = given[0] ? given.filter(Boolean) : backendArgs(data.text ?? "");
16907
+ await this.beginBackendCommand({ instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, respond: data.respond, respondChoices: data.respondChoices,
16908
+ respondButtons: data.respondButtons, retireButtons: data.retireButtons }, args);
16909
+ }
16910
+ /** Telegram `/backend` in a fleet topic (topic-commands) or a ClassicBot chat; the caller has checked the admin. */
16911
+ async runBackendCommand(instanceName, text, userId, channelId, adapter, chatId, threadId, adapterId) {
16912
+ const respond = async (reply) => (await adapter.sendText(chatId, reply, { threadId })).messageId;
16913
+ await this.beginBackendCommand({ instanceName, userId, channelId, adapterId, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId }, backendArgs(text.replace(/^\/backend(@\S+)?/, "")));
16914
+ }
16915
+ /** Consume a `/backend` click: the next step, or on the confirmation the switch itself. True for every backend-select id. */
16916
+ async handleBackendSelection(data, adapterId) {
16917
+ if (!data.callbackData.startsWith(BACKEND_SELECT_CALLBACK_PREFIX))
16918
+ return false;
16919
+ const match = data.callbackData.match(/^backend-select:([0-9a-f]+):(\d{1,2})$/);
16920
+ if (!match)
16921
+ return true;
16922
+ const pending = this.pendingBackendSelects.get(match[1]);
16923
+ if (!pending)
16924
+ return true;
16925
+ // As a /model click (#754 audit): the admin who opened it, through the same bot, in the same channel, still admin.
16926
+ const cbChannel = data.threadId ?? data.chatId;
16927
+ if (!data.userId || data.userId !== pending.userId
16928
+ || (pending.adapterId !== undefined && pending.adapterId !== adapterId)
16929
+ || (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
16930
+ || !this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)) {
16931
+ data.ack?.(t("buttons.admin_only"));
16932
+ return true;
16933
+ }
16934
+ this.pendingBackendSelects.delete(match[1]);
16935
+ clearTimeout(pending.timer);
16936
+ if (!pending.menuMessageId && data.messageId)
16937
+ pending.menuMessageId = data.messageId;
16938
+ const index = Number(match[2]);
16939
+ const { step } = pending;
16940
+ if (step.kind === "choose") {
16941
+ const choice = step.choices[index];
16942
+ if (!choice)
16943
+ return true;
16944
+ if (!this.backendTargetIs(pending.instanceName, pending.target)) {
16945
+ await this.endBackendMenu(pending, t("menu.click_stale")).catch(() => { });
16946
+ return true;
16947
+ }
16948
+ const next = await backendStep(this.backendCommandDeps(pending.instanceName, { live: true }), pending.instanceName, [...step.args, choice.token]);
16949
+ if (next.kind === "refuse") {
16950
+ await this.endBackendMenu(pending, next.text).catch(() => { });
16951
+ return true;
16952
+ }
16953
+ await this.presentBackendStep(pending, next);
16954
+ return true;
16955
+ }
16956
+ if (index === 1) {
16957
+ await this.endBackendMenu(pending, t("backend.cancelled")).catch(() => { });
16958
+ return true;
16959
+ }
16960
+ if (index !== 0)
16961
+ return true;
16962
+ // Confirmed: say so at once, then switch in the background (the relaunch takes a while) and say how it went.
16963
+ const progressId = await this.endBackendMenu(pending, t("backend.switching", pending.instanceName, step.plan.backend)).catch(() => undefined);
16964
+ const say = async (text) => {
16965
+ if (pending.adapter && pending.adapterChatId) {
16966
+ if (progressId)
16967
+ await pending.adapter.editMessage(pending.adapterChatId, progressId, text, pending.adapterThreadId);
16968
+ else
16969
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
16970
+ }
16971
+ else
16972
+ await pending.respond(text);
16973
+ };
16974
+ void (async () => {
16975
+ let result;
16976
+ try {
16977
+ // Asked again after the awaits above: the instance may have moved, or the admin lost admin.
16978
+ const stillCurrent = () => this.backendTargetIs(pending.instanceName, pending.target)
16979
+ && this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId);
16980
+ result = stillCurrent()
16981
+ ? await withSpawnHoldReply(say, () => this.applyBackendSwitch(pending.instanceName, step.args, step.from, stillCurrent))
16982
+ : t("menu.click_stale");
16983
+ }
16984
+ catch (err) {
16985
+ this.logger.error({ err, instance: pending.instanceName, backend: step.plan.backend }, "Backend switch failed");
16986
+ result = t("backend.failed", pending.instanceName, step.plan.backend, err instanceof Error ? err.message : String(err));
16987
+ }
16988
+ await say(result).catch(() => {
16989
+ if (pending.adapter && pending.adapterChatId)
16990
+ pending.adapter.sendText(pending.adapterChatId, result, { threadId: pending.adapterThreadId }).catch(() => { });
16991
+ });
16992
+ })();
16993
+ return true;
16994
+ }
15352
16995
  /** Read recent chat log for agent context */
15353
16996
  getRecentChatLog(instanceName, maxLines = 10) {
15354
16997
  const logDir = ClassicChannelManager.chatLogDir(instanceName);
@@ -15442,6 +17085,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15442
17085
  }
15443
17086
  /** Handle Discord's required static slash choice, warning before a likely startup failure. */
15444
17087
  async handleClassicStartSlash(data, adapterId) {
17088
+ return withSpawnHoldReply(text => data.respond(text), () => this.handleClassicStartSlashCore(data, adapterId));
17089
+ }
17090
+ async handleClassicStartSlashCore(data, adapterId) {
15445
17091
  const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
15446
17092
  if (!requestedBackend) {
15447
17093
  // beta.31 made this option required. Discord can briefly retain the old
@@ -15558,7 +17204,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15558
17204
  // failure. The selected backend is still attempted as requested.
15559
17205
  if (warning)
15560
17206
  await pending.complete(warning, pending.messageId);
15561
- const reply = await this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend);
17207
+ const reply = await withSpawnHoldReply(text => pending.complete(text, pending.messageId), () => this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend));
15562
17208
  await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
15563
17209
  }
15564
17210
  /** Start a classic channel instance with lightweight config */
@@ -15639,12 +17285,29 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15639
17285
  const ch = this.classicChannels.unregister(channelId, adapterId);
15640
17286
  if (!ch)
15641
17287
  return t("classic.no_agent");
17288
+ // #1664: the room is gone (not paused or stopped): nobody's favorite any more — unless the name is still an
17289
+ // instance (fleet.yaml's, or another room's), so a later room of the same name does not inherit an old star.
17290
+ if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, ch.instanceName)
17291
+ && !this.classicChannels.getAll().some((c) => c.instanceName === ch.instanceName)) {
17292
+ try {
17293
+ this.webPrefs.forgetInstance(ch.instanceName);
17294
+ }
17295
+ catch (err) {
17296
+ this.logger.warn({ err: err.message, instanceName: ch.instanceName }, "Dropping a removed room from web favorites failed");
17297
+ }
17298
+ }
15642
17299
  this.instanceWorldBinding.delete(ch.instanceName);
15643
- await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
17300
+ // #1716: say so in the reply when background work was ended with the stop.
17301
+ // Null-safe: a foreign stopInstance stub may resolve nothing at all.
17302
+ const stopOutcome = await this.stopInstance(ch.instanceName).catch(err => {
17303
+ this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance");
17304
+ return { backgroundWorkEnded: false };
17305
+ });
15644
17306
  clearPausedMarker(this.getInstanceDir(ch.instanceName));
15645
17307
  this.reregisterClassicChannels();
15646
17308
  this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
15647
- return t("classic.stopped");
17309
+ return t("classic.stopped")
17310
+ + (stopOutcome?.backgroundWorkEnded ? `\n${t("classic.stopped_bg_work")}` : "");
15648
17311
  }
15649
17312
  /**
15650
17313
  * Idempotent while in flight: SIGINT and SIGTERM share one handler and the
@@ -15740,6 +17403,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15740
17403
  clearInterval(this.eventLogPruneTimer);
15741
17404
  this.eventLogPruneTimer = null;
15742
17405
  }
17406
+ this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
17407
+ this.compactWatchService?.stopAll();
15743
17408
  if (this.outboxPruneTimer) {
15744
17409
  clearInterval(this.outboxPruneTimer);
15745
17410
  this.outboxPruneTimer = null;
@@ -15839,8 +17504,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15839
17504
  this.controlClient = null;
15840
17505
  if (this.healthServer) {
15841
17506
  this.healthServerListening = false;
15842
- this.healthServer.close();
15843
- this.healthServer = null;
17507
+ const server = this.healthServer;
17508
+ const drain = this.healthServerDrain ?? trackWebServer(server);
17509
+ await drain.close(this.sseClients);
17510
+ if (this.healthServer === server) {
17511
+ this.healthServer = null;
17512
+ this.healthServerDrain = null;
17513
+ }
15844
17514
  }
15845
17515
  this.stopPreviewListener();
15846
17516
  // The store writes lastSeen at most once a minute; what that debounce is still holding — and any
@@ -16169,8 +17839,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16169
17839
  if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter || profile.changed) {
16170
17840
  this.logger.info({ instanceName: ch.instanceName, ...(profile.changed ? { profileFrom: profile.from, profileTo: profile.to } : {}) }, "Classic cold config changed — restarting");
16171
17841
  observe?.(ch.instanceName, "restart", "running");
17842
+ const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
16172
17843
  await this.stopInstance(ch.instanceName).catch(() => { });
16173
- if (profile.startsFresh)
17844
+ // #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
17845
+ if (profile.carry)
17846
+ await this.switchConversation(ch.instanceName, { ...profile.carry, workingDirectory: launchedIn });
17847
+ else if (profile.startsFresh)
16174
17848
  this.writeFreshStartMarker(ch.instanceName);
16175
17849
  await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
16176
17850
  observe?.(ch.instanceName, "restart", "done");
@@ -17714,37 +19388,41 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17714
19388
  const currentVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "0.0.0";
17715
19389
  const latest = await npmVersion("@songsid/agend");
17716
19390
  let target = latest;
19391
+ let fromChannel = false;
17717
19392
  // The same channel rule as `agend update` (#1259): an alpha follows @alpha, any other prerelease @beta.
17718
19393
  const channel = installedChannel(currentVersion);
17719
19394
  if (channel !== "latest") {
17720
- // Prerelease users track their own channel (never fall back to @latest,
17721
- // which is older, and an alpha is never offered @beta), but should also
17722
- // hear when a newer STABLE ships — pick whichever is the newest.
17723
19395
  let pre = "";
17724
19396
  try {
17725
19397
  pre = await npmVersion(`@songsid/agend@${channel}`);
17726
19398
  }
17727
19399
  catch { /* no such tag */ }
17728
- target = pre || latest;
17729
- if (latest && this.semverGt(latest, target))
19400
+ // #1926: a beta install hears about @beta only: beta users stay on the beta line after a stable ships, so
19401
+ // @latest is not their target. Taking the higher of the two made a same-core stable (2.2.0 over beta.16) the
19402
+ // target, which the same-core guard then dropped, so the newer beta.17 was never announced.
19403
+ target = pre;
19404
+ fromChannel = true;
19405
+ // An alpha is still told about a stable that passed its own minor (with --stable, #1259): only a stable of
19406
+ // 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
19407
+ // taken from @latest and no longer hides a newer alpha; once the release points @alpha at it, it comes from
19408
+ // @alpha like any channel release.
19409
+ const core = (v) => v.replace(/^v/, "").split("-")[0];
19410
+ if (channel === "alpha" && latest && core(latest) !== core(currentVersion) && (!pre || this.semverGt(latest, pre))) {
17730
19411
  target = latest;
19412
+ fromChannel = false;
19413
+ }
17731
19414
  }
17732
- // A beta already at/ahead of its matching stable must NOT be told to
17733
- // "update" to that stable — e.g. 2.0.11-beta.41 already contains everything
17734
- // in stable 2.0.11, so semverGt(2.0.11, 2.0.11-beta.41) being true (a stable
17735
- // outranks a prerelease of the same core) is a false positive here. Suppress
17736
- // only that same-core stable-vs-my-beta case; a higher stable core (2.0.12)
17737
- // or a newer beta (2.0.11-beta.50) still notifies via semverGt below.
17738
- const core = (v) => v.replace(/^v/, "").split("-")[0];
17739
- const betaSupersedesStable = isPrereleaseVersion(currentVersion) && !isPrereleaseVersion(target) && core(target) === core(currentVersion);
17740
19415
  // Only notify when target is genuinely newer (semver), so a beta user on
17741
19416
  // 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
17742
- if (target && !betaSupersedesStable && this.semverGt(target, currentVersion)) {
19417
+ if (target && this.semverGt(target, currentVersion)) {
17743
19418
  const generalId = this.findGeneralInstance();
17744
19419
  if (generalId) {
17745
19420
  // No release URL — Discord's SuppressEmbeds proved unreliable and the
17746
19421
  // link preview looked bad. Version + /update instruction is enough.
17747
- this.notifyInstanceTopic(generalId, t(updateNoticeKey(currentVersion, target), `v${target}`, `v${currentVersion}`));
19422
+ // From the install's own channel tag, this is what /update installs — even a stable the release pointed the
19423
+ // tag at (2.2.0 on @alpha / @beta): the plain /update text, never "/update stays on … use --stable" (#1928).
19424
+ const key = fromChannel ? "update.available_current" : updateNoticeKey(currentVersion, target);
19425
+ this.notifyInstanceTopic(generalId, t(key, `v${target}`, `v${currentVersion}`));
17748
19426
  }
17749
19427
  }
17750
19428
  }
@@ -17809,8 +19487,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17809
19487
  this.logger.warn({ host: name }, "Web request refused: Host is not allowed (add it to web.allowed_hosts if this is a proxy you run)");
17810
19488
  }
17811
19489
  /** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
17812
- dispatchWebHttp(req, res, port) {
19490
+ dispatchWebHttp(req, res, port, closing = false) {
17813
19491
  try {
19492
+ // #1937: the listeners allow an upload long enough for a slow link; every other request keeps its old bound on
19493
+ // arriving whole — 10 s through the public link's gateway, Node's 300 s here.
19494
+ if (!isUploadRequest(req))
19495
+ limitRequestReceive(req, gatewayRequestContext(req) ? PUBLIC_GATEWAY_RECEIVE_MS : 300_000);
17814
19496
  res.setHeader("Content-Type", "application/json");
17815
19497
  // No Referer to a tunnel host, an upstream proxy, or any page linked from
17816
19498
  // the panel — the dashboard URL is itself a credential-bearing address.
@@ -17828,6 +19510,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17828
19510
  res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
17829
19511
  return;
17830
19512
  }
19513
+ if (closing) {
19514
+ res.writeHead(503, { Connection: "close", "Retry-After": "10" });
19515
+ res.end(JSON.stringify({ error: "fleet stopping" }));
19516
+ return;
19517
+ }
17831
19518
  const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
17832
19519
  // Branding is public, after Host admission: automatic favicon requests
17833
19520
  // must not become noisy web-token 401s in the browser console.
@@ -18013,14 +19700,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18013
19700
  return;
18014
19701
  }
18015
19702
  (async () => {
19703
+ let held = false;
18016
19704
  try {
18017
19705
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
18018
- await this.startInstance(name, config, topicMode ?? false, "fleet-topic", true);
19706
+ await withSpawnHoldReply(async (message) => {
19707
+ held = true;
19708
+ res.writeHead(202, { "Content-Type": "application/json" });
19709
+ res.end(JSON.stringify({ held: true, message, instance: name }));
19710
+ }, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
18019
19711
  this.emitSseEvent("status", this.getUiStatus());
19712
+ if (held)
19713
+ return;
18020
19714
  res.writeHead(200);
18021
19715
  res.end(JSON.stringify({ ok: true }));
18022
19716
  }
18023
19717
  catch (err) {
19718
+ if (held) {
19719
+ this.logger.error({ err, name }, "Held start failed");
19720
+ return;
19721
+ }
18024
19722
  res.writeHead(500);
18025
19723
  res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
18026
19724
  }
@@ -18035,15 +19733,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18035
19733
  const name = decodeURIComponent(req.url.slice("/restart/".length));
18036
19734
  this.logger.info({ name }, "Instance restart requested via HTTP");
18037
19735
  (async () => {
19736
+ let held = false;
18038
19737
  try {
18039
- await this.restartSingleInstance(name, { explicit: true });
19738
+ await withSpawnHoldReply(async (message) => {
19739
+ held = true;
19740
+ res.writeHead(202, { "Content-Type": "application/json" });
19741
+ res.end(JSON.stringify({ held: true, message, instance: name }));
19742
+ }, () => this.restartSingleInstance(name, { explicit: true }));
18040
19743
  this.logger.info({ name }, "Instance restarted");
18041
19744
  this.emitSseEvent("status", this.getUiStatus());
19745
+ if (held)
19746
+ return;
18042
19747
  res.writeHead(200);
18043
19748
  res.end(JSON.stringify({ restarted: name }));
18044
19749
  }
18045
19750
  catch (err) {
18046
19751
  this.logger.error({ err, name }, "Instance restart failed");
19752
+ if (held)
19753
+ return;
18047
19754
  const status = err.message.includes("not found") ? 404 : 500;
18048
19755
  res.writeHead(status);
18049
19756
  res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
@@ -18126,7 +19833,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18126
19833
  // Defensive for direct/unit callers; normal startup initializes these before adapters.
18127
19834
  if (!this.webToken || !this.webSessions)
18128
19835
  this.initializeWebAuthTokens();
18129
- this.healthServer = createServer((req, res) => this.dispatchWebHttp(req, res, port));
19836
+ const server = createServer((req, res) => this.dispatchWebHttp(req, res, port, drain.closing));
19837
+ // #1937: Node's default (300 s to receive a request) would cut a large upload on a slow link. The listener allows
19838
+ // more; the upload route keeps its own clocks (no byte for a minute, or 30 minutes in all), and every other request
19839
+ // keeps the old 300 s (limitRequestReceive in dispatchWebHttp).
19840
+ server.requestTimeout = UPLOAD_REQUEST_TIMEOUT_MS;
19841
+ const drain = trackWebServer(server);
19842
+ this.healthServer = server;
19843
+ this.healthServerDrain = drain;
18130
19844
  const markListening = (afterTakeover = false) => {
18131
19845
  this.healthServerListening = true;
18132
19846
  this.logger.info({ port }, afterTakeover
@@ -18212,7 +19926,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18212
19926
  this.logger.warn({ health_port: requestedPort }, "No port for the HTML preview listener (health_port is the highest port) — set web.preview_port; previews are off");
18213
19927
  return;
18214
19928
  }
18215
- const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig });
19929
+ const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
19930
+ // #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
19931
+ extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
18216
19932
  this.previewListener = listener;
18217
19933
  listener.server.on("error", (err) => {
18218
19934
  if (this.previewListener !== listener)
@@ -18261,6 +19977,37 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18261
19977
  decided.reason = "Previews are not available on this fleet right now.";
18262
19978
  return { ...decided, boot: listener ? listener.bootId : null };
18263
19979
  }
19980
+ /**
19981
+ * #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
19982
+ * origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
19983
+ */
19984
+ publicPreviewForUi(exposureId, dashboardOrigin) {
19985
+ const web = this.fleetConfig?.web;
19986
+ const off = (code, reason, extra = {}) => ({ dashboardOrigin, previewOrigin: null, reason, code, boot: null, frameSrc: null, ...extra });
19987
+ if (web?.preview === false)
19988
+ return off("fleetOff", "Previews are turned off for this fleet (web.preview: false).");
19989
+ if (web?.preview_on_public_link === false)
19990
+ return off("publicLink", "Previews are off on the public link (web.preview_on_public_link: false).");
19991
+ const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
19992
+ if (configured) {
19993
+ const listener = this.previewListening ? this.previewListener : null;
19994
+ if (!listener)
19995
+ return off("publicLink", "Previews are not available on this fleet right now.");
19996
+ if (!configured.startsWith("https:"))
19997
+ return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
19998
+ return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
19999
+ }
20000
+ const status = this.publicWebLink?.previewStatus(exposureId) ?? null;
20001
+ if (!status)
20002
+ return off("publicLink", "Previews are not offered on this public link.");
20003
+ // The page may load before the preview tunnel has a host: it frames the quick-tunnel wildcard, and only ever the
20004
+ // origin /ui/preview-status names (the preview side's exact frame-ancestors is the boundary).
20005
+ if (status.state === "starting")
20006
+ return off("publicLinkStarting", "Preview is starting…", { frameSrc: PUBLIC_PREVIEW_FRAME_SRC });
20007
+ if (status.state === "failed")
20008
+ return off("publicLinkPreviewFailed", `Previews are unavailable on this public link (${status.reason}).`, { detail: status.reason });
20009
+ return { dashboardOrigin, previewOrigin: status.origin, reason: null, code: null, boot: status.boot, frameSrc: PUBLIC_PREVIEW_FRAME_SRC };
20010
+ }
18264
20011
  getUiStatus() {
18265
20012
  return measureSyncWork("fleet.uiStatus", () => this.getUiStatusSync());
18266
20013
  }
@@ -18278,12 +20025,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18278
20025
  const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
18279
20026
  const profiles = profileIdentities(this.dataDir);
18280
20027
  // #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
18281
- let ownBots = new Map();
20028
+ let bots = { own: new Map(), speaking: new Map() };
18282
20029
  try {
18283
- ownBots = this.ownBots();
20030
+ bots = this.webBots();
18284
20031
  }
18285
20032
  catch (err) {
18286
- this.logger.debug({ err }, "own bots unreadable (getUiStatus)");
20033
+ this.logger.debug({ err }, "bots unreadable (getUiStatus)");
18287
20034
  }
18288
20035
  const instances = names.map(name => {
18289
20036
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
@@ -18322,9 +20069,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18322
20069
  const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
18323
20070
  const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
18324
20071
  const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
20072
+ const compact = this.compactWatchService?.view(name) ?? null;
18325
20073
  let image;
18326
20074
  try {
18327
- image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, ownBots);
20075
+ image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, bots);
18328
20076
  }
18329
20077
  catch (err) {
18330
20078
  this.logger.debug({ err, name }, "avatar unreadable (getUiStatus)");
@@ -18349,6 +20097,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18349
20097
  effort,
18350
20098
  effort_source,
18351
20099
  ...this.instancePresentation(name),
20100
+ // Board 003648ac: the last web /compact, while it runs and briefly after (the chat's card follows it).
20101
+ ...(compact ? { compact } : {}),
18352
20102
  };
18353
20103
  });
18354
20104
  return {
@@ -18357,6 +20107,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18357
20107
  uptime: Math.floor((Date.now() - this.startedAt) / 1000),
18358
20108
  // #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
18359
20109
  connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
20110
+ // #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
20111
+ prefs_rev: this.webPrefs.rev(),
20112
+ // #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
20113
+ // shown here rather than in chat). null when normal, unknown or advisory.
20114
+ host_memory: this.hostMemoryIndicator(),
18360
20115
  };
18361
20116
  }
18362
20117
  }