@songsid/agend 2.3.3 → 2.3.4-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 (368) hide show
  1. package/README.md +3 -3
  2. package/README.zh-TW.md +3 -3
  3. package/THIRD_PARTY_NOTICES.md +37 -0
  4. package/dist/agend-home.d.ts +13 -0
  5. package/dist/agend-home.js +42 -0
  6. package/dist/agend-home.js.map +1 -1
  7. package/dist/agent-cli-instructions.md +2 -2
  8. package/dist/agent-credentials.d.ts +24 -0
  9. package/dist/agent-credentials.js +66 -0
  10. package/dist/agent-credentials.js.map +1 -0
  11. package/dist/agent-endpoint.d.ts +3 -1
  12. package/dist/agent-endpoint.js +13 -0
  13. package/dist/agent-endpoint.js.map +1 -1
  14. package/dist/auth-api.d.ts +16 -3
  15. package/dist/auth-api.js +32 -8
  16. package/dist/auth-api.js.map +1 -1
  17. package/dist/authority/session-authorizations.d.ts +28 -4
  18. package/dist/authority/session-authorizations.js +63 -17
  19. package/dist/authority/session-authorizations.js.map +1 -1
  20. package/dist/authority/web-authority.d.ts +15 -1
  21. package/dist/authority/web-authority.js +27 -10
  22. package/dist/authority/web-authority.js.map +1 -1
  23. package/dist/backend/antigravity.js +3 -1
  24. package/dist/backend/antigravity.js.map +1 -1
  25. package/dist/backend/claude-code.d.ts +3 -0
  26. package/dist/backend/claude-code.js +100 -5
  27. package/dist/backend/claude-code.js.map +1 -1
  28. package/dist/backend/codex-footer-hints.d.ts +4 -0
  29. package/dist/backend/codex-footer-hints.js +27 -0
  30. package/dist/backend/codex-footer-hints.js.map +1 -0
  31. package/dist/backend/codex-session-lookup.d.ts +35 -0
  32. package/dist/backend/codex-session-lookup.js +93 -0
  33. package/dist/backend/codex-session-lookup.js.map +1 -1
  34. package/dist/backend/codex.d.ts +27 -0
  35. package/dist/backend/codex.js +136 -23
  36. package/dist/backend/codex.js.map +1 -1
  37. package/dist/backend/kiro-identity.d.ts +2 -0
  38. package/dist/backend/kiro-identity.js +13 -6
  39. package/dist/backend/kiro-identity.js.map +1 -1
  40. package/dist/backend/kiro.d.ts +3 -0
  41. package/dist/backend/kiro.js +68 -5
  42. package/dist/backend/kiro.js.map +1 -1
  43. package/dist/backend/muse.js +5 -3
  44. package/dist/backend/muse.js.map +1 -1
  45. package/dist/backend/types.d.ts +13 -1
  46. package/dist/backend/types.js +3 -0
  47. package/dist/backend/types.js.map +1 -1
  48. package/dist/backend-availability.d.ts +6 -0
  49. package/dist/backend-availability.js +13 -5
  50. package/dist/backend-availability.js.map +1 -1
  51. package/dist/cache-ledger.d.ts +85 -0
  52. package/dist/cache-ledger.js +236 -1
  53. package/dist/cache-ledger.js.map +1 -1
  54. package/dist/cache-service.d.ts +5 -0
  55. package/dist/cache-service.js +16 -4
  56. package/dist/cache-service.js.map +1 -1
  57. package/dist/channel/adapters/discord.js +27 -4
  58. package/dist/channel/adapters/discord.js.map +1 -1
  59. package/dist/channel/adapters/telegram.d.ts +51 -0
  60. package/dist/channel/adapters/telegram.js +238 -18
  61. package/dist/channel/adapters/telegram.js.map +1 -1
  62. package/dist/channel/inbox-temp-name.d.ts +9 -0
  63. package/dist/channel/inbox-temp-name.js +13 -0
  64. package/dist/channel/inbox-temp-name.js.map +1 -0
  65. package/dist/channel/mcp-tools.js +3 -3
  66. package/dist/channel/mcp-tools.js.map +1 -1
  67. package/dist/channel/types.d.ts +2 -0
  68. package/dist/chat-log-ids.d.ts +48 -0
  69. package/dist/chat-log-ids.js +182 -0
  70. package/dist/chat-log-ids.js.map +1 -0
  71. package/dist/classic-channel-manager.d.ts +8 -1
  72. package/dist/classic-channel-manager.js +24 -2
  73. package/dist/classic-channel-manager.js.map +1 -1
  74. package/dist/cli.js +4 -6
  75. package/dist/cli.js.map +1 -1
  76. package/dist/config-validator.js +18 -1
  77. package/dist/config-validator.js.map +1 -1
  78. package/dist/cross-instance-notice.d.ts +10 -0
  79. package/dist/cross-instance-notice.js +67 -5
  80. package/dist/cross-instance-notice.js.map +1 -1
  81. package/dist/daemon.d.ts +52 -0
  82. package/dist/daemon.js +538 -35
  83. package/dist/daemon.js.map +1 -1
  84. package/dist/delivery-consumed-watch.d.ts +23 -1
  85. package/dist/delivery-consumed-watch.js +30 -3
  86. package/dist/delivery-consumed-watch.js.map +1 -1
  87. package/dist/delivery-reconciliation.d.ts +7 -0
  88. package/dist/delivery-reconciliation.js +9 -1
  89. package/dist/delivery-reconciliation.js.map +1 -1
  90. package/dist/dialog-screen-text.d.ts +16 -0
  91. package/dist/dialog-screen-text.js +51 -0
  92. package/dist/dialog-screen-text.js.map +1 -0
  93. package/dist/env-file.d.ts +2 -0
  94. package/dist/env-file.js +13 -8
  95. package/dist/env-file.js.map +1 -1
  96. package/dist/export-import.d.ts +7 -0
  97. package/dist/export-import.js +44 -10
  98. package/dist/export-import.js.map +1 -1
  99. package/dist/fleet-context.d.ts +2 -0
  100. package/dist/fleet-manager.d.ts +172 -12
  101. package/dist/fleet-manager.js +897 -139
  102. package/dist/fleet-manager.js.map +1 -1
  103. package/dist/fleet-yaml-patch.d.ts +3 -0
  104. package/dist/fleet-yaml-patch.js +103 -0
  105. package/dist/fleet-yaml-patch.js.map +1 -0
  106. package/dist/general-knowledge/skills/concepts/SKILL.md +393 -0
  107. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +2 -1
  108. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -1
  109. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +41 -4
  110. package/dist/general-knowledge/skills/model-discovery/SKILL.md +2 -0
  111. package/dist/general-knowledge/skills/web-appearance/SKILL.md +1 -1
  112. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +14 -4
  113. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  114. package/dist/install-stream.d.ts +53 -0
  115. package/dist/install-stream.js +134 -0
  116. package/dist/install-stream.js.map +1 -0
  117. package/dist/instance-config-impact.js +1 -0
  118. package/dist/instance-config-impact.js.map +1 -1
  119. package/dist/instance-lifecycle.d.ts +22 -2
  120. package/dist/instance-lifecycle.js +103 -19
  121. package/dist/instance-lifecycle.js.map +1 -1
  122. package/dist/instance-topic-delete.d.ts +77 -0
  123. package/dist/instance-topic-delete.js +123 -0
  124. package/dist/instance-topic-delete.js.map +1 -0
  125. package/dist/instructions.d.ts +2 -2
  126. package/dist/instructions.js +2 -2
  127. package/dist/instructions.js.map +1 -1
  128. package/dist/interaction-observation.d.ts +1 -0
  129. package/dist/interaction-observation.js +1 -1
  130. package/dist/interaction-observation.js.map +1 -1
  131. package/dist/kiro-engine-status.d.ts +11 -0
  132. package/dist/kiro-engine-status.js +6 -0
  133. package/dist/kiro-engine-status.js.map +1 -1
  134. package/dist/kiro-resume-summary.d.ts +37 -0
  135. package/dist/kiro-resume-summary.js +85 -0
  136. package/dist/kiro-resume-summary.js.map +1 -0
  137. package/dist/locale.js +130 -6
  138. package/dist/locale.js.map +1 -1
  139. package/dist/log-service.d.ts +30 -0
  140. package/dist/log-service.js +36 -3
  141. package/dist/log-service.js.map +1 -1
  142. package/dist/log-steps.d.ts +4 -3
  143. package/dist/log-steps.js +32 -7
  144. package/dist/log-steps.js.map +1 -1
  145. package/dist/logger.d.ts +12 -0
  146. package/dist/logger.js +43 -1
  147. package/dist/logger.js.map +1 -1
  148. package/dist/login-controller.js +9 -1
  149. package/dist/login-controller.js.map +1 -1
  150. package/dist/login-flows.d.ts +2 -0
  151. package/dist/login-flows.js +5 -2
  152. package/dist/login-flows.js.map +1 -1
  153. package/dist/login-manager.d.ts +6 -0
  154. package/dist/login-manager.js +10 -1
  155. package/dist/login-manager.js.map +1 -1
  156. package/dist/missed-offline.d.ts +25 -0
  157. package/dist/missed-offline.js +79 -0
  158. package/dist/missed-offline.js.map +1 -0
  159. package/dist/needs-you-hub.d.ts +6 -1
  160. package/dist/needs-you-hub.js +31 -8
  161. package/dist/needs-you-hub.js.map +1 -1
  162. package/dist/needs-you.d.ts +22 -2
  163. package/dist/needs-you.js +17 -3
  164. package/dist/needs-you.js.map +1 -1
  165. package/dist/next-steps.d.ts +12 -2
  166. package/dist/next-steps.js +19 -3
  167. package/dist/next-steps.js.map +1 -1
  168. package/dist/office-share-page.d.ts +1 -1
  169. package/dist/office-share-page.js +6 -2
  170. package/dist/office-share-page.js.map +1 -1
  171. package/dist/office-share-service.d.ts +57 -0
  172. package/dist/office-share-service.js +76 -11
  173. package/dist/office-share-service.js.map +1 -1
  174. package/dist/office-share.d.ts +8 -0
  175. package/dist/office-share.js +34 -0
  176. package/dist/office-share.js.map +1 -1
  177. package/dist/outbound-handlers.js +59 -3
  178. package/dist/outbound-handlers.js.map +1 -1
  179. package/dist/outbound-schemas.d.ts +1 -0
  180. package/dist/outbound-schemas.js +10 -4
  181. package/dist/outbound-schemas.js.map +1 -1
  182. package/dist/package-preimage.js +27 -2
  183. package/dist/package-preimage.js.map +1 -1
  184. package/dist/page-share.d.ts +122 -0
  185. package/dist/page-share.js +199 -0
  186. package/dist/page-share.js.map +1 -0
  187. package/dist/public-preview.d.ts +4 -0
  188. package/dist/public-preview.js +5 -4
  189. package/dist/public-preview.js.map +1 -1
  190. package/dist/public-web-gateway.js +5 -0
  191. package/dist/public-web-gateway.js.map +1 -1
  192. package/dist/queued-handoffs.d.ts +33 -0
  193. package/dist/queued-handoffs.js +75 -0
  194. package/dist/queued-handoffs.js.map +1 -0
  195. package/dist/quickstart-api.d.ts +5 -0
  196. package/dist/quickstart-api.js +1 -1
  197. package/dist/quickstart-api.js.map +1 -1
  198. package/dist/quickstart.d.ts +40 -0
  199. package/dist/quickstart.js +174 -28
  200. package/dist/quickstart.js.map +1 -1
  201. package/dist/remote-card-answer.js +2 -0
  202. package/dist/remote-card-answer.js.map +1 -1
  203. package/dist/remote-card-prompt.js +4 -2
  204. package/dist/remote-card-prompt.js.map +1 -1
  205. package/dist/reply-buttons-controller.js +10 -0
  206. package/dist/reply-buttons-controller.js.map +1 -1
  207. package/dist/scheduler/db.d.ts +2 -1
  208. package/dist/scheduler/db.js +23 -9
  209. package/dist/scheduler/db.js.map +1 -1
  210. package/dist/selection-card.d.ts +20 -0
  211. package/dist/selection-card.js +202 -0
  212. package/dist/selection-card.js.map +1 -0
  213. package/dist/settings-api.d.ts +13 -0
  214. package/dist/settings-api.js +16 -1
  215. package/dist/settings-api.js.map +1 -1
  216. package/dist/settings-change.js +5 -0
  217. package/dist/settings-change.js.map +1 -1
  218. package/dist/settings-effect.js +2 -0
  219. package/dist/settings-effect.js.map +1 -1
  220. package/dist/setup-host.js +2 -2
  221. package/dist/setup-host.js.map +1 -1
  222. package/dist/setup-wizard.js +2 -2
  223. package/dist/setup-wizard.js.map +1 -1
  224. package/dist/stall-reporter.d.ts +21 -0
  225. package/dist/stall-reporter.js +82 -0
  226. package/dist/stall-reporter.js.map +1 -0
  227. package/dist/tmux-control.d.ts +2 -2
  228. package/dist/tmux-control.js +4 -2
  229. package/dist/tmux-control.js.map +1 -1
  230. package/dist/tmux-exec-helper.d.ts +6 -3
  231. package/dist/tmux-exec-helper.js +115 -53
  232. package/dist/tmux-exec-helper.js.map +1 -1
  233. package/dist/tmux-manager.d.ts +6 -0
  234. package/dist/tmux-manager.js +17 -0
  235. package/dist/tmux-manager.js.map +1 -1
  236. package/dist/tool-permissions.d.ts +5 -2
  237. package/dist/tool-permissions.js +6 -5
  238. package/dist/tool-permissions.js.map +1 -1
  239. package/dist/tool-progress.d.ts +2 -0
  240. package/dist/tool-progress.js +19 -3
  241. package/dist/tool-progress.js.map +1 -1
  242. package/dist/topic-commands.js +4 -0
  243. package/dist/topic-commands.js.map +1 -1
  244. package/dist/tunnel/cloudflared-install.d.ts +4 -0
  245. package/dist/tunnel/cloudflared-install.js +20 -1
  246. package/dist/tunnel/cloudflared-install.js.map +1 -1
  247. package/dist/tunnel/cloudflared.d.ts +8 -1
  248. package/dist/tunnel/cloudflared.js +34 -6
  249. package/dist/tunnel/cloudflared.js.map +1 -1
  250. package/dist/tunnel/lease.d.ts +3 -3
  251. package/dist/tunnel/lease.js +23 -19
  252. package/dist/tunnel/lease.js.map +1 -1
  253. package/dist/tunnel/manager.js +6 -7
  254. package/dist/tunnel/manager.js.map +1 -1
  255. package/dist/types.d.ts +10 -0
  256. package/dist/ui/chat-store.js +28 -4
  257. package/dist/ui/chat-strings.js +40 -4
  258. package/dist/ui/chat-thread.js +52 -10
  259. package/dist/ui/fleet-canvas.js +4 -3
  260. package/dist/ui/fleet-office-model.js +55 -22
  261. package/dist/ui/fleet-office-overview.js +6 -38
  262. package/dist/ui/fleet-office-pack.js +4 -3
  263. package/dist/ui/fleet-office-skin.js +114 -21
  264. package/dist/ui/fleet-office-store.js +5 -0
  265. package/dist/ui/fleet-office-zones.js +2 -2
  266. package/dist/ui/fleet-office.js +41 -9
  267. package/dist/ui/fleet-usage.js +90 -27
  268. package/dist/ui/page-share-dialog.js +191 -0
  269. package/dist/ui/panel-chat.js +16 -0
  270. package/dist/ui/panel-log.js +7 -1
  271. package/dist/ui/panel-needs.js +17 -8
  272. package/dist/ui/panel-notices.js +98 -0
  273. package/dist/ui/panel-settings.js +9 -2
  274. package/dist/ui/preview-pages.js +41 -0
  275. package/dist/ui/preview-panel-actions.js +161 -0
  276. package/dist/ui/preview-panel.js +209 -28
  277. package/dist/ui/preview-versions.js +155 -0
  278. package/dist/ui/preview.js +88 -3
  279. package/dist/ui/settings-registry.js +1 -0
  280. package/dist/ui/settings-strings.js +6 -2
  281. package/dist/ui/shared/app-i18n.js +6 -6
  282. package/dist/ui/shared/app-needs.js +1 -1
  283. package/dist/ui/shared/app-route.js +4 -0
  284. package/dist/ui/shared/app-shell.js +9 -5
  285. package/dist/ui/shared/app-store.js +71 -1
  286. package/dist/ui/shared/app-stream.js +27 -1
  287. package/dist/ui/shared/app.css +94 -4
  288. package/dist/ui/shared/app.js +22 -1
  289. package/dist/ui/shared/office/guild.png +0 -0
  290. package/dist/ui/shared/office/school.png +0 -0
  291. package/dist/ui/shared/panel-view.js +339 -17
  292. package/dist/ui/shared/signin.js +12 -1
  293. package/dist/ui/shared/ui-icons.js +3 -0
  294. package/dist/ui/shared/usage-dialog.js +18 -1
  295. package/dist/ui/shared/view-history.css +17 -0
  296. package/dist/ui/shared/view-history.js +438 -0
  297. package/dist/ui/shared/view-strings.js +42 -0
  298. package/dist/ui/shared/web-authorize.js +3 -1
  299. package/dist/unattended-install.d.ts +6 -0
  300. package/dist/unattended-install.js +79 -0
  301. package/dist/unattended-install.js.map +1 -0
  302. package/dist/usage/capacity.d.ts +69 -0
  303. package/dist/usage/capacity.js +119 -0
  304. package/dist/usage/capacity.js.map +1 -0
  305. package/dist/usage/i18n-keys.d.ts +1 -1
  306. package/dist/usage/i18n-keys.js +3 -0
  307. package/dist/usage/i18n-keys.js.map +1 -1
  308. package/dist/usage/profile-timeline.d.ts +16 -0
  309. package/dist/usage/profile-timeline.js +58 -0
  310. package/dist/usage/profile-timeline.js.map +1 -0
  311. package/dist/usage/quota-history.d.ts +43 -0
  312. package/dist/usage/quota-history.js +168 -0
  313. package/dist/usage/quota-history.js.map +1 -0
  314. package/dist/usage/usage-api.d.ts +31 -0
  315. package/dist/usage/usage-api.js +80 -1
  316. package/dist/usage/usage-api.js.map +1 -1
  317. package/dist/usage-report.d.ts +25 -1
  318. package/dist/usage-report.js +55 -4
  319. package/dist/usage-report.js.map +1 -1
  320. package/dist/usage-rollup.d.ts +4 -1
  321. package/dist/usage-rollup.js +12 -3
  322. package/dist/usage-rollup.js.map +1 -1
  323. package/dist/view-api.d.ts +31 -0
  324. package/dist/view-api.js +186 -41
  325. package/dist/view-api.js.map +1 -1
  326. package/dist/view-history.d.ts +84 -0
  327. package/dist/view-history.js +116 -0
  328. package/dist/view-history.js.map +1 -0
  329. package/dist/web-api.d.ts +32 -0
  330. package/dist/web-api.js +195 -5
  331. package/dist/web-api.js.map +1 -1
  332. package/dist/web-auth.js +7 -1
  333. package/dist/web-auth.js.map +1 -1
  334. package/dist/web-chat-history.d.ts +20 -0
  335. package/dist/web-chat-history.js +21 -0
  336. package/dist/web-chat-history.js.map +1 -1
  337. package/dist/web-guest-access.js +5 -1
  338. package/dist/web-guest-access.js.map +1 -1
  339. package/dist/web-login.d.ts +6 -3
  340. package/dist/web-login.js +26 -15
  341. package/dist/web-login.js.map +1 -1
  342. package/dist/web-notices.d.ts +51 -0
  343. package/dist/web-notices.js +118 -0
  344. package/dist/web-notices.js.map +1 -0
  345. package/dist/web-office-pack-tools.d.ts +1 -1
  346. package/dist/web-office-pack-tools.js +1 -1
  347. package/dist/web-office-pack-tools.js.map +1 -1
  348. package/dist/web-only-fleet.d.ts +22 -0
  349. package/dist/web-only-fleet.js +13 -0
  350. package/dist/web-only-fleet.js.map +1 -0
  351. package/dist/web-preview.d.ts +32 -3
  352. package/dist/web-preview.js +52 -19
  353. package/dist/web-preview.js.map +1 -1
  354. package/dist/web-session-authorization.d.ts +13 -2
  355. package/dist/web-session-authorization.js +10 -3
  356. package/dist/web-session-authorization.js.map +1 -1
  357. package/dist/web-session.d.ts +12 -0
  358. package/dist/web-session.js +22 -8
  359. package/dist/web-session.js.map +1 -1
  360. package/dist/web-settings-patch.d.ts +3 -1
  361. package/dist/web-settings-patch.js +4 -1
  362. package/dist/web-settings-patch.js.map +1 -1
  363. package/dist/web-shell-routes.d.ts +2 -0
  364. package/dist/web-shell-routes.js +3 -0
  365. package/dist/web-shell-routes.js.map +1 -1
  366. package/launcher/agend-install.cjs +16 -2
  367. package/launcher/private-home.cjs +1 -1
  368. package/package.json +1 -1
