@songsid/agend 2.3.4-beta.1 → 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 (284) hide show
  1. package/THIRD_PARTY_NOTICES.md +37 -0
  2. package/dist/agend-home.d.ts +13 -0
  3. package/dist/agend-home.js +42 -0
  4. package/dist/agend-home.js.map +1 -1
  5. package/dist/agent-cli-instructions.md +1 -1
  6. package/dist/agent-credentials.d.ts +24 -0
  7. package/dist/agent-credentials.js +66 -0
  8. package/dist/agent-credentials.js.map +1 -0
  9. package/dist/agent-endpoint.d.ts +3 -1
  10. package/dist/agent-endpoint.js +13 -0
  11. package/dist/agent-endpoint.js.map +1 -1
  12. package/dist/auth-api.js +10 -2
  13. package/dist/auth-api.js.map +1 -1
  14. package/dist/authority/session-authorizations.d.ts +28 -4
  15. package/dist/authority/session-authorizations.js +63 -17
  16. package/dist/authority/session-authorizations.js.map +1 -1
  17. package/dist/backend/antigravity.js +3 -1
  18. package/dist/backend/antigravity.js.map +1 -1
  19. package/dist/backend/claude-code.d.ts +3 -0
  20. package/dist/backend/claude-code.js +89 -4
  21. package/dist/backend/claude-code.js.map +1 -1
  22. package/dist/backend/codex-footer-hints.d.ts +4 -0
  23. package/dist/backend/codex-footer-hints.js +27 -0
  24. package/dist/backend/codex-footer-hints.js.map +1 -0
  25. package/dist/backend/codex-session-lookup.d.ts +35 -0
  26. package/dist/backend/codex-session-lookup.js +93 -0
  27. package/dist/backend/codex-session-lookup.js.map +1 -1
  28. package/dist/backend/codex.d.ts +27 -0
  29. package/dist/backend/codex.js +124 -23
  30. package/dist/backend/codex.js.map +1 -1
  31. package/dist/backend/kiro-identity.d.ts +2 -0
  32. package/dist/backend/kiro-identity.js +13 -6
  33. package/dist/backend/kiro-identity.js.map +1 -1
  34. package/dist/backend/kiro.d.ts +3 -0
  35. package/dist/backend/kiro.js +68 -5
  36. package/dist/backend/kiro.js.map +1 -1
  37. package/dist/backend/muse.js +5 -3
  38. package/dist/backend/muse.js.map +1 -1
  39. package/dist/backend/types.d.ts +6 -1
  40. package/dist/backend/types.js +3 -0
  41. package/dist/backend/types.js.map +1 -1
  42. package/dist/backend-availability.d.ts +6 -0
  43. package/dist/backend-availability.js +13 -5
  44. package/dist/backend-availability.js.map +1 -1
  45. package/dist/cache-ledger.d.ts +85 -0
  46. package/dist/cache-ledger.js +236 -1
  47. package/dist/cache-ledger.js.map +1 -1
  48. package/dist/cache-service.d.ts +5 -0
  49. package/dist/cache-service.js +16 -4
  50. package/dist/cache-service.js.map +1 -1
  51. package/dist/channel/adapters/discord.js +27 -4
  52. package/dist/channel/adapters/discord.js.map +1 -1
  53. package/dist/channel/adapters/telegram.d.ts +51 -0
  54. package/dist/channel/adapters/telegram.js +238 -18
  55. package/dist/channel/adapters/telegram.js.map +1 -1
  56. package/dist/channel/inbox-temp-name.d.ts +9 -0
  57. package/dist/channel/inbox-temp-name.js +13 -0
  58. package/dist/channel/inbox-temp-name.js.map +1 -0
  59. package/dist/channel/mcp-tools.js +3 -3
  60. package/dist/channel/mcp-tools.js.map +1 -1
  61. package/dist/channel/types.d.ts +2 -0
  62. package/dist/chat-log-ids.d.ts +48 -0
  63. package/dist/chat-log-ids.js +182 -0
  64. package/dist/chat-log-ids.js.map +1 -0
  65. package/dist/classic-channel-manager.d.ts +8 -1
  66. package/dist/classic-channel-manager.js +24 -2
  67. package/dist/classic-channel-manager.js.map +1 -1
  68. package/dist/cli.js +4 -6
  69. package/dist/cli.js.map +1 -1
  70. package/dist/cross-instance-notice.d.ts +10 -0
  71. package/dist/cross-instance-notice.js +67 -5
  72. package/dist/cross-instance-notice.js.map +1 -1
  73. package/dist/daemon.d.ts +52 -0
  74. package/dist/daemon.js +530 -34
  75. package/dist/daemon.js.map +1 -1
  76. package/dist/delivery-consumed-watch.d.ts +23 -1
  77. package/dist/delivery-consumed-watch.js +30 -3
  78. package/dist/delivery-consumed-watch.js.map +1 -1
  79. package/dist/delivery-reconciliation.d.ts +7 -0
  80. package/dist/delivery-reconciliation.js +9 -1
  81. package/dist/delivery-reconciliation.js.map +1 -1
  82. package/dist/dialog-screen-text.d.ts +16 -0
  83. package/dist/dialog-screen-text.js +51 -0
  84. package/dist/dialog-screen-text.js.map +1 -0
  85. package/dist/export-import.d.ts +7 -0
  86. package/dist/export-import.js +44 -10
  87. package/dist/export-import.js.map +1 -1
  88. package/dist/fleet-context.d.ts +2 -0
  89. package/dist/fleet-manager.d.ts +147 -11
  90. package/dist/fleet-manager.js +747 -118
  91. package/dist/fleet-manager.js.map +1 -1
  92. package/dist/fleet-yaml-patch.d.ts +3 -0
  93. package/dist/fleet-yaml-patch.js +103 -0
  94. package/dist/fleet-yaml-patch.js.map +1 -0
  95. package/dist/general-knowledge/skills/concepts/SKILL.md +393 -0
  96. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -0
  97. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +3 -1
  98. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +41 -4
  99. package/dist/general-knowledge/skills/model-discovery/SKILL.md +2 -0
  100. package/dist/general-knowledge/skills/web-appearance/SKILL.md +1 -1
  101. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +10 -4
  102. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  103. package/dist/install-stream.d.ts +53 -0
  104. package/dist/install-stream.js +134 -0
  105. package/dist/install-stream.js.map +1 -0
  106. package/dist/instance-lifecycle.d.ts +20 -2
  107. package/dist/instance-lifecycle.js +80 -12
  108. package/dist/instance-lifecycle.js.map +1 -1
  109. package/dist/instance-topic-delete.d.ts +77 -0
  110. package/dist/instance-topic-delete.js +123 -0
  111. package/dist/instance-topic-delete.js.map +1 -0
  112. package/dist/instructions.d.ts +1 -1
  113. package/dist/instructions.js +1 -1
  114. package/dist/instructions.js.map +1 -1
  115. package/dist/kiro-engine-status.d.ts +11 -0
  116. package/dist/kiro-engine-status.js +6 -0
  117. package/dist/kiro-engine-status.js.map +1 -1
  118. package/dist/kiro-resume-summary.d.ts +37 -0
  119. package/dist/kiro-resume-summary.js +85 -0
  120. package/dist/kiro-resume-summary.js.map +1 -0
  121. package/dist/locale.js +82 -6
  122. package/dist/locale.js.map +1 -1
  123. package/dist/log-service.d.ts +30 -0
  124. package/dist/log-service.js +36 -3
  125. package/dist/log-service.js.map +1 -1
  126. package/dist/log-steps.d.ts +4 -3
  127. package/dist/log-steps.js +32 -7
  128. package/dist/log-steps.js.map +1 -1
  129. package/dist/logger.d.ts +12 -0
  130. package/dist/logger.js +43 -1
  131. package/dist/logger.js.map +1 -1
  132. package/dist/login-controller.js +9 -1
  133. package/dist/login-controller.js.map +1 -1
  134. package/dist/login-flows.d.ts +2 -0
  135. package/dist/login-flows.js +5 -2
  136. package/dist/login-flows.js.map +1 -1
  137. package/dist/login-manager.d.ts +6 -0
  138. package/dist/login-manager.js +10 -1
  139. package/dist/login-manager.js.map +1 -1
  140. package/dist/missed-offline.d.ts +25 -0
  141. package/dist/missed-offline.js +79 -0
  142. package/dist/missed-offline.js.map +1 -0
  143. package/dist/needs-you-hub.d.ts +6 -1
  144. package/dist/needs-you-hub.js +31 -8
  145. package/dist/needs-you-hub.js.map +1 -1
  146. package/dist/needs-you.d.ts +22 -2
  147. package/dist/needs-you.js +17 -3
  148. package/dist/needs-you.js.map +1 -1
  149. package/dist/office-share-page.d.ts +1 -1
  150. package/dist/office-share-page.js +6 -2
  151. package/dist/office-share-page.js.map +1 -1
  152. package/dist/outbound-handlers.js +59 -3
  153. package/dist/outbound-handlers.js.map +1 -1
  154. package/dist/outbound-schemas.d.ts +1 -0
  155. package/dist/outbound-schemas.js +9 -3
  156. package/dist/outbound-schemas.js.map +1 -1
  157. package/dist/package-preimage.js +27 -2
  158. package/dist/package-preimage.js.map +1 -1
  159. package/dist/queued-handoffs.d.ts +33 -0
  160. package/dist/queued-handoffs.js +75 -0
  161. package/dist/queued-handoffs.js.map +1 -0
  162. package/dist/reply-buttons-controller.js +10 -0
  163. package/dist/reply-buttons-controller.js.map +1 -1
  164. package/dist/scheduler/db.d.ts +2 -1
  165. package/dist/scheduler/db.js +23 -9
  166. package/dist/scheduler/db.js.map +1 -1
  167. package/dist/selection-card.d.ts +2 -3
  168. package/dist/selection-card.js +99 -26
  169. package/dist/selection-card.js.map +1 -1
  170. package/dist/settings-api.d.ts +13 -0
  171. package/dist/settings-api.js +12 -1
  172. package/dist/settings-api.js.map +1 -1
  173. package/dist/stall-reporter.d.ts +21 -0
  174. package/dist/stall-reporter.js +82 -0
  175. package/dist/stall-reporter.js.map +1 -0
  176. package/dist/tmux-control.d.ts +2 -2
  177. package/dist/tmux-control.js +4 -2
  178. package/dist/tmux-control.js.map +1 -1
  179. package/dist/tmux-exec-helper.d.ts +6 -3
  180. package/dist/tmux-exec-helper.js +115 -53
  181. package/dist/tmux-exec-helper.js.map +1 -1
  182. package/dist/tmux-manager.d.ts +6 -0
  183. package/dist/tmux-manager.js +17 -0
  184. package/dist/tmux-manager.js.map +1 -1
  185. package/dist/tool-permissions.d.ts +5 -2
  186. package/dist/tool-permissions.js +6 -5
  187. package/dist/tool-permissions.js.map +1 -1
  188. package/dist/tool-progress.d.ts +2 -0
  189. package/dist/tool-progress.js +19 -3
  190. package/dist/tool-progress.js.map +1 -1
  191. package/dist/topic-commands.js +4 -0
  192. package/dist/topic-commands.js.map +1 -1
  193. package/dist/tunnel/cloudflared-install.d.ts +4 -0
  194. package/dist/tunnel/cloudflared-install.js +20 -1
  195. package/dist/tunnel/cloudflared-install.js.map +1 -1
  196. package/dist/tunnel/cloudflared.d.ts +8 -1
  197. package/dist/tunnel/cloudflared.js +34 -6
  198. package/dist/tunnel/cloudflared.js.map +1 -1
  199. package/dist/tunnel/lease.d.ts +3 -3
  200. package/dist/tunnel/lease.js +23 -19
  201. package/dist/tunnel/lease.js.map +1 -1
  202. package/dist/tunnel/manager.js +6 -7
  203. package/dist/tunnel/manager.js.map +1 -1
  204. package/dist/ui/chat-store.js +20 -3
  205. package/dist/ui/chat-strings.js +4 -4
  206. package/dist/ui/chat-thread.js +52 -10
  207. package/dist/ui/fleet-canvas.js +4 -3
  208. package/dist/ui/fleet-office-model.js +55 -22
  209. package/dist/ui/fleet-office-overview.js +6 -38
  210. package/dist/ui/fleet-office-pack.js +4 -3
  211. package/dist/ui/fleet-office-skin.js +114 -21
  212. package/dist/ui/fleet-office-store.js +5 -0
  213. package/dist/ui/fleet-office-zones.js +2 -2
  214. package/dist/ui/fleet-office.js +41 -9
  215. package/dist/ui/fleet-usage.js +90 -27
  216. package/dist/ui/panel-chat.js +2 -0
  217. package/dist/ui/panel-log.js +7 -1
  218. package/dist/ui/panel-needs.js +17 -8
  219. package/dist/ui/settings-strings.js +2 -2
  220. package/dist/ui/shared/app-i18n.js +4 -4
  221. package/dist/ui/shared/app-needs.js +1 -1
  222. package/dist/ui/shared/app-shell.js +4 -2
  223. package/dist/ui/shared/app-stream.js +26 -0
  224. package/dist/ui/shared/app.css +51 -3
  225. package/dist/ui/shared/office/guild.png +0 -0
  226. package/dist/ui/shared/office/school.png +0 -0
  227. package/dist/ui/shared/panel-view.js +339 -17
  228. package/dist/ui/shared/usage-dialog.js +18 -1
  229. package/dist/ui/shared/view-history.css +17 -0
  230. package/dist/ui/shared/view-history.js +438 -0
  231. package/dist/ui/shared/view-strings.js +42 -0
  232. package/dist/unattended-install.d.ts +6 -0
  233. package/dist/unattended-install.js +79 -0
  234. package/dist/unattended-install.js.map +1 -0
  235. package/dist/usage/capacity.d.ts +69 -0
  236. package/dist/usage/capacity.js +119 -0
  237. package/dist/usage/capacity.js.map +1 -0
  238. package/dist/usage/i18n-keys.d.ts +1 -1
  239. package/dist/usage/i18n-keys.js +3 -0
  240. package/dist/usage/i18n-keys.js.map +1 -1
  241. package/dist/usage/profile-timeline.d.ts +16 -0
  242. package/dist/usage/profile-timeline.js +58 -0
  243. package/dist/usage/profile-timeline.js.map +1 -0
  244. package/dist/usage/quota-history.d.ts +43 -0
  245. package/dist/usage/quota-history.js +168 -0
  246. package/dist/usage/quota-history.js.map +1 -0
  247. package/dist/usage/usage-api.d.ts +31 -0
  248. package/dist/usage/usage-api.js +80 -1
  249. package/dist/usage/usage-api.js.map +1 -1
  250. package/dist/usage-report.d.ts +25 -1
  251. package/dist/usage-report.js +55 -4
  252. package/dist/usage-report.js.map +1 -1
  253. package/dist/usage-rollup.d.ts +4 -1
  254. package/dist/usage-rollup.js +12 -3
  255. package/dist/usage-rollup.js.map +1 -1
  256. package/dist/view-api.d.ts +31 -0
  257. package/dist/view-api.js +186 -41
  258. package/dist/view-api.js.map +1 -1
  259. package/dist/view-history.d.ts +84 -0
  260. package/dist/view-history.js +116 -0
  261. package/dist/view-history.js.map +1 -0
  262. package/dist/web-api.d.ts +6 -0
  263. package/dist/web-api.js +15 -4
  264. package/dist/web-api.js.map +1 -1
  265. package/dist/web-chat-history.d.ts +20 -0
  266. package/dist/web-chat-history.js +21 -0
  267. package/dist/web-chat-history.js.map +1 -1
  268. package/dist/web-guest-access.js +5 -1
  269. package/dist/web-guest-access.js.map +1 -1
  270. package/dist/web-login.d.ts +4 -3
  271. package/dist/web-login.js +23 -14
  272. package/dist/web-login.js.map +1 -1
  273. package/dist/web-office-pack-tools.d.ts +1 -1
  274. package/dist/web-office-pack-tools.js +1 -1
  275. package/dist/web-office-pack-tools.js.map +1 -1
  276. package/dist/web-session-authorization.d.ts +13 -2
  277. package/dist/web-session-authorization.js +10 -3
  278. package/dist/web-session-authorization.js.map +1 -1
  279. package/dist/web-session.d.ts +5 -0
  280. package/dist/web-session.js +20 -8
  281. package/dist/web-session.js.map +1 -1
  282. package/launcher/agend-install.cjs +16 -2
  283. package/launcher/private-home.cjs +1 -1
  284. 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,6 +28,8 @@ 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";
