@songsid/agend 2.2.0-beta.1 → 2.2.0-beta.10

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 (377) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/agent-cli-instructions.md +16 -9
  4. package/dist/agent-cli.js +15 -7
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.js +21 -9
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-whats-new.d.ts +47 -0
  9. package/dist/agent-whats-new.js +147 -0
  10. package/dist/agent-whats-new.js.map +1 -0
  11. package/dist/auth-api.d.ts +2 -2
  12. package/dist/auth-api.js +4 -2
  13. package/dist/auth-api.js.map +1 -1
  14. package/dist/backend/antigravity.d.ts +3 -0
  15. package/dist/backend/antigravity.js +9 -0
  16. package/dist/backend/antigravity.js.map +1 -1
  17. package/dist/backend/claude-carry.d.ts +22 -0
  18. package/dist/backend/claude-carry.js +170 -0
  19. package/dist/backend/claude-carry.js.map +1 -0
  20. package/dist/backend/claude-code.d.ts +65 -2
  21. package/dist/backend/claude-code.js +226 -33
  22. package/dist/backend/claude-code.js.map +1 -1
  23. package/dist/backend/codex-metadata.d.ts +33 -0
  24. package/dist/backend/codex-metadata.js +122 -18
  25. package/dist/backend/codex-metadata.js.map +1 -1
  26. package/dist/backend/codex.d.ts +3 -0
  27. package/dist/backend/codex.js +17 -1
  28. package/dist/backend/codex.js.map +1 -1
  29. package/dist/backend/credential-profile.d.ts +40 -3
  30. package/dist/backend/credential-profile.js +113 -5
  31. package/dist/backend/credential-profile.js.map +1 -1
  32. package/dist/backend/effort-metadata.d.ts +1 -1
  33. package/dist/backend/effort-metadata.js +3 -2
  34. package/dist/backend/effort-metadata.js.map +1 -1
  35. package/dist/backend/grok.d.ts +4 -0
  36. package/dist/backend/grok.js +14 -0
  37. package/dist/backend/grok.js.map +1 -1
  38. package/dist/backend/kiro-engine-ledger.d.ts +5 -0
  39. package/dist/backend/kiro-engine-ledger.js +20 -0
  40. package/dist/backend/kiro-engine-ledger.js.map +1 -1
  41. package/dist/backend/kiro-identity.d.ts +6 -0
  42. package/dist/backend/kiro-identity.js +19 -5
  43. package/dist/backend/kiro-identity.js.map +1 -1
  44. package/dist/backend/kiro.d.ts +39 -3
  45. package/dist/backend/kiro.js +143 -19
  46. package/dist/backend/kiro.js.map +1 -1
  47. package/dist/backend/launch-effort.d.ts +33 -0
  48. package/dist/backend/launch-effort.js +123 -0
  49. package/dist/backend/launch-effort.js.map +1 -0
  50. package/dist/backend/muse.d.ts +84 -0
  51. package/dist/backend/muse.js +449 -53
  52. package/dist/backend/muse.js.map +1 -1
  53. package/dist/backend/types.d.ts +33 -4
  54. package/dist/backend/types.js +2 -1
  55. package/dist/backend/types.js.map +1 -1
  56. package/dist/backend-command.d.ts +91 -0
  57. package/dist/backend-command.js +122 -0
  58. package/dist/backend-command.js.map +1 -0
  59. package/dist/background-work-ended.d.ts +18 -0
  60. package/dist/background-work-ended.js +30 -0
  61. package/dist/background-work-ended.js.map +1 -0
  62. package/dist/cache-service.d.ts +2 -0
  63. package/dist/cache-service.js +2 -1
  64. package/dist/cache-service.js.map +1 -1
  65. package/dist/channel/adapters/discord.d.ts +24 -1
  66. package/dist/channel/adapters/discord.js +87 -2
  67. package/dist/channel/adapters/discord.js.map +1 -1
  68. package/dist/channel/adapters/telegram.d.ts +24 -2
  69. package/dist/channel/adapters/telegram.js +118 -20
  70. package/dist/channel/adapters/telegram.js.map +1 -1
  71. package/dist/channel/mcp-server.js +9 -1
  72. package/dist/channel/mcp-server.js.map +1 -1
  73. package/dist/channel/mcp-tools.d.ts +11 -0
  74. package/dist/channel/mcp-tools.js +24 -13
  75. package/dist/channel/mcp-tools.js.map +1 -1
  76. package/dist/channel/message-edit-errors.d.ts +5 -0
  77. package/dist/channel/message-edit-errors.js +24 -0
  78. package/dist/channel/message-edit-errors.js.map +1 -0
  79. package/dist/channel/telegram-markdown.d.ts +6 -0
  80. package/dist/channel/telegram-markdown.js +104 -0
  81. package/dist/channel/telegram-markdown.js.map +1 -0
  82. package/dist/channel/tool-router.js +9 -3
  83. package/dist/channel/tool-router.js.map +1 -1
  84. package/dist/channel/types.d.ts +39 -3
  85. package/dist/cli.js +133 -28
  86. package/dist/cli.js.map +1 -1
  87. package/dist/command-table.d.ts +2 -2
  88. package/dist/command-table.js +6 -3
  89. package/dist/command-table.js.map +1 -1
  90. package/dist/config-validator.js +59 -0
  91. package/dist/config-validator.js.map +1 -1
  92. package/dist/cross-instance-envelope.d.ts +15 -2
  93. package/dist/cross-instance-envelope.js +29 -5
  94. package/dist/cross-instance-envelope.js.map +1 -1
  95. package/dist/daemon.d.ts +156 -1
  96. package/dist/daemon.js +764 -89
  97. package/dist/daemon.js.map +1 -1
  98. package/dist/delivery-outbox.d.ts +62 -7
  99. package/dist/delivery-outbox.js +309 -22
  100. package/dist/delivery-outbox.js.map +1 -1
  101. package/dist/delivery-reconciliation.d.ts +18 -1
  102. package/dist/delivery-reconciliation.js +67 -2
  103. package/dist/delivery-reconciliation.js.map +1 -1
  104. package/dist/fleet-context.d.ts +10 -1
  105. package/dist/fleet-manager.d.ts +345 -10
  106. package/dist/fleet-manager.js +1868 -174
  107. package/dist/fleet-manager.js.map +1 -1
  108. package/dist/general-knowledge/skills/backend-providers/SKILL.md +1 -1
  109. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +92 -21
  110. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +27 -3
  111. package/dist/general-knowledge/skills/delegation-playbook/SKILL.md +8 -8
  112. package/dist/general-knowledge/skills/fleet-config/SKILL.md +5 -5
  113. package/dist/general-knowledge/skills/fleet-health/SKILL.md +4 -4
  114. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +13 -13
  115. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -2
  116. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +6 -0
  117. package/dist/general-knowledge/skills/model-discovery/SKILL.md +9 -9
  118. package/dist/general-knowledge/skills/multi-channel/SKILL.md +8 -2
  119. package/dist/general-knowledge/skills/scheduling/SKILL.md +5 -3
  120. package/dist/general-knowledge/skills/session-management/SKILL.md +6 -5
  121. package/dist/general-knowledge/skills/tui-effort/SKILL.md +2 -2
  122. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +8 -3
  123. package/dist/general-knowledge/steering/core-rules.md +8 -7
  124. package/dist/host-memory.d.ts +10 -0
  125. package/dist/host-memory.js +33 -1
  126. package/dist/host-memory.js.map +1 -1
  127. package/dist/human-delivery-receipts.d.ts +83 -0
  128. package/dist/human-delivery-receipts.js +194 -0
  129. package/dist/human-delivery-receipts.js.map +1 -0
  130. package/dist/instance-avatar.d.ts +36 -0
  131. package/dist/instance-avatar.js +57 -0
  132. package/dist/instance-avatar.js.map +1 -0
  133. package/dist/instance-config-impact.js +6 -0
  134. package/dist/instance-config-impact.js.map +1 -1
  135. package/dist/instance-lifecycle.d.ts +29 -6
  136. package/dist/instance-lifecycle.js +79 -28
  137. package/dist/instance-lifecycle.js.map +1 -1
  138. package/dist/instructions.d.ts +3 -1
  139. package/dist/instructions.js +11 -8
  140. package/dist/instructions.js.map +1 -1
  141. package/dist/kiro-log-gate.d.ts +42 -0
  142. package/dist/kiro-log-gate.js +39 -0
  143. package/dist/kiro-log-gate.js.map +1 -0
  144. package/dist/locale.js +200 -44
  145. package/dist/locale.js.map +1 -1
  146. package/dist/log-service.d.ts +99 -0
  147. package/dist/log-service.js +243 -0
  148. package/dist/log-service.js.map +1 -0
  149. package/dist/log-steps.d.ts +169 -0
  150. package/dist/log-steps.js +858 -0
  151. package/dist/log-steps.js.map +1 -0
  152. package/dist/login-controller.d.ts +20 -3
  153. package/dist/login-controller.js +71 -19
  154. package/dist/login-controller.js.map +1 -1
  155. package/dist/login-flows.d.ts +5 -2
  156. package/dist/login-flows.js +5 -4
  157. package/dist/login-flows.js.map +1 -1
  158. package/dist/memory-pressure.d.ts +13 -1
  159. package/dist/memory-pressure.js +50 -10
  160. package/dist/memory-pressure.js.map +1 -1
  161. package/dist/needs-you-hub.js +2 -0
  162. package/dist/needs-you-hub.js.map +1 -1
  163. package/dist/needs-you-live.d.ts +6 -0
  164. package/dist/needs-you-live.js +23 -3
  165. package/dist/needs-you-live.js.map +1 -1
  166. package/dist/next-steps.js +1 -1
  167. package/dist/next-steps.js.map +1 -1
  168. package/dist/outbound-handlers.d.ts +100 -0
  169. package/dist/outbound-handlers.js +263 -96
  170. package/dist/outbound-handlers.js.map +1 -1
  171. package/dist/outbound-schemas.d.ts +26 -15
  172. package/dist/outbound-schemas.js +24 -7
  173. package/dist/outbound-schemas.js.map +1 -1
  174. package/dist/public-link-progress.d.ts +1 -1
  175. package/dist/public-link-progress.js +8 -2
  176. package/dist/public-link-progress.js.map +1 -1
  177. package/dist/public-preview.d.ts +101 -0
  178. package/dist/public-preview.js +236 -0
  179. package/dist/public-preview.js.map +1 -0
  180. package/dist/public-web-gateway.js +11 -4
  181. package/dist/public-web-gateway.js.map +1 -1
  182. package/dist/public-web-link.d.ts +24 -1
  183. package/dist/public-web-link.js +63 -9
  184. package/dist/public-web-link.js.map +1 -1
  185. package/dist/reply-buttons-controller.d.ts +3 -0
  186. package/dist/reply-buttons-controller.js +3 -2
  187. package/dist/reply-buttons-controller.js.map +1 -1
  188. package/dist/restart-guard.d.ts +6 -3
  189. package/dist/restart-guard.js +6 -3
  190. package/dist/restart-guard.js.map +1 -1
  191. package/dist/restart-progress.d.ts +33 -0
  192. package/dist/restart-progress.js +30 -0
  193. package/dist/restart-progress.js.map +1 -1
  194. package/dist/service-activation.d.ts +15 -7
  195. package/dist/service-activation.js +46 -29
  196. package/dist/service-activation.js.map +1 -1
  197. package/dist/service-installer.d.ts +31 -0
  198. package/dist/service-installer.js +41 -2
  199. package/dist/service-installer.js.map +1 -1
  200. package/dist/settings-api.js +85 -25
  201. package/dist/settings-api.js.map +1 -1
  202. package/dist/settings-change.js +72 -1
  203. package/dist/settings-change.js.map +1 -1
  204. package/dist/settings-effect.js +28 -4
  205. package/dist/settings-effect.js.map +1 -1
  206. package/dist/settings-http-confirmation.js +7 -0
  207. package/dist/settings-http-confirmation.js.map +1 -1
  208. package/dist/setup-form.d.ts +1 -1
  209. package/dist/setup-form.js +1 -1
  210. package/dist/spawn-gate.js +5 -1
  211. package/dist/spawn-gate.js.map +1 -1
  212. package/dist/spawn-hold-reply.d.ts +12 -0
  213. package/dist/spawn-hold-reply.js +39 -0
  214. package/dist/spawn-hold-reply.js.map +1 -0
  215. package/dist/status-emojis.d.ts +10 -3
  216. package/dist/status-emojis.js +11 -2
  217. package/dist/status-emojis.js.map +1 -1
  218. package/dist/systemd-peer-bus.d.ts +95 -0
  219. package/dist/systemd-peer-bus.js +430 -0
  220. package/dist/systemd-peer-bus.js.map +1 -0
  221. package/dist/systemd-runtime.d.ts +27 -1
  222. package/dist/systemd-runtime.js +69 -7
  223. package/dist/systemd-runtime.js.map +1 -1
  224. package/dist/tips.js +4 -4
  225. package/dist/tips.js.map +1 -1
  226. package/dist/tool-progress.js +3 -2
  227. package/dist/tool-progress.js.map +1 -1
  228. package/dist/topic-commands.d.ts +42 -2
  229. package/dist/topic-commands.js +92 -7
  230. package/dist/topic-commands.js.map +1 -1
  231. package/dist/transcript-delta-reader.d.ts +3 -3
  232. package/dist/transcript-delta-reader.js +8 -8
  233. package/dist/transcript-delta-reader.js.map +1 -1
  234. package/dist/transcript-jsonl.d.ts +80 -3
  235. package/dist/transcript-jsonl.js +147 -10
  236. package/dist/transcript-jsonl.js.map +1 -1
  237. package/dist/transcript-monitor.d.ts +19 -0
  238. package/dist/transcript-monitor.js +87 -15
  239. package/dist/transcript-monitor.js.map +1 -1
  240. package/dist/transcript-sources.d.ts +78 -0
  241. package/dist/transcript-sources.js +375 -64
  242. package/dist/transcript-sources.js.map +1 -1
  243. package/dist/transcript-turns.d.ts +15 -2
  244. package/dist/transcript-turns.js +15 -2
  245. package/dist/transcript-turns.js.map +1 -1
  246. package/dist/tunnel/cloudflared.d.ts +7 -15
  247. package/dist/tunnel/cloudflared.js +36 -1
  248. package/dist/tunnel/cloudflared.js.map +1 -1
  249. package/dist/tunnel/purpose-lane.d.ts +5 -1
  250. package/dist/tunnel/purpose-lane.js +14 -7
  251. package/dist/tunnel/purpose-lane.js.map +1 -1
  252. package/dist/tunnel/types.d.ts +5 -1
  253. package/dist/tunnel/types.js.map +1 -1
  254. package/dist/turn-resume.d.ts +2 -0
  255. package/dist/turn-resume.js +1 -1
  256. package/dist/turn-resume.js.map +1 -1
  257. package/dist/types.d.ts +25 -0
  258. package/dist/ui/chat-commands.js +1 -0
  259. package/dist/ui/chat-emojis.js +33 -0
  260. package/dist/ui/chat-mentions.js +63 -0
  261. package/dist/ui/chat-render.js +42 -2
  262. package/dist/ui/chat-store.js +2 -2
  263. package/dist/ui/chat-strings.js +38 -20
  264. package/dist/ui/chat-thread.js +69 -12
  265. package/dist/ui/chat-time.js +80 -0
  266. package/dist/ui/panel-chat.js +39 -10
  267. package/dist/ui/panel-details.js +2 -0
  268. package/dist/ui/panel-fleet.js +199 -57
  269. package/dist/ui/panel-log.js +428 -0
  270. package/dist/ui/panel-needs.js +6 -2
  271. package/dist/ui/panel-settings.js +137 -32
  272. package/dist/ui/preview-panel.js +55 -15
  273. package/dist/ui/preview-status.js +28 -0
  274. package/dist/ui/preview.js +44 -3
  275. package/dist/ui/settings-dialogs.js +7 -2
  276. package/dist/ui/settings-model.js +2 -1
  277. package/dist/ui/settings-strings.js +46 -4
  278. package/dist/ui/settings-token.js +1 -1
  279. package/dist/ui/shared/app-i18n.js +43 -17
  280. package/dist/ui/shared/app-prefs.js +250 -0
  281. package/dist/ui/shared/app-route.js +7 -2
  282. package/dist/ui/shared/app-shell.js +165 -8
  283. package/dist/ui/shared/app-store.js +5 -1
  284. package/dist/ui/shared/app-stream.js +23 -5
  285. package/dist/ui/shared/app.css +155 -22
  286. package/dist/ui/shared/app.js +11 -1
  287. package/dist/ui/shared/fonts/README.md +1 -1
  288. package/dist/ui/shared/header-tools.js +55 -5
  289. package/dist/ui/shared/instance-nav.js +241 -45
  290. package/dist/ui/shared/instance-switch.js +4 -4
  291. package/dist/ui/shared/panel-view.js +6 -0
  292. package/dist/ui/shared/signin.js +7 -7
  293. package/dist/ui/shared/theme.js +39 -7
  294. package/dist/ui/shared/tokens.css +1 -1
  295. package/dist/ui/shared/ui-icons.js +1 -0
  296. package/dist/ui/shared/usage-dialog.js +23 -5
  297. package/dist/ui/shared/view-strings.js +32 -4
  298. package/dist/ui/signin.html +1 -1
  299. package/dist/unrecognised-prompt.d.ts +23 -0
  300. package/dist/unrecognised-prompt.js +74 -0
  301. package/dist/unrecognised-prompt.js.map +1 -0
  302. package/dist/update-install.d.ts +28 -2
  303. package/dist/update-install.js +22 -4
  304. package/dist/update-install.js.map +1 -1
  305. package/dist/update-marker.d.ts +51 -0
  306. package/dist/update-marker.js +61 -0
  307. package/dist/update-marker.js.map +1 -1
  308. package/dist/update-preflight.d.ts +100 -0
  309. package/dist/update-preflight.js +264 -0
  310. package/dist/update-preflight.js.map +1 -0
  311. package/dist/updater-caller.d.ts +31 -0
  312. package/dist/updater-caller.js +109 -0
  313. package/dist/updater-caller.js.map +1 -0
  314. package/dist/usage/i18n-keys.d.ts +1 -1
  315. package/dist/usage/i18n-keys.js +1 -1
  316. package/dist/usage/i18n-keys.js.map +1 -1
  317. package/dist/usage/providers.d.ts +34 -5
  318. package/dist/usage/providers.js +160 -27
  319. package/dist/usage/providers.js.map +1 -1
  320. package/dist/usage/statusline-usage.d.ts +6 -2
  321. package/dist/usage/statusline-usage.js +10 -4
  322. package/dist/usage/statusline-usage.js.map +1 -1
  323. package/dist/view-api.d.ts +1 -1
  324. package/dist/view-api.js +38 -8
  325. package/dist/view-api.js.map +1 -1
  326. package/dist/wake-coordinator.d.ts +11 -0
  327. package/dist/wake-coordinator.js +38 -4
  328. package/dist/wake-coordinator.js.map +1 -1
  329. package/dist/web-api.d.ts +49 -2
  330. package/dist/web-api.js +421 -31
  331. package/dist/web-api.js.map +1 -1
  332. package/dist/web-auth.d.ts +2 -2
  333. package/dist/web-auth.js +9 -4
  334. package/dist/web-auth.js.map +1 -1
  335. package/dist/web-commands.d.ts +27 -4
  336. package/dist/web-commands.js +19 -4
  337. package/dist/web-commands.js.map +1 -1
  338. package/dist/web-host-guard.d.ts +3 -11
  339. package/dist/web-host-guard.js +3 -12
  340. package/dist/web-host-guard.js.map +1 -1
  341. package/dist/web-images.d.ts +47 -0
  342. package/dist/web-images.js +169 -0
  343. package/dist/web-images.js.map +1 -0
  344. package/dist/web-mentions.d.ts +44 -0
  345. package/dist/web-mentions.js +75 -0
  346. package/dist/web-mentions.js.map +1 -0
  347. package/dist/web-prefs.d.ts +63 -0
  348. package/dist/web-prefs.js +265 -0
  349. package/dist/web-prefs.js.map +1 -0
  350. package/dist/web-preview.d.ts +14 -1
  351. package/dist/web-preview.js +35 -13
  352. package/dist/web-preview.js.map +1 -1
  353. package/dist/web-reply-files.d.ts +45 -0
  354. package/dist/web-reply-files.js +223 -0
  355. package/dist/web-reply-files.js.map +1 -0
  356. package/dist/web-schedules.d.ts +12 -0
  357. package/dist/web-schedules.js +34 -0
  358. package/dist/web-schedules.js.map +1 -0
  359. package/dist/web-server-drain.d.ts +10 -0
  360. package/dist/web-server-drain.js +50 -0
  361. package/dist/web-server-drain.js.map +1 -0
  362. package/dist/web-session.d.ts +36 -1
  363. package/dist/web-session.js +53 -6
  364. package/dist/web-session.js.map +1 -1
  365. package/dist/web-settings-patch.d.ts +23 -0
  366. package/dist/web-settings-patch.js +77 -0
  367. package/dist/web-settings-patch.js.map +1 -0
  368. package/dist/web-shell-routes.d.ts +5 -1
  369. package/dist/web-shell-routes.js +10 -1
  370. package/dist/web-shell-routes.js.map +1 -1
  371. package/dist/web-upload.d.ts +17 -4
  372. package/dist/web-upload.js +64 -13
  373. package/dist/web-upload.js.map +1 -1
  374. package/dist/workflow-templates/default.md +1 -1
  375. package/package.json +1 -1
  376. package/templates/launchd.plist.ejs +3 -1
  377. package/templates/systemd.service.ejs +2 -1
@@ -1,14 +1,19 @@
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
10
  import { createPublicWebGateway } 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";
@@ -27,20 +32,21 @@ import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath
27
32
  import { fileURLToPath } from "node:url";
28
33
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
29
34
  import { fleetLabel } from "./fleet-label.js";
30
- import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, setUpdateProgressStage, updateProgressOperation, } from "./update-marker.js";
35
+ import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
31
36
  import { formatUpdateProgress } from "./update-progress.js";
32
37
  import { sdNotify, sdNotifyBlocking } from "./sd-notify.js";
33
38
  import { readFleetMemory } from "./process-memory.js";
34
39
  import { MemoryPressure } from "./memory-pressure.js";
35
40
  import { replyDedupText, ReplyDeduper } from "./reply-dedup.js";
36
41
  import { isMap, isScalar, parseDocument } from "yaml";
42
+ import { kiroLogGate } from "./kiro-log-gate.js";
37
43
  const __filename = fileURLToPath(import.meta.url);
38
44
  const __dirname = dirname(__filename);
39
45
  /** Fallback access policy for a channel with no `access:` block — open (no gate). */
40
46
  const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
41
47
  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
48
  import { isProbeableRouteTarget } from "./fleet-context.js";