@@ -3,6 +3,7 @@ import { listKiroV1Sessions, readKiroClassicLinks } from "./backend/kiro-identit
3
3
  import { readKiroV1SavedModel } from "./backend/kiro-retired-model.js";
4
4
  import { sharedKiroV2StoreLane } from "./backend/kiro-v2-store.js";
5
5
  import { SEPARATE_FEATURE_KEYS, hasLegacyOffice, normalizeOfficeStorage } from "./dashboard-permissions.js";
6
+ import { maskSecrets } from "./log-steps.js";
6
7
  import { loginNoticeMode, normalizeNoticeDelivery, setNoticeMode } from "./notice-delivery.js";
7
8
  import { DEFAULT_REPLY_TRIGGERS } from "./reply-triggers.js";
8
9
  import { WorkingRecoveryCoordinator, WORKING_RECOVERY_KIND, buildWorkingRecoveryPrompt } from "./working-recovery.js";
@@ -27,7 +28,10 @@ import { clearPublicLinkOwner, readPublicLinkOwner, writePublicLinkOwner } from
27
28
  import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
28
29
  import { CloudflaredProvider } from "./tunnel/cloudflared.js";
29
30
  import { ensureCloudflared } from "./tunnel/cloudflared-install.js";
31
+ import { unattendedInstallCommand, loginShell } from "./unattended-install.js";
32
+ import { InstallStream } from "./install-stream.js";
30
33
  import { namedTestLane, runNamedLinkTest } from "./tunnel/named-link-test.js";
34
+ import { daemonTopicMode, isWebOnlyFleetConfig } from "./web-only-fleet.js";
31
35
  import { OFFICE_SHARE_TUNNEL_DIR, OfficeShareService } from "./office-share-service.js";
32
36
  import { ALL_OFFICES, WHOLE_FLEET } from "./office-share.js";
33
37
  import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
@@ -36,7 +40,7 @@ import { withinBudget } from "./monotonic-budget.js";
36
40
  import { measureSyncWork } from "./sync-work-attribution.js";
37
41
  import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
38
42
  import { ProfileControlServer } from "./profile-control.js";
39
- import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, lstatSync, constants as fsConstants, closeSync } from "node:fs";
43
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, realpathSync, lstatSync, closeSync } from "node:fs";
40
44
  import { atomicWriteFileSync } from "./atomic-write.js";
41
45
  import { createHash, randomBytes, randomUUID } from "node:crypto";
42
46
  import { freemem, totalmem, homedir } from "node:os";
@@ -53,6 +57,7 @@ import { applyKiroMigration, kiroMigrationPrompt, planKiroMigration } from "./ki
53
57
  import { fleetLabel } from "./fleet-label.js";
54
58
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
55
59
  import { formatUpdateProgress } from "./update-progress.js";
60
+ import { startStallReporter } from "./stall-reporter.js";
56
61
  import { sdNotify, sdNotifyBlocking, setSdNotifyWarn, startSdWatchdog } from "./sd-notify.js";
57
62
  import { StartupMemoryDeadline, fleetStopConcurrency } from "./startup-memory-deadline.js";
58
63
  import { readFleetMemory } from "./process-memory.js";
@@ -71,7 +76,8 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
71
76
  import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, loadFleetConfigSource, loadRawFleetConfigSource, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG, autoGeneralInstanceConfig } from "./config.js";
72
77
  import { EventLog } from "./event-log.js";
73
78
  import { binaryProbe } from "./binary-probe.js";
74
- import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes } from "./backend-availability.js";
79
+ import { deletePlannedTopic, planInstanceTopicDelete, topicDestination, topicMustStay } from "./instance-topic-delete.js";
80
+ import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes, findExecutableIn, resolveBackendBinary } from "./backend-availability.js";
75
81
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
76
82
  import { OutboxOpenError } from "./outbox-open-error.js";
77
83
  import { AdapterWorld } from "./adapter-world.js";
@@ -86,6 +92,7 @@ import { formatFleetTime } from "./tz-utils.js";
86
92
  import { createAdapter } from "./channel/factory.js";
87
93
  import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
88
94
  import { TelegramAdapter } from "./channel/adapters/telegram.js";
95
+ import { MissedOfflineStore } from "./missed-offline.js";
89
96
  import { createBackend } from "./backend/factory.js";
90
97
  import { readEffortMetadata } from "./backend/effort-metadata.js";
91
98
  import { codexEffort, flagEffort } from "./backend/launch-effort.js";
@@ -106,7 +113,7 @@ import { WebhookEmitter } from "./webhook-emitter.js";
106
113
  import { TmuxControlClient } from "./tmux-control.js";
107
114
  import { safeHandler } from "./safe-async.js";
108
115
  import { RoutingEngine } from "./routing-engine.js";
109
- import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
116
+ import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, checkBinaryInstalledAsync, } from "./instance-lifecycle.js";
110
117
  import { TopicArchiver } from "./topic-archiver.js";
111
118
  import { StatuslineWatcher } from "./statusline-watcher.js";
112
119
  import { applyInstanceConfigPatch, outboundHandlers } from "./outbound-handlers.js";
@@ -165,6 +172,7 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
165
172
  }
166
173
  import { handleWebRequest, webPeerOf, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
167
174
  import { LogService } from "./log-service.js";
175
+ import { viewHistory } from "./view-history.js";
168
176
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId, webChatFrom } from "./web-chat-history.js";
169
177
  import { WEB_ECHO_COPIES_MAX, WEB_MIRROR_TIMEOUT_MS, webReactionEmoji, webReactionProblem } from "./web-reactions.js";
170
178
  import { WebChatDiskStore } from "./web-chat-store.js";
@@ -197,7 +205,7 @@ import { envFileKeys } from "./token-env-name.js";
197
205
  import { discordGeneralChannelId } from "./discord-general.js";
198
206
  import { parseEnvText } from "./env-file.js";
199
207
  import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permissions.js";