31
34
  import { daemonTopicMode, isWebOnlyFleetConfig } from "./web-only-fleet.js";
32
35
  import { OFFICE_SHARE_TUNNEL_DIR, OfficeShareService } from "./office-share-service.js";
@@ -37,7 +40,7 @@ import { withinBudget } from "./monotonic-budget.js";
37
40
  import { measureSyncWork } from "./sync-work-attribution.js";
38
41
  import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
39
42
  import { ProfileControlServer } from "./profile-control.js";
40
- 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";
41
44
  import { atomicWriteFileSync } from "./atomic-write.js";
42
45
  import { createHash, randomBytes, randomUUID } from "node:crypto";
43
46
  import { freemem, totalmem, homedir } from "node:os";
@@ -54,6 +57,7 @@ import { applyKiroMigration, kiroMigrationPrompt, planKiroMigration } from "./ki
54
57
  import { fleetLabel } from "./fleet-label.js";
55
58
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
56
59
  import { formatUpdateProgress } from "./update-progress.js";
60
+ import { startStallReporter } from "./stall-reporter.js";
57
61
  import { sdNotify, sdNotifyBlocking, setSdNotifyWarn, startSdWatchdog } from "./sd-notify.js";
58
62
  import { StartupMemoryDeadline, fleetStopConcurrency } from "./startup-memory-deadline.js";
