@songsid/agend 2.3.0-beta.1 → 2.3.0-beta.11

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 (357) hide show
  1. package/dist/agent-cli-instructions.md +5 -2
  2. package/dist/agent-cli.js +43 -7
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-endpoint.js +4 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-file-read.d.ts +14 -6
  7. package/dist/agent-file-read.js +207 -19
  8. package/dist/agent-file-read.js.map +1 -1
  9. package/dist/agent-whats-new.d.ts +11 -0
  10. package/dist/agent-whats-new.js +29 -15
  11. package/dist/agent-whats-new.js.map +1 -1
  12. package/dist/auth-api.d.ts +30 -1
  13. package/dist/auth-api.js +45 -13
  14. package/dist/auth-api.js.map +1 -1
  15. package/dist/authority/authority-state.d.ts +38 -0
  16. package/dist/authority/authority-state.js +114 -0
  17. package/dist/authority/authority-state.js.map +1 -0
  18. package/dist/authority/session-authorizations.d.ts +43 -0
  19. package/dist/authority/session-authorizations.js +102 -0
  20. package/dist/authority/session-authorizations.js.map +1 -0
  21. package/dist/authority/web-authority.d.ts +120 -0
  22. package/dist/authority/web-authority.js +157 -0
  23. package/dist/authority/web-authority.js.map +1 -0
  24. package/dist/backend/claude-code.d.ts +2 -2
  25. package/dist/backend/claude-code.js +10 -5
  26. package/dist/backend/claude-code.js.map +1 -1
  27. package/dist/backend/types.d.ts +14 -0
  28. package/dist/backend/types.js +25 -0
  29. package/dist/backend/types.js.map +1 -1
  30. package/dist/backend-availability.d.ts +120 -0
  31. package/dist/backend-availability.js +396 -0
  32. package/dist/backend-availability.js.map +1 -0
  33. package/dist/backend-command.d.ts +21 -3
  34. package/dist/backend-command.js +23 -4
  35. package/dist/backend-command.js.map +1 -1
  36. package/dist/bundled-skills.d.ts +14 -0
  37. package/dist/bundled-skills.js +29 -0
  38. package/dist/bundled-skills.js.map +1 -0
  39. package/dist/cache-analysis.d.ts +47 -1
  40. package/dist/cache-analysis.js +68 -8
  41. package/dist/cache-analysis.js.map +1 -1
  42. package/dist/cache-ledger.d.ts +16 -3
  43. package/dist/cache-ledger.js +33 -13
  44. package/dist/cache-ledger.js.map +1 -1
  45. package/dist/cache-report-tool.d.ts +17 -0
  46. package/dist/cache-report-tool.js +40 -0
  47. package/dist/cache-report-tool.js.map +1 -0
  48. package/dist/cache-service.d.ts +70 -3
  49. package/dist/cache-service.js +178 -17
  50. package/dist/cache-service.js.map +1 -1
  51. package/dist/channel/adapters/discord.d.ts +7 -0
  52. package/dist/channel/adapters/discord.js +35 -0
  53. package/dist/channel/adapters/discord.js.map +1 -1
  54. package/dist/channel/mcp-tools.js +22 -10
  55. package/dist/channel/mcp-tools.js.map +1 -1
  56. package/dist/channel/types.d.ts +6 -0
  57. package/dist/classic-channel-manager.d.ts +10 -5
  58. package/dist/classic-channel-manager.js +12 -9
  59. package/dist/classic-channel-manager.js.map +1 -1
  60. package/dist/cli.js +130 -31
  61. package/dist/cli.js.map +1 -1
  62. package/dist/config-validator.js +103 -4
  63. package/dist/config-validator.js.map +1 -1
  64. package/dist/daemon.d.ts +7 -0
  65. package/dist/daemon.js +63 -16
  66. package/dist/daemon.js.map +1 -1
  67. package/dist/decision-list.d.ts +25 -0
  68. package/dist/decision-list.js +30 -0
  69. package/dist/decision-list.js.map +1 -0
  70. package/dist/delivery-outbox.d.ts +10 -0
  71. package/dist/delivery-outbox.js +12 -1
  72. package/dist/delivery-outbox.js.map +1 -1
  73. package/dist/fleet-context.d.ts +10 -0
  74. package/dist/fleet-manager.d.ts +417 -11
  75. package/dist/fleet-manager.js +1387 -109
  76. package/dist/fleet-manager.js.map +1 -1
  77. package/dist/fleet-traffic.d.ts +74 -0
  78. package/dist/fleet-traffic.js +196 -0
  79. package/dist/fleet-traffic.js.map +1 -0
  80. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +20 -5
  81. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +6 -1
  82. package/dist/general-knowledge/skills/multi-channel/SKILL.md +3 -2
  83. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
  84. package/dist/general-knowledge/skills/web-appearance/SKILL.md +257 -23
  85. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +478 -0
  86. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  87. package/dist/human-delivery-receipts.d.ts +24 -1
  88. package/dist/human-delivery-receipts.js +64 -11
  89. package/dist/human-delivery-receipts.js.map +1 -1
  90. package/dist/instance-config-impact.js +3 -1
  91. package/dist/instance-config-impact.js.map +1 -1
  92. package/dist/instance-names.d.ts +4 -0
  93. package/dist/instance-names.js +12 -0
  94. package/dist/instance-names.js.map +1 -0
  95. package/dist/instructions.d.ts +12 -1
  96. package/dist/instructions.js +13 -2
  97. package/dist/instructions.js.map +1 -1
  98. package/dist/locale.js +100 -8
  99. package/dist/locale.js.map +1 -1
  100. package/dist/ls-attach-resolve.d.ts +95 -0
  101. package/dist/ls-attach-resolve.js +174 -0
  102. package/dist/ls-attach-resolve.js.map +1 -0
  103. package/dist/memory-hogs.d.ts +27 -0
  104. package/dist/memory-hogs.js +124 -0
  105. package/dist/memory-hogs.js.map +1 -0
  106. package/dist/outbound-handlers.d.ts +10 -0
  107. package/dist/outbound-handlers.js +122 -2
  108. package/dist/outbound-handlers.js.map +1 -1
  109. package/dist/outbound-schemas.d.ts +164 -4
  110. package/dist/outbound-schemas.js +99 -3
  111. package/dist/outbound-schemas.js.map +1 -1
  112. package/dist/public-web-gateway.js +8 -2
  113. package/dist/public-web-gateway.js.map +1 -1
  114. package/dist/public-web-link.d.ts +9 -0
  115. package/dist/public-web-link.js +8 -0
  116. package/dist/public-web-link.js.map +1 -1
  117. package/dist/quickstart-api.d.ts +10 -1
  118. package/dist/quickstart-api.js +12 -4
  119. package/dist/quickstart-api.js.map +1 -1
  120. package/dist/reply-buttons-controller.d.ts +4 -0
  121. package/dist/reply-buttons-controller.js +2 -2
  122. package/dist/reply-buttons-controller.js.map +1 -1
  123. package/dist/restart-progress.d.ts +65 -1
  124. package/dist/restart-progress.js +236 -10
  125. package/dist/restart-progress.js.map +1 -1
  126. package/dist/rollout-index.d.ts +5 -0
  127. package/dist/rollout-index.js +26 -5
  128. package/dist/rollout-index.js.map +1 -1
  129. package/dist/scheduler/scheduler.d.ts +12 -0
  130. package/dist/scheduler/scheduler.js +83 -10
  131. package/dist/scheduler/scheduler.js.map +1 -1
  132. package/dist/service-installer.d.ts +2 -1
  133. package/dist/service-installer.js +18 -3
  134. package/dist/service-installer.js.map +1 -1
  135. package/dist/settings-api.d.ts +19 -0
  136. package/dist/settings-api.js +137 -2
  137. package/dist/settings-api.js.map +1 -1
  138. package/dist/settings-change.js +104 -0
  139. package/dist/settings-change.js.map +1 -1
  140. package/dist/settings-effect.js +17 -1
  141. package/dist/settings-effect.js.map +1 -1
  142. package/dist/tmux-manager.d.ts +5 -0
  143. package/dist/tmux-manager.js +16 -0
  144. package/dist/tmux-manager.js.map +1 -1
  145. package/dist/tool-permissions.d.ts +4 -6
  146. package/dist/tool-permissions.js +53 -5
  147. package/dist/tool-permissions.js.map +1 -1
  148. package/dist/topic-commands.d.ts +7 -1
  149. package/dist/topic-commands.js +18 -4
  150. package/dist/topic-commands.js.map +1 -1
  151. package/dist/types.d.ts +48 -1
  152. package/dist/ui/app-effects.js +77 -31
  153. package/dist/ui/chat-ambient.js +307 -0
  154. package/dist/ui/chat-effect-posters.js +8 -8
  155. package/dist/ui/chat-effects.js +101 -13
  156. package/dist/ui/chat-render.js +21 -3
  157. package/dist/ui/chat-strings.js +59 -6
  158. package/dist/ui/chat-thread.js +159 -21
  159. package/dist/ui/effect-trial-controller.js +13 -8
  160. package/dist/ui/effect-trial-panel.js +19 -16
  161. package/dist/ui/fleet-cache.js +23 -1
  162. package/dist/ui/fleet-canvas-layout.js +111 -0
  163. package/dist/ui/fleet-canvas.js +525 -0
  164. package/dist/ui/fleet-org.js +27 -12
  165. package/dist/ui/fleet-traffic.js +343 -0
  166. package/dist/ui/fleet-usage.js +401 -0
  167. package/dist/ui/panel-chat.js +154 -49
  168. package/dist/ui/panel-details.js +2 -1
  169. package/dist/ui/panel-fleet.js +43 -17
  170. package/dist/ui/panel-settings.js +26 -8
  171. package/dist/ui/preview.js +3 -2
  172. package/dist/ui/settings-appearance.js +42 -18
  173. package/dist/ui/settings-catalog.js +92 -0
  174. package/dist/ui/settings-dialogs.js +40 -12
  175. package/dist/ui/settings-effects.js +4 -3
  176. package/dist/ui/settings-guests.js +117 -0
  177. package/dist/ui/settings-learn.js +25 -0
  178. package/dist/ui/settings-model.js +15 -6
  179. package/dist/ui/settings-mods.js +23 -9
  180. package/dist/ui/settings-owner-effects.js +9 -9
  181. package/dist/ui/settings-strings.js +120 -42
  182. package/dist/ui/settings-web-authority.js +91 -0
  183. package/dist/ui/shared/agend-auth.js +1 -1
  184. package/dist/ui/shared/app-i18n.js +4 -2
  185. package/dist/ui/shared/app-prefs.js +5 -4
  186. package/dist/ui/shared/app-route.js +19 -1
  187. package/dist/ui/shared/app-session.js +2 -0
  188. package/dist/ui/shared/app-shell.js +92 -27
  189. package/dist/ui/shared/app-store.js +26 -1
  190. package/dist/ui/shared/app-stream.js +3 -1
  191. package/dist/ui/shared/app-themes.js +24 -6
  192. package/dist/ui/shared/app.css +365 -17
  193. package/dist/ui/shared/app.js +21 -14
  194. package/dist/ui/shared/card-strings.js +4 -0
  195. package/dist/ui/shared/effect-app-runtime.js +68 -19
  196. package/dist/ui/shared/effect-chat-runtime.js +16 -3
  197. package/dist/ui/shared/effect-frame.js +26 -7
  198. package/dist/ui/shared/effect-geometry.js +24 -9
  199. package/dist/ui/shared/effect-policy.js +21 -7
  200. package/dist/ui/shared/effect-profiler.js +31 -9
  201. package/dist/ui/shared/effect-registry.js +87 -48
  202. package/dist/ui/shared/effect-stock-snapshot.js +9 -0
  203. package/dist/ui/shared/effect-stock-trust.js +62 -10
  204. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  205. package/dist/ui/shared/effect-trial-source.js +1 -1
  206. package/dist/ui/shared/effect-trial-stage.js +54 -31
  207. package/dist/ui/shared/effect-trial.css +40 -0
  208. package/dist/ui/shared/fx-motion.js +67 -0
  209. package/dist/ui/shared/identity-card.js +75 -4
  210. package/dist/ui/shared/identity-color.js +3 -3
  211. package/dist/ui/shared/instance-nav.js +194 -28
  212. package/dist/ui/shared/panel-view.js +24 -4
  213. package/dist/ui/shared/tokens.css +9 -3
  214. package/dist/ui/shared/ui-icons.js +1 -0
  215. package/dist/ui/shared/usage-dialog.js +22 -2
  216. package/dist/ui/shared/view-strings.js +68 -2
  217. package/dist/ui/shared/web-authorize.js +151 -0
  218. package/dist/ui/signin.html +1 -1
  219. package/dist/ui/web-terminal/terminal.css +4 -4
  220. package/dist/usage/providers.d.ts +28 -0
  221. package/dist/usage/providers.js +126 -36
  222. package/dist/usage/providers.js.map +1 -1
  223. package/dist/usage-report.d.ts +158 -0
  224. package/dist/usage-report.js +226 -0
  225. package/dist/usage-report.js.map +1 -0
  226. package/dist/usage-rollup.d.ts +51 -0
  227. package/dist/usage-rollup.js +168 -0
  228. package/dist/usage-rollup.js.map +1 -0
  229. package/dist/view-api.d.ts +39 -0
  230. package/dist/view-api.js +65 -4
  231. package/dist/view-api.js.map +1 -1
  232. package/dist/web-ambient.d.ts +72 -0
  233. package/dist/web-ambient.js +161 -0
  234. package/dist/web-ambient.js.map +1 -0
  235. package/dist/web-api.d.ts +69 -5
  236. package/dist/web-api.js +362 -45
  237. package/dist/web-api.js.map +1 -1
  238. package/dist/web-appearance-policy.d.ts +4 -6
  239. package/dist/web-appearance-policy.js +5 -12
  240. package/dist/web-appearance-policy.js.map +1 -1
  241. package/dist/web-appearance-tools.d.ts +3 -0
  242. package/dist/web-appearance-tools.js +22 -3
  243. package/dist/web-appearance-tools.js.map +1 -1
  244. package/dist/web-appearance-view.d.ts +27 -2
  245. package/dist/web-appearance-view.js +129 -14
  246. package/dist/web-appearance-view.js.map +1 -1
  247. package/dist/web-appearance.d.ts +17 -3
  248. package/dist/web-appearance.js +82 -7
  249. package/dist/web-appearance.js.map +1 -1
  250. package/dist/web-asset-parts.d.ts +5 -10
  251. package/dist/web-asset-parts.js +3 -3
  252. package/dist/web-asset-parts.js.map +1 -1
  253. package/dist/web-auth.js +3 -1
  254. package/dist/web-auth.js.map +1 -1
  255. package/dist/web-card.d.ts +14 -0
  256. package/dist/web-card.js +40 -6
  257. package/dist/web-card.js.map +1 -1
  258. package/dist/web-channel-echo.d.ts +7 -1
  259. package/dist/web-channel-echo.js +6 -2
  260. package/dist/web-channel-echo.js.map +1 -1
  261. package/dist/web-chat-history.d.ts +50 -0
  262. package/dist/web-chat-history.js +87 -5
  263. package/dist/web-chat-history.js.map +1 -1
  264. package/dist/web-commands.d.ts +1 -0
  265. package/dist/web-effect-bootstrap.d.ts +12 -0
  266. package/dist/web-effect-bootstrap.js +49 -0
  267. package/dist/web-effect-bootstrap.js.map +1 -0
  268. package/dist/web-effect-bundle.js +46 -1
  269. package/dist/web-effect-bundle.js.map +1 -1
  270. package/dist/web-effect-playback.d.ts +30 -0
  271. package/dist/web-effect-playback.js +71 -0
  272. package/dist/web-effect-playback.js.map +1 -0
  273. package/dist/web-effect-profiles.d.ts +4 -4
  274. package/dist/web-effect-service.d.ts +11 -4
  275. package/dist/web-effect-service.js +28 -5
  276. package/dist/web-effect-service.js.map +1 -1
  277. package/dist/web-effect-tools.d.ts +1 -0
  278. package/dist/web-effect-tools.js +8 -5
  279. package/dist/web-effect-tools.js.map +1 -1
  280. package/dist/web-effect-trials.d.ts +4 -4
  281. package/dist/web-effect-trials.js +6 -6
  282. package/dist/web-effect-trials.js.map +1 -1
  283. package/dist/web-effect-upload.d.ts +2 -4
  284. package/dist/web-effect-upload.js +3 -8
  285. package/dist/web-effect-upload.js.map +1 -1
  286. package/dist/web-effect-worker.d.ts +1 -1
  287. package/dist/web-effect-worker.js +2 -2
  288. package/dist/web-effect-worker.js.map +1 -1
  289. package/dist/web-effect.d.ts +14 -0
  290. package/dist/web-effect.js +21 -0
  291. package/dist/web-effect.js.map +1 -1
  292. package/dist/web-guest-access.d.ts +125 -0
  293. package/dist/web-guest-access.js +234 -0
  294. package/dist/web-guest-access.js.map +1 -0
  295. package/dist/web-guest-rooms.d.ts +82 -0
  296. package/dist/web-guest-rooms.js +135 -0
  297. package/dist/web-guest-rooms.js.map +1 -0
  298. package/dist/web-identity-color.d.ts +1 -1
  299. package/dist/web-identity-color.js +2 -2
  300. package/dist/web-identity-color.js.map +1 -1
  301. package/dist/web-identity-palette.d.ts +1 -1
  302. package/dist/web-identity-palette.js +2 -2
  303. package/dist/web-identity-palette.js.map +1 -1
  304. package/dist/web-image-meta.d.ts +13 -0
  305. package/dist/web-image-meta.js +62 -0
  306. package/dist/web-image-meta.js.map +1 -1
  307. package/dist/web-login.d.ts +47 -0
  308. package/dist/web-login.js +115 -0
  309. package/dist/web-login.js.map +1 -1
  310. package/dist/web-mod-agent.d.ts +1 -1
  311. package/dist/web-mod-agent.js +12 -8
  312. package/dist/web-mod-agent.js.map +1 -1
  313. package/dist/web-mod-bundle.d.ts +14 -1
  314. package/dist/web-mod-bundle.js +68 -3
  315. package/dist/web-mod-bundle.js.map +1 -1
  316. package/dist/web-mod-export.d.ts +9 -0
  317. package/dist/web-mod-export.js +21 -0
  318. package/dist/web-mod-export.js.map +1 -1
  319. package/dist/web-mod-store.d.ts +7 -1
  320. package/dist/web-mod-store.js +21 -7
  321. package/dist/web-mod-store.js.map +1 -1
  322. package/dist/web-prefs.d.ts +14 -0
  323. package/dist/web-prefs.js +24 -4
  324. package/dist/web-prefs.js.map +1 -1
  325. package/dist/web-preview.d.ts +6 -2
  326. package/dist/web-preview.js +4 -2
  327. package/dist/web-preview.js.map +1 -1
  328. package/dist/web-returning-device.d.ts +41 -0
  329. package/dist/web-returning-device.js +57 -5
  330. package/dist/web-returning-device.js.map +1 -1
  331. package/dist/web-session-authorization.d.ts +132 -0
  332. package/dist/web-session-authorization.js +193 -0
  333. package/dist/web-session-authorization.js.map +1 -0
  334. package/dist/web-session.d.ts +70 -10
  335. package/dist/web-session.js +137 -22
  336. package/dist/web-session.js.map +1 -1
  337. package/dist/web-settings-patch.d.ts +49 -2
  338. package/dist/web-settings-patch.js +145 -1
  339. package/dist/web-settings-patch.js.map +1 -1
  340. package/dist/web-settings-tools.d.ts +89 -0
  341. package/dist/web-settings-tools.js +270 -0
  342. package/dist/web-settings-tools.js.map +1 -0
  343. package/dist/web-shell-routes.d.ts +1 -1
  344. package/dist/web-shell-routes.js +1 -1
  345. package/dist/web-shell-routes.js.map +1 -1
  346. package/dist/web-theme-store.d.ts +39 -0
  347. package/dist/web-theme-store.js +122 -11
  348. package/dist/web-theme-store.js.map +1 -1
  349. package/dist/web-theme.d.ts +2 -0
  350. package/dist/web-theme.js +10 -1
  351. package/dist/web-theme.js.map +1 -1
  352. package/dist/web-upload.d.ts +13 -0
  353. package/dist/web-upload.js +39 -3
  354. package/dist/web-upload.js.map +1 -1
  355. package/dist/workflow-templates/default.md +1 -1
  356. package/package.json +1 -1
  357. package/templates/systemd.service.ejs +8 -0
@@ -20,14 +20,14 @@ import { withinBudget } from "./monotonic-budget.js";
20
20
  import { measureSyncWork } from "./sync-work-attribution.js";
21
21
  import { RuntimeCpuProfiler, ProfileBusyError, profileDuration } from "./runtime-cpu-profile.js";
22
22
  import { ProfileControlServer } from "./profile-control.js";
23
- import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, constants as fsConstants, closeSync } from "node:fs";
23
+ import { existsSync, readFileSync, mkdirSync, writeFileSync, unlinkSync, rmSync, readdirSync, renameSync, copyFileSync, chmodSync, statSync, accessSync, realpathSync, lstatSync, constants as fsConstants, closeSync } from "node:fs";
24
24
  import { atomicWriteFileSync } from "./atomic-write.js";
25
25
  import { createHash, randomBytes, randomUUID } from "node:crypto";
26
26
  import { freemem, totalmem, cpus, homedir } from "node:os";
27
27
  import { createServer } from "node:http";
28
28
  import { Worker } from "node:worker_threads";
29
29
  import { ProbeWorkerPool } from "./probe-worker-pool.js";
30
- import { join, dirname, basename, delimiter, isAbsolute, resolve as resolvePath } from "node:path";
30
+ import { join, dirname, basename, delimiter, isAbsolute, relative, resolve as resolvePath } from "node:path";
31
31
  import { fileURLToPath } from "node:url";
