@songsid/agend 2.3.1 → 2.3.2-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 (338) hide show
  1. package/THIRD_PARTY_NOTICES.md +40 -0
  2. package/dist/agent-cli.js +21 -2
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-endpoint.js +5 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/antigravity-usage-scan.d.ts +58 -0
  7. package/dist/antigravity-usage-scan.js +257 -0
  8. package/dist/antigravity-usage-scan.js.map +1 -0
  9. package/dist/antigravity-usage-worker.d.ts +1 -0
  10. package/dist/antigravity-usage-worker.js +10 -0
  11. package/dist/antigravity-usage-worker.js.map +1 -0
  12. package/dist/auth-api.js +4 -0
  13. package/dist/auth-api.js.map +1 -1
  14. package/dist/authority/authority-state.d.ts +4 -0
  15. package/dist/authority/authority-state.js +16 -10
  16. package/dist/authority/authority-state.js.map +1 -1
  17. package/dist/authority/session-authorizations.d.ts +7 -1
  18. package/dist/authority/session-authorizations.js +6 -4
  19. package/dist/authority/session-authorizations.js.map +1 -1
  20. package/dist/authority/web-authority.d.ts +19 -6
  21. package/dist/authority/web-authority.js +22 -8
  22. package/dist/authority/web-authority.js.map +1 -1
  23. package/dist/backend/kiro-identity.d.ts +64 -0
  24. package/dist/backend/kiro-identity.js +101 -0
  25. package/dist/backend/kiro-identity.js.map +1 -1
  26. package/dist/backend/kiro-retired-model.d.ts +62 -0
  27. package/dist/backend/kiro-retired-model.js +160 -0
  28. package/dist/backend/kiro-retired-model.js.map +1 -0
  29. package/dist/backend/kiro.d.ts +71 -2
  30. package/dist/backend/kiro.js +352 -11
  31. package/dist/backend/kiro.js.map +1 -1
  32. package/dist/backend/types.d.ts +59 -0
  33. package/dist/backend/types.js.map +1 -1
  34. package/dist/backend-signin-guide.d.ts +1 -0
  35. package/dist/backend-signin-guide.js +9 -0
  36. package/dist/backend-signin-guide.js.map +1 -0
  37. package/dist/backend-signin-pending.d.ts +10 -0
  38. package/dist/backend-signin-pending.js +44 -0
  39. package/dist/backend-signin-pending.js.map +1 -0
  40. package/dist/browser-backend-signins.d.ts +56 -0
  41. package/dist/browser-backend-signins.js +189 -0
  42. package/dist/browser-backend-signins.js.map +1 -0
  43. package/dist/cache-ledger.d.ts +38 -2
  44. package/dist/cache-ledger.js +133 -8
  45. package/dist/cache-ledger.js.map +1 -1
  46. package/dist/cache-prices.d.ts +2 -1
  47. package/dist/cache-prices.js +5 -2
  48. package/dist/cache-prices.js.map +1 -1
  49. package/dist/cache-service.d.ts +68 -0
  50. package/dist/cache-service.js +355 -13
  51. package/dist/cache-service.js.map +1 -1
  52. package/dist/channel/adapters/discord.d.ts +13 -0
  53. package/dist/channel/adapters/discord.js +103 -16
  54. package/dist/channel/adapters/discord.js.map +1 -1
  55. package/dist/channel/mcp-tools.js +34 -7
  56. package/dist/channel/mcp-tools.js.map +1 -1
  57. package/dist/channel/tool-router.js +2 -1
  58. package/dist/channel/tool-router.js.map +1 -1
  59. package/dist/channel/types.d.ts +11 -0
  60. package/dist/classic-channel-manager.d.ts +11 -0
  61. package/dist/classic-channel-manager.js +39 -1
  62. package/dist/classic-channel-manager.js.map +1 -1
  63. package/dist/cli.js +21 -2
  64. package/dist/cli.js.map +1 -1
  65. package/dist/config-validator.d.ts +7 -1
  66. package/dist/config-validator.js +92 -12
  67. package/dist/config-validator.js.map +1 -1
  68. package/dist/config.d.ts +19 -0
  69. package/dist/config.js +31 -1
  70. package/dist/config.js.map +1 -1
  71. package/dist/cpu-quota.d.ts +23 -0
  72. package/dist/cpu-quota.js +86 -0
  73. package/dist/cpu-quota.js.map +1 -0
  74. package/dist/daemon.d.ts +57 -3
  75. package/dist/daemon.js +330 -50
  76. package/dist/daemon.js.map +1 -1
  77. package/dist/dashboard-permissions.d.ts +20 -6
  78. package/dist/dashboard-permissions.js +58 -9
  79. package/dist/dashboard-permissions.js.map +1 -1
  80. package/dist/delivery-outbox.d.ts +12 -1
  81. package/dist/delivery-outbox.js +67 -15
  82. package/dist/delivery-outbox.js.map +1 -1
  83. package/dist/doctor.d.ts +3 -0
  84. package/dist/doctor.js +11 -1
  85. package/dist/doctor.js.map +1 -1
  86. package/dist/event-log.d.ts +5 -0
  87. package/dist/event-log.js +13 -0
  88. package/dist/event-log.js.map +1 -1
  89. package/dist/fleet-manager.d.ts +212 -0
  90. package/dist/fleet-manager.js +914 -111
  91. package/dist/fleet-manager.js.map +1 -1
  92. package/dist/fleet-process-handlers.d.ts +1 -0
  93. package/dist/fleet-process-handlers.js +9 -1
  94. package/dist/fleet-process-handlers.js.map +1 -1
  95. package/dist/forged-envelope-scan.d.ts +9 -0
  96. package/dist/forged-envelope-scan.js +30 -5
  97. package/dist/forged-envelope-scan.js.map +1 -1
  98. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +5 -2
  99. package/dist/general-knowledge/skills/kiro-engine-migration/SKILL.md +14 -1
  100. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +28 -5
  101. package/dist/general-knowledge/skills/web-appearance/SKILL.md +31 -8
  102. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +138 -59
  103. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +145 -61
  104. package/dist/guest-seen-people.d.ts +38 -0
  105. package/dist/guest-seen-people.js +78 -0
  106. package/dist/guest-seen-people.js.map +1 -0
  107. package/dist/instance-config-impact.js +14 -6
  108. package/dist/instance-config-impact.js.map +1 -1
  109. package/dist/instance-lifecycle.d.ts +20 -0
  110. package/dist/instance-lifecycle.js +71 -13
  111. package/dist/instance-lifecycle.js.map +1 -1
  112. package/dist/kiro-db-reader.d.ts +5 -0
  113. package/dist/kiro-db-reader.js +18 -0
  114. package/dist/kiro-db-reader.js.map +1 -1
  115. package/dist/kiro-engine-status.d.ts +2 -0
  116. package/dist/kiro-engine-status.js +9 -0
  117. package/dist/kiro-engine-status.js.map +1 -1
  118. package/dist/kiro-migrate.d.ts +49 -0
  119. package/dist/kiro-migrate.js +105 -0
  120. package/dist/kiro-migrate.js.map +1 -0
  121. package/dist/kiro-transcript-lane.d.ts +2 -0
  122. package/dist/kiro-transcript-lane.js.map +1 -1
  123. package/dist/kiro-transcript-worker.js +1 -0
  124. package/dist/kiro-transcript-worker.js.map +1 -1
  125. package/dist/kiro-tui-forensic.d.ts +7 -0
  126. package/dist/kiro-tui-forensic.js +110 -0
  127. package/dist/kiro-tui-forensic.js.map +1 -0
  128. package/dist/kiro-usage-run.d.ts +7 -0
  129. package/dist/kiro-usage-run.js +39 -0
  130. package/dist/kiro-usage-run.js.map +1 -0
  131. package/dist/kiro-usage-scan.d.ts +49 -0
  132. package/dist/kiro-usage-scan.js +223 -0
  133. package/dist/kiro-usage-scan.js.map +1 -0
  134. package/dist/kiro-usage-worker.d.ts +1 -0
  135. package/dist/kiro-usage-worker.js +10 -0
  136. package/dist/kiro-usage-worker.js.map +1 -0
  137. package/dist/kiro-usage.d.ts +58 -0
  138. package/dist/kiro-usage.js +310 -0
  139. package/dist/kiro-usage.js.map +1 -0
  140. package/dist/locale.js +120 -12
  141. package/dist/locale.js.map +1 -1
  142. package/dist/log-service.js +1 -1
  143. package/dist/log-service.js.map +1 -1
  144. package/dist/login-url.d.ts +4 -0
  145. package/dist/login-url.js +22 -0
  146. package/dist/login-url.js.map +1 -0
  147. package/dist/mac-auto-restore.d.ts +117 -0
  148. package/dist/mac-auto-restore.js +294 -0
  149. package/dist/mac-auto-restore.js.map +1 -0
  150. package/dist/needs-you-hub.d.ts +2 -1
  151. package/dist/needs-you-hub.js +1 -1
  152. package/dist/needs-you-hub.js.map +1 -1
  153. package/dist/needs-you.d.ts +8 -2
  154. package/dist/needs-you.js +6 -0
  155. package/dist/needs-you.js.map +1 -1
  156. package/dist/notice-delivery.d.ts +16 -0
  157. package/dist/notice-delivery.js +35 -0
  158. package/dist/notice-delivery.js.map +1 -0
  159. package/dist/office-share-page.d.ts +16 -0
  160. package/dist/office-share-page.js +85 -0
  161. package/dist/office-share-page.js.map +1 -0
  162. package/dist/office-share-service.d.ts +91 -0
  163. package/dist/office-share-service.js +176 -0
  164. package/dist/office-share-service.js.map +1 -0
  165. package/dist/office-share.d.ts +258 -0
  166. package/dist/office-share.js +549 -0
  167. package/dist/office-share.js.map +1 -0
  168. package/dist/opencode-usage-scan.d.ts +28 -0
  169. package/dist/opencode-usage-scan.js +92 -0
  170. package/dist/opencode-usage-scan.js.map +1 -0
  171. package/dist/opencode-usage-worker.d.ts +1 -0
  172. package/dist/opencode-usage-worker.js +10 -0
  173. package/dist/opencode-usage-worker.js.map +1 -0
  174. package/dist/outbound-handlers.js +22 -2
  175. package/dist/outbound-handlers.js.map +1 -1
  176. package/dist/outbound-schemas.d.ts +28 -2
  177. package/dist/outbound-schemas.js +29 -2
  178. package/dist/outbound-schemas.js.map +1 -1
  179. package/dist/public-web-gateway.js +16 -2
  180. package/dist/public-web-gateway.js.map +1 -1
  181. package/dist/quickstart-api.js +13 -2
  182. package/dist/quickstart-api.js.map +1 -1
  183. package/dist/quickstart.js +2 -1
  184. package/dist/quickstart.js.map +1 -1
  185. package/dist/remote-card-answer.d.ts +83 -0
  186. package/dist/remote-card-answer.js +208 -0
  187. package/dist/remote-card-answer.js.map +1 -0
  188. package/dist/reply-triggers.d.ts +2 -0
  189. package/dist/reply-triggers.js +3 -0
  190. package/dist/reply-triggers.js.map +1 -0
  191. package/dist/settings-api.d.ts +28 -0
  192. package/dist/settings-api.js +148 -17
  193. package/dist/settings-api.js.map +1 -1
  194. package/dist/settings-change.js +54 -17
  195. package/dist/settings-change.js.map +1 -1
  196. package/dist/settings-effect.js +12 -3
  197. package/dist/settings-effect.js.map +1 -1
  198. package/dist/settings-instance-patch.js +4 -1
  199. package/dist/settings-instance-patch.js.map +1 -1
  200. package/dist/setup-form.d.ts +1 -1
  201. package/dist/setup-form.js +55 -6
  202. package/dist/setup-form.js.map +1 -1
  203. package/dist/setup-host.d.ts +9 -0
  204. package/dist/setup-host.js +127 -6
  205. package/dist/setup-host.js.map +1 -1
  206. package/dist/setup-signin-assets.d.ts +2 -0
  207. package/dist/setup-signin-assets.js +6 -0
  208. package/dist/setup-signin-assets.js.map +1 -0
  209. package/dist/setup-wizard.js +2 -0
  210. package/dist/setup-wizard.js.map +1 -1
  211. package/dist/start-attempt.d.ts +9 -5
  212. package/dist/start-attempt.js +27 -8
  213. package/dist/start-attempt.js.map +1 -1
  214. package/dist/start-failure-chat.d.ts +4 -0
  215. package/dist/start-failure-chat.js +23 -6
  216. package/dist/start-failure-chat.js.map +1 -1
  217. package/dist/start-failure-notice.d.ts +4 -0
  218. package/dist/start-failure-notice.js +8 -0
  219. package/dist/start-failure-notice.js.map +1 -1
  220. package/dist/start-failure.d.ts +25 -0
  221. package/dist/start-failure.js +75 -2
  222. package/dist/start-failure.js.map +1 -1
  223. package/dist/structured-signin-transport.d.ts +8 -0
  224. package/dist/structured-signin-transport.js +79 -0
  225. package/dist/structured-signin-transport.js.map +1 -0
  226. package/dist/structured-signin.d.ts +131 -0
  227. package/dist/structured-signin.js +388 -0
  228. package/dist/structured-signin.js.map +1 -0
  229. package/dist/tool-permissions.js +12 -0
  230. package/dist/tool-permissions.js.map +1 -1
  231. package/dist/topic-commands.js +6 -2
  232. package/dist/topic-commands.js.map +1 -1
  233. package/dist/transcript-sources.d.ts +26 -2
  234. package/dist/transcript-sources.js +103 -40
  235. package/dist/transcript-sources.js.map +1 -1
  236. package/dist/turn-reply-guard.d.ts +25 -0
  237. package/dist/turn-reply-guard.js +111 -12
  238. package/dist/turn-reply-guard.js.map +1 -1
  239. package/dist/types.d.ts +46 -9
  240. package/dist/ui/backend-signin.js +68 -12
  241. package/dist/ui/chat-ambient.js +1 -1
  242. package/dist/ui/chat-effects.js +4 -4
  243. package/dist/ui/chat-render.js +5 -2
  244. package/dist/ui/chat-strings.js +4 -4
  245. package/dist/ui/chat-thread.js +2 -2
  246. package/dist/ui/first-run.js +2 -2
  247. package/dist/ui/fleet-canvas-view.js +114 -0
  248. package/dist/ui/fleet-canvas.js +15 -77
  249. package/dist/ui/fleet-office-editor.js +94 -14
  250. package/dist/ui/fleet-office-layout.js +235 -0
  251. package/dist/ui/fleet-office-model.js +12 -6
  252. package/dist/ui/fleet-office-overview.js +167 -0
  253. package/dist/ui/fleet-office-pack.d.ts +44 -0
  254. package/dist/ui/fleet-office-pack.js +352 -37
  255. package/dist/ui/fleet-office-skin.js +220 -36
  256. package/dist/ui/fleet-office-store.js +112 -7
  257. package/dist/ui/fleet-office-view.js +155 -0
  258. package/dist/ui/fleet-office-zones.js +264 -42
  259. package/dist/ui/fleet-office.js +957 -160
  260. package/dist/ui/fleet-usage.js +26 -21
  261. package/dist/ui/office-share-dialog.js +145 -0
  262. package/dist/ui/office-share-page.js +164 -0
  263. package/dist/ui/panel-chat.js +9 -2
  264. package/dist/ui/panel-details.js +2 -2
  265. package/dist/ui/panel-fleet.js +11 -77
  266. package/dist/ui/panel-log.js +4 -4
  267. package/dist/ui/panel-needs.js +4 -4
  268. package/dist/ui/panel-settings.js +608 -167
  269. package/dist/ui/search-dialog.js +26 -7
  270. package/dist/ui/settings-appearance.js +82 -29
  271. package/dist/ui/settings-apply.js +42 -7
  272. package/dist/ui/settings-dialogs.js +76 -23
  273. package/dist/ui/settings-draft.js +51 -0
  274. package/dist/ui/settings-guests.js +255 -79
  275. package/dist/ui/settings-learn.js +2 -2
  276. package/dist/ui/settings-model.js +16 -6
  277. package/dist/ui/settings-registry.js +146 -0
  278. package/dist/ui/settings-strings.js +116 -44
  279. package/dist/ui/settings-web-authority.js +34 -23
  280. package/dist/ui/shared/app-i18n.js +18 -2
  281. package/dist/ui/shared/app-nav.js +10 -5
  282. package/dist/ui/shared/app-prefs.js +80 -2
  283. package/dist/ui/shared/app-route.js +39 -5
  284. package/dist/ui/shared/app-shell.js +73 -10
  285. package/dist/ui/shared/app-stream.js +3 -1
  286. package/dist/ui/shared/app.css +307 -17
  287. package/dist/ui/shared/app.js +3 -0
  288. package/dist/ui/shared/effect-stock-trust.js +1 -1
  289. package/dist/ui/shared/identity-color.js +4 -4
  290. package/dist/ui/shared/office/cozy.png +0 -0
  291. package/dist/ui/shared/side-panel.js +95 -0
  292. package/dist/ui/shared/signin.js +2 -2
  293. package/dist/ui/shared/ui-dialog.js +6 -1
  294. package/dist/ui/shared/ui-icons.js +2 -0
  295. package/dist/ui/shared/web-authorize.js +13 -12
  296. package/dist/ui/structured-signin.js +115 -0
  297. package/dist/usage-report.d.ts +20 -0
  298. package/dist/usage-report.js +54 -17
  299. package/dist/usage-report.js.map +1 -1
  300. package/dist/usage-rollup.d.ts +9 -1
  301. package/dist/usage-rollup.js +20 -4
  302. package/dist/usage-rollup.js.map +1 -1
  303. package/dist/web-api.d.ts +26 -2
  304. package/dist/web-api.js +361 -5
  305. package/dist/web-api.js.map +1 -1
  306. package/dist/web-auth.js +8 -2
  307. package/dist/web-auth.js.map +1 -1
  308. package/dist/web-backend-login.d.ts +1 -7
  309. package/dist/web-backend-login.js +2 -24
  310. package/dist/web-backend-login.js.map +1 -1
  311. package/dist/web-experimental.d.ts +1 -1
  312. package/dist/web-experimental.js +1 -1
  313. package/dist/web-experimental.js.map +1 -1
  314. package/dist/web-mod-agent.js +2 -2
  315. package/dist/web-mod-agent.js.map +1 -1
  316. package/dist/web-office-acts.d.ts +173 -0
  317. package/dist/web-office-acts.js +285 -0
  318. package/dist/web-office-acts.js.map +1 -0
  319. package/dist/web-office-skins.d.ts +109 -0
  320. package/dist/web-office-skins.js +299 -0
  321. package/dist/web-office-skins.js.map +1 -0
  322. package/dist/web-office-store.d.ts +8 -2
  323. package/dist/web-office-store.js +77 -14
  324. package/dist/web-office-store.js.map +1 -1
  325. package/dist/web-settings-patch.d.ts +8 -1
  326. package/dist/web-settings-patch.js +65 -5
  327. package/dist/web-settings-patch.js.map +1 -1
  328. package/dist/web-shell-routes.d.ts +4 -2
  329. package/dist/web-shell-routes.js +17 -7
  330. package/dist/web-shell-routes.js.map +1 -1
  331. package/dist/working-recovery.d.ts +104 -0
  332. package/dist/working-recovery.js +298 -0
  333. package/dist/working-recovery.js.map +1 -0
  334. package/dist/working-state-journal.d.ts +5 -1
  335. package/dist/working-state-journal.js +31 -3
  336. package/dist/working-state-journal.js.map +1 -1
  337. package/package.json +3 -2
  338. package/templates/windows-shortcuts.ps1 +155 -140