59
63
  import { readFleetMemory } from "./process-memory.js";
@@ -72,7 +76,8 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
72
76
  import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, loadFleetConfigSource, loadRawFleetConfigSource, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG, autoGeneralInstanceConfig } from "./config.js";
73
77
  import { EventLog } from "./event-log.js";
74
78
  import { binaryProbe } from "./binary-probe.js";
75
- 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";
76
81
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
77
82
  import { OutboxOpenError } from "./outbox-open-error.js";
78
83
  import { AdapterWorld } from "./adapter-world.js";
@@ -87,6 +92,7 @@ import { formatFleetTime } from "./tz-utils.js";
87
92
  import { createAdapter } from "./channel/factory.js";
88
93
  import { isWebChannelEcho, WEB_ECHO_PREFIX } from "./web-channel-echo.js";
89
94
  import { TelegramAdapter } from "./channel/adapters/telegram.js";
95
+ import { MissedOfflineStore } from "./missed-offline.js";
90
96
  import { createBackend } from "./backend/factory.js";
91
97
  import { readEffortMetadata } from "./backend/effort-metadata.js";
92
98
  import { codexEffort, flagEffort } from "./backend/launch-effort.js";
@@ -107,7 +113,7 @@ import { WebhookEmitter } from "./webhook-emitter.js";
107
113
  import { TmuxControlClient } from "./tmux-control.js";
108
114
  import { safeHandler } from "./safe-async.js";
109
115
  import { RoutingEngine } from "./routing-engine.js";
110
- import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, } from "./instance-lifecycle.js";
116
+ import { InstanceLifecycle, SupersededStartError, BACKEND_INSTALLATION_INFO, checkBinaryInstalled, checkBinaryInstalledAsync, } from "./instance-lifecycle.js";
111
117
  import { TopicArchiver } from "./topic-archiver.js";
112
118
  import { StatuslineWatcher } from "./statusline-watcher.js";
113
119
  import { applyInstanceConfigPatch, outboundHandlers } from "./outbound-handlers.js";
@@ -166,6 +172,7 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
166
172
  }
167
173
  import { handleWebRequest, webPeerOf, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
168
174
  import { LogService } from "./log-service.js";
175
+ import { viewHistory } from "./view-history.js";
169
176
  import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId, webChatFrom } from "./web-chat-history.js";
170
177
  import { WEB_ECHO_COPIES_MAX, WEB_MIRROR_TIMEOUT_MS, webReactionEmoji, webReactionProblem } from "./web-reactions.js";
171
178
  import { WebChatDiskStore } from "./web-chat-store.js";
@@ -198,7 +205,7 @@ import { envFileKeys } from "./token-env-name.js";
198
205
  import { discordGeneralChannelId } from "./discord-general.js";
199
206
  import { parseEnvText } from "./env-file.js";
200
207
  import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permissions.js";