32
32
  import { getAgendHome, ensureWorkspaceGit } from "./paths.js";
33
33
  import { fleetLabel } from "./fleet-label.js";
@@ -48,6 +48,7 @@ import { isProbeableRouteTarget } from "./fleet-context.js";
48
48
  import { getEffectiveInstanceDefaults, loadFleetConfig, loadRawFleetConfig, DEFAULT_COST_GUARD, DEFAULT_DAILY_SUMMARY, DEFAULT_INSTANCE_CONFIG } from "./config.js";
49
49
  import { EventLog } from "./event-log.js";
50
50
  import { binaryProbe } from "./binary-probe.js";
51
+ import { AVAILABILITY_STALE_MS, BackendAvailabilityStore, hostAvailabilityProbes } from "./backend-availability.js";
51
52
  import { classifySqliteOpenError } from "./sqlite-open-errors.js";
52
53
  import { OutboxOpenError } from "./outbox-open-error.js";
53
54
  import { AdapterWorld } from "./adapter-world.js";
@@ -65,7 +66,7 @@ import { createBackend } from "./backend/factory.js";
65
66
  import { readEffortMetadata } from "./backend/effort-metadata.js";
66
67
  import { codexEffort, flagEffort } from "./backend/launch-effort.js";
67
68
  import { codexMetadataHomes, readCodexModelEffortLevels, resolveCodexLaunchModel } from "./backend/codex-metadata.js";
68
- import { CLI_ENV_TTL_MS, isModelCompatible, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
69
+ import { CLI_ENV_TTL_MS, isModelCompatible, noteResolvedBinary, resolveBinary, SYSINFO_BACKEND_IDS, UnsupportedCliError } from "./backend/types.js";
69
70
  import { createLogger, rotateLogIfNeeded } from "./logger.js";
70
71
  import { processAttachments } from "./channel/attachment-handler.js";
71
72
  import { routeToolCall } from "./channel/tool-router.js";
@@ -134,9 +135,9 @@ export function applyTaskListCap(tasks, filterAssignee, filterStatus) {
134
135
  }
135
136
  return tasks;
136
137
  }
137
- import { handleWebRequest, broadcastSseEvent, SSE_HEARTBEAT_MS } from "./web-api.js";
138
+ import { handleWebRequest, broadcastSseEvent, broadcastToGuests, SSE_HEARTBEAT_MS } from "./web-api.js";
138
139
  import { LogService } from "./log-service.js";
139
- import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId } from "./web-chat-history.js";
140
+ import { WebChatHistory, WEB_CHAT_TEXT_MAX, isWebMessageId, newWebMessageId, webChatFrom } from "./web-chat-history.js";
140
141
  import { WebChatDiskStore } from "./web-chat-store.js";
141
142
  import { SeenUsers, resolveMentions } from "./web-mentions.js";
142
143
  import { OPERATOR, WebPrefsStore } from "./web-prefs.js";
@@ -158,7 +159,7 @@ import { ReplyButtonsController } from "./reply-buttons-controller.js";
158
159
  import { parsePoll } from "./reply-polls.js";
159
160
  import { isUploadRequest, limitRequestReceive, publicAttachment, sweepOrphanedUploads, UPLOAD_REQUEST_TIMEOUT_MS, uploadLimits, WebFileLedger } from "./web-upload.js";
160
161
  import { REPLY_FILES_MAX_BYTES, snapshotReplyFile, sweepReplySnapshots, webFilesDir } from "./web-reply-files.js";
161
- import { cardPicture, handleViewRequest, humanCards, identityColors, isViewPath, profileIdentities, resolveInstanceIdentity } from "./view-api.js";
162
+ import { cardPicture, handleViewRequest, humanCards, identityColors, instanceTeams, isViewPath, personBannerFocus, personIdentityColour, profileIdentities, resolveInstanceIdentity, setPersonBannerFocus, setPersonIdentityColour } from "./view-api.js";
162
163
  import { autoIdentityColor } from "./web-identity-color.js";
163
164
  import { AppearanceTools } from "./web-appearance-tools.js";
164
165
  import { cardVersion, resolveCard } from "./web-card.js";
@@ -178,7 +179,11 @@ import { runOnLoginShell } from "./login-shell.js";
178
179
  import { tightenInstanceDirs } from "./private-dir.js";
179
180
  import { decideSlash } from "./slash-authz.js";
180
181
  import { canonicalCommand, commandSpec, decideCommand, ruleFor } from "./command-table.js";
181
- import { runVisibilityCommand } from "./cross-instance-notice.js";
182
+ import { crossInstanceVisibility, runVisibilityCommand } from "./cross-instance-notice.js";
183
+ import { WebSettingsTools } from "./web-settings-tools.js";
184
+ import { bindGuestRequest, guestRouteDecision } from "./web-guest-access.js";
185
+ import { GUEST_ROOM_TICK_MS, GuestRoomMembership, guestGroupRooms } from "./web-guest-rooms.js";
186
+ import { Delegations, TrafficLog, trafficItem } from "./fleet-traffic.js";
182
187
  import { installedChannel, updateNoticeKey } from "./update-check.js";
183
188
  import { resolveInstalledAgend } from "./update-dispatch.js";
184
189
  import { resolveUpdateLaunch, openUpdateLog, updateLogPath, watchUpdateLaunch } from "./update-launch.js";
@@ -190,7 +195,7 @@ import { setLocale, detectLocale, getLocale, t } from "./locale.js";
190
195
  import { recentChatContext } from "./log-tail.js";
191
196
  import { describeSignalSource, recordInternalRequest, withOrigin } from "./fleet-control-audit.js";
192
197
  import { handleAgentRequest, ToolNotPermittedError } from "./agent-endpoint.js";