200
- import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
208
+ import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, setCapacityLookup, usageProviderIdForBackend } from "./usage/usage-api.js";
201
209
  import { LOGIN_FLOWS, checkAuthStatus, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
202
210
  import { LoginSession } from "./login-manager.js";
203
211
  import { runOnLoginShell } from "./login-shell.js";
@@ -228,6 +236,8 @@ import { remoteCardAction, remoteCardChoiceLabel, remoteCardOutcomeText, remoteC
228
236
  import { recentChatContext } from "./log-tail.js";
229
237
  import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
230
238
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
239
+ import { AgentCredentials, agentCredentialPolicy } from "./agent-credentials.js";
240
+ import { writeSecretFile } from "./secret-file.js";
231
241
  import { ConfigHistory, ConfigSyntaxError, configIssueText } from "./config-history.js";
232
242
  import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt, readClassicRoomNames } from "./classic-channel-manager.js";
233
243
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
@@ -264,7 +274,8 @@ import { authorizeSession, decideWebGate, loadOrCreateWebToken, readSessionCooki
264
274
  import { bypassesWebGate, handleAuthRequest, serveGuestRefusedPage, serveSigninPage } from "./auth-api.js";
265
275
  import { isWebPageNavigation } from "./web-shell-routes.js";
266
276
  import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
267
- import { authorityNow, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
277
+ import { WebNotices, WEB_NOTICES_FILE } from "./web-notices.js";
278
+ import { authorityNow, evaluateWebAuthority, HOST_OPERATOR, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
268
279
  import { AuthorityStateStore } from "./authority/authority-state.js";
269
280
  import { SessionAuthorizations } from "./authority/session-authorizations.js";
270
281
  import { SessionAuthorizationRequests } from "./web-session-authorization.js";
@@ -285,7 +296,7 @@ import { PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC, providerSecretSpec, p
285
296
  const SELF_RESTART_DEADLINE_MS = 300_000;
286
297
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
287
298
  import { kiroHome } from "./backend/kiro-kas-store.js";
288
- import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileLoginCommand, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
299
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileLoginCommand, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile, listConfiguredProfiles } from "./backend/credential-profile.js";
289
300
  import { carryClaudeConversation } from "./backend/claude-carry.js";
290
301
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
291
302
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
@@ -300,6 +311,13 @@ import { SpawnGate } from "./spawn-gate.js";
300
311
  import { BackendOutageTracker } from "./backend-outage.js";
301
312
  import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
302
313
  import { getTmuxSession } from "./config.js";
314
+ import { OwnStatusMessages, discordMessageKey } from "./chat-log-ids.js";
315
+ import { PROFILE_TIMELINE_FILE, PROFILE_UNRECORDED, recordProfileLaunch } from "./usage/profile-timeline.js";
316
+ import { readQuotaHistory } from "./usage/quota-history.js";
317
+ import { estimateCapacity } from "./usage/capacity.js";
318
+ import { covered, hourSources } from "./usage-rollup.js";
319
+ import { KEEP_DAYS } from "./cache-ledger.js";
320
+ import { ratesFor } from "./cache-prices.js";
303
321
  // Closed migration cohort: these 11 names existed immediately before the first
304
322
  // managed-skill publisher (a1ac499f; parent e267e684). Never grow this set when
305
323
  // adding a bundled skill: its new name is not proof of old AgEnD ownership.
@@ -441,6 +459,8 @@ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
441
459
  const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
442
460
  /** Floor between tool-progress-driven bubble edits (Telegram flood safety). */
443
461
  const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
462
+ /** M-2281: how long `/login`'s install window may run before it is stopped. */
463
+ const INSTALL_TIMEOUT_MS = 10 * 60 * 1000;
444
464
  /** Elapsed time is only shown once work has clearly outlasted a quick answer. */
445
465
  /**
446
466
  * Default delay before the button starts showing elapsed time. Configurable via
@@ -449,6 +469,8 @@ const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
449
469
  * while anything real shows signs of life well before the old two minutes.
450
470
  */
451
471
  const PROGRESS_MIN_ELAPSED_MS = 30_000;
472
+ /** M-2391: the threshold tick fires this long after it, never a millisecond before (timers can fire early). */
473
+ const PROGRESS_THRESHOLD_MARGIN_MS = 50;
452
474
  /** How much of a tool summary the progress line will show before eliding. */
453
475
  const PROGRESS_ACTIVITY_MAX_CHARS = 48;
454
476
  /**
@@ -620,6 +642,14 @@ function emojiListFilter(opts) {
620
642
  }
621
643
  /** #1920: a guest's scope until phase 2 derives one: nothing. */
622
644
  const NO_GUEST_SCOPE = new Set();
645
+ /** #1490 row 27: a restart asked for one lifecycle of an instance after it was stopped, restarted or replaced. */
646
+ export class RestartSupersededError extends Error {
647
+ /** `shown`: the instance as the chat list names it (shownInstanceName). */
648
+ constructor(shown) {
649
+ super(t("restart.superseded", shown));
650
+ this.name = "RestartSupersededError";
651
+ }
652
+ }
623
653
  export class FleetManager {
624
654
  dataDir;
625
655
  static signalTarget = null;
@@ -871,6 +901,7 @@ export class FleetManager {
871
901
  startupRetryNotices = new Map();
872
902
  /** Unsupported-CLI refusals waiting to go out, grouped by reason (#1109). */
873
903
  unsupportedCliNotices = new Map();
904
+ kiroCompatibilityHolds = new Map();
874
905
  kiroIncompatNotices = new Map();
875
906
  /** Backoff between automatic startup retries; the last step repeats while the backend is down. */
876
907
  static STARTUP_RETRY_BACKOFF_MS = [60_000, 5 * 60_000, 15 * 60_000];
@@ -899,6 +930,7 @@ export class FleetManager {
899
930
  kiroMigrationRuns = new Map();
900
931
  /** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
901
932
  needsYou = null;
933
+ missedOfflineStore = null;
902
934
  configHistory = null;
903
935
  configHistoryTimer = null;
904
936
  loadedFleetSource = null;
@@ -1014,11 +1046,15 @@ export class FleetManager {
1014
1046
  discordPresenceEagerPending = false;
1015
1047
  discordPresenceInFlight = null;
1016
1048
  static DISCORD_PRESENCE_REFRESH_MS = 15 * 60_000;
1049
+ /** #3066 §1d: how often a fleet with no Discord bot reads the quota for its history (the user's choice: 30 min). */
1050
+ static QUOTA_BACKGROUND_READ_MS = 30 * 60_000;
1051
+ quotaReadTimer = null;
1017
1052
  /** Days of event/activity history to keep. */
1018
1053
  static EVENT_LOG_RETENTION_DAYS = 30;
1019
1054
  watchdogTimer = null;
1020
1055
  startupMemoryDeadline = null;
1021
1056
  eventLoopWatch = null;
1057
+ stallReporter = null;
1022
1058
  startedAt = 0;
1023
1059
  stormOpenNotifyTimer = null;
1024
1060
  memoryLogAt = null;
@@ -1062,6 +1098,37 @@ export class FleetManager {
1062
1098
  webChatRemovals = new Map();
1063
1099
  /** #1774: a removal under way — its replies' fate waits for its outcome (success: theirs no more; failure: theirs). */
1064
1100
  webChatRemovalsPending = new Map();
1101
+ /**
1102
+ * #1490 row 27: "still the same lifecycle of `name`" — its lifecycle epoch, moved on synchronously by every stop,
1103
+ * restart and removal (the map is never pruned, so a same-name replacement has a later one). Taken when a button
1104
+ * or menu is claimed or offered, asked after every await before acting by name.
1105
+ */
1106
+ /** #1490 row 27: an instance as the chat list names it — its display name where it has one, else its name. */
1107
+ shownInstanceName(name) {
1108
+ try {
1109
+ const classic = this.classicChannels?.getAll().find(ch => ch.instanceName === name) ?? null;
1110
+ return resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic, profile: profileIdentities(this.dataDir).get(name) }).display_name || name;
1111
+ }
1112
+ catch {
1113
+ return name;
1114
+ }
1115
+ }
1116
+ lifecyclePin(name) {
1117
+ // Fail closed: a lifecycle that cannot say its epoch holds nothing (and never stops a prompt from being posted).
1118
+ let epoch;
1119
+ try {
1120
+ epoch = this.lifecycle?.epochOf(name);
1121
+ }
1122
+ catch {
1123
+ return () => false;
1124
+ }
1125
+ return () => { try {
1126
+ return !this.shuttingDown && this.lifecycle?.epochOf(name) === epoch;
1127
+ }
1128
+ catch {
1129
+ return false;
1130
+ } };
1131
+ }
1065
1132
  webChatGeneration(name) { return this.webChatGenerations.get(name) ?? 0; }
1066
1133
  /** #1774: who an instance's reply is from, taken when it is sent: the name's generation and its daemon then. */
1067
1134
  webChatIdentity(name) { return { generation: this.webChatGeneration(name), daemon: this.daemons.get(name) }; }
@@ -1201,6 +1268,11 @@ export class FleetManager {
1201
1268
  officeExists: id => this.officeStore.list().offices.some(o => o.id === id),
1202
1269
  ownerCurrent: owner => this.officeShareOwnerCurrent(owner),
1203
1270
  changed: () => this.emitSseEvent("office_shares", {}),
1271
+ // #2911: page shares on the same tunnel. Their end (expired, stopped, the feature off, the maker no longer an admin,
1272
+ // the fleet stopping) is said in the page's own channel, as its start is (pageShareNotice); never its URL.
1273
+ pageConfig: () => this.fleetConfig?.web?.page_share,
1274
+ pageCdn: () => this.fleetConfig?.web?.preview_cdn === true,
1275
+ pagesChanged: (list, ended) => this.pageSharesChanged(list, ended),
1204
1276
  log: (event, detail) => this.logger.info({ event, ...(detail ? { detail } : {}) }, "Office share link"),
1205
1277
  }));
1206
1278
  }
@@ -1215,6 +1287,17 @@ export class FleetManager {
1215
1287
  : share ? t("office_share.revoked_notice", who, office) : t("office_share.revoked_all_notice", who, count);
1216
1288
  this.notifyFleetError(text, { throttle: false });
1217
1289
  }
1290
+ /** #2911: the open page shares changed — the page hears it; each one handed out that ended is said in its channel. */
1291
+ pageSharesChanged(_list, ended) {
1292
+ this.emitSseEvent("page_shares", {});
1293
+ for (const s of ended)
1294
+ this.notifyInstanceTopic(s.instance, t("page_share.ended_notice", s.title || t("page_share.untitled")));
1295
+ }
1296
+ /** #2911: a page share was made — said in the channel of the instance whose chat it came from (never its URL). */
1297
+ pageShareNotice(who, share) {
1298
+ const minutes = Math.round((share.expiresAt - share.createdAt) / 60_000);
1299
+ this.notifyInstanceTopic(share.instance, t("page_share.created_notice", who, share.title || t("page_share.untitled"), minutes < 60 ? `${minutes} min` : `${Math.round(minutes / 60)} h`));
1300
+ }
1218
1301
  /** A share's owner: a person (`person:<adapter>:<user>`) still a fleet admin there; the host's own admin session stays. */
1219
1302
  officeShareOwnerCurrent(owner) {
1220
1303
  const m = /^person:([^:]+):(.+)$/.exec(owner);
@@ -1612,6 +1695,17 @@ export class FleetManager {
1612
1695
  const actor = verified();
1613
1696
  if (!actor)
1614
1697
  return null;
1698
+ // #2910 D1: a web-only fleet's host operator (its `agend web --code` session on this computer) confirms its own
1699
+ // change — the trust `agend settings confirm` has here; there is no account to be an admin of and no General to
1700
+ // ask. Asked again at execution: still the host operator now (this computer, not revoked or run out), still web-only.
1701
+ const hostOperator = (a) => !!a && a.adapter_id === HOST_OPERATOR.adapterId && a.user_id === HOST_OPERATOR.userId && this.isWebOnlyFleet();
1702
+ if (hostOperator(actor)) {
1703
+ return {
1704
+ label: t("settings_confirm.host_operator_label"),
1705
+ current: () => hostOperator(verified()),
1706
+ done: view => { this.logger.info({ id: view.id, state: view.state }, "Settings change confirmed from the web by this computer's admin"); },
1707
+ };
1708
+ }
1615
1709
  const chat = this.settingsChatAuthority(store, id);
1616
1710
  if (!chat)
1617
1711
  return null;
@@ -2078,6 +2172,22 @@ export class FleetManager {
2078
2172
  * startup-only key at startup. See checkStartupSignatureConsistency(). */
2079
2173
  fleetSignatureMismatch = null;
2080
2174
  healthServerListening = false;
2175
+ agentCredentialsCache;
2176
+ get agentCredentials() {
2177
+ return this.agentCredentialsCache ??= new AgentCredentials(name => agentCredentialPolicy(this.fleetConfig, name, this.isClassicInstance(name)), name => {
2178
+ try {
2179
+ const token = randomBytes(32).toString("hex");
2180
+ const result = writeSecretFile(join(this.getInstanceDir(name), "agent.token"), token);
2181
+ if (!result.ok)
2182
+ this.logger.error({ name, mode: result.mode, reason: result.reason }, "Agent token file is not owner-only");
2183
+ return token;
2184
+ }
2185
+ catch {
2186
+ this.logger.error({ name }, "Agent credential renewal failed; prior generation remains retired");
2187
+ return null;
2188
+ }
2189
+ });
2190
+ }
2081
2191
  constructor(dataDir) {
2082
2192
  this.dataDir = dataDir;
2083
2193
  FleetManager.signalTarget = this;
@@ -2100,6 +2210,7 @@ export class FleetManager {
2100
2210
  this.topicCommands = new TopicCommands(this);
2101
2211
  this.topicArchiver = new TopicArchiver(this);
2102
2212
  this.statuslineWatcher = new StatuslineWatcher(this);
2213
+ this.missedOfflineStore = new MissedOfflineStore(this.dataDir);
2103
2214
  }
2104
2215
  /** #1266: a reply's buttons — the store (reply-buttons.db) and what a click does. Created on first use. */
2105
2216
  replyButtonsCtl = null;
@@ -2458,6 +2569,10 @@ export class FleetManager {
2458
2569
  const daemon = this.daemons.get(name);
2459
2570
  if (daemon?.bootId === daemonBootId)
2460
2571
  this.listenForWorkingState(name, daemon);
2572
+ // #3066 §1d: a Codex launch records the credential profile it runs under, so its usage is told by account.
2573
+ if (daemon?.bootId === daemonBootId && this.backendNameOf(name) === "codex") {
2574
+ void recordProfileLaunch(join(this.getInstanceDir(name), PROFILE_TIMELINE_FILE), this.credentialProfileOf(name, "codex") ?? "default");
2575
+ }
2461
2576
  // #2802: no post-restart notices. The outbox still delivers or fails a row accepted before the restart, and an open
2462
2577
  // request is reminded by the ordinary turn-ended nudge (sweepReplyObligations) once the owner is idle.
2463
2578
  this.scheduleDeliveryOutboxPump();
@@ -3295,6 +3410,7 @@ export class FleetManager {
3295
3410
  * guild channel.
3296
3411
  */
3297
3412
  reregisterClassicChannels() {
3413
+ this.agentCredentialsCache?.observePolicies();
3298
3414
  if (!this.classicChannels)
3299
3415
  return;
3300
3416
  const channels = this.classicChannels.getAll();
@@ -3985,6 +4101,13 @@ export class FleetManager {
3985
4101
  changed: () => this.emitSseEvent("status", this.getUiStatus()),
3986
4102
  });
3987
4103
  }
4104
+ /**
4105
+ * Claude history in the View: an instance's earlier conversation for its View, from the transcript the Log reads —
4106
+ * the View's route applied its own access rules first (view-api.ts); the Log's switch plays no part.
4107
+ */
4108
+ viewHistory(name, q) {
4109
+ return viewHistory((n, rq) => this.log.readBack(n, rq), name, q);
4110
+ }
3988
4111
  /** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
3989
4112
  logSteps(name, q) { return this.log.steps(name, q); }
3990
4113
  /**
@@ -5607,6 +5730,23 @@ export class FleetManager {
5607
5730
  if (resumePaused)
5608
5731
  this.lifecycle.markActivitySeedNow(name);
5609
5732
  const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
5733
+ // #1490 row 67: off-loop resolve before skill/instruction publication
5734
+ // constructs a backend (resolveKnowledgeWorkDir). Capture stop/lifecycle
5735
+ // ownership first: a Stop during this wait finishes and bumps the epoch,
5736
+ // and the inner lifecycle check would otherwise capture the new epoch and
5737
+ // accept the stale start. Lifecycle start looks up again before its own
5738
+ // createBackend; the probe cache makes that cheap.
5739
+ const epoch = this.lifecycle.epochOf(name);
5740
+ const stopGen = this.explicitStopGeneration.get(name) ?? 0;
5741
+ const resolved = await resolveBackendBinary(backend);
5742
+ execution?.assert();
5743
+ if (this.lifecycle.epochOf(name) !== epoch
5744
+ || (this.explicitStopGeneration.get(name) ?? 0) !== stopGen) {
5745
+ throw new SupersededStartError(name);
5746
+ }
5747
+ const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
5748
+ if (binary)
5749
+ noteResolvedBinary(binary, resolved ?? binary);
5610
5750
  if (config.general_topic) {
5611
5751
  this.ensureGeneralInstructions(config.working_directory, backend, name);
5612
5752
  }
@@ -5690,8 +5830,7 @@ export class FleetManager {
5690
5830
  }
5691
5831
  /** Recreate a daemon for a marker-only paused instance after an explicit wake/delivery. */
5692
5832
  async startPersistedPausedInstance(name, transition) {
5693
- const topicMode = this.fleetConfig?.channel?.mode === "topic"
5694
- || !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
5833
+ const topicMode = daemonTopicMode(this.fleetConfig);
5695
5834
  const fleetConfig = this.fleetConfig?.instances[name];
5696
5835
  if (fleetConfig) {
5697
5836
  await this.startInstance(name, fleetConfig, topicMode, "fleet-topic", false, transition);
@@ -5902,8 +6041,7 @@ export class FleetManager {
5902
6041
  this.logger.info({ name, attempt: attempt + 1 }, "Startup retry deferred — tmux storm window is blocking spawns");
5903
6042
  return;
5904
6043
  }
5905
- const topicMode = this.fleetConfig?.channel?.mode === "topic"
5906
- || !!this.fleetConfig?.channels?.some(channel => channel.mode === "topic");
6044
+ const topicMode = daemonTopicMode(this.fleetConfig);
5907
6045
  this.startupRetriesRunning.add(name);
5908
6046
  try {
5909
6047
  await this.spawnGate.run({
@@ -6065,6 +6203,17 @@ export class FleetManager {
6065
6203
  this.queueKiroIncompatNotice(name, err.message);
6066
6204
  return true;
6067
6205
  }
6206
+ /** No pane is needed for a compatibility refusal to remain in Needs you. */
6207
+ setKiroCompatibilityHold(name, message) {
6208
+ const holds = this.kiroCompatibilityHolds ??= new Map();
6209
+ if ((message === null && !holds.has(name)) || (message !== null && holds.get(name)?.message === message))
6210
+ return;
6211
+ if (message === null)
6212
+ holds.delete(name);
6213
+ else
6214
+ holds.set(name, { message, since: Date.now() });
6215
+ this.needsYou?.poke();
6216
+ }
6068
6217
  /**
6069
6218
  * LifecycleContext: a kiro instance the installed kiro-cli cannot run as
6070
6219
  * configured (#1109) — at start, or at a respawn after kiro-cli replaced
@@ -6259,6 +6408,8 @@ export class FleetManager {
6259
6408
  }
6260
6409
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
6261
6410
  async restartSingleInstance(name, opts) {
6411
+ if (opts?.ownerCurrent && !opts.ownerCurrent())
6412
+ throw new RestartSupersededError(this.shownInstanceName(name));
6262
6413
  const migration = this.kiroMigrationRuns?.get(name);
6263
6414
  if (migration && migration.token !== opts?.kiroMigration)
6264
6415
  throw new Error(t("kiro_migrate.in_progress"));
@@ -6426,7 +6577,7 @@ export class FleetManager {
6426
6577
  const backend = config.backend ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
6427
6578
  return { backend, profile: credentialHomeSpec(backend) ? instanceCredentialProfile(config, this.fleetConfig?.defaults, backend) : null };
6428
6579
  });
6429
- const topicMode = this.fleetConfig?.channel?.mode === "topic";
6580
+ const topicMode = daemonTopicMode(this.fleetConfig);
6430
6581
  await this.startAfterRestart(name, wasPaused, pausedAt, pauseReason, () => opts?.kiroMigration ? this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition, undefined, opts.kiroMigration)
6431
6582
  : this.startInstance(name, config, topicMode ?? false, "fleet-topic", false, transition));
6432
6583
  this.stormWindow.markRecovered(name);
@@ -6729,6 +6880,7 @@ export class FleetManager {
6729
6880
  if (!publicPreviewWanted(this.fleetConfig?.web))
6730
6881
  return null;
6731
6882
  return new PublicPreview({ exposureId: id, expiresAt, dashboardOrigin, isCurrent, manager: this.getPreviewTunnel(),
6883
+ cdn: () => this.fleetConfig?.web?.preview_cdn === true,
6732
6884
  provider: () => new CloudflaredProvider({ binaryName: binary, protocol }),
6733
6885
  log: (event, detail) => this.logger.info({ event, exposureId: id, ...(detail ? { detail } : {}) }, "Public web link") });
6734
6886
  },
@@ -7558,7 +7710,7 @@ export class FleetManager {
7558
7710
  return { kind: "refused" };
7559
7711
  if (device.tier === "guest")
7560
7712
  return this.requestGuestDeviceCode(device, input, token);
7561
- const paused = this.webLoginCodes.pausedForMs();
7713
+ const paused = this.webLoginCodes.pausedForMs(input.exposureId ?? "local");
7562
7714
  if (paused > 0)
7563
7715
  return { kind: "limited", retryAfterMs: paused };
7564
7716
  const admitted = this.codeRequestLimiter.admit(device.idHash);
@@ -7690,7 +7842,8 @@ export class FleetManager {
7690
7842
  return;
7691
7843
  // The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
7692
7844
  // sign-in from the same kind of browser is exactly the one the operator most needs to hear about.
7693
- this.notifyFleetError(t("web.login_notice", info.label, info.surface, info.handle.slice(0, 8)));
7845
+ // #2910 D2: a web-only fleet has no /web to revoke with — its Notice names the host's way (every browser signed out).
7846
+ this.notifyFleetError(t(this.webOnlyPromptPlace() ? "web.login_notice_web_only" : "web.login_notice", info.label, info.surface, info.handle.slice(0, 8)));
7694
7847
  }
7695
7848
  getDashboardAccess() {
7696
7849
  return { ready: this.healthServerListening, token: this.webToken };
@@ -8042,6 +8195,9 @@ export class FleetManager {
8042
8195
  this.watchdogTimer = startSdWatchdog();
8043
8196
  this.eventLoopWatch?.stop();
8044
8197
  this.eventLoopWatch = startEventLoopWatch({ logger: this.logger });
8198
+ // #3009: the event-loop watch runs on the main thread; this one does not, so a stall that never ends is still named.
8199
+ this.stallReporter?.stop();
8200
+ this.stallReporter = startStallReporter({ logFile: join(this.dataDir, "daemon.log") });
8045
8201
  try {
8046
8202
  await this.startAllWithinDeadline(configPath, deadline);
8047
8203
  }
@@ -8055,6 +8211,8 @@ export class FleetManager {
8055
8211
  this.watchdogTimer = null;
8056
8212
  this.eventLoopWatch?.stop();
8057
8213
  this.eventLoopWatch = null;
8214
+ this.stallReporter?.stop();
8215
+ this.stallReporter = null;
8058
8216
  }
8059
8217
  }
8060
8218
  }
@@ -8110,7 +8268,8 @@ export class FleetManager {
8110
8268
  // Must run before the first General/CLI spawn: pressure can hold startup.
8111
8269
  // Logs remain available even before the adapters and health listener exist.
8112
8270
  this.memoryPressure.start();
8113
- const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
8271
+ // #2910: a web-only fleet's agents run in topic mode too (their cross-instance tools work; the dashboard is their place).
8272
+ const topicMode = daemonTopicMode(fleet);
8114
8273
  // Set tmux socket isolation for custom AGEND_HOME
8115
8274
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
8116
8275
  TmuxManager.setSocketName(getSocket());
@@ -8321,13 +8480,18 @@ export class FleetManager {
8321
8480
  const generalDir = join(getAgendHome(), name);
8322
8481
  mkdirSync(generalDir, { recursive: true, mode: 0o700 });
8323
8482
  const backendName = fleet.defaults.backend ?? "claude-code";
8483
+ const resolved = await resolveBackendBinary(backendName);
8484
+ const binary = BACKEND_INSTALLATION_INFO[backendName]?.binary;
8485
+ if (binary)
8486
+ noteResolvedBinary(binary, resolved ?? binary);
8324
8487
  this.ensureGeneralInstructions(generalDir, backendName, name);
8325
8488
  fleet.instances[name] = autoGeneralInstanceConfig(fleet.defaults, generalDir, need.adapterId); // #2598: its kiro_ui is the fleet's
8326
8489
  generalsCreated = true;
8327
8490
  }
8328
8491
  if (generalsCreated && !this.configHistory?.isFallback(this.configPath))
8329
8492
  this.saveFleetConfig();
8330
- if (topicMode && (fleet.channel || fleet.channels?.length)) {
8493
+ // #2910: the scheduler (schedules, the task board, decisions) runs on a web-only fleet too; it needs no chat.
8494
+ if (topicMode) {
8331
8495
  const schedulerConfig = {
8332
8496
  ...DEFAULT_SCHEDULER_CONFIG,
8333
8497
  ...this.fleetConfig?.defaults.scheduler,
@@ -8474,6 +8638,9 @@ export class FleetManager {
8474
8638
  if (others.length > 0 || classicAtBoot.length > 0) {
8475
8639
  await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady(), classicAtBoot);
8476
8640
  }
8641
+ // #3066 §1d (Fable r1): on every fleet — a web-only one (#2910) has no chat connection at all, and is the clearest
8642
+ // case with no Discord bot reading. Each tick decides for itself whether to read (backgroundQuotaRead).
8643
+ this.startQuotaBackgroundReads();
8477
8644
  if (topicMode && (fleet.channel || fleet.channels?.length)) {
8478
8645
  await adapterStartup;
8479
8646
  this.startDiscordUsagePresence();
@@ -8645,6 +8812,178 @@ export class FleetManager {
8645
8812
  this.logger.warn({ health }, "Fleet started with problems — see /health");
8646
8813
  }
8647
8814
  }
8815
+ /**
8816
+ * #3066 §1d (the user: yes, every 30 min): the quota history (usage/quota-history.ts) records only the readings AgEnD
8817
+ * makes. A Discord bot's presence refresh reads every 15 min; without one, nothing reads while nobody looks. So, with no
8818
+ * Discord bot and while some account has a running agent, read every 30 min — the same cached read (its 5-minute TTL
8819
+ * still bounds the vendor calls), recorded like any other.
8820
+ */
8821
+ startQuotaBackgroundReads() {
8822
+ if (this.quotaReadTimer)
8823
+ clearInterval(this.quotaReadTimer);
8824
+ this.quotaReadTimer = setInterval(() => { void this.backgroundQuotaRead(); }, FleetManager.QUOTA_BACKGROUND_READ_MS);
8825
+ this.quotaReadTimer.unref?.();
8826
+ // #3066 §1: the measured capacity, published for the usage reads (get_usage, the page) — refreshed every 10 min.
8827
+ setCapacityLookup((provider, metric) => this.capacityEstimates.get(`${provider}\u0000${metric}`) ?? null);
8828
+ if (this.capacityTimer)
8829
+ clearInterval(this.capacityTimer);
8830
+ void this.refreshCapacity();
8831
+ this.capacityTimer = setInterval(() => { void this.refreshCapacity(); }, FleetManager.CAPACITY_REFRESH_MS);
8832
+ this.capacityTimer.unref?.();
8833
+ }
8834
+ /** #3066 §1: the latest estimates, by `provider\0metric` (what usage-api's lookup reads, synchronously). */
8835
+ capacityEstimates = new Map();
8836
+ capacityTimer = null;
8837
+ static CAPACITY_REFRESH_MS = 10 * 60_000;
8838
+ /**
8839
+ * #3066 §1: estimate each account's weekly 100% from the quota history (§1d) and the usage ledger's hours — both read
8840
+ * asynchronously, the arithmetic small (usage/capacity.ts). An account is its usage provider id ("claude",
8841
+ * "claude:hsinlin", "codex:yanxianchen"): its hours are the ledger sources of that backend under that credential profile
8842
+ * (Codex's "unrecorded" hours are no account's). A per-model weekly ("Sonnet (weekly)") counts only that model's tokens.
8843
+ *
8844
+ * Prism #3073: no estimate rather than a wrong one —
8845
+ * - a window is measured only where the ledger has its hours: kept (31 days) and scanned (`days.coverage`) for every
8846
+ * instance of the account's backend, and every instance whose hours count for it; the quota history goes back 12 weeks;
8847
+ * - a profile literally named "default", "unknown" or "unrecorded" (configured, on disk, or in the quota history) makes
8848
+ * those hours ambiguous: neither the host
8849
+ * account nor the named one is estimated (each would be credited the other's use, or lose its own);
8850
+ * - only a metric the vendor marks per-model (`scope: "model"`) is filtered by model; another named weekly pool
8851
+ * ("Luna Reserve (weekly)", Antigravity pools) is no model name and is not estimated.
8852
+ */
8853
+ async refreshCapacity() {
8854
+ try {
8855
+ const readings = await readQuotaHistory();
8856
+ const groups = new Map();
8857
+ for (const r of readings) {
8858
+ const k = `${r.provider}\u0000${r.metric}`;
8859
+ (groups.get(k) ?? groups.set(k, []).get(k)).push(r);
8860
+ }
8861
+ if (!groups.size) {
8862
+ this.capacityEstimates = new Map();
8863
+ return 0;
8864
+ }
8865
+ const data = await this.cacheServiceFor().usageData();
8866
+ const now = Date.now(), DAY = 24 * 3_600_000, HOUR = 3_600_000;
8867
+ const keptFrom = now - KEEP_DAYS * DAY + HOUR; // the oldest hour still in every ledger (an hour of margin)
8868
+ // Profiles whose names are also the ledger's own tags: their hours cannot be told from the host's / unattributed.
8869
+ // Any trace of such a profile counts (Prism #3073 r2), not only today's config — a binding removed or switched
8870
+ // leaves its hours in the ledger: configured now, its directory on this host, or its account in the quota history.
8871
+ const RESERVED = new Set(["default", "unknown", PROFILE_UNRECORDED]);
8872
+ const historyProviders = new Set(readings.map((r) => r.provider));
8873
+ const reservedNamed = new Map();
8874
+ const reservedFor = (backend) => {
8875
+ const known = reservedNamed.get(backend);
8876
+ if (known)
8877
+ return known;
8878
+ const found = new Set(listConfiguredProfiles(this.fleetConfig, backend).filter((n) => RESERVED.has(n)));
8879
+ const base = usageProviderIdForBackend(backend);
8880
+ for (const n of RESERVED) {
8881
+ if (base && historyProviders.has(`${base}:${n}`))
8882
+ found.add(n);
8883
+ if (credentialHomeSpec(backend) && existsSync(credentialProfileHome(this.dataDir, backend, n)))
8884
+ found.add(n);
8885
+ }
8886
+ return reservedNamed.set(backend, found).get(backend);
8887
+ };
8888
+ // Every instance of a backend may have used any of its accounts (bindings change): an account is measured only
8889
+ // where all of them were scanned (Prism #3073 r2) — one still waiting for its first scan has no hours yet, not none.
8890
+ const daysOfBase = new Map();
8891
+ for (const inst of data.instances) {
8892
+ const base = usageProviderIdForBackend(inst.inst.backend);
8893
+ if (base)
8894
+ (daysOfBase.get(base) ?? daysOfBase.set(base, []).get(base)).push(inst.days);
8895
+ }
8896
+ // provider id → hour → model key → totals; and the instances whose hours count for it (their scan coverage).
8897
+ const byProvider = new Map();
8898
+ const daysOf = new Map();
8899
+ for (const inst of data.instances) {
8900
+ for (const [hk, hour] of Object.entries(inst.ledger.hours)) {
8901
+ for (const src of hourSources(hour)) {
8902
+ const base = usageProviderIdForBackend(src.backend);
8903
+ if (!base)
8904
+ continue;
8905
+ // Ambiguous (a named profile with the tag's name), or no account's: counted for no one, so an account whose
8906
+ // hours these may be has none — and gets no estimate, never a low one.
8907
+ if (reservedFor(src.backend).has(src.profile) || src.profile === "unknown" || src.profile === PROFILE_UNRECORDED)
8908
+ continue;
8909
+ const provider = src.profile === "default" ? base : `${base}:${src.profile}`;
8910
+ const list = daysOf.get(provider) ?? daysOf.set(provider, []).get(provider);
8911
+ if (!list.includes(inst.days))
8912
+ list.push(inst.days);
8913
+ const hours = byProvider.get(provider) ?? byProvider.set(provider, new Map()).get(provider);
8914
+ const into = hours.get(Number(hk)) ?? hours.set(Number(hk), Object.create(null)).get(Number(hk));
8915
+ for (const [mk, tot] of Object.entries(src.m)) {
8916
+ const cur = into[mk] ??= [0, 0, 0, 0, 0, 0, 0];
8917
+ for (let i = 0; i < 7; i++)
8918
+ cur[i] = (cur[i] ?? 0) + (tot[i] ?? 0);
8919
+ }
8920
+ }
8921
+ }
8922
+ }
8923
+ const next = new Map();
8924
+ for (const [key, list] of groups) {
8925
+ const [provider, metric] = key.split("\u0000");
8926
+ // A per-model weekly ("Sonnet (weekly)", the vendor's scope "model"): that model's tokens only. Another named
8927
+ // pool is not a model: no estimate. "Weekly", "Weekly limit": everything.
8928
+ const named = /^(.+) \((?:weekly|monthly|\d+d)\)$/i.exec(metric)?.[1]?.toLowerCase() ?? null;
8929
+ const perModel = list.some((r) => r.scope === "model");
8930
+ if (named && !perModel)
8931
+ continue;
8932
+ const hours = [];
8933
+ for (const [hour, models] of byProvider.get(provider) ?? []) {
8934
+ let api = 0, output = 0, all = 0;
8935
+ for (const [mk, tot] of Object.entries(models)) {
8936
+ const bar = mk.lastIndexOf("|"), model = mk.slice(0, bar), long = mk.slice(bar + 1) === "1";
8937
+ if (named && !model.toLowerCase().includes(named))
8938
+ continue;
8939
+ const [, uncached, read, w5, w1h, w, out] = tot;
8940
+ const r = ratesFor(model, long ? Number.MAX_SAFE_INTEGER : 0);
8941
+ api = api === null || !r ? null : api + (uncached * r.input + read * r.read + w5 * r.write5m + w1h * r.write1h + w * r.write + out * r.output) / 1_000_000;
8942
+ output += out;
8943
+ all += uncached + read + w5 + w1h + w + out;
8944
+ }
8945
+ if (all > 0)
8946
+ hours.push({ hour, api, output, all });
8947
+ }
8948
+ const base = provider.split(":")[0];
8949
+ const scanned = [...new Set([...(daysOf.get(provider) ?? []), ...(daysOfBase.get(base) ?? [])])];
8950
+ const available = (from, to) => {
8951
+ if (from < keptFrom || !scanned.length)
8952
+ return false;
8953
+ for (let h = from; h <= to; h += HOUR)
8954
+ if (!scanned.every((d) => covered(d, h)))
8955
+ return false;
8956
+ return true;
8957
+ };
8958
+ const est = estimateCapacity(list, hours, available);
8959
+ if (est)
8960
+ next.set(key, est);
8961
+ }
8962
+ this.capacityEstimates = next;
8963
+ return next.size;
8964
+ }
8965
+ catch (err) {
8966
+ this.logger.debug({ err }, "capacity estimate refresh failed");
8967
+ return -1;
8968
+ }
8969
+ }
8970
+ /** One background quota read, when nothing else reads (no Discord bot) and an account has a running agent. */
8971
+ async backgroundQuotaRead() {
8972
+ if (this.shuttingDown)
8973
+ return false;
8974
+ if ([...this.adapters.values()].some(a => a.type === "discord" && typeof a.setActivity === "function"))
8975
+ return false;
8976
+ const active = this.getActiveUsageProviderIds();
8977
+ if (active.size === 0)
8978
+ return false;
8979
+ try {
8980
+ await getUsageSnapshot(false, active);
8981
+ return true;
8982
+ }
8983
+ catch {
8984
+ return false;
8985
+ }
8986
+ }
8648
8987
  /** Keep Discord profile activity aligned with the same cached usage source as /usage. */
8649
8988
  startDiscordUsagePresence() {
8650
8989
  if (this.discordPresenceTimer)
@@ -9109,6 +9448,9 @@ export class FleetManager {
9109
9448
  return;
9110
9449
  await this.handleInboundMessage(msg);
9111
9450
  }, this.logger, "adapter.message"));
9451
+ // #2940: a deleted message's chat-log line goes (and it is never logged after this).
9452
+ adapter.on("message_deleted", (e) => { if (isCurrentAdapter())
9453
+ this.onPlatformMessageDeleted(e); });
9112
9454
  adapter.on("reaction", safeHandler(async (r) => {
9113
9455
  if (!isCurrentAdapter())
9114
9456
  return;
@@ -9117,6 +9459,11 @@ export class FleetManager {
9117
9459
  adapter.on("callback_query", safeHandler(async (data) => {
9118
9460
  await this.receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter);
9119
9461
  }, this.logger, "adapter.callback_query"));
9462
+ adapter.on("missed_offline", (ev) => {
9463
+ if (!isCurrentAdapter())
9464
+ return;
9465
+ this.recordMissedOffline(ev);
9466
+ });
9120
9467
  this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
9121
9468
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
9122
9469
  adapter.on("slash_command", safeHandler(async (data) => {
@@ -9259,6 +9606,9 @@ export class FleetManager {
9259
9606
  return;
9260
9607
  await this.handleInboundMessage(msg);
9261
9608
  }, this.logger, `adapter[${adapterId}].message`));
9609
+ // #2940: a deleted message's chat-log line goes (and it is never logged after this).
9610
+ adapter.on("message_deleted", (e) => { if (isCurrentAdapter())
9611
+ this.onPlatformMessageDeleted(e); });
9262
9612
  adapter.on("reaction", safeHandler(async (r) => {
9263
9613
  if (!isCurrentAdapter())
9264
9614
  return;
@@ -9267,6 +9617,11 @@ export class FleetManager {
9267
9617
  adapter.on("callback_query", safeHandler(async (data) => {
9268
9618
  await this.receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter);
9269
9619
  }, this.logger, `adapter[${adapterId}].callback_query`));
9620
+ adapter.on("missed_offline", (ev) => {
9621
+ if (!isCurrentAdapter())
9622
+ return;
9623
+ this.recordMissedOffline(ev);
9624
+ });
9270
9625
  this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
9271
9626
  // Slash commands: classic bot + admin commands
9272
9627
  adapter.on("slash_command", safeHandler(async (data) => {
@@ -10003,6 +10358,34 @@ export class FleetManager {
10003
10358
  }
10004
10359
  return true;
10005
10360
  }
10361
+ /** File a Telegram update that was too old to inject; Needs you lists it until Acknowledge. */
10362
+ recordMissedOffline(ev) {
10363
+ if (!this.missedOfflineStore)
10364
+ this.missedOfflineStore = new MissedOfflineStore(this.dataDir);
10365
+ const stub = {
10366
+ source: "telegram", adapterId: ev.adapterId, chatId: ev.chatId, threadId: ev.threadId,
10367
+ messageId: ev.messageId, userId: ev.userId, username: ev.username, text: ev.preview,
10368
+ timestamp: ev.timestamp, isBotMessage: false,
10369
+ };
10370
+ const threadId = this.inboundRouteThreadId(stub);
10371
+ const target = threadId ? this.resolveInboundTarget(stub, threadId) : undefined;
10372
+ let instance = target?.name ?? "";
10373
+ if (!instance) {
10374
+ instance = Object.entries(this.fleetConfig?.instances ?? {}).find(([name, cfg]) => cfg.general_topic && this.getInstanceAdapterId(name) === ev.adapterId)?.[0] ?? "";
10375
+ }
10376
+ const place = [ev.chatTitle, ev.threadId ? `#${ev.threadId}` : ""].filter(Boolean).join(" · ") || ev.chatId;
10377
+ this.missedOfflineStore.add({
10378
+ id: `missed:${ev.adapterId}:${ev.updateId}`,
10379
+ adapterId: ev.adapterId,
10380
+ instance,
10381
+ username: ev.username,
10382
+ place,
10383
+ preview: ev.preview,
10384
+ since: ev.timestamp.getTime(),
10385
+ });
10386
+ this.logger.info({ adapterId: ev.adapterId, updateId: ev.updateId, instance, chatId: ev.chatId, threadId: ev.threadId }, "Telegram message missed while AgEnD was offline — filed for Needs you");
10387
+ this.needsYou?.poke();
10388
+ }
10006
10389
  /**
10007
10390
  * #1085: coincident topic numbers must not route across Telegram groups.
10008
10391
  */
@@ -10836,7 +11219,10 @@ export class FleetManager {
10836
11219
  // else to go, and "retry shortly" would have the agent retry forever. It goes to the web chat.
10837
11220
  const webOnlyReply = tool === "reply" && this.worlds.size === 0 && this.isWebOnlyFleet();
10838
11221
  if (this.worlds.size === 0 && !webOnlyReply) {
10839
- respond(null, "Channel adapters are not ready — retry shortly");
11222
+ // #2910: on a web-only fleet there will never be an adapter — say so, so the agent does not retry forever.
11223
+ respond(null, this.isWebOnlyFleet()
11224
+ ? `${tool} is not available on a web-only fleet (no Discord or Telegram connection); answer with reply`
11225
+ : "Channel adapters are not ready — retry shortly");
10840
11226
  return;
10841
11227
  }
10842
11228
  // Resolve threadId: use sender's topic_id if sender is a known fleet instance,
@@ -11036,9 +11422,9 @@ export class FleetManager {
11036
11422
  }
11037
11423
  /** Side effects of a routed reply: cancel-button lifecycle, logs, SSE, chat log. */
11038
11424
  /** No chat platform is configured at all: the web dashboard is the fleet's only surface. */
11425
+ /** #2910: no Discord or Telegram connection at all (src/web-only-fleet.ts). */
11039
11426
  isWebOnlyFleet() {
11040
- const config = this.fleetConfig;
11041
- return config != null && !config.channel && !(config.channels?.length);
11427
+ return isWebOnlyFleetConfig(this.fleetConfig);
11042
11428
  }
11043
11429
  /** On a fleet with no chat platform, the dashboard is where an instance-health prompt is posted (#1307 item 6). */
11044
11430
  webOnlyPromptPlace() {
@@ -11413,6 +11799,9 @@ export class FleetManager {
11413
11799
  // through the target's adapter, so a Telegram-created schedule for a
11414
11800
  // Discord target announced its failure into the wrong platform.
11415
11801
  const adapter = this.scheduleSourceAdapter(schedule);
11802
+ // #2910 D2: a web-only fleet has no chat to say it in: a Notice about the schedule's target.
11803
+ if (!adapter && this.recordWebNotice("schedule", `⏰ Schedule "${schedule.label ?? schedule.id}" trigger failed: instance ${schedule.target} is offline.`, schedule.target))
11804
+ return;
11416
11805
  if (!adapter) {
11417
11806
  this.logger.warn({ scheduleId: schedule.id, chatId: schedule.reply_chat_id, source: schedule.source }, "No adapter can reach the schedule's source chat — failure notice not sent");
11418
11807
  return;
@@ -11620,6 +12009,11 @@ export class FleetManager {
11620
12009
  let result;
11621
12010
  switch (msg.type) {
11622
12011
  case "fleet_decision_create": {
12012
+ const supersedes = this.decisionSupersedes("ipc-typed", instanceName, payload.supersedes);
12013
+ if ("error" in supersedes) {
12014
+ ipc.send({ type: "fleet_decision_response", fleetRequestId, error: supersedes.error });
12015
+ return;
12016
+ }
11623
12017
  // Prune expired decisions on create
11624
12018
  db.pruneExpiredDecisions();
11625
12019
  result = db.createDecision({
@@ -11630,7 +12024,7 @@ export class FleetManager {
11630
12024
  tags: payload.tags,
11631
12025
  ttl_days: payload.ttl_days,
11632
12026
  created_by: instanceName,
11633
- supersedes: payload.supersedes,
12027
+ supersedes: supersedes.id,
11634
12028
  });
11635
12029
  break;
11636
12030
  }
@@ -11807,6 +12201,21 @@ export class FleetManager {
11807
12201
  return "ok";
11808
12202
  }
11809
12203
  }
12204
+ /**
12205
+ * `post_decision` with `supersedes` changes a decision someone else may have recorded (it stops being active): the
12206
+ * same permission as `update_decision`, asked through the same live check (tool set + dashboard switches), on both
12207
+ * create paths. An empty or absent value supersedes nothing and needs nothing; anything else must be an id.
12208
+ */
12209
+ decisionSupersedes(sink, instanceName, value) {
12210
+ if (value === undefined || value === null || value === "")
12211
+ return { id: undefined };
12212
+ if (typeof value !== "string")
12213
+ return { error: "supersedes must be a decision id" };
12214
+ const permitted = this.checkToolPermission(sink, instanceName, "update_decision");
12215
+ if (!permitted.allowed)
12216
+ return { error: `Superseding a decision needs update_decision. ${permitted.message}` };
12217
+ return { id: value };
12218
+ }
11810
12219
  async handleDecisionCrudHttp(instance, op, args) {
11811
12220
  if (!this.scheduler)
11812
12221
  return { error: "Scheduler not available" };
@@ -11822,16 +12231,24 @@ export class FleetManager {
11822
12231
  if (!title || !content)
11823
12232
  return { error: "title and content are required" };
11824
12233
  const scope = args.scope === "fleet" ? "fleet" : "project";
11825
- return db.createDecision({
11826
- project_root: projectRoot,
11827
- scope,
11828
- title,
11829
- content,
11830
- tags: asStrArr(args.tags),
11831
- ttl_days: asNum(args.ttl_days),
11832
- supersedes: asStr(args.supersedes),
11833
- created_by: instance,
11834
- });
12234
+ const supersedes = this.decisionSupersedes("agent-endpoint", instance, args.supersedes);
12235
+ if ("error" in supersedes)
12236
+ return { error: supersedes.error };
12237
+ try {
12238
+ return db.createDecision({
12239
+ project_root: projectRoot,
12240
+ scope,
12241
+ title,
12242
+ content,
12243
+ tags: asStrArr(args.tags),
12244
+ ttl_days: asNum(args.ttl_days),
12245
+ supersedes: supersedes.id,
12246
+ created_by: instance,
12247
+ });
12248
+ }
12249
+ catch (err) {
12250
+ return { error: err.message }; // a refused predecessor: nothing was inserted
12251
+ }
11835
12252
  }
11836
12253
  // #2152: titles by default (agend-agent decision-list); one decision's full text by id (decision-get <id>).
11837
12254
  // The CLI sends include_archived (snake case, as the MCP tool): both spellings are read.
@@ -12120,16 +12537,89 @@ export class FleetManager {
12120
12537
  }
12121
12538
  return adapter.createTopic(topicName);
12122
12539
  }
12123
- async deleteForumTopic(topicId, adapterId) {
12124
- try {
12125
- const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
12126
- if (!adapter?.deleteTopic)
12127
- return;
12128
- await adapter.deleteTopic(topicId);
12129
- }
12130
- catch (err) {
12131
- this.logger.warn({ err, topicId }, "Failed to delete forum topic during rollback");
12132
- }
12540
+ /**
12541
+ * Delete an agent and, unless asked not to, its own chat topic (user decision: by default). The topic is planned
12542
+ * before the removal (its id and bot go with the fleet.yaml entry) and deleted through the agent's OWN bot after it;
12543
+ * the outcome is the platform's answer (instance-topic-delete.ts). A topic that cannot go never fails the removal.
12544
+ */
12545
+ async removeInstanceAndTopic(name, authorization, opts) {
12546
+ const plan = opts.deleteTopic ? this.planInstanceTopicDelete(name) : null;
12547
+ if (opts.execution)
12548
+ await this.removeInstance(name, authorization, opts.execution);
12549
+ else
12550
+ await this.removeInstance(name, authorization);
12551
+ // #3055 review (Prism P1): asked again right before the platform call — the fleet as it is NOW, not as planned
12552
+ // before the removal was awaited (a new owner of the topic, or the bot rebuilt).
12553
+ return deletePlannedTopic(plan, this.logger, (p) => {
12554
+ if (this.instanceTopicAdapter(p.adapterId) !== p.adapter)
12555
+ return "the agent's bot changed while it was being deleted, so its topic was left";
12556
+ return topicMustStay(p.destination, this.topicOwnership(), null); // the original is gone: every owner now counts
12557
+ }, { begin: (d) => this.topicDeletionsInFlight.add(d), end: (d) => this.topicDeletionsInFlight.delete(d) });
12558
+ }
12559
+ /** #3055: destinations whose platform deletion is running now; a binding to one waits until it is over. */
12560
+ topicDeletionsInFlight = new Set();
12561
+ /** True while a topic with this id is being deleted on any connection (a new binding of it is refused meanwhile). */
12562
+ isTopicDeletionInFlight(topicId) {
12563
+ const tail = `:${topicId}`;
12564
+ return [...this.topicDeletionsInFlight].some(d => d.endsWith(tail));
12565
+ }
12566
+ /**
12567
+ * #3055 review r2 (Prism P1-3): would this instance config bind a topic whose deletion is running now? Compared by
12568
+ * physical destination, so the same thread number in another Telegram group is not held. Settings asks it at commit.
12569
+ */
12570
+ topicBindingInFlight(inst) {
12571
+ if (this.topicDeletionsInFlight.size === 0 || inst.topic_id == null || inst.topic_id === "")
12572
+ return false;
12573
+ const adapterId = typeof inst.channel_id === "string" && inst.channel_id ? inst.channel_id : this.getPrimaryAdapterId();
12574
+ return this.topicDeletionsInFlight.has(this.destinationFor(adapterId, inst.topic_id));
12575
+ }
12576
+ /** Any topic deletion running now (a connection's group or General may not be re-pointed meanwhile). */
12577
+ topicDeletionPending() { return this.topicDeletionsInFlight.size > 0; }
12578
+ /** The physical destination of `topicId` on the bot `adapterId`. */
12579
+ destinationFor(adapterId, topicId) {
12580
+ const world = adapterId ? this.worlds.get(adapterId) : undefined;
12581
+ const platform = world?.adapter?.type ?? this.getChannelConfig(adapterId)?.type;
12582
+ const groupId = world?.groupId ?? String(this.getChannelConfig(adapterId)?.group_id ?? "");
12583
+ return topicDestination(platform, groupId, adapterId, topicId);
12584
+ }
12585
+ /** The adapter of the bot `adapterId` names — never a substitute for a bot that is not there. */
12586
+ instanceTopicAdapter(adapterId) {
12587
+ return (adapterId ? this.worlds.get(adapterId)?.adapter : this.worlds.size === 0 ? this.adapter ?? undefined : undefined) ?? undefined;
12588
+ }
12589
+ /**
12590
+ * Topic ownership by physical destination (#3055 review, Prism P1): an instance's topic is where its bot's platform
12591
+ * puts it (a Telegram group + thread, a Discord channel), so two bots in one group, or two instances on one
12592
+ * channel, are seen to share it; and every connection's own General is protected, with or without a General agent.
12593
+ */
12594
+ topicOwnership() {
12595
+ const channels = this.fleetConfig?.channels ?? (this.fleetConfig?.channel ? [this.fleetConfig.channel] : []);
12596
+ const destinationOf = (n) => {
12597
+ const cfg = this.fleetConfig?.instances?.[n];
12598
+ if (!cfg || cfg.topic_id == null || cfg.topic_id === "")
12599
+ return null;
12600
+ return this.destinationFor(this.getInstanceAdapterId(n), cfg.topic_id);
12601
+ };
12602
+ const generalDestinations = () => {
12603
+ const out = new Set();
12604
+ for (const ch of channels) {
12605
+ const id = ch.id ?? ch.type;
12606
+ const groupId = this.worlds.get(id)?.groupId ?? (ch.group_id != null ? String(ch.group_id) : undefined);
12607
+ if (ch.type === "telegram")
12608
+ out.add(topicDestination("telegram", groupId, id, 1));
12609
+ if (ch.type === "discord") {
12610
+ const gcid = discordGeneralChannelId(ch);
12611
+ if (gcid)
12612
+ out.add(topicDestination("discord", groupId, id, gcid));
12613
+ }
12614
+ }
12615
+ return out;
12616
+ };
12617
+ return { instances: this.fleetConfig?.instances, destinationOf, generalDestinations };
12618
+ }
12619
+ /** What deleting `name`'s topic would act on: its own bot only — a bot that is not there is never substituted. */
12620
+ planInstanceTopicDelete(name) {
12621
+ const adapterId = this.getInstanceAdapterId(name);
12622
+ return planInstanceTopicDelete({ name, adapterId, adapter: this.instanceTopicAdapter(adapterId), ...this.topicOwnership() });
12133
12623
  }
12134
12624
  getForumTopicDeleter(adapterId) {
12135
12625
  const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
@@ -12530,6 +13020,7 @@ export class FleetManager {
12530
13020
  Object.assign(current, resolved);
12531
13021
  }
12532
13022
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
13023
+ this.agentCredentialsCache?.observePolicies();
12533
13024
  this.refreshSavedDashboardPermissions();
12534
13025
  this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
12535
13026
  }
@@ -12818,6 +13309,9 @@ export class FleetManager {
12818
13309
  await this.lifecycle.remove(name, authorization, execution, transition);
12819
13310
  else
12820
13311
  await this.lifecycle.remove(name, authorization);
13312
+ // #1490 row 27: its hang/exit offers were for the instance now gone (stopInstance clears them; removal stops
13313
+ // through the lifecycle directly). The offers' own lifecycle pins refuse them too.
13314
+ this.clearNoncePromptsForInstance(name);
12821
13315
  // #1774: removed — its replies still copying their files are no one's to show, replaced or not (a failed removal
12822
13316
  // throws above: its replies stay its own).
12823
13317
  this.webChatRemovals.set(name, { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}) });
@@ -14221,6 +14715,8 @@ export class FleetManager {
14221
14715
  * back to posting at the bare group id, which is the defect this replaced.
14222
14716
  */
14223
14717
  postDailySummary(text) {
14718
+ if (this.recordWebNotice("summary", text))
14719
+ return; // #2910 D2: a web-only fleet's Notices
14224
14720
  const target = this.fleetNoticeTarget();
14225
14721
  if (!this.adapter || !target) {
14226
14722
  this.logger.warn("Daily summary has no postable target — set a General topic or channel.options.general_channel_id");
@@ -14273,6 +14769,10 @@ export class FleetManager {
14273
14769
  dispatched = true;
14274
14770
  }
14275
14771
  }
14772
+ // #2910 D2: a web-only fleet has no General: the notice goes to the dashboard's Notices instead (and spends the
14773
+ // throttle window like a post would).
14774
+ if (!dispatched && this.recordWebNotice("fleet", body))
14775
+ dispatched = true;
14276
14776
  if (!dispatched) {
14277
14777
  // Do NOT record the throttle key. It used to be claimed before delivery
14278
14778
  // was attempted, so a message nobody could receive — every fleet error
@@ -14295,6 +14795,31 @@ export class FleetManager {
14295
14795
  }
14296
14796
  return true;
14297
14797
  }
14798
+ webNotices = null;
14799
+ /** #2910 D2 (web-api): the Notices on a web-only fleet; null on any other (its General says them). */
14800
+ webNoticesFeed() { return this.webOnlyPromptPlace() ? this.webNoticeStore() : null; }
14801
+ /** #2910 D2: the web-only fleet's Notices (web-notices.json), read on first use. */
14802
+ webNoticeStore() {
14803
+ if (!this.dataDir)
14804
+ return null;
14805
+ this.webNotices ??= new WebNotices(join(this.dataDir, WEB_NOTICES_FILE), this.logger);
14806
+ return this.webNotices;
14807
+ }
14808
+ /**
14809
+ * #2910 D2: on a web-only fleet (no chat connection at all), keep what General or an agent's topic would have been
14810
+ * told as a Notice, and push it to the dashboard. False on any other fleet (its chat says it; nothing is kept).
14811
+ */
14812
+ recordWebNotice(kind, text, instance) {
14813
+ if (!this.webOnlyPromptPlace())
14814
+ return false;
14815
+ const store = this.webNoticeStore();
14816
+ if (!store)
14817
+ return false;
14818
+ const known = instance !== undefined && Object.hasOwn(this.fleetConfig?.instances ?? {}, instance) ? instance : undefined;
14819
+ const notice = store.add({ kind, text: stripPlaceRefs(text), ...(known ? { instance: known } : {}) });
14820
+ this.emitSseEvent("notice", { notice, unread: store.unread() });
14821
+ return true;
14822
+ }
14298
14823
  static FLEET_ERROR_THROTTLE_MS = 10 * 60_000;
14299
14824
  fleetErrorNotices = new Map();
14300
14825
  /**
@@ -14308,6 +14833,9 @@ export class FleetManager {
14308
14833
  * would have to make this async and change every caller.
14309
14834
  */
14310
14835
  notifyInstanceTopic(instanceName, text, extraOpts) {
14836
+ // #2910 D2: a web-only fleet has no topics: what the agent's topic would have been told is a Notice about it.
14837
+ if (this.webOnlyPromptPlace())
14838
+ return this.recordWebNotice("instance", text, instanceName);
14311
14839
  const send = this.sendToInstanceTopic(instanceName, text, extraOpts);
14312
14840
  if (!send)
14313
14841
  return false;
@@ -14458,6 +14986,7 @@ export class FleetManager {
14458
14986
  threadId: opts.threadId,
14459
14987
  expiredText: opts.expiredText,
14460
14988
  createdAt: Date.now(),
14989
+ lifecycleCurrent: this.lifecyclePin(opts.instanceName),
14461
14990
  ...opts.extra,
14462
14991
  };
14463
14992
  entry.timer = setTimeout(() => {
@@ -14809,6 +15338,7 @@ export class FleetManager {
14809
15338
  const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
14810
15339
  name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
14811
15340
  claudeConfigDir: this.claudeConfigDirOf(name), profile: this.usageProfileOf(name, this.backendNameOf(name)),
15341
+ ...(this.backendNameOf(name) === "codex" ? { profileTimelinePath: join(this.getInstanceDir(name), PROFILE_TIMELINE_FILE) } : {}),
14812
15342
  kiroDbPath: this.kiroDbPathOf(name, this.backendNameOf(name)),
14813
15343
  }));
14814
15344
  const taken = new Set(fleet.map(i => i.name));
@@ -14820,6 +15350,9 @@ export class FleetManager {
14820
15350
  claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
14821
15351
  profile: this.usageProfileOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
14822
15352
  kiroDbPath: this.kiroDbPathOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
15353
+ // #3066 §1d (Fable r1 #2): a ClassicBot Codex room records its launches too (onDaemonReady) — read them the same way.
15354
+ ...((this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend) === "codex"
15355
+ ? { profileTimelinePath: join(this.getInstanceDir(ch.instanceName), PROFILE_TIMELINE_FILE) } : {}),
14823
15356
  }));
14824
15357
  return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
14825
15358
  }
@@ -14980,7 +15513,8 @@ export class FleetManager {
14980
15513
  out.push({
14981
15514
  name, state: p.state ?? undefined,
14982
15515
  interaction: p.interaction ? { kind: p.interaction.kind, owner: wait?.owner ?? null, episode: p.interaction.episode, since: p.interaction.since } : null,
14983
- interactionSummary: p.interaction_summary ?? null,
15516
+ interactionSummary: p.interaction?.kind === "kiro_choice" ? t("kiro3.running") : p.interaction_summary ?? null,
15517
+ kiroCompatibility: this.kiroCompatibilityHolds?.get(name),
14984
15518
  pauseReason, pausedAt,
14985
15519
  crashedAt: crashed ? (this.needsCrashedAt.get(name) ?? null) : null,
14986
15520
  signIn: pauseReason === "auth" ? this.signInFor(name) : null,
@@ -15056,6 +15590,10 @@ export class FleetManager {
15056
15590
  throw new Error("the delivery outbox is not open");
15057
15591
  return this.deliveryOutbox.acknowledge(deliveryId, by);
15058
15592
  },
15593
+ missedOffline: () => (this.missedOfflineStore?.list(Date.now()) ?? []).map(m => ({
15594
+ id: m.id, instance: m.instance, owner: m.adapterId, username: m.username, place: m.place, preview: m.preview, since: m.since,
15595
+ })),
15596
+ acknowledgeMissed: (id, by) => this.missedOfflineStore?.acknowledge(id, by) ?? false,
15059
15597
  tasks: () => this.needsYouTaskItems(),
15060
15598
  backendSignIns: () => this.needsYouBackendSignIns(),
15061
15599
  configIssues: () => (this.configHistory?.currentIssues() ?? []).map(issue => ({
@@ -15584,6 +16122,7 @@ export class FleetManager {
15584
16122
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
15585
16123
  authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
15586
16124
  policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now: Date.now(),
16125
+ hostOperator: this.isWebOnlyFleet(),
15587
16126
  });
15588
16127
  }
15589
16128
  sessionAuthorizations = null;
@@ -15616,6 +16155,33 @@ export class FleetManager {
15616
16155
  store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
15617
16156
  return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs, peer) };
15618
16157
  }
16158
+ /**
16159
+ * #2910 D1 (auth-api, at sign-in): a web-only fleet's host session — signed in with the host's own code (`agend web
16160
+ * --code`) on this computer — is the host operator, authorized from now for max_session_hours (as an account's
16161
+ * Confirm would be), under the current epoch: a revocation ends it, and only a new sign-in on this computer gives it
16162
+ * again. A fleet with a chat connection, another session, or another peer: nothing (§3 as before).
16163
+ */
16164
+ authorizeHostSession(session, peer) {
16165
+ const store = this.sessionAuthorizationStore();
16166
+ if (!store || !session.host || session.owner || !this.isWebOnlyFleet())
16167
+ return;
16168
+ const state = this.webAuthorityState(), now = Date.now(), policy = webAuthorityPolicy(this.fleetConfig?.web);
16169
+ const check = evaluateWebAuthority({
16170
+ session, gateway: false, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
16171
+ isAdmin: () => false,
16172
+ authorization: { handle: session.handle, owner: HOST_OPERATOR, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
16173
+ policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
16174
+ hostOperator: true,
16175
+ });
16176
+ if (check.refusal) {
16177
+ this.logger.info({ handle: session.handle, refusal: check.refusal }, "Host web session not authorized");
16178
+ return;
16179
+ }
16180
+ const live = new Set(this.webSessions?.list(undefined, () => true).map(r => r.handle) ?? []);
16181
+ store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
16182
+ const { durable } = store.authorize(session.handle, HOST_OPERATOR, now, state.epoch, now + policy.maxSessionMs, peer);
16183
+ this.logger.info({ handle: session.handle, durable }, "Host web session authorized (this computer's admin)");
16184
+ }
15619
16185
  authorityStateStore = null;
15620
16186
  /**
15621
16187
  * #1939 §10: the switch, "Only this computer can authorize" and the revocation epoch in force for the configuration as
@@ -15671,7 +16237,10 @@ export class FleetManager {
15671
16237
  const sessions = new Map((this.webSessions?.list(undefined, () => true) ?? []).map(r => [r.handle, r]));
15672
16238
  const authorized = state.enabled ? (this.sessionAuthorizationStore()?.list() ?? [])
15673
16239
  .filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
15674
- .map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
16240
+ .map(a => ({ handle: a.handle, label: sessions.get(a.handle).label,
16241
+ // #2910 D1: the host operator's own session says whose it is, not "host:operator".
16242
+ account: a.owner.adapterId === HOST_OPERATOR.adapterId && a.owner.userId === HOST_OPERATOR.userId && this.isWebOnlyFleet()
16243
+ ? t("settings_confirm.host_operator_label") : `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
15675
16244
  until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry),
15676
16245
  // Over a link: the temporary one (§0.3), else the named one (a record from before §0.3 has no peer).
15677
16246
  ...(sessions.get(a.handle).surface === "gateway" ? (a.peer === "quick_link" ? { quick: true } : { named: true }) : {}) })) : [];
@@ -15739,8 +16308,9 @@ export class FleetManager {
15739
16308
  return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
15740
16309
  },
15741
16310
  signOut: (handle) => {
15742
- this.webSessions?.revokeByHandle(handle);
15743
- this.sessionAuthorizationStore()?.prune(a => a.handle !== handle);
16311
+ const session = this.webSessions?.revokeByHandle(handle) ?? { found: false, durable: true };
16312
+ const authorization = this.sessionAuthorizationStore()?.prune(a => a.handle !== handle) ?? { dropped: 0, durable: true };
16313
+ return { durable: session.durable, authorizationDurable: authorization.durable };
15744
16314
  },
15745
16315
  event: (type, payload) => { try {
15746
16316
  this.eventLog?.insert("web", type, payload);
@@ -15753,6 +16323,7 @@ export class FleetManager {
15753
16323
  expired: (c) => t("web_auth.card_expired", c.label),
15754
16324
  refused: (c) => t("web_auth.card_refused", c.label, t(`web_auth.refusal_${c.refusal}`)),
15755
16325
  revoked: (c) => t("web_auth.card_revoked", c.label),
16326
+ notSaved: (c) => t("web_auth.card_not_saved", c.label),
15756
16327
  ended: (c) => t("web_auth.card_ended", c.label),
15757
16328
  generalHint: () => t("web_auth.general_hint"),
15758
16329
  confirmLabel: () => t("web_auth.confirm"),
@@ -15781,6 +16352,8 @@ export class FleetManager {
15781
16352
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
15782
16353
  authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
15783
16354
  policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
16355
+ // #2910 D1: a host operator's session is never asked about (no account to ask): only a sign-in authorizes it.
16356
+ hostOperator: this.isWebOnlyFleet(),
15784
16357
  });
15785
16358
  return r.refusal;
15786
16359
  }
@@ -16088,8 +16661,10 @@ export class FleetManager {
16088
16661
  ? [{ action: "resume", label: t("inst.session_oom_resume") }, { action: "fresh", label: t("inst.session_oom_fresh") }]
16089
16662
  : [{ action: "restart", label: t("exit.restart") }, { action: "ignore", label: t("exit.ignore") }];
16090
16663
  const extra = sessionOomRetry ? { sessionOomRetry, ...(current ? { confirmationCurrent: current } : {}) } : undefined;
16091
- this.notifyInstanceTopic(instanceName, (oom ? message : t("exit.instance_notice", instanceName)) + placeSuffix({ kind: "general" }, "General"));
16092
16664
  const web = this.webOnlyPromptPlace();
16665
+ // A pointer to General's controls; on a web-only fleet (#2910 D2) the controls are in Needs you and nothing points there.
16666
+ if (!web)
16667
+ this.notifyInstanceTopic(instanceName, (oom ? message : t("exit.instance_notice", instanceName)) + placeSuffix({ kind: "general" }, "General"));
16093
16668
  if (web) {
16094
16669
  await this.postNonceButtonPrompt({
16095
16670
  prefix, alertType: "exit_restart", instanceName, ...web,
@@ -16136,21 +16711,27 @@ export class FleetManager {
16136
16711
  if (claimed === "consumed")
16137
16712
  return true;
16138
16713
  const { entry: pending, action } = claimed;
16714
+ // #1490 row 27: the lifecycle the button was offered for — a replacement since (removal does not clear prompts) is stale.
16715
+ const current = pending.lifecycleCurrent ?? this.lifecyclePin(pending.instanceName);
16139
16716
  this.eventLog?.insert(pending.instanceName, oom ? "session_oom_action" : "normal_exit_action", { action, userId: data.userId });
16140
16717
  if (action === "ignore") {
16141
16718
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
16142
16719
  return true;
16143
16720
  }
16721
+ if (!current()) {
16722
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
16723
+ return true;
16724
+ }
16144
16725
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
16145
16726
  try {
16146
- await this.restartSingleInstance(pending.instanceName, { explicit: true, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
16727
+ await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current, ...(oom ? { freshStart: action === "fresh", sessionOomRetry: pending.sessionOomRetry,
16147
16728
  ...(pending.confirmationCurrent ? { recoveryCurrent: pending.confirmationCurrent } : {}) } : {}) });
16148
16729
  await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
16149
16730
  }
16150
16731
  catch (err) {
16151
16732
  const message = err instanceof Error ? err.message : String(err);
16152
16733
  this.logger.error({ err, instanceName: pending.instanceName }, "Normal-exit restart failed");
16153
- await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show normal-exit restart error"));
16734
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, err instanceof RestartSupersededError ? message : t("exit.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show normal-exit restart error"));
16154
16735
  }
16155
16736
  return true;
16156
16737
  }
@@ -16168,11 +16749,19 @@ export class FleetManager {
16168
16749
  * contain usernames or secret-adjacent text and stays in daemon.log.
16169
16750
  */
16170
16751
  async notifyInteractivePrompt(instanceName, kind) {
16171
- this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName) + placeSuffix({ kind: "general" }, "General"));
16752
+ // #2910 D2: on a web-only fleet the assist prompt is in Needs you; there is no General to point to.
16753
+ if (!this.webOnlyPromptPlace())
16754
+ this.notifyInstanceTopic(instanceName, t("interactive.instance_notice", instanceName) + placeSuffix({ kind: "general" }, "General"));
16172
16755
  // Multi-world fleets can have one General per bot/platform. Route the assist
16173
16756
  // request to the General bound to the same adapter as the blocked instance.
16174
16757
  const generalName = this.findGeneralInstance(this.getInstanceAdapterId(instanceName));
16175
16758
  if (!generalName) {
16759
+ // #2910 D4: a web-only fleet with no General to ask for help — and a password or sudo prompt is not answered from a
16760
+ // web page: a Notice says where it waits (its View, where the person types it).
16761
+ if (this.webOnlyPromptPlace()) {
16762
+ this.recordWebNotice("instance", t("interactive.web_only_notice", instanceName, this.interactivePromptLabel(kind)), instanceName);
16763
+ return;
16764
+ }
16176
16765
  this.logger.warn({ instanceName }, "Interactive prompt has no General topic notification target");
16177
16766
  return;
16178
16767
  }
@@ -16331,6 +16920,10 @@ export class FleetManager {
16331
16920
  * was gone; this is the fallback).
16332
16921
  */
16333
16922
  async postRemoteCard(instanceName, card) {
16923
+ // #2910 D4: a CLI's own dialog read as a card (selection-card.ts) is put to people only on a web-only fleet — the
16924
+ // dashboard; a chat fleet keeps its parked notice and its General's controls, as before.
16925
+ if (card.kind === "question" && card.webOnly && !this.webOnlyPromptPlace())
16926
+ return;
16334
16927
  this.retireRemoteCard(instanceName, null, t("card.answered_in_pane"));
16335
16928
  const place = this.remoteCardPlace(instanceName);
16336
16929
  if (!place) {
@@ -16651,7 +17244,14 @@ export class FleetManager {
16651
17244
  return;
16652
17245
  }
16653
17246
  const publication = this.beginCancelButtonPublication(instanceName, correlationId);
16654
- const initialProgressText = FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName, 0, 1));
17247
+ // M-2391: a re-post below a reply (preserveProgress) continues the same turn — its elapsed time continues from
17248
+ // the live bubble's start, as its tool list does. Restarting it at 0 meant an agent replying more often than the
17249
+ // threshold never showed elapsed time at all. A fresh turn (no preserve) starts at 0 as before.
17250
+ const carriedStart = preserveProgress ? this.liveButtonStart(instanceName) : undefined;
17251
+ const carriedMs = carriedStart !== undefined ? Math.max(0, Date.now() - carriedStart) : 0;
17252
+ const initialProgressText = carriedStart !== undefined && carriedMs >= this.progressMinElapsedMs()
17253
+ ? FleetManager.progressText(carriedMs, null, this.progressMinElapsedMs(), this.progressPrefixFor(instanceName, carriedMs))
17254
+ : FleetManager.progressText(0, null, 1, this.progressPrefixFor(instanceName, 0, 1));
16655
17255
  try {
16656
17256
  const sent = await adapter.notifyAlert(chatId, {
16657
17257
  type: "cancel",
@@ -16660,6 +17260,9 @@ export class FleetManager {
16660
17260
  choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
16661
17261
  }, threadId ? { threadId } : undefined);
16662
17262
  publication.inFlight = false;
17263
+ // #2940: a status bubble of ours — never a chat-log line, for any bot of this fleet in the room; one a sibling
17264
+ // already logged (its gateway event can come before this answer) is taken out again.
17265
+ this.noteOwnStatusMessage(adapter, sent);
16663
17266
  const entry = {
16664
17267
  instanceName,
16665
17268
  adapterId,
@@ -16671,7 +17274,7 @@ export class FleetManager {
16671
17274
  // Elapsed time is measured from when this button was posted — i.e. from
16672
17275
  // when the work was handed over — not from the pane's working transition,
16673
17276
  // which resets if the CLI blips idle mid-turn.
16674
- startedAt: Date.now(),
17277
+ startedAt: carriedStart ?? Date.now(),
16675
17278
  // Matches the text notifyAlert just posted, so the first 60s tick does
16676
17279
  // not re-edit identical text — which put a "(edited)" mark on Discord
16677
17280
  // with nothing visibly changed.
@@ -16794,14 +17397,16 @@ export class FleetManager {
16794
17397
  * Make a tool summary safe to paste into a channel message.
16795
17398
  *
16796
17399
  * The text is agent-controlled (it is built from tool inputs — file paths,
16797
- * shell commands), so it gets flattened to one line, capped, and stripped of
16798
- * the two Discord mass-mention triggers. Neither channel renders it with a
16799
- * parse mode, so no markup escaping is needed beyond that.
17400
+ * shell commands), so it gets flattened to one line, secrets masked, capped,
17401
+ * and stripped of the two Discord mass-mention triggers. Masking comes BEFORE
17402
+ * the cap: a cap can cut a mask's context but must never be what hides (or
17403
+ * fails to hide) a secret. Neither channel renders it with a parse mode, so no
17404
+ * markup escaping is needed beyond that.
16800
17405
  */
16801
17406
  static sanitizeActivity(activity) {
16802
17407
  if (!activity)
16803
17408
  return null;
16804
- const flat = activity.replace(/\s+/g, " ").replace(/@(everyone|here)/g, "@​$1").trim();
17409
+ const flat = maskSecrets(activity.replace(/\s+/g, " ")).replace(/@(everyone|here)/g, "@​$1").trim();
16805
17410
  if (!flat)
16806
17411
  return null;
16807
17412
  return flat.length > PROGRESS_ACTIVITY_MAX_CHARS
@@ -16920,6 +17525,12 @@ export class FleetManager {
16920
17525
  }
16921
17526
  return PROGRESS_MIN_ELAPSED_MS;
16922
17527
  }
17528
+ /** M-2391: when the instance's live (not retiring) bubble started — the current turn's start — or undefined. */
17529
+ liveButtonStart(instanceName) {
17530
+ const starts = [...this.cancelButtons.values()].filter((e) => e.instanceName === instanceName && !e.retiring && typeof e.startedAt === "number")
17531
+ .map((e) => e.startedAt);
17532
+ return starts.length ? Math.min(...starts) : undefined;
17533
+ }
16923
17534
  startProgressTicker(entry) {
16924
17535
  const tick = () => this.refreshBubble(entry);
16925
17536
  entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
@@ -16929,7 +17540,9 @@ export class FleetManager {
16929
17540
  // one additional edit per turn that lives past the threshold.
16930
17541
  const firstAt = this.progressMinElapsedMs() - (Date.now() - (entry.startedAt ?? Date.now()));
16931
17542
  if (firstAt > 0 && firstAt < PROGRESS_UPDATE_INTERVAL_MS) {
16932
- const firstTick = setTimeout(tick, firstAt);
17543
+ // M-2391: a little past the threshold — a timer can fire a millisecond before Date.now() reaches it, which
17544
+ // rendered the unchanged "處理中…" text and skipped the edit until the next 60 s interval.
17545
+ const firstTick = setTimeout(tick, firstAt + PROGRESS_THRESHOLD_MARGIN_MS);
16933
17546
  firstTick.unref?.();
16934
17547
  }
16935
17548
  }
@@ -17247,7 +17860,10 @@ export class FleetManager {
17247
17860
  return this.browserBackendSignInsInstance ??= new BrowserBackendSignIns({
17248
17861
  dataDir: this.dataDir, lock: this.loginWindow,
17249
17862
  current: owner => this.browserSignInOwnerCurrent(owner),
17250
- installed: async () => ONBOARDING_BACKENDS.filter(backend => this.isCliInstalled(backend)),
17863
+ installed: async () => {
17864
+ const found = await Promise.all(ONBOARDING_BACKENDS.map(async (backend) => (await this.isCliInstalledOffLoop(backend)) ? backend : null));
17865
+ return found.filter((backend) => backend !== null);
17866
+ },
17251
17867
  checkAuth: backend => checkAuthStatus(LOGIN_FLOWS[backend].authCheck),
17252
17868
  checkConsoleAuth: () => checkAuthStatus(LOGIN_FLOWS.antigravity.authCheck),
17253
17869
  transport: () => createStructuredSignInTransport(),
@@ -17351,13 +17967,13 @@ export class FleetManager {
17351
17967
  * #2233: `/login` for a dashboard requester. The same controller and checks; a CLI that is not installed is refused
17352
17968
  * with its install command (chat's install-first runs a terminal of its own, which the dashboard does not offer).
17353
17969
  */
17354
- startWebDashboardLogin(backendArg, chat, opts) {
17970
+ async startWebDashboardLogin(backendArg, chat, opts) {
17355
17971
  const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
17356
17972
  if (isRemovedBackend(wanted))
17357
- return Promise.resolve(removedBackendMessage(wanted));
17973
+ return removedBackendMessage(wanted);
17358
17974
  const info = BACKEND_INSTALLATION_INFO[wanted];
17359
- if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !this.isCliInstalled(wanted)) {
17360
- return Promise.resolve(t("login.web_dashboard_not_installed", wanted, info.install));
17975
+ if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !(await this.isCliInstalledOffLoop(wanted))) {
17976
+ return t("login.web_dashboard_not_installed", wanted, info.install);
17361
17977
  }
17362
17978
  if (!opts.skipAuthCheck)
17363
17979
  this.recordLoginFlow(wanted, "login", chat.userId);
@@ -17405,8 +18021,10 @@ export class FleetManager {
17405
18021
  ? removedBackendMessage(backend)
17406
18022
  : !installed.has(backend)
17407
18023
  ? (BACKEND_INSTALLATION_INFO[backend] ? t("login.install_by_name", backend) : t("login.install_on_host", backend))
18024
+ // #2981: one short line in the bare /login list — the full refusal (why, and what to run) is `/login <it>`'s;
18025
+ // a bare /login used to post Antigravity's whole paragraph to someone who never asked about it.
17408
18026
  : flow?.remoteLogin === "unsupported"
17409
- ? t("login.remote_unsupported_agent_cli", backend, flow.command)
18027
+ ? t("login.host_only", flow.command)
17410
18028
  : backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
17411
18029
  await chat.adapter.sendText(chat.chatId, guidance, { threadId: chat.threadId })
17412
18030
  .catch(err => this.logger.warn({ err }, "Failed to post unsupported login guidance"));
@@ -17585,7 +18203,8 @@ export class FleetManager {
17585
18203
  return admitted.refusal;
17586
18204
  opts = { ...opts, profile: admitted.profile };
17587
18205
  }
17588
- if (BACKEND_INSTALLATION_INFO[wanted] && !this.isCliInstalled(wanted)) {
18206
+ // M-2281: asked off the fleet loop — this runs on a chat click.
18207
+ if (BACKEND_INSTALLATION_INFO[wanted] && !(await this.isCliInstalledOffLoop(wanted))) {
17589
18208
  const flow = LOGIN_FLOWS[wanted];
17590
18209
  this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
17591
18210
  return opts.profile !== undefined
@@ -17810,6 +18429,8 @@ export class FleetManager {
17810
18429
  // installer, judge by exit code, verify the binary on a fresh login shell,
17811
18430
  // then sign in (the hand-off below).
17812
18431
  activeInstall = null;
18432
+ /** M-2281: how long `/login`'s install window may run (a field so a smoke can shorten it). */
18433
+ installTimeoutMs = INSTALL_TIMEOUT_MS;
17813
18434
  /** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
17814
18435
  installHandoff = null;
17815
18436
  /** Start a CLI install session. Caller enforces admin. */
@@ -17818,11 +18439,13 @@ export class FleetManager {
17818
18439
  const info = BACKEND_INSTALLATION_INFO[backend];
17819
18440
  if (!info)
17820
18441
  return t("install.unsupported", backendArg);
17821
- if (checkBinaryInstalled(info.binary))
17822
- return t("install.already", backend, info.binary);
17823
18442
  // Reserve the fleet-wide window before the first await (shared with web
17824
18443
  // login). Owned by this method until the session is published.
17825
18444
  const claim = this.loginWindow.tryClaim("install", backend);
18445
+ if (claim && await checkBinaryInstalledAsync(info.binary)) {
18446
+ this.loginWindow.release(claim);
18447
+ return t("install.already", backend, info.binary);
18448
+ }
17826
18449
  if (!claim)
17827
18450
  return this.loginWindow.busyMessage();
17828
18451
  let tmux;
@@ -17842,16 +18465,44 @@ export class FleetManager {
17842
18465
  // A synthetic flow: success is decided by the installer's exit code
17843
18466
  // (LoginSession treats a clean exit as success), never by pane text. The
17844
18467
  // session only judges that verdict; it does not read the output for URLs or prompts.
18468
+ // M-2281 (#2980): no terminal to ask on, low priority, stoppable as a whole, the user's own $SHELL — see
18469
+ // unattended-install.ts. A prompt nobody can answer fails at once instead of waiting out the timeout.
18470
+ const shell = (await loginShell()) ?? null;
18471
+ if (!this.loginWindow.isCurrent(claim)) {
18472
+ this.loginWindow.release(claim);
18473
+ return t("login.web_shutting_down");
18474
+ }
17845
18475
  const flow = {
17846
18476
  backend,
17847
- command: info.unattended ?? info.install,
18477
+ command: unattendedInstallCommand(info.unattended ?? info.install, shell),
17848
18478
  successPattern: /$^/,
17849
- timeoutMs: 10 * 60 * 1000,
18479
+ timeoutMs: this.installTimeoutMs,
17850
18480
  };
18481
+ // M-2281: the installer's output, live, in the chat that asked — no page shows this window.
18482
+ // Telegram gets its own <pre> (its markdown conversion keeps a fence as literal backticks); others a fenced block.
18483
+ const frameStyle = chat.adapter.type === "telegram" ? "html" : "markdown";
18484
+ const stream = new InstallStream({
18485
+ send: text => chat.adapter.sendText(chat.chatId, text, { threadId: chat.threadId, allowedMentions: { parse: [] } }),
18486
+ edit: (messageId, text) => chat.adapter.editMessage(chat.chatId, messageId, text, chat.threadId, { format: frameStyle }),
18487
+ warn: (message, err) => this.logger.debug({ backend, err: err?.message ?? String(err) }, message),
18488
+ }, t("install.stream_running", backend), undefined, undefined, frameStyle);
18489
+ const minutes = String(Math.max(1, Math.round(this.installTimeoutMs / 60_000)));
17851
18490
  const session = new LoginSession(flow, tmux, {
18491
+ onProgress: pane => stream.update(pane),
17852
18492
  onDone: async ({ ok, detail, cleanupFailed }) => {
17853
18493
  this.activeInstall = null;
17854
18494
  this.loginWindow.release(claim);
18495
+ // Between the install ending and the sign-in starting there is no
18496
+ // session to cancel; this hand-off is what `/login cancel` stops — armed
18497
+ // before the first await here, so a cancel during any of them counts.
18498
+ const handoff = ok ? { backend, cancelled: false } : null;
18499
+ if (handoff)
18500
+ this.installHandoff = handoff;
18501
+ // The live view's last frame first, so the result line below follows the output it is about.
18502
+ await stream.finish(ok ? t("install.stream_ok", backend)
18503
+ : detail === "timeout" ? t("install.stream_timeout", backend, minutes)
18504
+ : detail === "cancelled" ? t("install.stream_cancelled", backend)
18505
+ : t("install.stream_failed", backend)).catch(() => { });
17855
18506
  if (cleanupFailed) {
17856
18507
  await chat.adapter.sendText(chat.chatId, t("login.web_cleanup_failed", backend), { threadId: chat.threadId }).catch(() => { });
17857
18508
  }
@@ -17859,18 +18510,17 @@ export class FleetManager {
17859
18510
  // A cancel is user-initiated — the cancel command's own reply already
17860
18511
  // said so; a second message here would be a duplicate.
17861
18512
  if (detail !== "cancelled") {
17862
- await chat.adapter.sendText(chat.chatId, t("install.failed", backend, detail), { threadId: chat.threadId }).catch(() => { });
18513
+ await chat.adapter.sendText(chat.chatId, detail === "timeout" ? t("install.timeout", backend, minutes) : t("install.failed", backend, detail), { threadId: chat.threadId }).catch(() => { });
17863
18514
  }
17864
18515
  return;
17865
18516
  }
17866
- // Between the install ending and the sign-in starting there is no
17867
- // session to cancel; this hand-off is what `/login cancel` stops — from
17868
- // here on, as the login-shell lookup below is an await.
17869
- const handoff = { backend, cancelled: false };
17870
- this.installHandoff = handoff;
18517
+ if (!handoff || handoff.cancelled || this.installHandoff !== handoff)
18518
+ return;
17871
18519
  // The installer may only have added the binary to a profile PATH; a
17872
18520
  // fresh login shell sees that, the fleet process's PATH may not.
17873
- const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? this.locateInInstallerBinDirs(info);
18521
+ // #3009 (leader, cid-3017-3029-overlap): both lookups off the fleet's main thread — the login shell's own check,
18522
+ // then the installer's own directories through backend-availability's check (a killable child tests each one).
18523
+ const installedAt = (await this.locateBinaryOnLoginShell(info.binary)) ?? (await this.locateInstalledBackend(backend));
17874
18524
  if (handoff.cancelled || this.installHandoff !== handoff)
17875
18525
  return;
17876
18526
  if (!installedAt) {
@@ -17940,6 +18590,11 @@ export class FleetManager {
17940
18590
  const info = BACKEND_INSTALLATION_INFO[backend];
17941
18591
  return !!info && checkBinaryInstalled(info.binary);
17942
18592
  }
18593
+ /** M-2281: isCliInstalled without holding the fleet loop (a chat click's path: `which` on a slow PATH can take seconds). */
18594
+ async isCliInstalledOffLoop(backend) {
18595
+ const info = BACKEND_INSTALLATION_INFO[backend];
18596
+ return !!info && await checkBinaryInstalledAsync(info.binary);
18597
+ }
17943
18598
  /** Abort the active install (`/login cancel`) and remove its window. */
17944
18599
  async cancelInstallSession() {
17945
18600
  if (!this.activeInstall)
@@ -17957,38 +18612,27 @@ export class FleetManager {
17957
18612
  */
17958
18613
  async locateBinaryOnLoginShell(binary) {
17959
18614
  try {
17960
- const result = await runOnLoginShell(`command -v ${binary}`);
18615
+ // #3009: the executable-file check runs inside the same login shell (a killable child), never as accessSync/statSync
18616
+ // on the fleet's thread — a path on a filesystem that stopped answering held the whole fleet there.
18617
+ const result = await runOnLoginShell(`p=$(command -v ${binary}) && [ -f "$p" ] && [ -x "$p" ] && printf '%s\n' "$p"`);
17961
18618
  if (result.status !== 0)
17962
18619
  return null;
17963
18620
  const path = result.stdout.trim().split("\n").pop()?.trim() ?? "";
17964
- if (!isAbsolute(path))
17965
- return null;
17966
- accessSync(path, fsConstants.X_OK);
17967
- return statSync(path).isFile() ? path : null;
18621
+ return isAbsolute(path) ? path : null;
17968
18622
  }
17969
18623
  catch {
17970
18624
  return null;
17971
18625
  }
17972
18626
  }
17973
18627
  /**
17974
- * #1092: the binary in one of the directories its own installer installs to,
17975
- * when no login shell can see it (the installer recorded its PATH in an
17976
- * interactive-only rc file). The same executable-file check as the login
17977
- * shell lookup; a symlink to an executable counts, as codex's launcher is one.
18628
+ * #1092: the binary in one of the directories its own installer installs to, when no login shell can see it (the
18629
+ * installer recorded its PATH in an interactive-only rc file) — only those directories (#1092). #3009: through
18630
+ * backend-availability's install-directory check (the one resolveBackendBinary uses): one killable child, never
18631
+ * accessSync/statSync on the fleet's thread. An executable file counts, a symlink to one too (codex's launcher is one).
17978
18632
  */
17979
- locateInInstallerBinDirs(info) {
17980
- for (const dir of info.binDirs?.(process.env, homedir()) ?? []) {
17981
- if (!isAbsolute(dir))
17982
- continue;
17983
- const path = join(dir, info.binary);
17984
- try {
17985
- accessSync(path, fsConstants.X_OK);
17986
- if (statSync(path).isFile())
17987
- return path;
17988
- }
17989
- catch { /* not there */ }
17990
- }
17991
- return null;
18633
+ async locateInstalledBackend(backend) {
18634
+ const info = BACKEND_INSTALLATION_INFO[backend];
18635
+ return info ? (await findExecutableIn(info.binary, info.binDirs?.(process.env, homedir()) ?? [])) ?? null : null;
17992
18636
  }
17993
18637
  /** Put an installed binary's directory on the fleet's PATH (#1059), once. */
17994
18638
  adoptBinaryDirectory(binaryPath, backend) {
@@ -18304,20 +18948,26 @@ export class FleetManager {
18304
18948
  if (claimed === "consumed")
18305
18949
  return true;
18306
18950
  const { entry: pending, action } = claimed;
18951
+ // #1490 row 27: the lifecycle the button was offered for — a replacement since (removal does not clear prompts) is stale.
18952
+ const current = pending.lifecycleCurrent ?? this.lifecyclePin(pending.instanceName);
18307
18953
  this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
18308
18954
  if (action === "wait") {
18309
18955
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
18310
18956
  return true;
18311
18957
  }
18958
+ if (!current()) {
18959
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
18960
+ return true;
18961
+ }
18312
18962
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
18313
18963
  try {
18314
- await this.restartSingleInstance(pending.instanceName, { explicit: true });
18964
+ await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current });
18315
18965
  await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
18316
18966
  }
18317
18967
  catch (err) {
18318
18968
  const message = err instanceof Error ? err.message : String(err);
18319
18969
  this.logger.error({ err, instanceName: pending.instanceName }, "Hang force-restart failed");
18320
- await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show hang restart error"));
18970
+ await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, err instanceof RestartSupersededError ? message : t("hang.restart_failed", pending.instanceName, message), pending.threadId).catch(editErr => this.logger.warn({ err: editErr, instanceName: pending.instanceName }, "Failed to show hang restart error"));
18321
18971
  }
18322
18972
  return true;
18323
18973
  }
@@ -18393,6 +19043,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18393
19043
  resolveKnowledgeWorkDir(workDir, backend, instanceName) {
18394
19044
  // Backend resolution may create/normalize the real cwd. Instructions and
18395
19045
  // skills must land where the CLI actually runs, not an assumed path.
19046
+ // Caller pins resolveBackendBinary first so createBackend does not `which`.
18396
19047
  try {
18397
19048
  const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
18398
19049
  .resolveWorkingDirectory?.(workDir, instanceName);
@@ -18771,6 +19422,45 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18771
19422
  await adapter.sendText(msg.chatId, t("cmd.not_a_command"), { threadId: msg.threadId });
18772
19423
  return true;
18773
19424
  }
19425
+ /** #2940: this fleet's own status bubbles and the messages the platform said were deleted (ids, never text). */
19426
+ ownStatusMessages = new OwnStatusMessages();
19427
+ /**
19428
+ * #2940: a status bubble of ours was posted: never logged, and a line already logged for it (by a sibling bot) goes.
19429
+ * Discord only (#2943 review: the only platform where our bots see each other's posts), keyed by channel and id.
19430
+ */
19431
+ noteOwnStatusMessage(adapter, sent) {
19432
+ if (adapter.type !== "discord")
19433
+ return;
19434
+ const key = discordMessageKey(sent.threadId ?? sent.chatId, sent.messageId);
19435
+ if (!key)
19436
+ return;
19437
+ this.ownStatusMessages.addStatus(key);
19438
+ const removed = ClassicChannelManager.loggedLines.remove(key);
19439
+ if (removed)
19440
+ this.logger.debug({ key, removed }, "A status bubble logged before its id was known was taken out of the chat log");
19441
+ }
19442
+ /** #2940: Discord says a message was deleted — its chat-log line goes, and it is never logged after this. */
19443
+ onPlatformMessageDeleted(e) {
19444
+ const key = discordMessageKey(e?.channelId, e?.messageId);
19445
+ if (!key)
19446
+ return;
19447
+ this.ownStatusMessages.markDeleted(key);
19448
+ const removed = ClassicChannelManager.loggedLines.remove(key);
19449
+ if (removed)
19450
+ this.logger.debug({ key, removed }, "A deleted message was taken out of the chat log");
19451
+ }
19452
+ /** #2940: the key a Discord message's chat-log line is kept under (none for another platform: nothing is tracked). */
19453
+ chatLogKey(msg) {
19454
+ return msg.source === "discord" ? discordMessageKey(msg.threadId ?? msg.chatId, msg.messageId) : undefined;
19455
+ }
19456
+ /** #2940: whether `msg` must not enter the chat log (only its key is looked at; the debug trail names keys, not text). */
19457
+ chatLogExcludes(instanceName, msg) {
19458
+ const key = this.chatLogKey(msg);
19459
+ if (!this.ownStatusMessages.excluded(key))
19460
+ return false;
19461
+ this.logger.debug({ instanceName, key }, "Not logged: a status bubble of this fleet, or a deleted message");
19462
+ return true;
19463
+ }
18774
19464
  /** Handle a message in a classic channel: log it, forward only /chat messages */
18775
19465
  async handleClassicChannelMessage(instanceName, msg) {
18776
19466
  const text = msg.text ?? "";
@@ -18814,7 +19504,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18814
19504
  : (msg.attachments?.length
18815
19505
  ? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
18816
19506
  : "");
18817
- ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText);
19507
+ // #2940: one of this fleet's own status bubbles (any bot's), or a message already deleted, never enters the log.
19508
+ if (this.chatLogExcludes(instanceName, msg))
19509
+ return;
19510
+ ClassicChannelManager.logMessage(instanceName, msg.username, text + collabAttachTag, msg.timestamp, msg.replyToText, this.chatLogKey(msg));
18818
19511
  this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
18819
19512
  // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here.
18820
19513
  // Each bot matches ONLY its own id. A secondary bot must NOT fall back to the
@@ -18915,7 +19608,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18915
19608
  const attachmentTag = saved ? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
18916
19609
  : msg.attachments?.length ? ` [${msg.attachments.map(a => `📎 ${a.kind}${a.filename ? `: ${a.filename}` : ""}`).join(", ")}]`
18917
19610
  : "";
18918
- ClassicChannelManager.logMessage(instanceName, msg.username, text + attachmentTag, msg.timestamp, msg.replyToText);
19611
+ // #2940: likewise in a room without collab (a bot's message reaches here only through a /chat text command).
19612
+ if (!this.chatLogExcludes(instanceName, msg))
19613
+ ClassicChannelManager.logMessage(instanceName, msg.username, text + attachmentTag, msg.timestamp, msg.replyToText, this.chatLogKey(msg));
18919
19614
  // Bare attachment without /chat: save + log only, don't trigger agent
18920
19615
  if (!isChat) {
18921
19616
  const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
@@ -19671,7 +20366,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19671
20366
  }));
19672
20367
  const timer = setTimeout(() => this.pendingEffortSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
19673
20368
  timer.unref?.();
19674
- this.pendingEffortSelects.set(nonce, { instanceName: name, userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
20369
+ this.pendingEffortSelects.set(nonce, { instanceName: name, instanceCurrent: this.lifecyclePin(name), userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond });
19675
20370
  try {
19676
20371
  await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
19677
20372
  }
@@ -19706,7 +20401,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19706
20401
  }
19707
20402
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
19708
20403
  timer.unref?.();
19709
- this.pendingEffortSelects.set(nonce, { instanceName, userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
20404
+ this.pendingEffortSelects.set(nonce, { instanceName, instanceCurrent: this.lifecyclePin(instanceName), userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
19710
20405
  try {
19711
20406
  const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
19712
20407
  const pending = this.pendingEffortSelects.get(nonce);
@@ -19731,6 +20426,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19731
20426
  const pending = this.pendingEffortSelects.get(match[1]);
19732
20427
  if (!pending)
19733
20428
  return true;
20429
+ // #1490 row 27: the menu was for one lifecycle of the instance; stopped, restarted or replaced since, it is stale.
20430
+ if (!pending.instanceCurrent()) {
20431
+ this.pendingEffortSelects.delete(match[1]);
20432
+ clearTimeout(pending.timer);
20433
+ data.ack?.(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName)));
20434
+ await pending.respond(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))).catch(() => { });
20435
+ return true;
20436
+ }
19734
20437
  // The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
19735
20438
  // when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
19736
20439
  const cbChannel = data.threadId ?? data.chatId;
@@ -19773,17 +20476,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19773
20476
  let result;
19774
20477
  try {
19775
20478
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
19776
- result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
19777
- ? await withSpawnHoldReply(async (text) => {
19778
- if (pending.adapter && pending.adapterChatId) {
19779
- if (progressMsgId)
19780
- await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
20479
+ result = !pending.instanceCurrent() ? t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))
20480
+ : this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
20481
+ ? await withSpawnHoldReply(async (text) => {
20482
+ if (pending.adapter && pending.adapterChatId) {
20483
+ if (progressMsgId)
20484
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
20485
+ else
20486
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
20487
+ }
19781
20488
  else
19782
- await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
19783
- }
19784
- else
19785
- await pending.respond(text);
19786
- }, () => this.applyEffort(pending.instanceName, level)) : t("menu.click_stale");
20489
+ await pending.respond(text);
20490
+ }, () => this.applyEffort(pending.instanceName, level, { current: pending.instanceCurrent })) : t("menu.click_stale");
19787
20491
  }
19788
20492
  catch (err) {
19789
20493
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
@@ -19840,7 +20544,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19840
20544
  const choices = this.modelMenuChoices(name, nonce, options, currentModel);
19841
20545
  const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
19842
20546
  timer.unref?.();
19843
- this.pendingModelSelects.set(nonce, { instanceName: name, model: "", userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond, respondChoices: data.respondChoices });
20547
+ this.pendingModelSelects.set(nonce, { instanceName: name, instanceCurrent: this.lifecyclePin(name), model: "", userId: data.userId, channelId: data.channelId, adapterId, timer, respond: data.respond, respondChoices: data.respondChoices });
19844
20548
  try {
19845
20549
  await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
19846
20550
  }
@@ -19876,7 +20580,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19876
20580
  }
19877
20581
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
19878
20582
  timer.unref?.();
19879
- this.pendingModelSelects.set(nonce, { instanceName, model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
20583
+ this.pendingModelSelects.set(nonce, { instanceName, instanceCurrent: this.lifecyclePin(instanceName), model: "", userId, channelId, adapterId, timer, respond, adapter, adapterChatId: chatId, adapterThreadId: threadId });
19880
20584
  try {
19881
20585
  const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
19882
20586
  const pending = this.pendingModelSelects.get(nonce);
@@ -20049,6 +20753,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20049
20753
  const pending = this.pendingModelSelects.get(match[1]);
20050
20754
  if (!pending)
20051
20755
  return true;
20756
+ // #1490 row 27: the menu was for one lifecycle of the instance; stopped, restarted or replaced since, it is stale.
20757
+ if (!pending.instanceCurrent()) {
20758
+ this.pendingModelSelects.delete(match[1]);
20759
+ clearTimeout(pending.timer);
20760
+ data.ack?.(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName)));
20761
+ await pending.respond(t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))).catch(() => { });
20762
+ return true;
20763
+ }
20052
20764
  // The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
20053
20765
  // when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
20054
20766
  const cbChannel = data.threadId ?? data.chatId;
@@ -20109,17 +20821,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20109
20821
  let result;
20110
20822
  try {
20111
20823
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
20112
- result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
20113
- ? await withSpawnHoldReply(async (text) => {
20114
- if (pending.adapter && pending.adapterChatId) {
20115
- if (progressMsgId)
20116
- await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
20824
+ result = !pending.instanceCurrent() ? t("menu.instance_replaced", this.shownInstanceName(pending.instanceName))
20825
+ : this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
20826
+ ? await withSpawnHoldReply(async (text) => {
20827
+ if (pending.adapter && pending.adapterChatId) {
20828
+ if (progressMsgId)
20829
+ await pending.adapter.editMessage(pending.adapterChatId, progressMsgId, text, pending.adapterThreadId);
20830
+ else
20831
+ await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
20832
+ }
20117
20833
  else
20118
- await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
20119
- }
20120
- else
20121
- await pending.respond(text);
20122
- }, () => this.applyModel(pending.instanceName, model)) : t("menu.click_stale");
20834
+ await pending.respond(text);
20835
+ }, () => this.applyModel(pending.instanceName, model, { current: pending.instanceCurrent })) : t("menu.click_stale");
20123
20836
  }
20124
20837
  catch (err) {
20125
20838
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
@@ -20212,7 +20925,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20212
20925
  * restart backends only read it at launch, so it is persisted and the
20213
20926
  * instance respawns.
20214
20927
  */
20215
- async applyEffort(instanceName, requested) {
20928
+ async applyEffort(instanceName, requested, opts) {
20929
+ // #1490 row 27: a menu's choice is for the lifecycle it was offered to — asked first and fenced into the restart.
20930
+ if (opts?.current && !opts.current())
20931
+ return t("menu.instance_replaced", this.shownInstanceName(instanceName));
20216
20932
  const level = requested.trim().toLowerCase();
20217
20933
  const backendName = this.backendNameForInstance(instanceName);
20218
20934
  let strategy = "unsupported";
@@ -20247,10 +20963,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20247
20963
  this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
20248
20964
  return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
20249
20965
  }
20250
- await this.restartSingleInstance(instanceName);
20966
+ await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
20251
20967
  return `${warn}${t("effort.restart_success", instanceName, applied)}`;
20252
20968
  }
20253
- async applyModel(instanceName, model) {
20969
+ async applyModel(instanceName, model, opts) {
20970
+ // #1490 row 27: a menu's choice is for the lifecycle it was offered to — asked first, after the runtime switch's
20971
+ // await, and fenced into the restart.
20972
+ if (opts?.current && !opts.current())
20973
+ return t("menu.instance_replaced", this.shownInstanceName(instanceName));
20254
20974
  // Reject model names with newlines or control characters: they would be
20255
20975
  // persisted to fleet.yaml and pasted raw into the CLI (P3 from #1490 audit).
20256
20976
  if (/[\x00-\x1f\x7f]/.test(model)) {
@@ -20276,6 +20996,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20276
20996
  // #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
20277
20997
  if (strategy === "runtime" && backendName === "claude-code") {
20278
20998
  const result = await this.runRuntimeModelSwitch(instanceName, model);
20999
+ if (opts?.current && !opts.current())
21000
+ return t("menu.instance_replaced", this.shownInstanceName(instanceName));
20279
21001
  if (result.outcome === "mismatch")
20280
21002
  return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
20281
21003
  if (result.outcome === "not_sent")
@@ -20296,7 +21018,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20296
21018
  this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
20297
21019
  return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
20298
21020
  }
20299
- await this.restartSingleInstance(instanceName);
21021
+ await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
20300
21022
  return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
20301
21023
  }
20302
21024
  /** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
@@ -21175,6 +21897,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21175
21897
  // because we asked it to. Set synchronously — doStopAll runs to its first
21176
21898
  // await in the same tick as the signal handler, so no event can slip in.
21177
21899
  this.shuttingDown = true;
21900
+ // Stop receive admission before the first await or any instance shutdown; outbound
21901
+ // adapters remain available below to retire notices and pending prompts.
21902
+ for (const state of this.adapterState.values()) {
21903
+ if (state.retryTimer) {
21904
+ clearTimeout(state.retryTimer);
21905
+ state.retryTimer = undefined;
21906
+ }
21907
+ }
21908
+ const inboundStopped = Promise.all([...this.adapters.values()].map(async (adapter) => {
21909
+ try {
21910
+ await adapter.stopReceiving?.();
21911
+ }
21912
+ catch (err) {
21913
+ this.logger.warn({ adapterId: adapter.id, err }, "Adapter receive stop failed; inbound remains fenced");
21914
+ }
21915
+ }));
21178
21916
  this.workingRecovery?.reset();
21179
21917
  if (this.workingRecoveryTimer)
21180
21918
  clearInterval(this.workingRecoveryTimer);
@@ -21229,9 +21967,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21229
21967
  }
21230
21968
  this.eventLoopWatch?.stop();
21231
21969
  this.eventLoopWatch = null;
21970
+ this.stallReporter?.stop();
21971
+ this.stallReporter = null;
21232
21972
  // A login/install window is a dedicated tmux server with its own TTL
21233
21973
  // timer and HTTP listener living in THIS process: without an explicit
21234
21974
  // shutdown it would outlive us as an owner-less login CLI (sol B3).
21975
+ await inboundStopped;
21235
21976
  await publicStopped;
21236
21977
  await sharesStopped?.catch(() => { });
21237
21978
  await profileStopped;
@@ -21242,13 +21983,6 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21242
21983
  await this.cpuProfileControl?.close();
21243
21984
  this.cpuProfileControl = null;
21244
21985
  await this.shutdownLoginWindows();
21245
- // Cancel adapter retry timers
21246
- for (const state of this.adapterState.values()) {
21247
- if (state.retryTimer) {
21248
- clearTimeout(state.retryTimer);
21249
- state.retryTimer = undefined;
21250
- }
21251
- }
21252
21986
  this.clearStatuslineWatchers();
21253
21987
  this.costGuard?.stop();
21254
21988
  this.dailySummary?.stop();
@@ -21286,6 +22020,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21286
22020
  clearInterval(this.discordPresenceTimer);
21287
22021
  this.discordPresenceTimer = null;
21288
22022
  }
22023
+ if (this.quotaReadTimer) {
22024
+ clearInterval(this.quotaReadTimer);
22025
+ this.quotaReadTimer = null;
22026
+ }
22027
+ if (this.capacityTimer) {
22028
+ clearInterval(this.capacityTimer);
22029
+ this.capacityTimer = null;
22030
+ }
22031
+ setCapacityLookup(null);
21289
22032
  if (this.discordPresenceEagerTimer) {
21290
22033
  clearTimeout(this.discordPresenceEagerTimer);
21291
22034
  this.discordPresenceEagerTimer = null;
@@ -21590,6 +22333,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21590
22333
  // mark every row done and tell the user "changes applied".
21591
22334
  return { rejected: why };
21592
22335
  }
22336
+ this.agentCredentialsCache?.observePolicies();
21593
22337
  if (this.loadedFleetSource)
21594
22338
  this.getConfigHistory().accepted("fleet.yaml", this.configPath, this.loadedFleetSource);
21595
22339
  this.migrateOfficeStorage();
@@ -21624,7 +22368,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21624
22368
  this.reconcilePreviewListener();
21625
22369
  this.publicWebLink?.refresh();
21626
22370
  const newInstances = this.fleetConfig.instances;
21627
- const topicMode = this.fleetConfig?.channel?.mode === "topic";
22371
+ const topicMode = daemonTopicMode(this.fleetConfig);
21628
22372
  // Only what a fresh process can adopt, and only relative to the signature
21629
22373
  // this process came up on: a Settings edit mutates this.fleetConfig in place
21630
22374
  // before the reload, so comparing the pre-load copy sees nothing at all.
@@ -22490,6 +23234,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22490
23234
  }
22491
23235
  async runConnectionBindingApply(job, binding, execution) {
22492
23236
  try {
23237
+ // #3055 review r2: a connection is not re-pointed while one of its topics is being deleted (nothing is changed).
23238
+ if (this.topicDeletionPending())
23239
+ throw new Error("a topic deletion is running; try again in a moment");
22493
23240
  await this.rebuildAdapterForBinding(job.connectionId, binding, execution);
22494
23241
  job.result = "applied";
22495
23242
  }
@@ -23203,7 +23950,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
23203
23950
  }
23204
23951
  const fleet = this.loadConfig(this.configPath);
23205
23952
  this.fleetConfig = fleet;
23206
- const topicMode = fleet.channel?.mode === "topic" || !!fleet.channels?.some(ch => ch.mode === "topic");
23953
+ const topicMode = daemonTopicMode(fleet);
23207
23954
  const restartProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), restartStartedAt, this.logger);
23208
23955
  // Phase 1: generals first
23209
23956
  const restartEntries = Object.entries(fleet.instances);
@@ -23355,7 +24102,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
23355
24102
  // Only notify when target is genuinely newer (semver), so a beta user on
23356
24103
  // 2.0.8-beta.16 is never told that stable 2.0.7 is "available".
23357
24104
  if (target && this.semverGt(target, currentVersion)) {
23358
- const generalId = this.findGeneralInstance();
24105
+ // #2910 D2 (#2925 review): a web-only fleet first — even with a General instance it has no chat for /update.
24106
+ const generalId = this.webOnlyPromptPlace() ? null : this.findGeneralInstance();
23359
24107
  if (generalId) {
23360
24108
  // No release URL — Discord's SuppressEmbeds proved unreliable and the
23361
24109
  // link preview looked bad. Version + /update instruction is enough.
@@ -23366,6 +24114,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
23366
24114
  if (key)
23367
24115
  this.notifyInstanceTopic(generalId, t(key, `v${target}`, `v${currentVersion}`));
23368
24116
  }
24117
+ else if (this.webOnlyPromptPlace() && (fromChannel || updateNoticeKey(currentVersion, target))) {
24118
+ // #2910 D2: a web-only fleet: a Notice, once per version (the check runs daily); installed from this computer.
24119
+ const text = t("update.available_web_only", `v${target}`, `v${currentVersion}`);
24120
+ if (this.webNoticeStore()?.latest("update")?.text !== text)
24121
+ this.recordWebNotice("update", text);
24122
+ }
23369
24123
  }
23370
24124
  }
23371
24125
  catch { /* silent — network issues */ }
@@ -23679,7 +24433,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
23679
24433
  if (decision.reason === "no-credential" && req.method === "GET"
23680
24434
  && String(req.headers.accept ?? "").includes("text/html")
23681
24435
  && isWebPageNavigation(requestPath)) {
23682
- serveSigninPage(res, 401);
24436
+ serveSigninPage(res, 401, { webOnly: this.isWebOnlyFleet() });
23683
24437
  return;
23684
24438
  }
23685
24439
  res.writeHead(decision.status);
@@ -23902,7 +24656,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
23902
24656
  (async () => {
23903
24657
  let held = false;
23904
24658
  try {
23905
- const topicMode = this.fleetConfig?.channel?.mode === "topic";
24659
+ const topicMode = daemonTopicMode(this.fleetConfig);
23906
24660
  await withSpawnHoldReply(async (message) => {
23907
24661
  held = true;
23908
24662
  res.writeHead(202, { "Content-Type": "application/json" });
@@ -24137,7 +24891,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
24137
24891
  }
24138
24892
  const listener = createPreviewListener({ settings, healthPort: boundPort, config: this.fleetConfig,
24139
24893
  // #1723: with a web.preview_origin, the open public link's dashboard frames this listener's preview too.
24140
- extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; } });
24894
+ extraOrigins: () => { const o = settings.origin ? this.publicWebLink?.openDashboardOrigin() : null; return o ? [o] : []; },
24895
+ // #2911 (7): CDN libraries in previews, as fleet.yaml says now (a change applies to the next preview started).
24896
+ cdn: () => this.fleetConfig?.web?.preview_cdn === true });
24141
24897
  this.previewListener = listener;
24142
24898
  listener.server.on("error", (err) => {
24143
24899
  if (this.previewListener !== listener)
@@ -24357,6 +25113,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
24357
25113
  // #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
24358
25114
  // shown here rather than in chat). null when normal, unknown or advisory.
24359
25115
  host_memory: this.hostMemoryIndicator(),
25116
+ // #2910 D2: a web-only fleet's newest Notice — a polling page (no SSE `notice`) reads the list again when it moves.
25117
+ ...(this.webOnlyPromptPlace() ? { notices_newest: this.webNoticeStore()?.newest() ?? 0 } : {}),
24360
25118
  // #2015: the largest processes while it lasts (name, pid, RSS, and whose: an instance, the fleet or tmux).
24361
25119
  ...(this.hostMemoryIndicator() && this.memoryTop ? { host_memory_top: this.memoryTop.processes } : {}),
24362
25120
  };