201
- 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";
202
209
  import { LOGIN_FLOWS, checkAuthStatus, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
203
210
  import { LoginSession } from "./login-manager.js";
204
211
  import { runOnLoginShell } from "./login-shell.js";
@@ -229,6 +236,8 @@ import { remoteCardAction, remoteCardChoiceLabel, remoteCardOutcomeText, remoteC
229
236
  import { recentChatContext } from "./log-tail.js";
230
237
  import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
231
238
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
239
+ import { AgentCredentials, agentCredentialPolicy } from "./agent-credentials.js";
240
+ import { writeSecretFile } from "./secret-file.js";
232
241
  import { ConfigHistory, ConfigSyntaxError, configIssueText } from "./config-history.js";
233
242
  import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt, readClassicRoomNames } from "./classic-channel-manager.js";
234
243
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
@@ -287,7 +296,7 @@ import { PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC, providerSecretSpec, p
287
296
  const SELF_RESTART_DEADLINE_MS = 300_000;
288
297
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
289
298
  import { kiroHome } from "./backend/kiro-kas-store.js";
290
- 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";
291
300
  import { carryClaudeConversation } from "./backend/claude-carry.js";
292
301
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
293
302
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
@@ -302,6 +311,13 @@ import { SpawnGate } from "./spawn-gate.js";
302
311
  import { BackendOutageTracker } from "./backend-outage.js";
303
312
  import { canUnlockAdvancedTips, DailyTipScheduler, selectTip, visibleTipLevels, } from "./tips.js";
304
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";
305
321
  // Closed migration cohort: these 11 names existed immediately before the first
306
322
  // managed-skill publisher (a1ac499f; parent e267e684). Never grow this set when
307
323
  // adding a bundled skill: its new name is not proof of old AgEnD ownership.
@@ -443,6 +459,8 @@ const CANCEL_BTN_LEDGER_FILE = "cancel-buttons.json";
443
459
  const PROGRESS_UPDATE_INTERVAL_MS = 60_000;
444
460
  /** Floor between tool-progress-driven bubble edits (Telegram flood safety). */
445
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;
446
464
  /** Elapsed time is only shown once work has clearly outlasted a quick answer. */
447
465
  /**
448
466
  * Default delay before the button starts showing elapsed time. Configurable via
@@ -451,6 +469,8 @@ const TOOL_PROGRESS_EDIT_MIN_MS = 4_000;
451
469
  * while anything real shows signs of life well before the old two minutes.
452
470
  */
453
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;
454
474
  /** How much of a tool summary the progress line will show before eliding. */
455
475
  const PROGRESS_ACTIVITY_MAX_CHARS = 48;
456
476
  /**
@@ -622,6 +642,14 @@ function emojiListFilter(opts) {
622
642
  }
623
643
  /** #1920: a guest's scope until phase 2 derives one: nothing. */
624
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
+ }
625
653
  export class FleetManager {
626
654
  dataDir;
627
655
  static signalTarget = null;
@@ -873,6 +901,7 @@ export class FleetManager {
873
901
  startupRetryNotices = new Map();
874
902
  /** Unsupported-CLI refusals waiting to go out, grouped by reason (#1109). */
875
903
  unsupportedCliNotices = new Map();
904
+ kiroCompatibilityHolds = new Map();
876
905
  kiroIncompatNotices = new Map();
877
906
  /** Backoff between automatic startup retries; the last step repeats while the backend is down. */
878
907
  static STARTUP_RETRY_BACKOFF_MS = [60_000, 5 * 60_000, 15 * 60_000];
@@ -901,6 +930,7 @@ export class FleetManager {
901
930
  kiroMigrationRuns = new Map();
902
931
  /** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
903
932
  needsYou = null;
933
+ missedOfflineStore = null;
904
934
  configHistory = null;
905
935
  configHistoryTimer = null;
906
936
  loadedFleetSource = null;
@@ -1016,11 +1046,15 @@ export class FleetManager {
1016
1046
  discordPresenceEagerPending = false;
1017
1047
  discordPresenceInFlight = null;
1018
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;
1019
1052
  /** Days of event/activity history to keep. */
1020
1053
  static EVENT_LOG_RETENTION_DAYS = 30;
1021
1054
  watchdogTimer = null;
1022
1055
  startupMemoryDeadline = null;
1023
1056
  eventLoopWatch = null;
1057
+ stallReporter = null;
1024
1058
  startedAt = 0;
1025
1059
  stormOpenNotifyTimer = null;
1026
1060
  memoryLogAt = null;
@@ -1064,6 +1098,37 @@ export class FleetManager {
1064
1098
  webChatRemovals = new Map();
1065
1099
  /** #1774: a removal under way — its replies' fate waits for its outcome (success: theirs no more; failure: theirs). */
1066
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
+ }
1067
1132
  webChatGeneration(name) { return this.webChatGenerations.get(name) ?? 0; }
1068
1133
  /** #1774: who an instance's reply is from, taken when it is sent: the name's generation and its daemon then. */
1069
1134
  webChatIdentity(name) { return { generation: this.webChatGeneration(name), daemon: this.daemons.get(name) }; }
@@ -2107,6 +2172,22 @@ export class FleetManager {
2107
2172
  * startup-only key at startup. See checkStartupSignatureConsistency(). */
2108
2173
  fleetSignatureMismatch = null;
2109
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
+ }
2110
2191
  constructor(dataDir) {
2111
2192
  this.dataDir = dataDir;
2112
2193
  FleetManager.signalTarget = this;
@@ -2129,6 +2210,7 @@ export class FleetManager {
2129
2210
  this.topicCommands = new TopicCommands(this);
2130
2211
  this.topicArchiver = new TopicArchiver(this);
2131
2212
  this.statuslineWatcher = new StatuslineWatcher(this);
2213
+ this.missedOfflineStore = new MissedOfflineStore(this.dataDir);
2132
2214
  }
2133
2215
  /** #1266: a reply's buttons — the store (reply-buttons.db) and what a click does. Created on first use. */
2134
2216
  replyButtonsCtl = null;
@@ -2487,6 +2569,10 @@ export class FleetManager {
2487
2569
  const daemon = this.daemons.get(name);
2488
2570
  if (daemon?.bootId === daemonBootId)
2489
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
+ }
2490
2576
  // #2802: no post-restart notices. The outbox still delivers or fails a row accepted before the restart, and an open
2491
2577
  // request is reminded by the ordinary turn-ended nudge (sweepReplyObligations) once the owner is idle.
2492
2578
  this.scheduleDeliveryOutboxPump();
@@ -3324,6 +3410,7 @@ export class FleetManager {
3324
3410
  * guild channel.
3325
3411
  */
3326
3412
  reregisterClassicChannels() {
3413
+ this.agentCredentialsCache?.observePolicies();
3327
3414
  if (!this.classicChannels)
3328
3415
  return;
3329
3416
  const channels = this.classicChannels.getAll();
@@ -4014,6 +4101,13 @@ export class FleetManager {
4014
4101
  changed: () => this.emitSseEvent("status", this.getUiStatus()),
4015
4102
  });
4016
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
+ }
4017
4111
  /** #1218: GET /ui/steps — an instance's steps, read on demand (null: no such instance). */
4018
4112
  logSteps(name, q) { return this.log.steps(name, q); }
4019
4113
  /**
@@ -5636,6 +5730,23 @@ export class FleetManager {
5636
5730
  if (resumePaused)
5637
5731
  this.lifecycle.markActivitySeedNow(name);
5638
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);
5639
5750
  if (config.general_topic) {
5640
5751
  this.ensureGeneralInstructions(config.working_directory, backend, name);
5641
5752
  }
@@ -6092,6 +6203,17 @@ export class FleetManager {
6092
6203
  this.queueKiroIncompatNotice(name, err.message);
6093
6204
  return true;
6094
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
+ }
6095
6217
  /**
6096
6218
  * LifecycleContext: a kiro instance the installed kiro-cli cannot run as
6097
6219
  * configured (#1109) — at start, or at a respawn after kiro-cli replaced
@@ -6286,6 +6408,8 @@ export class FleetManager {
6286
6408
  }
6287
6409
  /** Restart a single instance, reloading fleet.yaml first to pick up config changes. */
6288
6410
  async restartSingleInstance(name, opts) {
6411
+ if (opts?.ownerCurrent && !opts.ownerCurrent())
6412
+ throw new RestartSupersededError(this.shownInstanceName(name));
6289
6413
  const migration = this.kiroMigrationRuns?.get(name);
6290
6414
  if (migration && migration.token !== opts?.kiroMigration)
6291
6415
  throw new Error(t("kiro_migrate.in_progress"));
@@ -7586,7 +7710,7 @@ export class FleetManager {
7586
7710
  return { kind: "refused" };
7587
7711
  if (device.tier === "guest")
7588
7712
  return this.requestGuestDeviceCode(device, input, token);
7589
- const paused = this.webLoginCodes.pausedForMs();
7713
+ const paused = this.webLoginCodes.pausedForMs(input.exposureId ?? "local");
7590
7714
  if (paused > 0)
7591
7715
  return { kind: "limited", retryAfterMs: paused };
7592
7716
  const admitted = this.codeRequestLimiter.admit(device.idHash);
@@ -8071,6 +8195,9 @@ export class FleetManager {
8071
8195
  this.watchdogTimer = startSdWatchdog();
8072
8196
  this.eventLoopWatch?.stop();
8073
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") });
8074
8201
  try {
8075
8202
  await this.startAllWithinDeadline(configPath, deadline);
8076
8203
  }
@@ -8084,6 +8211,8 @@ export class FleetManager {
8084
8211
  this.watchdogTimer = null;
8085
8212
  this.eventLoopWatch?.stop();
8086
8213
  this.eventLoopWatch = null;
8214
+ this.stallReporter?.stop();
8215
+ this.stallReporter = null;
8087
8216
  }
8088
8217
  }
8089
8218
  }
@@ -8351,6 +8480,10 @@ export class FleetManager {
8351
8480
  const generalDir = join(getAgendHome(), name);
8352
8481
  mkdirSync(generalDir, { recursive: true, mode: 0o700 });
8353
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);
8354
8487
  this.ensureGeneralInstructions(generalDir, backendName, name);
8355
8488
  fleet.instances[name] = autoGeneralInstanceConfig(fleet.defaults, generalDir, need.adapterId); // #2598: its kiro_ui is the fleet's
8356
8489
  generalsCreated = true;
@@ -8505,6 +8638,9 @@ export class FleetManager {
8505
8638
  if (others.length > 0 || classicAtBoot.length > 0) {
8506
8639
  await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady(), classicAtBoot);
8507
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();
8508
8644
  if (topicMode && (fleet.channel || fleet.channels?.length)) {
8509
8645
  await adapterStartup;
8510
8646
  this.startDiscordUsagePresence();
@@ -8676,6 +8812,178 @@ export class FleetManager {
8676
8812
  this.logger.warn({ health }, "Fleet started with problems — see /health");
8677
8813
  }
8678
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
+ }
8679
8987
  /** Keep Discord profile activity aligned with the same cached usage source as /usage. */
8680
8988
  startDiscordUsagePresence() {
8681
8989
  if (this.discordPresenceTimer)
@@ -9140,6 +9448,9 @@ export class FleetManager {
9140
9448
  return;
9141
9449
  await this.handleInboundMessage(msg);
9142
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); });
9143
9454
  adapter.on("reaction", safeHandler(async (r) => {
9144
9455
  if (!isCurrentAdapter())
9145
9456
  return;
@@ -9148,6 +9459,11 @@ export class FleetManager {
9148
9459
  adapter.on("callback_query", safeHandler(async (data) => {
9149
9460
  await this.receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter);
9150
9461
  }, this.logger, "adapter.callback_query"));
9462
+ adapter.on("missed_offline", (ev) => {
9463
+ if (!isCurrentAdapter())
9464
+ return;
9465
+ this.recordMissedOffline(ev);
9466
+ });
9151
9467
  this.bindTopicClosedHandler(adapter, adapterId, "adapter.topic_closed");
9152
9468
  // Handle classic bot slash commands (/start, /stop, /chat, /compact, /save, /load)
9153
9469
  adapter.on("slash_command", safeHandler(async (data) => {
@@ -9290,6 +9606,9 @@ export class FleetManager {
9290
9606
  return;
9291
9607
  await this.handleInboundMessage(msg);
9292
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); });
9293
9612
  adapter.on("reaction", safeHandler(async (r) => {
9294
9613
  if (!isCurrentAdapter())
9295
9614
  return;
@@ -9298,6 +9617,11 @@ export class FleetManager {
9298
9617
  adapter.on("callback_query", safeHandler(async (data) => {
9299
9618
  await this.receiveAdapterCallback(data, adapterId, adapter, isCurrentAdapter);
9300
9619
  }, this.logger, `adapter[${adapterId}].callback_query`));
9620
+ adapter.on("missed_offline", (ev) => {
9621
+ if (!isCurrentAdapter())
9622
+ return;
9623
+ this.recordMissedOffline(ev);
9624
+ });
9301
9625
  this.bindTopicClosedHandler(adapter, adapterId, `adapter[${adapterId}].topic_closed`);
9302
9626
  // Slash commands: classic bot + admin commands
9303
9627
  adapter.on("slash_command", safeHandler(async (data) => {
@@ -10034,6 +10358,34 @@ export class FleetManager {
10034
10358
  }
10035
10359
  return true;
10036
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
+ }
10037
10389
  /**
10038
10390
  * #1085: coincident topic numbers must not route across Telegram groups.
10039
10391
  */
@@ -11657,6 +12009,11 @@ export class FleetManager {
11657
12009
  let result;
11658
12010
  switch (msg.type) {
11659
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
+ }
11660
12017
  // Prune expired decisions on create
11661
12018
  db.pruneExpiredDecisions();
11662
12019
  result = db.createDecision({
@@ -11667,7 +12024,7 @@ export class FleetManager {
11667
12024
  tags: payload.tags,
11668
12025
  ttl_days: payload.ttl_days,
11669
12026
  created_by: instanceName,
11670
- supersedes: payload.supersedes,
12027
+ supersedes: supersedes.id,
11671
12028
  });
11672
12029
  break;
11673
12030
  }
@@ -11844,6 +12201,21 @@ export class FleetManager {
11844
12201
  return "ok";
11845
12202
  }
11846
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
+ }
11847
12219
  async handleDecisionCrudHttp(instance, op, args) {
11848
12220
  if (!this.scheduler)
11849
12221
  return { error: "Scheduler not available" };
@@ -11859,16 +12231,24 @@ export class FleetManager {
11859
12231
  if (!title || !content)
11860
12232
  return { error: "title and content are required" };
11861
12233
  const scope = args.scope === "fleet" ? "fleet" : "project";
11862
- return db.createDecision({
11863
- project_root: projectRoot,
11864
- scope,
11865
- title,
11866
- content,
11867
- tags: asStrArr(args.tags),
11868
- ttl_days: asNum(args.ttl_days),
11869
- supersedes: asStr(args.supersedes),
11870
- created_by: instance,
11871
- });
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
+ }
11872
12252
  }
11873
12253
  // #2152: titles by default (agend-agent decision-list); one decision's full text by id (decision-get <id>).
11874
12254
  // The CLI sends include_archived (snake case, as the MCP tool): both spellings are read.
@@ -12157,16 +12537,89 @@ export class FleetManager {
12157
12537
  }
12158
12538
  return adapter.createTopic(topicName);
12159
12539
  }
12160
- async deleteForumTopic(topicId, adapterId) {
12161
- try {
12162
- const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
12163
- if (!adapter?.deleteTopic)
12164
- return;
12165
- await adapter.deleteTopic(topicId);
12166
- }
12167
- catch (err) {
12168
- this.logger.warn({ err, topicId }, "Failed to delete forum topic during rollback");
12169
- }
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() });
12170
12623
  }
12171
12624
  getForumTopicDeleter(adapterId) {
12172
12625
  const adapter = (adapterId ? this.worlds.get(adapterId)?.adapter : undefined) ?? this.adapter;
@@ -12567,6 +13020,7 @@ export class FleetManager {
12567
13020
  Object.assign(current, resolved);
12568
13021
  }
12569
13022
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
13023
+ this.agentCredentialsCache?.observePolicies();
12570
13024
  this.refreshSavedDashboardPermissions();
12571
13025
  this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
12572
13026
  }
@@ -12855,6 +13309,9 @@ export class FleetManager {
12855
13309
  await this.lifecycle.remove(name, authorization, execution, transition);
12856
13310
  else
12857
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);
12858
13315
  // #1774: removed — its replies still copying their files are no one's to show, replaced or not (a failed removal
12859
13316
  // throws above: its replies stay its own).
12860
13317
  this.webChatRemovals.set(name, { below: removedBelow, ...(removedDaemon !== undefined ? { daemon: removedDaemon } : {}) });
@@ -14529,6 +14986,7 @@ export class FleetManager {
14529
14986
  threadId: opts.threadId,
14530
14987
  expiredText: opts.expiredText,
14531
14988
  createdAt: Date.now(),
14989
+ lifecycleCurrent: this.lifecyclePin(opts.instanceName),
14532
14990
  ...opts.extra,
14533
14991
  };
14534
14992
  entry.timer = setTimeout(() => {
@@ -14880,6 +15338,7 @@ export class FleetManager {
14880
15338
  const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
14881
15339
  name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
14882
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) } : {}),
14883
15342
  kiroDbPath: this.kiroDbPathOf(name, this.backendNameOf(name)),
14884
15343
  }));
14885
15344
  const taken = new Set(fleet.map(i => i.name));
@@ -14891,6 +15350,9 @@ export class FleetManager {
14891
15350
  claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
14892
15351
  profile: this.usageProfileOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
14893
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) } : {}),
14894
15356
  }));
14895
15357
  return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
14896
15358
  }
@@ -15051,7 +15513,8 @@ export class FleetManager {
15051
15513
  out.push({
15052
15514
  name, state: p.state ?? undefined,
15053
15515
  interaction: p.interaction ? { kind: p.interaction.kind, owner: wait?.owner ?? null, episode: p.interaction.episode, since: p.interaction.since } : null,
15054
- 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),
15055
15518
  pauseReason, pausedAt,
15056
15519
  crashedAt: crashed ? (this.needsCrashedAt.get(name) ?? null) : null,
15057
15520
  signIn: pauseReason === "auth" ? this.signInFor(name) : null,
@@ -15127,6 +15590,10 @@ export class FleetManager {
15127
15590
  throw new Error("the delivery outbox is not open");
15128
15591
  return this.deliveryOutbox.acknowledge(deliveryId, by);
15129
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,
15130
15597
  tasks: () => this.needsYouTaskItems(),
15131
15598
  backendSignIns: () => this.needsYouBackendSignIns(),
15132
15599
  configIssues: () => (this.configHistory?.currentIssues() ?? []).map(issue => ({
@@ -15841,8 +16308,9 @@ export class FleetManager {
15841
16308
  return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
15842
16309
  },
15843
16310
  signOut: (handle) => {
15844
- this.webSessions?.revokeByHandle(handle);
15845
- 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 };
15846
16314
  },
15847
16315
  event: (type, payload) => { try {
15848
16316
  this.eventLog?.insert("web", type, payload);
@@ -15855,6 +16323,7 @@ export class FleetManager {
15855
16323
  expired: (c) => t("web_auth.card_expired", c.label),
15856
16324
  refused: (c) => t("web_auth.card_refused", c.label, t(`web_auth.refusal_${c.refusal}`)),
15857
16325
  revoked: (c) => t("web_auth.card_revoked", c.label),
16326
+ notSaved: (c) => t("web_auth.card_not_saved", c.label),
15858
16327
  ended: (c) => t("web_auth.card_ended", c.label),
15859
16328
  generalHint: () => t("web_auth.general_hint"),
15860
16329
  confirmLabel: () => t("web_auth.confirm"),
@@ -16242,21 +16711,27 @@ export class FleetManager {
16242
16711
  if (claimed === "consumed")
16243
16712
  return true;
16244
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);
16245
16716
  this.eventLog?.insert(pending.instanceName, oom ? "session_oom_action" : "normal_exit_action", { action, userId: data.userId });
16246
16717
  if (action === "ignore") {
16247
16718
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.ignored", pending.instanceName));
16248
16719
  return true;
16249
16720
  }
16721
+ if (!current()) {
16722
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
16723
+ return true;
16724
+ }
16250
16725
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("exit.restarting", pending.instanceName));
16251
16726
  try {
16252
- 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,
16253
16728
  ...(pending.confirmationCurrent ? { recoveryCurrent: pending.confirmationCurrent } : {}) } : {}) });
16254
16729
  await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("exit.restarted", pending.instanceName), pending.threadId);
16255
16730
  }
16256
16731
  catch (err) {
16257
16732
  const message = err instanceof Error ? err.message : String(err);
16258
16733
  this.logger.error({ err, instanceName: pending.instanceName }, "Normal-exit restart failed");
16259
- 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"));
16260
16735
  }
16261
16736
  return true;
16262
16737
  }
@@ -16769,7 +17244,14 @@ export class FleetManager {
16769
17244
  return;
16770
17245
  }
16771
17246
  const publication = this.beginCancelButtonPublication(instanceName, correlationId);
16772
- 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));
16773
17255
  try {
16774
17256
  const sent = await adapter.notifyAlert(chatId, {
16775
17257
  type: "cancel",
@@ -16778,6 +17260,9 @@ export class FleetManager {
16778
17260
  choices: [{ id: `cancel:${instanceName}`, label: t("cancel.button") }],
16779
17261
  }, threadId ? { threadId } : undefined);
16780
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);
16781
17266
  const entry = {
16782
17267
  instanceName,
16783
17268
  adapterId,
@@ -16789,7 +17274,7 @@ export class FleetManager {
16789
17274
  // Elapsed time is measured from when this button was posted — i.e. from
16790
17275
  // when the work was handed over — not from the pane's working transition,
16791
17276
  // which resets if the CLI blips idle mid-turn.
16792
- startedAt: Date.now(),
17277
+ startedAt: carriedStart ?? Date.now(),
16793
17278
  // Matches the text notifyAlert just posted, so the first 60s tick does
16794
17279
  // not re-edit identical text — which put a "(edited)" mark on Discord
16795
17280
  // with nothing visibly changed.
@@ -16912,14 +17397,16 @@ export class FleetManager {
16912
17397
  * Make a tool summary safe to paste into a channel message.
16913
17398
  *
16914
17399
  * The text is agent-controlled (it is built from tool inputs — file paths,
16915
- * shell commands), so it gets flattened to one line, capped, and stripped of
16916
- * the two Discord mass-mention triggers. Neither channel renders it with a
16917
- * 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.
16918
17405
  */
16919
17406
  static sanitizeActivity(activity) {
16920
17407
  if (!activity)
16921
17408
  return null;
16922
- 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();
16923
17410
  if (!flat)
16924
17411
  return null;
16925
17412
  return flat.length > PROGRESS_ACTIVITY_MAX_CHARS
@@ -17038,6 +17525,12 @@ export class FleetManager {
17038
17525
  }
17039
17526
  return PROGRESS_MIN_ELAPSED_MS;
17040
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
+ }
17041
17534
  startProgressTicker(entry) {
17042
17535
  const tick = () => this.refreshBubble(entry);
17043
17536
  entry.progressTimer = setInterval(tick, PROGRESS_UPDATE_INTERVAL_MS);
@@ -17047,7 +17540,9 @@ export class FleetManager {
17047
17540
  // one additional edit per turn that lives past the threshold.
17048
17541
  const firstAt = this.progressMinElapsedMs() - (Date.now() - (entry.startedAt ?? Date.now()));
17049
17542
  if (firstAt > 0 && firstAt < PROGRESS_UPDATE_INTERVAL_MS) {
17050
- 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);
17051
17546
  firstTick.unref?.();
17052
17547
  }
17053
17548
  }
@@ -17365,7 +17860,10 @@ export class FleetManager {
17365
17860
  return this.browserBackendSignInsInstance ??= new BrowserBackendSignIns({
17366
17861
  dataDir: this.dataDir, lock: this.loginWindow,
17367
17862
  current: owner => this.browserSignInOwnerCurrent(owner),
17368
- 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
+ },
17369
17867
  checkAuth: backend => checkAuthStatus(LOGIN_FLOWS[backend].authCheck),
17370
17868
  checkConsoleAuth: () => checkAuthStatus(LOGIN_FLOWS.antigravity.authCheck),
17371
17869
  transport: () => createStructuredSignInTransport(),
@@ -17469,13 +17967,13 @@ export class FleetManager {
17469
17967
  * #2233: `/login` for a dashboard requester. The same controller and checks; a CLI that is not installed is refused
17470
17968
  * with its install command (chat's install-first runs a terminal of its own, which the dashboard does not offer).
17471
17969
  */
17472
- startWebDashboardLogin(backendArg, chat, opts) {
17970
+ async startWebDashboardLogin(backendArg, chat, opts) {
17473
17971
  const wanted = LOGIN_BACKEND_ALIASES[backendArg.toLowerCase()] ?? backendArg.toLowerCase();
17474
17972
  if (isRemovedBackend(wanted))
17475
- return Promise.resolve(removedBackendMessage(wanted));
17973
+ return removedBackendMessage(wanted);
17476
17974
  const info = BACKEND_INSTALLATION_INFO[wanted];
17477
- if (info && LOGIN_FLOWS[wanted]?.remoteLogin !== "unsupported" && !this.isCliInstalled(wanted)) {
17478
- 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);
17479
17977
  }
17480
17978
  if (!opts.skipAuthCheck)
17481
17979
  this.recordLoginFlow(wanted, "login", chat.userId);
@@ -17523,8 +18021,10 @@ export class FleetManager {
17523
18021
  ? removedBackendMessage(backend)
17524
18022
  : !installed.has(backend)
17525
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.
17526
18026
  : flow?.remoteLogin === "unsupported"
17527
- ? t("login.remote_unsupported_agent_cli", backend, flow.command)
18027
+ ? t("login.host_only", flow.command)
17528
18028
  : backend === "opencode" ? t("login.unsupported", backend) : t("login.no_remote_flow", backend)}`).join("\n");
17529
18029
  await chat.adapter.sendText(chat.chatId, guidance, { threadId: chat.threadId })
17530
18030
  .catch(err => this.logger.warn({ err }, "Failed to post unsupported login guidance"));
@@ -17703,7 +18203,8 @@ export class FleetManager {
17703
18203
  return admitted.refusal;
17704
18204
  opts = { ...opts, profile: admitted.profile };
17705
18205
  }
17706
- 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))) {
17707
18208
  const flow = LOGIN_FLOWS[wanted];
17708
18209
  this.recordLoginFlow(wanted, flow && flow.remoteLogin !== "unsupported" ? "install_then_login" : "install_only", chat.userId);
17709
18210
  return opts.profile !== undefined
@@ -17928,6 +18429,8 @@ export class FleetManager {
17928
18429
  // installer, judge by exit code, verify the binary on a fresh login shell,
17929
18430
  // then sign in (the hand-off below).
17930
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;
17931
18434
  /** An install that succeeded, between its success line and the sign-in it hands over to (#1131). */
17932
18435
  installHandoff = null;
17933
18436
  /** Start a CLI install session. Caller enforces admin. */
@@ -17936,11 +18439,13 @@ export class FleetManager {
17936
18439
  const info = BACKEND_INSTALLATION_INFO[backend];
17937
18440
  if (!info)
17938
18441
  return t("install.unsupported", backendArg);
17939
- if (checkBinaryInstalled(info.binary))
17940
- return t("install.already", backend, info.binary);
17941
18442
  // Reserve the fleet-wide window before the first await (shared with web
17942
18443
  // login). Owned by this method until the session is published.
17943
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
+ }
17944
18449
  if (!claim)
17945
18450
  return this.loginWindow.busyMessage();
17946
18451
  let tmux;
@@ -17960,16 +18465,44 @@ export class FleetManager {
17960
18465
  // A synthetic flow: success is decided by the installer's exit code
17961
18466
  // (LoginSession treats a clean exit as success), never by pane text. The
17962
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
+ }
17963
18475
  const flow = {
17964
18476
  backend,
17965
- command: info.unattended ?? info.install,
18477
+ command: unattendedInstallCommand(info.unattended ?? info.install, shell),
17966
18478
  successPattern: /$^/,
17967
- timeoutMs: 10 * 60 * 1000,
18479
+ timeoutMs: this.installTimeoutMs,
17968
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)));
17969
18490
  const session = new LoginSession(flow, tmux, {
18491
+ onProgress: pane => stream.update(pane),
17970
18492
  onDone: async ({ ok, detail, cleanupFailed }) => {
17971
18493
  this.activeInstall = null;
17972
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(() => { });
17973
18506
  if (cleanupFailed) {
17974
18507
  await chat.adapter.sendText(chat.chatId, t("login.web_cleanup_failed", backend), { threadId: chat.threadId }).catch(() => { });
17975
18508
  }
@@ -17977,18 +18510,17 @@ export class FleetManager {
17977
18510
  // A cancel is user-initiated — the cancel command's own reply already
17978
18511
  // said so; a second message here would be a duplicate.
17979
18512
  if (detail !== "cancelled") {
17980
- 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(() => { });
17981
18514
  }
17982
18515
  return;
17983
18516
  }
17984
- // Between the install ending and the sign-in starting there is no
17985
- // session to cancel; this hand-off is what `/login cancel` stops — from
17986
- // here on, as the login-shell lookup below is an await.
17987
- const handoff = { backend, cancelled: false };
17988
- this.installHandoff = handoff;
18517
+ if (!handoff || handoff.cancelled || this.installHandoff !== handoff)
18518
+ return;
17989
18519
  // The installer may only have added the binary to a profile PATH; a
17990
18520
  // fresh login shell sees that, the fleet process's PATH may not.
17991
- 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));
17992
18524
  if (handoff.cancelled || this.installHandoff !== handoff)
17993
18525
  return;
17994
18526
  if (!installedAt) {
@@ -18058,6 +18590,11 @@ export class FleetManager {
18058
18590
  const info = BACKEND_INSTALLATION_INFO[backend];
18059
18591
  return !!info && checkBinaryInstalled(info.binary);
18060
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
+ }
18061
18598
  /** Abort the active install (`/login cancel`) and remove its window. */
18062
18599
  async cancelInstallSession() {
18063
18600
  if (!this.activeInstall)
@@ -18075,38 +18612,27 @@ export class FleetManager {
18075
18612
  */
18076
18613
  async locateBinaryOnLoginShell(binary) {
18077
18614
  try {
18078
- 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"`);
18079
18618
  if (result.status !== 0)
18080
18619
  return null;
18081
18620
  const path = result.stdout.trim().split("\n").pop()?.trim() ?? "";
18082
- if (!isAbsolute(path))
18083
- return null;
18084
- accessSync(path, fsConstants.X_OK);
18085
- return statSync(path).isFile() ? path : null;
18621
+ return isAbsolute(path) ? path : null;
18086
18622
  }
18087
18623
  catch {
18088
18624
  return null;
18089
18625
  }
18090
18626
  }
18091
18627
  /**
18092
- * #1092: the binary in one of the directories its own installer installs to,
18093
- * when no login shell can see it (the installer recorded its PATH in an
18094
- * interactive-only rc file). The same executable-file check as the login
18095
- * 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).
18096
18632
  */
18097
- locateInInstallerBinDirs(info) {
18098
- for (const dir of info.binDirs?.(process.env, homedir()) ?? []) {
18099
- if (!isAbsolute(dir))
18100
- continue;
18101
- const path = join(dir, info.binary);
18102
- try {
18103
- accessSync(path, fsConstants.X_OK);
18104
- if (statSync(path).isFile())
18105
- return path;
18106
- }
18107
- catch { /* not there */ }
18108
- }
18109
- 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;
18110
18636
  }
18111
18637
  /** Put an installed binary's directory on the fleet's PATH (#1059), once. */
18112
18638
  adoptBinaryDirectory(binaryPath, backend) {
@@ -18422,20 +18948,26 @@ export class FleetManager {
18422
18948
  if (claimed === "consumed")
18423
18949
  return true;
18424
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);
18425
18953
  this.eventLog?.insert(pending.instanceName, "hang_action", { action, userId: data.userId });
18426
18954
  if (action === "wait") {
18427
18955
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.waiting", pending.instanceName));
18428
18956
  return true;
18429
18957
  }
18958
+ if (!current()) {
18959
+ await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("restart.superseded", this.shownInstanceName(pending.instanceName)));
18960
+ return true;
18961
+ }
18430
18962
  await this.retireNonceButtons(pending, pending.messageId ?? data.messageId, t("hang.restarting", pending.instanceName));
18431
18963
  try {
18432
- await this.restartSingleInstance(pending.instanceName, { explicit: true });
18964
+ await this.restartSingleInstance(pending.instanceName, { explicit: true, ownerCurrent: current });
18433
18965
  await pending.adapter.editMessage(pending.chatId, pending.messageId ?? data.messageId, t("hang.restarted", pending.instanceName), pending.threadId);
18434
18966
  }
18435
18967
  catch (err) {
18436
18968
  const message = err instanceof Error ? err.message : String(err);
18437
18969
  this.logger.error({ err, instanceName: pending.instanceName }, "Hang force-restart failed");
18438
- 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"));
18439
18971
  }
18440
18972
  return true;
18441
18973
  }
@@ -18511,6 +19043,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18511
19043
  resolveKnowledgeWorkDir(workDir, backend, instanceName) {
18512
19044
  // Backend resolution may create/normalize the real cwd. Instructions and
18513
19045
  // skills must land where the CLI actually runs, not an assumed path.
19046
+ // Caller pins resolveBackendBinary first so createBackend does not `which`.
18514
19047
  try {
18515
19048
  const resolved = createBackend(backend, join(getAgendHome(), "cli-env"))
18516
19049
  .resolveWorkingDirectory?.(workDir, instanceName);
@@ -18889,6 +19422,45 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18889
19422
  await adapter.sendText(msg.chatId, t("cmd.not_a_command"), { threadId: msg.threadId });
18890
19423
  return true;
18891
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
+ }
18892
19464
  /** Handle a message in a classic channel: log it, forward only /chat messages */
18893
19465
  async handleClassicChannelMessage(instanceName, msg) {
18894
19466
  const text = msg.text ?? "";
@@ -18932,7 +19504,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18932
19504
  : (msg.attachments?.length
18933
19505
  ? ` [${msg.attachments.map(a => `${a.kind === "photo" ? "📷" : "📎"} ${a.filename || a.kind}`).join(", ")}]`
18934
19506
  : "");
18935
- 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));
18936
19511
  this.logger.info({ instanceName, user: msg.username, textLen: text.length, attachments: msg.attachments?.length ?? 0, source: msg.source }, "Collab mode message");
18937
19512
  // Check for @mention trigger: must be exact <@BOT_USER_ID>, not @everyone/@here.
18938
19513
  // Each bot matches ONLY its own id. A secondary bot must NOT fall back to the
@@ -19033,7 +19608,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19033
19608
  const attachmentTag = saved ? ` [${saved.kind === "photo" ? "📷" : "📎"} saved: ${saved.paths.join(", ")}]`
19034
19609
  : msg.attachments?.length ? ` [${msg.attachments.map(a => `📎 ${a.kind}${a.filename ? `: ${a.filename}` : ""}`).join(", ")}]`
19035
19610
  : "";
19036
- 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));
19037
19614
  // Bare attachment without /chat: save + log only, don't trigger agent
19038
19615
  if (!isChat) {
19039
19616
  const reactAdapter = this.worlds.get(msg.adapterId ?? "")?.adapter ?? this.adapter;
@@ -19789,7 +20366,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19789
20366
  }));
19790
20367
  const timer = setTimeout(() => this.pendingEffortSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
19791
20368
  timer.unref?.();
19792
- 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 });
19793
20370
  try {
19794
20371
  await data.respondChoices(t("effort.menu", this.effortMenuHeader(name)), choices);
19795
20372
  }
@@ -19824,7 +20401,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19824
20401
  }
19825
20402
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
19826
20403
  timer.unref?.();
19827
- 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 });
19828
20405
  try {
19829
20406
  const menuMessageId = await adapter.promptUser(chatId, t("effort.menu", this.effortMenuHeader(instanceName)), choices, { threadId });
19830
20407
  const pending = this.pendingEffortSelects.get(nonce);
@@ -19849,6 +20426,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19849
20426
  const pending = this.pendingEffortSelects.get(match[1]);
19850
20427
  if (!pending)
19851
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
+ }
19852
20437
  // The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