@@ -1,3 +1,7 @@
1
+ import { hasLegacyOffice, normalizeOfficeStorage } from "./dashboard-permissions.js";
2
+ import { loginNoticeMode, normalizeNoticeDelivery, setNoticeMode } from "./notice-delivery.js";
3
+ import { DEFAULT_REPLY_TRIGGERS } from "./reply-triggers.js";
4
+ import { WorkingRecoveryCoordinator, WORKING_RECOVERY_KIND, buildWorkingRecoveryPrompt } from "./working-recovery.js";
1
5
  import { PUBLIC_TUNNEL_TOKEN_ENV, PUBLIC_TUNNEL_TOKEN_HINT, normalizePublicTunnelToken } from "./public-tunnel-config.js";
2
6
  import { humanOwnerObjectId, webHumanOrigin } from "./human-delivery-receipts.js";
3
7
  import { SettingsBaselines } from "./settings-baseline.js";
@@ -18,6 +22,9 @@ import { PublicLinkAutoOpen } from "./public-link-auto-open.js";
18
22
  import { clearPublicLinkOwner, readPublicLinkOwner, writePublicLinkOwner } from "./public-link-owner.js";
19
23
  import { PUBLIC_PREVIEW_FRAME_SRC, PUBLIC_PREVIEW_TUNNEL_DIR, PublicPreview, publicPreviewWanted } from "./public-preview.js";
20
24
  import { CloudflaredProvider } from "./tunnel/cloudflared.js";
25
+ import { ensureCloudflared } from "./tunnel/cloudflared-install.js";
26
+ import { OFFICE_SHARE_TUNNEL_DIR, OfficeShareService } from "./office-share-service.js";
27
+ import { ALL_OFFICES } from "./office-share.js";
21
28
  import { TunnelPurposeLane } from "./tunnel/purpose-lane.js";
22
29
  import { ManagedTunnel } from "./tunnel/manager.js";
23
30
  import { withinBudget } from "./monotonic-budget.js";
@@ -27,13 +34,16 @@ import { ProfileControlServer } from "./profile-control.js";
27
34
  import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, lstatSync, constants as fsConstants, closeSync } from "node:fs";
28
35
  import { atomicWriteFileSync } from "./atomic-write.js";
29
36
  import { createHash, randomBytes, randomUUID } from "node:crypto";
30
- import { freemem, totalmem, cpus, homedir } from "node:os";
37
+ import { freemem, totalmem, homedir } from "node:os";
38
+ import { effectiveCpuCount } from "./cpu-quota.js";
31
39
  import { createServer } from "node:http";
32
40
  import { Worker } from "node:worker_threads";
33
41
  import { ProbeWorkerPool } from "./probe-worker-pool.js";
34
42
  import { join, dirname, basename, delimiter, isAbsolute, relative, resolve as resolvePath } from "node:path";
35
43
  import { fileURLToPath } from "node:url";
44
+ import { isDeepStrictEqual } from "node:util";
36
45
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
46
+ import { applyKiroMigration, kiroMigrationPrompt, planKiroMigration } from "./kiro-migrate.js";
37
47
  import { fleetLabel } from "./fleet-label.js";
38
48
  import { beginFullRestartProgress as persistFullRestartProgress, beginUpdateProgress as persistUpdateProgress, clearUpdateMarker, isUpdateInProgress, readUpdateProgress, sameUpdateMarker, setUpdateProgressStage, updateMarkerIdentity, updateProgressOperation, updateReceiptOf, } from "./update-marker.js";
39
49
  import { formatUpdateProgress } from "./update-progress.js";
@@ -52,7 +62,7 @@ const __dirname = dirname(__filename);
52
62
  const DEFAULT_OPEN_ACCESS = { mode: "open", allowed_users: [], max_pending_codes: 0, code_expiry_minutes: 0 };
53
63
  import { STATUS_EMOJI_CONFIG_KEYS, STATUS_EMOJI_KEYS, STATUS_EMOJI_SUGGESTIONS, TELEGRAM_REACTION_EMOJIS, builtinStatusEmojis, customEmojiValue, emojiImageUrl, normalizeEmoji, previewStatusEmojis, reactionForm, reactionMatchKey, resolveStatusEmojis, statusAvoidList, statusEmojiProblem, statusMatchKey, statusMatchKeys, textForm, } from "./status-emojis.js";
54
64
  import { isProbeableRouteTarget } from "./fleet-context.js";
55
- import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
65
+ import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG, autoGeneralInstanceConfig } from "./config.js";
56
66
  import { EventLog } from "./event-log.js";
57
67
  import { binaryProbe } from "./binary-probe.js";
58
68
  import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes } from "./backend-availability.js";
@@ -158,6 +168,8 @@ import { agendVersion } from "./agent-whats-new.js";
158
168
  import { OwnerCssStore } from "./web-owner-css-store.js";
159
169
  import { ThemeStore } from "./web-theme-store.js";
160
170
  import { OfficeStore, OfficeWriteLimiter } from "./web-office-store.js";
171
+ import { OfficeSkinStore } from "./web-office-skins.js";
172
+ import { OfficeActs } from "./web-office-acts.js";
161
173
  import { WebAppearance } from "./web-appearance.js";
162
174
  import { WebEffectLibrary } from "./web-effect-library.js";
163
175
  import { WebEffectTrials } from "./web-effect-trials.js";
@@ -187,7 +199,7 @@ import { discordGeneralChannelId } from "./discord-general.js";
187
199
  import { parseEnvText } from "./env-file.js";
188
200
  import { isDisallowedIntentsError, isRejectedTokenError } from "./discord-permissions.js";
189
201
  import { filterUsageProviders, formatDiscordUsageActivity, getUsageSnapshot, handleUsageRequest, isUsagePath, usageProviderIdForBackend } from "./usage/usage-api.js";