43
- import { loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
49
+ import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
44
50
  import { EventLog } from "./event-log.js";
45
51
  import { binaryProbe } from "./binary-probe.js";
46
52
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
@@ -51,13 +57,16 @@ import { startEventLoopWatch } from "./event-loop-watch.js";
51
57
  import { TmuxManager } from "./tmux-manager.js";
52
58
  import { AccessManager } from "./channel/access-manager.js";
53
59
  import { IpcClient } from "./channel/ipc-bridge.js";
60
+ import { usesChannelAddress } from "./channel/mcp-tools.js";
54
61
  import { formatFleetTime } from "./tz-utils.js";
55
62
  import { createAdapter } from "./channel/factory.js";
56
63
  import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
57
64
  import { TelegramAdapter } from "./channel/adapters/telegram.js";
58
65
  import { createBackend } from "./backend/factory.js";
59
66
  import { readEffortMetadata } from "./backend/effort-metadata.js";
60
- import { CLI_ENV_TTL_MS, isModelCompatible, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
67
+ import { codexEffort, flagEffort } from "./backend/launch-effort.js";
68
+ import { codexMetadataHomes, readCodexModelEffortLevels, resolveCodexLaunchModel } from "./backend/codex-metadata.js";
69
+ import { CLI_ENV_TTL_MS, isModelCompatible, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
61
70
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
62
71
  import { processAttachments } from "./channel/attachment-handler.js";
63
72
  import { routeToolCall } from "./channel/tool-router.js";
@@ -73,8 +82,9 @@ import { RoutingEngine } from "./routing-engine.js";
73
82
  import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
74
83
  import { TopicArchiver } from "./topic-archiver.js";
75
84
  import { StatuslineWatcher } from "./statusline-watcher.js";
76
- import { outboundHandlers } from "./outbound-handlers.js";
77
- import { DeliveryOutbox } from "./delivery-outbox.js";
85
+ import { applyInstanceConfigPatch, outboundHandlers } from "./outbound-handlers.js";
86
+ import { backendArgs, backendStep, joinBackendArgs, modelNameCarried, planDisplay } from "./backend-command.js";
87
+ import { DeliveryOutbox, DEFAULT_REPLY_OBLIGATION_TTL_MS, isSilentScheduleDelivery } from "./delivery-outbox.js";
78
88
  // The target Daemon can legitimately wait 30 minutes for a busy pane before
79
89
  // deciding to defer before submission. This is an alert threshold only: the
80
90
  // active manager/target generation pair owns the lane until the daemon reports
@@ -126,12 +136,19 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
126
136
  return tasks;
127
137
  }
128
138
  import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
139
+ import { LogService } from "./log-service.js";
129
140
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
130
141
  import { WebChatDiskStore } from "./web-chat-store.js";
142
+ import { SeenUsers, resolveMentions } from "./web-mentions.js";
143
+ import { WebPrefsStore } from "./web-prefs.js";
131
144
  import { ReplyButtonStore, parseReplyButtons, replyButtonClickText, replyButtonsFallbackText, REPLY_BUTTON_PREFIX } from "./reply-buttons.js";
132
145
  import { ReplyButtonsController } from "./reply-buttons-controller.js";
133
146
  import { publicAttachment, sweepOrphanedUploads, WebFileLedger } from "./web-upload.js";
147
+ import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
134
148
  import { handleViewRequest, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
149
+ import { downloadStickerImage } from "./channel/sticker-download.js";
150
+ import { WebImageCache, imageMimeForFile, WEB_IMAGE_MAX_AGE_MS, WEB_IMAGE_MAX_BYTES, WEB_IMAGE_RETRY_MS } from "./web-images.js";
151
+ import { accentColor, avatarFor, ownBotConnections } from "./instance-avatar.js";
135
152
  import { envFileKeys } from "./token-env-name.js";
136
153
  import { discordGeneralChannelId } from "./discord-general.js";
137
154
  import { parseEnvText } from "./env-file.js";
@@ -146,7 +163,7 @@ import { runVisibilityCommand } from "./cross-instance-notice.js";
146
163
  import { installedChannel, isPrereleaseVersion, updateNoticeKey } from "./update-check.js";
147
164
  import { resolveInstalledAgend } from "./update-dispatch.js";
148
165
  import { resolveUpdateLaunch, watchUpdateLaunch } from "./update-launch.js";
149
- import { LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
166
+ import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOGIN_RECOVERY_DEADLINE_MS } from "./login-controller.js";
150
167
  import { runBeforeDeadline } from "./deadline.js";
151
168
  import { LoginWindowLock } from "./login-window-lock.js";
152
169
  import { handleSettingsRequest } from "./settings-api.js";
@@ -156,11 +173,12 @@ import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet
156
173
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
157
174
  import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
158
175
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
159
- import { validateFleetConfig } from "./config-validator.js";
176
+ import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
160
177
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
161
178
  import { presentationState, interactionSummary, sameInteractionOwner } from "./interaction-observation.js";
162
179
  import { readLastInboundAt } from "./daemon.js";
163
180
  import { clearPausedMarker, readPausedAt, readPauseReason, writePausedMarker } from "./pause-marker.js";
181
+ import { readBackgroundWorkEndedMarker } from "./background-work-ended.js";
164
182
  import { DEFAULT_WARM_OVERFLOW, WakeCoordinator } from "./wake-coordinator.js";
165
183
  import { TargetQueueWorker } from "./target-queue-worker.js";
166
184
  import { resolveDeliveryWorkerMode } from "./types.js";
@@ -174,17 +192,17 @@ import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
174
192
  import { buildOrgChart } from "./web-org.js";
175
193
  import { CacheService, WINDOWS } from "./cache-service.js";
176
194
  import { runWebCommand } from "./web-commands.js";
177
- import { claudeProjectKey } from "./backend/claude-code.js";
195
+ import { claudeConfigDirFor, claudeProjectKey } from "./backend/claude-code.js";
178
196
  import { sharedRolloutIndex } from "./rollout-index.js";
179
197
  import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefusedMessage, } from "./tool-permissions.js";
180
198
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
181
199
  import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
182
200
  import { isWebPageNavigation } from "./web-shell-routes.js";
183
- import { tokenEpoch, WebSessionStore } from "./web-session.js";
201
+ import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
184
202
  import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
185
203
  import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
186
204
  import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
187
- import { createPreviewListener, previewAvailability, previewSettings } from "./web-preview.js";
205
+ import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
188
206
  import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
189
207
  import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
190
208
  import { SecretStore } from "./secret-store.js";
@@ -194,10 +212,11 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
194
212
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
195
213
  const SELF_RESTART_DEADLINE_MS = 300_000;
196
214
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
197
- import { credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
215
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
216
+ import { carryClaudeConversation } from "./backend/claude-carry.js";
198
217
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
199
218
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
200
- import { formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
219
+ import { cleanupWatchVerdict, formatRestartProgressCompletion, RESTART_PROGRESS_TERMINAL_TIMEOUT_MS, RestartProgress, } from "./restart-progress.js";
201
220
  import { launchFullRestartHelper } from "./full-restart.js";
202
221
  import { SYSTEMD_RESTART_INDETERMINATE_EXIT_CODE } from "./service-restart-selection.js";
203
222
  import { collectRedundantInstanceDefaultPaths } from "./fleet-yaml-slim.js";
@@ -248,6 +267,10 @@ export function isOrphanInstanceWindowName(name) {
248
267
  /** Retry cadence for retiring a cancel button whose delete failed (e.g. a DC
249
268
  * forum thread the bot momentarily can't reach). 3 retries × 5min = 15min. */
250
269
  const CANCEL_BTN_RETRY_INTERVAL_MS = 5 * 60_000;
270
+ /** #1753: a carried conversation this large is logged as a warning (it is still carried). */
271
+ const CARRY_LARGE_BYTES = 256 * 1024 * 1024;
272
+ /** #1742: how long after a Telegram /web that could not reach an admin privately a bare /start counts as /start web. */
273
+ const TG_WEB_START_WINDOW_MS = 10 * 60_000;
251
274
  const CANCEL_BTN_MAX_RETRIES = 3;
252
275
  /** Backstop: every 5min, retire a button whose instance has gone idle. Catches
253
276
  * buttons no clear trigger reached (e.g. a scheduled/HTTP turn that never called
@@ -333,6 +356,7 @@ const CLASSIC_BACKEND_SELECTION_TIMEOUT_MS = 60_000;
333
356
  const CLASSIC_BACKEND_CALLBACK_PREFIX = "classic-backend:";
334
357
  const MODEL_SELECT_CALLBACK_PREFIX = "model-select:";
335
358
  const EFFORT_SELECT_CALLBACK_PREFIX = "effort-select:";
359
+ const BACKEND_SELECT_CALLBACK_PREFIX = "backend-select:";
336
360
  const INTERACTIVE_ASSIST_CALLBACK_PREFIX = "interactive-assist:";
337
361
  const EXIT_RESTART_CALLBACK_PREFIX = "exit-restart:";
338
362
  /** #1386: how long a paused instance's marker reason is reused before it is read again (transitions drop it at once). */
@@ -423,6 +447,8 @@ const CLI_ENV_FRESH_MS = 60 * 60 * 1000;
423
447
  * and hides exactly the newly released model the user opened `/model` to find.
424
448
  * The wait is announced before it starts, so it reads as progress, not a stall.
425
449
  */
450
+ /** #1690: how long `/model` waits for the daemon's word on our own runtime switch (its dialog wait plus settling). */
451
+ export const RUNTIME_SWITCH_RESULT_MS = 12_000;
426
452
  export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
427
453
  /**
428
454
  * How many CLIs may cold-start at once, from BOTH memory and cores.
@@ -610,6 +636,11 @@ export class FleetManager {
610
636
  webChannelEchoGuards = new Map();
611
637
  /** instanceName → restart currently executing; concurrent callers join it. */
612
638
  restartsInFlight = new Map();
639
+ /**
640
+ * #1753: each in-flight restart's stop → start gap — the conversation carry it will run there (its own, or one a later
641
+ * caller joined with before the gap), and whether the gap has been reached.
642
+ */
643
+ restartGaps = new Map();
613
644
  /**
614
645
  * Delayed automatic retries for instances whose startup failed. Before this a
615
646
  * failed start was logged once and the instance stayed `stopped` until an
@@ -645,6 +676,8 @@ export class FleetManager {
645
676
  /** In-flight /effort selections, same coordinator shape as pendingModelSelects. */
646
677
  pendingEffortSelects = new Map();
647
678
  pendingModelSelects = new Map();
679
+ /** #1806: in-flight /backend steps, keyed by nonce (see handleBackendSelection). */
680
+ pendingBackendSelects = new Map();
648
681
  /** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
649
682
  pendingNonceButtons = new Map();
650
683
  /** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
@@ -714,6 +747,7 @@ export class FleetManager {
714
747
  collabInstances = new Set();
715
748
  // Health endpoint
716
749
  healthServer = null;
750
+ healthServerDrain = null;
717
751
  /** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
718
752
  previewListener = null;
719
753
  previewListening = false;
@@ -726,6 +760,13 @@ export class FleetManager {
726
760
  updateProgressEditRunning = false;
727
761
  lastUpdateProgressText = null;
728
762
  updateCompletionTipText = null;
763
+ /**
764
+ * #1660: an adopted update whose retire outcome is still owed. The
765
+ * completion summary was (or will be) posted; this watch appends the
766
+ * cleanup outcome to the same receipt instead of letting stage edits
767
+ * clobber it — and refuses a receipt that no longer matches (checklist 3/5).
768
+ */
769
+ updateCleanupWatch = null;
729
770
  /** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
730
771
  fullRestartLauncher = launchFullRestartHelper;
731
772
  eventLogPruneTimer = null;
@@ -745,8 +786,8 @@ export class FleetManager {
745
786
  memoryLogAt = null;
746
787
  memoryDebugAt = null;
747
788
  memoryLogLevel = null;
748
- memoryNoticeAt = null;
749
- memoryNoticeLevel = null;
789
+ /** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
790
+ memoryIndicatorShown = null;
750
791
  // Mirror topic: buffer cross-instance messages, flush every 3s
751
792
  mirrorBuffer = [];
752
793
  mirrorTimer = null;
@@ -758,6 +799,21 @@ export class FleetManager {
758
799
  webChatStore = null;
759
800
  /** Uploaded files and the files the dashboard may fetch back (uploads, reply attachments). */
760
801
  webFiles = new WebFileLedger();
802
+ /** #1770: per instance, its last web reply still copying its files — the next one is shown after it, in order. */
803
+ webReplyOrder = new Map();
804
+ /** #1770: an instance's web-files byte cap (a field, so a test can make it small). */
805
+ webReplyMaxBytes = REPLY_FILES_MAX_BYTES;
806
+ /** #1682: the people seen on inbound messages (id → name), for the web chat's mention pills; bounded, in memory. */
807
+ seenUsers = new SeenUsers();
808
+ webPrefsStore;
809
+ /** #1665: the web preferences kept on the host (<home>/web-prefs.json), read on first use. */
810
+ get webPrefs() {
811
+ // #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
812
+ return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
813
+ exists: (name) => Object.hasOwn(this.fleetConfig?.instances ?? {}, name)
814
+ || (this.classicChannels?.getAll() ?? []).some((ch) => ch.instanceName === name),
815
+ }));
816
+ }
761
817
  /**
762
818
  * Read from disk on every access rather than cached at startup: `agend
763
819
  * web-token rotate` runs in a separate process, and a cached copy would keep
@@ -1147,7 +1203,7 @@ export class FleetManager {
1147
1203
  const reactions = this.pendingReactionsMeta(set.instance);
1148
1204
  let accepted = false;
1149
1205
  try {
1150
- accepted = (await this.deliverToInstance(set.instance, {
1206
+ accepted = (await this.deliverHumanToInstance(set.instance, {
1151
1207
  type: "fleet_inbound",
1152
1208
  content,
1153
1209
  targetSession: set.instance,
@@ -1156,7 +1212,7 @@ export class FleetManager {
1156
1212
  thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
1157
1213
  ...reactions.meta,
1158
1214
  },
1159
- })) !== false;
1215
+ }, by.humanOrigin, button.label)) !== false;
1160
1216
  }
1161
1217
  finally {
1162
1218
  // Both false and throw reopen the choice in the controller. Finish the
@@ -1215,8 +1271,13 @@ export class FleetManager {
1215
1271
  if (event.deliveryId && (event.state === "failed" || event.state === "uncertain")) {
1216
1272
  const row = outbox.get(event.deliveryId);
1217
1273
  if (row && row.kind !== "delivery_outcome_notice" && row.kind !== "post_restart_outcome_notice") {
1218
- const notice = t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
1219
- this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state }, notice);
1274
+ // #1647: the cause goes in the log (the notice points there), and a silent schedule's paste says what it is.
1275
+ const silent = isSilentScheduleDelivery(row);
1276
+ const notice = silent
1277
+ ? t(`delivery.fleet_${row.state}_silent_schedule`, row.targetInstance, row.operationId, row.deliveryId, row.lastError ?? "unknown")
1278
+ : t(`delivery.fleet_${row.state}`, row.targetInstance, row.operationId, row.deliveryId);
1279
+ this.logger.error({ deliveryId: row.deliveryId, operationId: row.operationId, target: row.targetInstance, state: row.state,
1280
+ cause: row.lastError, ...(silent ? { silentSchedule: true } : {}) }, notice);
1220
1281
  this.notifyFleetError(notice);
1221
1282
  }
1222
1283
  }
@@ -1270,11 +1331,25 @@ export class FleetManager {
1270
1331
  deliveryId: admitted.delivery.deliveryId,
1271
1332
  state: admitted.delivery.state,
1272
1333
  duplicate: !admitted.inserted,
1334
+ kind: admitted.delivery.kind,
1273
1335
  };
1274
1336
  }
1275
1337
  queryDurableDeliveryStatus(callerInstance, selector) {
1276
1338
  this.ensureDeliveryOutbox();
1277
- return this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
1339
+ const page = this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
1340
+ // #1641: a sender that sees only `queued` cannot tell "next in line" from "the target is stuck": say which.
1341
+ return {
1342
+ ...page,
1343
+ items: page.items.map(item => (item.state === "queued" || item.state === "retry_wait")
1344
+ ? { ...item, waiting_reason: this.deliveryWaitingReason(item.target_instance) }
1345
+ : item),
1346
+ };
1347
+ }
1348
+ /** #1641: why a target is not taking queued work right now, or null when nothing known holds it. */
1349
+ deliveryWaitingReason(target) {
1350
+ if (this.lifecycle.isPaused(target))
1351
+ return t("delivery.hold_state", "paused");
1352
+ return this.daemons.get(target)?.notAcceptingReason?.() ?? null;
1278
1353
  }
1279
1354
  getDaemonBootId(instanceName) {
1280
1355
  return this.daemons.get(instanceName)?.bootId;
@@ -1298,7 +1373,9 @@ export class FleetManager {
1298
1373
  // #926: a restart kills the turn that was working on a request, and a
1299
1374
  // resumed CLI does not pick it back up. Remind the owner once per restart.
1300
1375
  try {
1301
- outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, since: new Date() });
1376
+ const now = new Date();
1377
+ this.expireReplyObligations(now);
1378
+ outbox.remindReplyObligations(name, { reason: "restart", graceMs: 0, now, since: now, restartId: daemonBootId });
1302
1379
  }
1303
1380
  catch (err) {
1304
1381
  this.logger.error({ err, owner: name }, "Could not persist the post-restart reply reminder");
@@ -1316,6 +1393,7 @@ export class FleetManager {
1316
1393
  return;
1317
1394
  const stateOf = (name) => this.daemons.get(name)?.getInstanceState();
1318
1395
  try {
1396
+ this.expireReplyObligations(now);
1319
1397
  for (const name of this.daemons.keys()) {
1320
1398
  if (stateOf(name) === "idle")
1321
1399
  outbox.remindReplyObligations(name, { now, graceMs: REPLY_REMINDER_GRACE_MS, reason: "turn-ended" });
@@ -1327,6 +1405,15 @@ export class FleetManager {
1327
1405
  this.logger.error({ err }, "Reply-obligation sweep failed");
1328
1406
  }
1329
1407
  }
1408
+ expireReplyObligations(now) {
1409
+ const hours = this.fleetConfig?.defaults?.reply_obligation_ttl_hours;
1410
+ const duration = typeof hours === "number" && hours > 0 && Number.isFinite(hours * 3_600_000)
1411
+ ? hours * 3_600_000 : DEFAULT_REPLY_OBLIGATION_TTL_MS;
1412
+ for (const row of this.deliveryOutbox.expireReplyObligations({ now, ttlMs: duration })) {
1413
+ this.logger.info({ owner: row.ownerInstance, requester: row.requesterInstance,
1414
+ correlationId: row.correlationId, lastAskedAt: row.lastAskedAt, expiredAt: row.expiredAt }, "Reply obligation expired");
1415
+ }
1416
+ }
1330
1417
  scheduleDeliveryOutboxPump(delayMs = 0) {
1331
1418
  if (!this.deliveryOutbox || this.shuttingDown || !this.deliveryOutbox.isOpen)
1332
1419
  return;
@@ -1566,6 +1653,14 @@ export class FleetManager {
1566
1653
  return Math.max(1, Math.min(20, explicit));
1567
1654
  return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
1568
1655
  }
1656
+ /** #1685: "elevated" or "critical" while host memory pressure slows or holds new agents; otherwise null (an
1657
+ * unknown macOS reading — the advisory case — is unknown, so null too). */
1658
+ hostMemoryIndicator() {
1659
+ if (!this.memoryPressure)
1660
+ return null;
1661
+ const level = this.memoryPressure.snapshot().level;
1662
+ return level === "elevated" || level === "critical" ? level : null;
1663
+ }
1569
1664
  /** Separate cooldown: changing measurements must not create new text-throttle keys. */
1570
1665
  reportMemoryPressure(snapshot) {
1571
1666
  if (this.shuttingDown)
@@ -1576,6 +1671,12 @@ export class FleetManager {
1576
1671
  this.logger.debug({ hostMemory: snapshot }, "Host memory sample");
1577
1672
  }
1578
1673
  const changed = snapshot.level !== this.memoryLogLevel;
1674
+ // #1685: the dashboard's indicator (the status frame's host_memory) is re-sent when it changes.
1675
+ const indicator = this.hostMemoryIndicator();
1676
+ if (indicator !== this.memoryIndicatorShown) {
1677
+ this.memoryIndicatorShown = indicator;
1678
+ this.emitSseEvent("status", this.getUiStatus());
1679
+ }
1579
1680
  if (this.memoryPressure.advisoryOnly()) {
1580
1681
  // The sampler logs the unknown kernel value once per lifecycle; no warning/cooldown here.
1581
1682
  this.memoryLogLevel = snapshot.level;
@@ -1596,20 +1697,6 @@ export class FleetManager {
1596
1697
  : "Host memory sample unavailable — slowing new CLI spawns");
1597
1698
  }
1598
1699
  this.memoryLogLevel = snapshot.level;
1599
- if (!snapshot.memory || snapshot.level === "unknown" || (!this.adapter && this.adapters.size === 0))
1600
- return;
1601
- const escalation = snapshot.level === "critical" && this.memoryNoticeLevel !== "critical";
1602
- if (!escalation && this.memoryNoticeAt !== null && now - this.memoryNoticeAt < 10 * 60_000)
1603
- return;
1604
- const size = (bytes) => bytes === null ? t("memory.unknown") : `${Math.round(bytes / 1024 / 1024)} MiB`;
1605
- const action = t(snapshot.level === "critical" ? "memory.holding" : "memory.slowing");
1606
- const text = this.memoryPressure.platform === "darwin"
1607
- ? t("memory.kernel_pressure", snapshot.memory.darwinPressureLevel ?? t("memory.unknown"), size(snapshot.memory.availableBytes), size(snapshot.memory.swapFreeBytes), action)
1608
- : t("memory.pressure", t(snapshot.memory.availableKind === "available" ? "memory.available" : "memory.free", size(snapshot.memory.availableBytes)), size(snapshot.memory.swapFreeBytes), action);
1609
- if (this.notifyFleetError(text, { throttle: false })) {
1610
- this.memoryNoticeAt = now;
1611
- this.memoryNoticeLevel = snapshot.level;
1612
- }
1613
1700
  }
1614
1701
  /** Wire the one fleet-wide storm into notification and recovery surfaces. */
1615
1702
  bindStormWindowEvents() {
@@ -1730,7 +1817,7 @@ export class FleetManager {
1730
1817
  isPlannedRestart() {
1731
1818
  return this.shuttingDown || isUpdateInProgress(this.dataDir);
1732
1819
  }
1733
- finishStartup() {
1820
+ finishStartup(bootUpdateMarker = null) {
1734
1821
  this.startupComplete = true;
1735
1822
  this.startNeedsYou();
1736
1823
  // Resolve whatever a previous run — or a setup host that crashed — left
@@ -1740,14 +1827,17 @@ export class FleetManager {
1740
1827
  // the next tunnel and says so, it does not block the fleet.
1741
1828
  this.announceToolPermissionsChange();
1742
1829
  this.tightenInstanceDirectories();
1743
- void reapStaleTunnel(this.dataDir)
1744
- .then(outcome => {
1745
- if (outcome.kind === "manual")
1746
- this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
1747
- else if (outcome.kind === "reaped")
1748
- this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
1749
- })
1750
- .catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
1830
+ // #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way.
1831
+ for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR)]) {
1832
+ void reapStaleTunnel(leaseDir)
1833
+ .then(outcome => {
1834
+ if (outcome.kind === "manual")
1835
+ this.logger.warn({ tunnel: outcome }, manualCleanupMessage(outcome));
1836
+ else if (outcome.kind === "reaped")
1837
+ this.logger.info({ how: outcome.how, pid: outcome.pid }, "Reaped a leftover tunnel");
1838
+ })
1839
+ .catch(err => this.logger.warn({ err }, "Tunnel reaper failed"));
1840
+ }
1751
1841
  // An existing installation has never written the setup marker — it predates
1752
1842
  // it — so `agend setup` would open a pre-fleet form for a fleet that plainly
1753
1843
  // exists. A fleet that just came up on a config with agents in it is proof
@@ -1767,14 +1857,33 @@ export class FleetManager {
1767
1857
  for (const settled of this.applyJobs.settleAfterRestart()) {
1768
1858
  this.logger.info({ jobId: settled.id }, "Settings apply job settled by fleet restart");
1769
1859
  }
1770
- // We are the post-update fleet: the update is over by definition. Clearing
1771
- // it here (rather than in the update command, which exits before the new
1772
- // fleet is up) is what keeps the quiet window from outliving the restart.
1773
- clearUpdateMarker(this.dataDir);
1860
+ this.clearSettledUpdateMarker(bootUpdateMarker);
1774
1861
  if (this.reloadPending)
1775
1862
  this.scheduleReconcile();
1776
1863
  void this.sweepOrphanedCancelButtons();
1777
1864
  }
1865
+ /**
1866
+ * We are the post-update fleet: the update is over by definition. Clearing it here (rather than in the update
1867
+ * command, which exits before the new fleet is up) is what keeps the quiet window from outliving the restart.
1868
+ * #1660 exception: an update still owing its retire outcome keeps its receipt until the cleanup watch resolves it —
1869
+ * deleting it here is what lost the warning. Terminal stages are still cleared unconditionally.
1870
+ * #1735: and while the watch is armed it owns the marker — even if the outcome landed after the completion posted
1871
+ * (a launchd kickstart or a detached restart returns at once, so it can land at any point of the boot): the watch
1872
+ * appends it, then clears.
1873
+ * #1737 review: and only the marker this boot adopted. One written since — a `/restart full` or a new `/update`
1874
+ * landing while this boot's completion was still being edited — belongs to its writer and is left alone.
1875
+ */
1876
+ clearSettledUpdateMarker(bootUpdateMarker) {
1877
+ if (this.updateCleanupWatch)
1878
+ return;
1879
+ const current = updateMarkerIdentity(this.dataDir);
1880
+ if (current && !sameUpdateMarker(current, bootUpdateMarker))
1881
+ return;
1882
+ const leftover = readUpdateProgress(this.dataDir)?.progress;
1883
+ if (leftover?.cleanup === "pending" && leftover.stage !== "failed" && leftover.stage !== "complete")
1884
+ return;
1885
+ clearUpdateMarker(this.dataDir);
1886
+ }
1778
1887
  // ── ArchiverContext bridge ────────────────────────────────────────────
1779
1888
  lastActivityMs(name) {
1780
1889
  return this.lastActivity.get(name) ?? 0;
@@ -2558,6 +2667,8 @@ export class FleetManager {
2558
2667
  startsFresh: !backendChanged && credentialSwitchStartsFresh(backend),
2559
2668
  from: profileKey(launched),
2560
2669
  to: profileKey(next),
2670
+ ...(!backendChanged && launched.state !== "invalid" && next.state !== "invalid"
2671
+ ? { carry: { backend, from: profileName(launched), to: profileName(next) } } : {}),
2561
2672
  };
2562
2673
  }
2563
2674
  /** `[instance, effective backend, credential profile]` for everything that is
@@ -2592,6 +2703,55 @@ export class FleetManager {
2592
2703
  return this.classicChannels?.getAll().some(channel => channel.instanceName === name) ?? false;
2593
2704
  }
2594
2705
  /** #1523 N2: a ClassicBot room's own summary for its Details page (classicBot.yaml), or null. */
2706
+ /** #1218: the Log tab's on-demand service — made on first use; with `web.log_tab` off it answers off and does nothing. */
2707
+ logService = null;
2708
+ get log() {
2709
+ return this.logService ??= new LogService({
2710
+ enabled: () => this.fleetConfig?.web?.log_tab === true,
2711
+ known: (name) => Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, name) || this.classicRoomFor(name) !== null,
2712
+ source: (name) => this.logSource(name),
2713
+ events: (name, limit) => (this.eventLog?.query({ instance: name, limit }) ?? []),
2714
+ emit: (event, data) => this.emitSseEvent(event, data),
2715
+ });
2716
+ }
2717
+ /** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
2718
+ logSteps(name, q) { return this.log.steps(name, q); }
2719
+ /**
2720
+ * #1218: where an instance's steps are read from, resolved only when its Log asks: the transcript its daemon follows
2721
+ * now; for claude-code, also the path its statusline last named (so a stopped instance's Log still shows its last
2722
+ * work). kiro: fleet events with the reason its history is not read (kiro-log-gate.ts); other backends have no
2723
+ * transcript AgEnD can read.
2724
+ */
2725
+ logSource(name) {
2726
+ const instances = this.fleetConfig?.instances ?? {};
2727
+ const config = Object.prototype.hasOwnProperty.call(instances, name) ? instances[name] : undefined;
2728
+ const backend = config?.backend ?? (config ? null : this.classicRoomFor(name)?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
2729
+ const live = this.lifecycle.daemons.get(name)?.logTranscriptPath() ?? null;
2730
+ if (backend === "claude-code") {
2731
+ let path = live;
2732
+ if (!path) {
2733
+ try {
2734
+ path = JSON.parse(readFileSync(join(this.getInstanceDir(name), "statusline.json"), "utf-8")).transcript_path ?? null;
2735
+ }
2736
+ catch {
2737
+ path = null;
2738
+ }
2739
+ if (typeof path !== "string" || !path)
2740
+ path = null;
2741
+ }
2742
+ return { backend, path, format: "claude", ...(path ? {} : { reason: "no_transcript" }) };
2743
+ }
2744
+ if (backend === "codex")
2745
+ return { backend, path: live, format: "codex", ...(live ? {} : { reason: "not_running" }) };
2746
+ if (backend === "kiro-cli") {
2747
+ // #1218 PR4: events only until the launch's root and the current conversation are both proven (kiro-log-gate.ts);
2748
+ // the reason names which proof is missing. With today's proofs the gate never opens, and nothing is read for kiro:
2749
+ // the reader is bound to an admitted launch when the proofs arrive (#849 root, #1789 current conversation).
2750
+ const gate = kiroLogGate(this.lifecycle.daemons.get(name)?.logLaunch() ?? null);
2751
+ return { backend, path: null, format: null, reason: gate.open ? "kiro_session_unobservable" : gate.reason };
2752
+ }
2753
+ return { backend, path: null, format: null, reason: "unsupported" };
2754
+ }
2595
2755
  classicRoomFor(name) {
2596
2756
  const ch = this.classicChannels?.getAll().find(channel => channel.instanceName === name);
2597
2757
  if (!ch)
@@ -2893,7 +3053,7 @@ export class FleetManager {
2893
3053
  const queued = this.ipcWaitTails.get(instanceName);
2894
3054
  if (!queued) {
2895
3055
  const ipc = this.instanceIpcClients.get(instanceName);
2896
- if (ipc?.connected && ipc.send(payload))
3056
+ if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
2897
3057
  return true;
2898
3058
  }
2899
3059
  const attempt = (queued ?? Promise.resolve())
@@ -2928,7 +3088,7 @@ export class FleetManager {
2928
3088
  // Re-read every round: a reconnect replaces the IpcClient object entirely,
2929
3089
  // so a cached reference would stay dead forever.
2930
3090
  const ipc = this.instanceIpcClients.get(instanceName);
2931
- if (ipc?.connected && ipc.send(payload))
3091
+ if (ipc?.connected && ipc.send(this.bindHumanReceiptPayload(instanceName, payload)))
2932
3092
  return true;
2933
3093
  if (Date.now() >= deadline) {
2934
3094
  throw new Error(`Instance '${instanceName}' IPC is unavailable`);
@@ -3003,13 +3163,38 @@ export class FleetManager {
3003
3163
  }
3004
3164
  return posted;
3005
3165
  }
3006
- reserveWebChannelEcho(instanceName, sendEcho) {
3166
+ /**
3167
+ * #1666: a delivered web chat message starts the same cancel/progress lifecycle in the instance's topic as a platform
3168
+ * inbound (afterUserInboundDelivered): the same bubble, ticker and retire rules. Not its ✅ tracking — the web message
3169
+ * id is no platform message, and the echo is the fleet's own post. The web send calls this on the echo's lane.
3170
+ */
3171
+ startWebChatProgress(instanceName) {
3172
+ if (this.shuttingDown)
3173
+ return Promise.resolve();
3174
+ return this.sendCancelButton(instanceName);
3175
+ }
3176
+ /**
3177
+ * `copy: false` (#1666/#1668): an entry that is not a copy of a message — the web send's Cancel/progress bubble. It
3178
+ * 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
3179
+ * starts (waiting behind a slow echo is not its delay), its own timeout only releases the lane (the copies queued
3180
+ * behind it are not "behind an ambiguous copy"), and another entry's timeout never drops it. A copy reserved while
3181
+ * any non-copy is still open starts its own budget when the last of them finishes: a slow bubble is no copy's delay,
3182
+ * however far back the copy is. And a timeout only ever drops copies reserved AFTER the entry that timed out — with
3183
+ * deferred budgets, deadlines no longer grow with the order, so that rule is stated, not implied (#1668 r3).
3184
+ */
3185
+ reserveWebChannelEcho(instanceName, sendEcho, opts = {}) {
3186
+ const copy = opts.copy !== false;
3007
3187
  const epoch = this.getDeliveryEpoch(instanceName);
3008
- const deadlineAt = performance.now() + 5_000;
3188
+ const queued = this.webChannelEchoTails.get(instanceName);
3189
+ // The last open non-copy ahead of this copy: its budget starts when that one finishes.
3190
+ const nonCopyAhead = copy && (queued?.nonCopiesOpen ?? 0) > 0 ? queued?.lastNonCopyDone : undefined;
3191
+ let deadlineAt = copy && !nonCopyAhead ? performance.now() + 5_000 : Number.POSITIVE_INFINITY;
3009
3192
  let decide;
3010
3193
  const admission = new Promise(resolve => { decide = resolve; });
3011
- const queue = this.webChannelEchoTails.get(instanceName)
3194
+ const queue = queued
3012
3195
  ?? { tail: Promise.resolve(), pending: new Set() };
3196
+ const seq = queue.nextSeq ?? 0;
3197
+ queue.nextSeq = seq + 1;
3013
3198
  const previous = queue.tail;
3014
3199
  let resolveDone;
3015
3200
  const tail = new Promise(resolve => { resolveDone = resolve; });
@@ -3020,6 +3205,8 @@ export class FleetManager {
3020
3205
  if (done)
3021
3206
  return;
3022
3207
  done = true;
3208
+ if (!copy)
3209
+ queue.nonCopiesOpen = Math.max(0, (queue.nonCopiesOpen ?? 0) - 1);
3023
3210
  clearTimeout(timer);
3024
3211
  queue.pending.delete(entry);
3025
3212
  if (guard && this.webChannelEchoGuards.get(instanceName) === guard) {
@@ -3027,7 +3214,7 @@ export class FleetManager {
3027
3214
  }
3028
3215
  resolveDone();
3029
3216
  };
3030
- const entry = { started: false, drop: () => {
3217
+ const entry = { seq, started: false, copy, drop: () => {
3031
3218
  this.logger.warn({ instanceName }, "Queued web channel echo dropped after ordering timeout");
3032
3219
  finish();
3033
3220
  } };
@@ -3037,14 +3224,16 @@ export class FleetManager {
3037
3224
  expired = true;
3038
3225
  this.logger.warn({ instanceName, inFlight: entry.started }, "Web channel echo ordering timed out");
3039
3226
  finish();
3040
- // Do not post copies that were queued behind an ambiguous platform send.
3041
- for (const pending of queue.pending)
3042
- if (!pending.started)
3043
- pending.drop();
3227
+ // Do not post copies that were queued behind an ambiguous platform send. A non-copy that timed out was no copy
3228
+ // at all (nothing behind it is ambiguous), and a non-copy is never dropped as one.
3229
+ if (copy)
3230
+ for (const pending of queue.pending)
3231
+ if (pending.seq > seq && !pending.started && pending.copy)
3232
+ pending.drop();
3044
3233
  };
3045
3234
  const checkDeadline = () => {
3046
- if (done)
3047
- return;
3235
+ if (done || deadlineAt === Number.POSITIVE_INFINITY)
3236
+ return; // a non-copy's budget has not started yet
3048
3237
  const remaining = deadlineAt - performance.now();
3049
3238
  if (remaining <= 0)
3050
3239
  expire();
@@ -3053,8 +3242,19 @@ export class FleetManager {
3053
3242
  };
3054
3243
  queue.pending.add(entry);
3055
3244
  queue.tail = tail;
3245
+ if (!copy) {
3246
+ queue.nonCopiesOpen = (queue.nonCopiesOpen ?? 0) + 1;
3247
+ queue.lastNonCopyDone = tail;
3248
+ }
3056
3249
  this.webChannelEchoTails.set(instanceName, queue);
3057
3250
  checkDeadline();
3251
+ // Registered before the admission below (which waits on `previous`, itself after the non-copy ahead), so the budget
3252
+ // is set by the time that runs.
3253
+ if (nonCopyAhead)
3254
+ void nonCopyAhead.then(() => { if (!done) {
3255
+ deadlineAt = performance.now() + 5_000;
3256
+ checkDeadline();
3257
+ } });
3058
3258
  void Promise.all([previous, admission]).then(([, accepted]) => {
3059
3259
  if (done)
3060
3260
  return;
@@ -3063,11 +3263,15 @@ export class FleetManager {
3063
3263
  return;
3064
3264
  }
3065
3265
  // A delayed timer callback must not admit an already expired copy.
3066
- if (performance.now() >= deadlineAt) {
3266
+ if (copy && performance.now() >= deadlineAt) {
3067
3267
  expire();
3068
3268
  return;
3069
3269
  }
3070
3270
  entry.started = true;
3271
+ if (!copy) {
3272
+ deadlineAt = performance.now() + 5_000;
3273
+ checkDeadline();
3274
+ }
3071
3275
  guard = { epoch, deadlineAt };
3072
3276
  this.webChannelEchoGuards.set(instanceName, guard);
3073
3277
  let request;
@@ -3102,6 +3306,101 @@ export class FleetManager {
3102
3306
  instanceLaunchSupportsSteer(instanceName) {
3103
3307
  return this.daemons.get(instanceName)?.launchSupportsSteer();
3104
3308
  }
3309
+ humanReceiptDaemons = new WeakSet();
3310
+ /** Cached FM + lifecycle + Daemon + tmux owner; unknown ownership grants nothing. */
3311
+ humanReceiptOwner(instanceName, daemon = this.daemons.get(instanceName)) {
3312
+ try {
3313
+ if (this.shuttingDown || !daemon || this.daemons.get(instanceName) !== daemon)
3314
+ return null;
3315
+ const epoch = this.lifecycle.epochOf(instanceName);
3316
+ const owner = daemon.captureHumanReceiptOwner();
3317
+ if (!Number.isSafeInteger(epoch) || !owner)
3318
+ return null;
3319
+ return JSON.stringify([this.managerBootId, epoch, this.getDeliveryEpoch(instanceName), humanOwnerObjectId(daemon), owner]);
3320
+ }
3321
+ catch {
3322
+ return null;
3323
+ }
3324
+ }
3325
+ listenForHumanReceipt(instanceName, daemon) {
3326
+ // Lazy initialization also supports narrow prototype-based integration fixtures.
3327
+ this.humanReceiptDaemons ??= new WeakSet();
3328
+ if (this.humanReceiptDaemons.has(daemon))
3329
+ return;
3330
+ this.humanReceiptDaemons.add(daemon);
3331
+ daemon.on("human_receipt_result", (result) => {
3332
+ try {
3333
+ this.deliveryOutbox?.settleHumanReceipt(result.receipt_id, instanceName, result.owner_key, result.proof, this.humanReceiptOwner(instanceName, daemon));
3334
+ }
3335
+ catch (err) {
3336
+ this.logger.error({ err, instanceName }, "Could not persist human delivery evidence");
3337
+ }
3338
+ });
3339
+ }
3340
+ /** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
3341
+ humanReceiptPayload(instanceName, payload, origin, originalBody) {
3342
+ if (!origin || !this.deliveryOutbox)
3343
+ return payload;
3344
+ const row = this.deliveryOutbox.createHumanReceipt({ targetInstance: instanceName, originalBody,
3345
+ origin: { ...origin }, inputKind: String(payload.type), managerBootId: this.managerBootId });
3346
+ return { ...payload, meta: { ...(payload.meta ?? {}),
3347
+ human_receipt_id: row.receipt_id, verification_id: row.message_id } };
3348
+ }
3349
+ /** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
3350
+ bindHumanReceiptPayload(instanceName, payload) {
3351
+ const meta = payload.meta;
3352
+ if (!meta?.human_receipt_id || !this.deliveryOutbox)
3353
+ return payload;
3354
+ const daemon = this.daemons.get(instanceName);
3355
+ const owner = this.humanReceiptOwner(instanceName, daemon);
3356
+ if (!daemon || !owner) {
3357
+ this.deliveryOutbox.markHumanReceiptUncertain(meta.human_receipt_id, instanceName);
3358
+ return payload;
3359
+ }
3360
+ this.listenForHumanReceipt(instanceName, daemon);
3361
+ const daemonOwner = daemon.captureHumanReceiptOwner();
3362
+ const wire = daemon.humanReceiptWire(String(payload.content ?? ""), meta, String(payload.type));
3363
+ const row = this.deliveryOutbox.bindHumanReceipt(meta.human_receipt_id, instanceName, owner, wire, this.managerBootId);
3364
+ if (!daemonOwner || this.humanReceiptOwner(instanceName, daemon) !== owner)
3365
+ throw new Error("Human delivery ownership changed");
3366
+ // /raw retains exact bytes and never acquires a positive verification capability.
3367
+ if (payload.type === "raw_paste" || String(payload.content ?? "").startsWith("/raw ")) {
3368
+ this.deliveryOutbox.markHumanReceiptUncertain(row.receipt_id, instanceName);
3369
+ return payload;
3370
+ }
3371
+ return { ...payload, meta: { ...meta, human_owner_key: owner, human_daemon_owner: daemonOwner, human_wire_sha256: row.wire_sha256 } };
3372
+ }
3373
+ async deliverHumanToInstance(instanceName, payload, origin, originalBody, options) {
3374
+ const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
3375
+ const id = admitted.meta?.human_receipt_id;
3376
+ let sent = false;
3377
+ try {
3378
+ const result = options ? await this.deliverToInstance(instanceName, admitted, options) : await this.deliverToInstance(instanceName, admitted);
3379
+ sent = result !== false;
3380
+ return result;
3381
+ }
3382
+ finally {
3383
+ if (!sent && id)
3384
+ this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
3385
+ }
3386
+ }
3387
+ /** Synchronous steer/btw handoff, using the same evidence and owner binding. */
3388
+ sendHumanControl(instanceName, payload, origin, originalBody) {
3389
+ const ipc = this.instanceIpcClients.get(instanceName);
3390
+ if (this.shuttingDown || !ipc?.connected)
3391
+ return false;
3392
+ const admitted = this.humanReceiptPayload(instanceName, payload, origin, originalBody);
3393
+ const id = admitted.meta?.human_receipt_id;
3394
+ let sent = false;
3395
+ try {
3396
+ sent = ipc.send(this.bindHumanReceiptPayload(instanceName, admitted));
3397
+ return sent;
3398
+ }
3399
+ finally {
3400
+ if (!sent && id)
3401
+ this.deliveryOutbox?.markHumanReceiptUncertain(id, instanceName);
3402
+ }
3403
+ }
3105
3404
  /** Single delivery facade: wake paused CLIs and serialize non-user work behind idle. */
3106
3405
  async deliverToInstance(instanceName, payload, options = {}) {
3107
3406
  const deliveryEpoch = this.getDeliveryEpoch(instanceName);
@@ -3234,6 +3533,9 @@ export class FleetManager {
3234
3533
  * the check and the act, so the channel's instance cannot be rebound to another bot in between.
3235
3534
  */
3236
3535
  async dispatchSlash(slash, adapterId, adapter) {
3536
+ return withSpawnHoldReply(text => slash.respond(text), () => this.dispatchSlashCore(slash, adapterId, adapter));
3537
+ }
3538
+ async dispatchSlashCore(slash, adapterId, adapter) {
3237
3539
  // #1569: an alias (/web) is its command (/dashboard) from here on — the door, the table and the handler see one name.
3238
3540
  const data = { ...slash, command: canonicalCommand(slash.command) };
3239
3541
  const door = this.slashDoor(data, adapterId);
@@ -3291,7 +3593,7 @@ export class FleetManager {
3291
3593
  userId: data.userId,
3292
3594
  username,
3293
3595
  source: "discord",
3294
- timestamp: new Date(),
3596
+ timestamp: new Date(), adapterId, humanOrigin: data.humanOrigin, humanOriginalBody: text,
3295
3597
  });
3296
3598
  }
3297
3599
  else if (data.command === "save") {
@@ -3315,6 +3617,9 @@ export class FleetManager {
3315
3617
  else if (data.command === "model") {
3316
3618
  await this.handleModelSlash(data, adapterId);
3317
3619
  }
3620
+ else if (data.command === "backend") {
3621
+ await this.handleBackendSlash(data, adapterId);
3622
+ }
3318
3623
  else if (data.command === "clear") {
3319
3624
  await this.handleClearSlash(data, adapterId);
3320
3625
  }
@@ -3436,7 +3741,8 @@ export class FleetManager {
3436
3741
  await data.respond(t("classic.no_agent"));
3437
3742
  return;
3438
3743
  }
3439
- const steerText = String(data.options?.message ?? "").trim();
3744
+ const originalSteerText = String(data.options?.message ?? "");
3745
+ const steerText = originalSteerText.trim();
3440
3746
  if (!steerText) {
3441
3747
  await data.respond(t("steer.usage"));
3442
3748
  return;
@@ -3446,7 +3752,7 @@ export class FleetManager {
3446
3752
  // rerouting the instance's replies to the slash context.
3447
3753
  const result = this.topicCommands.sendSteer(name, steerText, {
3448
3754
  chatId: "", messageId: "", username: data.username ?? "user",
3449
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
3755
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalSteerText,
3450
3756
  });
3451
3757
  await data.respond(result);
3452
3758
  }
@@ -3456,14 +3762,15 @@ export class FleetManager {
3456
3762
  await data.respond(t("classic.no_agent"));
3457
3763
  return;
3458
3764
  }
3459
- const btwText = String(data.options?.message ?? "").trim();
3765
+ const originalBtwText = String(data.options?.message ?? "");
3766
+ const btwText = originalBtwText.trim();
3460
3767
  if (!btwText) {
3461
3768
  await data.respond(t("btw.usage"));
3462
3769
  return;
3463
3770
  }
3464
3771
  const result = this.topicCommands.sendBtw(name, btwText, {
3465
3772
  chatId: "", messageId: "", username: data.username ?? "user",
3466
- userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord",
3773
+ userId: data.userId ?? "", threadId: undefined, adapterId, source: "discord", humanOrigin: data.humanOrigin, humanOriginalBody: originalBtwText,
3467
3774
  });
3468
3775
  await data.respond(result);
3469
3776
  }
@@ -3763,12 +4070,16 @@ export class FleetManager {
3763
4070
  const autoPauseChanged = oldAutoPause.get(ch.instanceName) !== newAutoPause;
3764
4071
  // #1220: a running CLI keeps the login it was launched with, so a new
3765
4072
  // credential_profile only takes effect through a restart.
3766
- const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo } = this.classicProfileChange(ch.instanceName, backendChanged);
4073
+ const { changed: profileChanged, startsFresh: profileSwitchStartsFresh, from: profileFrom, to: profileTo, carry: profileCarry } = this.classicProfileChange(ch.instanceName, backendChanged);
3767
4074
  if (this.daemons.has(ch.instanceName) && (backendChanged || modelChanged || autoPauseChanged || profileChanged)) {
3768
4075
  this.logger.info({ instanceName: ch.instanceName, backendFrom: oldBackends.get(ch.instanceName), backendTo: newBackend, modelFrom: oldModels.get(ch.instanceName), modelTo: newModel,
3769
4076
  ...(profileChanged ? { profileFrom, profileTo } : {}) }, "Backend/model/profile changed — restarting");
4077
+ const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
3770
4078
  await this.stopInstance(ch.instanceName).catch(() => { });
3771
- if (profileSwitchStartsFresh)
4079
+ // #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
4080
+ if (profileCarry)
4081
+ await this.switchConversation(ch.instanceName, { ...profileCarry, workingDirectory: launchedIn });
4082
+ else if (profileSwitchStartsFresh)
3772
4083
  this.writeFreshStartMarker(ch.instanceName);
3773
4084
  // Small delay to let tmux window clean up
3774
4085
  await new Promise(r => setTimeout(r, 2000));
@@ -4152,6 +4463,33 @@ export class FleetManager {
4152
4463
  names.add(channel.instanceName);
4153
4464
  return [...names];
4154
4465
  }
4466
+ /** #1624: migration cannot infer ownership from the running daemon roster.
4467
+ * Stopped/paused ClassicBot entries count too; their workspace is exactly
4468
+ * the one startClassicInstance builds, not this FleetManager's dataDir.
4469
+ */
4470
+ kiroPeerWorkingDirectories(name) {
4471
+ if (!this.fleetConfig || !this.classicChannels)
4472
+ throw new Error("Kiro peer configuration is not loaded");
4473
+ const peers = [];
4474
+ for (const [peer, cfg] of Object.entries(this.fleetConfig.instances)) {
4475
+ if (peer === name || (cfg.backend ?? this.fleetConfig.defaults?.backend ?? "claude-code") !== "kiro-cli")
4476
+ continue;
4477
+ if (typeof cfg.working_directory !== "string" || !cfg.working_directory)
4478
+ throw new Error("Kiro peer cwd is unknown");
4479
+ peers.push(cfg.working_directory);
4480
+ }
4481
+ for (const ch of this.classicChannels.getAll()) {
4482
+ if (ch.instanceName === name) {
4483
+ if (Object.hasOwn(this.fleetConfig.instances, name))
4484
+ throw new Error("Kiro instance name has two configurations");
4485
+ continue;
4486
+ }
4487
+ if (this.classicChannels.getBackendByInstance(ch.instanceName, this.fleetConfig.defaults?.backend) === "kiro-cli") {
4488
+ peers.push(join(getAgendHome(), "workspaces", ch.instanceName));
4489
+ }
4490
+ }
4491
+ return peers;
4492
+ }
4155
4493
  /**
4156
4494
  * Probe the same executable set exposed by the web backend catalog.
4157
4495
  *
@@ -4374,6 +4712,9 @@ export class FleetManager {
4374
4712
  // Published so the outage hand-off can wait for an explicit stop that began
4375
4713
  // BEFORE it (the generation fence alone only catches stops that begin after
4376
4714
  // the hand-off snapshotted it).
4715
+ // #1716: scope the marker to this stop — a marker older than beganAt is
4716
+ // from an earlier stop and never reports.
4717
+ const beganAt = Date.now();
4377
4718
  const run = (async () => {
4378
4719
  try {
4379
4720
  await this.lifecycle.stop(name, transition);
@@ -4381,6 +4722,10 @@ export class FleetManager {
4381
4722
  finally {
4382
4723
  this.clearNoncePromptsForInstance(name);
4383
4724
  }
4725
+ const backgroundWorkEnded = readBackgroundWorkEndedMarker(this.getInstanceDir(name), beganAt) !== null;
4726
+ if (backgroundWorkEnded)
4727
+ this.notifyInstanceTopic(name, t("stop.background_work_ended", name));
4728
+ return { backgroundWorkEnded };
4384
4729
  })().finally(() => { if (this.stopsInFlight.get(name) === run)
4385
4730
  this.stopsInFlight.delete(name); });
4386
4731
  this.stopsInFlight.set(name, run);
@@ -4395,6 +4740,16 @@ export class FleetManager {
4395
4740
  const inFlight = this.restartsInFlight.get(name);
4396
4741
  if (inFlight) {
4397
4742
  this.logger.info({ name }, "restartSingleInstance: joining the restart already in flight");
4743
+ // #1753: a credential-profile switch joining it still needs its conversation carried in that restart's stop →
4744
+ // start gap: handed to the restart while it has not reached the gap (and carries nothing else); otherwise not
4745
+ // carried, and said so — the restart has already started, or will start, the CLI without it.
4746
+ if (opts?.carry) {
4747
+ const gap = this.restartGaps.get(name);
4748
+ if (gap && !gap.reached && !gap.carry)
4749
+ gap.carry = opts.carry;
4750
+ else
4751
+ opts.carry.result = { carried: false, reason: gap?.carry ? "a restart in flight carries another switch" : "joined a restart in flight past its stop" };
4752
+ }
4398
4753
  return inFlight;
4399
4754
  }
4400
4755
  const workingDirectory = this.fleetConfig?.instances[name]?.working_directory || this.getInstanceDir(name);
@@ -4403,6 +4758,8 @@ export class FleetManager {
4403
4758
  // spawn gate is taken inside the transition, never the other way round, so
4404
4759
  // the start being waited for can still get its own gate slot.
4405
4760
  this.lifecycle.invalidate(name);
4761
+ const gap = { carry: opts?.carry, reached: false };
4762
+ this.restartGaps.set(name, gap);
4406
4763
  const run = this.lifecycle.runTransition(name, transition => this.spawnGate.run({
4407
4764
  instanceName: name,
4408
4765
  workingDirectory,
@@ -4411,6 +4768,8 @@ export class FleetManager {
4411
4768
  }, () => this.doRestartSingleInstance(name, opts, transition)))
4412
4769
  .finally(() => {
4413
4770
  this.restartsInFlight.delete(name);
4771
+ if (this.restartGaps.get(name) === gap)
4772
+ this.restartGaps.delete(name);
4414
4773
  this.scheduleDeliveryOutboxPump();
4415
4774
  });
4416
4775
  this.restartsInFlight.set(name, run);
@@ -4480,10 +4839,41 @@ export class FleetManager {
4480
4839
  async doRestartWithSlot(name, opts, transition, config, wasPaused) {
4481
4840
  const pausedAt = wasPaused ? (this.lifecycle.getLastPausedAt(name) ?? readPausedAt(this.getInstanceDir(name))) : null;
4482
4841
  const pauseReason = wasPaused ? readPauseReason(this.getInstanceDir(name)) : null;
4842
+ // #1753: the directory the CLI being stopped was launched in — its conversation is under that directory's key.
4843
+ const launchedIn = this.daemons.get(name)?.getConfigSnapshot?.()?.working_directory;
4844
+ // Between the stop and the start: the old CLI's transcript is complete, the new one has not looked yet.
4845
+ /** `launching`: the login the start will use — read only when a switch is to be carried. */
4846
+ const betweenStopAndStart = async (launching) => {
4847
+ // #1753: this restart's own carry, or one a caller joined it with before this point (restartSingleInstance).
4848
+ const gap = this.restartGaps.get(name);
4849
+ const carry = gap?.carry ?? opts?.carry;
4850
+ if (gap)
4851
+ gap.reached = true;
4852
+ if (!carry) {
4853
+ if (opts?.freshStart)
4854
+ this.writeFreshStartMarker(name);
4855
+ return;
4856
+ }
4857
+ // Only into the login this start really launches: a restart that read its configuration before the switch was
4858
+ // saved (one a switch joined) launches the old one — a copy into the new would be reported as carried, falsely.
4859
+ const launch = launching();
4860
+ if (launch.backend !== carry.backend || launch.profile !== carry.to) {
4861
+ const login = (p) => (p ? `profile "${p}"` : "the default login");
4862
+ carry.result = { carried: false, reason: `the restart launches ${launch.backend !== carry.backend ? launch.backend : login(launch.profile)}, not ${login(carry.to)}` };
4863
+ this.logger.info({ name, launches: launch, carry: { backend: carry.backend, from: carry.from, to: carry.to } }, "Conversation not carried: the restart launches another login");
4864
+ // The old login resumes its own conversation; any other starts fresh, as an uncarried switch does.
4865
+ if (launch.backend !== carry.backend || launch.profile !== carry.from)
4866
+ this.writeFreshStartMarker(name);
4867
+ return;
4868
+ }
4869
+ carry.result = await this.switchConversation(name, { ...carry, workingDirectory: launchedIn });
4870
+ };
4483
4871
  if (config) {
4484
4872
  await this.stopInstance(name, transition);
4485
- if (opts?.freshStart)
4486
- this.writeFreshStartMarker(name);
4873
+ await betweenStopAndStart(() => {
4874
+ const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
4875
+ return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, this.fleetConfig?.defaults, backend) : null };
4876
+ });
4487
4877
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
4488
4878
  await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
4489
4879
  this.stormWindow.markRecovered(name);
@@ -4496,8 +4886,10 @@ export class FleetManager {
4496
4886
  const adapterId = this.classicChannels.getAdapterIdByInstance(name);
4497
4887
  await this.stopInstance(name, transition);
4498
4888
  await new Promise(r => setTimeout(r, 1000)); // let tmux clean up
4499
- if (opts?.freshStart)
4500
- this.writeFreshStartMarker(name);
4889
+ await betweenStopAndStart(() => {
4890
+ const backend = this.classicChannels.getBackendByInstance(name, fleetBackend);
4891
+ return { backend, profile: credentialHomeSpec(backend) ? profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults)) : null };
4892
+ });
4501
4893
  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));
4502
4894
  this.stormWindow.markRecovered(name);
4503
4895
  return;
@@ -4518,6 +4910,43 @@ export class FleetManager {
4518
4910
  this.logger.warn({ err, name }, "freshStart: failed to write crash-state marker");
4519
4911
  }
4520
4912
  }
4913
+ /**
4914
+ * #1753: a credential-profile switch, between the old CLI's stop and the new one's start. Where the backend can take
4915
+ * the conversation along (claude-code), copy it into the new profile and make it the one `--continue` picks; when that
4916
+ * cannot be done, or the backend cannot, start fresh as before. Null when nothing was tried.
4917
+ */
4918
+ async switchConversation(name, sw) {
4919
+ if (!credentialSwitchCarriesConversation(sw.backend)) {
4920
+ if (credentialSwitchStartsFresh(sw.backend))
4921
+ this.writeFreshStartMarker(name);
4922
+ return null;
4923
+ }
4924
+ let sessionId = null;
4925
+ try {
4926
+ sessionId = readFileSync(join(this.getInstanceDir(name), "session-id"), "utf-8");
4927
+ }
4928
+ catch { /* none: nothing to carry */ }
4929
+ const dir = (profile) => claudeConfigDirFor(profile ? credentialProfileHome(this.dataDir, sw.backend, profile) : null);
4930
+ let result;
4931
+ try {
4932
+ result = await carryClaudeConversation({ sessionId, workingDirectory: sw.workingDirectory ?? "", fromConfigDir: dir(sw.from), toConfigDir: dir(sw.to) });
4933
+ }
4934
+ catch (err) {
4935
+ result = { carried: false, reason: `copy failed: ${err.message}` };
4936
+ }
4937
+ if (result.carried) {
4938
+ const fields = { name, from: sw.from ?? "default", to: sw.to ?? "default", bytes: result.bytes, ms: result.ms, replacedOlderCopy: result.replacedOlderCopy };
4939
+ if (result.bytes > CARRY_LARGE_BYTES)
4940
+ this.logger.warn(fields, "Conversation carried to the new credential profile (a large transcript)");
4941
+ else
4942
+ this.logger.info(fields, "Conversation carried to the new credential profile");
4943
+ }
4944
+ else {
4945
+ 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");
4946
+ this.writeFreshStartMarker(name);
4947
+ }
4948
+ return result;
4949
+ }
4521
4950
  /** Load .env file from data dir into process.env */
4522
4951
  loadEnvFile(owner) {
4523
4952
  const envPath = join(this.dataDir, ".env");
@@ -4549,6 +4978,9 @@ export class FleetManager {
4549
4978
  this.webSessions = new WebSessionStore({
4550
4979
  dataDir: this.dataDir,
4551
4980
  onWarn: message => this.logger.warn(message),
4981
+ // #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
4982
+ // (shortening ends the sessions it now outlives). The public link's policy stays fixed.
4983
+ localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
4552
4984
  });
4553
4985
  }
4554
4986
  if (!this.returningDevices) {
@@ -4578,7 +5010,13 @@ export class FleetManager {
4578
5010
  return { display: issued.display, expiresAt: issued.expiresAt, ttlMinutes: Math.round(LOGIN_CODE_TTL_MS / 60_000) };
4579
5011
  }
4580
5012
  tunnelPurposeLane = null;
5013
+ previewTunnel = null;
4581
5014
  publicWebLink = null;
5015
+ /** #1723: the public link's preview tunnel — a manager and lease of its own, never the fleet's one tunnel lane. */
5016
+ getPreviewTunnel() {
5017
+ return this.previewTunnel ??= new ManagedTunnel({ dataDir: join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR),
5018
+ log: () => this.logger.warn("Public preview tunnel cleanup requires attention") });
5019
+ }
4582
5020
  getTunnelLane() {
4583
5021
  return this.tunnelPurposeLane ??= new TunnelPurposeLane(new ManagedTunnel({ dataDir: this.dataDir,
4584
5022
  log: () => this.logger.warn("Managed tunnel cleanup requires attention") }));
@@ -4616,6 +5054,61 @@ export class FleetManager {
4616
5054
  }
4617
5055
  return null;
4618
5056
  }
5057
+ /**
5058
+ * #1742: the General a fleet admin's DM speaks for (`/start web`): the same owner `/web` in that General would make
5059
+ * — this bot's configured group and its one General topic. No address to match in a DM, so it must be unambiguous:
5060
+ * no General, or more than one on this bot and group, and there is no owner (nothing is guessed).
5061
+ */
5062
+ dmDashboardOwner(userId, adapterId) {
5063
+ const world = this.worlds.get(adapterId);
5064
+ if (!world || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(userId, adapterId))
5065
+ return null;
5066
+ const configured = (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).find(ch => (ch.id ?? ch.type) === adapterId);
5067
+ const group = String(configured?.group_id ?? "");
5068
+ if (!group)
5069
+ return null;
5070
+ const topics = Object.entries(this.fleetConfig?.instances ?? {})
5071
+ .filter(([name, cfg]) => cfg.general_topic && cfg.topic_id != null && this.getInstanceAdapterId(name) === adapterId
5072
+ && String(this.getGroupIdForInstance(name) ?? "") === group)
5073
+ .map(([, cfg]) => cfg.topic_id.toString());
5074
+ if (topics.length !== 1)
5075
+ return null;
5076
+ const owner = { userId, adapterId, chatId: group, threadId: topics[0], binding: world.adapter };
5077
+ return this.publicOwnerCurrent(owner) ? owner : null;
5078
+ }
5079
+ /**
5080
+ * #1742: a fleet admin whose Telegram `/web` could not reach them privately may next open the bot's DM and press
5081
+ * Telegram's START, which sends a bare `/start` — read as `/start web` (the DM menu) for 10 minutes, once, for that
5082
+ * admin on that bot. Monotonic: a wall-clock jump neither opens nor shuts it.
5083
+ */
5084
+ tgWebStartWindows = new Map();
5085
+ openTgWebStartWindow(adapterId, userId) {
5086
+ const now = performance.now();
5087
+ for (const [key, until] of this.tgWebStartWindows)
5088
+ if (until <= now)
5089
+ this.tgWebStartWindows.delete(key);
5090
+ this.tgWebStartWindows.set(`${adapterId}\0${userId}`, now + TG_WEB_START_WINDOW_MS);
5091
+ }
5092
+ /** Uses the window up: true when it was open. */
5093
+ takeTgWebStartWindow(adapterId, userId) {
5094
+ const key = `${adapterId}\0${userId}`;
5095
+ const until = this.tgWebStartWindows.get(key);
5096
+ this.tgWebStartWindows.delete(key);
5097
+ return until !== undefined && performance.now() < until;
5098
+ }
5099
+ /** #1742: `/start web` from a fleet admin: the /web menu right here in the DM, for the General this bot serves. */
5100
+ async startWebInDm(msg, adapter) {
5101
+ if (!adapter)
5102
+ return;
5103
+ const owner = msg.chatId === msg.userId ? this.dmDashboardOwner(msg.userId, msg.adapterId ?? "") : null;
5104
+ if (!owner) {
5105
+ await adapter.sendText(msg.chatId, t("dashboard.start_web_ack"));
5106
+ return;
5107
+ }
5108
+ if (!await this.showDashboardMenu(owner, undefined, undefined, msg.chatId)) {
5109
+ await adapter.sendText(msg.chatId, t("dashboard.menu_failed")).catch(() => undefined);
5110
+ }
5111
+ }
4619
5112
  getPublicWebLink() {
4620
5113
  this.initializeWebSessions();
4621
5114
  return this.publicWebLink ??= new PublicWebLink({
@@ -4630,7 +5123,14 @@ export class FleetManager {
4630
5123
  if (result && !result.durable)
4631
5124
  this.logger.warn("Public session revocation could not be saved; exposure remains closed");
4632
5125
  },
4633
- log: (event, exposureId) => this.logger.info({ event, exposureId }, "Public web link"),
5126
+ log: (event, exposureId, detail) => this.logger.info({ event, exposureId, ...(detail ? { detail } : {}) }, "Public web link"),
5127
+ createPreview: ({ id, expiresAt, binary, protocol, dashboardOrigin, isCurrent }) => {
5128
+ if (!publicPreviewWanted(this.fleetConfig?.web))
5129
+ return null;
5130
+ return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
5131
+ provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
5132
+ log: (event, detail) => this.logger.info({ event, exposureId: id, ...(detail ? { detail } : {}) }, "Public web link") });
5133
+ },
4634
5134
  onCleanupUnconfirmed: (result, owner) => {
4635
5135
  this.logger.warn({ pid: result.confirmed ? null : result.pid }, "Public web access closed; tunnel cleanup unconfirmed");
4636
5136
  const world = this.worlds.get(owner.adapterId);
@@ -4663,6 +5163,16 @@ export class FleetManager {
4663
5163
  await owner.binding.sendText(owner.chatId, t("dashboard.menu_failed"), { threadId: owner.threadId }).catch(() => undefined);
4664
5164
  }
4665
5165
  }
5166
+ /**
5167
+ * #1686: build the Telegram deep-link that opens a DM with this bot pre-seeded with `/start web`.
5168
+ * Returns null when no botUsername is known or the username fails Telegram's format validation.
5169
+ */
5170
+ tgStartWebUrl(adapterId) {
5171
+ const username = this.worlds.get(adapterId)?.botUsername;
5172
+ if (!username || !/^[A-Za-z0-9_]{5,32}$/.test(username))
5173
+ return null;
5174
+ return `https://t.me/${username}?start=web`;
5175
+ }
4666
5176
  async sendLocalDashboard(data, adapterId) {
4667
5177
  if (this.shuttingDown || !this.hasFleetAdmins(adapterId) || !this.isFleetAdmin(data.userId, adapterId)) {
4668
5178
  await data.respond(t("not_authorized"));
@@ -4776,7 +5286,11 @@ export class FleetManager {
4776
5286
  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");
4777
5287
  }
4778
5288
  /** False when the menu was not shown (the caller still owes the user an answer). */
4779
- async showDashboardMenu(owner, respondButtons, retireButtons) {
5289
+ /**
5290
+ * `dmChatId` (#1742): show it in the owner's own private chat with the bot instead of the General — the owner, its
5291
+ * gates and the code's route are the same; only where the menu sits (and its clicks are good) changes.
5292
+ */
5293
+ async showDashboardMenu(owner, respondButtons, retireButtons, dmChatId) {
4780
5294
  if (!this.publicOwnerCurrent(owner)) {
4781
5295
  this.logger.info("Dashboard menu not shown: the General owner changed");
4782
5296
  return false;
@@ -4784,16 +5298,18 @@ export class FleetManager {
4784
5298
  const adapter = owner.binding;
4785
5299
  const status = this.getPublicWebStatus();
4786
5300
  const publicAllowed = publicLinkSettings(this.fleetConfig?.web).allowed;
5301
+ const tgHint = adapter.type === "telegram" && dmChatId === undefined ? this.tgStartWebUrl(owner.adapterId) : null;
4787
5302
  const menuText = this.topicCommands.getDashboardText(false, !publicAllowed)
4788
5303
  + (publicAllowed ? "\n\n" + t("dashboard.public_risk") : "")
4789
5304
  + "\n" + t("dashboard.public_status", status.state, status.remainingSeconds ?? 0)
4790
- + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "");
5305
+ + (status.expiresAt ? t("dashboard.public_until", formatFleetTime(status.expiresAt)) : "")
5306
+ + (tgHint ? "\n" + t("dashboard.menu_tg_hint", tgHint) : "");
4791
5307
  const choices = [{ action: "local", label: t("dashboard.local") },
4792
5308
  ...(publicLinkSettings(this.fleetConfig?.web).allowed ? [{ action: "public", label: t("dashboard.public_open") }] : []),
4793
5309
  ...(status.state !== "closed" ? [{ action: "close", label: t("dashboard.public_close") }] : [])];
4794
5310
  const posted = await this.postNonceButtonPrompt({
4795
5311
  prefix: "dashboard:", alertType: "login", instanceName: "dashboard", adapter, adapterId: owner.adapterId,
4796
- chatId: owner.chatId, threadId: owner.threadId, timeoutMs: 5 * 60_000,
5312
+ chatId: dmChatId ?? owner.chatId, threadId: dmChatId === undefined ? owner.threadId : undefined, timeoutMs: 5 * 60_000,
4797
5313
  message: menuText,
4798
5314
  choices, expiredText: t("buttons.stale"), extra: { requesterUserId: owner.userId, dashboardOwner: owner, publicExposureId: this.publicWebLink?.exposureId },
4799
5315
  ...(respondButtons ? { deliver: async (c) => {
@@ -4856,6 +5372,8 @@ export class FleetManager {
4856
5372
  }) : null;
4857
5373
  let lastSteps;
4858
5374
  let deliveredByDm = false;
5375
+ // #1742: a DM was tried and did not go — only then is "open the private chat" the way out.
5376
+ let dmRefused = false;
4859
5377
  const send = async (url, exposureId, expiresAt, current = () => this.publicOwnerCurrent(owner)) => {
4860
5378
  const token = this.webToken;
4861
5379
  if (!token || !current())
@@ -4881,6 +5399,7 @@ export class FleetManager {
4881
5399
  }
4882
5400
  catch (err) {
4883
5401
  this.logDashboardDmFailure(err);
5402
+ dmRefused = true;
4884
5403
  if (direct.type !== "discord" || !data.respondPrivate || !current())
4885
5404
  throw new Error("private delivery unavailable");
4886
5405
  const sent = await withinBudget(data.respondPrivate(text, c), deadline);
@@ -4926,11 +5445,23 @@ export class FleetManager {
4926
5445
  this.logger.warn({ err }, "Dashboard sign-in delivery failed");
4927
5446
  }
4928
5447
  // Only safe, static words enter General; never link, code or platform error text.
4929
- const outcome = t(!ok ? "dashboard.private_failed"
4930
- : owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
5448
+ // #1742: Telegram's failure walks through opening the DM (never a bare /start, which starts a ClassicBot agent),
5449
+ // with the "先開私訊 →" button when the bot's username is known; and for a while a bare /start there counts as
5450
+ // /start web. A menu shown in the DM itself says the details are right below it. Only when a DM was tried and did
5451
+ // not go: a link that never got that far (the tunnel lane held, the owner gone) says so instead.
5452
+ const telegram = owner.binding.type === "telegram";
5453
+ const dmSteps = !ok && telegram && dmRefused;
5454
+ const tgLink = dmSteps ? this.tgStartWebUrl(adapterId) : null;
5455
+ if (dmSteps)
5456
+ this.openTgWebStartWindow(adapterId, owner.userId);
5457
+ const outcome = t(!ok ? (!telegram ? "dashboard.private_failed" : !dmSteps ? "dashboard.link_failed" : tgLink ? "dashboard.private_failed_tg" : "dashboard.private_failed_tg_nolink")
5458
+ : telegram && entry.chatId === owner.userId ? "dashboard.private_sent_below"
5459
+ : owner.binding.type !== "discord" ? "dashboard.private_sent" : deliveredByDm ? "dashboard.private_sent_dm" : "dashboard.private_sent_here");
4931
5460
  await progress;
4932
5461
  await steps?.close(); // the last step edit has landed: the outcome cannot be overwritten by it
4933
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked);
5462
+ // #1686: attach the deep-link button on the failure outcome (Telegram only; Discord and the editClicked path ignore urlButtons).
5463
+ const failUrlButtons = tgLink ? [{ label: t("dashboard.start_web_label"), url: tgLink }] : undefined;
5464
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, lastSteps ? renderPublicLinkProgress(lastSteps, performance.now(), outcome) : outcome, data.editClicked, failUrlButtons);
4934
5465
  return true;
4935
5466
  }
4936
5467
  /** `/dashboard revoke`: sign out every browser and withdraw any unused code. */
@@ -5172,6 +5703,86 @@ export class FleetManager {
5172
5703
  setUpdateProgressStage(this.dataDir, "failed", { error: message });
5173
5704
  }
5174
5705
  /** The old fleet edits CLI install stages; the new fleet adopts the same message below. */
5706
+ /**
5707
+ * #1660: arm the post-completion cleanup watch and make sure its monitor
5708
+ * actually runs. The new fleet adopts the receipt at boot but nothing else
5709
+ * starts this timer on that path (Prism a) — without this, the owed
5710
+ * outcome would sit in the marker unwatched. READY never waits for it.
5711
+ */
5712
+ /**
5713
+ * #1735: what the completion of an adopted update does about its retire outcome, read from the marker as it is now.
5714
+ * Already landed with a warning (a detached restart returns before this fleet boots, so the updater retires first)
5715
+ * → render it in the completion. Still owed (`cleanup: "pending"` — a service restart waits for READY, which comes
5716
+ * after the completion) → watch for it. Anything else (no retire on this update, done silently, another receipt)
5717
+ * → nothing.
5718
+ */
5719
+ /**
5720
+ * #1716: background work this restart/update ended, scoped to the operation
5721
+ * so an older stop never reports (`sinceAt` null = plain boot, report
5722
+ * nothing). A method — not a module function — because the #1737
5723
+ * extracted-completion harness runs this region verbatim with only
5724
+ * `this`-methods plus a fixed context.
5725
+ */
5726
+ collectBackgroundWorkEndedNames(names, sinceAt) {
5727
+ if (sinceAt === null)
5728
+ return [];
5729
+ return names.filter(n => readBackgroundWorkEndedMarker(this.getInstanceDir(n), sinceAt) !== null);
5730
+ }
5731
+ updateCleanupAtCompletion(pending, operation) {
5732
+ if (!pending || operation !== "update")
5733
+ return null;
5734
+ const receipt = updateReceiptOf(pending);
5735
+ if (!receipt)
5736
+ return null;
5737
+ const current = readUpdateProgress(this.dataDir);
5738
+ const verdict = cleanupWatchVerdict(current, receipt);
5739
+ if (verdict.action === "append")
5740
+ return { render: { warning: verdict.warning, cleanupCommand: verdict.cleanupCommand } };
5741
+ if (verdict.action === "wait" && current?.progress.cleanup === "pending")
5742
+ return { watch: receipt };
5743
+ return null;
5744
+ }
5745
+ armCleanupWatch(receipt, summary) {
5746
+ this.updateCleanupWatch = { receipt, summary };
5747
+ if (!this.updateProgressTimer)
5748
+ this.startUpdateProgressMonitor();
5749
+ }
5750
+ /**
5751
+ * #1660: append a recorded cleanup outcome to the adopted update message,
5752
+ * then release the receipt — but only if this receipt still owns the
5753
+ * marker. The await yields, so a newer receipt (e.g. a full-restart that
5754
+ * landed mid-edit) must not be cleared or disturbed (Prism b).
5755
+ */
5756
+ async appendCleanupWarning(target, watch, warning, cleanupCommand) {
5757
+ const text = formatRestartProgressCompletion("update", {
5758
+ ...watch.summary,
5759
+ warningText: warning,
5760
+ warningCommand: cleanupCommand,
5761
+ }, watch.receipt.startedAt);
5762
+ const stopWatching = () => {
5763
+ this.updateCleanupWatch = null;
5764
+ if (this.updateProgressTimer)
5765
+ clearInterval(this.updateProgressTimer);
5766
+ this.updateProgressTimer = null;
5767
+ };
5768
+ if (text !== this.lastUpdateProgressText) {
5769
+ const adapter = this.adapters.get(target.adapterId);
5770
+ if (!adapter)
5771
+ return; // retry on a later tick
5772
+ await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
5773
+ this.lastUpdateProgressText = text;
5774
+ }
5775
+ const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
5776
+ if (verdict.action === "drop") {
5777
+ // Someone else owns the marker now — leave it entirely alone.
5778
+ this.updateCleanupWatch = null;
5779
+ return;
5780
+ }
5781
+ if (verdict.action === "wait")
5782
+ return; // not terminal; keep watching
5783
+ clearUpdateMarker(this.dataDir);
5784
+ stopWatching();
5785
+ }
5175
5786
  startUpdateProgressMonitor(initialAdapter) {
5176
5787
  if (this.updateProgressTimer)
5177
5788
  clearInterval(this.updateProgressTimer);
@@ -5185,6 +5796,48 @@ export class FleetManager {
5185
5796
  this.updateProgressTimer = null;
5186
5797
  return;
5187
5798
  }
5799
+ // #1660: the completion summary was already posted; only watch for
5800
+ // the retire outcome owed to this exact receipt. Never stage-edit
5801
+ // here (that would clobber the posted completion), and never touch a
5802
+ // receipt that no longer matches (checklist 3/5).
5803
+ const watch = this.updateCleanupWatch;
5804
+ if (watch) {
5805
+ const stopWatching = () => {
5806
+ this.updateCleanupWatch = null;
5807
+ if (this.updateProgressTimer)
5808
+ clearInterval(this.updateProgressTimer);
5809
+ this.updateProgressTimer = null;
5810
+ };
5811
+ const verdict = cleanupWatchVerdict(marker, watch.receipt);
5812
+ if (verdict.action === "drop") {
5813
+ // Receipt gone or replaced (e.g. a later full-restart adopted the
5814
+ // marker): not ours — leave it to its own lifecycle, drop the watch.
5815
+ this.logger.info("[update] cleanup watch: marker receipt changed, dropping watch");
5816
+ this.updateCleanupWatch = null;
5817
+ }
5818
+ else if (verdict.action === "release") {
5819
+ // Done silent, or the updater died past the max age: release it.
5820
+ clearUpdateMarker(this.dataDir);
5821
+ stopWatching();
5822
+ return;
5823
+ }
5824
+ else if (verdict.action === "wait") {
5825
+ return;
5826
+ }
5827
+ else {
5828
+ this.updateProgressEditRunning = true;
5829
+ try {
5830
+ await this.appendCleanupWarning(marker.progress.target, watch, verdict.warning, verdict.cleanupCommand);
5831
+ }
5832
+ catch (err) {
5833
+ this.logger.warn({ err }, "[update] cleanup watch: failed to append warning, will retry");
5834
+ }
5835
+ finally {
5836
+ this.updateProgressEditRunning = false;
5837
+ }
5838
+ return;
5839
+ }
5840
+ }
5188
5841
  const target = marker.progress.target;
5189
5842
  const adapter = this.adapters.get(target.adapterId) ?? (initialAdapter?.id === target.adapterId ? initialAdapter : undefined);
5190
5843
  if (!adapter)
@@ -5251,6 +5904,8 @@ export class FleetManager {
5251
5904
  const fleet = this.loadConfig(configPath);
5252
5905
  this.slimFleetConfigAtStartup();
5253
5906
  setLocale(detectLocale(fleet)); // user-facing text language (fleet.yaml defaults.locale / timezone)
5907
+ // #1737 review: the marker this boot adopts — the startup clear may only ever clear this one.
5908
+ const bootUpdateMarker = updateMarkerIdentity(this.dataDir);
5254
5909
  const savedUpdateProgress = readUpdateProgress(this.dataDir);
5255
5910
  const pendingUpdateProgress = savedUpdateProgress
5256
5911
  && savedUpdateProgress.progress.stage !== "failed"
@@ -5275,7 +5930,11 @@ export class FleetManager {
5275
5930
  // Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
5276
5931
  // before instances spawn, so a CLI that comes up on its sign-in screen gets
5277
5932
  // an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
5278
- this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => config.backend ?? fleet.defaults?.backend ?? "claude-code"));
5933
+ this.lifecycle.primeAuthVerification(Object.values(fleet.instances).map(config => {
5934
+ const backend = config.backend ?? fleet.defaults?.backend ?? "claude-code";
5935
+ // #1699 follow-up: each instance's own login — a profile is probed with its own environment.
5936
+ return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, fleet.defaults, backend) : null };
5937
+ }));
5279
5938
  // Start tmux control mode client for idle detection
5280
5939
  if (!this.controlClient) {
5281
5940
  this.controlClient = new TmuxControlClient(getTmuxSession(), 2000, this.logger);
@@ -5680,19 +6339,37 @@ export class FleetManager {
5680
6339
  const _require = createRequire(import.meta.url);
5681
6340
  const agendVersion = _require("../package.json").version ?? "unknown";
5682
6341
  await progressStart;
5683
- const progressCompleted = await startupProgress.finish({
6342
+ // #1660/#1735: the nvm route's retire outcome, read NOW (not from the boot snapshot). Either order happens:
6343
+ // a detached fleet's `agend restart` returns before this fleet boots, so the outcome may already be here —
6344
+ // render it in the completion itself; a service restart waits for READY (after this), so it may still be owed
6345
+ // — post without it and arm the watch, which appends it to the same receipt once it lands. READY never waits
6346
+ // for cleanup (that would deadlock with an updater that retires after its restart returns).
6347
+ const cleanupOutcome = this.updateCleanupAtCompletion(pendingUpdateProgress, pendingProgressOperation);
6348
+ // #1716: background work this restart/update ended, scoped to the
6349
+ // operation so an older stop never reports. No pending operation means
6350
+ // a plain boot — nothing was stopped by an operation, report nothing.
6351
+ const backgroundWorkEndedNames = this.collectBackgroundWorkEndedNames(configuredNames, pendingUpdateProgress?.startedAt ?? null);
6352
+ const completionSummary = {
5684
6353
  running: started,
5685
6354
  total,
5686
6355
  version: agendVersion,
5687
6356
  pausedNames,
5688
6357
  failedNames,
6358
+ backgroundWorkEndedNames,
5689
6359
  tipText: pendingProgressOperation === "update" && this.tipsEnabled()
5690
6360
  ? (() => {
5691
6361
  const tip = this.pickAvailableTip();
5692
6362
  return tip ? this.formatTip(tip) : undefined;
5693
6363
  })()
5694
6364
  : undefined,
5695
- });
6365
+ ...(cleanupOutcome && "render" in cleanupOutcome
6366
+ ? { warningText: cleanupOutcome.render.warning, warningCommand: cleanupOutcome.render.cleanupCommand }
6367
+ : {}),
6368
+ };
6369
+ const progressCompleted = await startupProgress.finish(completionSummary);
6370
+ // Armed only after the completion posted: an append racing ahead of it would be overwritten by it.
6371
+ if (cleanupOutcome && "watch" in cleanupOutcome)
6372
+ this.armCleanupWatch(cleanupOutcome.watch, completionSummary);
5696
6373
  if (!progressCompleted && fleet.channel?.group_id) {
5697
6374
  let text;
5698
6375
  if (pendingProgressOperation === "full-restart") {
@@ -5702,6 +6379,7 @@ export class FleetManager {
5702
6379
  version: agendVersion,
5703
6380
  pausedNames,
5704
6381
  failedNames,
6382
+ backgroundWorkEndedNames,
5705
6383
  }, pendingUpdateProgress.startedAt);
5706
6384
  }
5707
6385
  else if (failedNames.length === 0 && pausedNames.length === 0) {
@@ -5714,6 +6392,18 @@ export class FleetManager {
5714
6392
  text = t("fleet.ready_with_failed", started, total, agendVersion, failedNames.join(", "))
5715
6393
  + (pausedNames.length > 0 ? `\n⏸ Paused: ${pausedNames.join(", ")}` : "");
5716
6394
  }
6395
+ // #1716: the fallback carries the same background-work names as the
6396
+ // completion (inline: this block is executed verbatim by the #1737
6397
+ // extracted-completion harness, which resolves `this`-methods plus a
6398
+ // fixed context — no new bare module references here).
6399
+ if (backgroundWorkEndedNames.length) {
6400
+ text += `\n⚠️ Background work was running and was ended: ${backgroundWorkEndedNames.join(", ")}`;
6401
+ }
6402
+ // #1735: an outcome that already landed goes in this message too; one still owed is the watch's.
6403
+ if (cleanupOutcome && "render" in cleanupOutcome) {
6404
+ text += `\n\n⚠️ ${cleanupOutcome.render.warning}`
6405
+ + (cleanupOutcome.render.cleanupCommand ? `\n\`$ ${cleanupOutcome.render.cleanupCommand}\`` : "");
6406
+ }
5717
6407
  await this.sendFleetStartCompletionFallback(String(fleet.channel.group_id), text, generalThreadId != null ? String(generalThreadId) : undefined);
5718
6408
  }
5719
6409
  // After "fleet ready", so the notice is not the first thing people see of a restart.
@@ -5735,7 +6425,7 @@ export class FleetManager {
5735
6425
  // A SIGHUP may arrive after the PID/general is available but before the
5736
6426
  // rest of startup finishes. Replay one coalesced reload only after all
5737
6427
  // startup-owned lifecycle work and signal handlers are in place.
5738
- this.finishStartup();
6428
+ this.finishStartup(bootUpdateMarker);
5739
6429
  // Tell systemd we are ready only now. This used to fire right after the
5740
6430
  // generals started — before adapters, classic instances, topic creation and the
5741
6431
  // health server — so `systemctl start` returned success while the fleet was
@@ -6463,6 +7153,14 @@ export class FleetManager {
6463
7153
  });
6464
7154
  }
6465
7155
  }
7156
+ else if (msg.type === "runtime_switch_result") {
7157
+ // #1690: what our own runtime /model came to (the daemon answered claude's "Switch model?" or saw the result).
7158
+ const waiter = typeof msg.id === "string" ? this.runtimeSwitchWaiters.get(msg.id) : undefined;
7159
+ if (waiter) {
7160
+ this.runtimeSwitchWaiters.delete(msg.id);
7161
+ waiter(msg);
7162
+ }
7163
+ }
6466
7164
  else if (msg.type === "instance_state" || msg.type === "instance_state_response") {
6467
7165
  this.cacheInstanceExecutionState(name, msg);
6468
7166
  if (msg.type === "instance_state_response") {
@@ -6975,6 +7673,24 @@ export class FleetManager {
6975
7673
  });
6976
7674
  }
6977
7675
  async handleInboundMessage(msg) {
7676
+ msg = { ...msg, humanOriginalBody: msg.humanOriginalBody ?? msg.text, humanOrigin: msg.isBotMessage ? undefined : msg.humanOrigin && { ...msg.humanOrigin } };
7677
+ if (msg.isBotMessage)
7678
+ return withSpawnHoldReply(undefined, () => this.handleInboundMessageCore(msg));
7679
+ return withSpawnHoldReply(text => this.inboundNoticeAdapter(msg)?.sendText(msg.chatId, text, { threadId: msg.threadId }) ?? Promise.resolve(), () => this.handleInboundMessageCore(msg));
7680
+ }
7681
+ /**
7682
+ * #1752: the bot that speaks back about an inbound message — in a fleet topic the owning instance's adapter, never
7683
+ * the sibling that happened to win the dedup race; elsewhere (ClassicBot rooms, no routed instance) the receiver.
7684
+ */
7685
+ inboundNoticeAdapter(msg) {
7686
+ const id = this.governingAccess(msg, this.inboundRouteThreadId(msg)).adapterId ?? "";
7687
+ return this.worlds.get(id)?.adapter ?? this.adapters.get(id) ?? this.adapter;
7688
+ }
7689
+ async handleInboundMessageCore(msg) {
7690
+ // #1682: who wrote, for the web chat's mention pills (a person, never one of the fleet's bots: those are named by
7691
+ // the instance they belong to).
7692
+ if (!msg.isBotMessage && msg.userId && msg.username)
7693
+ this.seenUsers.record(msg.userId, msg.username);
6978
7694
  // Platform author identity is the authority. This runs before routing,
6979
7695
  // collab/commands/access/dedup and does not depend on any echo setting/ACK.
6980
7696
  const fleetBotIds = new Set();
@@ -7002,6 +7718,15 @@ export class FleetManager {
7002
7718
  return;
7003
7719
  }
7004
7720
  const threadId = this.inboundRouteThreadId(msg);
7721
+ // #1752: a post by one of this fleet's own bots is never input to a fleet topic's instance. They speak there only
7722
+ // for an instance (its replies, notices, warnings) — taking that as a user turn makes one bot's notice another
7723
+ // agent's "user" message, and a loop. Fleet /collab and open mode admit OTHER bots and webhooks; they never meant
7724
+ // our own. Exempt: ClassicBot rooms, where bots this fleet runs deliberately talk to each other by @mention
7725
+ // (same-channel multi-bot collab), and no-thread Telegram (classic candidates; Telegram bots never see each other).
7726
+ if (msg.userId && fleetBotIds.has(msg.userId) && threadId && !this.classicChannels?.hasChannel(threadId)) {
7727
+ 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");
7728
+ return;
7729
+ }
7005
7730
  const rootCommand = this.telegramRootCommandTarget(msg, threadId);
7006
7731
  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");
7007
7732
  // Ownership and per-adapter filtering both run before the dedup claim below.
@@ -7130,6 +7855,23 @@ export class FleetManager {
7130
7855
  }
7131
7856
  // Handle /start command
7132
7857
  if (text === "/start" || text.startsWith("/start ")) {
7858
+ // #1686: intercept /start web before the ClassicBot admission flow runs.
7859
+ // /start alone already opens the DM for future bot-to-user messages — no further action needed.
7860
+ const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
7861
+ // #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
7862
+ // (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
7863
+ const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
7864
+ const webStart = isPrivateChat && (requestedBackend === "web"
7865
+ || (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
7866
+ if (webStart) {
7867
+ if (requestedBackend === "web")
7868
+ this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
7869
+ // #1742: the /web menu right here; the code comes to this DM.
7870
+ if (admin)
7871
+ await this.startWebInDm(msg, msgAdapter ?? undefined);
7872
+ // Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
7873
+ return;
7874
+ }
7133
7875
  // All new starts, including explicit backend and later chooser callbacks,
7134
7876
  // share validateClassicStart. Existing channels are recognized first.
7135
7877
  const blocker = this.validateClassicStart(chatId, msg.userId, undefined, msg.adapterId);
@@ -7138,7 +7880,6 @@ export class FleetManager {
7138
7880
  return;
7139
7881
  }
7140
7882
  const channelName = msg.username || chatId;
7141
- const requestedBackend = text.slice("/start".length).trim().split(/\s+/, 1)[0] || undefined;
7142
7883
  if (requestedBackend) {
7143
7884
  // handleClassicStart binds the instance to this adapter authoritatively.
7144
7885
  const reply = await this.handleClassicStart(chatId, channelName, msg.userId, undefined, msg.adapterId, requestedBackend);
@@ -7206,6 +7947,21 @@ export class FleetManager {
7206
7947
  }
7207
7948
  return;
7208
7949
  }
7950
+ // #1806: /backend — channel admin, as /model. A ClassicBot agent is not switched by it (it says so).
7951
+ if (text === "/backend" || text.startsWith("/backend ") || text.startsWith("/backend@")) {
7952
+ if (!this.isModelAdmin(msg.userId, chatId, msg.adapterId)) {
7953
+ await msgAdapter?.sendText(chatId, t("permission.denied"));
7954
+ return;
7955
+ }
7956
+ const backendInstance = this.classicChannels.getInstanceByChannel(chatId, msg.adapterId);
7957
+ if (!backendInstance) {
7958
+ await msgAdapter?.sendText(chatId, t("classic.no_agent_start"));
7959
+ return;
7960
+ }
7961
+ if (msgAdapter)
7962
+ await this.runBackendCommand(backendInstance, text, msg.userId, chatId, msgAdapter, chatId, undefined, msg.adapterId);
7963
+ return;
7964
+ }
7209
7965
  // Handle /compact command (admin only)
7210
7966
  const classicCompact = parseCompactCommand(text);
7211
7967
  if (classicCompact) {
@@ -7391,7 +8147,7 @@ export class FleetManager {
7391
8147
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, generalInstance);
7392
8148
  const generalReactions = this.pendingReactionsMeta(generalInstance);
7393
8149
  try {
7394
- await this.deliverToInstance(generalInstance, {
8150
+ await this.deliverHumanToInstance(generalInstance, {
7395
8151
  type: "fleet_inbound",
7396
8152
  content: text,
7397
8153
  targetSession: generalInstance,
@@ -7412,7 +8168,7 @@ export class FleetManager {
7412
8168
  ...generalReactions.meta,
7413
8169
  ...extraMeta,
7414
8170
  },
7415
- });
8171
+ }, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
7416
8172
  generalReactions.consume();
7417
8173
  this.afterUserInboundDelivered(generalInstance, msg);
7418
8174
  this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
@@ -7492,7 +8248,7 @@ export class FleetManager {
7492
8248
  const { text, extraMeta } = await processAttachments(msg, inboundAdapter, this.logger, instanceName);
7493
8249
  const reactions = this.pendingReactionsMeta(instanceName);
7494
8250
  try {
7495
- await this.deliverToInstance(instanceName, {
8251
+ await this.deliverHumanToInstance(instanceName, {
7496
8252
  type: "fleet_inbound",
7497
8253
  content: text,
7498
8254
  targetSession: instanceName, // Channel messages → instance's own session
@@ -7511,7 +8267,7 @@ export class FleetManager {
7511
8267
  ...reactions.meta,
7512
8268
  ...extraMeta,
7513
8269
  },
7514
- });
8270
+ }, msg.humanOrigin, msg.humanOriginalBody ?? msg.text);
7515
8271
  // Only after the message actually went out. A failed delivery keeps the
7516
8272
  // reactions queued for the retry / the next message.
7517
8273
  reactions.consume();
@@ -7554,9 +8310,14 @@ export class FleetManager {
7554
8310
  if (Date.now() - lastWarn < 30 * 60_000)
7555
8311
  return;
7556
8312
  this.rateLimitWarnedAt.set(instanceName, Date.now());
7557
- const warnAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
7558
- if (warnAdapter && msg.chatId) {
7559
- warnAdapter.sendText(msg.chatId, warning, { threadId: msg.threadId ?? undefined }).catch(() => { });
8313
+ // #1752: the instance's own bot, in the instance's own topic. `msg.adapterId` is whichever sibling bot won the
8314
+ // inbound dedup race; a warning from that bot is a stranger's post in this topic (and, to the owning adapter, a
8315
+ // bot message it would take as input).
8316
+ const warnAdapter = this.getAdapterForInstance(instanceName);
8317
+ const dest = this.cancelButtonDestination(instanceName);
8318
+ const { chatId, threadId } = dest?.threadId ? dest : { chatId: msg.chatId, threadId: msg.threadId ?? undefined };
8319
+ if (warnAdapter && chatId) {
8320
+ warnAdapter.sendText(chatId, warning, { threadId }).catch(() => { });
7560
8321
  }
7561
8322
  }
7562
8323
  /** Handle outbound tool calls from a daemon instance */
@@ -7710,10 +8471,12 @@ export class FleetManager {
7710
8471
  respond(null, "No adapter available");
7711
8472
  return;
7712
8473
  }
8474
+ // Only channel-addressing schemas receive ClassicBot routing metadata.
8475
+ // Non-channel calls (notably strict delivery_status) retain their arguments.
7713
8476
  // For classic instances: force chat_id to channelId and clear thread_id
7714
8477
  // (daemon may have set chat_id to guild_id which is wrong for DC; TG may have set thread_id which causes 'thread not found')
7715
8478
  const classicChannelId = this.classicChannels?.getChannelIdByInstance(senderInstanceName ?? instanceName);
7716
- if (classicChannelId) {
8479
+ if (classicChannelId && usesChannelAddress(tool)) {
7717
8480
  args.chat_id = classicChannelId;
7718
8481
  // #1085: a Telegram forum ClassicBot replies in the topic it was asked
7719
8482
  // in. Any other thread_id (threadless Telegram, where it echoes the chat
@@ -7812,7 +8575,7 @@ export class FleetManager {
7812
8575
  // an agent cannot invent it to suppress the normal completion marker.
7813
8576
  if (!error && result != null && msg.statusOnly !== true) {
7814
8577
  try {
7815
- this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
8578
+ void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
7816
8579
  }
7817
8580
  catch (err) {
7818
8581
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -7865,6 +8628,7 @@ export class FleetManager {
7865
8628
  webOnlyPromptPlace() {
7866
8629
  return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
7867
8630
  }
8631
+ /** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
7868
8632
  afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
7869
8633
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
7870
8634
  // more work either. Split the difference: an instance that is clearly
@@ -7882,27 +8646,74 @@ export class FleetManager {
7882
8646
  this.reactDone(instanceName);
7883
8647
  const replyTo = this.lastInboundUser.get(instanceName) ?? "user";
7884
8648
  this.logger.info(`${instanceName} → ${replyTo}: ${(args.text ?? "").slice(0, 100)}`);
7885
- // Files the agent attached are shown in the web chat too: registered for fetching by id (the path
7886
- // already passed the reply tool's sendability check, and is resolved once more here), never by path.
7887
- const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
7888
- const attachments = replyFiles
7889
- .map(path => this.webFiles.registerServed({ path, instance: instanceName }))
7890
- .filter((f) => f !== null)
7891
- .map(publicAttachment);
7892
- // The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
7893
- // role that may get HTML preview cards. Never inferred from the sender name or the text.
7894
- this.emitSseEvent("message", {
7895
- instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
7896
- text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
7897
- ts: new Date().toISOString(),
7898
- ...(attachments.length ? { attachments } : {}),
7899
- ...(buttons ? { buttons } : {}), // #1266
7900
- });
7901
8649
  // Log bot reply to classic instance chat-log
7902
8650
  const isClassic = this.classicChannels?.getChannelIdByInstance(instanceName) !== undefined;
7903
8651
  if (isClassic) {
7904
8652
  ClassicChannelManager.logMessage(instanceName, "bot", args.text ?? "", new Date());
7905
8653
  }
8654
+ // Files the agent attached are shown in the web chat too, fetched by id, never by path. #1770: each is copied
8655
+ // first (web-reply-files.ts), and the copy is what the chat serves and keeps — so the message is shown once the
8656
+ // copies are done, and an instance's replies wait for the one before them: they reach the chat in order.
8657
+ const replyFiles = Array.isArray(args.files) ? args.files.filter((f) => typeof f === "string") : [];
8658
+ const ts = new Date().toISOString();
8659
+ const show = (attachments) => {
8660
+ // The one place a delivered agent reply reaches the web chat: the server marks it `agent` (#1306) — the only
8661
+ // role that may get HTML preview cards. Never inferred from the sender name or the text.
8662
+ this.emitSseEvent("message", {
8663
+ instance: instanceName, sender: senderSessionName ?? instanceName, role: "agent",
8664
+ text: (args.text ?? "").slice(0, WEB_CHAT_TEXT_MAX),
8665
+ ts,
8666
+ ...(attachments.length ? { attachments } : {}),
8667
+ ...(buttons ? { buttons } : {}), // #1266
8668
+ });
8669
+ };
8670
+ const before = this.webReplyOrder.get(instanceName);
8671
+ if (!replyFiles.length && !before) {
8672
+ show([]);
8673
+ return Promise.resolve();
8674
+ }
8675
+ const done = (before ?? Promise.resolve())
8676
+ .then(() => this.webReplyAttachments(instanceName, replyFiles))
8677
+ .catch((err) => { this.logger.warn({ err, instanceName }, "Reply files could not be prepared for the web chat"); return []; })
8678
+ .then((attachments) => { try {
8679
+ show(attachments);
8680
+ }
8681
+ catch (err) {
8682
+ this.logger.warn({ err, instanceName }, "Reply could not be shown in the web chat");
8683
+ } })
8684
+ .finally(() => { if (this.webReplyOrder.get(instanceName) === done)
8685
+ this.webReplyOrder.delete(instanceName); });
8686
+ this.webReplyOrder.set(instanceName, done);
8687
+ return done;
8688
+ }
8689
+ /**
8690
+ * #1770: a reply's files as the web chat shows them. Each is copied into the instance's web-files/ and served from
8691
+ * the copy. One the copy refuses (a symlink, not a regular file, over 50 MB, a state file, missing) is not shown;
8692
+ * one that could not be copied (I/O) is served from the agent's own path, as before — until the fleet restarts.
8693
+ */
8694
+ async webReplyAttachments(instanceName, paths) {
8695
+ const home = getAgendHome();
8696
+ const dir = webFilesDir(home, instanceName);
8697
+ const out = [];
8698
+ for (const path of paths) {
8699
+ const id = randomBytes(16).toString("hex");
8700
+ const got = dir ? await snapshotReplyFile(path, dir, id, { stateDir: home, maxDirBytes: this.webReplyMaxBytes }) : null;
8701
+ if (got && "evicted" in got && got.evicted.length)
8702
+ this.webFiles.dropServed(got.evicted);
8703
+ let file = null;
8704
+ if (got?.ok)
8705
+ file = this.webFiles.registerServed({ id, path: got.path, name: basename(path), instance: instanceName, snapshot: true });
8706
+ else if (got?.refused)
8707
+ this.logger.info({ instanceName, file: basename(path), reason: got.reason }, "Reply file not shown in the web chat");
8708
+ else {
8709
+ if (got)
8710
+ 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");
8711
+ file = this.webFiles.registerServed({ path, instance: instanceName });
8712
+ }
8713
+ if (file)
8714
+ out.push(publicAttachment(file));
8715
+ }
8716
+ return out;
7906
8717
  }
7907
8718
  // ===================== Scheduler =====================
7908
8719
  async handleScheduleTrigger(schedule, stableRunId = randomUUID(), retry) {
@@ -9301,7 +10112,9 @@ export class FleetManager {
9301
10112
  else
9302
10113
  await this.lifecycle.remove(name, authorization);
9303
10114
  // A later same-name registration owns its files and caches.
9304
- if (this.fleetConfig?.instances[name] || this.daemons.has(name))
10115
+ // Own keys only: an instance named "constructor" is removed as any other (its inherited namesake is not a
10116
+ // re-registration) — #1677 review.
10117
+ if (Object.hasOwn(this.fleetConfig?.instances ?? {}, name) || this.daemons.has(name))
9305
10118
  return;
9306
10119
  execution?.assert();
9307
10120
  // Clean up per-instance tracking maps so they don't grow unbounded
@@ -9313,6 +10126,13 @@ export class FleetManager {
9313
10126
  // must not be shown the old one's conversation, and deleted names must not pile up.
9314
10127
  this.webChatHistory.forget(name);
9315
10128
  this.webFiles.forget(name);
10129
+ // #1664: and it is nobody's favorite any more (every device sees that at its next status frame).
10130
+ try {
10131
+ this.webPrefs.forgetInstance(name);
10132
+ }
10133
+ catch (err) {
10134
+ this.logger.warn({ err: err.message, name }, "Dropping a removed instance from web favorites failed");
10135
+ }
9316
10136
  // Clean up statusline watcher + instance directory
9317
10137
  this.statuslineWatcher.unwatch(name);
9318
10138
  // instanceDirToClean and effectiveBackend are captured before lifecycle.remove
@@ -9502,6 +10322,190 @@ export class FleetManager {
9502
10322
  /** Keyed `<connection>\0<server id>`. */
9503
10323
  guildEmojiCache = new Map();
9504
10324
  guildEmojiFetches = new Map();
10325
+ // ── #1630: what stands for an instance in the web app, and Discord server emoji in web chat ──
10326
+ webImageCaches = null;
10327
+ webImageCache(kind) {
10328
+ const onError = (key, err) => this.logger.debug({ err: err?.message, key }, "web image fetch failed (kept what was cached)");
10329
+ this.webImageCaches ??= {
10330
+ avatars: new WebImageCache({ dir: join(this.dataDir, "cache", "avatars"), onError }),
10331
+ emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError }),
10332
+ };
10333
+ return this.webImageCaches[kind];
10334
+ }
10335
+ /**
10336
+ * instance → the connection that is its own bot: configured now (a connection removed from fleet.yaml is nobody's,
10337
+ * whatever an instance still names — #1635 review), not the primary, and answering for it alone.
10338
+ */
10339
+ ownBots() {
10340
+ const instances = this.fleetConfig?.instances ?? {};
10341
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
10342
+ const configured = new Set(channels.map(ch => ch.id ?? ch.type).filter((id) => typeof id === "string"));
10343
+ const assigned = (name) => { const c = this.getInstanceAdapterId(name); return c && configured.has(c) ? c : undefined; };
10344
+ const assignments = Object.keys(instances).map(n => [n, assigned(n)]);
10345
+ for (const room of this.classicChannels?.getAll() ?? []) {
10346
+ if (!Object.hasOwn(instances, room.instanceName))
10347
+ assignments.push([room.instanceName, assigned(room.instanceName)]);
10348
+ }
10349
+ return ownBotConnections(this.getPrimaryAdapterId(), assignments);
10350
+ }
10351
+ /**
10352
+ * The bot a connection is now: its running adapter and that bot's own user id. Everything cached for an own bot is
10353
+ * keyed by both, so a bot put in its place (a token replaced under the same connection id) never shows the old one's
10354
+ * face or colour, and the old one's late answers land under the old key (#1635 review). null while it has no id.
10355
+ */
10356
+ botOwner(connection) {
10357
+ const adapter = this.adapters.get(connection), id = adapter?.getBotUserId?.();
10358
+ return adapter && id ? { adapter, id } : null;
10359
+ }
10360
+ /** Still the same bot behind `connection` (after an await): the same adapter object and the same bot id. */
10361
+ botStillOwns(connection, owner) {
10362
+ return this.adapters.get(connection) === owner.adapter && owner.adapter.getBotUserId?.() === owner.id;
10363
+ }
10364
+ /** keyed `<connection>\0<bot id>\0<channel>`: the bot's colour there, refreshed at most daily (monotonic), one read at a time. */
10365
+ webAccents = new Map();
10366
+ webAccentReads = new Set();
10367
+ webAccent(connection, owner, channelId) {
10368
+ const adapter = owner.adapter;
10369
+ if (!adapter.botAccentColor)
10370
+ return null;
10371
+ const key = `${connection}\0${owner.id}\0${channelId ?? ""}`, now = performance.now(), cached = this.webAccents.get(key);
10372
+ if ((!cached || now >= cached.until) && !this.webAccentReads.has(key)) {
10373
+ this.webAccentReads.add(key);
10374
+ adapter.botAccentColor(channelId)
10375
+ // Keyed by the bot's id: a replaced bot's late answer lands under its own key, which the new bot never reads.
10376
+ .then(value => { this.webAccents.set(key, { value: accentColor(value), until: performance.now() + WEB_IMAGE_MAX_AGE_MS }); }, () => { this.webAccents.set(key, { value: cached?.value ?? null, until: performance.now() + WEB_IMAGE_RETRY_MS }); })
10377
+ .finally(() => this.webAccentReads.delete(key));
10378
+ }
10379
+ return cached?.value ?? null;
10380
+ }
10381
+ /** The cache key of an own bot's avatar, and its loader (which refuses an answer from a bot no longer there). */
10382
+ botAvatarSource(connection, owner) {
10383
+ return {
10384
+ key: `bot:${connection}:${owner.id}`,
10385
+ load: async () => {
10386
+ const got = await owner.adapter.fetchBotAvatar();
10387
+ if (!this.botStillOwns(connection, owner))
10388
+ throw new Error("the bot was replaced while its avatar was fetched");
10389
+ return got;
10390
+ },
10391
+ };
10392
+ }
10393
+ /** One instance's avatar and ring for the status frame. Synchronous: what is cached now; fetches run behind. */
10394
+ webIdentityImage(name, displayName, profileAvatar, own) {
10395
+ let profileVersion = null;
10396
+ if (profileAvatar && imageMimeForFile(profileAvatar)) {
10397
+ try {
10398
+ profileVersion = Math.floor(statSync(join(this.dataDir, "avatars", basename(profileAvatar))).mtimeMs).toString(36);
10399
+ }
10400
+ catch { /* removed */ }
10401
+ }
10402
+ const connection = own.get(name), owner = connection ? this.botOwner(connection) : null;
10403
+ const source = connection && owner?.adapter.fetchBotAvatar ? this.botAvatarSource(connection, owner) : null;
10404
+ const bot = source ? this.webImageCache("avatars").peek(source.key, source.load) : null;
10405
+ const cfg = this.ownInstanceConfig(name);
10406
+ const avatar = avatarFor({ name, displayName, profileVersion, bot: bot && connection ? { connection, version: bot.version } : null,
10407
+ personaEmoji: cfg?.status_emojis?.delivered });
10408
+ // The ring belongs to the bot's own face: only when that is what is shown.
10409
+ const accent = !profileVersion && bot && connection && owner
10410
+ ? this.webAccent(connection, owner, cfg ? (cfg.topic_id == null ? undefined : String(cfg.topic_id)) : this.classicChannels?.getChannelIdByInstance(name))
10411
+ : null;
10412
+ return accent ? { avatar, accent } : { avatar };
10413
+ }
10414
+ /** GET /ui/img/bot|avatar/<key>: what is cached, never a fetch. Only an own-bot connection, or a known instance's upload. */
10415
+ webImage(kind, key) {
10416
+ if (kind === "bot") {
10417
+ if (![...this.ownBots().values()].includes(key))
10418
+ return null;
10419
+ const owner = this.botOwner(key);
10420
+ return owner ? this.webImageCache("avatars").read(this.botAvatarSource(key, owner).key) : null;
10421
+ }
10422
+ const known = Object.hasOwn(this.fleetConfig?.instances ?? {}, key) || this.classicChannels?.getChannelIdByInstance(key) !== undefined;
10423
+ if (!known)
10424
+ return null;
10425
+ const file = profileIdentities(this.dataDir).get(key)?.avatar_path;
10426
+ const mime = file ? imageMimeForFile(file) : null;
10427
+ if (!file || !mime)
10428
+ return null;
10429
+ try {
10430
+ const path = join(this.dataDir, "avatars", basename(file));
10431
+ return { bytes: readFileSync(path), mime, version: Math.floor(statSync(path).mtimeMs).toString(36) };
10432
+ }
10433
+ catch {
10434
+ return null;
10435
+ }
10436
+ }
10437
+ /**
10438
+ * #1682 GET /ui/mentions: names for the mention ids a page found in what it rendered — never a Discord request; in order:
10439
+ * the fleet's bots (an instance's own bot is that instance's label: fleet.yaml, and the View profiles read from
10440
+ * profiles.db, as the status frame does), the people seen on inbound messages, and the Discord adapters' caches
10441
+ * (members, users, roles, channels).
10442
+ */
10443
+ webMentionNames(query) {
10444
+ const bots = (q) => {
10445
+ const u = Object.create(null);
10446
+ if (!q.u.length)
10447
+ return { u };
10448
+ let own = new Map();
10449
+ try {
10450
+ own = this.ownBots();
10451
+ }
10452
+ catch { /* none known */ }
10453
+ const profiles = profileIdentities(this.dataDir);
10454
+ const rooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
10455
+ for (const [instance, connection] of own) {
10456
+ const id = this.adapters.get(connection)?.getBotUserId?.();
10457
+ if (!id || !q.u.includes(id))
10458
+ continue;
10459
+ const cfg = Object.hasOwn(this.fleetConfig?.instances ?? {}, instance) ? this.fleetConfig.instances[instance] : undefined;
10460
+ u[id] = resolveInstanceIdentity({ cfg, classic: rooms.get(instance), profile: profiles.get(instance) }).display_name || instance;
10461
+ }
10462
+ return { u };
10463
+ };
10464
+ const seen = (q) => {
10465
+ const u = Object.create(null);
10466
+ for (const id of q.u) {
10467
+ const name = this.seenUsers.get(id);
10468
+ if (name)
10469
+ u[id] = name;
10470
+ }
10471
+ return { u };
10472
+ };
10473
+ const caches = [...this.adapters.values()].filter(a => typeof a.mentionNames === "function").map(a => (q) => a.mentionNames(q));
10474
+ return resolveMentions(query, [bots, seen, ...caches]);
10475
+ }
10476
+ /** GET /ui/emojis: the custom emoji of every server the fleet's Discord bots are in (the Settings picker's cache). */
10477
+ async webEmojiList() {
10478
+ const adopted = [];
10479
+ for (const [id, world] of [...this.worlds]) {
10480
+ if (world.type !== "discord")
10481
+ continue;
10482
+ const listed = await this.listGuildEmojis(id);
10483
+ if (listed.ok)
10484
+ adopted.push({ id, world, emojis: listed.emojis });
10485
+ }
10486
+ // After the LAST await (#1635 review r2): only the lists whose world is still the one that was read — a connection
10487
+ // removed or replaced while a later list was awaited takes its servers along, whenever it went.
10488
+ const out = new Map();
10489
+ for (const a of adopted) {
10490
+ if (this.worlds.get(a.id) !== a.world)
10491
+ continue;
10492
+ for (const e of a.emojis)
10493
+ if (!out.has(e.id))
10494
+ out.set(e.id, { id: e.id, name: e.name, animated: e.animated });
10495
+ }
10496
+ return [...out.values()];
10497
+ }
10498
+ /** GET /ui/img/emoji/<id>: only an emoji of those servers; fetched once from Discord's CDN, then from the host cache. */
10499
+ async webEmoji(id) {
10500
+ if (!/^\d{15,25}$/.test(id))
10501
+ return null;
10502
+ const emoji = (await this.webEmojiList()).find(e => e.id === id);
10503
+ if (!emoji)
10504
+ return null;
10505
+ const image = await this.webImageCache("emoji").get(`emoji:${id}`, () => downloadStickerImage(`https://cdn.discordapp.com/emojis/${id}.${emoji.animated ? "gif" : "png"}?size=64`, { maxBytes: WEB_IMAGE_MAX_BYTES }));
10506
+ // Still an emoji of the fleet's servers once it is here (a connection removed meanwhile takes its servers along).
10507
+ return image && (await this.webEmojiList()).some(e => e.id === id) ? image : null;
10508
+ }
9505
10509
  /**
9506
10510
  * What `instanceName` may pick as its persona emoji (the `list_emojis`
9507
10511
  * tool): its current status set with where each value comes from, the
@@ -10693,7 +11697,8 @@ export class FleetManager {
10693
11697
  // on the sign-in DM, armed by the first tap). A double tap, or a late second one from an old keyboard, lands
10694
11698
  // here with the spent nonce — and collapsing would wipe the message's text and the live buttons. The clicker is
10695
11699
  // told; the message is left alone.
10696
- if ([...this.pendingNonceButtons.values()].some(e => e.prefix === prefix && e.chatId === data.chatId && e.messageId === data.messageId)) {
11700
+ // #1617: and of the same bot — two bots' DM message ids can collide; another bot's live prompt says nothing here.
11701
+ if ([...this.pendingNonceButtons.values()].some(e => e.prefix === prefix && e.adapterId === callbackAdapterId && e.chatId === data.chatId && e.messageId === data.messageId)) {
10697
11702
  data.ack?.(t("buttons.stale_notice"));
10698
11703
  return "consumed";
10699
11704
  }
@@ -10846,6 +11851,8 @@ export class FleetManager {
10846
11851
  return true;
10847
11852
  if (await this.handleEffortSelection(data, adapterId))
10848
11853
  return true;
11854
+ if (await this.handleBackendSelection(data, adapterId))
11855
+ return true;
10849
11856
  if (await this.handleHangPrompt(data, adapterId, adapter))
10850
11857
  return true;
10851
11858
  if (data.callbackData.startsWith("cancel:")) {
@@ -10911,6 +11918,48 @@ export class FleetManager {
10911
11918
  });
10912
11919
  }
10913
11920
  cacheService = null;
11921
+ /**
11922
+ * #1699: the credential profile an instance's CLI signs in with on `backend` — fleet.yaml's (with its defaults), or a
11923
+ * ClassicBot room's launched or would-be login when the room runs that backend. Null: the backend's default login
11924
+ * (also for a backend that has no profiles, where the option is ignored).
11925
+ */
11926
+ credentialProfileOf(name, backend) {
11927
+ if (!credentialHomeSpec(backend))
11928
+ return null;
11929
+ const cfg = this.fleetConfig?.instances?.[name];
11930
+ if (cfg)
11931
+ return instanceCredentialProfile(cfg, this.fleetConfig?.defaults, backend);
11932
+ const launched = this.daemons.get(name)?.getConfigSnapshot?.();
11933
+ if (launched)
11934
+ return (launched.backend ?? "claude-code") === backend ? profileName(profileOf(launched.backend_options, backend)) : null;
11935
+ if (!this.classicChannels || this.classicChannels.getBackendByInstance(name, this.fleetConfig?.defaults?.backend) !== backend)
11936
+ return null;
11937
+ return profileName(this.classicChannels.getCredentialProfileByInstance(name, this.fleetConfig?.defaults));
11938
+ }
11939
+ /**
11940
+ * #1699: the claude config dir an instance's CLI uses — its credential profile's, else exactly as before
11941
+ * (CLAUDE_CONFIG_DIR or ~/.claude).
11942
+ */
11943
+ claudeConfigDirOf(name) {
11944
+ const profile = this.credentialProfileOf(name, "claude-code");
11945
+ return claudeConfigDirFor(profile ? credentialProfileHome(getAgendHome(), "claude-code", profile) : null);
11946
+ }
11947
+ /** The instances the cache analysis reads: fleet.yaml's, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend). */
11948
+ cacheInstances() {
11949
+ const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
11950
+ name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
11951
+ claudeConfigDir: this.claudeConfigDirOf(name),
11952
+ }));
11953
+ const taken = new Set(fleet.map(i => i.name));
11954
+ const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
11955
+ name: ch.instanceName,
11956
+ backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
11957
+ workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
11958
+ ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
11959
+ claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
11960
+ }));
11961
+ return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
11962
+ }
10914
11963
  /**
10915
11964
  * #1468: the prompt-cache expiry analysis for the web, per instance, over `window`. Read from each instance's
10916
11965
  * ledger as it is now; the first request starts the bounded, persisted catch-up over the transcripts
@@ -10919,20 +11968,7 @@ export class FleetManager {
10919
11968
  cacheReport(window) {
10920
11969
  const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
10921
11970
  this.cacheService ??= new CacheService({
10922
- // fleet.yaml's instances, then the ClassicBot rooms (their workspace under the AgEnD home, their own backend).
10923
- instances: () => {
10924
- const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
10925
- name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
10926
- }));
10927
- const taken = new Set(fleet.map(i => i.name));
10928
- const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
10929
- name: ch.instanceName,
10930
- backend: this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code",
10931
- workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
10932
- ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
10933
- }));
10934
- return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
10935
- },
11971
+ instances: () => this.cacheInstances(),
10936
11972
  claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
10937
11973
  claudeKey: claudeProjectKey,
10938
11974
  codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
@@ -11119,7 +12155,9 @@ export class FleetManager {
11119
12155
  }
11120
12156
  async retireNonceButtons(pending, messageId, text,
11121
12157
  /** The click's own edit of a private prompt (AdapterCallbackData.editClicked); preferred over the prompt's. */
11122
- editClicked) {
12158
+ editClicked,
12159
+ /** #1686: URL buttons to attach when editing the outcome (Telegram only; ignored when editClicked or retire is set). */
12160
+ urlButtons) {
11123
12161
  this.webPromptGone(pending, text);
11124
12162
  try {
11125
12163
  const retire = editClicked ?? pending.retire;
@@ -11129,7 +12167,12 @@ export class FleetManager {
11129
12167
  }
11130
12168
  if (!pending.adapter.editMessageRemoveButtons)
11131
12169
  throw new Error("adapter cannot remove prompt buttons");
11132
- await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
12170
+ if (urlButtons) {
12171
+ await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId, urlButtons);
12172
+ }
12173
+ else {
12174
+ await pending.adapter.editMessageRemoveButtons(pending.chatId, messageId, text, pending.threadId);
12175
+ }
11133
12176
  }
11134
12177
  catch (err) {
11135
12178
  // The action was already atomically consumed. An edit failure must not
@@ -11137,7 +12180,8 @@ export class FleetManager {
11137
12180
  // only place some outcomes are told (cancel, later, a resend's result),
11138
12181
  // so the text goes out as a message instead (#1133).
11139
12182
  this.logger.warn({ err, instanceName: pending.instanceName }, "Failed to retire prompt buttons; posting the outcome instead");
11140
- await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId })
12183
+ // #1742: with its link button, if it had one — the outcome may be the only way to the DM.
12184
+ await pending.adapter.sendText(pending.chatId, text, { threadId: pending.threadId, ...(urlButtons ? { urlButtons } : {}) })
11141
12185
  .catch(sendErr => this.logger.warn({ err: sendErr, instanceName: pending.instanceName }, "Could not post a prompt's outcome"));
11142
12186
  }
11143
12187
  }
@@ -11432,6 +12476,8 @@ export class FleetManager {
11432
12476
  }
11433
12477
  };
11434
12478
  }
12479
+ /** #1806: the web's /backend confirmations — one per question, used once, for the arguments and backend asked about. */
12480
+ webBackendTokens = new Map();
11435
12481
  /** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
11436
12482
  webClearTokens = new Map();
11437
12483
  /** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
@@ -11440,7 +12486,7 @@ export class FleetManager {
11440
12486
  const topicId = this.fleetConfig?.instances[instance]?.topic_id;
11441
12487
  return {
11442
12488
  chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(), username: "web-user", userId: "web-user",
11443
- threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web",
12489
+ threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
11444
12490
  };
11445
12491
  };
11446
12492
  const choicesFromCache = (instance) => {
@@ -11470,9 +12516,44 @@ export class FleetManager {
11470
12516
  const current = this.resolveInstanceEffort(instance).effort;
11471
12517
  return { current, options: levels.map(l => ({ id: l, label: this.effortChoiceLabel(l, current) })) };
11472
12518
  },
12519
+ backendStep: async (instance, args) => {
12520
+ const refusal = this.backendCommandRefusal(instance);
12521
+ if (refusal)
12522
+ return { error: refusal };
12523
+ // The cached model lists only, as the web's /model: never a live probe on this path.
12524
+ const step = await backendStep(this.backendCommandDeps(instance, { live: false }), instance, backendArgs(args));
12525
+ if (step.kind === "refuse")
12526
+ return step.noop ? { text: step.text } : { error: step.text };
12527
+ if (step.kind === "choose") {
12528
+ const id = (token) => joinBackendArgs([...step.args, token]);
12529
+ 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 })) } };
12530
+ }
12531
+ const now = performance.now();
12532
+ for (const [k, v] of this.webBackendTokens)
12533
+ if (v.deadline < now)
12534
+ this.webBackendTokens.delete(k);
12535
+ const token = randomBytes(16).toString("hex");
12536
+ this.webBackendTokens.set(token, {
12537
+ instance, asked: joinBackendArgs(backendArgs(args)), args: step.args, from: step.from,
12538
+ target: this.fleetConfig.instances[instance], deadline: now + CLASSIC_BACKEND_SELECTION_TIMEOUT_MS,
12539
+ });
12540
+ return { confirm: { token, message: step.text } };
12541
+ },
12542
+ backendConfirm: async (instance, args, token) => {
12543
+ const entry = this.webBackendTokens.get(token);
12544
+ this.webBackendTokens.delete(token); // used once, whatever happens next
12545
+ if (!entry || entry.instance !== instance || entry.asked !== joinBackendArgs(backendArgs(args)) || performance.now() > entry.deadline) {
12546
+ return { status: 409, text: t("backend.selection_expired") };
12547
+ }
12548
+ // The instance it was asked about: one deleted and created again under the name, stopped or not, is another.
12549
+ const stillCurrent = () => this.backendTargetIs(instance, entry.target);
12550
+ if (!stillCurrent())
12551
+ return { status: 409, text: t("menu.click_stale") };
12552
+ return { status: 200, text: await this.applyBackendSwitch(instance, entry.args, entry.from, stillCurrent) };
12553
+ },
11473
12554
  cancel: (instance) => this.cancelInstance(instance),
11474
- steer: (instance, text) => this.topicCommands.sendSteer(instance, text, webMeta(instance)),
11475
- btw: (instance, text) => this.topicCommands.sendBtw(instance, text, webMeta(instance)),
12555
+ steer: (instance, text, originalBody) => this.topicCommands.sendSteer(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
12556
+ btw: (instance, text, originalBody) => this.topicCommands.sendBtw(instance, text, { ...webMeta(instance), humanOriginalBody: originalBody }),
11476
12557
  pauseWake: (instance, action) => this.topicCommands.runPauseWake(instance, action),
11477
12558
  save: (instance, filename) => this.topicCommands.sendSave(instance, filename),
11478
12559
  clearAsk: (instance) => {
@@ -11743,9 +12824,12 @@ export class FleetManager {
11743
12824
  * Backends with no remote login flow (opencode logs in from a terminal) get
11744
12825
  * no button — the alert's own wording already tells them what to run.
11745
12826
  */
11746
- async offerBackendLogin(targetInstance, backend) {
12827
+ async offerBackendLogin(targetInstance, backend, profile) {
11747
12828
  if (!LOGIN_FLOWS[backend])
11748
12829
  return;
12830
+ if (profile && !CHAT_PROFILE_LOGIN_BACKENDS.has(backend))
12831
+ return; // that profile signs in on the host
12832
+ const shown = profile ? t("login.profile_label", backend, profile) : backend;
11749
12833
  const adapterId = this.getInstanceAdapterId(targetInstance);
11750
12834
  const adapter = this.getAdapterForInstance(targetInstance);
11751
12835
  const chatId = this.getGroupIdForInstance(targetInstance);
@@ -11763,9 +12847,10 @@ export class FleetManager {
11763
12847
  adapterId,
11764
12848
  chatId,
11765
12849
  threadId,
11766
- message: t("login.offer", backend),
11767
- choices: [{ action: backend, label: t("login.offer_action", backend) }],
12850
+ message: t("login.offer", shown),
12851
+ choices: [{ action: backend, label: t("login.offer_action", shown) }],
11768
12852
  expiredText: t("buttons.stale"),
12853
+ ...(profile ? { extra: { loginProfile: profile } } : {}),
11769
12854
  });
11770
12855
  }
11771
12856
  /** Consume a General assist button exactly once. */
@@ -12578,7 +13663,7 @@ export class FleetManager {
12578
13663
  fleetConfig: () => this.fleetConfig,
12579
13664
  isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
12580
13665
  eventLog: () => this.eventLog,
12581
- recoverBackendInstances: backend => this.recoverBackendInstances(backend),
13666
+ recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
12582
13667
  claimWindow: backend => this.loginWindow.tryClaim("web", backend),
12583
13668
  releaseWindow: claim => { this.loginWindow.release(claim); },
12584
13669
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
@@ -12587,11 +13672,12 @@ export class FleetManager {
12587
13672
  reserveTunnel: owner => this.getTunnelLane().reserve("login", owner),
12588
13673
  // Throws when the prompt cannot be posted: the controller tells the
12589
13674
  // user so instead of leaving "Starting…" as the last word (#1133).
12590
- postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText }) => {
13675
+ postButtons: async ({ prefix, instanceName, chat, message, choices, expiredText, profile }) => {
12591
13676
  await this.postNonceButtonPromptOrThrow({
12592
13677
  prefix, alertType: "login", instanceName,
12593
13678
  adapter: chat.adapter, adapterId: chat.adapterId, chatId: chat.chatId, threadId: chat.threadId,
12594
13679
  message, choices, expiredText,
13680
+ ...(profile ? { extra: { loginProfile: profile } } : {}),
12595
13681
  });
12596
13682
  },
12597
13683
  });
@@ -12805,10 +13891,20 @@ export class FleetManager {
12805
13891
  // A removed backend (#1280) is neither installed nor signed into: say what replaces it.
12806
13892
  if (isRemovedBackend(wanted))
12807
13893
  return removedBackendMessage(wanted);
13894
+ // #1699 part 2: a profile is admitted before anything is installed for it — a name that is not one, or a backend
13895
+ // whose profiles sign in on the host, is refused here, not after an install it did not need.
13896
+ if (opts.profile !== undefined) {
13897
+ const admitted = admitProfileLogin(wanted, opts.profile);
13898
+ if ("refusal" in admitted)
13899
+ return admitted.refusal;
13900
+ opts = { ...opts, profile: admitted.profile };
13901
+ }
12808
13902
  if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
12809
13903
  const flow = LOGIN_FLOWS[wanted];
12810
13904
  this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
12811
- return this.startInstallSession(wanted, chat);
13905
+ return opts.profile !== undefined
13906
+ ? this.startInstallSession(wanted, chat, { profile: opts.profile })
13907
+ : this.startInstallSession(wanted, chat);
12812
13908
  }
12813
13909
  this.recordLoginFlow(wanted, "login", chat.userId);
12814
13910
  return this.launchSignIn(backendArg, chat, opts);
@@ -12897,7 +13993,9 @@ export class FleetManager {
12897
13993
  * cancelled: they are still coming back, and are reported as pending so the
12898
13994
  * message can say so instead of implying failure.
12899
13995
  */
12900
- async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS) {
13996
+ async recoverBackendInstances(backend, deadlineMs = POST_LOGIN_RECOVERY_DEADLINE_MS,
13997
+ /** #1699 part 2: the login just signed in — a profile, or null for the backend's default one. */
13998
+ profile = null) {
12901
13999
  const woken = [];
12902
14000
  const restarted = [];
12903
14001
  const pending = [];
@@ -12905,6 +14003,9 @@ export class FleetManager {
12905
14003
  for (const name of this.configuredBackendInstanceNames()) {
12906
14004
  if (this.backendNameOf(name) !== backend)
12907
14005
  continue;
14006
+ // Only the instances on the login that changed: another profile's (or the default's) credentials did not.
14007
+ if (this.credentialProfileOf(name, backend) !== profile)
14008
+ continue;
12908
14009
  const status = this.getInstanceStatus(name);
12909
14010
  if (status !== "paused" && status !== "running")
12910
14011
  continue;
@@ -12942,14 +14043,16 @@ export class FleetManager {
12942
14043
  if (claimed === "consumed")
12943
14044
  return true;
12944
14045
  const { entry, action: backend } = claimed;
12945
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
12946
- const text = await this.startLoginSession(backend, {
14046
+ const profile = entry.loginProfile;
14047
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", profile ? t("login.profile_label", backend, profile) : backend));
14048
+ const chat = {
12947
14049
  adapter: entry.adapter,
12948
14050
  adapterId: entry.adapterId,
12949
14051
  chatId: entry.chatId,
12950
14052
  threadId: entry.threadId,
12951
14053
  userId: data.userId,
12952
- });
14054
+ };
14055
+ const text = profile ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
12953
14056
  if (text)
12954
14057
  await this.postPromptOutcome(entry, text);
12955
14058
  return true;
@@ -12963,11 +14066,13 @@ export class FleetManager {
12963
14066
  return true;
12964
14067
  const { entry, action } = claimed;
12965
14068
  const backend = entry.instanceName;
14069
+ const profile = entry.loginProfile;
14070
+ const shown = profile ? t("login.profile_label", backend, profile) : backend;
12966
14071
  if (action === "cancel") {
12967
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", backend));
14072
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.cancelled", shown));
12968
14073
  return true;
12969
14074
  }
12970
- await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", backend));
14075
+ await this.retireNonceButtons(entry, entry.messageId ?? data.messageId, t("login.starting_backend", shown));
12971
14076
  const text = await this.startLoginSession(backend, {
12972
14077
  adapter: entry.adapter,
12973
14078
  adapterId: entry.adapterId,
@@ -12979,6 +14084,7 @@ export class FleetManager {
12979
14084
  tokenPresent: action === "go-relogin" || action === "go-relogin-tunnel",
12980
14085
  // The consent is the button itself: only the explicit "Open public link" actions carry it.
12981
14086
  tunnel: action === "go-tunnel" || action === "go-relogin-tunnel",
14087
+ ...(profile ? { profile } : {}),
12982
14088
  });
12983
14089
  if (text)
12984
14090
  await this.postPromptOutcome(entry, text);
@@ -13006,7 +14112,7 @@ export class FleetManager {
13006
14112
  /** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
13007
14113
  installHandoff = null;
13008
14114
  /** Start a CLI install session. Caller enforces admin. */
13009
- async startInstallSession(backendArg, chat) {
14115
+ async startInstallSession(backendArg, chat, signIn = {}) {
13010
14116
  const backend = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
13011
14117
  const info = BACKEND_INSTALLATION_INFO[backend];
13012
14118
  if (!info)
@@ -13090,7 +14196,8 @@ export class FleetManager {
13090
14196
  // without a click.
13091
14197
  // launchSignIn, not startLoginSession: if the new binary were still
13092
14198
  // not visible, startLoginSession would route straight back to an install.
13093
- const next = await this.launchSignIn(backend, chat, {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
14199
+ // A `/login <backend> <profile>` that had to install first signs that profile in, not the default login.
14200
+ const next = await this.launchSignIn(backend, chat, signIn.profile !== undefined ? { profile: signIn.profile } : {}).catch((err) => t("login.failed", backend, err?.message ?? String(err)));
13094
14201
  if (next) {
13095
14202
  await chat.adapter.sendText(chat.chatId, next, { threadId: chat.threadId })
13096
14203
  .catch(err => this.logger.warn({ err, backend }, "Could not post the sign-in step after an install"));
@@ -13198,8 +14305,16 @@ export class FleetManager {
13198
14305
  return;
13199
14306
  }
13200
14307
  const backend = String(data.options?.backend ?? "").trim();
14308
+ // #1699 part 2: a supplied profile is never dropped — a blank one is refused by the admission like any bad name,
14309
+ // and one without a backend is refused rather than becoming the default login's picker.
14310
+ const profileSupplied = data.options?.profile !== undefined && data.options?.profile !== null;
14311
+ const profile = profileSupplied ? String(data.options?.profile).trim() : undefined;
14312
+ if (profileSupplied && !backend) {
14313
+ await data.respond(t("login.profile_needs_backend"));
14314
+ return;
14315
+ }
13201
14316
  if (backend) {
13202
- const text = await this.startLoginSession(backend, chat);
14317
+ const text = profile !== undefined ? await this.startLoginSession(backend, chat, { profile }) : await this.startLoginSession(backend, chat);
13203
14318
  await data.respond(text ?? t("login.confirm_posted"));
13204
14319
  return;
13205
14320
  }
@@ -13234,16 +14349,26 @@ export class FleetManager {
13234
14349
  try {
13235
14350
  const store = new WebChatDiskStore({ home: getAgendHome(), logger: this.logger, ownerOf: (name) => this.webChatOwner(name) });
13236
14351
  const names = new Set([...Object.keys(fleet.instances ?? {}), ...(this.classicChannels?.getAll().map(c => c.instanceName) ?? [])]);
13237
- // A kept message's files are served again under their ids only from the instance's own inbox, by the ledger's
13238
- // checks (web-upload.ts restoreServed); they leave the ledger with their message.
14352
+ // A kept message's files are served again under their ids only from the instance's own inbox or (#1770) its reply
14353
+ // snapshots, by the ledger's checks (web-upload.ts restoreServed); they leave the ledger with their message.
13239
14354
  this.webChatHistory.setFiles({
13240
14355
  storablePath: (id) => this.webFiles.storablePath(id),
13241
- restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox")),
14356
+ restore: (entry, instance) => this.webFiles.restoreServed(entry, instance, join(getAgendHome(), "workspaces", instance, "inbox"), webFilesDir(getAgendHome(), instance) ?? undefined),
13242
14357
  drop: (ids) => this.webFiles.dropServed(ids),
13243
14358
  });
13244
14359
  let restored = 0;
13245
14360
  for (const name of names)
13246
14361
  restored += this.webChatHistory.restore(name, store.load(name));
14362
+ // #1770: before any instance replies, the snapshots no restored message holds (it left the history while the
14363
+ // fleet was down, or a copy stopped midway) are removed.
14364
+ let swept = 0;
14365
+ for (const name of names) {
14366
+ const dir = webFilesDir(getAgendHome(), name);
14367
+ if (dir)
14368
+ swept += sweepReplySnapshots(dir, this.webFiles.heldSnapshots(name));
14369
+ }
14370
+ if (swept)
14371
+ this.logger.info({ swept }, "Removed web reply snapshots no message holds");
13247
14372
  this.webChatStore = store;
13248
14373
  this.webChatHistory.setStore(store);
13249
14374
  if (restored)
@@ -14034,12 +15159,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14034
15159
  const reactions = this.pendingReactionsMeta(instanceName);
14035
15160
  Object.assign(meta, reactions.meta);
14036
15161
  try {
14037
- await this.deliverToInstance(instanceName, {
15162
+ await this.deliverHumanToInstance(instanceName, {
14038
15163
  type: "fleet_inbound",
14039
15164
  content: fullText,
14040
15165
  targetSession: instanceName,
14041
15166
  meta,
14042
- });
15167
+ }, msg.humanOrigin, msg.humanOriginalBody ?? text);
14043
15168
  reactions.consume();
14044
15169
  }
14045
15170
  catch (err) {
@@ -14056,6 +15181,50 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14056
15181
  });
14057
15182
  }
14058
15183
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
15184
+ /** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
15185
+ runtimeSwitchWaiters = new Map();
15186
+ /**
15187
+ * #1690: type `/model <model>` and wait for what it came to: the daemon answers claude's "Switch model?" for us (Yes
15188
+ * when it names this model, No otherwise) or sees claude's own result. Null when it could not be sent; a timeout is
15189
+ * "not confirmed".
15190
+ */
15191
+ runRuntimeModelSwitch(instanceName, model) {
15192
+ const ipc = this.instanceIpcClients.get(instanceName);
15193
+ if (!ipc)
15194
+ return Promise.resolve({ outcome: "not_sent" });
15195
+ const id = `switch-${randomUUID()}`;
15196
+ return new Promise(resolve => {
15197
+ const timer = setTimeout(() => { this.runtimeSwitchWaiters.delete(id); resolve({ outcome: "not_confirmed" }); }, RUNTIME_SWITCH_RESULT_MS);
15198
+ timer.unref?.();
15199
+ this.runtimeSwitchWaiters.set(id, result => { clearTimeout(timer); resolve(result); });
15200
+ const sent = ipc.send({ type: "raw_paste", content: `/model ${model}`, runtime_switch: { id, kind: "model", requested: model } });
15201
+ if (!sent) {
15202
+ clearTimeout(timer);
15203
+ this.runtimeSwitchWaiters.delete(id);
15204
+ resolve({ outcome: "not_sent" });
15205
+ return;
15206
+ }
15207
+ this.logger.info({ instanceName, model }, "Runtime model switch sent");
15208
+ });
15209
+ }
15210
+ /**
15211
+ * #1690: the names claude-code is known to accept, for checking `/model <name>` BEFORE it is typed — claude itself
15212
+ * accepts any string ("Set model to opsu"), and its "Switch model?" then names the typo. Static aliases (with [1m]),
15213
+ * the cached account catalog, the configured model; without a cached catalog, any `claude-…` id too (cannot check).
15214
+ */
15215
+ claudeModelKnown(instanceName, model,
15216
+ /** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
15217
+ configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
15218
+ const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
15219
+ const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
15220
+ const lower = model.trim().toLowerCase();
15221
+ const bare = lower.replace(/\[1m\]$/, "");
15222
+ const known = aliases.some(a => a.toLowerCase() === bare)
15223
+ || api.some(id => id.toLowerCase() === lower || `${id.toLowerCase()}[1m]` === lower)
15224
+ || (typeof configured === "string" && configured.toLowerCase() === lower)
15225
+ || (api.length === 0 && /^claude-[a-z0-9][a-z0-9.-]*(\[1m\])?$/.test(lower));
15226
+ return { known, names: [...aliases, ...api.filter(id => !id.endsWith("[1m]")).slice(0, 12)] };
15227
+ }
14059
15228
  pasteRawToClassicInstance(instanceName, text) {
14060
15229
  const ipc = this.instanceIpcClients.get(instanceName);
14061
15230
  if (!ipc) {
@@ -14655,7 +15824,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14655
15824
  try {
14656
15825
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
14657
15826
  result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
14658
- ? await this.applyEffort(pending.instanceName, level) : t("menu.click_stale");
15827
+ ? await withSpawnHoldReply(async (text) => {
15828
+ if (pending.adapter && pending.adapterChatId) {
15829
+ if (progressMsgId)
15830
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
15831
+ else
15832
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
15833
+ }
15834
+ else
15835
+ await pending.respond(text);
15836
+ }, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
14659
15837
  }
14660
15838
  catch (err) {
14661
15839
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
@@ -14980,7 +16158,16 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
14980
16158
  try {
14981
16159
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
14982
16160
  result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
14983
- ? await this.applyModel(pending.instanceName, model) : t("menu.click_stale");
16161
+ ? await withSpawnHoldReply(async (text) => {
16162
+ if (pending.adapter && pending.adapterChatId) {
16163
+ if (progressMsgId)
16164
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
16165
+ else
16166
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
16167
+ }
16168
+ else
16169
+ await pending.respond(text);
16170
+ }, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
14984
16171
  }
14985
16172
  catch (err) {
14986
16173
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
@@ -15114,21 +16301,34 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15114
16301
  }
15115
16302
  catch { /* default restart */ }
15116
16303
  const warn = isModelCompatible(backendName, model) ? "" : t("model.pattern_warning", model, backendName);
16304
+ // #1690: claude takes any name; a mistyped one would be "switched" to and fail the next turn. Checked first —
16305
+ // nothing is typed or saved for an unknown one.
16306
+ if (backendName === "claude-code") {
16307
+ const check = this.claudeModelKnown(instanceName, model);
16308
+ if (!check.known)
16309
+ return t("model.unknown_name", model, check.names.join(", "));
16310
+ }
15117
16311
  if (strategy === "runtime" && !this.instanceIpcClients.get(instanceName)) {
15118
16312
  return `${warn}${t("effort.not_running", instanceName)}`;
15119
16313
  }
16314
+ // #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
16315
+ if (strategy === "runtime" && backendName === "claude-code") {
16316
+ const result = await this.runRuntimeModelSwitch(instanceName, model);
16317
+ if (result.outcome === "mismatch")
16318
+ return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
16319
+ if (result.outcome === "not_sent")
16320
+ return `${warn}${t("model.switch_not_sent", instanceName, model)}`;
16321
+ const persistedNow = this.persistInstanceModel(instanceName, model);
16322
+ if (!persistedNow)
16323
+ return `${warn}${t("model.persist_failed", instanceName)}`;
16324
+ const head = result.outcome === "switched"
16325
+ ? (result.confirmed ? t("model.switch_confirmed", instanceName, model) : t("model.runtime_success", instanceName, model))
16326
+ : t("model.switch_unconfirmed", instanceName, model, Math.round(RUNTIME_SWITCH_RESULT_MS / 1000));
16327
+ return `${warn}${head}${this.effortSuffix(instanceName)}`;
16328
+ }
15120
16329
  // Persist either way: a runtime switch must survive the next respawn too,
15121
16330
  // or the instance silently reverts to the CLI default after a fleet restart.
15122
- let persisted = false;
15123
- if (this.fleetConfig?.instances[instanceName]) {
15124
- this.fleetConfig.instances[instanceName].model = model;
15125
- this.saveFleetConfig();
15126
- persisted = true;
15127
- }
15128
- else if (this.classicChannels?.setModelByInstance(instanceName, model)) {
15129
- persisted = true;
15130
- }
15131
- if (!persisted)
16331
+ if (!this.persistInstanceModel(instanceName, model))
15132
16332
  return `${warn}${t("model.persist_failed", instanceName)}`;
15133
16333
  if (strategy === "runtime") {
15134
16334
  this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
@@ -15137,6 +16337,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15137
16337
  await this.restartSingleInstance(instanceName);
15138
16338
  return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
15139
16339
  }
16340
+ /** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
16341
+ persistInstanceModel(instanceName, model) {
16342
+ if (this.fleetConfig?.instances[instanceName]) {
16343
+ this.fleetConfig.instances[instanceName].model = model;
16344
+ this.saveFleetConfig();
16345
+ return true;
16346
+ }
16347
+ return this.classicChannels?.setModelByInstance(instanceName, model) === true;
16348
+ }
15140
16349
  /**
15141
16350
  * The trailing "Current effort: …" line for a /model reply.
15142
16351
  *
@@ -15154,6 +16363,377 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15154
16363
  ? `\n${t("effort.current_fleet", effort)}`
15155
16364
  : `\n${t("effort.current", effort)}`;
15156
16365
  }
16366
+ // ── #1806: /backend — another CLI backend, with its model and effort, in one write and one relaunch ──
16367
+ /** The steps' view of this fleet (backend-command.ts). `live`: a missing or stale model list may be probed (not on the web). */
16368
+ backendCommandDeps(instanceName, opts) {
16369
+ return {
16370
+ currentBackend: (name) => this.backendNameForInstance(name),
16371
+ currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
16372
+ installedBackends: () => KNOWN_BACKENDS.filter(b => b !== "mock" && this.backendInstalledHere(b)),
16373
+ models: (backend) => this.backendModelOptions(backend, opts),
16374
+ // #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
16375
+ modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
16376
+ effortLevels: (backend, model) => this.backendEffortLevels(instanceName, backend, model),
16377
+ inherited: (key) => {
16378
+ const value = getEffectiveInstanceDefaults(this.fleetConfig?.defaults)[key];
16379
+ return typeof value === "string" && value ? value : undefined;
16380
+ },
16381
+ modelCompatible: (backend, model) => isModelCompatible(backend, model),
16382
+ state: (name) => {
16383
+ const status = this.getInstanceStatus(name);
16384
+ return { running: status === "running", busy: status === "running" && !this.getInstanceIdle(name), status };
16385
+ },
16386
+ t: (key, ...args) => t(key, ...args),
16387
+ };
16388
+ }
16389
+ /**
16390
+ * `/effort`'s levels for a backend this instance may move to, for the model that will run there (codex's levels are
16391
+ * per model; null: its configured default). Empty: it has no effort setting. A file read, never a CLI.
16392
+ */
16393
+ backendEffortLevels(instanceName, backend, model = null) {
16394
+ // codex: the model the NEXT launch runs, for the levels and the gate alike (#1807 review r2).
16395
+ let runs = model;
16396
+ if (backend === "codex") {
16397
+ const next = this.prospectiveCodexModel(instanceName, model);
16398
+ if (!next.proven)
16399
+ return [];
16400
+ runs = next.model;
16401
+ }
16402
+ let levels;
16403
+ try {
16404
+ const metadata = readEffortMetadata(backend, this.getInstanceDir(instanceName), runs);
16405
+ levels = metadata.strategy === "unsupported" ? [] : metadata.levels;
16406
+ }
16407
+ catch {
16408
+ return [];
16409
+ }
16410
+ // #1811: only the levels this host's launch of that backend will pass (launch-effort.ts, the gate the launch
16411
+ // itself applies) — an effort chosen here reaches the new CLI with the one relaunch, or is not offered.
16412
+ const takes = this.launchEffortGate(instanceName, backend, runs);
16413
+ return levels.filter(takes);
16414
+ }
16415
+ /**
16416
+ * #1808: whether a backend takes this model name — the one rule for /backend and update_instance_config's switch.
16417
+ * The name must be one its launch line can carry (modelNameCarried); then its offered list (the cached CLI env), and
16418
+ * claude-code's known names (without this instance's configured model, which belongs to the backend being left).
16419
+ * With no list known, the backend's usual name pattern decides.
16420
+ */
16421
+ backendModelVerdict(instanceName, backend, model,
16422
+ /** The provider the backend will run on (default: this instance's own, else the fleet's). */
16423
+ opts = { provider: this.customProviderFor(instanceName, backend) }) {
16424
+ // A custom provider has its own catalog, which the CLI reads at launch and AgEnD cannot see before one (#1815
16425
+ // review): only the launch-line rule applies — never the account's list or the default provider's naming pattern.
16426
+ if (opts.provider)
16427
+ return modelNameCarried(backend, model) ? { ok: true } : { ok: false, choices: [] };
16428
+ const listed = this.readCliEnv(backend)?.models ?? [];
16429
+ const choices = listed.slice(0, 24).map(o => o.id);
16430
+ if (!modelNameCarried(backend, model))
16431
+ return { ok: false, choices };
16432
+ if (listed.some(o => o.id === model))
16433
+ return { ok: true };
16434
+ if (backend === "claude-code") {
16435
+ const check = this.claudeModelKnown(instanceName, model, null);
16436
+ return check.known ? { ok: true } : { ok: false, choices: check.names };
16437
+ }
16438
+ return listed.length === 0 && isModelCompatible(backend, model) ? { ok: true } : { ok: false, choices };
16439
+ }
16440
+ /**
16441
+ * #1807 review r2: the model codex's next launch of this instance runs. Every launch's writeConfig copies the SHARED
16442
+ * config.toml into the instance's codex home first, so that source decides — not the home's copy from an earlier
16443
+ * launch. Read-only; a model chosen here (`-c model=…`) outranks it; a project file that could change it, unproven.
16444
+ */
16445
+ prospectiveCodexModel(instanceName, model) {
16446
+ const { sharedHome } = codexMetadataHomes(this.getInstanceDir(instanceName));
16447
+ return resolveCodexLaunchModel({
16448
+ isolatedHome: sharedHome, sharedHome, model,
16449
+ workingDirectory: this.fleetConfig?.instances[instanceName]?.working_directory,
16450
+ });
16451
+ }
16452
+ /** #1811: whether a launch of `backend` here would pass a level: the same gate its builder applies. */
16453
+ launchEffortGate(instanceName, backend, model) {
16454
+ if (backend === "claude-code" || backend === "grok" || backend === "antigravity") {
16455
+ const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
16456
+ if (!binary)
16457
+ return () => false;
16458
+ const path = resolveBinary(binary);
16459
+ return level => flagEffort(backend, level, path).arg !== null;
16460
+ }
16461
+ if (backend === "codex") {
16462
+ // `model` is the next launch's, proven by the caller (prospectiveCodexModel); the catalog is codex's own cache.
16463
+ if (!model)
16464
+ return () => false;
16465
+ const catalog = readCodexModelEffortLevels({ ...codexMetadataHomes(this.getInstanceDir(instanceName)), model });
16466
+ return level => codexEffort(level, catalog).arg !== null;
16467
+ }
16468
+ // kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
16469
+ return () => true;
16470
+ }
16471
+ /** Installed the way the launch finds a CLI (resolveBinary): on PATH or in a known install directory. */
16472
+ backendInstalledHere(backend) {
16473
+ const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
16474
+ return !!binary && resolveBinary(binary) !== binary;
16475
+ }
16476
+ /** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
16477
+ async backendModelOptions(backend, opts) {
16478
+ const cached = this.readCliEnv(backend);
16479
+ if (!opts.live || (cached?.models.length && !this.cliEnvNeedsRefresh(cached)))
16480
+ return cached?.models ?? [];
16481
+ opts.onLiveProbe?.();
16482
+ const env = await this.probeBackendBounded(backend).catch(() => null);
16483
+ return env?.models.length ? env.models : cached?.models ?? [];
16484
+ }
16485
+ /**
16486
+ * #1806 review: the instance a `/backend` was asked about is still this config entry — not one deleted and created
16487
+ * again under the same name, or replaced by a Settings edit (both put a new object there), stopped or not.
16488
+ */
16489
+ backendTargetIs(instanceName, target) {
16490
+ const instances = this.fleetConfig?.instances;
16491
+ return !!target && !!instances && Object.prototype.hasOwnProperty.call(instances, instanceName) && instances[instanceName] === target;
16492
+ }
16493
+ /** `/backend` is for fleet.yaml instances: update_instance_config does not manage a ClassicBot agent. */
16494
+ backendCommandRefusal(instanceName) {
16495
+ return this.fleetConfig?.instances && Object.prototype.hasOwnProperty.call(this.fleetConfig.instances, instanceName)
16496
+ ? null : t("backend.classic_unsupported", instanceName);
16497
+ }
16498
+ /**
16499
+ * Apply a confirmed `/backend`: judged again first (a menu lives a minute — the backend may have moved, a CLI been
16500
+ * removed), then update_instance_config's write: backend, model and effort at once, refused and rolled back whole
16501
+ * (its credential-profile login gate included), then one fresh relaunch of a running instance.
16502
+ */
16503
+ async applyBackendSwitch(instanceName, args, from, stillCurrent) {
16504
+ if (this.backendNameForInstance(instanceName) !== from)
16505
+ return t("backend.changed", instanceName, from);
16506
+ if (!stillCurrent())
16507
+ return t("menu.click_stale");
16508
+ const deps = this.backendCommandDeps(instanceName, { live: false });
16509
+ const step = await backendStep(deps, instanceName, args);
16510
+ if (step.kind !== "confirm")
16511
+ return step.kind === "refuse" ? step.text : t("backend.usage");
16512
+ // Asked again after the await, with nothing awaited between here and the write (applyInstanceConfigPatch writes
16513
+ // synchronously): two switches confirmed together, or an instance changed meanwhile, never both succeed.
16514
+ if (this.backendNameForInstance(instanceName) !== from)
16515
+ return t("backend.changed", instanceName, from);
16516
+ if (!stillCurrent())
16517
+ return t("menu.click_stale");
16518
+ const { plan } = step;
16519
+ const shown = planDisplay(deps, plan);
16520
+ const { value, error } = await new Promise(resolve => {
16521
+ applyInstanceConfigPatch(this, instanceName, { backend: plan.backend, model: plan.model, effort: plan.effort }, (value, error) => resolve({ value, error }));
16522
+ });
16523
+ if (error)
16524
+ return t("backend.refused", instanceName, error);
16525
+ const result = (value ?? {});
16526
+ if (result.warning)
16527
+ return t("backend.restart_failed", instanceName, plan.backend, result.warning);
16528
+ if (result.restarted)
16529
+ return t("backend.switched", instanceName, plan.backend, shown.model, shown.effort);
16530
+ return t("backend.switched_not_running", instanceName, plan.backend, shown.model, shown.effort, this.getInstanceStatus(instanceName));
16531
+ }
16532
+ /** A step's buttons: index callbacks (a model id can be longer than Telegram's 64-byte callback data allows). */
16533
+ backendStepButtons(instanceName, nonce, step) {
16534
+ if (step.kind === "choose")
16535
+ return step.choices.map((c, i) => ({ id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:${i}`, label: c.label }));
16536
+ if (step.kind !== "confirm")
16537
+ return [];
16538
+ const running = this.getInstanceStatus(instanceName) === "running";
16539
+ return [
16540
+ { id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:0`, label: t(running ? "backend.confirm_button" : "backend.confirm_button_stopped") },
16541
+ { id: `${BACKEND_SELECT_CALLBACK_PREFIX}${nonce}:1`, label: t("backend.cancel_button") },
16542
+ ];
16543
+ }
16544
+ /**
16545
+ * Show a `/backend` step (a choice, or the confirmation) where the command was given: Discord edits its interaction
16546
+ * reply; Telegram retires the previous step's keyboard and posts the next. One pending entry per step, under a fresh
16547
+ * nonce: a click on an older step finds nothing.
16548
+ */
16549
+ async presentBackendStep(place, step) {
16550
+ const nonce = randomBytes(6).toString("hex");
16551
+ const buttons = this.backendStepButtons(place.instanceName, nonce, step);
16552
+ const timer = setTimeout(() => {
16553
+ const p = this.pendingBackendSelects.get(nonce);
16554
+ if (!p)
16555
+ return;
16556
+ this.pendingBackendSelects.delete(nonce);
16557
+ void this.endBackendMenu(p, t("backend.selection_expired")).catch(() => { });
16558
+ }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
16559
+ timer.unref?.();
16560
+ const pending = { ...place, step, timer };
16561
+ this.pendingBackendSelects.set(nonce, pending);
16562
+ try {
16563
+ if (step.kind === "confirm" && place.respondButtons) {
16564
+ await place.respondButtons(step.text, buttons);
16565
+ return;
16566
+ }
16567
+ if (place.respondChoices) {
16568
+ await place.respondChoices(step.text, buttons);
16569
+ return;
16570
+ }
16571
+ if (!place.adapter || !place.adapterChatId)
16572
+ throw new Error("no place to show the menu");
16573
+ if (place.menuMessageId && place.adapter.editMessageRemoveButtons) {
16574
+ const chosen = t("backend.chosen", joinBackendArgs(step.args));
16575
+ await place.adapter.editMessageRemoveButtons(place.adapterChatId, place.menuMessageId, chosen, place.adapterThreadId).catch(() => { });
16576
+ }
16577
+ const menuMessageId = await place.adapter.promptUser(place.adapterChatId, step.text, buttons, { threadId: place.adapterThreadId });
16578
+ const fresh = this.pendingBackendSelects.get(nonce);
16579
+ if (fresh)
16580
+ fresh.menuMessageId = menuMessageId;
16581
+ }
16582
+ catch (err) {
16583
+ this.pendingBackendSelects.delete(nonce);
16584
+ clearTimeout(timer);
16585
+ this.logger.warn({ err, instanceName: place.instanceName }, "backend menu failed");
16586
+ await place.respond(t("backend.usage")).catch(() => { });
16587
+ }
16588
+ }
16589
+ /** Close a `/backend` menu with a last word: its buttons go (Telegram edits them away), the text stays. Returns where it was said. */
16590
+ async endBackendMenu(pending, text) {
16591
+ if (pending.adapter && pending.adapterChatId) {
16592
+ if (pending.menuMessageId && pending.adapter.editMessageRemoveButtons) {
16593
+ try {
16594
+ await pending.adapter.editMessageRemoveButtons(pending.adapterChatId, pending.menuMessageId, text, pending.adapterThreadId);
16595
+ return pending.menuMessageId;
16596
+ }
16597
+ catch { /* say it in a new message */ }
16598
+ }
16599
+ return (await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId })).messageId;
16600
+ }
16601
+ if (pending.retireButtons)
16602
+ await pending.retireButtons(text);
16603
+ else
16604
+ await pending.respond(text);
16605
+ return undefined;
16606
+ }
16607
+ /** Start `/backend` for a fleet instance from Discord or Telegram; the caller has checked the channel admin. */
16608
+ async beginBackendCommand(place, args) {
16609
+ const refusal = this.backendCommandRefusal(place.instanceName);
16610
+ if (refusal) {
16611
+ await place.respond(refusal);
16612
+ return;
16613
+ }
16614
+ if (!place.respondChoices && !(place.adapter && place.adapterChatId)) {
16615
+ await place.respond(t("backend.usage"));
16616
+ return;
16617
+ }
16618
+ place = { ...place, target: this.fleetConfig.instances[place.instanceName] };
16619
+ const deps = this.backendCommandDeps(place.instanceName, {
16620
+ live: true,
16621
+ onLiveProbe: () => { void place.respond(t("model.refreshing")).catch(() => { }); },
16622
+ });
16623
+ const step = await backendStep(deps, place.instanceName, args);
16624
+ if (step.kind === "refuse") {
16625
+ await place.respond(step.text);
16626
+ return;
16627
+ }
16628
+ await this.presentBackendStep(place, step);
16629
+ }
16630
+ /** `/backend` slash handler: channel admin, as `/model`. Options or typed text give the direct form. */
16631
+ async handleBackendSlash(data, adapterId) {
16632
+ if (!this.isModelAdmin(data.userId, data.channelId, adapterId)) {
16633
+ await data.respond(t("permission.denied"));
16634
+ return;
16635
+ }
16636
+ const name = this.resolveSlashTarget(data.channelId, adapterId);
16637
+ if (!name) {
16638
+ await data.respond(t("classic.no_agent"));
16639
+ return;
16640
+ }
16641
+ const option = (key) => typeof data.options?.[key] === "string" ? data.options[key].trim() : "";
16642
+ const given = [option("backend"), option("model"), option("effort")];
16643
+ // A later option without the one before it says nothing that can be judged in order.
16644
+ if (given.some((value, i) => value && given.slice(0, i).some(before => !before))) {
16645
+ await data.respond(t("backend.usage"));
16646
+ return;
16647
+ }
16648
+ const args = given[0] ? given.filter(Boolean) : backendArgs(data.text ?? "");
16649
+ await this.beginBackendCommand({ instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, respond: data.respond, respondChoices: data.respondChoices,
16650
+ respondButtons: data.respondButtons, retireButtons: data.retireButtons }, args);
16651
+ }
16652
+ /** Telegram `/backend` in a fleet topic (topic-commands) or a ClassicBot chat; the caller has checked the admin. */
16653
+ async runBackendCommand(instanceName, text, userId, channelId, adapter, chatId, threadId, adapterId) {
16654
+ const respond = async (reply) => (await adapter.sendText(chatId, reply, { threadId })).messageId;
16655
+ await this.beginBackendCommand({ instanceName, userId, channelId, adapterId, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId }, backendArgs(text.replace(/^\/backend(@\S+)?/, "")));
16656
+ }
16657
+ /** Consume a `/backend` click: the next step, or on the confirmation the switch itself. True for every backend-select id. */
16658
+ async handleBackendSelection(data, adapterId) {
16659
+ if (!data.callbackData.startsWith(BACKEND_SELECT_CALLBACK_PREFIX))
16660
+ return false;
16661
+ const match = data.callbackData.match(/^backend-select:([0-9a-f]+):(\d{1,2})$/);
16662
+ if (!match)
16663
+ return true;
16664
+ const pending = this.pendingBackendSelects.get(match[1]);
16665
+ if (!pending)
16666
+ return true;
16667
+ // As a /model click (#754 audit): the admin who opened it, through the same bot, in the same channel, still admin.
16668
+ const cbChannel = data.threadId ?? data.chatId;
16669
+ if (!data.userId || data.userId !== pending.userId
16670
+ || (pending.adapterId !== undefined && pending.adapterId !== adapterId)
16671
+ || (cbChannel !== pending.channelId && data.chatId !== pending.channelId)
16672
+ || !this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)) {
16673
+ data.ack?.(t("buttons.admin_only"));
16674
+ return true;
16675
+ }
16676
+ this.pendingBackendSelects.delete(match[1]);
16677
+ clearTimeout(pending.timer);
16678
+ if (!pending.menuMessageId && data.messageId)
16679
+ pending.menuMessageId = data.messageId;
16680
+ const index = Number(match[2]);
16681
+ const { step } = pending;
16682
+ if (step.kind === "choose") {
16683
+ const choice = step.choices[index];
16684
+ if (!choice)
16685
+ return true;
16686
+ if (!this.backendTargetIs(pending.instanceName, pending.target)) {
16687
+ await this.endBackendMenu(pending, t("menu.click_stale")).catch(() => { });
16688
+ return true;
16689
+ }
16690
+ const next = await backendStep(this.backendCommandDeps(pending.instanceName, { live: true }), pending.instanceName, [...step.args, choice.token]);
16691
+ if (next.kind === "refuse") {
16692
+ await this.endBackendMenu(pending, next.text).catch(() => { });
16693
+ return true;
16694
+ }
16695
+ await this.presentBackendStep(pending, next);
16696
+ return true;
16697
+ }
16698
+ if (index === 1) {
16699
+ await this.endBackendMenu(pending, t("backend.cancelled")).catch(() => { });
16700
+ return true;
16701
+ }
16702
+ if (index !== 0)
16703
+ return true;
16704
+ // Confirmed: say so at once, then switch in the background (the relaunch takes a while) and say how it went.
16705
+ const progressId = await this.endBackendMenu(pending, t("backend.switching", pending.instanceName, step.plan.backend)).catch(() => undefined);
16706
+ const say = async (text) => {
16707
+ if (pending.adapter && pending.adapterChatId) {
16708
+ if (progressId)
16709
+ await pending.adapter.editMessage(pending.adapterChatId, progressId, text, pending.adapterThreadId);
16710
+ else
16711
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
16712
+ }
16713
+ else
16714
+ await pending.respond(text);
16715
+ };
16716
+ void (async () => {
16717
+ let result;
16718
+ try {
16719
+ // Asked again after the awaits above: the instance may have moved, or the admin lost admin.
16720
+ const stillCurrent = () => this.backendTargetIs(pending.instanceName, pending.target)
16721
+ && this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId);
16722
+ result = stillCurrent()
16723
+ ? await withSpawnHoldReply(say, () => this.applyBackendSwitch(pending.instanceName, step.args, step.from, stillCurrent))
16724
+ : t("menu.click_stale");
16725
+ }
16726
+ catch (err) {
16727
+ this.logger.error({ err, instance: pending.instanceName, backend: step.plan.backend }, "Backend switch failed");
16728
+ result = t("backend.failed", pending.instanceName, step.plan.backend, err instanceof Error ? err.message : String(err));
16729
+ }
16730
+ await say(result).catch(() => {
16731
+ if (pending.adapter && pending.adapterChatId)
16732
+ pending.adapter.sendText(pending.adapterChatId, result, { threadId: pending.adapterThreadId }).catch(() => { });
16733
+ });
16734
+ })();
16735
+ return true;
16736
+ }
15157
16737
  /** Read recent chat log for agent context */
15158
16738
  getRecentChatLog(instanceName, maxLines = 10) {
15159
16739
  const logDir = ClassicChannelManager.chatLogDir(instanceName);
@@ -15247,6 +16827,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15247
16827
  }
15248
16828
  /** Handle Discord's required static slash choice, warning before a likely startup failure. */
15249
16829
  async handleClassicStartSlash(data, adapterId) {
16830
+ return withSpawnHoldReply(text => data.respond(text), () => this.handleClassicStartSlashCore(data, adapterId));
16831
+ }
16832
+ async handleClassicStartSlashCore(data, adapterId) {
15250
16833
  const requestedBackend = typeof data.options?.backend === "string" ? data.options.backend : undefined;
15251
16834
  if (!requestedBackend) {
15252
16835
  // beta.31 made this option required. Discord can briefly retain the old
@@ -15363,7 +16946,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15363
16946
  // failure. The selected backend is still attempted as requested.
15364
16947
  if (warning)
15365
16948
  await pending.complete(warning, pending.messageId);
15366
- const reply = await this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend);
16949
+ const reply = await withSpawnHoldReply(text => pending.complete(text, pending.messageId), () => this.handleClassicStart(pending.channelId, pending.channelName, pending.userId, pending.guildId, pending.adapterId, selectedBackend));
15367
16950
  await pending.complete(warning ? `${warning}\n\n${reply}` : reply, pending.messageId);
15368
16951
  }
15369
16952
  /** Start a classic channel instance with lightweight config */
@@ -15444,12 +17027,29 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15444
17027
  const ch = this.classicChannels.unregister(channelId, adapterId);
15445
17028
  if (!ch)
15446
17029
  return t("classic.no_agent");
17030
+ // #1664: the room is gone (not paused or stopped): nobody's favorite any more — unless the name is still an
17031
+ // instance (fleet.yaml's, or another room's), so a later room of the same name does not inherit an old star.
17032
+ if (!Object.hasOwn(this.fleetConfig?.instances ?? {}, ch.instanceName)
17033
+ && !this.classicChannels.getAll().some((c) => c.instanceName === ch.instanceName)) {
17034
+ try {
17035
+ this.webPrefs.forgetInstance(ch.instanceName);
17036
+ }
17037
+ catch (err) {
17038
+ this.logger.warn({ err: err.message, instanceName: ch.instanceName }, "Dropping a removed room from web favorites failed");
17039
+ }
17040
+ }
15447
17041
  this.instanceWorldBinding.delete(ch.instanceName);
15448
- await this.stopInstance(ch.instanceName).catch(err => this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance"));
17042
+ // #1716: say so in the reply when background work was ended with the stop.
17043
+ // Null-safe: a foreign stopInstance stub may resolve nothing at all.
17044
+ const stopOutcome = await this.stopInstance(ch.instanceName).catch(err => {
17045
+ this.logger.warn({ err, instanceName: ch.instanceName }, "Failed to stop classic instance");
17046
+ return { backgroundWorkEnded: false };
17047
+ });
15449
17048
  clearPausedMarker(this.getInstanceDir(ch.instanceName));
15450
17049
  this.reregisterClassicChannels();
15451
17050
  this.logger.info({ channelId, adapterId, instanceName: ch.instanceName }, "Classic channel stopped");
15452
- return t("classic.stopped");
17051
+ return t("classic.stopped")
17052
+ + (stopOutcome?.backgroundWorkEnded ? `\n${t("classic.stopped_bg_work")}` : "");
15453
17053
  }
15454
17054
  /**
15455
17055
  * Idempotent while in flight: SIGINT and SIGTERM share one handler and the
@@ -15545,6 +17145,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15545
17145
  clearInterval(this.eventLogPruneTimer);
15546
17146
  this.eventLogPruneTimer = null;
15547
17147
  }
17148
+ this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
15548
17149
  if (this.outboxPruneTimer) {
15549
17150
  clearInterval(this.outboxPruneTimer);
15550
17151
  this.outboxPruneTimer = null;
@@ -15644,8 +17245,13 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15644
17245
  this.controlClient = null;
15645
17246
  if (this.healthServer) {
15646
17247
  this.healthServerListening = false;
15647
- this.healthServer.close();
15648
- this.healthServer = null;
17248
+ const server = this.healthServer;
17249
+ const drain = this.healthServerDrain ?? trackWebServer(server);
17250
+ await drain.close(this.sseClients);
17251
+ if (this.healthServer === server) {
17252
+ this.healthServer = null;
17253
+ this.healthServerDrain = null;
17254
+ }
15649
17255
  }
15650
17256
  this.stopPreviewListener();
15651
17257
  // The store writes lastSeen at most once a minute; what that debounce is still holding — and any
@@ -15974,8 +17580,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15974
17580
  if (old.backend !== backend || old.model !== model || old.autoPauseAfter !== autoPauseAfter || profile.changed) {
15975
17581
  this.logger.info({ instanceName: ch.instanceName, ...(profile.changed ? { profileFrom: profile.from, profileTo: profile.to } : {}) }, "Classic cold config changed — restarting");
15976
17582
  observe?.(ch.instanceName, "restart", "running");
17583
+ const launchedIn = this.daemons.get(ch.instanceName)?.getConfigSnapshot?.()?.working_directory;
15977
17584
  await this.stopInstance(ch.instanceName).catch(() => { });
15978
- if (profile.startsFresh)
17585
+ // #1753: the conversation goes along where it can; otherwise a switch that loses it starts fresh.
17586
+ if (profile.carry)
17587
+ await this.switchConversation(ch.instanceName, { ...profile.carry, workingDirectory: launchedIn });
17588
+ else if (profile.startsFresh)
15979
17589
  this.writeFreshStartMarker(ch.instanceName);
15980
17590
  await this.startClassicInstanceUnattended(ch, "classic instance after fleet reload");
15981
17591
  observe?.(ch.instanceName, "restart", "done");
@@ -17614,7 +19224,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17614
19224
  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)");
17615
19225
  }
17616
19226
  /** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
17617
- dispatchWebHttp(req, res, port) {
19227
+ dispatchWebHttp(req, res, port, closing = false) {
17618
19228
  try {
17619
19229
  res.setHeader("Content-Type", "application/json");
17620
19230
  // No Referer to a tunnel host, an upstream proxy, or any page linked from
@@ -17633,6 +19243,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17633
19243
  res.end(JSON.stringify({ error: WEB_HOST_REJECTED_MESSAGE }));
17634
19244
  return;
17635
19245
  }
19246
+ if (closing) {
19247
+ res.writeHead(503, { Connection: "close", "Retry-After": "10" });
19248
+ res.end(JSON.stringify({ error: "fleet stopping" }));
19249
+ return;
19250
+ }
17636
19251
  const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
17637
19252
  // Branding is public, after Host admission: automatic favicon requests
17638
19253
  // must not become noisy web-token 401s in the browser console.
@@ -17818,14 +19433,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17818
19433
  return;
17819
19434
  }
17820
19435
  (async () => {
19436
+ let held = false;
17821
19437
  try {
17822
19438
  const topicMode = this.fleetConfig?.channel?.mode === "topic";
17823
- await this.startInstance(name, config, topicMode ?? false, "fleet-topic", true);
19439
+ await withSpawnHoldReply(async (message) => {
19440
+ held = true;
19441
+ res.writeHead(202, { "Content-Type": "application/json" });
19442
+ res.end(JSON.stringify({ held: true, message, instance: name }));
19443
+ }, () => this.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
17824
19444
  this.emitSseEvent("status", this.getUiStatus());
19445
+ if (held)
19446
+ return;
17825
19447
  res.writeHead(200);
17826
19448
  res.end(JSON.stringify({ ok: true }));
17827
19449
  }
17828
19450
  catch (err) {
19451
+ if (held) {
19452
+ this.logger.error({ err, name }, "Held start failed");
19453
+ return;
19454
+ }
17829
19455
  res.writeHead(500);
17830
19456
  res.end(JSON.stringify({ error: `Start failed: ${err.message}` }));
17831
19457
  }
@@ -17840,15 +19466,24 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17840
19466
  const name = decodeURIComponent(req.url.slice("/restart/".length));
17841
19467
  this.logger.info({ name }, "Instance restart requested via HTTP");
17842
19468
  (async () => {
19469
+ let held = false;
17843
19470
  try {
17844
- await this.restartSingleInstance(name, { explicit: true });
19471
+ await withSpawnHoldReply(async (message) => {
19472
+ held = true;
19473
+ res.writeHead(202, { "Content-Type": "application/json" });
19474
+ res.end(JSON.stringify({ held: true, message, instance: name }));
19475
+ }, () => this.restartSingleInstance(name, { explicit: true }));
17845
19476
  this.logger.info({ name }, "Instance restarted");
17846
19477
  this.emitSseEvent("status", this.getUiStatus());
19478
+ if (held)
19479
+ return;
17847
19480
  res.writeHead(200);
17848
19481
  res.end(JSON.stringify({ restarted: name }));
17849
19482
  }
17850
19483
  catch (err) {
17851
19484
  this.logger.error({ err, name }, "Instance restart failed");
19485
+ if (held)
19486
+ return;
17852
19487
  const status = err.message.includes("not found") ? 404 : 500;
17853
19488
  res.writeHead(status);
17854
19489
  res.end(JSON.stringify({ error: `Restart failed: ${err.message}` }));
@@ -17931,7 +19566,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17931
19566
  // Defensive for direct/unit callers; normal startup initializes these before adapters.
17932
19567
  if (!this.webToken || !this.webSessions)
17933
19568
  this.initializeWebAuthTokens();
17934
- this.healthServer = createServer((req, res) => this.dispatchWebHttp(req, res, port));
19569
+ const server = createServer((req, res) => this.dispatchWebHttp(req, res, port, drain.closing));
19570
+ const drain = trackWebServer(server);
19571
+ this.healthServer = server;
19572
+ this.healthServerDrain = drain;
17935
19573
  const markListening = (afterTakeover = false) => {
17936
19574
  this.healthServerListening = true;
17937
19575
  this.logger.info({ port }, afterTakeover
@@ -17940,7 +19578,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17940
19578
  // Never the token: fleet.log is readable by anything that can read the
17941
19579
  // data dir, is copied into bug reports, and is tailed in shared terminals.
17942
19580
  // `/dashboard` and `agend web` are the ways to get an authorized link.
17943
- this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /dashboard or `agend web`)");
19581
+ this.logger.info({ url: `http://localhost:${port}/ui` }, "Web UI available (open it with /web or `agend web`)");
17944
19582
  this.logger.info({ url: `http://localhost:${port}/view` }, "Web View available");
17945
19583
  // #1306: the preview listener starts once the web listener is bound — its frame-ancestors name the real port.
17946
19584
  const bound = this.healthServer?.address();
@@ -18017,7 +19655,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18017
19655
  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");
18018
19656
  return;
18019
19657
  }
18020
- const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig });
19658
+ const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
19659
+ // #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
19660
+ extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
18021
19661
  this.previewListener = listener;
18022
19662
  listener.server.on("error", (err) => {
18023
19663
  if (this.previewListener !== listener)
@@ -18066,6 +19706,37 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18066
19706
  decided.reason = "Previews are not available on this fleet right now.";
18067
19707
  return { ...decided, boot: listener ? listener.bootId : null };
18068
19708
  }
19709
+ /**
19710
+ * #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
19711
+ * origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
19712
+ */
19713
+ publicPreviewForUi(exposureId, dashboardOrigin) {
19714
+ const web = this.fleetConfig?.web;
19715
+ const off = (code, reason, extra = {}) => ({ dashboardOrigin, previewOrigin: null, reason, code, boot: null, frameSrc: null, ...extra });
19716
+ if (web?.preview === false)
19717
+ return off("fleetOff", "Previews are turned off for this fleet (web.preview: false).");
19718
+ if (web?.preview_on_public_link === false)
19719
+ return off("publicLink", "Previews are off on the public link (web.preview_on_public_link: false).");
19720
+ const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
19721
+ if (configured) {
19722
+ const listener = this.previewListening ? this.previewListener : null;
19723
+ if (!listener)
19724
+ return off("publicLink", "Previews are not available on this fleet right now.");
19725
+ if (!configured.startsWith("https:"))
19726
+ return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
19727
+ return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
19728
+ }
19729
+ const status = this.publicWebLink?.previewStatus(exposureId) ?? null;
19730
+ if (!status)
19731
+ return off("publicLink", "Previews are not offered on this public link.");
19732
+ // The page may load before the preview tunnel has a host: it frames the quick-tunnel wildcard, and only ever the
19733
+ // origin /ui/preview-status names (the preview side's exact frame-ancestors is the boundary).
19734
+ if (status.state === "starting")
19735
+ return off("publicLinkStarting", "Preview is starting…", { frameSrc: PUBLIC_PREVIEW_FRAME_SRC });
19736
+ if (status.state === "failed")
19737
+ return off("publicLinkPreviewFailed", `Previews are unavailable on this public link (${status.reason}).`, { detail: status.reason });
19738
+ return { dashboardOrigin, previewOrigin: status.origin, reason: null, code: null, boot: status.boot, frameSrc: PUBLIC_PREVIEW_FRAME_SRC };
19739
+ }
18069
19740
  getUiStatus() {
18070
19741
  return measureSyncWork("fleet.uiStatus", () => this.getUiStatusSync());
18071
19742
  }
@@ -18082,6 +19753,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18082
19753
  // /api/profiles (resolveInstanceIdentity) — the sidebar groups, searches and labels with it on every page.
18083
19754
  const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
18084
19755
  const profiles = profileIdentities(this.dataDir);
19756
+ // #1630: decorative — whatever goes wrong deciding an avatar never costs the frame (every instance gets its first character).
19757
+ let ownBots = new Map();
19758
+ try {
19759
+ ownBots = this.ownBots();
19760
+ }
19761
+ catch (err) {
19762
+ this.logger.debug({ err }, "own bots unreadable (getUiStatus)");
19763
+ }
18085
19764
  const instances = names.map(name => {
18086
19765
  const statusFile = join(this.getInstanceDir(name), "statusline.json");
18087
19766
  let cost = 0;
@@ -18119,9 +19798,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18119
19798
  const effort = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.effort;
18120
19799
  const effort_source = (effortStrategy === "unsupported" || isAgy) ? null : effortResolved.source;
18121
19800
  const identity = resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) });
19801
+ let image;
19802
+ try {
19803
+ image = this.webIdentityImage(name, identity.display_name, profiles.get(name)?.avatar_path, ownBots);
19804
+ }
19805
+ catch (err) {
19806
+ this.logger.debug({ err, name }, "avatar unreadable (getUiStatus)");
19807
+ image = { avatar: avatarFor({ name, displayName: identity.display_name }) };
19808
+ }
18122
19809
  return {
18123
19810
  name,
18124
19811
  display_name: identity.display_name || undefined,
19812
+ avatar: image.avatar,
19813
+ ...(image.accent ? { accent: image.accent } : {}),
18125
19814
  description: identity.description || undefined,
18126
19815
  role: identity.role || undefined,
18127
19816
  tags: identity.tags,
@@ -18144,6 +19833,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18144
19833
  uptime: Math.floor((Date.now() - this.startedAt) / 1000),
18145
19834
  // #1519 P7: how many chat connections are configured — 0 is a web-only fleet, which gets the first-run card.
18146
19835
  connections: (this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : [])).length,
19836
+ // #1665: the host preferences' revision — a page that has another re-reads /ui/prefs (no values in this frame).
19837
+ prefs_rev: this.webPrefs.rev(),
19838
+ // #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
19839
+ // shown here rather than in chat). null when normal, unknown or advisory.
19840
+ host_memory: this.hostMemoryIndicator(),
18147
19841
  };
18148
19842
  }
18149
19843
  }