19853
20438
  // when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
19854
20439
  const cbChannel = data.threadId ?? data.chatId;
@@ -19891,17 +20476,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19891
20476
  let result;
19892
20477
  try {
19893
20478
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
19894
- result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
19895
- ? await withSpawnHoldReply(async (text) => {
19896
- if (pending.adapter && pending.adapterChatId) {
19897
- if (progressMsgId)
19898
- 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
+ }
19899
20488
  else
19900
- await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
19901
- }
19902
- else
19903
- await pending.respond(text);
19904
- }, () => 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");
19905
20491
  }
19906
20492
  catch (err) {
19907
20493
  this.logger.error({ err, instance: pending.instanceName, level }, "Effort switch failed");
@@ -19958,7 +20544,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19958
20544
  const choices = this.modelMenuChoices(name, nonce, options, currentModel);
19959
20545
  const timer = setTimeout(() => this.pendingModelSelects.delete(nonce), CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
19960
20546
  timer.unref?.();
19961
- 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 });
19962
20548
  try {
19963
20549
  await data.respondChoices(t("model.menu", `**${currentDisplay}**`), choices);
19964
20550
  }
@@ -19994,7 +20580,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19994
20580
  }
19995
20581
  }, CLASSIC_BACKEND_SELECTION_TIMEOUT_MS);