190
- import { LOGIN_FLOWS, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
202
+ import { LOGIN_FLOWS, checkAuthStatus, LOGIN_BACKEND_ALIASES } from "./login-flows.js";
191
203
  import { LoginSession } from "./login-manager.js";
192
204
  import { runOnLoginShell } from "./login-shell.js";
193
205
  import { tightenInstanceDirs } from "./private-dir.js";
@@ -207,12 +219,16 @@ import { admitProfileLogin, LoginController, LOGIN_TOKEN_RESEND_PREFIX, POST_LOG
207
219
  import { WebBackendLogins, dashboardSignInSupported } from "./web-backend-login.js";
208
220
  import { runBeforeDeadline } from "./deadline.js";
209
221
  import { LoginWindowLock } from "./login-window-lock.js";
222
+ import { BrowserBackendSignIns } from "./browser-backend-signins.js";
223
+ import { createStructuredSignInTransport } from "./structured-signin-transport.js";
224
+ import { ONBOARDING_BACKENDS, readPendingBackendSignIns } from "./backend-signin-pending.js";
225
+ import { structuredSignInSupported } from "./structured-signin.js";
210
226
  import { handleSettingsRequest } from "./settings-api.js";
211
227
  import { setLocale, detectLocale, getLocale, t } from "./locale.js";
212
228
  import { recentChatContext } from "./log-tail.js";
213
229
  import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
214
230
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
215
- import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
231
+ import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt, readClassicRoomNames } from "./classic-channel-manager.js";
216
232
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
217
233
  import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
218
234
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
@@ -270,6 +286,7 @@ import { PROVIDER_SECRET_SPECS, PUBLIC_TUNNEL_SECRET_SPEC, providerSecretSpec, p
270
286
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
271
287
  const SELF_RESTART_DEADLINE_MS = 300_000;
272
288
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
289
+ import { kiroHome } from "./backend/kiro-kas-store.js";
273
290
  import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileLoginCommand, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
274
291
  import { carryClaudeConversation } from "./backend/claude-carry.js";
275
292
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
@@ -467,6 +484,10 @@ const OOM_RETRY_CALLBACK_PREFIX = "oom-retry:";
467
484
  const NEEDS_PAUSE_TTL_MS = 10_000;
468
485
  const HANG_CALLBACK_PREFIX = "hang:";
469
486
  const CLEAR_CONFIRM_CALLBACK_PREFIX = "clear-confirm:";
487
+ /** #2598 B2: a kiro classic → TUI move's Confirm in General. "kiro-migrate:" (13) + 32 hex + ":confirm" (8) = 53 bytes. */
488
+ const KIRO_MIGRATE_CALLBACK_PREFIX = "kiro-migrate:";
489
+ /** Long enough for an admin to read the prompt; the move is judged again on the click. */
490
+ const KIRO_MIGRATE_TIMEOUT_MS = 10 * 60_000;
470
491
  /**
471
492
  * The prompts the web dashboard also offers: the ones about an instance's own health, which a dashboard
472
493
  * user — holding the full-fleet web credential — may answer exactly as a fleet admin on the platform may.
@@ -562,6 +583,7 @@ export const CLI_ENV_PROBE_DEADLINE_MS = 16_000;
562
583
  * started ten CLIs together, saturated the CPU, and healthy starts then missed
563
584
  * their startup budget — which used to cost the user their conversation. Cores
564
585
  * bound how many can actually make progress; memory bounds how many fit.
586
+ * `cores` is what this process may use (cpuset and cgroup quota, #749), not the host's count.
565
587
  */
566
588
  export function deriveSpawnConcurrency(freeMemMB, cores) {
567
589
  const byMemory = Math.floor(freeMemMB / 300);
@@ -605,6 +627,99 @@ export class FleetManager {
605
627
  managerBootId = randomUUID();
606
628
  deliveryOutbox = null;
607
629
  deliveryOutboxRecovered = false;
630
+ workingRecovery = null;
631
+ workingRecoveryTimer = null;
632
+ workingRecoveryCause = "unknown_interruption";
633
+ workingRecoveryGroup = this.managerBootId;
634
+ recoveryEnabled(name) {
635
+ const own = this.fleetConfig?.instances[name]?.resume_interrupted_work;
636
+ return own ?? this.fleetConfig?.defaults?.resume_interrupted_work ?? true;
637
+ }
638
+ initializeWorkingRecovery(outbox) {
639
+ const store = outbox.workingRecovery;
640
+ this.workingRecoveryCause = store.beginRun(this.managerBootId, process.pid, Date.now());
641
+ this.workingRecovery = new WorkingRecoveryCoordinator({
642
+ store, now: () => performance.now(), active: () => this.startupComplete && !this.shuttingDown && outbox.isOpen,
643
+ pressure: () => {
644
+ const p = this.memoryPressure.snapshot();
645
+ return p.sampledAt === null || Date.now() - p.sampledAt > 60_000 ? { ...p, level: "unknown" } : p;
646
+ }, storm: () => this.stormWindow.isActive(),
647
+ enabled: name => this.recoveryEnabled(name),
648
+ live: name => {
649
+ const daemon = this.daemons.get(name);
650
+ if (this.sessionOomStopped(name))
651
+ return { owner: "", configuration: "", sessionId: null, idle: false,
652
+ paused: false, stopped: true, legacyPending: false, legacyCovered: false, nativeWork: false, cancelled: false, background: null };
653
+ return daemon?.getWorkingRecoveryState?.() ?? null;
654
+ },
655
+ card: id => this.scheduler?.db.board.findCard(id) ?? (this.scheduler ? undefined : null),
656
+ laneFree: name => !this.isInstanceRestarting(name) && !this.activeDurableTargets.has(name)
657
+ && outbox.countForTarget(name, ["queued", "retry_wait", "delivering", "submission_started", "reconciliation_pending"]) === 0,
658
+ admit: (episode, tasks, advisory) => outbox.admitWorkingRecovery(episode, buildWorkingRecoveryPrompt(tasks, this.workingRecoveryCause, advisory), advisory),
659
+ summary: (group, rows) => {
660
+ const general = this.findGeneralInstance();
661
+ if (!general || !this.daemons.has(general))
662
+ return;
663
+ // Our own informational notice must not supersede General's recovery
664
+ // as unrelated pending work before it crosses the input boundary.
665
+ const generalEpisode = store.get(general);
666
+ if (generalEpisode?.outcome === "admitted" && generalEpisode.deliveryId) {
667
+ const state = outbox.get(generalEpisode.deliveryId)?.state;
668
+ if (!state || !["delivered", "uncertain", "failed", "cancelled"].includes(state))
669
+ return;
670
+ }
671
+ const deferredAdvisories = rows.filter(row => row.outcome === "deferred"
672
+ && (this.daemons.get(row.instance)?.getWorkingRecoveryState?.().background ?? null) === null)
673
+ .map(row => `${row.instance} (${row.reason})`);
674
+ const posted = store.summary(group, () => outbox.admitSystemNotice(general, `working-recovery-summary:${group}`, `[system:working-recovery-summary] Observed cause: unknown interruption${this.workingRecoveryCause === "unknown_interruption" ? "" : `; prior requested action: ${this.workingRecoveryCause}`}. ${rows.length} interrupted instances; continuation queued ${rows.filter(r => r.outcome === "admitted").length}, excluded ${rows.filter(r => r.outcome === "excluded").length}, still deferred ${rows.filter(r => r.outcome === "deferred").length}. ${rows.filter(r => r.reason === "advisory_background_unknown").length ? `Advisory, background unknown: ${rows.filter(r => r.reason === "advisory_background_unknown").map(r => r.instance).join(", ")}. ` : ""}${deferredAdvisories.length ? `Deferred advisory candidates (background unknown; all other gates still apply): ${deferredAdvisories.join(", ")}. ` : ""}Details are in the Log. Deferred work remains eligible; no action or confirmation is requested.`));
675
+ if (posted) {
676
+ const counts = { interrupted: rows.length, queued: rows.filter(row => row.outcome === "admitted").length,
677
+ excluded: rows.filter(row => row.outcome === "excluded").length, deferred: rows.filter(row => row.outcome === "deferred").length };
678
+ const requestedAction = this.workingRecoveryCause === "unknown_interruption" ? null : this.workingRecoveryCause;
679
+ this.logger.info({ group, ...counts, observedCause: "unknown_interruption", requestedAction }, "Interrupted work recovery summary queued for General");
680
+ this.eventLog?.insert(general, "working_state", { reason: "recovery_summary", group, ...counts,
681
+ observedCause: "unknown_interruption", requestedAction,
682
+ message: `Interrupted work summary queued for General: ${counts.interrupted} interrupted; ${counts.queued} queued, ${counts.excluded} excluded, ${counts.deferred} deferred.` });
683
+ }
684
+ },
685
+ log: (episode, outcome, reason) => {
686
+ const requestedAction = this.workingRecoveryCause === "unknown_interruption" ? null : this.workingRecoveryCause;
687
+ this.logger.info({ name: episode.instance, episode: episode.key, outcome, reason,
688
+ observedCause: "unknown_interruption", requestedAction }, "Interrupted work recovery decision");
689
+ this.eventLog?.insert(episode.instance, "working_state", {
690
+ message: `Interrupted work: ${outcome} (${reason}); observed cause unknown_interruption${requestedAction ? `; prior requested action ${requestedAction}` : ""}.`,
691
+ reason, outcome, observedCause: "unknown_interruption", requestedAction
692
+ });
693
+ },
694
+ });
695
+ if (this.workingRecoveryTimer)
696
+ clearInterval(this.workingRecoveryTimer);
697
+ this.workingRecoveryTimer = setInterval(() => {
698
+ if (!outbox.isOpen || this.shuttingDown)
699
+ return;
700
+ try {
701
+ this.workingRecovery?.tick();
702
+ this.scheduleDeliveryOutboxPump();
703
+ }
704
+ catch {
705
+ this.logger.warn("Working recovery deferred: prerequisites or durable store unavailable");
706
+ }
707
+ }, 1_000);
708
+ this.workingRecoveryTimer.unref?.();
709
+ }
710
+ recoveryDeliveryEligible(row, writing = false) {
711
+ if (row.kind !== WORKING_RECOVERY_KIND)
712
+ return true;
713
+ const outbox = this.deliveryOutbox, episode = outbox?.workingRecovery.get(row.targetInstance);
714
+ if (!episode || episode.deliveryId !== row.deliveryId
715
+ || outbox.hasOtherPendingWork(row.targetInstance, row.deliveryId)
716
+ || this.workingRecovery?.verdict(episode).outcome === "excluded") {
717
+ outbox?.withdrawWorkingRecovery(row.deliveryId, "recovery superseded or no longer authorized before input");
718
+ return false;
719
+ }
720
+ return (writing ? this.workingRecovery?.accepts(row.targetInstance, episode.key)
721
+ : this.workingRecovery?.canDispatch(row.targetInstance, episode.key)) === true;
722
+ }
608
723
  /** Phase 2b: wakes paused targets for queued durable work (delivery_worker ≠ off). */
609
724
  wakeCoordinator = null;
610
725
  /**
@@ -796,6 +911,8 @@ export class FleetManager {
796
911
  pendingBackendSelects = new Map();
797
912
  /** nonce → pending button prompt (hang restart, interactive assist, clean-exit restart). */
798
913
  pendingNonceButtons = new Map();
914
+ /** #2598 B2: the kiro moves waiting on a fleet admin's Confirm in General, by prompt nonce. */
915
+ pendingKiroMigrations = new Map();
799
916
  /** #1386 "Needs you": the list, its live chat messages and Acknowledge. Started with the fleet (finishStartup). */
800
917
  needsYou = null;
801
918
  /** When the fleet first saw an instance crashed, for its "Needs you" age; cleared when it is not. */
@@ -1054,9 +1171,106 @@ export class FleetManager {
1054
1171
  }
1055
1172
  officeStoreInstance;
1056
1173
  /** Fleet → Org chart → Office: the offices people made (web-offices.json, beside web-themes.json). */
1057
- get officeStore() { return (this.officeStoreInstance ??= new OfficeStore(this.dataDir)); }
1174
+ get officeStore() { return (this.officeStoreInstance ??= new OfficeStore(this.dataDir, id => this.officeSkins.pack(id))); }
1175
+ officeSkinsInstance;
1176
+ /** #2513: the Office view's uploaded skin packs (<AGEND_HOME>/web-office-skins/). */
1177
+ get officeSkins() { return (this.officeSkinsInstance ??= new OfficeSkinStore(this.dataDir)); }
1178
+ /** How often a person may upload or delete a skin pack; memory only, as the offices' limiter. */
1179
+ officeSkinWrites = new OfficeWriteLimiter(undefined, { perWindow: 10, windowMs: 60_000 });
1058
1180
  /** How often a person may save the offices (PUT /ui/offices); memory only, as the appearance limiter. */
1059
1181
  officeWrites = new OfficeWriteLimiter();
1182
+ officeSharesInstance;
1183
+ /**
1184
+ * #2636: Office share links — the live Office view for people outside the fleet, read-only, on a quick tunnel of
1185
+ * their own (a manager and lease of its own: never the fleet's one tunnel lane nor the public link's preview tunnel).
1186
+ */
1187
+ get officeShares() {
1188
+ return (this.officeSharesInstance ??= new OfficeShareService({
1189
+ source: {
1190
+ status: () => this.getUiStatus(),
1191
+ org: () => this.orgChart(),
1192
+ offices: () => this.officeStore.list(),
1193
+ acts: () => this.officeActs.live(),
1194
+ traffic: () => this.trafficSnapshot(),
1195
+ needs: () => this.needsYouItems().map(i => i.instance),
1196
+ hidden: name => crossInstanceVisibility(this.fleetConfig, name) === "hidden",
1197
+ skins: this.officeSkins,
1198
+ },
1199
+ manager: new ManagedTunnel({ dataDir: join(this.dataDir, OFFICE_SHARE_TUNNEL_DIR), log: () => this.logger.warn("Office share tunnel cleanup requires attention") }),
1200
+ ensure: signal => ensureCloudflared({ dataDir: this.dataDir, pinnedOnly: true, signal }),
1201
+ provider: binary => new CloudflaredProvider({ binaryName: binary, protocol: this.fleetConfig?.web?.public_link?.protocol ?? "http2" }),
1202
+ config: () => this.fleetConfig?.web?.office_share,
1203
+ officeExists: id => this.officeStore.list().offices.some(o => o.id === id),
1204
+ ownerCurrent: owner => this.officeShareOwnerCurrent(owner),
1205
+ changed: () => this.emitSseEvent("office_shares", {}),
1206
+ log: (event, detail) => this.logger.info({ event, ...(detail ? { detail } : {}) }, "Office share link"),
1207
+ }));
1208
+ }
1209
+ /**
1210
+ * #2636: a share made or ended — said in General, as a sign-in is (who, which office, for how long; never its URL).
1211
+ * `share` null with `count`: "Revoke all".
1212
+ */
1213
+ officeShareNotice(event, who, share, count) {
1214
+ const office = !share ? "" : share.office === ALL_OFFICES ? t("office_share.all") : this.officeStore.list().offices.find(o => o.id === share.office)?.name ?? share.office;
1215
+ const minutes = share ? Math.round((share.expiresAt - share.createdAt) / 60_000) : 0;
1216
+ const text = event === "created" ? t("office_share.created_notice", who, office, minutes < 60 ? `${minutes} min` : `${Math.round(minutes / 60)} h`)
1217
+ : share ? t("office_share.revoked_notice", who, office) : t("office_share.revoked_all_notice", who, count);
1218
+ this.notifyFleetError(text, { throttle: false });
1219
+ }
1220
+ /** A share's owner: a person (`person:<adapter>:<user>`) still a fleet admin there; the host's own admin session stays. */
1221
+ officeShareOwnerCurrent(owner) {
1222
+ const m = /^person:([^:]+):(.+)$/.exec(owner);
1223
+ if (!m)
1224
+ return owner === "admin-session";
1225
+ return this.hasFleetAdmins(m[1]) && this.isFleetAdmin(m[2], m[1]);
1226
+ }
1227
+ officeActsInstance;
1228
+ /** #2578: the agents' acts in the Office view (office_view / office_set), memory only; every change is `office_acts`. */
1229
+ get officeActs() {
1230
+ return (this.officeActsInstance ??= new OfficeActs({
1231
+ offices: () => this.officeStore.list().offices,
1232
+ pack: id => this.officeSkins.pack(id),
1233
+ chart: () => { const c = this.orgChart(); return { names: Object.keys(c.instances), teams: c.teams }; },
1234
+ people: () => this.officePeople(),
1235
+ changed: acts => this.emitSseEvent("office_acts", { acts, now: Date.now() }),
1236
+ }));
1237
+ }
1238
+ /**
1239
+ * #2578: each instance's state as the org chart shows it (fleet-org.js orgState, from the same fields the status
1240
+ * frame and the Needs-you list carry) and its display name (resolveInstanceIdentity, as getUiStatus) — read once.
1241
+ */
1242
+ officePeople() {
1243
+ const needs = new Map();
1244
+ for (const item of this.needsYouItems())
1245
+ needs.set(item.instance, (needs.get(item.instance) ?? 0) + 1);
1246
+ const classicRooms = new Map((this.classicChannels?.getAll() ?? []).map(ch => [ch.instanceName, ch]));
1247
+ let profiles = null;
1248
+ return {
1249
+ stateOf: name => {
1250
+ if (!this.isKnownInstance(name))
1251
+ return "missing";
1252
+ const p = this.instancePresentation(name), status = this.getInstanceStatus(name);
1253
+ if (p.state === "awaiting_input" || (needs.get(name) ?? 0) > 0)
1254
+ return "needs";
1255
+ if (p.startup_phase === "queued" || p.startup_phase === "starting")
1256
+ return p.startup_phase;
1257
+ if (status === "crashed" || status === "paused")
1258
+ return status;
1259
+ if (status !== "running")
1260
+ return "stopped";
1261
+ return p.execution_state === "stuck" || p.execution_state === "working" ? p.execution_state : "idle";
1262
+ },
1263
+ displayName: name => {
1264
+ try {
1265
+ profiles ??= profileIdentities(this.dataDir);
1266
+ return resolveInstanceIdentity({ cfg: this.fleetConfig?.instances[name], classic: classicRooms.get(name), profile: profiles.get(name) }).display_name || undefined;
1267
+ }
1268
+ catch {
1269
+ return undefined;
1270
+ }
1271
+ },
1272
+ };
1273
+ }
1060
1274
  /** #1629: the theme tools, a person's reset and lock, and the one appearance rate limiter (shared with effects). */
1061
1275
  get webAppearance() {
1062
1276
  return (this.webAppearanceService ??= new WebAppearance(this.themeStore, () => this.fleetConfig?.web ?? null, () => { try {
@@ -1582,6 +1796,74 @@ export class FleetManager {
1582
1796
  // No platform confirmation was reachable. Host inspection is still required.
1583
1797
  this.logger.info({ id: view.id }, "Settings change requires local `agend settings confirm <id>`");
1584
1798
  }
1799
+ kiroMigrateDeps() {
1800
+ return {
1801
+ agendHome: getAgendHome(),
1802
+ fleetConfig: () => this.fleetConfig,
1803
+ saveFleetConfig: () => this.saveFleetConfig(),
1804
+ status: name => this.getInstanceStatus(name),
1805
+ restart: name => this.restartSingleInstance(name, { explicit: true }),
1806
+ };
1807
+ }
1808
+ /**
1809
+ * #2598 B2, migrate_kiro_instance: judge the move, then ask in General for a fleet admin's Confirm. Nothing changes
1810
+ * here; the move runs on the Confirm (handleKiroMigrateCallback), judged again then.
1811
+ */
1812
+ async requestKiroMigration(name, startFresh) {
1813
+ const judged = planKiroMigration(this.kiroMigrateDeps(), name, startFresh);
1814
+ if (!judged.ok)
1815
+ return judged;
1816
+ const general = this.findGeneralInstance();
1817
+ const adapterId = general ? this.getInstanceAdapterId(general) : undefined;
1818
+ const adapter = general ? this.getAdapterForInstance(general) : undefined;
1819
+ const chatId = general ? this.getGroupIdForInstance(general) : undefined;
1820
+ const topic = general ? String(this.fleetConfig?.instances[general]?.topic_id ?? "") : "";
1821
+ if (!general || !adapterId || !adapter || !chatId)
1822
+ return { ok: false, error: t("kiro_migrate.no_general") };
1823
+ const { plan } = judged;
1824
+ try {
1825
+ const nonce = await this.postNonceButtonPromptOrThrow({
1826
+ prefix: KIRO_MIGRATE_CALLBACK_PREFIX, alertType: "clear_confirm", instanceName: name, adapter, adapterId, chatId,
1827
+ ...(topic ? { threadId: topic } : {}),
1828
+ message: kiroMigrationPrompt(plan, this.getInstanceStatus(name) === "running"),
1829
+ choices: [{ action: "confirm", label: t("kiro_migrate.confirm_button") }, { action: "cancel", label: t("kiro_migrate.cancel_button") }],
1830
+ expiredText: t("kiro_migrate.expired", name),
1831
+ timeoutMs: KIRO_MIGRATE_TIMEOUT_MS,
1832
+ });
1833
+ this.pendingKiroMigrations.set(nonce, plan);
1834
+ const expiry = setTimeout(() => this.pendingKiroMigrations.delete(nonce), KIRO_MIGRATE_TIMEOUT_MS + 60_000);
1835
+ expiry.unref?.();
1836
+ }
1837
+ catch (err) {
1838
+ return { ok: false, error: `${t("kiro_migrate.no_general")} (${err.message})` };
1839
+ }
1840
+ return { ok: true, text: t("kiro_migrate.requested"), classic_conversation: plan.classicId };
1841
+ }
1842
+ /** A fleet admin's Confirm or Cancel on a kiro move. Any fleet admin (the default nonce authorization), in General. */
1843
+ async handleKiroMigrateCallback(data, adapterId, adapter) {
1844
+ const claimed = this.consumeNonceCallback(KIRO_MIGRATE_CALLBACK_PREFIX, /^kiro-migrate:([0-9a-f]{32}):(confirm|cancel)$/, data, adapterId, adapter);
1845
+ if (claimed === null)
1846
+ return false;
1847
+ if (claimed === "consumed")
1848
+ return true;
1849
+ const { entry, action } = claimed;
1850
+ const nonce = data.callbackData.slice(KIRO_MIGRATE_CALLBACK_PREFIX.length, KIRO_MIGRATE_CALLBACK_PREFIX.length + 32);
1851
+ const plan = this.pendingKiroMigrations.get(nonce);
1852
+ this.pendingKiroMigrations.delete(nonce);
1853
+ const messageId = entry.messageId ?? data.messageId;
1854
+ if (action === "cancel" || !plan) {
1855
+ await this.retireNonceButtons(entry, messageId, t(plan ? "kiro_migrate.cancelled" : "kiro_migrate.expired", entry.instanceName));
1856
+ return true;
1857
+ }
1858
+ this.eventLog?.insert(plan.name, "kiro_migrate", { classicId: plan.classicId, userId: data.userId });
1859
+ await this.retireNonceButtons(entry, messageId, t("kiro_migrate.applying", plan.name));
1860
+ const outcome = await applyKiroMigration(this.kiroMigrateDeps(), plan);
1861
+ this.logger.info({ name: plan.name, ok: outcome.ok, classicId: plan.classicId }, "kiro classic → TUI move");
1862
+ this.notifyFleetError(outcome.text);
1863
+ if (outcome.ok)
1864
+ this.notifyInstanceTopic(plan.name, outcome.text);
1865
+ return true;
1866
+ }
1585
1867
  async handleSettingsChangeCallback(data, adapterId, adapter) {
1586
1868
  const result = this.consumeNonceCallback("settings-confirm:", /^settings-confirm:([0-9a-f]{32}):(confirm|reject)$/, data, adapterId, adapter, undefined, (userId, entry) => entry.confirmationAdmin?.(userId) === true);
1587
1869
  if (!result)
@@ -1846,6 +2128,7 @@ export class FleetManager {
1846
2128
  }
1847
2129
  this.deliveryOutbox = outbox;
1848
2130
  this.deliveryOutboxRecovered = true;
2131
+ this.initializeWorkingRecovery(outbox);
1849
2132
  outbox.on("working_references", (names) => {
1850
2133
  for (const name of names)
1851
2134
  this.observeWorkingState(name, "obligations");
@@ -2124,6 +2407,10 @@ export class FleetManager {
2124
2407
  lastActivityAt: this.lastActivity.get(name) ?? null, references, totalReferences, unavailable });
2125
2408
  if (!entry)
2126
2409
  return;
2410
+ if (reason === "cancelled")
2411
+ outbox.workingRecovery.cancel(name, observation.configuration, observation.sessionId);
2412
+ if (entry.prior)
2413
+ outbox.workingRecovery.discover(name, entry.prior, this.workingRecoveryGroup);
2127
2414
  const state = entry.current.observation.executionState ?? "unknown";
2128
2415
  const counts = { tasks: entry.current.references.filter(r => r.kind === "task").length,
2129
2416
  owed: entry.current.references.filter(r => r.kind === "owed").length,
@@ -2217,7 +2504,7 @@ export class FleetManager {
2217
2504
  // Phase 2c: a lane a queue worker owns is never claimed here.
2218
2505
  target => this.queueWorkers.has(target) || this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target)
2219
2506
  ? null
2220
- : this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets);
2507
+ : this.daemons.get(target)?.bootId ?? null, this.activeDurableTargets, row => this.recoveryDeliveryEligible(row));
2221
2508
  if (!claimed)
2222
2509
  break;
2223
2510
  this.activeDurableTargets.add(claimed.targetInstance);
@@ -2279,7 +2566,7 @@ export class FleetManager {
2279
2566
  available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
2280
2567
  blocked: () => this.isInstanceRestarting(target) || this.wakeCoordinator?.blocksClaim(target) === true,
2281
2568
  daemonBootId: () => this.daemons.get(target)?.bootId ?? null,
2282
- claim: bootId => this.deliveryOutbox?.claimNext(this.managerBootId, candidate => candidate === target ? bootId : null, new Set()),
2569
+ claim: bootId => this.deliveryOutbox?.claimNext(this.managerBootId, candidate => candidate === target ? bootId : null, new Set(), row => this.recoveryDeliveryEligible(row)),
2283
2570
  tryAcquireBudget: () => {
2284
2571
  if (this.activeDurableTargets.size >= 8)
2285
2572
  return false;
@@ -2320,7 +2607,24 @@ export class FleetManager {
2320
2607
  // including time spent waiting for a handoff/idle gate to return.
2321
2608
  try {
2322
2609
  let sent;
2323
- if (claimed.kind === "raw_paste") {
2610
+ if (claimed.kind === WORKING_RECOVERY_KIND) {
2611
+ const daemon = this.daemons.get(target), key = claimed.sourceKey.replace(/^working-recovery:/, "");
2612
+ if (!daemon || typeof payload.content !== "string")
2613
+ throw new Error("Working recovery owner unavailable");
2614
+ const release = this.workingRecovery?.reserveDispatch(target, key);
2615
+ if (!release) {
2616
+ outbox.retryBeforeBegin(claimed.deliveryId, daemon.bootId, claimed.attemptNo, "working recovery pace or prerequisites pending", 5_000);
2617
+ return;
2618
+ }
2619
+ try {
2620
+ await daemon.deliverWorkingRecovery(payload.content, claimed, () => this.daemons.get(target) === daemon && this.recoveryDeliveryEligible(claimed, true));
2621
+ }
2622
+ finally {
2623
+ release();
2624
+ }
2625
+ sent = true;
2626
+ }
2627
+ else if (claimed.kind === "raw_paste") {
2324
2628
  if (payload.type !== "raw_paste" || typeof payload.content !== "string") {
2325
2629
  throw new Error("Durable raw_paste row has an invalid payload");
2326
2630
  }
@@ -2413,7 +2717,7 @@ export class FleetManager {
2413
2717
  const explicit = this.fleetConfig?.defaults?.startup?.concurrency;
2414
2718
  if (explicit != null)
2415
2719
  return Math.max(1, Math.min(20, explicit));
2416
- return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), cpus().length);
2720
+ return deriveSpawnConcurrency(Math.round(freemem() / (1024 * 1024)), effectiveCpuCount());
2417
2721
  }
2418
2722
  /** #1685: "elevated" or "critical" while host memory pressure slows or holds new agents; otherwise null (an
2419
2723
  * unknown macOS reading — the advisory case — is unknown, so null too). */
@@ -2630,8 +2934,9 @@ export class FleetManager {
2630
2934
  // the next tunnel and says so, it does not block the fleet.
2631
2935
  this.announceToolPermissionsChange();
2632
2936
  this.tightenInstanceDirectories();
2633
- // #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way.
2634
- for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR)]) {
2937
+ // #1723: the public link's preview tunnel keeps its own lease beside it; a leftover one is reaped the same way (and
2938
+ // #2636's Office share tunnel's).
2939
+ for (const leaseDir of [this.dataDir, join(this.dataDir, PUBLIC_PREVIEW_TUNNEL_DIR), join(this.dataDir, OFFICE_SHARE_TUNNEL_DIR)]) {
2635
2940
  void reapStaleTunnel(leaseDir)
2636
2941
  .then(outcome => {
2637
2942
  if (outcome.kind === "manual")
@@ -3004,6 +3309,7 @@ export class FleetManager {
3004
3309
  }
3005
3310
  /** SIGUSR1: full process reload. Names who asked (#1120). */
3006
3311
  onFullRestartSignal() {
3312
+ this.recordWorkingRestartIntent("manual_restart");
3007
3313
  this.logger.info(`Received SIGUSR1, initiating full restart (process reload)... ${describeSignalSource(this.dataDir, "SIGUSR1")}`);
3008
3314
  this.gracefulShutdownForReload()
3009
3315
  .then(() => {
@@ -3195,7 +3501,7 @@ export class FleetManager {
3195
3501
  await data.respond(t("permission.denied"));
3196
3502
  return;
3197
3503
  }
3198
- this.fleetConfig.defaults.tips = mode === "on";
3504
+ setNoticeMode(this.fleetConfig, "tips", mode === "on");
3199
3505
  this.saveFleetConfig();
3200
3506
  await data.respond(t(mode === "on" ? "tips.enabled" : "tips.disabled"));
3201
3507
  return;
@@ -4866,7 +5172,7 @@ export class FleetManager {
4866
5172
  return new WakeCoordinator({
4867
5173
  mode: target => this.deliveryWorkerMode(target),
4868
5174
  available: () => !this.shuttingDown && this.deliveryOutbox?.isOpen === true,
4869
- listPending: () => this.deliveryOutbox?.listPending() ?? [],
5175
+ listPending: () => this.deliveryOutbox?.listPending().filter(row => row.kind !== WORKING_RECOVERY_KIND) ?? [],
4870
5176
  isPaused: target => !this.sessionOomStopped(target) && this.lifecycle.isPaused(target),
4871
5177
  pauseReason: target => readPauseReason(this.getInstanceDir(target)),
4872
5178
  isRestarting: target => this.isInstanceRestarting(target),
@@ -4985,6 +5291,7 @@ export class FleetManager {
4985
5291
  }
4986
5292
  classicBehaviorUpdate(instanceName) {
4987
5293
  return {
5294
+ reply_triggers: this.classicChannels?.getReplyTriggersByInstance(instanceName, this.fleetConfig?.defaults?.reply_triggers) ?? DEFAULT_REPLY_TRIGGERS,
4988
5295
  tool_progress: this.classicChannels?.getToolProgressByInstance(instanceName, this.fleetConfig?.defaults?.tool_progress) ?? "off",
4989
5296
  reply_completion_guard: this.classicChannels?.getReplyCompletionGuardByInstance(instanceName, this.fleetConfig?.defaults?.reply_completion_guard) ?? true,
4990
5297
  };
@@ -5283,36 +5590,40 @@ export class FleetManager {
5283
5590
  * config file races. Stagger applies between outer lifecycle admissions.
5284
5591
  * TODO: per-instance startup timeout (existing issue, not introduced here)
5285
5592
  */
5286
- async startInstancesWithConcurrency(entries, topicMode, onReady) {
5593
+ async startInstancesWithConcurrency(entries, topicMode, onReady, classic = []) {
5287
5594
  // Persisted pauses are intentionally preserved across fleet restarts. Filter
5288
5595
  // them before grouping/staggering: startInstance() retains its own guard as
5289
5596
  // a final backstop, but putting a no-op entry in this queue still consumes a
5290
5597
  // full stagger slot for every distinct working directory.
5291
5598
  const runnableEntries = entries.filter(([name]) => !this.lifecycle.isPaused(name));
5599
+ // ClassicBot rooms queue with the fleet instances, at the same pressure pacing (#2642 follow-up): they used to start
5600
+ // only after the whole fleet, the chat connections, topics and icons. A name the fleet also configures is the fleet's.
5601
+ const fleetNames = new Set(entries.map(([name]) => name));
5602
+ const runnableClassic = classic.filter(ch => !this.lifecycle.isPaused(ch.instanceName) && !fleetNames.has(ch.instanceName));
5292
5603
  const pausedCount = entries.length - runnableEntries.length;
5293
5604
  if (pausedCount > 0) {
5294
5605
  this.logger.info({ pausedCount }, "Paused instances excluded from startup queue");
5295
5606
  }
5296
- if (runnableEntries.length === 0)
5607
+ if (runnableEntries.length === 0 && runnableClassic.length === 0)
5297
5608
  return;
5298
5609
  if (this.fleetConfig?.defaults?.startup?.concurrency == null) {
5299
5610
  this.logger.info({
5300
5611
  concurrency: this.spawnConcurrency(),
5301
5612
  freeMemMB: Math.round(freemem() / (1024 * 1024)),
5302
- totalInstances: runnableEntries.length,
5613
+ totalInstances: runnableEntries.length + runnableClassic.length,
5303
5614
  }, "Adaptive startup concurrency");
5304
5615
  }
5305
5616
  const startedAt = performance.now();
5306
5617
  let active = 0, peakConcurrentStarts = 0, readyInstances = 0;
5307
- await Promise.all(runnableEntries.map(([name, config]) => this.spawnGate.run({
5618
+ const admit = (name, workingDirectory, start) => this.spawnGate.run({
5308
5619
  instanceName: name,
5309
- workingDirectory: config.working_directory,
5620
+ workingDirectory,
5310
5621
  reason: "startup",
5311
5622
  stage: "lifecycle",
5312
5623
  }, async () => {
5313
5624
  peakConcurrentStarts = Math.max(peakConcurrentStarts, ++active);
5314
5625
  try {
5315
- if (await this.startInstanceUnattended(name, config, topicMode, "instance")) {
5626
+ if (await start()) {
5316
5627
  readyInstances++;
5317
5628
  onReady?.(name);
5318
5629
  }
@@ -5320,9 +5631,13 @@ export class FleetManager {
5320
5631
  finally {
5321
5632
  active--;
5322
5633
  }
5323
- })));
5634
+ });
5635
+ await Promise.all([
5636
+ ...runnableEntries.map(([name, config]) => admit(name, config.working_directory, () => this.startInstanceUnattended(name, config, topicMode, "instance"))),
5637
+ ...runnableClassic.map(ch => admit(ch.instanceName, join(getAgendHome(), "workspaces", ch.instanceName), () => this.startClassicInstanceUnattended(ch, "classic instance"))),
5638
+ ]);
5324
5639
  this.logger.info({
5325
- totalInstances: runnableEntries.length, readyInstances,
5640
+ totalInstances: runnableEntries.length + runnableClassic.length, classicInstances: runnableClassic.length, readyInstances,
5326
5641
  elapsedSeconds: Math.round((performance.now() - startedAt) / 100) / 10,
5327
5642
  peakConcurrentStarts,
5328
5643
  }, "Fleet startup batch completed");
@@ -6722,7 +7037,8 @@ export class FleetManager {
6722
7037
  const [adapterId, userId] = k.split("\u0000");
6723
7038
  accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
6724
7039
  }
6725
- for (const key of Object.keys(cfg?.narrow ?? {})) {
7040
+ // #2348: every granted account is listed too, signed in or not.
7041
+ for (const key of [...Object.keys(cfg?.narrow ?? {}), ...Object.keys(cfg?.grants ?? {})]) {
6726
7042
  const at = key.indexOf(":"), adapterId = key.slice(0, at), userId = key.slice(at + 1), k = `${adapterId}\u0000${userId}`;
6727
7043
  if (at > 0 && !accounts.has(k))
6728
7044
  accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
@@ -6731,10 +7047,166 @@ export class FleetManager {
6731
7047
  const owner = { adapterId: a.adapterId, userId: a.userId };
6732
7048
  const badge = this.guestBadge(owner);
6733
7049
  // `reach`: before narrowing — what Settings offers to narrow to (#2104 review: never an agent it cannot reach).
7050
+ const grant = own(cfg?.grants, `${a.adapterId}:${a.userId}`);
7051
+ const sent = this.guestCodeSent.get(`${a.adapterId}:${a.userId}`);
6734
7052
  return { ...a, name: badge.name, platform: badge.platform, scope: [...this.guestScope(owner)].sort(), reach: [...this.guestReach(owner)].sort(),
6735
- devices: devices.get(k) ?? 0, narrow: own(cfg?.narrow, `${a.adapterId}:${a.userId}`) ?? null };
7053
+ devices: devices.get(k) ?? 0, narrow: own(cfg?.narrow, `${a.adapterId}:${a.userId}`) ?? null,
7054
+ // #2348: the grant as fleet.yaml has it (null: none), and the last code an admin sent from Settings.
7055
+ grant: grant ? { ...(grant.name ? { name: grant.name } : {}), instances: [...grant.instances] } : null,
7056
+ ...(sent ? { code: { at: sent.at, outcome: sent.outcome.kind, ...(sent.outcome.kind === "sent" ? { expiresAt: sent.at + sent.outcome.expiresInMs } : {}),
7057
+ ...(sent.outcome.kind === "dm_failed" ? { reason: sent.outcome.reason, platform: sent.outcome.platform, ...(sent.outcome.startUrl ? { startUrl: sent.outcome.startUrl } : {}) } : {}) } } : {}) };
6736
7058
  }).sort((x, y) => (y.lastSeen ?? -1) - (x.lastSeen ?? -1));
6737
- return { enabled: cfg?.enabled === true, chat: cfg?.chat !== false, instances: Object.keys(this.fleetConfig?.instances ?? {}).sort(), connections, guests };
7059
+ // #2348: `explicit` — grants are the scope (default deny); false: the derived scope of #1920, until grants are saved.
7060
+ // #2667: the ClassicBot rooms there are now, apart from fleet.yaml's instances (a grant may name either: guestReach),
7061
+ // with its display name as the sidebar resolves it (a profile alias, else the room's own), when it has one.
7062
+ const profiles = profileIdentities(this.dataDir);
7063
+ const rooms = (this.classicChannels?.getAll() ?? []).filter((r) => !Object.hasOwn(this.fleetConfig?.instances ?? {}, r.instanceName))
7064
+ .map((r) => {
7065
+ const shown = resolveInstanceIdentity({ classic: r, profile: profiles.get(r.instanceName) }).display_name;
7066
+ return { name: r.instanceName, ...(shown && shown !== r.instanceName ? { display_name: shown } : {}) };
7067
+ })
7068
+ .sort((x, y) => (x.name < y.name ? -1 : x.name > y.name ? 1 : 0));
7069
+ return { enabled: cfg?.enabled === true, chat: cfg?.chat !== false, explicit: cfg?.grants !== undefined, instances: Object.keys(this.fleetConfig?.instances ?? {}).sort(), rooms, connections, guests,
7070
+ audit: this.guestAudit() };
7071
+ }
7072
+ /** #2667: the ClassicBot rooms' instance names now — what a settings save's validation knows besides fleet.yaml's instances. */
7073
+ classicRoomNames() {
7074
+ return (this.classicChannels?.getAll() ?? []).map((r) => r.instanceName);
7075
+ }
7076
+ /**
7077
+ * #2499 review: the guest audit an admin reads — the newest grant changes and admin-sent codes from the event log
7078
+ * (`fleet` rows: no agent's Log shows them). Through guestOverview only: Settings' admin gate, never over the public
7079
+ * link, never a guest's. The person's name as the card shows it; a session handle stays on the server (`by` → whether
7080
+ * a person saved it here or it was confirmed in General).
7081
+ */
7082
+ guestAudit(limit = FleetManager.GUEST_AUDIT_SHOWN) {
7083
+ if (!this.eventLog)
7084
+ return [];
7085
+ // One read, newest first by row id: a total order (created_at is to the second), so the cap never drops a newer row.
7086
+ let rows = [];
7087
+ try {
7088
+ rows = this.eventLog.latestOfTypes("fleet", ["guest_grant_changed", "guest_code_sent"], limit);
7089
+ }
7090
+ catch {
7091
+ return [];
7092
+ }
7093
+ const names = (list) => (Array.isArray(list) ? list.filter((n) => typeof n === "string") : []);
7094
+ return rows.flatMap((r) => {
7095
+ const p = r.payload ?? {}, account = typeof p.account === "string" ? p.account : "";
7096
+ const at = account.indexOf(":");
7097
+ if (at <= 0)
7098
+ return [];
7099
+ const name = this.webPerson({ adapterId: account.slice(0, at), userId: account.slice(at + 1) }).name;
7100
+ const when = Date.parse(r.created_at.includes("T") ? r.created_at : `${r.created_at.replace(" ", "T")}Z`);
7101
+ return r.event_type === "guest_grant_changed"
7102
+ ? [{ kind: "grant", at: when, account, name, via: p.via === "confirmed" ? "confirmed" : "direct", added: names(p.added), removed: names(p.removed),
7103
+ endedSessions: typeof p.endedSessions === "number" ? p.endedSessions : 0 }]
7104
+ : [{ kind: "code", at: when, account, name, outcome: typeof p.outcome === "string" ? p.outcome : "unknown" }];
7105
+ });
7106
+ }
7107
+ static GUEST_AUDIT_SHOWN = 20;
7108
+ /**
7109
+ * #2348: Settings → Guests' picker — the people AgEnD has seen in chat (seen_people, from human receipts: never a bot,
7110
+ * never the web), without fleet admins (they sign in as admins) or accounts of a connection the fleet no longer has.
7111
+ * Admin only (settings-api.ts), never over the public link.
7112
+ */
7113
+ guestPeople() {
7114
+ const seen = this.deliveryOutbox?.isOpen ? this.deliveryOutbox.seenPeople() : [];
7115
+ const people = seen.filter((p) => this.worlds.has(p.adapterId) && !this.isFleetAdmin(p.userId, p.adapterId)).map((p) => {
7116
+ const owner = { adapterId: p.adapterId, userId: p.userId };
7117
+ return { adapterId: p.adapterId, userId: p.userId, platform: this.worlds.get(p.adapterId)?.adapter.type ?? p.adapterId, username: p.username,
7118
+ name: this.webPerson(owner).name, lastSeen: p.lastSeen, instances: p.instances.map((i) => i.name) };
7119
+ });
7120
+ return { people };
7121
+ }
7122
+ /**
7123
+ * #2348: why this account may not be given a grant, or null. Only someone AgEnD has seen in chat, on a connection the
7124
+ * fleet runs, who is not a fleet admin of it (an admin is never a guest).
7125
+ */
7126
+ guestGrantRefusal(adapterId, userId) {
7127
+ if (!this.worlds.has(adapterId) && !this.fleetConfig?.channels?.some((c) => (c.id ?? c.type) === adapterId))
7128
+ return "no such connection in this fleet";
7129
+ if (this.isFleetAdmin(userId, adapterId))
7130
+ return "a fleet admin signs in as an admin, not as a guest";
7131
+ if (!this.deliveryOutbox?.isOpen || !this.deliveryOutbox.isSeenPerson(adapterId, userId))
7132
+ return "only someone AgEnD has seen in chat can be given guest access";
7133
+ return null;
7134
+ }
7135
+ /**
7136
+ * #2348 delta §5: a grant changed with a save — one event-log entry per person, shown in Settings → Guests' Recent
7137
+ * changes (guestOverview: admin-only, never over the public link or to a guest): who saved it (an admin's
7138
+ * session handle, the header token, or null when replayed for a confirmation), whether it applied at once or was
7139
+ * confirmed in General, the agents added and taken away, and the sessions a removal ended. Never message content.
7140
+ */
7141
+ noteGuestGrantChange(change) {
7142
+ this.logger.info({ account: change.account, via: change.via, added: change.added.length, removed: change.removed.length }, "Guest access changed in Settings");
7143
+ try {
7144
+ this.eventLog?.insert("fleet", "guest_grant_changed", { ...change });
7145
+ }
7146
+ catch { /* the log line stands */ }
7147
+ }
7148
+ /** #2348: an admin's Send sign-in code, per admin (the caller): at most this many in ADMIN_CODE_WINDOW_MS. */
7149
+ static ADMIN_CODE_MAX = 10;
7150
+ static ADMIN_CODE_WINDOW_MS = 10 * 60_000;
7151
+ adminCodeSends = new Map();
7152
+ /** #2348: the last admin-sent code per account, for the Settings card ("Code sent 18:42 · expires 18:52", or why not). */
7153
+ guestCodeSent = new Map();
7154
+ /**
7155
+ * #2348: Settings → Guests' "Send sign-in code" — a guest code to an account an admin granted access, by DM only (no
7156
+ * chat to answer in, nothing posted anywhere). The same issuance as the person's own /web (one code per account, the
7157
+ * code store's caps and breaker, the public link's audience when it is open) and the same once-a-minute limit per
7158
+ * account, plus a per-admin cap. Refused without guest sign-in, for a fleet admin, or with nothing granted. Audited.
7159
+ */
7160
+ async sendGuestCodeByAdmin(owner, by,
7161
+ /** #2365 review: the initiating admin is still signed in and still an admin (settings-api re-checks the session). */
7162
+ current = () => true) {
7163
+ const guests = this.fleetConfig?.web?.guests;
7164
+ if (guests?.enabled !== true)
7165
+ return { status: 409, body: { ok: false, outcome: "guests_off", error: "Guest sign-in is off" } };
7166
+ if (this.isFleetAdmin(owner.userId, owner.adapterId))
7167
+ return { status: 409, body: { ok: false, outcome: "admin", error: "A fleet admin signs in as an admin" } };
7168
+ if (!this.guestScope(owner).size)
7169
+ return { status: 409, body: { ok: false, outcome: "nothing", error: "Nothing is granted to this person yet: save their access first" } };
7170
+ const token = this.webToken;
7171
+ const adapter = this.adapters.get(owner.adapterId);
7172
+ if (this.shuttingDown || !token || !adapter)
7173
+ return { status: 503, body: { ok: false, outcome: "starting", error: "AgEnD is starting or this connection is not running" } };
7174
+ const key = `${owner.adapterId}\u0000${owner.userId}`, asked = this.guestCodeAskedAt.get(key);
7175
+ const now = performance.now();
7176
+ if (this.guestCodeInFlight.has(key) || (asked !== undefined && now - asked < 60_000)) {
7177
+ return { status: 429, body: { ok: false, outcome: "wait", retryAfterMs: asked !== undefined ? Math.max(0, 60_000 - (now - asked)) : 60_000 } };
7178
+ }
7179
+ const mine = (this.adminCodeSends.get(by) ?? []).filter((at) => now - at < FleetManager.ADMIN_CODE_WINDOW_MS);
7180
+ if (mine.length >= FleetManager.ADMIN_CODE_MAX)
7181
+ return { status: 429, body: { ok: false, outcome: "admin_limit", retryAfterMs: FleetManager.ADMIN_CODE_WINDOW_MS - (now - mine[0]) } };
7182
+ this.adminCodeSends.set(by, [...mine, now]);
7183
+ while (this.adminCodeSends.size > 256)
7184
+ this.adminCodeSends.delete(this.adminCodeSends.keys().next().value);
7185
+ let outcome = { kind: "busy" };
7186
+ this.guestCodeInFlight.add(key);
7187
+ try {
7188
+ await this.issueGuestCode({ replyHere: async () => undefined }, owner, key, adapter, token, (o) => { outcome = o; }, current);
7189
+ }
7190
+ finally {
7191
+ this.guestCodeInFlight.delete(key);
7192
+ }
7193
+ const done = outcome;
7194
+ // #2365 review: the admin's session ended (or stopped being an admin's) while the code was on its way: nothing was
7195
+ // sent, or what was made is withdrawn — the answer says so, as to anyone no longer signed in.
7196
+ if (done.kind === "revoked" && !current())
7197
+ return { status: 401, body: { ok: false, outcome: "revoked", error: "no longer authorized" } };
7198
+ this.guestCodeSent.set(`${owner.adapterId}:${owner.userId}`, { at: Date.now(), outcome: done });
7199
+ while (this.guestCodeSent.size > 512)
7200
+ this.guestCodeSent.delete(this.guestCodeSent.keys().next().value);
7201
+ // Audited: who asked, for whom, what happened (never the code).
7202
+ this.logger.info({ by, adapterId: owner.adapterId, outcome: done.kind }, "Guest sign-in code sent by an admin from Settings");
7203
+ try {
7204
+ this.eventLog?.insert("fleet", "guest_code_sent", { by, account: `${owner.adapterId}:${owner.userId}`, outcome: done.kind, instances: this.guestScope(owner).size });
7205
+ }
7206
+ catch { /* the log line stands */ }
7207
+ const status = done.kind === "sent" ? 200 : done.kind === "dm_failed" ? 502 : 409;
7208
+ return { status, body: { ok: done.kind === "sent", outcome: done.kind, ...(done.kind === "sent" ? { expiresAt: Date.now() + done.expiresInMs, publicLink: done.publicLink } : {}),
7209
+ ...(done.kind === "dm_failed" ? { platform: done.platform, reason: done.reason, ...(done.startUrl ? { startUrl: done.startUrl } : {}) } : {}) } };
6738
7210
  }
6739
7211
  /** #1920 phase 3 (the app shell): a guest's chip — its web name, and its connection's platform (discord, telegram). */
6740
7212
  guestBadge(owner) {
@@ -6803,24 +7275,35 @@ export class FleetManager {
6803
7275
  return true;
6804
7276
  }
6805
7277
  /** guestWebCommand's issuance, one per account at a time; a revoke made meanwhile (either kind) ends it unissued. */
6806
- async issueGuestCode(ask, owner, key, adapter, token) {
7278
+ async issueGuestCode(ask, owner, key, adapter, token,
7279
+ /** #2348: how it ended, for a caller with no chat to answer in (Settings → Guests' Send sign-in code). */
7280
+ report = () => { },
7281
+ /**
7282
+ * #2365 review: the one who asked may still ask — for an admin's Send sign-in code, that admin's session as it is
7283
+ * now. Asked with the revocation checks, after every wait: false ends the issuance, and withdraws its code if one was made.
7284
+ */
7285
+ stillAllowed = () => true) {
6807
7286
  const say = (text) => ask.replyHere(text).catch(() => undefined);
6808
7287
  const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
6809
- const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes;
7288
+ const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes && stillAllowed();
6810
7289
  // §3.2: its rooms are read at sign-in (bounded; never longer than the reads' own deadline).
6811
7290
  await withinBudget(this.refreshGuestRooms(owner, { mode: "force" }), performance.now() + 6_000).catch(() => undefined);
6812
- if (!unrevoked())
6813
- return; // revoked while its rooms were read: no code
7291
+ if (!unrevoked()) {
7292
+ report({ kind: "revoked" });
7293
+ return;
7294
+ } // revoked while its rooms were read: no code
6814
7295
  const scope = this.guestScope(owner);
6815
7296
  if (!scope.size) {
6816
7297
  const open = this.worlds.get(owner.adapterId)?.accessManager.getMode() === "open";
6817
7298
  await say(t(open ? "web.guest_not_set_up" : "web.guest_nothing"));
7299
+ report({ kind: "nothing" });
6818
7300
  return;
6819
7301
  }
6820
7302
  this.initializeWebSessions();
6821
7303
  const codes = this.webGuestLoginCodes;
6822
7304
  if (codes.pausedForMs() > 0) {
6823
7305
  await say(t("web.guest_paused"));
7306
+ report({ kind: "paused" });
6824
7307
  return;
6825
7308
  }
6826
7309
  // §5.5 (step 4b): with the public link open, the code works through it too (an admin opened it; a guest is still a
@@ -6831,6 +7314,7 @@ export class FleetManager {
6831
7314
  recipient: { adapterId: owner.adapterId, userId: owner.userId, ...(ask.username ? { username: ask.username } : {}) } });
6832
7315
  if (!login) {
6833
7316
  await say(t("web.guest_busy"));
7317
+ report({ kind: "busy" });
6834
7318
  return;
6835
7319
  }
6836
7320
  this.guestCodeAskedAt.delete(key);
@@ -6865,13 +7349,17 @@ export class FleetManager {
6865
7349
  throw new Error("guest no longer admitted");
6866
7350
  await say(t("web.guest_sent"));
6867
7351
  }
7352
+ report({ kind: "sent", expiresInMs: LOGIN_CODE_TTL_MS, publicLink: !!open });
6868
7353
  }
6869
7354
  catch (err) {
6870
7355
  codes.revokeIfCurrent(login.issuanceId); // this flow's code only, never a later one's
6871
7356
  this.logger.info({ err: err?.message, adapterId: owner.adapterId }, "Guest sign-in code was not delivered privately");
6872
- if (!unrevoked())
6873
- return; // revoked meanwhile: the revoke's own answer said so
7357
+ if (!unrevoked()) {
7358
+ report({ kind: "revoked" });
7359
+ return;
7360
+ } // revoked meanwhile: the revoke's own answer said so
6874
7361
  const start = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
7362
+ report({ kind: "dm_failed", platform: adapter.type, reason: String(err?.message ?? err).slice(0, 200), ...(start ? { startUrl: start } : {}) });
6875
7363
  // #1742 for guests (step 4b-3): its START button (a bare /start, or the link's /start web) then asks again, from
6876
7364
  // the DM — once, within the window.
6877
7365
  if (adapter.type === "telegram")
@@ -7039,7 +7527,8 @@ export class FleetManager {
7039
7527
  this.notifyFleetError(t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8)));
7040
7528
  return;
7041
7529
  }
7042
- if (this.fleetConfig?.web?.notify_login === false)
7530
+ // Never by DM (hmsg-3b8ac2af: Telegram cannot DM a user who has not started the bot); a legacy "dm" reads as on.
7531
+ if (!loginNoticeMode(this.fleetConfig?.web))
7043
7532
  return;
7044
7533
  // The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
7045
7534
  // sign-in from the same kind of browser is exactly the one the operator most needs to hear about.
@@ -7413,11 +7902,13 @@ export class FleetManager {
7413
7902
  }
7414
7903
  }
7415
7904
  async startAllWithinDeadline(configPath, deadline) {
7416
- this.loginWindow.reopen(); // a stopAll → startAll restart must accept login windows again
7905
+ if (!this.structuredLoginCleanupFailed)
7906
+ this.loginWindow.reopen(); // a stopAll → startAll restart must accept login windows again
7417
7907
  this.loginController?.reopen();
7418
7908
  const startupStartedAt = Date.now();
7419
7909
  FleetManager.signalTarget = this;
7420
7910
  this.startupComplete = false;
7911
+ this.workingRecovery?.reset();
7421
7912
  this.startupDashboard = null;
7422
7913
  // Cleared here, not at the end of doStopAll: a stop has an async tail, and
7423
7914
  // anything arriving during it is still part of the stop.
@@ -7673,12 +8164,7 @@ export class FleetManager {
7673
8164
  mkdirSync(generalDir, { recursive: true, mode: 0o700 });
7674
8165
  const backendName = fleet.defaults.backend ?? "claude-code";
7675
8166
  this.ensureGeneralInstructions(generalDir, backendName, name);
7676
- fleet.instances[name] = {
7677
- ...DEFAULT_INSTANCE_CONFIG,
7678
- working_directory: generalDir,
7679
- general_topic: true,
7680
- channel_id: need.adapterId,
7681
- };
8167
+ fleet.instances[name] = autoGeneralInstanceConfig(fleet.defaults, generalDir, need.adapterId); // #2598: its kiro_ui is the fleet's
7682
8168
  generalsCreated = true;
7683
8169
  }
7684
8170
  if (generalsCreated)
@@ -7824,9 +8310,11 @@ export class FleetManager {
7824
8310
  this.rotateAllInstanceLogs();
7825
8311
  }, 24 * 60 * 60_000);
7826
8312
  this.logRotateTimer.unref?.();
7827
- // Phase 2: Start remaining instances with staggered concurrency
7828
- if (others.length > 0) {
7829
- await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady());
8313
+ // Phase 2: Start remaining instances, and the ClassicBot rooms with them, with staggered concurrency. A room needs no
8314
+ // chat connection to spawn: its open-list is set above (reregisterClassicChannels) and inbound queues until it is up.
8315
+ const classicAtBoot = topicMode && (fleet.channel || fleet.channels?.length) ? this.classicChannels?.getAll() ?? [] : [];
8316
+ if (others.length > 0 || classicAtBoot.length > 0) {
8317
+ await this.startInstancesWithConcurrency(others, topicMode, () => startupProgress.markReady(), classicAtBoot);
7830
8318
  }
7831
8319
  if (topicMode && (fleet.channel || fleet.channels?.length)) {
7832
8320
  await adapterStartup;
@@ -7868,22 +8356,7 @@ export class FleetManager {
7868
8356
  // IPC is already wired by startInstancesWithConcurrency → startInstance →
7869
8357
  // connectIpcToInstance. The previous 3s sleep + connectToInstances loop
7870
8358
  // was redundant.
7871
- // Start classic channel instances (parallel, concurrency 3)
7872
- if (this.classicChannels) {
7873
- const fleetBackend = this.fleetConfig?.defaults?.backend;
7874
- const channels = this.classicChannels.getAll()
7875
- .filter(ch => !this.lifecycle.isPaused(ch.instanceName));
7876
- const concurrency = 3;
7877
- let idx = 0;
7878
- while (idx < channels.length) {
7879
- const batch = channels.slice(idx, idx + concurrency);
7880
- await Promise.allSettled(batch.map(async (ch) => {
7881
- if (await this.startClassicInstanceUnattended(ch, "classic instance"))
7882
- startupProgress.markReady();
7883
- }));
7884
- idx += concurrency;
7885
- }
7886
- }
8359
+ // The ClassicBot rooms started in Phase 2, with the fleet.
7887
8360
  for (const name of Object.keys(fleet.instances)) {
7888
8361
  this.startStatuslineWatcher(name);
7889
8362
  }
@@ -7996,12 +8469,17 @@ export class FleetManager {
7996
8469
  // health server — so `systemctl start` returned success while the fleet was
7997
8470
  // still deaf: no path existed for a user message to arrive.
7998
8471
  sdNotify("READY=1");
8472
+ this.workingRecovery?.ready();
7999
8473
  // #2481: this version runs — the CLI's start-failure banner stops, and this is the version to go back to later.
8000
8474
  try {
8001
8475
  const { ownPackage, recordStarted } = await import("./start-failure.js");
8002
8476
  const version = ownPackage().version;
8003
8477
  if (version)
8004
8478
  recordStarted(this.dataDir, version, process.pid, Date.now());
8479
+ // #2481: a start completed — the failed tree an automatic restore kept for a report can go.
8480
+ const prefix = ownPackage().prefix;
8481
+ if (prefix)
8482
+ void (await import("./mac-auto-restore.js")).pruneFailedTrees(prefix).catch(() => { });
8005
8483
  }
8006
8484
  catch (err) {
8007
8485
  this.logger.debug({ err }, "fleet-started record not written");
@@ -8145,25 +8623,43 @@ export class FleetManager {
8145
8623
  if (channelConfigs.length === 0)
8146
8624
  return;
8147
8625
  // Start ALL adapters in parallel — any single failure doesn't block others.
8148
- const results = await Promise.allSettled(channelConfigs.map((cfg, i) => i === 0
8626
+ const settled = channelConfigs.map(() => false);
8627
+ // Each connection is recorded (connected, or retrying with its retry scheduled) as its own login settles.
8628
+ const recorded = channelConfigs.map((cfg, i) => (i === 0
8149
8629
  ? this.startSingleAdapter(fleet, cfg)
8150
- : this.startAdditionalAdapter(cfg)));
8151
- // Track state + schedule background retry for failures.
8152
- for (let i = 0; i < channelConfigs.length; i++) {
8153
- const adapterId = channelConfigs[i].id ?? channelConfigs[i].type;
8154
- if (results[i].status === "fulfilled") {
8155
- this.adapterState.set(adapterId, { status: "connected", retryCount: 0 });
8156
- }
8157
- else {
8158
- const err = results[i].reason;
8159
- this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
8160
- this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
8161
- this.scheduleAdapterRetry(adapterId);
8162
- // Notify admin via whichever adapter is already up
8163
- this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
8164
- }
8630
+ : this.startAdditionalAdapter(cfg)).then(() => ({ status: "fulfilled" }), (reason) => ({ status: "rejected", reason }))
8631
+ .then(result => { settled[i] = true; this.recordAdapterStartup(cfg, result); })
8632
+ .catch(err => this.logger.error({ err }, "Recording a chat connection's startup failed")));
8633
+ // #2642: bounded. Startup continues past a connection still logging in after this long (ClassicBot instances,
8634
+ // READY); that connection is recorded, or retried, when its login settles, and joins the fleet as it would anyway.
8635
+ let timer;
8636
+ const inTime = await Promise.race([
8637
+ Promise.all(recorded).then(() => true),
8638
+ new Promise(resolve => { timer = setTimeout(() => resolve(false), FleetManager.ADAPTER_STARTUP_WAIT_MS); timer.unref?.(); }),
8639
+ ]).finally(() => clearTimeout(timer));
8640
+ if (!inTime) {
8641
+ this.logger.warn({
8642
+ pending: channelConfigs.filter((_, i) => !settled[i]).map(cfg => cfg.id ?? cfg.type),
8643
+ waitedMs: FleetManager.ADAPTER_STARTUP_WAIT_MS,
8644
+ }, "Chat connection still connecting — fleet startup continues without waiting for it");
8165
8645
  }
8166
8646
  }
8647
+ /** How long fleet startup waits for its chat connections before it continues without the slow ones (#2642). */
8648
+ static ADAPTER_STARTUP_WAIT_MS = 60_000;
8649
+ /** Track a connection's startup outcome; a failure gets the background retry. */
8650
+ recordAdapterStartup(cfg, result) {
8651
+ const adapterId = cfg.id ?? cfg.type;
8652
+ if (result.status === "fulfilled") {
8653
+ this.adapterState.set(adapterId, { status: "connected", retryCount: 0 });
8654
+ return;
8655
+ }
8656
+ const err = result.reason;
8657
+ this.logger.error({ adapterId, err: err?.message ?? err }, "Adapter startup failed — scheduling background retry");
8658
+ this.adapterState.set(adapterId, { status: "retrying", retryCount: 0, lastError: err?.message ?? String(err) });
8659
+ this.scheduleAdapterRetry(adapterId);
8660
+ // Notify admin via whichever adapter is already up
8661
+ this.notifyAdapterFailure(adapterId, err?.message ?? String(err));
8662
+ }
8167
8663
  /** Keep trying while the fleet runs, with a five-minute cap even after a long outage. */
8168
8664
  scheduleAdapterRetry(adapterId) {
8169
8665
  const INITIAL_DELAY_MS = 5_000;
@@ -10666,6 +11162,7 @@ export class FleetManager {
10666
11162
  // overwritten with coordinates the target's adapter cannot address.
10667
11163
  ...(foreignWorld ? {} : { chat_id: reply_chat_id, thread_id: reply_thread_id }),
10668
11164
  user: "scheduler",
11165
+ source: "schedule",
10669
11166
  ...(adapterId ? { adapter_id: adapterId } : {}),
10670
11167
  },
10671
11168
  }, { waitForIdle: true, ...(retryCurrent ? { stillCurrent: retryCurrent } : {}) });
@@ -10842,7 +11339,7 @@ export class FleetManager {
10842
11339
  if (!ipc || !fleetRequestId)
10843
11340
  return;
10844
11341
  const type = String(msg.type);
10845
- const responseType = isCanvasWorkerTool(type.replace(/^fleet_/, "")) ? "fleet_web_theme_response"
11342
+ const responseType = isCanvasWorkerTool(type.replace(/^fleet_/, "")) || type === "fleet_office_view" || type === "fleet_office_set" ? "fleet_web_theme_response"
10846
11343
  : type.startsWith("fleet_schedule_") ? "fleet_schedule_response"
10847
11344
  : type.startsWith("fleet_decision_") ? "fleet_decision_response"
10848
11345
  : type === "fleet_task" ? "fleet_task_response"
@@ -10896,7 +11393,8 @@ export class FleetManager {
10896
11393
  || type === "fleet_get_web_appearance" || type === "fleet_preview_web_appearance"
10897
11394
  || type === "fleet_set_web_widget" || type === "fleet_remove_web_widget"
10898
11395
  || type === "fleet_set_web_ambient" || type === "fleet_clear_web_ambient"
10899
- || type === "fleet_export_web_mod" || type === "fleet_propose_web_mod") {
11396
+ || type === "fleet_export_web_mod" || type === "fleet_propose_web_mod"
11397
+ || type === "fleet_office_view" || type === "fleet_office_set") {
10900
11398
  this.handleWebTheme(name, msg);
10901
11399
  return;
10902
11400
  }
@@ -10917,7 +11415,8 @@ export class FleetManager {
10917
11415
  const result = tool === "get_web_appearance" || tool === "preview_web_appearance"
10918
11416
  ? this.appearanceTools.handle(instanceName, tool, args)
10919
11417
  : tool === "export_web_mod" || tool === "propose_web_mod" ? this.agentMods.handle(instanceName, tool, args)
10920
- : this.webAppearance.handleTool(instanceName, tool, args);
11418
+ : tool === "office_view" || tool === "office_set" ? this.officeActs.handle(instanceName, tool, args)
11419
+ : this.webAppearance.handleTool(instanceName, tool, args);
10921
11420
  ipc.send({ type: "fleet_web_theme_response", fleetRequestId, result });
10922
11421
  }
10923
11422
  catch (err) {
@@ -11353,6 +11852,8 @@ export class FleetManager {
11353
11852
  return this.appearanceTools.handle(instance, tool, args);
11354
11853
  if (tool === "export_web_mod" || tool === "propose_web_mod")
11355
11854
  return this.agentMods.handle(instance, tool, args);
11855
+ if (tool === "office_view" || tool === "office_set")
11856
+ return this.officeActs.handle(instance, tool, args); // #2578
11356
11857
  return this.webAppearance.handleTool(instance, tool, args);
11357
11858
  }
11358
11859
  async handlePreviewEmojisHttp(instance, args) {
@@ -11787,6 +12288,24 @@ export class FleetManager {
11787
12288
  return;
11788
12289
  if (!this.savedFleetConfigSnapshot)
11789
12290
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
12291
+ normalizeNoticeDelivery(this.fleetConfig);
12292
+ normalizeOfficeStorage(this.fleetConfig);
12293
+ explicitPatches = explicitPatches.flatMap(patch => {
12294
+ const path = patch.path;
12295
+ if (path.at(-2) === "dashboard" && path.at(-1) === "office" && !patch.remove) {
12296
+ return [{ ...patch, path: [...path.slice(0, -2), "office_access"] }, { path, remove: true }];
12297
+ }
12298
+ if (path.at(-1) === "dashboard" && patch.value && typeof patch.value === "object" && Object.hasOwn(patch.value, "office")) {
12299
+ const { office, ...dashboard } = patch.value;
12300
+ return [{ ...patch, value: dashboard }, { path: [...path.slice(0, -1), "office_access"], value: office, remove: office === null }];
12301
+ }
12302
+ // #2581's DM choice was withdrawn: an old string is just "on", posted in the topic, and its route is dropped.
12303
+ if (patch.path.join(".") === "web.notify_login" && patch.value === "dm")
12304
+ return [{ ...patch, value: true }, { path: ["web", "notice_delivery", "login"], remove: true }];
12305
+ if (patch.path.join(".") === "defaults.tips" && patch.value === "dm")
12306
+ return [{ ...patch, value: true }, { path: ["web", "notice_delivery", "tips"], remove: true }];
12307
+ return [patch];
12308
+ });
11790
12309
  // Re-read immediately before patching so an unrelated concurrent/manual
11791
12310
  // edit is retained. Invalid concurrent YAML is never overwritten.
11792
12311
  const source = existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "{}\n";
@@ -11862,9 +12381,46 @@ export class FleetManager {
11862
12381
  },
11863
12382
  });
11864
12383
  this.rawFleetConfig = loadRawFleetConfig(this.configPath);
12384
+ // The instance map is defaults-expanded. A defaults edit changes that view even when it writes no instance
12385
+ // leaves. Re-resolve from the committed YAML so inherited values cannot become stale overrides on the next save.
12386
+ // Keep the FleetConfig object itself: Settings and other request contexts hold it.
12387
+ const resolvedInstances = loadFleetConfig(this.configPath).instances;
12388
+ for (const [name, resolved] of Object.entries(resolvedInstances)) {
12389
+ const current = this.fleetConfig.instances[name];
12390
+ if (!current) {
12391
+ this.fleetConfig.instances[name] = resolved;
12392
+ continue;
12393
+ }
12394
+ // Lifecycle rollback fences hold these object identities across save. Refresh their values, not their owner.
12395
+ for (const key of Object.keys(current))
12396
+ if (!Object.hasOwn(resolved, key))
12397
+ delete current[key];
12398
+ Object.assign(current, resolved);
12399
+ }
11865
12400
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
12401
+ this.refreshSavedDashboardPermissions();
11866
12402
  this.logger.info({ path: this.configPath, strippedDefaults: redundantPaths.length }, "Saved fleet config (lossless patch)");
11867
12403
  }
12404
+ /** A permission tightening is effective when its save returns, independently of the later apply job or IPC. */
12405
+ refreshSavedDashboardPermissions() {
12406
+ if (!this.fleetConfig)
12407
+ return;
12408
+ const classicNames = new Set(this.classicChannels?.getAll().map(ch => ch.instanceName) ?? []);
12409
+ for (const [name, daemon] of this.daemons) {
12410
+ const next = this.fleetConfig.instances[name] ?? (classicNames.has(name) ? getEffectiveInstanceDefaults(this.fleetConfig.defaults) : undefined);
12411
+ const previous = daemon.getConfigSnapshot?.();
12412
+ if (!next || !previous)
12413
+ continue;
12414
+ if (isDeepStrictEqual(previous.dashboard, next.dashboard) && previous.office_access === next.office_access)
12415
+ continue;
12416
+ // Only permissions are adopted here. Cold fields retain the live daemon's old snapshot, so reconcile still
12417
+ // detects that they require a restart. MCP menus also watch the raw file; this updates the next spawn's env.
12418
+ daemon.applyConfigUpdate({ dashboard: next.dashboard ?? null, office_access: next.office_access ?? null });
12419
+ if (taskBoardSwitchedOn(previous.dashboard, next.dashboard)) {
12420
+ this.admitTaskBoardNotice(name, TASK_BOARD_ON_NOTICE, `task-board-on:${name}:${Date.now()}`);
12421
+ }
12422
+ }
12423
+ }
11868
12424
  /** Why the config written to `candidate` must not replace fleet.yaml, or null: it does not load, or it adds errors. */
11869
12425
  savedFleetConfigProblem(candidate) {
11870
12426
  let after;
@@ -11893,7 +12449,7 @@ export class FleetManager {
11893
12449
  logConfigWarningsAtStartup() {
11894
12450
  let warnings = [];
11895
12451
  try {
11896
- warnings = validateFleetConfig(this.rawFleetConfig).warnings;
12452
+ warnings = validateFleetConfig(this.rawFleetConfig, { rooms: readClassicRoomNames(this.dataDir) }).warnings;
11897
12453
  }
11898
12454
  catch (err) {
11899
12455
  this.logger.debug({ err }, "fleet.yaml validation failed (startup warnings)");
@@ -11902,7 +12458,44 @@ export class FleetManager {
11902
12458
  this.logger.warn({ path: w.path }, `fleet.yaml: ${w.path ? `${w.path}: ` : ""}${w.message}`);
11903
12459
  }
11904
12460
  /** One-time upgrade migration; invalid YAML is never rewritten. */
12461
+ migrateNoticeDeliveryStorage() {
12462
+ if (this.fleetConfig && this.configPath && (this.fleetConfig.web?.notify_login === "dm" || this.fleetConfig.defaults.tips === "dm"
12463
+ || this.fleetConfig.web?.notice_delivery !== undefined)
12464
+ && validateFleetConfig(this.rawFleetConfig).valid) {
12465
+ try {
12466
+ this.writeFleetConfigBackup(readFileSync(this.configPath, "utf8"));
12467
+ this.saveFleetConfig();
12468
+ this.logger.info("Notices no longer go by DM: stored them as topic booleans and dropped the old DM routing");
12469
+ }
12470
+ catch (err) {
12471
+ this.logger.warn({ err }, "Could not migrate DM notice routing; keeping the original file");
12472
+ }
12473
+ }
12474
+ }
12475
+ migrateOfficeStorage() {
12476
+ if (!this.fleetConfig || !this.configPath || !hasLegacyOffice(this.rawFleetConfig) || !validateFleetConfig(this.rawFleetConfig).valid)
12477
+ return;
12478
+ try {
12479
+ this.writeFleetConfigBackup(readFileSync(this.configPath, "utf8"));
12480
+ const patches = [];
12481
+ const raw = this.rawFleetConfig;
12482
+ for (const [path, item] of [[["defaults"], raw.defaults], ...Object.entries(raw.instances ?? {}).map(([name, value]) => [["instances", name], value])]) {
12483
+ if (!item?.dashboard || !Object.hasOwn(item.dashboard, "office"))
12484
+ continue;
12485
+ patches.push({ path: [...path, "dashboard", "office"], remove: true });
12486
+ if (!Object.hasOwn(item, "office_access") && typeof item.dashboard.office === "boolean")
12487
+ patches.push({ path: [...path, "office_access"], value: item.dashboard.office });
12488
+ }
12489
+ this.saveFleetConfig(patches);
12490
+ this.logger.info("Stored Office access outside dashboard for downgrade compatibility");
12491
+ }
12492
+ catch (err) {
12493
+ this.logger.warn({ err }, "Could not migrate Office access; keeping the original file");
12494
+ }
12495
+ }
11905
12496
  slimFleetConfigAtStartup() {
12497
+ this.migrateOfficeStorage();
12498
+ this.migrateNoticeDeliveryStorage();
11906
12499
  const redundantPaths = collectRedundantInstanceDefaultPaths(this.rawFleetConfig);
11907
12500
  if (redundantPaths.length === 0)
11908
12501
  return;
@@ -12125,6 +12718,7 @@ export class FleetManager {
12125
12718
  this.effectToolsService?.forget(name);
12126
12719
  this.mods.forgetInstance(name);
12127
12720
  this.agentMods.forget(name);
12721
+ this.officeActsInstance?.forget(name);
12128
12722
  }
12129
12723
  catch (err) {
12130
12724
  this.logger.warn({ err: err.message, name }, "Dropping a removed instance's web theme failed");
@@ -13931,6 +14525,8 @@ export class FleetManager {
13931
14525
  return true;
13932
14526
  if (await this.handleSettingsChangeCallback(data, adapterId, adapter))
13933
14527
  return true;
14528
+ if (await this.handleKiroMigrateCallback(data, adapterId, adapter))
14529
+ return true;
13934
14530
  if (await this.handleDashboardCallback(data, adapterId, adapter))
13935
14531
  return true;
13936
14532
  if (await this.handleClassicApproval(data, adapterId, adapter))
@@ -14051,6 +14647,7 @@ export class FleetManager {
14051
14647
  const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
14052
14648
  name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
14053
14649
  claudeConfigDir: this.claudeConfigDirOf(name), profile: this.usageProfileOf(name, this.backendNameOf(name)),
14650
+ kiroDbPath: this.kiroDbPathOf(name, this.backendNameOf(name)),
14054
14651
  }));
14055
14652
  const taken = new Set(fleet.map(i => i.name));
14056
14653
  const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
@@ -14060,6 +14657,7 @@ export class FleetManager {
14060
14657
  ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
14061
14658
  claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
14062
14659
  profile: this.usageProfileOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
14660
+ kiroDbPath: this.kiroDbPathOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
14063
14661
  }));
14064
14662
  return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
14065
14663
  }
@@ -14080,8 +14678,19 @@ export class FleetManager {
14080
14678
  return this.credentialProfileOf(name, "claude-code") ?? "default";
14081
14679
  if (backend === "codex")
14082
14680
  return "unrecorded";
14681
+ if (backend === "kiro-cli")
14682
+ return this.credentialProfileOf(name, "kiro-cli") ?? "default";
14683
+ if (backend === "grok" || backend === "muse" || backend === "opencode" || backend === "antigravity")
14684
+ return "default";
14083
14685
  return undefined;
14084
14686
  }
14687
+ /** #2122 §10: a Kiro instance with a credential profile writes its classic store in that profile's home. */
14688
+ kiroDbPathOf(name, backend) {
14689
+ if (backend !== "kiro-cli")
14690
+ return undefined;
14691
+ const profile = this.credentialProfileOf(name, "kiro-cli");
14692
+ return profile ? join(credentialProfileStoreHome(getAgendHome(), "kiro-cli", profile), "data.sqlite3") : undefined;
14693
+ }
14085
14694
  /** #2122: the fleet's day boundary zone — defaults.scheduler.default_timezone, else this host's. */
14086
14695
  usageZone() {
14087
14696
  return this.fleetConfig?.defaults?.scheduler?.default_timezone?.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
@@ -14124,6 +14733,19 @@ export class FleetManager {
14124
14733
  rolloutsUnreadable: root => sharedRolloutIndex(root).unreadable,
14125
14734
  metaPath: join(this.dataDir, "cache-codex-rollouts.json"),
14126
14735
  zone: () => this.usageZone(),
14736
+ // #2122 §10: Kiro's shared classic store (XDG_DATA_HOME, as kiro-cli resolves it) and its KIRO_HOME.
14737
+ kiroDbPath: () => join(process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share"), "kiro-cli", "data.sqlite3"),
14738
+ kiroHome: () => kiroHome(),
14739
+ // #2122 §10 step 3: grok keeps its sessions under ~/.grok (grok.ts reads them there too).
14740
+ grokSessionsDir: () => join(homedir(), ".grok", "sessions"),
14741
+ // #2122 §10 step 4: muse keeps sessions under $XDG_DATA_HOME/muse (else ~/.local/share/muse), as muse.ts reads them.
14742
+ // #2122 §10 step 5: OpenCode's database, under XDG_DATA_HOME as opencode resolves it.
14743
+ // #2122 §10 step 6: agy's conversations, under ~/.gemini as the antigravity backend reads its settings.
14744
+ antigravityDir: () => join(homedir(), ".gemini", "antigravity-cli", "conversations"),
14745
+ // The version AgEnD's CLI probe last recorded for agy: its cache file, whatever its age (never runs agy).
14746
+ antigravityVersion: () => this.lastRecordedCliVersion("antigravity"),
14747
+ opencodeDbPath: () => join(process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share"), "opencode", "opencode.db"),
14748
+ museSessionsDir: () => join(process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share"), "muse", "sessions"),
14127
14749
  log: (level, msg, extra) => this.logger[level](extra ?? {}, msg),
14128
14750
  });
14129
14751
  }
@@ -14244,6 +14866,7 @@ export class FleetManager {
14244
14866
  return this.deliveryOutbox.acknowledge(deliveryId, by);
14245
14867
  },
14246
14868
  tasks: () => this.needsYouTaskItems(),
14869
+ backendSignIns: () => this.needsYouBackendSignIns(),
14247
14870
  taskAction: action => this.taskActionFromChat(action),
14248
14871
  settings: () => ({
14249
14872
  liveMessage: this.fleetConfig?.needs_you?.live_message !== false,
@@ -14760,7 +15383,7 @@ export class FleetManager {
14760
15383
  ...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
14761
15384
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
14762
15385
  authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
14763
- policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now: Date.now(),
15386
+ policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now: Date.now(),
14764
15387
  });
14765
15388
  }
14766
15389
  sessionAuthorizations = null;
@@ -14785,13 +15408,13 @@ export class FleetManager {
14785
15408
  session, gateway, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
14786
15409
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
14787
15410
  authorization: { handle: session.handle, owner: session.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
14788
- policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
15411
+ policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
14789
15412
  });
14790
15413
  if (check.refusal)
14791
15414
  return { ok: false, refusal: check.refusal };
14792
15415
  const live = new Set(this.webSessions?.list(undefined, () => true).map(r => r.handle) ?? []);
14793
15416
  store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
14794
- return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs) };
15417
+ return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs, peer) };
14795
15418
  }
14796
15419
  authorityStateStore = null;
14797
15420
  /**
@@ -14800,13 +15423,24 @@ export class FleetManager {
14800
15423
  */
14801
15424
  webAuthorityState() {
14802
15425
  if (!this.dataDir)
14803
- return { enabled: false, loopbackOnly: true, namedLink: false, epoch: 0 };
15426
+ return { enabled: false, loopbackOnly: true, namedLink: false, quickLink: false, epoch: 0 };
14804
15427
  this.authorityStateStore ??= new AuthorityStateStore(join(this.dataDir, "web-authority.json"), this.logger, undefined, undefined, () => { this.ensureDeliveryOutbox?.(); return this.deliveryOutbox?.maxWebAuthorityEpoch() ?? 0; });
14805
15428
  const state = this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
14806
- // #2453 (§0.2): the named-link option seen going off — the authority of every session over the link ends now, for
14807
- // good: their authorizations go (a later "on" can never bring one back without its account's Confirm).
14808
- if (state.namedLinkClosed)
14809
- this.sessionAuthorizationStore()?.prune(a => this.webSessions?.findByHandle(a.handle)?.surface === "local");
15429
+ // #2453 (§0.2) / §0.3: a link's option seen going off — the authority of every session over that link ends now, for
15430
+ // good: their authorizations go (a later "on" can never bring one back without its account's Confirm). A record from
15431
+ // before §0.3 has no peer: over the gateway, only a named-link session could have been authorized then.
15432
+ if (state.namedLinkClosed || state.quickLinkClosed) {
15433
+ const ended = (a) => {
15434
+ const session = this.webSessions?.findByHandle(a.handle);
15435
+ if (!session)
15436
+ return true;
15437
+ if (session.surface === "local")
15438
+ return false;
15439
+ const peer = a.peer ?? "named_link";
15440
+ return (!!state.namedLinkClosed && peer === "named_link") || (!!state.quickLinkClosed && peer === "quick_link");
15441
+ };
15442
+ this.sessionAuthorizationStore()?.prune(a => !ended(a));
15443
+ }
14810
15444
  return state;
14811
15445
  }
14812
15446
  /**
@@ -14839,7 +15473,8 @@ export class FleetManager {
14839
15473
  .filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
14840
15474
  .map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
14841
15475
  until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry),
14842
- ...(sessions.get(a.handle).surface === "gateway" ? { named: true } : {}) })) : [];
15476
+ // Over a link: the temporary one (§0.3), else the named one (a record from before §0.3 has no peer).
15477
+ ...(sessions.get(a.handle).surface === "gateway" ? (a.peer === "quick_link" ? { quick: true } : { named: true }) : {}) })) : [];
14843
15478
  const host = this.fleetConfig?.web?.public_link?.hostname;
14844
15479
  return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized, namedHost: typeof host === "string" && host ? host : null };
14845
15480
  }
@@ -14854,9 +15489,17 @@ export class FleetManager {
14854
15489
  const adapterOf = (adapterId) => this.adapters.get(adapterId) ?? this.worlds.get(adapterId)?.adapter;
14855
15490
  // The host's clock, with its zone: the person may read the DM on a phone in another one.
14856
15491
  const when = (ms) => new Date(ms).toLocaleString(undefined, { timeZoneName: "short" });
15492
+ const quickHost = () => { try {
15493
+ const u = this.publicWebLink?.openSignin()?.url;
15494
+ return u ? new URL(u).host : "?";
15495
+ }
15496
+ catch {
15497
+ return "?";
15498
+ } };
14857
15499
  const where = (peer) => peer === "named_link"
14858
15500
  ? t("web_auth.peer_named_link", String(this.fleetConfig?.web?.public_link?.hostname ?? "?")) // §0.2: the internet
14859
- : t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
15501
+ : peer === "quick_link" ? t("web_auth.peer_quick_link", quickHost()) // §0.3: the internet
15502
+ : t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
14860
15503
  return this.authorizationRequests = new SessionAuthorizationRequests({
14861
15504
  now: () => performance.now(), wallNow: () => Date.now(),
14862
15505
  maxSessionMs: () => webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs,
@@ -14890,8 +15533,8 @@ export class FleetManager {
14890
15533
  const record = this.webSessions?.findByHandle(handle);
14891
15534
  if (!record)
14892
15535
  return { ok: false, refusal: "session_gone" };
14893
- // §0.2: a named-link session is judged as the gateway request it is (its exposure still the current one).
14894
- const r = this.authorizeWebSession(record, peer, peer === "named_link");
15536
+ // §0.2 / §0.3: a session over a link is judged as the gateway request it is (its exposure still the current one).
15537
+ const r = this.authorizeWebSession(record, peer, peer === "named_link" || peer === "quick_link");
14895
15538
  // What the card says: the end of the authorization, or the session's own end when that comes first.
14896
15539
  return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
14897
15540
  },
@@ -14937,7 +15580,7 @@ export class FleetManager {
14937
15580
  session: s, gateway: input.gateway, peer: input.peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
14938
15581
  isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
14939
15582
  authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
14940
- policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink }, epoch: state.epoch, now,
15583
+ policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly, namedLink: state.namedLink, quickLink: state.quickLink }, epoch: state.epoch, now,
14941
15584
  });
14942
15585
  return r.refusal;
14943
15586
  }
@@ -16228,6 +16871,55 @@ export class FleetManager {
16228
16871
  }
16229
16872
  return this.loginController;
16230
16873
  }
16874
+ browserBackendSignInsInstance = null;
16875
+ structuredLoginCleanupFailed = false;
16876
+ pendingBackendSince = new Map();
16877
+ browserSignInOwnerCurrent(owner) {
16878
+ const record = owner;
16879
+ return !this.shuttingDown && typeof record.handle === "string" && this.webSessions?.findByHandle(record.handle) === owner
16880
+ && this.isLocalAdminSession(record.handle);
16881
+ }
16882
+ /** #2599: default-account controls share the exact writer reservation with chat/profile/install. */
16883
+ get browserBackendSignIns() {
16884
+ return this.browserBackendSignInsInstance ??= new BrowserBackendSignIns({
16885
+ dataDir: this.dataDir, lock: this.loginWindow,
16886
+ current: owner => this.browserSignInOwnerCurrent(owner),
16887
+ installed: async () => ONBOARDING_BACKENDS.filter(backend => this.isCliInstalled(backend)),
16888
+ checkAuth: backend => checkAuthStatus(LOGIN_FLOWS[backend].authCheck),
16889
+ checkConsoleAuth: () => checkAuthStatus(LOGIN_FLOWS.antigravity.authCheck),
16890
+ transport: () => createStructuredSignInTransport(),
16891
+ changed: (event, owner, launched) => {
16892
+ try {
16893
+ this.eventLog?.insert("login", "browser_backend_signin", event);
16894
+ }
16895
+ catch { /* safe metadata only */ }
16896
+ broadcastSseEvent(this.sseClients, "backend_login", {}, err => this.logger.debug({ err }, "backend_login event failed"));
16897
+ this.needsYou?.poke();
16898
+ if (!this.browserSignInOwnerCurrent(owner))
16899
+ return;
16900
+ if (event.phase === "starting") {
16901
+ const record = owner;
16902
+ this.notifyFleetError(t("login.web_dashboard_notice", event.backend, record.label, record.handle.slice(0, 8)));
16903
+ }
16904
+ if (event.phase === "done") {
16905
+ void this.backendAvailability.refresh([event.backend], { force: true }).catch(() => { });
16906
+ if (launched)
16907
+ void this.recoverBackendInstances(event.backend, undefined, null).catch(() => this.logger.warn({ backend: event.backend }, "Post sign-in recovery failed"));
16908
+ }
16909
+ },
16910
+ });
16911
+ }
16912
+ needsYouBackendSignIns() {
16913
+ const pending = readPendingBackendSignIns(this.dataDir).pending;
16914
+ for (const backend of this.pendingBackendSince?.keys() ?? [])
16915
+ if (!pending.includes(backend))
16916
+ this.pendingBackendSince.delete(backend);
16917
+ return pending.map(backend => {
16918
+ if (!this.pendingBackendSince.has(backend))
16919
+ this.pendingBackendSince.set(backend, Date.now());
16920
+ return { backend, since: this.pendingBackendSince.get(backend), browser: structuredSignInSupported(backend) };
16921
+ });
16922
+ }
16231
16923
  /** #2233: sign-ins started from the web dashboard (local admin sessions), on the same controller as chat's. */
16232
16924
  webBackendLoginsInstance = null;
16233
16925
  get webBackendLogins() {
@@ -16580,6 +17272,14 @@ export class FleetManager {
16580
17272
  // "cancelled" is the detail both legacy onDone handlers keep quiet about —
16581
17273
  // a stopping fleet must not announce "login failed — fleet shutdown".
16582
17274
  await Promise.all([
17275
+ bounded(this.browserBackendSignInsInstance?.close().then(confirmed => {
17276
+ if (confirmed)
17277
+ this.browserBackendSignInsInstance = null;
17278
+ else {
17279
+ this.structuredLoginCleanupFailed = true;
17280
+ this.logger.error("Structured login cleanup failed; sign-in admission remains closed until the host is restarted.");
17281
+ }
17282
+ }), "browser-controls"),
16583
17283
  bounded(this.loginController?.shutdown(), "web-login"),
16584
17284
  bounded(this.activeInstall?.session.cancel("cancelled"), "install"),
16585
17285
  ]);
@@ -16596,6 +17296,13 @@ export class FleetManager {
16596
17296
  this.installHandoff = null;
16597
17297
  return t("login.cancelled", backend);
16598
17298
  }
17299
+ const browserLogin = this.browserBackendSignInsInstance, browserBackend = browserLogin?.activeBackend;
17300
+ if (browserLogin && browserBackend) {
17301
+ const confirmed = await browserLogin.close();
17302
+ if (confirmed)
17303
+ this.browserBackendSignInsInstance = null;
17304
+ return confirmed ? t("login.cancelled", browserBackend) : t("login.browser_cleanup_failed");
17305
+ }
16599
17306
  if (this.loginController?.isActive())
16600
17307
  return this.loginController.cancel();
16601
17308
  return t("login.no_session");
@@ -16767,7 +17474,7 @@ export class FleetManager {
16767
17474
  // session only judges that verdict; it does not read the output for URLs or prompts.
16768
17475
  const flow = {
16769
17476
  backend,
16770
- command: info.install,
17477
+ command: info.unattended ?? info.install,
16771
17478
  successPattern: /$^/,
16772
17479
  timeoutMs: 10 * 60 * 1000,
16773
17480
  };
@@ -17764,7 +18471,15 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17764
18471
  const isPrimaryAdapter = !mentionWorld || mentionWorld.adapter === this.adapter;
17765
18472
  const adapterBotUserId = mentionWorld?.botUserId ?? (isPrimaryAdapter ? this.botUserId : undefined);
17766
18473
  const mentionTag = adapterBotUserId ? `<@${adapterBotUserId}>` : null;
17767
- const isMentioned = mentionTag && text.includes(mentionTag);
18474
+ // #2551: a native reply to one of THIS bot's messages is addressed to it, as a mention is (a Discord reply carries
18475
+ // its ping in the reference, not the text). One hop only: a reply to a message of ours that itself answered a bot
18476
+ // (or the sender) is a chain of bots answering each other — a mention still carries it on. A reply to our answer
18477
+ // to a person is the ordinary handoff, and is addressed.
18478
+ const replyTriggers = this.classicChannels?.getReplyTriggers(channelId, msg.adapterId, this.fleetConfig?.defaults?.reply_triggers) ?? DEFAULT_REPLY_TRIGGERS;
18479
+ const repliesToUs = replyTriggers && !!adapterBotUserId && msg.replyToAuthorId === adapterBotUserId && msg.replyToChain !== true;
18480
+ // Snapshot/quoted content is context, never the forwarder's own mention.
18481
+ const addressText = msg.source === "discord" ? (msg.humanOriginalBody ?? text) : text;
18482
+ const isMentioned = (mentionTag && addressText.includes(mentionTag)) || repliesToUs;
17768
18483
  if (!isMentioned) {
17769
18484
  // Bare attachment (no @mention) — already saved above; just acknowledge.
17770
18485
  if (saved) {
@@ -18099,6 +18814,19 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
18099
18814
  catch { /* missing / stale / corrupt */ }
18100
18815
  return null;
18101
18816
  }
18817
+ /**
18818
+ * #2122 §10: the version the CLI probe last recorded for a backend — its cache file read whatever its age (the usage
18819
+ * reader's version gate needs the last known version, not a fresh one); null when none was recorded. Never a probe.
18820
+ */
18821
+ lastRecordedCliVersion(backend) {
18822
+ try {
18823
+ const v = JSON.parse(readFileSync(this.cliEnvPath(backend), "utf-8")).version;
18824
+ return typeof v === "string" && v.trim() ? v.trim() : null;
18825
+ }
18826
+ catch {
18827
+ return null;
18828
+ }
18829
+ }
18102
18830
  /** Return cached CLI versions immediately. Refresh is started after /sysinfo is sent. */
18103
18831
  getBackendCliVersionSnapshot() {
18104
18832
  const snapshot = {};
@@ -20033,6 +20761,23 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20033
20761
  }
20034
20762
  tmuxExecHelperTimer = null;
20035
20763
  stopAllInFlight = null;
20764
+ /** Signal-handler continuation, after successful graceful shutdown only. */
20765
+ noteRequestedShutdown() {
20766
+ const progress = readUpdateProgress(this.dataDir)?.progress;
20767
+ // Host CLI updates carry a generic marker with no chat progress target.
20768
+ const update = progress ? updateProgressOperation(progress) === "update"
20769
+ && ["installed", "stopping", "starting"].includes(progress.stage) : isUpdateInProgress(this.dataDir);
20770
+ this.recordWorkingRestartIntent(update ? "update" : "manual_restart");
20771
+ }
20772
+ recordWorkingRestartIntent(cause) {
20773
+ this.workingRecoveryCause = cause;
20774
+ try {
20775
+ this.deliveryOutbox?.workingRecovery.requested(this.managerBootId, cause);
20776
+ }
20777
+ catch {
20778
+ this.logger.warn("Could not record requested restart; next recovery will report an unknown interruption");
20779
+ }
20780
+ }
20036
20781
  /** Stops release memory. Reassess between batches without waiting for recovery. */
20037
20782
  async stopInstancesInBatches(entries, stop) {
20038
20783
  let index = 0;
@@ -20050,15 +20795,25 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20050
20795
  }
20051
20796
  }
20052
20797
  async doStopAll() {
20798
+ // The scheduler closes before individual daemons stop. Preserve the owned
20799
+ // task/reply boundary while those stores are still available, not a later
20800
+ // shutdown snapshot whose unavailable sources cannot authorize recovery.
20801
+ for (const name of this.daemons.keys())
20802
+ this.observeWorkingState(name, "before_stop");
20053
20803
  this.startupMemoryDeadline?.stop();
20054
20804
  this.startupMemoryDeadline = null;
20055
20805
  this.startupComplete = false;
20806
+ this.workingRecovery?.reset();
20056
20807
  this.startupDashboard = null;
20057
20808
  this.reloadPending = false;
20058
20809
  // Before anything is stopped: everything that dies from here on dies
20059
20810
  // because we asked it to. Set synchronously — doStopAll runs to its first
20060
20811
  // await in the same tick as the signal handler, so no event can slip in.
20061
20812
  this.shuttingDown = true;
20813
+ this.workingRecovery?.reset();
20814
+ if (this.workingRecoveryTimer)
20815
+ clearInterval(this.workingRecoveryTimer);
20816
+ this.workingRecoveryTimer = null;
20062
20817
  if (this.updateProgressTimer) {
20063
20818
  clearInterval(this.updateProgressTimer);
20064
20819
  this.updateProgressTimer = null;
@@ -20071,6 +20826,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20071
20826
  const settingsControlStopped = settingsControl?.close();
20072
20827
  this.publicLinkAutoOpen?.stop();
20073
20828
  const publicStopped = this.publicWebLink?.close("fleet shutdown");
20829
+ const sharesStopped = this.officeSharesInstance?.close(); // #2636: every Office share ends, and its tunnel
20074
20830
  // #1386: every live "Needs you" message says the fleet stopped (capabilities revoked first) — while the
20075
20831
  // adapters can still edit. Bounded: a platform that does not answer must not hold the shutdown.
20076
20832
  const needsStopped = this.stopNeedsYou();
@@ -20109,6 +20865,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20109
20865
  // timer and HTTP listener living in THIS process: without an explicit
20110
20866
  // shutdown it would outlive us as an owner-less login CLI (sol B3).
20111
20867
  await publicStopped;
20868
+ await sharesStopped?.catch(() => { });
20112
20869
  await profileStopped;
20113
20870
  await needsStopped;
20114
20871
  await settingsControlStopped;
@@ -20461,6 +21218,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20461
21218
  // mark every row done and tell the user "changes applied".
20462
21219
  return { rejected: why };
20463
21220
  }
21221
+ this.migrateOfficeStorage();
21222
+ this.migrateNoticeDeliveryStorage();
20464
21223
  if (canvasWorkerEnabled(oldConfig?.web) !== this.canvasWorkerEnabled()) {
20465
21224
  this.canvasWorkerSettingChanged();
20466
21225
  }
@@ -20738,6 +21497,27 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20738
21497
  fleetSignatureMismatchKeys() {
20739
21498
  return this.fleetSignatureMismatch;
20740
21499
  }
21500
+ /**
21501
+ * #2347 restart flow: saved changes that take effect only after AgEnD restarts — the startup-only keys fleet.yaml
21502
+ * now differs on, and the newest apply job whose fleet row can carry the restart (`job_id`, null when none can: a
21503
+ * hand edit, or a job past its retention). Null when nothing is pending, and when a restart could not clear it
21504
+ * (the signature mismatch the panel reports instead). The same test requestSettingsSelfRestart makes.
21505
+ */
21506
+ settingsRestartPending() {
21507
+ if (this.appliedFleetLevel === null || this.fleetSignatureMismatch)
21508
+ return null;
21509
+ const next = this.nextFleetConfig();
21510
+ if (this.appliedFleetLevel === this.fleetLevelSignature(next))
21511
+ return null;
21512
+ let job = null;
21513
+ // A job whose restart was asked for already has its row in flight ("running"), so it is never offered again.
21514
+ for (const candidate of this.applyJobs.all()) {
21515
+ const row = candidate.targets.find(item => item.target === APPLY_FLEET_TARGET);
21516
+ if (row?.status === "restart-required" && (!job || candidate.startedAt >= job.startedAt))
21517
+ job = candidate;
21518
+ }
21519
+ return { keys: fleetLevelDifferences(this.startupFleetConfig, next), job_id: job ? job.id : null };
21520
+ }
20741
21521
  /**
20742
21522
  * Restart AgEnD itself on behalf of a Settings apply.
20743
21523
  *
@@ -21988,6 +22768,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
21988
22768
  this.logger.info("No instances to restart");
21989
22769
  return;
21990
22770
  }
22771
+ this.workingRecoveryGroup = randomUUID();
22772
+ this.recordWorkingRestartIntent("manual_restart");
22773
+ this.workingRecovery?.reset();
21991
22774
  this.logger.info(`Graceful restart: waiting for ${instanceNames.length} instances to idle...`);
21992
22775
  const groupId = this.fleetConfig?.channel?.group_id;
21993
22776
  const generalName = this.findGeneralInstance();
@@ -22061,34 +22844,32 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22061
22844
  }
22062
22845
  // General is ready again; now its topic can own the live progress message.
22063
22846
  await restartProgress.start(progressTarget);
22064
- if (restartOthers.length > 0) {
22065
- await this.startInstancesWithConcurrency(restartOthers, topicMode, () => restartProgress.markReady());
22847
+ // The ClassicBot rooms (killed during orphan cleanup) restart with the fleet instances, in the same queue; their
22848
+ // open-lists are refreshed first so a room's inbound is never dropped while it starts.
22849
+ const restartClassic = topicMode ? this.classicChannels?.getAll() ?? [] : [];
22850
+ if (restartClassic.length > 0)
22851
+ this.reregisterClassicChannels();
22852
+ if (restartOthers.length > 0 || restartClassic.length > 0) {
22853
+ await this.startInstancesWithConcurrency(restartOthers, topicMode, () => restartProgress.markReady(), restartClassic);
22066
22854
  }
22067
22855
  if (topicMode) {
22068
22856
  this.routing.rebuild(this.fleetConfig);
22069
22857
  this.reregisterClassicChannels();
22070
22858
  // startInstance already calls connectIpcToInstance, no need for connectToInstances here
22071
- // Restart classic channel instances (killed during orphan cleanup)
22072
- if (this.classicChannels) {
22073
- const fleetBackend = this.fleetConfig?.defaults?.backend;
22074
- const channels = this.classicChannels.getAll()
22075
- .filter(ch => !this.lifecycle.isPaused(ch.instanceName));
22076
- const concurrency = 3;
22077
- let idx = 0;
22078
- while (idx < channels.length) {
22079
- const batch = channels.slice(idx, idx + concurrency);
22080
- await Promise.allSettled(batch.map(async (ch) => {
22081
- if (await this.startClassicInstanceUnattended(ch, "classic instance"))
22082
- restartProgress.markReady();
22083
- }));
22084
- idx += concurrency;
22085
- }
22086
- }
22087
22859
  for (const name of Object.keys(fleet.instances)) {
22088
22860
  this.startStatuslineWatcher(name);
22089
22861
  }
22090
22862
  }
22863
+ this.workingRecovery?.ready();
22091
22864
  this.logger.info("Graceful restart complete");
22865
+ // The in-process interval keeps its label, but a later unrelated process
22866
+ // crash must not inherit this completed request as its shutdown intent.
22867
+ try {
22868
+ this.deliveryOutbox?.workingRecovery.clearRequested(this.managerBootId);
22869
+ }
22870
+ catch {
22871
+ this.logger.warn("Could not clear completed in-process restart intent");
22872
+ }
22092
22873
  const configuredNames = this.configuredStartupInstanceNames(fleet, topicMode);
22093
22874
  const total = configuredNames.length;
22094
22875
  const started = configuredNames.filter(name => this.daemons.has(name)).length;
@@ -22143,6 +22924,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22143
22924
  }
22144
22925
  const ipc = this.instanceIpcClients.get(name);
22145
22926
  if (ipc?.connected) {
22927
+ daemon?.noteLegacyRestartReminder?.();
22146
22928
  ipc.send({
22147
22929
  type: "fleet_inbound",
22148
22930
  content: msg,
@@ -22290,6 +23072,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22290
23072
  const guests = this.fleetConfig?.web?.guests;
22291
23073
  if (!owner || guests?.enabled !== true)
22292
23074
  return NO_GUEST_SCOPE;
23075
+ // #2348: explicit grants, once the key exists, are the whole scope — no narrowing on top (a grant is already exact).
23076
+ if (guests.grants !== undefined) {
23077
+ const granted = this.guestReach(owner);
23078
+ return granted.size ? granted : NO_GUEST_SCOPE;
23079
+ }
22293
23080
  const scope = new Set(this.guestReach(owner));
22294
23081
  const narrow = guests.narrow && Object.hasOwn(guests.narrow, `${owner.adapterId}:${owner.userId}`) ? guests.narrow[`${owner.adapterId}:${owner.userId}`] : undefined;
22295
23082
  if (narrow) {
@@ -22310,6 +23097,20 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22310
23097
  const scope = new Set();
22311
23098
  if (guests?.enabled !== true)
22312
23099
  return scope;
23100
+ // #2348: with explicit grants, what an admin granted this account — of the instances that exist now (fleet.yaml's, or
23101
+ // a ClassicBot room's) — and nothing else: no connection access, no room membership, no open_instances.
23102
+ if (guests.grants !== undefined) {
23103
+ const key = `${owner.adapterId}:${owner.userId}`;
23104
+ const grant = Object.hasOwn(guests.grants, key) ? guests.grants[key] : undefined;
23105
+ if (!grant || !Array.isArray(grant.instances))
23106
+ return scope;
23107
+ const rooms = new Set((this.classicChannels?.getAll() ?? []).map((r) => r.instanceName));
23108
+ for (const name of grant.instances) {
23109
+ if ((this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, name)) || rooms.has(name))
23110
+ scope.add(name);
23111
+ }
23112
+ return scope;
23113
+ }
22313
23114
  const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
22314
23115
  const world = this.worlds.get(owner.adapterId);
22315
23116
  if (world && world.accessManager.isAllowed(owner.userId)) {
@@ -22351,7 +23152,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
22351
23152
  const owner = record.owner;
22352
23153
  if (this.guestScope(owner).size)
22353
23154
  return "ok";
22354
- if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
23155
+ // #2348: with explicit grants no room's membership can give anything: nothing granted is gone, not pending. Nor does
23156
+ // a connection's start matter: grants are config (and ClassicBot rooms), read without the adapter.
23157
+ if (owner && this.fleetConfig?.web?.guests?.enabled === true && this.fleetConfig.web.guests.grants === undefined) {
22355
23158
  // Before adapters initialize, topic scope is unknown, not revoked. Keep the returning cookie but grant no
22356
23159
  // request until its configured world exists. Removed connections and initialized denied access stay final.
22357
23160
  const channels = this.fleetConfig.channels ?? (this.fleetConfig.channel ? [this.fleetConfig.channel] : []);