193
- import { ClassicChannelManager, getClassicBackendChoices, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
198
+ import { ClassicChannelManager, classicWebEchoOn, getClassicBackendChoices, inferClassicChannelType, isSelectableClassicBackend, readClassicLastActivityAt } from "./classic-channel-manager.js";
194
199
  import { assertExplicitInstanceRemoval } from "./instance-removal.js";
195
200
  import { KNOWN_BACKENDS, validateFleetConfig } from "./config-validator.js";
196
201
  import { isRemovedBackend, removedBackendMessage } from "./backend/removed.js";
@@ -209,8 +214,9 @@ import { NeedsYouHub } from "./needs-you-hub.js";
209
214
  import { WEB_CHAT_NOTICE, claimNotice, hasWebChat, releaseNotice, upgradeNoticesPath } from "./upgrade-notices.js";
210
215
  import { GENERAL_PAUSE_ERROR, isGeneralInstance } from "./general-instance.js";
211
216
  import { buildOrgChart } from "./web-org.js";
217
+ import { topMemoryHogs } from "./memory-hogs.js";
212
218
  import { GENERAL_POINTING_REFUSALS, SNOWFLAKE, hasPlaceRefs, placeRef, webRoute, webTextLinks, placeSuffix, renderPlaceRefs, stripPlaceRefs, topicUrl, worldPlaceOf } from "./topic-link.js";
213
- import { CacheService, WINDOWS } from "./cache-service.js";
219
+ import { CacheService, cacheWindow } from "./cache-service.js";
214
220
  import { runWebCommand } from "./web-commands.js";
215
221
  import { CompactWatch, launchIdentity } from "./compact-watch.js";
216
222
  import { agyLegacyAgentsFile, danglingSymlink, migrateAgyAgentsFile } from "./backend/antigravity.js";
@@ -220,11 +226,17 @@ import { mayUseTool, resolveToolSet, scheduleOpRefusal, toolForIpcType, toolRefu
220
226
  import { authorizeSession, decideWebGate, loadOrCreateWebToken, readWebToken } from "./web-auth.js";
221
227
  import { bypassesWebGate, handleAuthRequest, serveSigninPage } from "./auth-api.js";
222
228
  import { isWebPageNavigation } from "./web-shell-routes.js";
223
- import { localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
224
- import { WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
229
+ import { guestSessionPolicy, localSessionPolicy, tokenEpoch, WebSessionStore } from "./web-session.js";
230
+ import { authorityNow, evaluateWebAuthority, VERIFIED_SESSION_HOURS, webAuthorityPolicy } from "./authority/web-authority.js";
231
+ import { AuthorityStateStore } from "./authority/authority-state.js";
232
+ import { SessionAuthorizations } from "./authority/session-authorizations.js";
233
+ import { SessionAuthorizationRequests } from "./web-session-authorization.js";
234
+ import { GuestLoginCodes, WebLoginCodes, LOGIN_CODE_TTL_MS } from "./web-login.js";
235
+ import { decisionListAnswer } from "./decision-list.js";
225
236
  import { CodeRequestLimiter, ReturningDevices } from "./web-returning-device.js";
226
- import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
237
+ import { allowedHostNames, applyWebSecurityHeaders, hostnameOf, isHostAllowed, LOOPBACK_HOST_NAMES, WEB_HOST_REJECTED_MESSAGE } from "./web-host-guard.js";
227
238
  import { createPreviewListener, normalizeOrigin, previewAvailability, previewSettings } from "./web-preview.js";
239
+ import { createEffectBootstrap } from "./web-effect-bootstrap.js";
228
240
  import { fleetLevelDifferences, fleetLevelSignature, appendedConnections } from "./fleet-level-config.js";
229
241
  import { checkSelfRestartAllowance, recordSelfRestartAttempt } from "./self-restart-limit.js";
230
242
  import { SecretStore } from "./secret-store.js";
@@ -234,7 +246,7 @@ import { PROVIDER_SECRET_SPECS, providerSecretSpec, providerRegistryEnvKeys, isR
234
246
  /** A self-restart is a whole service restart; 120s is the apply budget, not this. */
235
247
  const SELF_RESTART_DEADLINE_MS = 300_000;
236
248
  import { APPLY_FLEET_TARGET, ApplyJobStore, viewOf, } from "./apply-job.js";
237
- import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
249
+ import { CHAT_PROFILE_LOGIN_BACKENDS, credentialHomeSpec, credentialProfileHome, credentialProfileLogin, credentialProfileStoreHome, credentialSwitchCarriesConversation, credentialSwitchStartsFresh, instanceCredentialProfile } from "./backend/credential-profile.js";
238
250
  import { carryClaudeConversation } from "./backend/claude-carry.js";
239
251
  import { mergeBackendOptions, profileKey, profileName, profileOf } from "./classic-bindings.js";
240
252
  import { classifyInstanceChange, CLASSIC_HOT_CONFIG_KEYS, HOT_INSTANCE_CONFIG_KEYS, hotConfigUpdate, } from "./instance-config-impact.js";
@@ -283,6 +295,41 @@ export function selectLruEvictions(warm, cap, opts) {
283
295
  * fleet.yaml. Keep in sync with `uniqueInstanceName` — every name it emits
284
296
  * must match here, or a deleted instance's CLI is left running (#1305 P2-3).
285
297
  */
298
+ /**
299
+ * #2163: where `p` really is — symlinks resolved. A path that does not exist yet is resolved through its deepest
300
+ * existing ancestor (a missing child of a link elsewhere is elsewhere too).
301
+ */
302
+ function realPathThroughAncestors(p) {
303
+ const missing = [];
304
+ let at = resolvePath(p);
305
+ for (;;) {
306
+ try {
307
+ return join(realpathSync(at), ...missing.reverse());
308
+ }
309
+ catch { /* not there yet: try its parent */ }
310
+ try {
311
+ lstatSync(at);
312
+ return null;
313
+ }
314
+ catch { /* truly absent — not a broken link whose target is unknown */ }
315
+ const up = dirname(at);
316
+ if (up === at)
317
+ return resolvePath(p);
318
+ missing.push(basename(at));
319
+ at = up;
320
+ }
321
+ }
322
+ /**
323
+ * #2163: whether `dir` is inside AgEnD's own workspaces (<AGEND_HOME>/workspaces/<name>/…), compared after resolving
324
+ * symlinks on both sides — including a missing folder's existing ancestors — so a link elsewhere is not inside, nor is a broken link.
325
+ */
326
+ export function insideManagedWorkspaces(dir, agendHome = getAgendHome()) {
327
+ const root = realPathThroughAncestors(join(agendHome, "workspaces")), real = realPathThroughAncestors(dir);
328
+ if (root === null || real === null)
329
+ return false;
330
+ const rel = relative(root, real);
331
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
332
+ }
286
333
  export function isOrphanInstanceWindowName(name) {
287
334
  return /-t\d+$/.test(name) || /^classic-/.test(name);
288
335
  }
@@ -504,6 +551,8 @@ function emojiListFilter(opts) {
504
551
  withImageUrls: flag(opts.with_image_urls),
505
552
  };
506
553
  }
554
+ /** #1920: a guest's scope until phase 2 derives one: nothing. */
555
+ const NO_GUEST_SCOPE = new Set();
507
556
  export class FleetManager {
508
557
  dataDir;
509
558
  static signalTarget = null;
@@ -732,6 +781,8 @@ export class FleetManager {
732
781
  // Adapter restart: prevents re-entrant restart attempts
733
782
  adapterRestarting = new Set();
734
783
  // Adapter isolation: track state per adapter for retry + visibility
784
+ /** #2175: this boot's progress message, whose completion may still be owed by its background retry. */
785
+ startupProgress = null;
735
786
  adapterState = new Map();
736
787
  /** Web Settings secret rotation is deliberately separate from reconnect
737
788
  * recovery: a rotation must build a fresh provider client with the new token,
@@ -770,10 +821,16 @@ export class FleetManager {
770
821
  collabInstances = new Set();
771
822
  // Health endpoint
772
823
  healthServer = null;
824
+ /** #2118: independent of HTML preview availability/port; fixed bootstrap only, no effect resources. */
825
+ effectBootstrap = createEffectBootstrap();
826
+ /** Fixed trusted document coherence, independent of the HTML preview listener; never a resource grant. */
827
+ get effectBoot() { return this.effectBootstrap.boot; }
773
828
  healthServerDrain = null;
774
829
  /** #1306: the preview listener (health_port + 1 by default), and whether it is listening. */
775
830
  previewListener = null;
776
831
  previewListening = false;
832
+ /** The preview listener could not open its port: "<port>: <error code>", for the page (#2061 follow-up); null otherwise. */
833
+ previewListenError = null;
777
834
  /** The ports the preview listener was started for, and the inputs it was built from (a reload compares them). */
778
835
  previewPorts = null;
779
836
  previewInputs = "";
@@ -792,6 +849,9 @@ export class FleetManager {
792
849
  /** Injectable only to keep the chat→CLI reload hand-off deterministic in tests. */
793
850
  fullRestartLauncher = launchFullRestartHelper;
794
851
  eventLogPruneTimer = null;
852
+ /** #1920 §3.2: the background reads of guests' ClassicBot room membership (a minute's tick; reads only what is due). */
853
+ guestRoomsTimer = null;
854
+ guestRoomMembership = null;
795
855
  outboxPruneTimer = null;
796
856
  logRotateTimer = null;
797
857
  discordPresenceTimer = null;
@@ -806,6 +866,17 @@ export class FleetManager {
806
866
  startedAt = 0;
807
867
  stormOpenNotifyTimer = null;
808
868
  memoryLogAt = null;
869
+ /**
870
+ * #2015: while host memory is elevated or critical, the processes holding the most in the fleet's service and whose
871
+ * they are (an instance, the fleet, tmux), for the pressure log, /health and the dashboard — never chat (#1712).
872
+ * Rescanned at most once a minute (async, one at a time); cleared when pressure ends.
873
+ */
874
+ memoryTop = null;
875
+ memoryTopScan = { at: null, inFlight: false, loggedAt: null };
876
+ /** The scan (a seam for tests: they give it a fake /proc). */
877
+ memoryHogScan = topMemoryHogs;
878
+ /** The fleet's own tmux session exists (only then are its panes read: a fleet that never started has none). */
879
+ tmuxSessionEnsured = false;
809
880
  memoryDebugAt = null;
810
881
  memoryLogLevel = null;
811
882
  /** #1685: the host_memory value the last status frame carried, so a change re-sends the frame. */
@@ -978,7 +1049,7 @@ export class FleetManager {
978
1049
  this.emitSseEvent("status", this.getUiStatus());
979
1050
  }
980
1051
  catch { /* later status carries placements */ } },
981
- // A nonmutating own/shared offer is not an appearance write. Only an explicit local-admin click may start it.
1052
+ // A nonmutating own/shared offer is not an appearance write. Only an explicit non-public admin-session click may start it.
982
1053
  preview: async (instance, id) => {
983
1054
  const result = this.effectTrials.offer(instance, id);
984
1055
  if (!result.ok)
@@ -986,11 +1057,12 @@ export class FleetManager {
986
1057
  const present = this.webChatHistory.list(instance).some(row => row.effectTrial?.offer === result.offer.offer);
987
1058
  if (!present && !this.publishEffectTrial(instance, result.offer))
988
1059
  return { error: "trial_unavailable" };
989
- return { id, status: "offered", expiresAt: result.offer.expiresAt, message: "A trial card is in your web chat. A signed-in local admin can start it; nothing was applied." };
1060
+ return { id, status: "offered", expiresAt: result.offer.expiresAt, message: "A trial card is in your web chat. A signed-in admin outside the public link can start it; nothing was applied." };
990
1061
  },
991
1062
  profiles: id => this.effectProfiles.read(id),
992
1063
  profileSummary: id => this.effectService.profileSummary(id),
993
1064
  profileSummaries: ids => this.effectService.profileSummaries(ids),
1065
+ playbackSummary: (name, id) => this.effectService.playbackSummary(name, id),
994
1066
  }));
995
1067
  }
996
1068
  appearanceToolsService;
@@ -998,7 +1070,12 @@ export class FleetManager {
998
1070
  get appearanceTools() {
999
1071
  return (this.appearanceToolsService ??= new AppearanceTools({
1000
1072
  frame: (name) => (this.getUiStatus().instances.find(i => i.name === name) ?? { name }),
1001
- theme: (name) => { const r = this.themeStore.get(name); return { theme: r.theme, locked: r.locked, widgets: r.placements.widgets }; },
1073
+ theme: (name) => {
1074
+ const r = this.themeStore.get(name);
1075
+ return { theme: r.theme, locked: r.locked, widgets: r.placements.widgets, ambient: r.placements.ambient,
1076
+ glyphAsset: (id) => this.themeStore.asset(name, id) };
1077
+ },
1078
+ effects: name => this.themeStore.readable() ? this.themeStore.get(name).placements.effects.map(p => ({ id: p.id, by: p.by, ...(p.params ? { params: p.params } : {}), playback: this.effectService.playbackSummary(name, p.id) })) : [],
1002
1079
  card: (name) => resolveCard(name, humanCards(this.dataDir).get(name), this.themeStore.get(name).theme.card),
1003
1080
  // #1958: the host's identity-colour settings, as the page reads them (an unreadable prefs file: the defaults).
1004
1081
  colorPrefs: () => { try {
@@ -1079,6 +1156,31 @@ export class FleetManager {
1079
1156
  get ownerCss() {
1080
1157
  return (this.ownerCssStore ??= new OwnerCssStore(this.dataDir, this.webPrefs));
1081
1158
  }
1159
+ webSettingsToolsService;
1160
+ /**
1161
+ * #2056: Settings → Web appearance for a tool (General, a coordinator, full) — the page's own stores and checks, and the
1162
+ * page's announcement after a change (web-settings-tools.ts).
1163
+ */
1164
+ get webSettings() {
1165
+ return (this.webSettingsToolsService ??= new WebSettingsTools({
1166
+ prefs: this.webPrefs, ownerCss: this.ownerCss, mods: this.mods, effects: this.effectService,
1167
+ instances: () => [...Object.keys(this.fleetConfig?.instances ?? {}),
1168
+ ...(this.classicChannels?.getAll() ?? []).map((ch) => ch.instanceName).filter((n) => !Object.hasOwn(this.fleetConfig?.instances ?? {}, n))],
1169
+ colour: (name) => personIdentityColour(this.dataDir, name),
1170
+ setColour: (name, patch) => { const w = setPersonIdentityColour(this.dataDir, name, patch); if (w.ok)
1171
+ this.identityChanged(); return w; },
1172
+ bannerFocus: (name) => personBannerFocus(this.dataDir, name),
1173
+ setBannerFocus: (name, focus) => { const w = setPersonBannerFocus(this.dataDir, name, focus); if (w.ok)
1174
+ this.identityChanged(); return w; },
1175
+ placements: (name) => this.themeStore.get(name).placements.effects.map((e) => (typeof e === "string" ? { id: e } : e)),
1176
+ ambient: (name) => Object.fromEntries(Object.entries(this.themeStore.get(name).placements.ambient).flatMap(([k, a]) => a ? [[k, { spec: a.spec, by: a.by }]] : [])),
1177
+ setAmbient: (name, spec, which) => this.webAppearance.humanSetAmbient(name, spec, which),
1178
+ announce: () => { try {
1179
+ this.emitSseEvent("status", this.getUiStatus());
1180
+ }
1181
+ catch { /* the next status frame carries it */ } },
1182
+ }));
1183
+ }
1082
1184
  get webPrefs() {
1083
1185
  // #1664: a favorite must be an instance of this fleet now — fleet.yaml's (own keys) or a ClassicBot room's.
1084
1186
  return (this.webPrefsStore ??= new WebPrefsStore(this.dataDir, {
@@ -1349,9 +1451,26 @@ export class FleetManager {
1349
1451
  sseHeartbeatMs = SSE_HEARTBEAT_MS;
1350
1452
  /** The dashboard's login codes. Memory only: a code that outlives the process is a code nobody can prove was not copied. */
1351
1453
  webLoginCodes = null;
1454
+ /**
1455
+ * #1920 step 4: guests' codes, apart from the admins' — one per account, so no guest's `/web` withdraws another's or
1456
+ * an admin's code (#2093 review), at most 32 at once. Local sign-in only for now (audience "local").
1457
+ */
1458
+ webGuestLoginCodes = null;
1459
+ /** #1920 step 4: when each account last got a guest code (monotonic) — one a minute, so `/web` is no DM flood. */
1460
+ guestCodeAskedAt = new Map();
1461
+ /** #2093 review: an account's guest /web in progress — one at a time, so two at once cannot both pass the minute. */
1462
+ guestCodeInFlight = new Set();
1463
+ /**
1464
+ * #2093 review: revokes a guest /web in progress must see — every web session's (`/web revoke` from an admin) bumps
1465
+ * the global count, an account's own bumps its entry — checked before the code is issued and while it is delivered.
1466
+ */
1467
+ guestRevokes = 0;
1468
+ guestAccountRevokes = new Map();
1352
1469
  /** #1570: browsers that may ask the sign-in page for a fresh code, and how often they may. */
1353
1470
  returningDevices = null;
1354
1471
  codeRequestLimiter = new CodeRequestLimiter();
1472
+ /** #1920 step 4b: guests' device code requests, limited apart — guests never use up the admins' fleet-wide budget. */
1473
+ guestCodeRequestLimiter = new CodeRequestLimiter();
1355
1474
  /**
1356
1475
  * Set while a Settings apply job is driving the reconcile. The reconcile
1357
1476
  * stays the single doer; it just says out loud what it is doing to whom, so
@@ -1499,6 +1618,7 @@ export class FleetManager {
1499
1618
  meta: {
1500
1619
  chat_id: set.chatId, message_id: messageId, user: by.username, user_id: by.userId, ts,
1501
1620
  thread_id: set.threadId, adapter_id: inbound.adapterId, source: inbound.source,
1621
+ ...(web && by.guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]", as /ui/send
1502
1622
  ...reactions.meta,
1503
1623
  },
1504
1624
  }, by.humanOrigin, button.label)) !== false;
@@ -1519,6 +1639,9 @@ export class FleetManager {
1519
1639
  this.emitSseEvent("message", {
1520
1640
  instance: set.instance, sender: by.username, role: "user", text: content, ts, ...(web ? { messageId } : {}),
1521
1641
  click: { label: button.label, of: set.id },
1642
+ // #1920: a platform click is its clicker's; it is the instance's channel's only when its buttons are there (the
1643
+ // same rule as a reply's: a set sent to another topic or a DM is not). A web click is the web's.
1644
+ ...(web ? {} : { from: webChatFrom(set.adapterId, by.userId), ...(this.clickInOwnChannel(set) ? { channel: true } : {}) }),
1522
1645
  });
1523
1646
  return true;
1524
1647
  }
@@ -1573,6 +1696,18 @@ export class FleetManager {
1573
1696
  }
1574
1697
  }
1575
1698
  });
1699
+ // #1918: the org canvas's traffic — from the row this listener's events already name, never a probe of its own.
1700
+ // The row as it is now (it has already changed when the event comes): its state, not the event's, is the truth.
1701
+ outbox.on("state", (event) => {
1702
+ const row = event.deliveryId ? outbox.get(event.deliveryId) : undefined;
1703
+ if (row)
1704
+ this.publishTraffic(row);
1705
+ });
1706
+ outbox.on("consumed", (event) => {
1707
+ const row = event.deliveryId ? outbox.get(event.deliveryId) : undefined;
1708
+ if (row)
1709
+ this.publishTraffic(row, "consumed");
1710
+ });
1576
1711
  outbox.on("generation_recovered", () => this.scheduleDeliveryOutboxPump());
1577
1712
  this.replyObligationTimer = setInterval(() => measureSyncWork("fleet.replyObligationSweep", () => this.sweepReplyObligations()), REPLY_OBLIGATION_SWEEP_MS);
1578
1713
  this.replyObligationTimer.unref?.();
@@ -1594,6 +1729,52 @@ export class FleetManager {
1594
1729
  });
1595
1730
  this.scheduleDeliveryOutboxPump();
1596
1731
  }
1732
+ /** #1918: cross-instance traffic, as the org canvas draws it (fleet-traffic.ts). */
1733
+ trafficLog = new TrafficLog();
1734
+ delegations = new Delegations();
1735
+ trafficMemo = null;
1736
+ /**
1737
+ * One delivery row's traffic item, kept for the snapshot and sent to the open pages when it is news; and the
1738
+ * delegation it opens or closes (a task and its report), sent as "delegation".
1739
+ */
1740
+ publishTraffic(row, state) {
1741
+ try {
1742
+ const item = trafficItem(row, this.fleetConfig, new Date().toISOString(), state);
1743
+ const news = item ? this.trafficLog.note(item) : null;
1744
+ const work = news ? this.delegations.note(row, news) : [];
1745
+ if (news || work.length)
1746
+ this.trafficMemo = null;
1747
+ if (news)
1748
+ this.emitSseEvent("traffic", news);
1749
+ // Each delegation as its two instances are seen now: one that expires or is pushed out may be older than a change
1750
+ // to their visibility (the incoming row's own item was checked; these can be anyone's).
1751
+ const visible = (name) => crossInstanceVisibility(this.fleetConfig, name) !== "hidden";
1752
+ for (const d of work)
1753
+ if (visible(d.from) && visible(d.to))
1754
+ this.emitSseEvent("delegation", d);
1755
+ }
1756
+ catch (err) {
1757
+ this.logger.debug({ err }, "traffic: an item was not published");
1758
+ }
1759
+ }
1760
+ /**
1761
+ * GET /ui/traffic: what is in flight now (the outbox's pending rows) and what finished in the last two minutes, at
1762
+ * most TRAFFIC_SNAPSHOT_MAX items. Worked out at most once every two seconds, so the public link's polling (or any
1763
+ * number of pages) costs one indexed read per two seconds. Visibility is applied as of now.
1764
+ */
1765
+ trafficSnapshot() {
1766
+ const nowMs = performance.now();
1767
+ if (this.trafficMemo && nowMs - this.trafficMemo.atMs < 2_000)
1768
+ return this.trafficMemo.value;
1769
+ const now = new Date().toISOString();
1770
+ const pending = (this.deliveryOutbox?.listPending() ?? [])
1771
+ .map((r) => trafficItem(r, this.fleetConfig, r.createdAt ?? now)).filter((i) => i !== null);
1772
+ const visible = (name) => crossInstanceVisibility(this.fleetConfig, name) !== "hidden";
1773
+ const items = this.trafficLog.snapshot(pending).filter((i) => visible(i.from) && visible(i.to));
1774
+ const delegations = this.delegations.snapshot().filter((d) => visible(d.from) && visible(d.to));
1775
+ this.trafficMemo = { atMs: nowMs, value: { now, items, delegations } };
1776
+ return this.trafficMemo.value;
1777
+ }
1597
1778
  /** Synchronous SQLite admission; the caller may acknowledge only after this returns. */
1598
1779
  admitDurableDelivery(input) {
1599
1780
  const outbox = this.deliveryOutbox;
@@ -1606,6 +1787,8 @@ export class FleetManager {
1606
1787
  }
1607
1788
  const sourceKey = `mcp:${input.sourceInstance}:${input.operationId}:${input.targetInstance}:${input.targetSession ?? ""}:${input.kind}`;
1608
1789
  const admitted = outbox.admit({ ...input, sourceDaemonBootId, sourceKey });
1790
+ if (admitted.inserted)
1791
+ this.publishTraffic(admitted.delivery);
1609
1792
  if (admitted.inserted)
1610
1793
  this.logger.info({
1611
1794
  deliveryId: admitted.delivery.deliveryId,
@@ -1628,10 +1811,18 @@ export class FleetManager {
1628
1811
  queryDurableDeliveryStatus(callerInstance, selector) {
1629
1812
  this.ensureDeliveryOutbox();
1630
1813
  const page = this.deliveryOutbox.queryStatusForInstance(callerInstance, selector);
1814
+ // #1939 §10: a human receipt's authority as it reads now (switch, epoch, loopback_only).
1815
+ const state = page.items.some(item => item.authority) ? this.webAuthorityState() : null;
1816
+ const human = (item) => {
1817
+ if (!item.authority || !state)
1818
+ return item;
1819
+ const authority = authorityNow(item.authority, state);
1820
+ return { ...item, authority, ...(authority.downgraded ? { source_instance: "human:web:unknown" } : {}) };
1821
+ };
1631
1822
  // #1641: a sender that sees only `queued` cannot tell "next in line" from "the target is stuck": say which.
1632
1823
  return {
1633
1824
  ...page,
1634
- items: page.items.map(item => (item.state === "queued" || item.state === "retry_wait")
1825
+ items: page.items.map(human).map(item => (item.state === "queued" || item.state === "retry_wait")
1635
1826
  ? { ...item, waiting_reason: this.deliveryWaitingReason(item.target_instance) }
1636
1827
  : item),
1637
1828
  };
@@ -1979,17 +2170,58 @@ export class FleetManager {
1979
2170
  this.logger.info({ hostMemory: snapshot }, "Host memory recovered");
1980
2171
  }
1981
2172
  this.memoryLogLevel = snapshot.level;
2173
+ if (this.memoryTop) {
2174
+ this.memoryTop = null;
2175
+ this.memoryTopScan.loggedAt = null;
2176
+ this.emitSseEvent("status", this.getUiStatus());
2177
+ }
1982
2178
  return;
1983
2179
  }
2180
+ if (snapshot.level === "elevated" || snapshot.level === "critical")
2181
+ this.refreshMemoryTop(snapshot);
1984
2182
  if (changed || this.memoryLogAt === null || now - this.memoryLogAt >= 10 * 60_000) {
1985
2183
  this.memoryLogAt = now;
1986
- this.logger.warn({ hostMemory: snapshot }, snapshot.level === "critical"
2184
+ this.logger.warn({ hostMemory: snapshot, ...(this.memoryTop ? { topProcesses: this.memoryTop.processes } : {}) }, snapshot.level === "critical"
1987
2185
  ? "Host memory critical — deferring new CLI spawns"
1988
2186
  : snapshot.level === "elevated" ? "Host memory pressure — slowing new CLI spawns"
1989
2187
  : "Host memory sample unavailable — slowing new CLI spawns");
1990
2188
  }
1991
2189
  this.memoryLogLevel = snapshot.level;
1992
2190
  }
2191
+ /**
2192
+ * #2015: rescan the largest processes (at most once a minute, one at a time). The first result of an episode, and
2193
+ * then at most every 10 minutes, is logged with the pressure; /health and the dashboard read the latest. Never chat.
2194
+ */
2195
+ refreshMemoryTop(snapshot) {
2196
+ const scan = this.memoryTopScan;
2197
+ const now = performance.now();
2198
+ if (scan.inFlight || (scan.at !== null && now - scan.at < 60_000))
2199
+ return;
2200
+ scan.inFlight = true;
2201
+ scan.at = now;
2202
+ void (async () => {
2203
+ const session = getTmuxSession();
2204
+ const [tmuxServerPid, panes] = this.tmuxSessionEnsured
2205
+ ? await Promise.all([TmuxManager.getServerPid(session), TmuxManager.listPanePids(session)])
2206
+ : [null, []];
2207
+ const processes = await this.memoryHogScan({
2208
+ fleetPid: process.pid, tmuxServerPid, panes,
2209
+ isInstance: name => this.daemons.has(name) || Object.hasOwn(this.fleetConfig?.instances ?? {}, name),
2210
+ }, 3);
2211
+ // Pressure may have ended while the scan ran: nothing is kept then.
2212
+ if (this.shuttingDown || processes.length === 0 || this.hostMemoryIndicator() === null)
2213
+ return;
2214
+ const before = JSON.stringify(this.memoryTop?.processes ?? null);
2215
+ this.memoryTop = { at: new Date().toISOString(), processes };
2216
+ if (scan.loggedAt === null || performance.now() - scan.loggedAt >= 10 * 60_000) {
2217
+ scan.loggedAt = performance.now();
2218
+ this.logger.warn({ hostMemory: snapshot, topProcesses: processes }, "Host memory pressure — largest processes in the fleet");
2219
+ }
2220
+ if (JSON.stringify(processes) !== before)
2221
+ this.emitSseEvent("status", this.getUiStatus());
2222
+ })().catch(err => this.logger.debug({ err }, "Largest-process scan failed"))
2223
+ .finally(() => { scan.inFlight = false; });
2224
+ }
1993
2225
  /** Wire the one fleet-wide storm into notification and recovery surfaces. */
1994
2226
  bindStormWindowEvents() {
1995
2227
  this.stormWindow.on("opened", (snapshot) => {
@@ -2311,6 +2543,7 @@ export class FleetManager {
2311
2543
  this.fleetConfig = loaded;
2312
2544
  this.savedFleetConfigSnapshot = structuredClone(this.fleetConfig);
2313
2545
  this.warnAboutRemovedLoginMode(loaded);
2546
+ this.enforceGuestSwitch();
2314
2547
  return this.fleetConfig;
2315
2548
  }
2316
2549
  relayLoginModeWarned = false;
@@ -3436,7 +3669,8 @@ export class FleetManager {
3436
3669
  * for `instance` (#1320 part B). Per entry: the channel's own adapter, no
3437
3670
  * thread, mention suppression on. Returns the number of channels posted
3438
3671
  * to. Failures are per-entry warn-and-continue — a failed echo never fails
3439
- * the web send. Entries without `web_echo: true` are never touched.
3672
+ * the web send. Only entries classicWebEchoOn admits are touched (#2054: unset follows web.echo_to_channel; an explicit
3673
+ * `web_echo: false` never echoes).
3440
3674
  *
3441
3675
  * Every copy is fenced at start time: an entry whose explicit world is
3442
3676
  * gone is skipped (only legacy adapterId-less entries fall back to the
@@ -3447,6 +3681,15 @@ export class FleetManager {
3447
3681
  * already in flight may still land late — the contract permits that.
3448
3682
  */
3449
3683
  /** `sender` (#1827): the web person's name, as the room log records them ("web-user" when none). */
3684
+ /**
3685
+ * #1920 §5.2: a web message's channel copy was sent — it is now what the channel saw. Kept so (and on disk), and sent
3686
+ * to the guests' streams only: an admin's page already has it, and nothing about it changed for them.
3687
+ */
3688
+ webMessageInChannel(instance, messageId) {
3689
+ const m = this.webChatHistory.markChannel(instance, messageId);
3690
+ if (m)
3691
+ broadcastToGuests(this.sseClients, "message", m, (err) => this.logger.debug({ err }, "SSE client write failed; evicting"));
3692
+ }
3450
3693
  async sendClassicWebEcho(instance, text, sender = WEB_NAME_FALLBACK) {
3451
3694
  const guard = this.webChannelEchoGuards.get(instance);
3452
3695
  const handled = new Set();
@@ -3463,7 +3706,7 @@ export class FleetManager {
3463
3706
  }
3464
3707
  }
3465
3708
  const entry = (this.classicChannels?.getAll() ?? [])
3466
- .find(candidate => candidate.instanceName === instance && candidate.webEcho === true
3709
+ .find(candidate => candidate.instanceName === instance && classicWebEchoOn(candidate.webEcho, this.fleetConfig?.web?.echo_to_channel)
3467
3710
  && !handled.has(`${candidate.channelId}#${candidate.adapterId ?? ""}`));
3468
3711
  if (!entry)
3469
3712
  break;
@@ -3663,12 +3906,31 @@ export class FleetManager {
3663
3906
  }
3664
3907
  /** Evidence-only admission. Bots/system/agent payloads have no trusted origin. */
3665
3908
  humanReceiptPayload(instanceName, payload, origin, originalBody) {
3909
+ // #1939 S3: "via web (verified)" comes only from the receipt written here — never from a caller's meta.
3910
+ const given = payload.meta;
3911
+ if (given && "web_verified" in given) {
3912
+ const { web_verified: _claimed, ...rest } = given;
3913
+ payload = { ...payload, meta: rest };
3914
+ }
3666
3915
  if (!origin || !this.deliveryOutbox)
3667
3916
  return payload;
3668
3917
  const row = this.deliveryOutbox.createHumanReceipt({ targetInstance: instanceName, originalBody,
3669
3918
  origin: { ...origin }, inputKind: String(payload.type), managerBootId: this.managerBootId });
3919
+ // #1939 §7: each ordinary verified web message, for the S1 beta to watch. No text, cookie or session id: the handle.
3920
+ const authority = origin.authority;
3921
+ if (authority?.kind === "web-verified" && authority.actor && authority.session) {
3922
+ try {
3923
+ this.eventLog?.insert(instanceName, "web_authority", { receipt_id: row.receipt_id, session: authority.session.handle,
3924
+ peer: authority.session.peer, surface: "local", actor: `${authority.actor.adapter_id}:${authority.actor.user_id}`,
3925
+ input_kind: String(payload.type), outcome: "web-verified", authority_epoch: authority.authority_epoch });
3926
+ }
3927
+ catch (err) {
3928
+ this.logger.warn({ err, instanceName }, "Could not record web authority");
3929
+ }
3930
+ }
3931
+ const verified = origin.platform === "web" && authority?.kind === "web-verified";
3670
3932
  return { ...payload, meta: { ...(payload.meta ?? {}),
3671
- human_receipt_id: row.receipt_id, verification_id: row.message_id } };
3933
+ human_receipt_id: row.receipt_id, verification_id: row.message_id, ...(verified ? { web_verified: "1" } : {}) } };
3672
3934
  }
3673
3935
  /** Bind at the actual IPC send, after all wake/reconnect waits. No human retry lane. */
3674
3936
  bindHumanReceiptPayload(instanceName, payload) {
@@ -3882,6 +4144,14 @@ export class FleetManager {
3882
4144
  return;
3883
4145
  }
3884
4146
  const scope = door.scope;
4147
+ // #1920 step 4: a non-admin's /web while guest sign-in is on is theirs to sign in as a guest (or revoke their own)
4148
+ // — before the table, which keeps /web for fleet admins. Per account: it touches no instance binding.
4149
+ // Decided synchronously, so an admin's /web still reaches the table with no await after the door (#1399).
4150
+ if (data.command === "dashboard" && this.isGuestWebAsk(adapterId, data.userId)) {
4151
+ await this.guestWebCommand({ adapterId, userId: data.userId, username: data.username,
4152
+ arg: String(data.options?.action ?? "").trim().toLowerCase(), replyHere: text => data.respond(text), replyPrivately: text => data.respond(text) });
4153
+ return;
4154
+ }
3885
4155
  const spec = commandSpec(data.command);
3886
4156
  if (!spec) {
3887
4157
  this.logger.info({ command: data.command, adapterId }, "Slash command is not in the command table");
@@ -4516,6 +4786,22 @@ export class FleetManager {
4516
4786
  this.logger.warn({ err, name, backend }, "Failed to sync worker skills — continuing startup");
4517
4787
  }
4518
4788
  }
4789
+ else if (kind === "classic") {
4790
+ // #2163: a Classic bot gets only the skills marked `classic` (its own look, the dashboard) — never the fleet's
4791
+ // coordination playbooks (#557 kept those out of end-user bots) — and only into a workspace AgEnD manages: the
4792
+ // room's own folder under <AGEND_HOME>/workspaces/. Anywhere else, nothing is written; `agend-agent skill <name>`
4793
+ // reads any bundled skill on demand.
4794
+ try {
4795
+ const skillsWorkDir = this.resolveKnowledgeWorkDir(config.working_directory, backend, name);
4796
+ if (insideManagedWorkspaces(skillsWorkDir))
4797
+ measureSyncWork("fleet.classicSkills", () => this.syncRoleSkills(skillsWorkDir, backend, "classic"));
4798
+ else
4799
+ this.logger.debug({ name, skillsWorkDir }, "Classic workspace is not AgEnD-managed — no skills published");
4800
+ }
4801
+ catch (err) {
4802
+ this.logger.warn({ err, name, backend }, "Failed to sync classic skills — continuing startup");
4803
+ }
4804
+ }
4519
4805
  const receipt = execution ? this.classicSettingsOwners.get(execution) : undefined;
4520
4806
  const identity = { kind, backend, model: this.resolveInstanceModel(name).display };
4521
4807
  if (receipt) {
@@ -5326,10 +5612,14 @@ export class FleetManager {
5326
5612
  // #1620: fleet.yaml's web.session.local, read at every check — a Settings save or a reload applies at once
5327
5613
  // (shortening ends the sessions it now outlives). The public link's policy stays fixed.
5328
5614
  localPolicy: () => localSessionPolicy(this.fleetConfig?.web?.session?.local),
5615
+ // #1920 §5.4: a guest's, from web.guests.session (never longer than the surface's own).
5616
+ guestPolicy: () => guestSessionPolicy(this.fleetConfig?.web?.guests?.session),
5329
5617
  });
5618
+ this.enforceGuestSwitch(); // #1920: guests off while the fleet was down
5330
5619
  }
5331
5620
  if (!this.returningDevices) {
5332
5621
  this.returningDevices = new ReturningDevices({ dataDir: this.dataDir, onWarn: message => this.logger.warn(message) });
5622
+ this.enforceGuestSwitch(); // #1920: guests' devices too, if guests went off while down
5333
5623
  }
5334
5624
  if (!this.webLoginCodes) {
5335
5625
  this.webLoginCodes = new WebLoginCodes({
@@ -5341,6 +5631,16 @@ export class FleetManager {
5341
5631
  },
5342
5632
  });
5343
5633
  }
5634
+ if (!this.webGuestLoginCodes) {
5635
+ this.webGuestLoginCodes = new GuestLoginCodes({
5636
+ onEvent: event => {
5637
+ if (event === "burned")
5638
+ this.logger.warn("A guest web login code was used up by wrong attempts");
5639
+ else if (event === "breaker-open")
5640
+ this.logger.warn("Guest web sign-in paused: too many wrong login codes");
5641
+ },
5642
+ });
5643
+ }
5344
5644
  }
5345
5645
  /**
5346
5646
  * A single-use login code for the dashboard, for a channel only the operator
@@ -5464,6 +5764,7 @@ export class FleetManager {
5464
5764
  dispatch: (req, res) => this.dispatchWebHttp(req, res, this.fleetConfig?.health_port ?? 19280) }),
5465
5765
  revoke: id => {
5466
5766
  this.webLoginCodes?.revokeAudience(id);
5767
+ this.webGuestLoginCodes?.revokeAudience(id);
5467
5768
  const result = this.webSessions?.revokeExposure(id);
5468
5769
  if (result && !result.durable)
5469
5770
  this.logger.warn("Public session revocation could not be saved; exposure remains closed");
@@ -5495,6 +5796,24 @@ export class FleetManager {
5495
5796
  if (!current() || !this.publicOwnerCurrent(info.owner))
5496
5797
  throw new Error("public owner changed");
5497
5798
  }
5799
+ /**
5800
+ * #1920 §5.5 (step 4b): a guest signing in through the public link — said in the General of the admin who opened it
5801
+ * (who, how many agents), awaited as an admin's public sign-in is, while that link is the open one, its opener still
5802
+ * current, and the account still an admitted guest. A failure refuses the sign-in.
5803
+ */
5804
+ async confirmGuestPublicWebLogin(info, current) {
5805
+ const admitted = () => {
5806
+ const open = this.publicWebLink?.openSignin();
5807
+ return current() && open && open.exposureId === info.exposureId && this.publicOwnerCurrent(open.owner) && this.guestSignInAllowed(info.account) ? open : null;
5808
+ };
5809
+ const open = admitted();
5810
+ if (!open)
5811
+ throw new Error("guest public sign-in unavailable");
5812
+ const adapter = open.owner.binding;
5813
+ await adapter.sendText(open.owner.chatId, t("web.guest_public_login_notice", this.webPerson(info.account).name, info.label, this.guestScope(info.account).size, info.handle.slice(0, 8)), { threadId: open.owner.threadId, allowedMentions: { parse: [] } });
5814
+ if (!admitted())
5815
+ throw new Error("guest public sign-in changed");
5816
+ }
5498
5817
  /** Telegram's typed command enters through the same strict owner and nonce path as Discord. */
5499
5818
  async dashboardMenu(msg) {
5500
5819
  const id = msg.adapterId;
@@ -5818,16 +6137,197 @@ export class FleetManager {
5818
6137
  this.initializeWebSessions();
5819
6138
  void this.publicWebLink?.close("dashboard revoke");
5820
6139
  this.webLoginCodes.revoke();
6140
+ this.webGuestLoginCodes.revokeAll();
6141
+ this.guestRevokes++; // and every guest /web still on its way
5821
6142
  // #1570: no browser may ask the sign-in page for a code any more.
5822
6143
  const devices = this.returningDevices.revokeAll();
5823
6144
  const sessions = this.webSessions.revokeAll();
5824
- const result = { count: sessions.count, durable: sessions.durable && devices.durable };
6145
+ // #1939 §10: and every web authority given so far (its receipts read as none).
6146
+ const authority = this.revokeWebAuthority();
6147
+ const result = { count: sessions.count, durable: sessions.durable && devices.durable && authority.durable };
5825
6148
  if (result.durable)
5826
6149
  this.logger.info({ count: result.count, devices: devices.count }, "Web sessions revoked (all)");
5827
6150
  else
5828
6151
  this.logger.warn({ count: result.count }, "Web sessions revoked in memory only — the session file could not be updated or removed");
5829
6152
  return result;
5830
6153
  }
6154
+ /**
6155
+ * #1920 phase 3: Settings → Web sign-ins → Guests — the switches, each connection's access mode and the agents named for
6156
+ * an open one, and every guest account (signed in, with a returning device, or narrowed): who, its platform, what it
6157
+ * reaches now, its sessions and devices, and its narrowing. Ids are the accounts' own (an admin's page).
6158
+ */
6159
+ guestOverview() {
6160
+ this.initializeWebSessions();
6161
+ const cfg = this.fleetConfig?.web?.guests;
6162
+ const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
6163
+ const connections = [...this.worlds.entries()].map(([id, w]) => ({ id, type: w.adapter.type, mode: (w.accessManager.getMode() === "open" ? "open" : "allowlist"),
6164
+ named: [...(own(cfg?.open_instances, id) ?? [])] }));
6165
+ const accounts = new Map();
6166
+ for (const a of this.webSessions.guestActivity())
6167
+ accounts.set(`${a.adapterId}\u0000${a.userId}`, a);
6168
+ const devices = this.returningDevices.guestCounts();
6169
+ for (const k of devices.keys())
6170
+ if (!accounts.has(k)) {
6171
+ const [adapterId, userId] = k.split("\u0000");
6172
+ accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
6173
+ }
6174
+ for (const key of Object.keys(cfg?.narrow ?? {})) {
6175
+ const at = key.indexOf(":"), adapterId = key.slice(0, at), userId = key.slice(at + 1), k = `${adapterId}\u0000${userId}`;
6176
+ if (at > 0 && !accounts.has(k))
6177
+ accounts.set(k, { adapterId, userId, sessions: 0, lastSeen: null });
6178
+ }
6179
+ const guests = [...accounts.entries()].map(([k, a]) => {
6180
+ const owner = { adapterId: a.adapterId, userId: a.userId };
6181
+ const badge = this.guestBadge(owner);
6182
+ // `reach`: before narrowing — what Settings offers to narrow to (#2104 review: never an agent it cannot reach).
6183
+ return { ...a, name: badge.name, platform: badge.platform, scope: [...this.guestScope(owner)].sort(), reach: [...this.guestReach(owner)].sort(),
6184
+ devices: devices.get(k) ?? 0, narrow: own(cfg?.narrow, `${a.adapterId}:${a.userId}`) ?? null };
6185
+ }).sort((x, y) => (y.lastSeen ?? -1) - (x.lastSeen ?? -1));
6186
+ return { enabled: cfg?.enabled === true, chat: cfg?.chat !== false, instances: Object.keys(this.fleetConfig?.instances ?? {}).sort(), connections, guests };
6187
+ }
6188
+ /** #1920 phase 3 (the app shell): a guest's chip — its web name, and its connection's platform (discord, telegram). */
6189
+ guestBadge(owner) {
6190
+ return { name: this.webPerson(owner).name, platform: this.worlds.get(owner.adapterId)?.adapter.type ?? owner.adapterId };
6191
+ }
6192
+ /** #1920 step 4: whether a `/web` is a guest's — guest sign-in on, and not from a fleet admin (theirs runs as before). */
6193
+ isGuestWebAsk(adapterId, userId) {
6194
+ return !!adapterId && this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(userId, adapterId);
6195
+ }
6196
+ /** #1920 step 4 (auth-api): a guest code's account may still sign in — guests on, and something in its scope now. */
6197
+ guestSignInAllowed(account) {
6198
+ return this.fleetConfig?.web?.guests?.enabled === true && !this.isFleetAdmin(account.userId, account.adapterId) && this.guestScope(account).size > 0;
6199
+ }
6200
+ /**
6201
+ * #1920 §5.4: a guest's own `/web revoke` — its sessions and its outstanding code; nobody else's (an admin's revokes
6202
+ * everyone's, as before).
6203
+ */
6204
+ revokeGuestWebSessions(owner) {
6205
+ this.initializeWebSessions();
6206
+ const key = `${owner.adapterId}\u0000${owner.userId}`;
6207
+ this.guestAccountRevokes.set(key, (this.guestAccountRevokes.get(key) ?? 0) + 1); // its /web on its way, too
6208
+ while (this.guestAccountRevokes.size > 4096)
6209
+ this.guestAccountRevokes.delete(this.guestAccountRevokes.keys().next().value);
6210
+ this.webGuestLoginCodes.revokeRecipient(owner);
6211
+ // §5.4: its sessions and its returning devices (no browser of theirs may ask for a code any more).
6212
+ const devices = this.returningDevices.revokeGuest(owner);
6213
+ const ended = this.webSessions.revokeAll(r => r.tier === "guest" && r.owner?.adapterId === owner.adapterId && r.owner.userId === owner.userId);
6214
+ this.logger.info({ adapterId: owner.adapterId, count: ended.count, devices: devices.count, durable: ended.durable && devices.durable }, "Guest web sessions revoked by their account");
6215
+ return { count: ended.count, durable: ended.durable && devices.durable };
6216
+ }
6217
+ /**
6218
+ * #1920 §2.3 (step 4): `/web` from someone who is not a fleet admin, while guest sign-in is on. `revoke` ends their
6219
+ * own guest sessions. Otherwise a guest code (tier guest, bound to this account) goes privately — a DM, else the
6220
+ * slash command's ephemeral answer (`replyPrivately`) — once a minute at most, and only when their scope (from what
6221
+ * they may already use in chat, rooms read at sign-in) holds something. The code itself is never posted where asked.
6222
+ * Callers ask isGuestWebAsk first (synchronously); false here too when it is not a guest's.
6223
+ */
6224
+ async guestWebCommand(ask) {
6225
+ if (!this.isGuestWebAsk(ask.adapterId, ask.userId))
6226
+ return false;
6227
+ const owner = { adapterId: ask.adapterId, userId: ask.userId };
6228
+ const say = (text) => ask.replyHere(text).catch(() => undefined);
6229
+ if (ask.arg === "revoke") {
6230
+ const ended = this.revokeGuestWebSessions(owner);
6231
+ await say(t(ended.durable ? "web.guest_revoked" : "web.guest_revoked_not_durable", ended.count));
6232
+ return true;
6233
+ }
6234
+ const token = this.webToken;
6235
+ const adapter = this.adapters.get(ask.adapterId);
6236
+ if (this.shuttingDown || !token || !adapter) {
6237
+ await say(t("dashboard.starting"));
6238
+ return true;
6239
+ }
6240
+ const key = `${owner.adapterId}\u0000${owner.userId}`, asked = this.guestCodeAskedAt.get(key);
6241
+ if (this.guestCodeInFlight.has(key) || (asked !== undefined && performance.now() - asked < 60_000)) {
6242
+ await say(t("web.guest_wait"));
6243
+ return true;
6244
+ }
6245
+ this.guestCodeInFlight.add(key);
6246
+ try {
6247
+ await this.issueGuestCode(ask, owner, key, adapter, token);
6248
+ }
6249
+ finally {
6250
+ this.guestCodeInFlight.delete(key);
6251
+ }
6252
+ return true;
6253
+ }
6254
+ /** guestWebCommand's issuance, one per account at a time; a revoke made meanwhile (either kind) ends it unissued. */
6255
+ async issueGuestCode(ask, owner, key, adapter, token) {
6256
+ const say = (text) => ask.replyHere(text).catch(() => undefined);
6257
+ const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
6258
+ const unrevoked = () => this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes;
6259
+ // §3.2: its rooms are read at sign-in (bounded; never longer than the reads' own deadline).
6260
+ await withinBudget(this.refreshGuestRooms(owner, { mode: "force" }), performance.now() + 6_000).catch(() => undefined);
6261
+ if (!unrevoked())
6262
+ return; // revoked while its rooms were read: no code
6263
+ const scope = this.guestScope(owner);
6264
+ if (!scope.size) {
6265
+ const open = this.worlds.get(owner.adapterId)?.accessManager.getMode() === "open";
6266
+ await say(t(open ? "web.guest_not_set_up" : "web.guest_nothing"));
6267
+ return;
6268
+ }
6269
+ this.initializeWebSessions();
6270
+ const codes = this.webGuestLoginCodes;
6271
+ if (codes.pausedForMs() > 0) {
6272
+ await say(t("web.guest_paused"));
6273
+ return;
6274
+ }
6275
+ // §5.5 (step 4b): with the public link open, the code works through it too (an admin opened it; a guest is still a
6276
+ // guest there) — most guests are not on the host's network. Its closing withdraws that audience (revokeAudience).
6277
+ const open = this.publicWebLink?.openSignin() ?? null;
6278
+ // One code per account (a new one replaces only its own); at the cap, refused — never another guest's evicted.
6279
+ const login = codes.issue({ epoch: tokenEpoch(token), audiences: ["local", ...(open ? [open.exposureId] : [])],
6280
+ recipient: { adapterId: owner.adapterId, userId: owner.userId, ...(ask.username ? { username: ask.username } : {}) } });
6281
+ if (!login) {
6282
+ await say(t("web.guest_busy"));
6283
+ return;
6284
+ }
6285
+ this.guestCodeAskedAt.delete(key);
6286
+ this.guestCodeAskedAt.set(key, performance.now());
6287
+ while (this.guestCodeAskedAt.size > 256)
6288
+ this.guestCodeAskedAt.delete(this.guestCodeAskedAt.keys().next().value);
6289
+ const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(owner.adapterId) === adapter
6290
+ && unrevoked() && this.guestSignInAllowed(owner);
6291
+ const privateText = `${t("web.guest_intro", scope.size)}\n${this.topicCommands.getDashboardText()}${open ? `\n${t("web.guest_public", open.url)}` : ""}\n${t("dashboard.code", login.display, Math.round(LOGIN_CODE_TTL_MS / 60_000))}`;
6292
+ const deadline = performance.now() + 10_000;
6293
+ try {
6294
+ let byDm = false;
6295
+ try {
6296
+ if (!adapter.sendDirect)
6297
+ throw new Error("DM unavailable");
6298
+ await withinBudget(adapter.sendDirect(owner.userId, `${privateText}\n${t("dashboard.code_next")}`, { disablePreview: true }), Math.min(deadline, performance.now() + 5_000));
6299
+ byDm = true;
6300
+ }
6301
+ catch (err) {
6302
+ this.logDashboardDmFailure(err);
6303
+ if (!ask.replyPrivately || !current())
6304
+ throw err;
6305
+ const id = await withinBudget(ask.replyPrivately(privateText), deadline); // a slash command's answer: ephemeral
6306
+ if (!id)
6307
+ throw new Error("private delivery unconfirmed");
6308
+ }
6309
+ if (!current())
6310
+ throw new Error("guest no longer admitted");
6311
+ if (byDm) {
6312
+ await this.sendCodeAlone((body, opts) => adapter.sendDirect(owner.userId, body, opts), adapter.type, login.display);
6313
+ if (!current())
6314
+ throw new Error("guest no longer admitted");
6315
+ await say(t("web.guest_sent"));
6316
+ }
6317
+ }
6318
+ catch (err) {
6319
+ codes.revokeIfCurrent(login.issuanceId); // this flow's code only, never a later one's
6320
+ this.logger.info({ err: err?.message, adapterId: owner.adapterId }, "Guest sign-in code was not delivered privately");
6321
+ if (!unrevoked())
6322
+ return; // revoked meanwhile: the revoke's own answer said so
6323
+ const start = adapter.type === "telegram" ? this.tgStartWebUrl(owner.adapterId) : null;
6324
+ // #1742 for guests (step 4b-3): its START button (a bare /start, or the link's /start web) then asks again, from
6325
+ // the DM — once, within the window.
6326
+ if (adapter.type === "telegram")
6327
+ this.openTgWebStartWindow(owner.adapterId, owner.userId);
6328
+ await say(start ? t("web.guest_dm_failed_link", start) : t("web.guest_dm_failed"));
6329
+ }
6330
+ }
5831
6331
  /** #1570 (auth-api): this browser becomes a returning device of the code's owner. The cookie value, or null. */
5832
6332
  rememberReturningDevice(owner, input) {
5833
6333
  const token = this.webToken;
@@ -5855,6 +6355,12 @@ export class FleetManager {
5855
6355
  if (!token || this.shuttingDown)
5856
6356
  return { kind: "refused" };
5857
6357
  this.initializeWebSessions();
6358
+ // #2095 review: verified before the request's body was read — a revoke (its own, or an admin's) made meanwhile
6359
+ // removed it, and nothing it asks for may then go out.
6360
+ if (!this.returningDevices.current(device, tokenEpoch(token)))
6361
+ return { kind: "refused" };
6362
+ if (device.tier === "guest")
6363
+ return this.requestGuestDeviceCode(device, input, token);
5858
6364
  const paused = this.webLoginCodes.pausedForMs();
5859
6365
  if (paused > 0)
5860
6366
  return { kind: "limited", retryAfterMs: paused };
@@ -5886,7 +6392,7 @@ export class FleetManager {
5886
6392
  this.logger.info({ err: err?.message, adapterId }, "Sign-in code request: the private message could not be sent");
5887
6393
  return { kind: "refused" };
5888
6394
  }
5889
- if (this.webToken !== token || !this.publicOwnerCurrent(owner)) {
6395
+ if (this.webToken !== token || !this.publicOwnerCurrent(owner) || !this.returningDevices.current(device, tokenEpoch(token))) {
5890
6396
  this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
5891
6397
  return { kind: "refused" };
5892
6398
  }
@@ -5894,15 +6400,94 @@ export class FleetManager {
5894
6400
  // Asked again after the follow-up's wait: the owner, the token, shutdown, and a public request's exposure.
5895
6401
  const exposureGone = input.surface === "gateway"
5896
6402
  && (this.publicWebLink?.exposureId !== input.exposureId || this.publicWebLink?.status().state !== "open");
5897
- if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone) {
6403
+ if (this.shuttingDown || this.webToken !== token || !this.publicOwnerCurrent(owner) || exposureGone || !this.returningDevices.current(device, tokenEpoch(token))) {
5898
6404
  this.webLoginCodes.revokeIfCurrent(issued.issuanceId);
5899
6405
  return { kind: "refused" };
5900
6406
  }
5901
6407
  this.logger.info({ adapterId, surface: input.surface }, "Web sign-in code sent privately to a returning device's owner");
5902
6408
  return { kind: "sent" };
5903
6409
  }
6410
+ /**
6411
+ * #1920 step 4b: a guest's returning device asks for a code — a guest code for that account, by DM only, under the
6412
+ * same rules as its /web (guests on, not an admin, something in scope now; one per account and the cap; one flow per
6413
+ * account at a time; a revoke meanwhile ends it), its own limiter, and the guests' breaker. Asked through the public
6414
+ * link: only while that exposure is the open one (its code then works through it alone), as an admin's device.
6415
+ */
6416
+ async requestGuestDeviceCode(device, input, token) {
6417
+ const codes = this.webGuestLoginCodes;
6418
+ const account = { adapterId: device.owner.adapterId, userId: device.owner.userId };
6419
+ const key = `${account.adapterId}\u0000${account.userId}`;
6420
+ const adapter = this.adapters.get(account.adapterId);
6421
+ const viaLink = input.surface === "gateway";
6422
+ const linkOpen = () => !!input.exposureId && this.publicWebLink?.openSignin()?.exposureId === input.exposureId;
6423
+ if ((viaLink ? !linkOpen() : input.exposureId !== undefined) || !adapter?.sendDirect || !this.guestSignInAllowed(account)) {
6424
+ this.logger.info({ adapterId: account.adapterId, surface: input.surface }, "Guest sign-in code request refused");
6425
+ return { kind: "refused" };
6426
+ }
6427
+ const paused = codes.pausedForMs();
6428
+ if (paused > 0)
6429
+ return { kind: "limited", retryAfterMs: paused };
6430
+ const asked = this.guestCodeAskedAt.get(key);
6431
+ if (this.guestCodeInFlight.has(key))
6432
+ return { kind: "limited", retryAfterMs: 5_000 };
6433
+ if (asked !== undefined && performance.now() - asked < 60_000)
6434
+ return { kind: "limited", retryAfterMs: 60_000 - (performance.now() - asked) };
6435
+ const admitted = this.guestCodeRequestLimiter.admit(device.idHash);
6436
+ if (!admitted.ok)
6437
+ return { kind: "limited", retryAfterMs: admitted.retryAfterMs };
6438
+ const revokes = this.guestRevokes, accountRevokes = this.guestAccountRevokes.get(key) ?? 0;
6439
+ const current = () => !this.shuttingDown && this.webToken === token && this.adapters.get(account.adapterId) === adapter
6440
+ && this.guestRevokes === revokes && (this.guestAccountRevokes.get(key) ?? 0) === accountRevokes && this.guestSignInAllowed(account)
6441
+ && (!viaLink || linkOpen()) && this.returningDevices.current(device, tokenEpoch(token));
6442
+ const issued = codes.issue({ epoch: tokenEpoch(token), recipient: account, audiences: [viaLink ? input.exposureId : "local"] });
6443
+ if (!issued)
6444
+ return { kind: "limited", retryAfterMs: 60_000 }; // the guests' cap: never someone's evicted
6445
+ this.guestCodeInFlight.add(key);
6446
+ this.guestCodeAskedAt.delete(key);
6447
+ this.guestCodeAskedAt.set(key, performance.now());
6448
+ try {
6449
+ const text = t("web.resend_code", issued.display, Math.round(LOGIN_CODE_TTL_MS / 60_000), input.label || t("web.resend_unknown_browser"), t(viaLink ? "web.resend_public" : "web.resend_local"))
6450
+ + "\n" + t("dashboard.code_next");
6451
+ try {
6452
+ await withinBudget(adapter.sendDirect(account.userId, text, { disablePreview: true }), performance.now() + 5_000);
6453
+ }
6454
+ catch (err) {
6455
+ codes.revokeIfCurrent(issued.issuanceId);
6456
+ this.logger.info({ err: err?.message, adapterId: account.adapterId }, "Guest sign-in code request: the private message could not be sent");
6457
+ return { kind: "refused" };
6458
+ }
6459
+ if (!current()) {
6460
+ codes.revokeIfCurrent(issued.issuanceId);
6461
+ return { kind: "refused" };
6462
+ }
6463
+ await this.sendCodeAlone((body, opts) => adapter.sendDirect(account.userId, body, opts), adapter.type, issued.display);
6464
+ if (!current()) {
6465
+ codes.revokeIfCurrent(issued.issuanceId);
6466
+ return { kind: "refused" };
6467
+ }
6468
+ this.logger.info({ adapterId: account.adapterId }, "Guest sign-in code sent privately to a returning device's account");
6469
+ return { kind: "sent" };
6470
+ }
6471
+ finally {
6472
+ this.guestCodeInFlight.delete(key);
6473
+ }
6474
+ }
6475
+ /** #1920 step 4b (auth-api): this browser becomes a guest's returning device. The cookie value, or null. */
6476
+ rememberGuestDevice(account, input) {
6477
+ const token = this.webToken;
6478
+ if (!token)
6479
+ return null;
6480
+ this.initializeWebSessions();
6481
+ return this.returningDevices.rememberGuest(account, { ...input, tokenEpoch: tokenEpoch(token), webToken: token });
6482
+ }
5904
6483
  /** Called by the sign-in endpoint: a login the operator did not make should be visible to them. */
5905
6484
  onWebLogin(info) {
6485
+ // #1920 §2.3: a guest's sign-in is always said in General (web.notify_login is the operator's own sign-ins): who,
6486
+ // and how many agents they now reach — admins stay aware of everyone who is not one of them.
6487
+ if (info.tier === "guest" && info.owner) {
6488
+ this.notifyFleetError(t("web.guest_login_notice", this.webPerson(info.owner).name, info.label, this.guestScope(info.owner).size, info.handle.slice(0, 8)));
6489
+ return;
6490
+ }
5906
6491
  if (this.fleetConfig?.web?.notify_login === false)
5907
6492
  return;
5908
6493
  // The session handle makes each notice distinct: notifyFleetError throttles by text, and a second
@@ -6101,11 +6686,17 @@ export class FleetManager {
6101
6686
  * landed mid-edit) must not be cleared or disturbed (Prism b).
6102
6687
  */
6103
6688
  async appendCleanupWarning(target, watch, warning, cleanupCommand) {
6104
- const text = formatRestartProgressCompletion("update", {
6105
- ...watch.summary,
6106
- warningText: warning,
6107
- warningCommand: cleanupCommand,
6108
- }, watch.receipt.startedAt);
6689
+ // #2175: one text per outcome, frozen at its first tick. The completion carries elapsed seconds, so a text made
6690
+ // anew each tick would never equal the one already handed over and landed (terminalLanded compares exactly).
6691
+ const key = JSON.stringify([warning, cleanupCommand ?? null]);
6692
+ if (watch.frozen?.key !== key) {
6693
+ watch.frozen = { key, text: formatRestartProgressCompletion("update", {
6694
+ ...watch.summary,
6695
+ warningText: warning,
6696
+ warningCommand: cleanupCommand,
6697
+ }, watch.receipt.startedAt) };
6698
+ }
6699
+ const text = watch.frozen.text;
6109
6700
  const stopWatching = () => {
6110
6701
  this.updateCleanupWatch = null;
6111
6702
  if (this.updateProgressTimer)
@@ -6113,10 +6704,22 @@ export class FleetManager {
6113
6704
  this.updateProgressTimer = null;
6114
6705
  };
6115
6706
  if (text !== this.lastUpdateProgressText) {
6116
- const adapter = this.adapters.get(target.adapterId);
6117
- if (!adapter)
6118
- return; // retry on a later tick
6119
- await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
6707
+ // #2175: while the completion is still owed by the background retry, that retry is the message's only
6708
+ // writer — this text (the completion plus the warning) goes to it rather than racing it. Handing it over is not
6709
+ // delivery: the receipt and this watch stay until the retry reports this exact text landed (a later tick).
6710
+ const progress = this.startupProgress;
6711
+ if (progress?.terminalLanded(text)) {
6712
+ // landed through the retry
6713
+ }
6714
+ else if (progress?.replaceTerminalText(text)) {
6715
+ return;
6716
+ }
6717
+ else {
6718
+ const adapter = this.adapters.get(target.adapterId);
6719
+ if (!adapter)
6720
+ return; // retry on a later tick
6721
+ await adapter.editMessage(target.chatId, target.messageId, text, target.threadId);
6722
+ }
6120
6723
  this.lastUpdateProgressText = text;
6121
6724
  }
6122
6725
  const verdict = cleanupWatchVerdict(readUpdateProgress(this.dataDir), watch.receipt);
@@ -6266,6 +6869,9 @@ export class FleetManager {
6266
6869
  const { getTmuxSocketName: getSocket } = await import("./paths.js");
6267
6870
  TmuxManager.setSocketName(getSocket());
6268
6871
  await TmuxManager.ensureSession(getTmuxSession());
6872
+ this.tmuxSessionEnsured = true;
6873
+ // #2072: what this host can run — probed in the background, published for every picker.
6874
+ this.startAvailabilityRefresh();
6269
6875
  // Pre-flight (advisory, fire-and-forget): warm the per-backend auth cache
6270
6876
  // before instances spawn, so a CLI that comes up on its sign-in screen gets
6271
6877
  // an instant 🔑 verdict instead of decaying into crash/MCP-died noise.
@@ -6503,6 +7109,7 @@ export class FleetManager {
6503
7109
  const generals = allEntries.filter(([_, cfg]) => cfg.general_topic);
6504
7110
  const others = allEntries.filter(([_, cfg]) => !cfg.general_topic);
6505
7111
  const startupProgress = new RestartProgress(this.runnableStartupCount(fleet, topicMode), pendingUpdateProgress?.startedAt ?? startupStartedAt, this.logger, { mode: pendingProgressOperation === "full-restart" ? "reload" : pendingUpdateProgress ? "update" : "restart" });
7112
+ this.startupProgress = startupProgress;
6506
7113
  if (generals.length > 0) {
6507
7114
  for (const [name, cfg] of generals) {
6508
7115
  try {
@@ -6589,6 +7196,18 @@ export class FleetManager {
6589
7196
  this.pruneEventLog();
6590
7197
  this.eventLogPruneTimer = setInterval(() => this.pruneEventLog(), 24 * 60 * 60_000);
6591
7198
  this.eventLogPruneTimer.unref?.();
7199
+ // #2122 §5.5: usage is collected from the start, not from the first time someone opens a report — the same bounded
7200
+ // background passes the cache report uses (16 MB a pass, then every 10 minutes).
7201
+ {
7202
+ const cache = this.cacheServiceFor();
7203
+ cache.start();
7204
+ void cache.kick();
7205
+ }
7206
+ // #1920 §3.2: guests' ClassicBot room membership, read in the background — now, then each key ten minutes after
7207
+ // its answer (a minute after a failed read; the tick only finds what is due). A request only reads the answers.
7208
+ void this.refreshAllGuestRooms();
7209
+ this.guestRoomsTimer = setInterval(() => { void this.refreshAllGuestRooms(); }, GUEST_ROOM_TICK_MS);
7210
+ this.guestRoomsTimer.unref?.();
6592
7211
  // #1335: delivery-outbox.db and Task Board retention. Same once-at-startup
6593
7212
  // + daily-timer pattern; chunked DELETEs so one run cannot hold the loop.
6594
7213
  void this.pruneOutboxAndTasks();
@@ -7089,7 +7708,8 @@ export class FleetManager {
7089
7708
  adapters: { total: this.adapterState.size, connected, states, details },
7090
7709
  startupComplete: this.startupComplete,
7091
7710
  memory: readFleetMemory(),
7092
- hostMemory,
7711
+ hostMemory: this.memoryTop && (hostMemory.level === "elevated" || hostMemory.level === "critical")
7712
+ ? { ...hostMemory, topProcesses: this.memoryTop.processes, topProcessesAt: this.memoryTop.at } : hostMemory,
7093
7713
  problems,
7094
7714
  };
7095
7715
  }
@@ -8240,6 +8860,13 @@ export class FleetManager {
8240
8860
  if (!isPrivateChat && !cmdMatch && rawText.startsWith("/")) {
8241
8861
  return; // bare slash in group — ignore silently
8242
8862
  }
8863
+ // #1920 step 4b-3: /web typed in a ClassicBot room (a group: `/web@ThisBot`) by someone who is not a fleet admin,
8864
+ // while guest sign-in is on: their guest code, privately — as anywhere else. An admin's goes on as before.
8865
+ if (/^\/(?:web|dashboard)(?:\s|$)/i.test(text) && msgAdapter && this.isGuestWebAsk(msg.adapterId, msg.userId)) {
8866
+ await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username,
8867
+ arg: text.replace(/^\/(?:web|dashboard)/i, "").trim().toLowerCase(), replyHere: body => msgAdapter.sendText(chatId, body) });
8868
+ return;
8869
+ }
8243
8870
  // Handle /start command
8244
8871
  if (text === "/start" || text.startsWith("/start ")) {
8245
8872
  // #1686: intercept /start web before the ClassicBot admission flow runs.
@@ -8248,15 +8875,21 @@ export class FleetManager {
8248
8875
  // #1742: a bare /start from a fleet admin whose /web just failed to reach them privately is that /start web
8249
8876
  // (Telegram's START button sends it bare) — once, within the window; any other bare /start is ClassicBot's.
8250
8877
  const admin = isPrivateChat && this.isFleetAdmin(msg.userId, msg.adapterId ?? "");
8878
+ // #1920 step 4b-3: a guest's too (guest sign-in on, not an admin) — its /web reached it no other way.
8879
+ const guest = isPrivateChat && !admin && this.isGuestWebAsk(msg.adapterId, msg.userId);
8251
8880
  const webStart = isPrivateChat && (requestedBackend === "web"
8252
- || (text === "/start" && admin && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
8881
+ || (text === "/start" && (admin || guest) && this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId)));
8253
8882
  if (webStart) {
8254
8883
  if (requestedBackend === "web")
8255
8884
  this.takeTgWebStartWindow(msg.adapterId ?? "", msg.userId);
8256
8885
  // #1742: the /web menu right here; the code comes to this DM.
8257
8886
  if (admin)
8258
8887
  await this.startWebInDm(msg, msgAdapter ?? undefined);
8259
- // Non-fleet-admins: /start alone is enough to open the DM channel; no further action.
8888
+ // A guest: its guest code, here (this DM is where it goes).
8889
+ else if (guest && msgAdapter)
8890
+ await this.guestWebCommand({ adapterId: msg.adapterId, userId: msg.userId, username: msg.username, arg: "",
8891
+ replyHere: body => msgAdapter.sendText(chatId, body) });
8892
+ // Anyone else: /start alone is enough to open the DM channel; no further action.
8260
8893
  return;
8261
8894
  }
8262
8895
  // All new starts, including explicit backend and later chooser callbacks,
@@ -8560,10 +9193,7 @@ export class FleetManager {
8560
9193
  this.afterUserInboundDelivered(generalInstance, msg);
8561
9194
  this.logger.info(`${msg.username} → ${generalInstance}: ${(text ?? "").slice(0, 100)}`);
8562
9195
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), generalInstance);
8563
- this.emitSseEvent("message", {
8564
- instance: generalInstance, sender: msg.username, role: "user",
8565
- text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
8566
- });
9196
+ this.emitPlatformInbound(generalInstance, msg, text ?? "");
8567
9197
  }
8568
9198
  catch (err) {
8569
9199
  this.logger.warn({ err: err.message, instanceName: generalInstance }, "General wake/delivery failed");
@@ -8672,9 +9302,16 @@ export class FleetManager {
8672
9302
  this.afterUserInboundDelivered(instanceName, msg);
8673
9303
  this.logger.info(`${msg.username} → ${instanceName}: ${(text ?? "").slice(0, 100)}`);
8674
9304
  this.eventLog?.logActivity("message", msg.username, (text ?? "").slice(0, 200), instanceName);
9305
+ this.emitPlatformInbound(instanceName, msg, text ?? "");
9306
+ }
9307
+ /**
9308
+ * A person's platform message as the web chat shows it — the user's own words, as a user (#1306), in the instance's
9309
+ * history (#1565). #1920 §5.2: said in the instance's topic, General or room, so it is that channel's, by that account.
9310
+ */
9311
+ emitPlatformInbound(instance, msg, text) {
8675
9312
  this.emitSseEvent("message", {
8676
- instance: instanceName, sender: msg.username, role: "user",
8677
- text: (text ?? "").slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
9313
+ instance, sender: msg.username, role: "user", text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
9314
+ channel: true, from: webChatFrom(msg.adapterId, msg.userId),
8678
9315
  });
8679
9316
  }
8680
9317
  /** Handle outbound tool calls from a daemon instance */
@@ -8976,7 +9613,14 @@ export class FleetManager {
8976
9613
  // an agent cannot invent it to suppress the normal completion marker.
8977
9614
  if (!error && result != null && msg.statusOnly !== true) {
8978
9615
  try {
8979
- void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView);
9616
+ // #1920 §5.2: shown to the instance's guests only when it landed in the instance's own topic or room.
9617
+ let inChannel = false;
9618
+ try {
9619
+ inChannel = outAdapter !== WEB_ONLY_REPLY_SINK
9620
+ && this.landedInOwnChannel(instanceName, contextAdapterId ?? this.getInstanceAdapterId(instanceName), result);
9621
+ }
9622
+ catch { /* not known: not the channel's (fail closed), and the bookkeeping below still runs */ }
9623
+ void this.afterReplyRouted(instanceName, args, senderSessionName, buttonsView, inChannel);
8980
9624
  }
8981
9625
  catch (err) {
8982
9626
  this.logger.warn({ err, instanceName }, "Reply delivered but post-delivery bookkeeping failed");
@@ -9030,7 +9674,35 @@ export class FleetManager {
9030
9674
  return this.worlds.size === 0 && this.isWebOnlyFleet() ? { adapter: WEB_ONLY_PROMPT_SINK, adapterId: "web", chatId: "web" } : null;
9031
9675
  }
9032
9676
  /** Resolves when the reply is in the web chat (#1770: after its files are copied); the caller need not wait. */
9033
- afterReplyRouted(instanceName, args, senderSessionName, buttons = null) {
9677
+ /**
9678
+ * #1920 §5.2: whether a platform send landed in the instance's own place — its ClassicBot room, or its topic in its
9679
+ * connection's group — so its channel's members saw it. Anything else (another chat, a DM, a context left from General,
9680
+ * an answer without the chat it went to) is not the channel's.
9681
+ */
9682
+ landedInOwnChannel(instance, adapterId, sent) {
9683
+ const s = sent && typeof sent === "object" ? sent : {};
9684
+ if (!adapterId || typeof s.chatId !== "string" || !s.chatId)
9685
+ return false;
9686
+ const roomId = this.classicChannels?.getChannelIdByInstance(instance);
9687
+ if (roomId !== undefined)
9688
+ return (this.classicChannels?.getAdapterIdByInstance(instance) ?? this.getPrimaryAdapterId()) === adapterId && roomId === s.chatId;
9689
+ const cfg = this.fleetConfig?.instances && Object.hasOwn(this.fleetConfig.instances, instance) ? this.fleetConfig.instances[instance] : undefined;
9690
+ const world = this.worlds.get(adapterId);
9691
+ if (!cfg || this.getInstanceAdapterId(instance) !== adapterId || !world?.groupId || s.chatId !== world.groupId)
9692
+ return false;
9693
+ const thread = typeof s.threadId === "string" && s.threadId ? s.threadId : null;
9694
+ return cfg.topic_id == null ? thread === null : thread === String(cfg.topic_id);
9695
+ }
9696
+ /** #1920 §5.2 (#2085 review): a platform click's buttons are in the instance's own place — else not its channel's. */
9697
+ clickInOwnChannel(set) {
9698
+ try {
9699
+ return this.landedInOwnChannel(set.instance, set.adapterId, { chatId: set.chatId, threadId: set.threadId || undefined });
9700
+ }
9701
+ catch {
9702
+ return false;
9703
+ }
9704
+ }
9705
+ afterReplyRouted(instanceName, args, senderSessionName, buttons = null, inChannel = false) {
9034
9706
  // A reply is NOT proof the turn is over (#410) — but it is not proof of
9035
9707
  // more work either. Split the difference: an instance that is clearly
9036
9708
  // idle loses the button now; one that looks busy keeps it (re-posted
@@ -9066,6 +9738,7 @@ export class FleetManager {
9066
9738
  ts,
9067
9739
  ...(attachments.length ? { attachments } : {}),
9068
9740
  ...(buttons ? { buttons } : {}), // #1266
9741
+ ...(inChannel ? { channel: true } : {}), // #1920 §5.2
9069
9742
  });
9070
9743
  };
9071
9744
  const before = this.webReplyOrder.get(instanceName);
@@ -9460,6 +10133,7 @@ export class FleetManager {
9460
10133
  if (type === "fleet_get_web_theme" || type === "fleet_set_web_theme" || type === "fleet_set_web_theme_asset" || type === "fleet_reset_web_theme"
9461
10134
  || type === "fleet_get_web_appearance" || type === "fleet_preview_web_appearance"
9462
10135
  || type === "fleet_set_web_widget" || type === "fleet_remove_web_widget"
10136
+ || type === "fleet_set_web_ambient" || type === "fleet_clear_web_ambient"
9463
10137
  || type === "fleet_export_web_mod" || type === "fleet_propose_web_mod") {
9464
10138
  this.handleWebTheme(name, msg);
9465
10139
  return;
@@ -9574,10 +10248,11 @@ export class FleetManager {
9574
10248
  }
9575
10249
  case "fleet_decision_list":
9576
10250
  db.pruneExpiredDecisions();
9577
- result = db.listDecisions(projectRoot, {
10251
+ // #2152: titles by default; the full text by id, or all of it with full: true.
10252
+ result = decisionListAnswer(db.listDecisions(projectRoot, {
9578
10253
  includeArchived: payload.include_archived,
9579
10254
  tags: payload.tags,
9580
- });
10255
+ }), { id: payload.id, full: payload.full });
9581
10256
  break;
9582
10257
  case "fleet_decision_update": {
9583
10258
  const id = payload.id;
@@ -9770,10 +10445,16 @@ export class FleetManager {
9770
10445
  created_by: instance,
9771
10446
  });
9772
10447
  }
9773
- case "list": return db.listDecisions(projectRoot, {
9774
- includeArchived: args.includeArchived === true,
9775
- tags: asStrArr(args.tags),
9776
- });
10448
+ // #2152: titles by default (agend-agent decision-list); one decision's full text by id (decision-get <id>).
10449
+ // The CLI sends include_archived (snake case, as the MCP tool): both spellings are read.
10450
+ case "list":
10451
+ case "get":
10452
+ if (op === "get" && !asStr(args.id)?.trim())
10453
+ return { error: "id is required" };
10454
+ return decisionListAnswer(db.listDecisions(projectRoot, {
10455
+ includeArchived: args.includeArchived === true || args.include_archived === true,
10456
+ tags: asStrArr(args.tags),
10457
+ }), { id: op === "get" ? asStr(args.id) ?? "" : args.id, full: args.full });
9777
10458
  case "update": {
9778
10459
  const id = asStr(args.id);
9779
10460
  if (!id)
@@ -10319,6 +11000,7 @@ export class FleetManager {
10319
11000
  */
10320
11001
  saveFleetConfig(explicitPatches = []) {
10321
11002
  this.publicWebLink?.refresh();
11003
+ this.enforceGuestSwitch(); // #1920: a Settings save turning guests off ends their sessions
10322
11004
  if (!this.fleetConfig || !this.configPath)
10323
11005
  return;
10324
11006
  if (!this.savedFleetConfigSnapshot)
@@ -10808,11 +11490,23 @@ export class FleetManager {
10808
11490
  guildEmojiFetches = new Map();
10809
11491
  // ── #1630: what stands for an instance in the web app, and Discord server emoji in web chat ──
10810
11492
  webImageCaches = null;
11493
+ /** Server emoji whose picture fetch failure was already logged at warn (once each; bounded). */
11494
+ webEmojiFailures = new Set();
10811
11495
  webImageCache(kind) {
10812
11496
  const onError = (key, err) => this.logger.debug({ err: err?.message, key }, "web image fetch failed (kept what was cached)");
11497
+ // A server emoji's picture that cannot be fetched reads `:name:` in web chat (it used to be an empty box, the user's
11498
+ // "tofu"): say why once per emoji, where it can be seen, not on every render (its key is quiet for an hour anyway).
11499
+ const emojiError = (key, err) => {
11500
+ if (this.webEmojiFailures.has(key))
11501
+ return onError(key, err);
11502
+ this.webEmojiFailures.add(key);
11503
+ while (this.webEmojiFailures.size > 512)
11504
+ this.webEmojiFailures.delete(this.webEmojiFailures.values().next().value);
11505
+ this.logger.warn({ err: err?.message, key }, "Discord server emoji picture could not be fetched (web chat shows its :name:)");
11506
+ };
10813
11507
  this.webImageCaches ??= {
10814
11508
  avatars: new WebImageCache({ dir: join(this.dataDir, "cache", "avatars"), onError }),
10815
- emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError }),
11509
+ emoji: new WebImageCache({ dir: join(this.dataDir, "cache", "emoji"), onError: emojiError }),
10816
11510
  };
10817
11511
  return this.webImageCaches[kind];
10818
11512
  }
@@ -12342,6 +13036,8 @@ export class FleetManager {
12342
13036
  }
12343
13037
  /** Routes a click to the handler that owns its prefix; false when none does. */
12344
13038
  async dispatchAdapterCallback(data, adapterId, adapter) {
13039
+ if (await this.sessionAuthorizationRequests().handleCallback(data, adapterId))
13040
+ return true; // #1939 S2b
12345
13041
  if (data.callbackData.startsWith(REPLY_BUTTON_PREFIX)) { // #1266
12346
13042
  const buttons = this.replyButtons();
12347
13043
  if (!buttons) {
@@ -12479,7 +13175,7 @@ export class FleetManager {
12479
13175
  cacheInstances() {
12480
13176
  const fleet = Object.entries(this.fleetConfig?.instances ?? {}).map(([name, cfg]) => ({
12481
13177
  name, backend: this.backendNameOf(name), workingDirectory: cfg.working_directory, ledgerPath: join(this.getInstanceDir(name), "cache-ledger.json"),
12482
- claudeConfigDir: this.claudeConfigDirOf(name),
13178
+ claudeConfigDir: this.claudeConfigDirOf(name), profile: this.usageProfileOf(name, this.backendNameOf(name)),
12483
13179
  }));
12484
13180
  const taken = new Set(fleet.map(i => i.name));
12485
13181
  const classic = (this.classicChannels?.getAll() ?? []).filter(ch => !taken.has(ch.instanceName)).map(ch => ({
@@ -12488,6 +13184,7 @@ export class FleetManager {
12488
13184
  workingDirectory: join(getAgendHome(), "workspaces", ch.instanceName),
12489
13185
  ledgerPath: join(this.getInstanceDir(ch.instanceName), "cache-ledger.json"),
12490
13186
  claudeConfigDir: this.claudeConfigDirOf(ch.instanceName),
13187
+ profile: this.usageProfileOf(ch.instanceName, this.classicChannels?.getBackendByInstance(ch.instanceName, this.fleetConfig?.defaults?.backend) ?? this.fleetConfig?.defaults?.backend ?? "claude-code"),
12491
13188
  }));
12492
13189
  return [...fleet, ...new Map(classic.map(c => [c.name, c])).values()];
12493
13190
  }
@@ -12497,17 +13194,63 @@ export class FleetManager {
12497
13194
  * (cache-service.ts) — never a vendor call, never a synchronous transcript read.
12498
13195
  */
12499
13196
  cacheReport(window) {
12500
- const w = Object.prototype.hasOwnProperty.call(WINDOWS, window) ? window : "7d";
12501
- this.cacheService ??= new CacheService({
13197
+ return this.cacheServiceFor().report(cacheWindow(window));
13198
+ }
13199
+ /**
13200
+ * #2122 §5.1: the profile the usage read now is tagged with — the claude profile whose config dir is read ("default"
13201
+ * without one); codex "unrecorded" (its rollouts don't say which login wrote them).
13202
+ */
13203
+ usageProfileOf(name, backend) {
13204
+ if (backend === "claude-code")
13205
+ return this.credentialProfileOf(name, "claude-code") ?? "default";
13206
+ if (backend === "codex")
13207
+ return "unrecorded";
13208
+ return undefined;
13209
+ }
13210
+ /** #2122: the fleet's day boundary zone — defaults.scheduler.default_timezone, else this host's. */
13211
+ usageZone() {
13212
+ return this.fleetConfig?.defaults?.scheduler?.default_timezone?.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
13213
+ }
13214
+ /**
13215
+ * #2122: the usage report (usage-report.ts) — from the ledgers and rollups as they are now and the delivered-message
13216
+ * counts; never a transcript read. `only`: just that instance (an ordinary agent's own row).
13217
+ */
13218
+ async usageReport(params) {
13219
+ const { buildUsageReport } = await import("./usage-report.js");
13220
+ const data = await this.cacheServiceFor().usageData();
13221
+ const team = new Map();
13222
+ for (const [t, cfg] of Object.entries(this.fleetConfig?.teams ?? {}))
13223
+ for (const m of cfg?.members ?? [])
13224
+ if (!team.has(m))
13225
+ team.set(m, t);
13226
+ const now = Date.now();
13227
+ this.ensureDeliveryOutbox?.();
13228
+ // Every hour of the kept history: the report places each on a local date and keeps the range's own.
13229
+ const messages = this.deliveryOutbox?.deliveredMessageHours(now - 401 * 86_400_000, now + 3_600_000) ?? [];
13230
+ const keep = (name) => params.only === undefined || name === params.only;
13231
+ return buildUsageReport({
13232
+ now, zone: this.usageZone(), range: params.range, group: params.group, scanning: data.scanning,
13233
+ instances: data.instances.filter(i => keep(i.inst.name)).map(i => ({ name: i.inst.name, backend: i.inst.backend, team: team.get(i.inst.name) ?? null,
13234
+ status: i.status, ...(i.with ? { with: i.with } : {}), ledger: i.ledger, days: i.days })),
13235
+ messages: messages.filter(m => keep(m.instance)),
13236
+ });
13237
+ }
13238
+ /** #2001: get_cache_report's — the same report, as last worked out (reused for a minute, until the next pass). */
13239
+ cacheReportPublished(window) {
13240
+ return this.cacheServiceFor().published(cacheWindow(window));
13241
+ }
13242
+ cacheServiceFor() {
13243
+ return this.cacheService ??= new CacheService({
12502
13244
  instances: () => this.cacheInstances(),
12503
13245
  claudeProjectsDir: () => join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
12504
13246
  claudeKey: claudeProjectKey,
12505
13247
  codexSessionsDir: () => join(process.env.CODEX_HOME?.trim() || join(homedir(), ".codex"), "sessions"),
12506
13248
  listRollouts: root => sharedRolloutIndex(root).list(),
13249
+ rolloutsUnreadable: root => sharedRolloutIndex(root).unreadable,
12507
13250
  metaPath: join(this.dataDir, "cache-codex-rollouts.json"),
13251
+ zone: () => this.usageZone(),
12508
13252
  log: (level, msg, extra) => this.logger[level](extra ?? {}, msg),
12509
13253
  });
12510
- return this.cacheService.report(w);
12511
13254
  }
12512
13255
  /** The web's Acknowledge (any item; a signed-in session is fleet-admin level). */
12513
13256
  acknowledgeNeedsItem(id, principal) {
@@ -13017,6 +13760,198 @@ export class FleetManager {
13017
13760
  webBackendTokens = new Map();
13018
13761
  /** #1269: the web's /clear confirmations — one token per question, used once, for the instance as it was asked about. */
13019
13762
  webClearTokens = new Map();
13763
+ /**
13764
+ * #1939 S1: the design's §3 for one web request (authority/web-authority.ts), with this fleet's admins, policy and
13765
+ * token; `instance` once the body has named it. Observe only: receipts record it, no agent acts on it yet.
13766
+ */
13767
+ webAuthority(input) {
13768
+ const state = this.webAuthorityState();
13769
+ return evaluateWebAuthority({
13770
+ session: input.session, gateway: input.gateway, peer: input.peer,
13771
+ tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
13772
+ ...(input.instance !== undefined ? { target: { adapterId: this.getInstanceAdapterId(input.instance) } } : {}),
13773
+ isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
13774
+ authorization: input.session ? this.sessionAuthorizationStore()?.get(input.session.handle) ?? null : null,
13775
+ policy: { ...webAuthorityPolicy(this.fleetConfig?.web), enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now: Date.now(),
13776
+ });
13777
+ }
13778
+ sessionAuthorizations = null;
13779
+ sessionAuthorizationStore() {
13780
+ if (!this.dataDir)
13781
+ return null;
13782
+ this.sessionAuthorizations ??= new SessionAuthorizations(join(this.dataDir, "web-authorizations.json"), this.logger);
13783
+ return this.sessionAuthorizations;
13784
+ }
13785
+ /**
13786
+ * #1939 §0: record that a session's account has authorized it — called only once that account has confirmed on Discord
13787
+ * or Telegram (S2b). The session must qualify now (§3, as a message from it would be judged); then every message,
13788
+ * click, /steer and /btw from it is web-verified until max_session_hours after now, the session ends, or a revocation.
13789
+ * Ones no longer worth keeping (their session gone, an old epoch, run out) are dropped on the way.
13790
+ */
13791
+ authorizeWebSession(session, peer, gateway) {
13792
+ const store = this.sessionAuthorizationStore();
13793
+ const state = this.webAuthorityState(), now = Date.now(), policy = webAuthorityPolicy(this.fleetConfig?.web);
13794
+ if (!store || !session.owner)
13795
+ return { ok: false, refusal: !store ? "disabled" : "no_account" };
13796
+ const check = evaluateWebAuthority({
13797
+ session, gateway, peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
13798
+ isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
13799
+ authorization: { handle: session.handle, owner: session.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch },
13800
+ policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
13801
+ });
13802
+ if (check.refusal)
13803
+ return { ok: false, refusal: check.refusal };
13804
+ const live = new Set(this.webSessions?.list(undefined, () => true).map(r => r.handle) ?? []);
13805
+ store.prune(a => a.epoch === state.epoch && now <= Math.min(a.until, a.authorizedAt + policy.maxSessionMs) && live.has(a.handle));
13806
+ return { ok: true, ...store.authorize(session.handle, session.owner, now, state.epoch, now + policy.maxSessionMs) };
13807
+ }
13808
+ authorityStateStore = null;
13809
+ /**
13810
+ * #1939 §10: the switch, "Only this computer can authorize" and the revocation epoch in force for the configuration as
13811
+ * it is now (a change since last seen moves the epoch first; authority-state.ts). No data directory: off.
13812
+ */
13813
+ webAuthorityState() {
13814
+ if (!this.dataDir)
13815
+ return { enabled: false, loopbackOnly: true, epoch: 0 };
13816
+ this.authorityStateStore ??= new AuthorityStateStore(join(this.dataDir, "web-authority.json"), this.logger, undefined, undefined, () => { this.ensureDeliveryOutbox?.(); return this.deliveryOutbox?.maxWebAuthorityEpoch() ?? 0; });
13817
+ return this.authorityStateStore.current(this.fleetConfig?.web?.verified_authority);
13818
+ }
13819
+ /**
13820
+ * #1939 S2a: Settings → Web → Web authority — the state in force (the switch counts as off while its state cannot be
13821
+ * written), what fleet.yaml says, the hosts that make the dashboard reachable from elsewhere (the warning while
13822
+ * loopback_only is off), and the sessions authorized now (§0): each one's device, account, and when it runs out.
13823
+ */
13824
+ webAuthorityOverview() {
13825
+ const configured = this.fleetConfig?.web?.verified_authority ?? {};
13826
+ const lanHosts = (this.fleetConfig?.web?.allowed_hosts ?? []).filter(h => { const name = hostnameOf(String(h)); return name !== null && !LOOPBACK_HOST_NAMES.includes(name); });
13827
+ const state = this.webAuthorityState(), maxMs = webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs, now = Date.now();
13828
+ const sessions = new Map((this.webSessions?.list(undefined, () => true) ?? []).map(r => [r.handle, r]));
13829
+ const authorized = state.enabled ? (this.sessionAuthorizationStore()?.list() ?? [])
13830
+ .filter(a => a.epoch === state.epoch && sessions.has(a.handle) && now <= Math.min(a.until, a.authorizedAt + maxMs))
13831
+ .map(a => ({ handle: a.handle, label: sessions.get(a.handle).label, account: `${a.owner.adapterId}:${a.owner.userId}`, authorizedAt: a.authorizedAt,
13832
+ until: Math.min(a.until, a.authorizedAt + maxMs, sessions.get(a.handle).absoluteExpiry) })) : [];
13833
+ return { state, configured, hours: VERIFIED_SESSION_HOURS, lanHosts, authorized };
13834
+ }
13835
+ authorizationRequests = null;
13836
+ /**
13837
+ * #1939 S2b (§0.1): "Authorize this browser" — the Confirm card in the account's DM. Confirm runs §3 again at the tap
13838
+ * (authorizeWebSession on the session read again by its handle); Not me signs the session out.
13839
+ */
13840
+ sessionAuthorizationRequests() {
13841
+ if (this.authorizationRequests)
13842
+ return this.authorizationRequests;
13843
+ const adapterOf = (adapterId) => this.adapters.get(adapterId) ?? this.worlds.get(adapterId)?.adapter;
13844
+ // The host's clock, with its zone: the person may read the DM on a phone in another one.
13845
+ const when = (ms) => new Date(ms).toLocaleString(undefined, { timeZoneName: "short" });
13846
+ const where = (peer) => t(peer === "loopback" ? "web_auth.peer_loopback" : "web_auth.peer_lan");
13847
+ return this.authorizationRequests = new SessionAuthorizationRequests({
13848
+ now: () => performance.now(), wallNow: () => Date.now(),
13849
+ maxSessionMs: () => webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs,
13850
+ sendDirect: async (owner, text, choices) => {
13851
+ const adapter = adapterOf(owner.adapterId);
13852
+ if (!adapter?.sendDirect)
13853
+ return null;
13854
+ const sent = await adapter.sendDirect(owner.userId, text, { choices, allowedMentions: { parse: [] } });
13855
+ return sent?.messageId ? { chatId: sent.chatId, messageId: sent.messageId } : null;
13856
+ },
13857
+ sendGeneralHint: async (owner, text) => {
13858
+ const adapter = adapterOf(owner.adapterId), general = this.findGeneralInstance(owner.adapterId);
13859
+ const topic = general ? this.fleetConfig?.instances[general]?.topic_id : undefined;
13860
+ const group = general ? this.getGroupIdForInstance(general) : undefined;
13861
+ if (adapter && group)
13862
+ await adapter.sendText(String(group), text, { ...(topic != null ? { threadId: String(topic) } : {}), allowedMentions: { parse: [] } });
13863
+ },
13864
+ editCard: async (owner, at, text, choices) => {
13865
+ const adapter = adapterOf(owner.adapterId);
13866
+ if (!adapter)
13867
+ return;
13868
+ if (!choices.length && adapter.editMessageRemoveButtons) {
13869
+ await adapter.editMessageRemoveButtons(at.chatId, at.messageId, text);
13870
+ return;
13871
+ }
13872
+ await adapter.editMessage(at.chatId, at.messageId, text);
13873
+ if (adapter.replaceMessageButtons)
13874
+ await adapter.replaceMessageButtons(at.chatId, at.messageId, choices);
13875
+ },
13876
+ authorize: (handle, peer) => {
13877
+ const record = this.webSessions?.findByHandle(handle);
13878
+ if (!record)
13879
+ return { ok: false, refusal: "session_gone" };
13880
+ const r = this.authorizeWebSession(record, peer, false);
13881
+ // What the card says: the end of the authorization, or the session's own end when that comes first.
13882
+ return r.ok ? { ok: true, until: Math.min(r.authorization.until, record.absoluteExpiry) } : { ok: false, refusal: r.refusal };
13883
+ },
13884
+ signOut: (handle) => {
13885
+ this.webSessions?.revokeByHandle(handle);
13886
+ this.sessionAuthorizationStore()?.prune(a => a.handle !== handle);
13887
+ },
13888
+ event: (type, payload) => { try {
13889
+ this.eventLog?.insert("web", type, payload);
13890
+ }
13891
+ catch { /* the event log is best effort */ } },
13892
+ text: {
13893
+ ask: (c) => t("web_auth.card_ask", c.label, where(c.peer), when(c.askedAt), when(c.until)),
13894
+ confirmed: (c) => t("web_auth.card_confirmed", c.label, where(c.peer), when(c.until)),
13895
+ denied: (c) => t("web_auth.card_denied", c.label),
13896
+ expired: (c) => t("web_auth.card_expired", c.label),
13897
+ refused: (c) => t("web_auth.card_refused", c.label, t(`web_auth.refusal_${c.refusal}`)),
13898
+ revoked: (c) => t("web_auth.card_revoked", c.label),
13899
+ ended: (c) => t("web_auth.card_ended", c.label),
13900
+ generalHint: () => t("web_auth.general_hint"),
13901
+ confirmLabel: () => t("web_auth.confirm"),
13902
+ notMeLabel: () => t("web_auth.not_me"),
13903
+ notYours: () => t("web_auth.not_yours"),
13904
+ },
13905
+ log: this.logger,
13906
+ });
13907
+ }
13908
+ /** #1939 S2b: the web session asks to be authorized (the request judged by §3 first — as it will be again at the tap). */
13909
+ async requestSessionAuthorization(input) {
13910
+ const s = input.session;
13911
+ const gate = this.webAuthorityQualifies(input);
13912
+ if (gate)
13913
+ return { ok: false, refusal: gate };
13914
+ return this.sessionAuthorizationRequests().ask({ handle: s.handle, owner: s.owner, label: s.label, peer: input.peer, sessionEnd: s.absoluteExpiry });
13915
+ }
13916
+ /**
13917
+ * #1939 §3 without the authorization: would this request's session qualify to be authorized? null: yes; otherwise the
13918
+ * first check it fails (for the app to say why it can't be authorized here).
13919
+ */
13920
+ webAuthorityQualifies(input) {
13921
+ const s = input.session, state = this.webAuthorityState(), now = Date.now(), policy = webAuthorityPolicy(this.fleetConfig?.web);
13922
+ const r = evaluateWebAuthority({
13923
+ session: s, gateway: input.gateway, peer: input.peer, tokenEpoch: this.webToken ? tokenEpoch(this.webToken) : null,
13924
+ isAdmin: (userId, adapterId) => this.adminGate(userId, adapterId) === "ok",
13925
+ authorization: s?.owner ? { handle: s.handle, owner: s.owner, authorizedAt: now, until: now + policy.maxSessionMs, epoch: state.epoch } : null,
13926
+ policy: { ...policy, enabled: state.enabled, loopbackOnly: state.loopbackOnly }, epoch: state.epoch, now,
13927
+ });
13928
+ return r.refusal;
13929
+ }
13930
+ /** #1939 S2b: what the app shows for this browser — verified (until when), a card pending, or why not. */
13931
+ webSessionAuthorization(input) {
13932
+ const s = input.session;
13933
+ const verdict = this.webAuthority(input);
13934
+ if (!verdict.refusal && verdict.authority.session) {
13935
+ const a = this.sessionAuthorizationStore()?.get(s.handle);
13936
+ const max = webAuthorityPolicy(this.fleetConfig?.web).maxSessionMs;
13937
+ if (a)
13938
+ return { state: "verified", until: Math.min(a.until, a.authorizedAt + max, s.absoluteExpiry) };
13939
+ }
13940
+ const pending = s ? this.sessionAuthorizationRequests().pending(s.handle) : null;
13941
+ if (pending)
13942
+ return { state: "pending", expiresInMs: pending.expiresInMs };
13943
+ return { state: "none", reason: this.webAuthorityQualifies(input) ?? (verdict.refusal ?? "not_authorized") };
13944
+ }
13945
+ /** #1939 §10: revoke every web authority given so far (receipts read as none), without signing anyone out. */
13946
+ revokeWebAuthority() {
13947
+ this.webAuthorityState(); // any switch change first, then this revocation
13948
+ const result = this.authorityStateStore?.revoke() ?? { epoch: 0, durable: false };
13949
+ if (result.durable)
13950
+ this.logger.info({ epoch: result.epoch }, "Web authority revoked");
13951
+ else
13952
+ this.logger.warn({ epoch: result.epoch }, "Web authority revoked in memory only — web authority stays off until its state can be written; a restart before then undoes this revocation");
13953
+ return result;
13954
+ }
13020
13955
  /** #1269: an instance's chat command from the web chat (web-commands.ts); `publicLink`: a gateway request. */
13021
13956
  webCommand(input, opts) {
13022
13957
  const webMeta = (instance) => {
@@ -13025,15 +13960,16 @@ export class FleetManager {
13025
13960
  // #1827: a /steer or /btw reaches the agent as the web person who wrote it.
13026
13961
  chatId: this.getGroupIdForInstance(instance) || "", messageId: newWebMessageId(),
13027
13962
  username: opts.person?.name ?? WEB_NAME_FALLBACK, userId: opts.person?.userId ?? WEB_NAME_FALLBACK,
13028
- threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true),
13963
+ threadId: topicId != null ? String(topicId) : undefined, adapterId: this.getAdapterForInstance(instance)?.id ?? "", source: "web", humanOrigin: webHumanOrigin(true, opts.authority),
13029
13964
  };
13030
13965
  };
13031
13966
  const choicesFromCache = (instance) => {
13032
- const cached = this.readCliEnv(this.backendNameForInstance(instance)); // the cached catalog only: never a probe here
13967
+ const backend = this.backendNameForInstance(instance);
13968
+ const cached = this.readCliEnv(backend, this.catalogProfile(backend, instance)); // the cached catalog only: never a probe here
13033
13969
  if (!cached?.models.length)
13034
13970
  return null;
13035
13971
  const current = this.resolveInstanceModel(instance).model;
13036
- return { current, options: cached.models.map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
13972
+ return { current, options: this.withCurrentModel(cached.models, current).map(o => ({ id: o.id, label: this.modelChoiceLabel(o, current) })) };
13037
13973
  };
13038
13974
  return runWebCommand({
13039
13975
  // Own entries only: a name like "__proto__" or "constructor" must not be found on the prototype (#1476 review).
@@ -13080,7 +14016,7 @@ export class FleetManager {
13080
14016
  return step.noop ? { text: step.text } : { error: step.text };
13081
14017
  if (step.kind === "choose") {
13082
14018
  const id = (token) => joinBackendArgs([...step.args, token]);
13083
- return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label })) } };
14019
+ return { choices: { prompt: step.text, current: step.current === null ? null : id(step.current), options: step.choices.map(c => ({ id: id(c.token), label: c.label, ...(c.disabled ? { disabled: c.disabled } : {}) })) } };
13084
14020
  }
13085
14021
  const now = performance.now();
13086
14022
  for (const [k, v] of this.webBackendTokens)
@@ -14218,7 +15154,12 @@ export class FleetManager {
14218
15154
  fleetConfig: () => this.fleetConfig,
14219
15155
  isFleetAdmin: (userId, adapterId) => this.isFleetAdmin(userId, adapterId),
14220
15156
  eventLog: () => this.eventLog,
14221
- recoverBackendInstances: (backend, profile) => this.recoverBackendInstances(backend, undefined, profile ?? null),
15157
+ recoverBackendInstances: (backend, profile) => {
15158
+ // #2072: a sign-in just finished — the pickers learn it now, not at the next pass.
15159
+ if (!profile)
15160
+ void this.backendAvailability.refresh([backend], { force: true });
15161
+ return this.recoverBackendInstances(backend, undefined, profile ?? null);
15162
+ },
14222
15163
  claimWindow: backend => this.loginWindow.tryClaim("web", backend),
14223
15164
  releaseWindow: claim => { this.loginWindow.release(claim); },
14224
15165
  isClaimCurrent: claim => this.loginWindow.isCurrent(claim),
@@ -14737,6 +15678,8 @@ export class FleetManager {
14737
15678
  // binary the same way, so a binary only a login shell could see was
14738
15679
  // installed, reported as verified, and then offered nowhere.
14739
15680
  this.adoptBinaryDirectory(installedAt, backend);
15681
+ // #2072: an install just finished — the pickers learn it now, not at the next pass.
15682
+ void this.backendAvailability.refresh([backend], { force: true });
14740
15683
  if (!LOGIN_FLOWS[backend]) {
14741
15684
  this.installHandoff = null;
14742
15685
  await chat.adapter.sendText(chat.chatId, t("install.success_no_login", backend), { threadId: chat.threadId }).catch(() => { });
@@ -14971,6 +15914,8 @@ export class FleetManager {
14971
15914
  buttons: m.buttons,
14972
15915
  click: m.click,
14973
15916
  links: m.links,
15917
+ channel: m.channel,
15918
+ from: m.from,
14974
15919
  });
14975
15920
  broadcastSseEvent(this.sseClients, event, recorded, onError, this.webChatHistory.cursorOf(recorded));
14976
15921
  // #1629: live-only receipt after the recorded agent reply. Never persist/replay it or give it an SSE cursor.
@@ -15200,7 +16145,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15200
16145
  // can keep those copies current; other backends never had unmanaged
15201
16146
  // AgEnD-published skills and must retain the normal user-ownership guard.
15202
16147
  const adoptLegacyUnmanaged = backend === "kiro-cli" && role === "general";
15203
- this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged);
16148
+ this.syncManagedSkills(join(workDir, ...skillSegments), join(root, "skills"), role, adoptLegacyUnmanaged,
16149
+ // #2163: a Classic bot's skills folder (or a skill in it) may be a link out of its workspace; every path is
16150
+ // checked where it really lands, just before it is written.
16151
+ role === "classic" ? (path) => insideManagedWorkspaces(path) : undefined);
15204
16152
  this.logger.debug({ workDir, backend, role }, "Synced role-based bundled skills");
15205
16153
  }
15206
16154
  /**
@@ -15286,11 +16234,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15286
16234
  * migration exception is the pre-manifest Kiro General layout explicitly
15287
16235
  * selected by adoptLegacyUnmanaged.
15288
16236
  */
15289
- syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false) {
16237
+ syncManagedSkills(destSkills, srcSkills, role, adoptLegacyUnmanaged = false, confined) {
15290
16238
  if (!existsSync(srcSkills))
15291
16239
  return;
16240
+ const refuse = (path) => {
16241
+ if (!confined || confined(path))
16242
+ return false;
16243
+ this.logger.warn({ path, destSkills }, "Not publishing skills — the path leads out of the managed workspace");
16244
+ return true;
16245
+ };
16246
+ if (refuse(destSkills))
16247
+ return;
15292
16248
  mkdirSync(destSkills, { recursive: true });
15293
16249
  const manifestPath = join(destSkills, ".agend-managed-skills.json");
16250
+ // Its ownership claims decide what is replaced or removed below: one that lives elsewhere is never believed.
16251
+ if (refuse(manifestPath))
16252
+ return;
15294
16253
  const hadManifest = existsSync(manifestPath);
15295
16254
  let previouslyManaged = [];
15296
16255
  try {
@@ -15319,9 +16278,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15319
16278
  }
15320
16279
  const eligible = bundled.filter(name => {
15321
16280
  const roles = this.readManagedSkillRoles(join(srcSkills, name, "SKILL.md"));
15322
- // General is the coordinator and receives both coordinator and worker
15323
- // playbooks. Workers receive only skills explicitly marked for workers.
15324
- return role === "general" || roles.includes("worker");
16281
+ // General is the coordinator and receives every playbook. Workers and Classic bots receive only skills
16282
+ // explicitly marked for their role (#2163).
16283
+ return role === "general" || roles.includes(role);
15325
16284
  });
15326
16285
  const managed = [];
15327
16286
  for (const name of eligible) {
@@ -15338,6 +16297,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15338
16297
  }
15339
16298
  managed.push(name);
15340
16299
  const newContent = readFileSync(join(srcSkills, name, "SKILL.md"), "utf-8");
16300
+ if (refuse(dest))
16301
+ continue;
15341
16302
  try {
15342
16303
  if (existsSync(dest) && readFileSync(dest, "utf-8") === newContent)
15343
16304
  continue;
@@ -15384,9 +16345,9 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15384
16345
  if (!Array.isArray(frontmatter.roles))
15385
16346
  throw new Error("roles must be an array");
15386
16347
  const roles = [...new Set(frontmatter.roles)]
15387
- .filter((value) => value === "general" || value === "worker");
16348
+ .filter((value) => value === "general" || value === "worker" || value === "classic");
15388
16349
  if (roles.length !== frontmatter.roles.length || roles.length === 0) {
15389
- throw new Error("roles must contain only general and/or worker");
16350
+ throw new Error("roles must contain only general, worker and/or classic");
15390
16351
  }
15391
16352
  return roles;
15392
16353
  }
@@ -15771,10 +16732,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15771
16732
  this.logger.info(`${msg.username} → ${instanceName} (classic): ${text.slice(0, 100)}`);
15772
16733
  // #1592: the web chat shows what was asked, as for every other inbound — the user's own words (no chat-log
15773
16734
  // context), as a user (#1306: never a role read from text), in the room's history (#1565).
15774
- this.emitSseEvent("message", {
15775
- instance: instanceName, sender: msg.username, role: "user",
15776
- text: text.slice(0, WEB_CHAT_TEXT_MAX), ts: new Date().toISOString(),
15777
- });
16735
+ this.emitPlatformInbound(instanceName, msg, text);
15778
16736
  }
15779
16737
  /** Paste raw text directly to a classic instance's CLI (no [user:] wrapping) */
15780
16738
  /** #1690: our own runtime switches awaiting the daemon's outcome, by id. */
@@ -15812,7 +16770,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15812
16770
  /** #1806: null on a switch INTO claude-code — the configured model then belongs to the backend being left. */
15813
16771
  configured = this.fleetConfig?.instances[instanceName]?.model ?? this.fleetConfig?.defaults?.model) {
15814
16772
  const aliases = ["default", "sonnet", "opus", "haiku", "opusplan", "Fable"];
15815
- const api = (this.readCliEnv("claude-code")?.apiModels ?? []).map(m => m.id);
16773
+ const api = (this.readCliEnv("claude-code", this.catalogProfile("claude-code", instanceName))?.apiModels ?? []).map(m => m.id);
15816
16774
  const lower = model.trim().toLowerCase();
15817
16775
  const bare = lower.replace(/\[1m\]$/, "");
15818
16776
  const known = aliases.some(a => a.toLowerCase() === bare)
@@ -15840,13 +16798,22 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15840
16798
  : undefined;
15841
16799
  return classic ?? this.fleetConfig?.defaults?.backend ?? "claude-code";
15842
16800
  }
15843
- cliEnvPath(backend) {
15844
- return join(getAgendHome(), "cli-env", `${backend.replace(/[^A-Za-z0-9._-]/g, "_")}.json`);
16801
+ /**
16802
+ * #2072: a catalog is the account's, so a credential profile has its own. Only claude-code's probe reads an account
16803
+ * catalog through a profile's login (its `GET /v1/models` with the profile's token); other backends' probes read the
16804
+ * process environment, so their catalog stays the backend's own.
16805
+ */
16806
+ catalogProfile(backend, instanceName) {
16807
+ return backend === "claude-code" && instanceName ? this.credentialProfileOf(instanceName, backend) : null;
15845
16808
  }
15846
- /** Read the cached CLI env for a backend, or null if missing/stale/unparseable. */
15847
- readCliEnv(backend) {
16809
+ cliEnvPath(backend, profile = null) {
16810
+ const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_");
16811
+ return join(getAgendHome(), "cli-env", profile ? `${safe(backend)}@${safe(profile)}.json` : `${safe(backend)}.json`);
16812
+ }
16813
+ /** Read the cached CLI env for a backend (a credential profile's own, when given), or null if missing/stale/unparseable. */
16814
+ readCliEnv(backend, profile = null) {
15848
16815
  try {
15849
- const env = JSON.parse(readFileSync(this.cliEnvPath(backend), "utf-8"));
16816
+ const env = JSON.parse(readFileSync(this.cliEnvPath(backend, profile), "utf-8"));
15850
16817
  if (typeof env?.probedAt === "number" && Date.now() - env.probedAt < CLI_ENV_TTL_MS)
15851
16818
  return env;
15852
16819
  }
@@ -15889,10 +16856,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15889
16856
  * previous list, never stall the command that asked for it.
15890
16857
  */
15891
16858
  async probeBackendBounded(backend, opts = {}) {
15892
- // #725: single-flight per backend. Explicit vendor refreshes remain a
16859
+ // #725: single-flight per backend (#2072: per backend and credential profile). Explicit vendor refreshes remain a
15893
16860
  // separate flight from an ordinary probe, while sysinfo can join either.
15894
- const regularFlight = this.pendingCliEnvProbes.get(backend);
15895
- const vendorFlight = this.pendingVendorCliEnvProbes.get(backend);
16861
+ const profile = opts.profile ?? null;
16862
+ const key = profile ? `${backend}@${profile}` : backend;
16863
+ const regularFlight = this.pendingCliEnvProbes.get(key);
16864
+ const vendorFlight = this.pendingVendorCliEnvProbes.get(key);
15896
16865
  if (opts.refreshVendorCatalog && vendorFlight)
15897
16866
  return vendorFlight;
15898
16867
  if (!opts.refreshVendorCatalog && (vendorFlight || regularFlight))
@@ -15903,25 +16872,26 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
15903
16872
  const epoch = this.cliEnvProbeEpoch;
15904
16873
  const input = {
15905
16874
  mode: "env", backend, instanceDir,
15906
- config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {} },
16875
+ config: { workingDirectory: "", instanceDir, instanceName: `probe-${backend}`, mcpServers: {},
16876
+ ...(profile ? { credentialStoreHome: credentialProfileStoreHome(getAgendHome(), backend, profile) } : {}) },
15907
16877
  refreshVendorCatalog: opts.refreshVendorCatalog === true,
15908
16878
  };
15909
- const bounded = this.runBackendProbeWorker(`global:${backend}`, input).then(probed => {
16879
+ const bounded = this.runBackendProbeWorker(`global:${key}`, input).then(probed => {
15910
16880
  if (!probed || Array.isArray(probed) || this.shuttingDown || epoch !== this.cliEnvProbeEpoch)
15911
16881
  return null;
15912
16882
  try {
15913
- return this.persistCliEnvProbeResult(backend, probed);
16883
+ return this.persistCliEnvProbeResult(backend, probed, profile);
15914
16884
  }
15915
16885
  catch (err) {
15916
- this.logger.warn({ backend, err }, "CLI env cache write failed");
16886
+ this.logger.warn({ backend, profile, err }, "CLI env cache write failed");
15917
16887
  return null;
15918
16888
  }
15919
16889
  }).finally(() => {
15920
16890
  const flights = opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes;
15921
- if (flights.get(backend) === bounded)
15922
- flights.delete(backend);
16891
+ if (flights.get(key) === bounded)
16892
+ flights.delete(key);
15923
16893
  });
15924
- (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(backend, bounded);
16894
+ (opts.refreshVendorCatalog ? this.pendingVendorCliEnvProbes : this.pendingCliEnvProbes).set(key, bounded);
15925
16895
  return bounded;
15926
16896
  }
15927
16897
  stopCliEnvProbes() {
@@ -16036,12 +17006,21 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16036
17006
  modelDisplayForInstance(instanceName) {
16037
17007
  return this.resolveInstanceModel(instanceName).display;
16038
17008
  }
17009
+ /**
17010
+ * #2072: the model an instance runs is always among its choices — one its CLI's catalog does not list (an older
17011
+ * name, a custom provider's, a typed one) is shown first, marked, so the menu never hides what is running.
17012
+ */
17013
+ withCurrentModel(options, currentModel) {
17014
+ if (!currentModel || currentModel === "default" || options.some(o => o.id === currentModel))
17015
+ return options;
17016
+ return [{ id: currentModel, label: currentModel, description: t("model.not_listed") }, ...options];
17017
+ }
16039
17018
  modelChoiceLabel(option, currentModel) {
16040
17019
  const label = option.description ? `${option.label} — ${option.description}` : option.label;
16041
17020
  return option.id === currentModel ? `✓ ${label}` : label;
16042
17021
  }
16043
17022
  /** Preserve the existing cache merge rules for worker probes. */
16044
- persistCliEnvProbeResult(backend, probed) {
17023
+ persistCliEnvProbeResult(backend, probed, profile = null) {
16045
17024
  const env = { backend, probedAt: Date.now(), ...probed };
16046
17025
  // An empty result must never overwrite a catalog we already have. Some
16047
17026
  // probes hit the network (`agy models` fetches, 5s cap), so a slow moment
@@ -16050,14 +17029,14 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16050
17029
  // antigravity cache replaced by an empty one. Keep the known models and
16051
17030
  // let the fresher currentModel/version through.
16052
17031
  if (!env.models?.length) {
16053
- const previous = this.readCliEnv(backend);
17032
+ const previous = this.readCliEnv(backend, profile);
16054
17033
  if (previous?.models?.length)
16055
17034
  env.models = previous.models;
16056
17035
  }
16057
17036
  // Same protection for the extended catalog: one offline moment must not
16058
17037
  // blank a good account list for the whole cache TTL.
16059
17038
  if (!env.apiModels?.length) {
16060
- const previous = this.readCliEnv(backend);
17039
+ const previous = this.readCliEnv(backend, profile);
16061
17040
  if (previous?.apiModels?.length)
16062
17041
  env.apiModels = previous.apiModels;
16063
17042
  }
@@ -16065,12 +17044,12 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16065
17044
  // keeps the cached levels only for that same binary: both versions known and equal, cache still valid. A help
16066
17045
  // that was read and lists none ([]) is an answer and is written as is, so the fallback applies.
16067
17046
  if (env.effortLevels === undefined) {
16068
- const previous = this.readCliEnv(backend);
17047
+ const previous = this.readCliEnv(backend, profile);
16069
17048
  if (previous?.effortLevels && previous.version && env.version && previous.version === env.version) {
16070
17049
  env.effortLevels = previous.effortLevels;
16071
17050
  }
16072
17051
  }
16073
- const path = this.cliEnvPath(backend);
17052
+ const path = this.cliEnvPath(backend, profile);
16074
17053
  mkdirSync(dirname(path), { recursive: true });
16075
17054
  writeFileSync(path, JSON.stringify(env, null, 2));
16076
17055
  return env;
@@ -16092,6 +17071,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16092
17071
  // indefinitely — each is wrapped by CLI_ENV_PROBE_DEADLINE_MS.
16093
17072
  for (const b of backends)
16094
17073
  void this.probeBackendBounded(b);
17074
+ // #2072: each Claude credential profile in use has its own account catalog. Only fleet.yaml's agents are walked
17075
+ // here; a ClassicBot room on a profile is probed on its first /model (catalogProfile knows rooms too).
17076
+ const profiles = new Set();
17077
+ for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
17078
+ if (this.backendNameForInstance(name) !== "claude-code")
17079
+ continue;
17080
+ const profile = this.catalogProfile("claude-code", name);
17081
+ if (profile)
17082
+ profiles.add(profile);
17083
+ }
17084
+ for (const profile of profiles)
17085
+ void this.probeBackendBounded("claude-code", { profile });
16095
17086
  }
16096
17087
  /** Best-effort model list for `/model`: cached CLI env first, else live probe. Never throws. */
16097
17088
  async getModelOptions(instanceName, refresh = false, onLiveProbe) {
@@ -16105,7 +17096,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16105
17096
  */
16106
17097
  async getModelOptionsWithSource(instanceName, opts = {}) {
16107
17098
  const backendName = this.backendNameForInstance(instanceName);
16108
- const cached = this.readCliEnv(backendName);
17099
+ const profile = this.catalogProfile(backendName, instanceName);
17100
+ const cached = this.readCliEnv(backendName, profile);
16109
17101
  if (!opts.refresh && cached?.models.length && !this.cliEnvNeedsRefresh(cached)) {
16110
17102
  return { models: cached.models, source: "cache" };
16111
17103
  }
@@ -16116,7 +17108,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16116
17108
  // Stale, missing, or a forced refresh → probe live (also refreshes the cache).
16117
17109
  // A newly released model is invisible until this runs, which is why staleness
16118
17110
  // triggers it rather than waiting for the 24h hard expiry or a cold start.
16119
- const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog });
17111
+ const env = await this.probeBackendBounded(backendName, { refreshVendorCatalog: opts.refreshVendorCatalog, profile });
16120
17112
  if (env?.models.length)
16121
17113
  return { models: env.models, source: "live" };
16122
17114
  // Probe failed or timed out: the previous list is still the best answer.
@@ -16131,7 +17123,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16131
17123
  modelMenuChoices(instanceName, nonce, options, currentModel) {
16132
17124
  const isClaude = this.backendNameForInstance(instanceName) === "claude-code";
16133
17125
  const choices = [{ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:__refresh__`, label: t("model.refresh") }];
16134
- for (const o of options.slice(0, isClaude ? 23 : 24)) {
17126
+ for (const o of this.withCurrentModel(options, currentModel).slice(0, isClaude ? 23 : 24)) {
16135
17127
  choices.push({ id: `${MODEL_SELECT_CALLBACK_PREFIX}${nonce}:${o.id}`, label: this.modelChoiceLabel(o, currentModel) });
16136
17128
  }
16137
17129
  if (isClaude)
@@ -16538,16 +17530,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16538
17530
  }
16539
17531
  }
16540
17532
  /** Cached-or-live account catalog behind "/model → more models" (claude). */
16541
- async claudeApiModelOptions() {
16542
- const cached = this.readCliEnv("claude-code");
17533
+ async claudeApiModelOptions(instanceName) {
17534
+ // #2072: the account behind THIS instance — its credential profile's catalog, else the default login's.
17535
+ const profile = this.catalogProfile("claude-code", instanceName);
17536
+ const cached = this.readCliEnv("claude-code", profile);
16543
17537
  if (cached?.apiModels?.length)
16544
17538
  return cached.apiModels;
16545
- const env = await this.probeBackendBounded("claude-code");
17539
+ const env = await this.probeBackendBounded("claude-code", { profile });
16546
17540
  return env?.apiModels ?? [];
16547
17541
  }
16548
17542
  /** Replace a consumed "/model" menu with the full account catalog tier. */
16549
17543
  async expandClaudeModelMenu(pending) {
16550
- const expanded = await this.claudeApiModelOptions();
17544
+ const expanded = await this.claudeApiModelOptions(pending.instanceName);
16551
17545
  if (!expanded.length) {
16552
17546
  await pending.respond(t("model.more_unavailable")).catch(() => { });
16553
17547
  return;
@@ -16975,7 +17969,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
16975
17969
  return {
16976
17970
  currentBackend: (name) => this.backendNameForInstance(name),
16977
17971
  currentDisplay: (name) => `${this.backendNameForInstance(name)} · ${this.resolveInstanceModel(name).display}`,
16978
- installedBackends: () => KNOWN_BACKENDS.filter(b => b !== "mock" && this.backendInstalledHere(b)),
17972
+ availability: () => this.backendAvailabilityFor(instanceName),
16979
17973
  models: (backend) => this.backendModelOptions(backend, opts),
16980
17974
  // #1808: the same rule update_instance_config's switch applies (backendModelVerdict).
16981
17975
  modelKnown: (backend, model) => this.backendModelVerdict(instanceName, backend, model).ok,
@@ -17074,10 +18068,76 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17074
18068
  // kiro-cli gates its own --effort by version at launch (a quiet console note otherwise); muse always passes it.
17075
18069
  return () => true;
17076
18070
  }
17077
- /** Installed the way the launch finds a CLI (resolveBinary): on PATH or in a known install directory. */
17078
- backendInstalledHere(backend) {
17079
- const binary = BACKEND_INSTALLATION_INFO[backend]?.binary;
17080
- return !!binary && resolveBinary(binary) !== binary;
18071
+ /**
18072
+ * #2072: what this host can run, from the published availability snapshot (backend-availability.ts). Read only: a
18073
+ * handler never starts a probe, not even in the background — launch, the timer, and /login install or sign-in do.
18074
+ */
18075
+ backendAvailabilityRows() {
18076
+ return this.backendAvailability.snapshot();
18077
+ }
18078
+ /**
18079
+ * #2072 part 3: the models a Settings field offers for `backend` — the cached catalog (an instance's own credential
18080
+ * profile's, when it is named and has one), claude's account catalog included. Read only: never a probe.
18081
+ */
18082
+ modelCatalogFor(backend, instance) {
18083
+ if (!KNOWN_BACKENDS.includes(backend) || backend === "mock")
18084
+ return [];
18085
+ const own = instance && Object.prototype.hasOwnProperty.call(this.fleetConfig?.instances ?? {}, instance) ? instance
18086
+ : instance && this.classicChannels?.getChannelIdByInstance(instance) !== undefined ? instance : null;
18087
+ const env = this.readCliEnv(backend, this.catalogProfile(backend, own));
18088
+ const seen = new Set(), out = [];
18089
+ for (const m of [...(env?.models ?? []), ...(env?.apiModels ?? [])])
18090
+ if (!seen.has(m.id)) {
18091
+ seen.add(m.id);
18092
+ out.push(m);
18093
+ }
18094
+ return out;
18095
+ }
18096
+ /**
18097
+ * #2072 (#2078 review): the snapshot as THIS instance would see it. Its sign-in on a backend is its own credential
18098
+ * profile's when it has one there — the host's default login says nothing about it. A profile's login is a file check
18099
+ * (credentialProfileLogin), no process.
18100
+ */
18101
+ backendAvailabilityFor(instanceName) {
18102
+ return this.backendAvailabilityRows().map(row => {
18103
+ const profile = this.credentialProfileOf(instanceName, row.backend);
18104
+ if (!profile || (row.state !== "usable" && row.state !== "not_signed_in" && row.state !== "unknown") || !row.path)
18105
+ return row;
18106
+ let login = "unknown";
18107
+ try {
18108
+ login = credentialProfileLogin(this.dataDir, row.backend, profile).state;
18109
+ }
18110
+ catch { /* unknown */ }
18111
+ // Signed in there: usable for this instance. Signed out (or unknown): the host's default login says nothing about
18112
+ // it, so no "not signed in" claim from it — update_instance_config's profile gate refuses a signed-out profile
18113
+ // when applied, naming the profile and the command that signs it in.
18114
+ if (login === "logged-in")
18115
+ return { ...row, auth: "valid", state: "usable" };
18116
+ return row.state === "not_signed_in" ? { ...row, auth: "unknown", state: "unknown" } : row;
18117
+ });
18118
+ }
18119
+ /** The probes the snapshot runs (tests replace them: no CLI, no account, no network). */
18120
+ availabilityProbes = hostAvailabilityProbes;
18121
+ availabilityStore = null;
18122
+ availabilityTimer = null;
18123
+ get backendAvailability() {
18124
+ return this.availabilityStore ??= new BackendAvailabilityStore(KNOWN_BACKENDS.filter(b => b !== "mock" && !!BACKEND_INSTALLATION_INFO[b]), this.availabilityProbes, { onChange: () => this.noteAvailabilityPaths() });
18125
+ }
18126
+ /** A launch need not look the binary up again (a synchronous `which` on the loop) when the snapshot found it. */
18127
+ noteAvailabilityPaths() {
18128
+ for (const row of this.availabilityStore?.snapshot() ?? []) {
18129
+ const binary = BACKEND_INSTALLATION_INFO[row.backend]?.binary;
18130
+ if (binary)
18131
+ noteResolvedBinary(binary, row.state === "not_installed" ? null : row.path);
18132
+ }
18133
+ }
18134
+ /** Probe now and then every AVAILABILITY_STALE_MS; a pass that finds nothing changed starts no CLI. */
18135
+ startAvailabilityRefresh() {
18136
+ void this.backendAvailability.refresh();
18137
+ if (this.availabilityTimer)
18138
+ clearInterval(this.availabilityTimer);
18139
+ this.availabilityTimer = setInterval(() => { void this.backendAvailability.refresh(); }, AVAILABILITY_STALE_MS);
18140
+ this.availabilityTimer.unref?.();
17081
18141
  }
17082
18142
  /** The models `/model` would offer on a backend: the cached CLI env, else (live) a bounded probe. Never throws. */
17083
18143
  async backendModelOptions(backend, opts) {
@@ -17419,9 +18479,11 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17419
18479
  }
17420
18480
  return undefined;
17421
18481
  }
18482
+ /** #2072: from the published snapshot (no `which` on the loop); not checked yet counts as installed (no false alarm). */
17422
18483
  isBackendInstalled(backend) {
17423
- const installation = BACKEND_INSTALLATION_INFO[backend];
17424
- return !!installation && checkBinaryInstalled(installation.binary);
18484
+ if (!BACKEND_INSTALLATION_INFO[backend])
18485
+ return false;
18486
+ return this.backendAvailability.get(backend)?.state !== "not_installed";
17425
18487
  }
17426
18488
  getMissingBackendWarning(backend) {
17427
18489
  if (!backend)
@@ -17759,6 +18821,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17759
18821
  clearInterval(this.eventLogPruneTimer);
17760
18822
  this.eventLogPruneTimer = null;
17761
18823
  }
18824
+ if (this.guestRoomsTimer) {
18825
+ clearInterval(this.guestRoomsTimer);
18826
+ this.guestRoomsTimer = null;
18827
+ }
17762
18828
  this.logService?.unwatchAll(); // #1218: a Log's watch timers end with the fleet
17763
18829
  this.compactWatchService?.stopAll();
17764
18830
  if (this.outboxPruneTimer) {
@@ -17858,6 +18924,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
17858
18924
  this.adapters.clear();
17859
18925
  this.controlClient?.stop();
17860
18926
  this.controlClient = null;
18927
+ if (this.availabilityTimer) {
18928
+ clearInterval(this.availabilityTimer);
18929
+ this.availabilityTimer = null;
18930
+ }
17861
18931
  if (this.healthServer) {
17862
18932
  this.healthServerListening = false;
17863
18933
  const server = this.healthServer;
@@ -19842,6 +20912,159 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19842
20912
  this.rejectedHostsLogged.add(name);
19843
20913
  this.logger.warn({ host: name }, "Web request refused: Host is not allowed (add it to web.allowed_hosts if this is a proxy you run)");
19844
20914
  }
20915
+ /**
20916
+ * #1920 §3: the instances a guest session may see — derived on every request from the account's chat access, never
20917
+ * stored; nothing while `web.guests.enabled` is not true.
20918
+ * - Its connection's fleet topics and General (what it may talk to in chat): when that connection's access lets the
20919
+ * account in (AccessManager.isAllowed — allowed_users or paired). An `open` connection lets everyone in, so it gives
20920
+ * only the instances an admin named for its guests (`web.guests.open_instances`; the user's decision: least
20921
+ * privilege, as an open group has no list of people).
20922
+ * - A Telegram private ClassicBot room that is the account's own (its chat id is the user id).
20923
+ * - A ClassicBot group or guild room on its connection: only while its membership is verified (§3.2 — a "member"
20924
+ * answer read in the background, at most 15 minutes old; unknown, failed or stale hides it).
20925
+ * - Then an admin's narrowing (`web.guests.narrow`), which only takes away.
20926
+ * A fleet admin is not a guest: an admin outside the connection's access gets nothing here (admins sign in as admins).
20927
+ */
20928
+ guestScope(owner) {
20929
+ const guests = this.fleetConfig?.web?.guests;
20930
+ if (!owner || guests?.enabled !== true)
20931
+ return NO_GUEST_SCOPE;
20932
+ const scope = new Set(this.guestReach(owner));
20933
+ const narrow = guests.narrow && Object.hasOwn(guests.narrow, `${owner.adapterId}:${owner.userId}`) ? guests.narrow[`${owner.adapterId}:${owner.userId}`] : undefined;
20934
+ if (narrow) {
20935
+ const keep = new Set(narrow);
20936
+ for (const name of [...scope])
20937
+ if (!keep.has(name))
20938
+ scope.delete(name);
20939
+ }
20940
+ return scope.size ? scope : NO_GUEST_SCOPE;
20941
+ }
20942
+ /**
20943
+ * #1920 (#2104 review): what an account reaches before an admin's narrowing — its connection's topics (an open one's
20944
+ * named agents), its own Telegram private room, its verified group rooms. Narrowing can only take from this set, so it
20945
+ * is what Settings offers to narrow to. Empty while guest sign-in is off.
20946
+ */
20947
+ guestReach(owner) {
20948
+ const guests = this.fleetConfig?.web?.guests;
20949
+ const scope = new Set();
20950
+ if (guests?.enabled !== true)
20951
+ return scope;
20952
+ const own = (map, key) => (map && Object.hasOwn(map, key) ? map[key] : undefined);
20953
+ const world = this.worlds.get(owner.adapterId);
20954
+ if (world && world.accessManager.isAllowed(owner.userId)) {
20955
+ const named = world.accessManager.getMode() === "open" ? new Set(own(guests.open_instances, owner.adapterId) ?? []) : null;
20956
+ for (const name of Object.keys(this.fleetConfig?.instances ?? {})) {
20957
+ if (this.getInstanceAdapterId(name) !== owner.adapterId || this.isClassicInstance(name))
20958
+ continue;
20959
+ if (!named || named.has(name))
20960
+ scope.add(name);
20961
+ }
20962
+ }
20963
+ const rooms = this.classicChannels?.getAll() ?? [];
20964
+ for (const room of rooms) {
20965
+ if (this.isOwnPrivateRoom(owner, room))
20966
+ scope.add(room.instanceName);
20967
+ }
20968
+ const members = this.guestRooms();
20969
+ for (const room of guestGroupRooms(rooms, owner)) {
20970
+ if (members.verified(room, owner.userId))
20971
+ scope.add(room.instance);
20972
+ }
20973
+ return scope;
20974
+ }
20975
+ /**
20976
+ * #1920 §3.5: a guest session still admitted — current, with something in scope. A scope that became empty (guest
20977
+ * sign-in off, the account removed from the connection's access, everything narrowed away) ends the session here,
20978
+ * so the next request and the open stream find it gone.
20979
+ */
20980
+ guestStillAdmitted(record) {
20981
+ return this.guestAdmission(record) === "ok";
20982
+ }
20983
+ /**
20984
+ * The same, with a third answer (§3.2): "pending" — nothing in scope now, but a room's membership is not read yet
20985
+ * (a restart, a room just created). The request is refused and the session kept; its read starts in the background.
20986
+ */
20987
+ guestAdmission(record) {
20988
+ if (!this.webSessions || !this.webToken || !this.webSessions.isCurrent(record, tokenEpoch(this.webToken)))
20989
+ return "gone";
20990
+ const owner = record.owner;
20991
+ if (this.guestScope(owner).size)
20992
+ return "ok";
20993
+ if (owner && this.fleetConfig?.web?.guests?.enabled === true) {
20994
+ const members = this.guestRooms(), rooms = guestGroupRooms(this.classicChannels?.getAll() ?? [], owner);
20995
+ if (rooms.some((room) => members.pending(room, owner.userId))) {
20996
+ void this.refreshGuestRooms(owner, { mode: "triggered" }); // rate-limited per room (30 s), single-flight
20997
+ return "pending";
20998
+ }
20999
+ }
21000
+ this.enforceGuestSwitch();
21001
+ const ended = this.webSessions.revokeByHandle(record.handle);
21002
+ if (ended.found)
21003
+ this.logger.info({ adapterId: record.owner?.adapterId, durable: ended.durable }, "Guest web session ended: nothing left in its scope");
21004
+ return "gone";
21005
+ }
21006
+ /** #1920 §3.2: the membership answers; the read goes to the room's own connection, never waits past its deadline. */
21007
+ guestRooms() {
21008
+ this.guestRoomMembership ??= new GuestRoomMembership({
21009
+ now: () => performance.now(),
21010
+ read: (room, userId) => {
21011
+ const adapter = this.worlds.get(room.adapterId)?.adapter;
21012
+ return room.kind === "telegram" ? adapter?.isGroupMember?.(room.channelId, userId) : adapter?.canViewChannel?.(room.channelId, userId);
21013
+ },
21014
+ // Said once per key while its reads keep failing (no user id: the room and connection are what an admin fixes).
21015
+ onPersistentFailure: (room, failures) => this.logger.warn({ adapterId: room.adapterId, instance: room.instance, failures }, "Guest room membership keeps failing to read (the bot may lack the permission, or was removed); retrying with backoff up to 10 minutes"),
21016
+ onRecovered: (room) => this.logger.info({ adapterId: room.adapterId, instance: room.instance }, "Guest room membership reads again"),
21017
+ });
21018
+ return this.guestRoomMembership;
21019
+ }
21020
+ /** #1920 §3.2: read this guest's rooms that are due (GuestRoomMembership.refresh says when each mode reads). */
21021
+ async refreshGuestRooms(owner, opts = {}) {
21022
+ if (this.fleetConfig?.web?.guests?.enabled !== true)
21023
+ return;
21024
+ try {
21025
+ await this.guestRooms().refresh(owner, guestGroupRooms(this.classicChannels?.getAll() ?? [], owner), opts);
21026
+ }
21027
+ catch (err) {
21028
+ this.logger.warn({ err: err.message }, "Guest room membership could not be read");
21029
+ }
21030
+ }
21031
+ /** Every account with a live guest session, one after another (the guest caps bound them); one pass at a time. */
21032
+ async refreshAllGuestRooms() {
21033
+ if (this.guestRoomsPass || this.fleetConfig?.web?.guests?.enabled !== true || !this.webSessions)
21034
+ return;
21035
+ this.guestRoomsPass = true;
21036
+ try {
21037
+ for (const owner of this.webSessions.guestOwners())
21038
+ await this.refreshGuestRooms(owner);
21039
+ }
21040
+ finally {
21041
+ this.guestRoomsPass = false;
21042
+ }
21043
+ }
21044
+ guestRoomsPass = false;
21045
+ /**
21046
+ * #1920 §3.5: guest sign-in turned off ends every guest session — checked when the configuration is read or saved
21047
+ * and when the sessions are opened, so turning it back on never revives one.
21048
+ */
21049
+ enforceGuestSwitch() {
21050
+ if (this.fleetConfig?.web?.guests?.enabled === true || !this.webSessions)
21051
+ return;
21052
+ const ended = this.webSessions.revokeAll((r) => r.tier === "guest");
21053
+ if (ended.count)
21054
+ this.logger.info({ count: ended.count, durable: ended.durable }, "Guest web sign-in is off: every guest session ended");
21055
+ // #1920 step 4b: and every guest's returning device — turning it back on lets no old browser ask for a code.
21056
+ const devices = this.returningDevices?.revokeGuest();
21057
+ if (devices?.count)
21058
+ this.logger.info({ count: devices.count, durable: devices.durable }, "Guest web sign-in is off: every guest's returning device forgotten");
21059
+ }
21060
+ /** #1920 §5.2: whether this instance is the account's own Telegram private ClassicBot room (its chat id is the user id). */
21061
+ guestOwnRoom(owner, instance) {
21062
+ return (this.classicChannels?.getAll() ?? []).some((room) => room.instanceName === instance && this.isOwnPrivateRoom(owner, room));
21063
+ }
21064
+ /** A private room's chat id is its one user's id (a group's is negative, so never equal); Telegram only. */
21065
+ isOwnPrivateRoom(owner, room) {
21066
+ return room.adapterId === owner.adapterId && room.channelId === owner.userId && inferClassicChannelType(room.channelId) === "telegram";
21067
+ }
19845
21068
  /** Shared handler dispatcher; gateway admission is enforced by its separate listener. */
19846
21069
  dispatchWebHttp(req, res, port, closing = false) {
19847
21070
  try {
@@ -19872,6 +21095,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19872
21095
  return;
19873
21096
  }
19874
21097
  const requestPath = new URL(req.url ?? "/", `http://localhost:${port}`).pathname;
21098
+ if (this.effectBootstrap.handle(req, res))
21099
+ return;
19875
21100
  // Branding is public, after Host admission: automatic favicon requests
19876
21101
  // must not become noisy web-token 401s in the browser console.
19877
21102
  if (handleWebIconRequest(req, res, requestPath))
@@ -19902,6 +21127,40 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
19902
21127
  res.end(JSON.stringify({ error: decision.message }));
19903
21128
  return;
19904
21129
  }
21130
+ // #1920: a guest reaches only GUEST_ROUTES, within the scope derived for it now — default deny, before any
21131
+ // handler, on this listener and the public link's gateway alike (both come through here).
21132
+ if (decision.via === "session" && decision.session.tier === "guest") {
21133
+ const owner = decision.session.owner;
21134
+ const admitted = decision.session;
21135
+ const admission = this.guestAdmission(admitted);
21136
+ if (admission === "pending") {
21137
+ // §3.2: a room's membership is being read — try again shortly; the session stays.
21138
+ res.writeHead(503, { "Retry-After": "5" });
21139
+ res.end(JSON.stringify({ error: "guest_scope_pending" }));
21140
+ return;
21141
+ }
21142
+ if (admission === "gone") {
21143
+ res.writeHead(401);
21144
+ res.end(JSON.stringify({ error: "guest_scope_empty" }));
21145
+ return;
21146
+ }
21147
+ const verdict = guestRouteDecision(req.method ?? "GET", parsedUrl, this.guestScope(owner));
21148
+ if (verdict.kind === "deny") {
21149
+ res.writeHead(verdict.status);
21150
+ res.end(JSON.stringify({ error: verdict.error }));
21151
+ return;
21152
+ }
21153
+ // Phase 1b: the handlers that answer per recipient (the stream, the poll, a file) know it is a guest's, and
21154
+ // ask its scope anew each time; an emptied scope ends the session (and closes its stream).
21155
+ bindGuestRequest(req, { owner, scope: () => this.guestScope(owner), current: () => this.guestStillAdmitted(admitted),
21156
+ // #1920 §5.2: its own Telegram private room shows it everything; the kept messages place ticks and buttons.
21157
+ ownRoom: (instance) => this.guestOwnRoom(owner, instance),
21158
+ // §3.2: a stream that connects reads its rooms again (in the background; at most once a minute).
21159
+ streamOpened: () => { void this.refreshGuestRooms(owner, { mode: "force" }); },
21160
+ history: () => this.webChatHistory,
21161
+ // #1920 §5.1: guests chat unless the host made them read-only (web.guests.chat: false).
21162
+ chat: () => this.fleetConfig?.web?.guests?.chat !== false });
21163
+ }
19905
21164
  }
19906
21165
  if (req.method === "GET" && req.url === "/health") {
19907
21166
  const health = this.getFleetHealth();
@@ -20292,6 +21551,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20292
21551
  this.logger.warn({ code: err.code, port: settings.port }, "Preview listener unavailable; HTML previews are off");
20293
21552
  this.previewListening = false;
20294
21553
  this.previewListener = null;
21554
+ this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
20295
21555
  });
20296
21556
  // An optional listener never takes the fleet down: a port Node refuses outright throws here, synchronously.
20297
21557
  try {
@@ -20302,6 +21562,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20302
21562
  this.logger.warn({ err: err.message, port: settings.port }, "Preview listener cannot listen; HTML previews are off");
20303
21563
  this.previewListening = false;
20304
21564
  this.previewListener = null;
21565
+ this.previewListenError = `${settings.port}: ${err.code ?? err.message}`;
20305
21566
  }
20306
21567
  }
20307
21568
  /** What a reload may change hot: web.preview, and the dashboard names (the shim's allow-list and frame-ancestors). */
@@ -20322,6 +21583,7 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20322
21583
  }
20323
21584
  stopPreviewListener() {
20324
21585
  this.previewListening = false;
21586
+ this.previewListenError = null;
20325
21587
  this.previewListener?.close();
20326
21588
  this.previewListener = null;
20327
21589
  }
@@ -20329,10 +21591,18 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20329
21591
  previewForUi(hostHeader, secure) {
20330
21592
  const listener = this.previewListening ? this.previewListener : null;
20331
21593
  const decided = previewAvailability(listener ? listener.settings : null, hostHeader, secure, listener ? listener.origins : undefined);
21594
+ // Previews are on, but no listener answers: say why — its port could not be opened (the error, never "turned off"),
21595
+ // or it is not listening yet.
20332
21596
  if (!listener && this.fleetConfig?.web?.preview !== false)
20333
- decided.reason = "Previews are not available on this fleet right now.";
21597
+ Object.assign(decided, this.previewNotListening());
20334
21598
  return { ...decided, boot: listener ? listener.bootId : null };
20335
21599
  }
21600
+ previewNotListening() {
21601
+ const failed = this.previewListenError;
21602
+ return failed
21603
+ ? { code: "listenFailed", detail: failed, reason: `Previews are unavailable: the fleet could not open its preview port (${failed}). Another program may be using it — set web.preview_port, then restart AgEnD.` }
21604
+ : { code: "notListening", reason: "Previews are not available on this fleet right now." };
21605
+ }
20336
21606
  /**
20337
21607
  * #1723: for one page of the public link (exposure `exposureId`, at `dashboardOrigin`): its own preview tunnel's
20338
21608
  * origin once up, "starting" until then, why it failed if it did — or a configured web.preview_origin, used instead.
@@ -20347,8 +21617,10 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20347
21617
  const configured = typeof web?.preview_origin === "string" ? normalizeOrigin(web.preview_origin) : null;
20348
21618
  if (configured) {
20349
21619
  const listener = this.previewListening ? this.previewListener : null;
20350
- if (!listener)
20351
- return off("publicLink", "Previews are not available on this fleet right now.");
21620
+ if (!listener) {
21621
+ const why = this.previewNotListening();
21622
+ return off(why.code, why.reason, why.detail ? { detail: why.detail } : {});
21623
+ }
20352
21624
  if (!configured.startsWith("https:"))
20353
21625
  return off("needHttps", "Previews need an https web.preview_origin when the dashboard is reached over https.");
20354
21626
  return { dashboardOrigin, previewOrigin: configured, reason: null, code: null, boot: listener.bootId, frameSrc: `${configured}/frame` };
@@ -20452,6 +21724,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20452
21724
  catch {
20453
21725
  return {};
20454
21726
  } })(),
21727
+ // #2118: only this Chat's placement content; guest projection drops global revisions.
21728
+ effect_chat_v: createHash("sha256").update(JSON.stringify(this.themeStore.get(name).placements.effects)).digest("hex").slice(0, 32),
20455
21729
  // #1958: the identity colour's tiers (the page resolves them: the agent's only while it shows agents' themes).
20456
21730
  color: { ...(colors.get(name)?.color ? { person: colors.get(name).color } : {}), ...(image.agentColor ? { agent: image.agentColor } : {}),
20457
21731
  ...(image.roleColor ? { role: image.roleColor } : {}), auto: autoIdentityColor(name) },
@@ -20460,6 +21734,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20460
21734
  description: identity.description || undefined,
20461
21735
  role: identity.role || undefined,
20462
21736
  tags: identity.tags,
21737
+ teams: instanceTeams(this.fleetConfig?.teams, name), // #2172: the sidebar groups a team's members under it
21738
+ last_reply: this.webChatHistory.lastAgentReply(name) ?? undefined, // #2179: its last reply to a person (ISO)
20463
21739
  status: this.getInstanceStatus(name),
20464
21740
  // `state` (presentation: may be awaiting_input) and `execution_state` (working / idle / stuck, or null —
20465
21741
  // what the dashboard's activity events carry) come from instancePresentation (#1212).
@@ -20488,6 +21764,8 @@ Plus the operational skills (fleet-health, instance-lifecycle, scheduling, sessi
20488
21764
  // #1685: host memory pressure while it slows or holds new agents (the dashboard's indicator; macOS elevated is
20489
21765
  // shown here rather than in chat). null when normal, unknown or advisory.
20490
21766
  host_memory: this.hostMemoryIndicator(),
21767
+ // #2015: the largest processes while it lasts (name, pid, RSS, and whose: an instance, the fleet or tmux).
21768
+ ...(this.hostMemoryIndicator() && this.memoryTop ? { host_memory_top: this.memoryTop.processes } : {}),
20491
21769
  };
20492
21770
  }
20493
21771
  }