19996
20582
  timer.unref?.();
19997
- 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 });
19998
20584
  try {
19999
20585
  const menuMessageId = await adapter.promptUser(chatId, t("model.menu", currentDisplay), choices, { threadId });
20000
20586
  const pending = this.pendingModelSelects.get(nonce);
@@ -20167,6 +20753,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20167
20753
  const pending = this.pendingModelSelects.get(match[1]);
20168
20754
  if (!pending)
20169
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
+ }
20170
20764
  // The admin who opened the menu, through the adapter that posted it, in the same channel — and still an admin
20171
20765
  // when they click (#754 audit): the menu lives for a minute, and a click carries its own callback data.
20172
20766
  const cbChannel = data.threadId ?? data.chatId;
@@ -20227,17 +20821,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20227
20821
  let result;
20228
20822
  try {
20229
20823
  // Asked again after the progress edit awaited above: the instance may have moved, or the admin lost admin.
20230
- result = this.menuClickStillCurrent(pending.instanceName, data.userId, pending.channelId, adapterId, pending.adapterChatId)
20231
- ? await withSpawnHoldReply(async (text) => {
20232
- if (pending.adapter && pending.adapterChatId) {
20233
- if (progressMsgId)
20234
- 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
+ }
20235
20833
  else
20236
- await pending.adapter.sendText(pending.adapterChatId, text, { threadId: pending.adapterThreadId });
20237
- }
20238
- else
20239
- await pending.respond(text);
20240
- }, () => 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");
20241
20836
  }
20242
20837
  catch (err) {
20243
20838
  this.logger.error({ err, instance: pending.instanceName, model }, "Model switch failed");
@@ -20330,7 +20925,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20330
20925
  * restart backends only read it at launch, so it is persisted and the
20331
20926
  * instance respawns.
20332
20927
  */
20333
- 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));
20334
20932
  const level = requested.trim().toLowerCase();
20335
20933
  const backendName = this.backendNameForInstance(instanceName);
20336
20934
  let strategy = "unsupported";
@@ -20365,10 +20963,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20365
20963
  this.pasteRawToClassicInstance(instanceName, `/effort ${applied}`);
20366
20964
  return `${warn}${t("effort.runtime_success", instanceName, applied)}`;
20367
20965
  }
20368
- await this.restartSingleInstance(instanceName);
20966
+ await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
20369
20967
  return `${warn}${t("effort.restart_success", instanceName, applied)}`;
20370
20968
  }
20371
- 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));
20372
20974
  // Reject model names with newlines or control characters: they would be
20373
20975
  // persisted to fleet.yaml and pasted raw into the CLI (P3 from #1490 audit).
20374
20976
  if (/[\x00-\x1f\x7f]/.test(model)) {
@@ -20394,6 +20996,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20394
20996
  // #1690: claude-code's runtime switch reports what it came to; the model is saved unless claude was told No.
20395
20997
  if (strategy === "runtime" && backendName === "claude-code") {
20396
20998
  const result = await this.runRuntimeModelSwitch(instanceName, model);
20999
+ if (opts?.current && !opts.current())
21000
+ return t("menu.instance_replaced", this.shownInstanceName(instanceName));
20397
21001
  if (result.outcome === "mismatch")
20398
21002
  return `${warn}${t("model.switch_mismatch", instanceName, result.shown, model)}`;
20399
21003
  if (result.outcome === "not_sent")
@@ -20414,7 +21018,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20414
21018
  this.pasteRawToClassicInstance(instanceName, `/model ${model}`);
20415
21019
  return `${warn}${t("model.runtime_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
20416
21020
  }
20417
- await this.restartSingleInstance(instanceName);
21021
+ await this.restartSingleInstance(instanceName, { ownerCurrent: opts?.current });
20418
21022
  return `${warn}${t("model.restart_success", instanceName, model)}${this.effortSuffix(instanceName)}`;
20419
21023
  }
20420
21024
  /** Save an instance's model (fleet.yaml, or its ClassicBot channel). False when neither holds it. */
@@ -21293,6 +21897,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21293
21897
  // because we asked it to. Set synchronously — doStopAll runs to its first
21294
21898
  // await in the same tick as the signal handler, so no event can slip in.
21295
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
+ }));
21296
21916
  this.workingRecovery?.reset();
21297
21917
  if (this.workingRecoveryTimer)
21298
21918
  clearInterval(this.workingRecoveryTimer);
@@ -21347,9 +21967,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21347
21967
  }
21348
21968
  this.eventLoopWatch?.stop();
21349
21969
  this.eventLoopWatch = null;
21970
+ this.stallReporter?.stop();
21971
+ this.stallReporter = null;
21350
21972
  // A login/install window is a dedicated tmux server with its own TTL
21351
21973
  // timer and HTTP listener living in THIS process: without an explicit
21352
21974
  // shutdown it would outlive us as an owner-less login CLI (sol B3).
21975
+ await inboundStopped;
21353
21976
  await publicStopped;
21354
21977
  await sharesStopped?.catch(() => { });
21355
21978
  await profileStopped;
@@ -21360,13 +21983,6 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21360
21983
  await this.cpuProfileControl?.close();
21361
21984
  this.cpuProfileControl = null;
21362
21985
  await this.shutdownLoginWindows();
21363
- // Cancel adapter retry timers
21364
- for (const state of this.adapterState.values()) {
21365
- if (state.retryTimer) {
21366
- clearTimeout(state.retryTimer);
21367
- state.retryTimer = undefined;
21368
- }
21369
- }
21370
21986
  this.clearStatuslineWatchers();
21371
21987
  this.costGuard?.stop();
21372
21988
  this.dailySummary?.stop();
@@ -21404,6 +22020,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21404
22020
  clearInterval(this.discordPresenceTimer);
21405
22021
  this.discordPresenceTimer = null;
21406
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);
21407
22032
  if (this.discordPresenceEagerTimer) {
21408
22033
  clearTimeout(this.discordPresenceEagerTimer);
21409
22034
  this.discordPresenceEagerTimer = null;
@@ -21708,6 +22333,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21708
22333
  // mark every row done and tell the user "changes applied".
21709
22334
  return { rejected: why };
21710
22335
  }
22336
+ this.agentCredentialsCache?.observePolicies();
21711
22337
  if (this.loadedFleetSource)
21712
22338
  this.getConfigHistory().accepted("fleet.yaml", this.configPath, this.loadedFleetSource);
21713
22339
  this.migrateOfficeStorage();
@@ -22608,6 +23234,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22608
23234
  }
22609
23235
  async runConnectionBindingApply(job, binding, execution) {
22610
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");
22611
23240
  await this.rebuildAdapterForBinding(job.connectionId, binding, execution);
22612
23241
  job.result = "applied";
22613
23242
  }