@songsid/agend 2.3.0-beta.2 → 2.3.0-beta.21

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 (502) hide show
  1. package/README.md +7 -1
  2. package/README.zh-TW.md +7 -1
  3. package/dist/agent-cli-instructions.md +9 -2
  4. package/dist/agent-cli.js +63 -2
  5. package/dist/agent-cli.js.map +1 -1
  6. package/dist/agent-endpoint.js +4 -0
  7. package/dist/agent-endpoint.js.map +1 -1
  8. package/dist/agent-file-read.d.ts +4 -1
  9. package/dist/agent-file-read.js +189 -16
  10. package/dist/agent-file-read.js.map +1 -1
  11. package/dist/agent-whats-new.d.ts +11 -0
  12. package/dist/agent-whats-new.js +29 -5
  13. package/dist/agent-whats-new.js.map +1 -1
  14. package/dist/auth-api.d.ts +30 -1
  15. package/dist/auth-api.js +45 -13
  16. package/dist/auth-api.js.map +1 -1
  17. package/dist/authority/authority-state.d.ts +38 -0
  18. package/dist/authority/authority-state.js +114 -0
  19. package/dist/authority/authority-state.js.map +1 -0
  20. package/dist/authority/session-authorizations.d.ts +43 -0
  21. package/dist/authority/session-authorizations.js +102 -0
  22. package/dist/authority/session-authorizations.js.map +1 -0
  23. package/dist/authority/web-authority.d.ts +120 -0
  24. package/dist/authority/web-authority.js +157 -0
  25. package/dist/authority/web-authority.js.map +1 -0
  26. package/dist/backend/claude-code.d.ts +2 -2
  27. package/dist/backend/claude-code.js +18 -8
  28. package/dist/backend/claude-code.js.map +1 -1
  29. package/dist/backend/codex-metadata.d.ts +8 -0
  30. package/dist/backend/codex-metadata.js +74 -17
  31. package/dist/backend/codex-metadata.js.map +1 -1
  32. package/dist/backend/codex.js +15 -2
  33. package/dist/backend/codex.js.map +1 -1
  34. package/dist/backend/credential-profile.d.ts +2 -0
  35. package/dist/backend/credential-profile.js +2 -0
  36. package/dist/backend/credential-profile.js.map +1 -1
  37. package/dist/backend/types.d.ts +6 -0
  38. package/dist/backend/types.js +15 -0
  39. package/dist/backend/types.js.map +1 -1
  40. package/dist/backend-availability.d.ts +120 -0
  41. package/dist/backend-availability.js +396 -0
  42. package/dist/backend-availability.js.map +1 -0
  43. package/dist/backend-command.d.ts +23 -3
  44. package/dist/backend-command.js +25 -4
  45. package/dist/backend-command.js.map +1 -1
  46. package/dist/bug-report-cli.d.ts +30 -0
  47. package/dist/bug-report-cli.js +138 -0
  48. package/dist/bug-report-cli.js.map +1 -0
  49. package/dist/bug-report.d.ts +47 -0
  50. package/dist/bug-report.js +136 -0
  51. package/dist/bug-report.js.map +1 -0
  52. package/dist/bundled-skills.d.ts +14 -0
  53. package/dist/bundled-skills.js +29 -0
  54. package/dist/bundled-skills.js.map +1 -0
  55. package/dist/cache-ledger.d.ts +16 -3
  56. package/dist/cache-ledger.js +33 -13
  57. package/dist/cache-ledger.js.map +1 -1
  58. package/dist/cache-service.d.ts +55 -3
  59. package/dist/cache-service.js +152 -16
  60. package/dist/cache-service.js.map +1 -1
  61. package/dist/channel/adapters/discord-client-retirement.d.ts +5 -0
  62. package/dist/channel/adapters/discord-client-retirement.js +57 -0
  63. package/dist/channel/adapters/discord-client-retirement.js.map +1 -0
  64. package/dist/channel/adapters/discord.d.ts +9 -0
  65. package/dist/channel/adapters/discord.js +49 -4
  66. package/dist/channel/adapters/discord.js.map +1 -1
  67. package/dist/channel/mcp-tools.js +22 -11
  68. package/dist/channel/mcp-tools.js.map +1 -1
  69. package/dist/channel/types.d.ts +6 -0
  70. package/dist/classic-channel-manager.d.ts +10 -5
  71. package/dist/classic-channel-manager.js +12 -9
  72. package/dist/classic-channel-manager.js.map +1 -1
  73. package/dist/cli.js +224 -111
  74. package/dist/cli.js.map +1 -1
  75. package/dist/config-validator.d.ts +6 -0
  76. package/dist/config-validator.js +129 -3
  77. package/dist/config-validator.js.map +1 -1
  78. package/dist/config.d.ts +13 -1
  79. package/dist/config.js +14 -5
  80. package/dist/config.js.map +1 -1
  81. package/dist/cross-instance-notice.d.ts +7 -0
  82. package/dist/cross-instance-notice.js +10 -0
  83. package/dist/cross-instance-notice.js.map +1 -1
  84. package/dist/daemon.d.ts +24 -1
  85. package/dist/daemon.js +151 -19
  86. package/dist/daemon.js.map +1 -1
  87. package/dist/decision-list.d.ts +25 -0
  88. package/dist/decision-list.js +30 -0
  89. package/dist/decision-list.js.map +1 -0
  90. package/dist/delivery-outbox.d.ts +10 -0
  91. package/dist/delivery-outbox.js +17 -1
  92. package/dist/delivery-outbox.js.map +1 -1
  93. package/dist/diagnostic-redaction.d.ts +10 -0
  94. package/dist/diagnostic-redaction.js +167 -0
  95. package/dist/diagnostic-redaction.js.map +1 -0
  96. package/dist/fleet-context.d.ts +10 -0
  97. package/dist/fleet-manager.d.ts +505 -15
  98. package/dist/fleet-manager.js +1870 -204
  99. package/dist/fleet-manager.js.map +1 -1
  100. package/dist/fleet-process-handlers.d.ts +18 -0
  101. package/dist/fleet-process-handlers.js +69 -0
  102. package/dist/fleet-process-handlers.js.map +1 -0
  103. package/dist/fleet-traffic.d.ts +74 -0
  104. package/dist/fleet-traffic.js +196 -0
  105. package/dist/fleet-traffic.js.map +1 -0
  106. package/dist/general-knowledge/skills/bug-report/SKILL.md +31 -0
  107. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +17 -6
  108. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +6 -1
  109. package/dist/general-knowledge/skills/fleet-config/SKILL.md +2 -0
  110. package/dist/general-knowledge/skills/multi-channel/SKILL.md +3 -2
  111. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
  112. package/dist/general-knowledge/skills/scheduling/SKILL.md +4 -0
  113. package/dist/general-knowledge/skills/web-appearance/SKILL.md +190 -47
  114. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +545 -0
  115. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  116. package/dist/human-delivery-receipts.d.ts +24 -1
  117. package/dist/human-delivery-receipts.js +64 -11
  118. package/dist/human-delivery-receipts.js.map +1 -1
  119. package/dist/instance-config-impact.js +7 -1
  120. package/dist/instance-config-impact.js.map +1 -1
  121. package/dist/instance-lifecycle.d.ts +10 -1
  122. package/dist/instance-lifecycle.js +79 -18
  123. package/dist/instance-lifecycle.js.map +1 -1
  124. package/dist/instance-names.d.ts +4 -0
  125. package/dist/instance-names.js +12 -0
  126. package/dist/instance-names.js.map +1 -0
  127. package/dist/instructions.d.ts +12 -1
  128. package/dist/instructions.js +13 -2
  129. package/dist/instructions.js.map +1 -1
  130. package/dist/locale.js +173 -12
  131. package/dist/locale.js.map +1 -1
  132. package/dist/log-steps.d.ts +1 -0
  133. package/dist/log-steps.js +15 -0
  134. package/dist/log-steps.js.map +1 -1
  135. package/dist/login-controller.d.ts +45 -0
  136. package/dist/login-controller.js +101 -5
  137. package/dist/login-controller.js.map +1 -1
  138. package/dist/ls-attach-resolve.d.ts +96 -0
  139. package/dist/ls-attach-resolve.js +180 -0
  140. package/dist/ls-attach-resolve.js.map +1 -0
  141. package/dist/ls-rows.d.ts +12 -0
  142. package/dist/ls-rows.js +82 -3
  143. package/dist/ls-rows.js.map +1 -1
  144. package/dist/memory-hogs.d.ts +27 -0
  145. package/dist/memory-hogs.js +124 -0
  146. package/dist/memory-hogs.js.map +1 -0
  147. package/dist/needs-you.d.ts +10 -0
  148. package/dist/needs-you.js +2 -1
  149. package/dist/needs-you.js.map +1 -1
  150. package/dist/outbound-handlers.d.ts +11 -0
  151. package/dist/outbound-handlers.js +139 -11
  152. package/dist/outbound-handlers.js.map +1 -1
  153. package/dist/outbound-schemas.d.ts +200 -4
  154. package/dist/outbound-schemas.js +124 -11
  155. package/dist/outbound-schemas.js.map +1 -1
  156. package/dist/public-web-gateway.js +17 -4
  157. package/dist/public-web-gateway.js.map +1 -1
  158. package/dist/public-web-link.d.ts +9 -0
  159. package/dist/public-web-link.js +8 -0
  160. package/dist/public-web-link.js.map +1 -1
  161. package/dist/quickstart-api.d.ts +19 -1
  162. package/dist/quickstart-api.js +94 -4
  163. package/dist/quickstart-api.js.map +1 -1
  164. package/dist/reply-buttons-controller.d.ts +4 -0
  165. package/dist/reply-buttons-controller.js +2 -2
  166. package/dist/reply-buttons-controller.js.map +1 -1
  167. package/dist/restart-progress.d.ts +80 -1
  168. package/dist/restart-progress.js +306 -21
  169. package/dist/restart-progress.js.map +1 -1
  170. package/dist/rollout-index.d.ts +5 -0
  171. package/dist/rollout-index.js +26 -5
  172. package/dist/rollout-index.js.map +1 -1
  173. package/dist/scheduler/db.d.ts +40 -13
  174. package/dist/scheduler/db.js +123 -89
  175. package/dist/scheduler/db.js.map +1 -1
  176. package/dist/scheduler/task-board-text.d.ts +26 -0
  177. package/dist/scheduler/task-board-text.js +135 -0
  178. package/dist/scheduler/task-board-text.js.map +1 -0
  179. package/dist/scheduler/task-board.d.ts +650 -0
  180. package/dist/scheduler/task-board.js +1938 -0
  181. package/dist/scheduler/task-board.js.map +1 -0
  182. package/dist/scheduler/types.d.ts +6 -1
  183. package/dist/search/search-docs.d.ts +58 -0
  184. package/dist/search/search-docs.js +63 -0
  185. package/dist/search/search-docs.js.map +1 -0
  186. package/dist/search/search-index.d.ts +141 -0
  187. package/dist/search/search-index.js +440 -0
  188. package/dist/search/search-index.js.map +1 -0
  189. package/dist/search/search-store.d.ts +122 -0
  190. package/dist/search/search-store.js +236 -0
  191. package/dist/search/search-store.js.map +1 -0
  192. package/dist/search/search-worker.d.ts +1 -0
  193. package/dist/search/search-worker.js +163 -0
  194. package/dist/search/search-worker.js.map +1 -0
  195. package/dist/service-installer.js +12 -0
  196. package/dist/service-installer.js.map +1 -1
  197. package/dist/settings-api.d.ts +23 -0
  198. package/dist/settings-api.js +164 -3
  199. package/dist/settings-api.js.map +1 -1
  200. package/dist/settings-change.js +104 -0
  201. package/dist/settings-change.js.map +1 -1
  202. package/dist/settings-confirmation.d.ts +3 -1
  203. package/dist/settings-confirmation.js +2 -0
  204. package/dist/settings-confirmation.js.map +1 -1
  205. package/dist/settings-effect.js +34 -3
  206. package/dist/settings-effect.js.map +1 -1
  207. package/dist/settings-http-confirmation.d.ts +11 -0
  208. package/dist/settings-http-confirmation.js +10 -0
  209. package/dist/settings-http-confirmation.js.map +1 -1
  210. package/dist/setup-form.d.ts +1 -1
  211. package/dist/setup-form.js +23 -7
  212. package/dist/setup-form.js.map +1 -1
  213. package/dist/spawn-gate.d.ts +1 -0
  214. package/dist/spawn-gate.js +13 -3
  215. package/dist/spawn-gate.js.map +1 -1
  216. package/dist/task-board-tool.d.ts +25 -0
  217. package/dist/task-board-tool.js +154 -0
  218. package/dist/task-board-tool.js.map +1 -0
  219. package/dist/task-board-waker.d.ts +44 -0
  220. package/dist/task-board-waker.js +128 -0
  221. package/dist/task-board-waker.js.map +1 -0
  222. package/dist/tmux-exec-helper.d.ts +94 -0
  223. package/dist/tmux-exec-helper.js +407 -0
  224. package/dist/tmux-exec-helper.js.map +1 -0
  225. package/dist/tmux-manager.d.ts +5 -0
  226. package/dist/tmux-manager.js +42 -11
  227. package/dist/tmux-manager.js.map +1 -1
  228. package/dist/tool-permissions.d.ts +0 -7
  229. package/dist/tool-permissions.js +44 -5
  230. package/dist/tool-permissions.js.map +1 -1
  231. package/dist/topic-commands.d.ts +17 -1
  232. package/dist/topic-commands.js +45 -19
  233. package/dist/topic-commands.js.map +1 -1
  234. package/dist/tunnel/cloudflared.js +37 -2
  235. package/dist/tunnel/cloudflared.js.map +1 -1
  236. package/dist/tunnel/types.d.ts +3 -1
  237. package/dist/tunnel/types.js.map +1 -1
  238. package/dist/types.d.ts +63 -1
  239. package/dist/ui/app-effects.js +90 -32
  240. package/dist/ui/backend-signin.js +297 -0
  241. package/dist/ui/chat-ambient.js +307 -0
  242. package/dist/ui/chat-effect-posters.js +8 -8
  243. package/dist/ui/chat-effects.js +110 -15
  244. package/dist/ui/chat-msg-actions.js +176 -0
  245. package/dist/ui/chat-render.js +21 -3
  246. package/dist/ui/chat-store.js +64 -12
  247. package/dist/ui/chat-strings.js +85 -8
  248. package/dist/ui/chat-thread.js +204 -21
  249. package/dist/ui/effect-trial-controller.js +13 -8
  250. package/dist/ui/effect-trial-panel.js +19 -16
  251. package/dist/ui/effects-waker.js +70 -0
  252. package/dist/ui/fleet-canvas-layout.js +117 -0
  253. package/dist/ui/fleet-canvas.js +555 -0
  254. package/dist/ui/fleet-org.js +27 -12
  255. package/dist/ui/fleet-route-scene.js +207 -0
  256. package/dist/ui/fleet-route-worker.js +21 -0
  257. package/dist/ui/fleet-route.js +88 -0
  258. package/dist/ui/fleet-task-card.js +436 -0
  259. package/dist/ui/fleet-task-common.js +448 -0
  260. package/dist/ui/fleet-tasks.js +535 -0
  261. package/dist/ui/fleet-traffic.js +359 -0
  262. package/dist/ui/fleet-usage.js +435 -0
  263. package/dist/ui/panel-chat.js +264 -66
  264. package/dist/ui/panel-details.js +2 -1
  265. package/dist/ui/panel-fleet.js +65 -83
  266. package/dist/ui/panel-needs.js +4 -2
  267. package/dist/ui/panel-settings.js +56 -11
  268. package/dist/ui/preview.js +3 -2
  269. package/dist/ui/search-dialog.js +349 -0
  270. package/dist/ui/settings-appearance.js +44 -19
  271. package/dist/ui/settings-apply.js +3 -3
  272. package/dist/ui/settings-catalog.js +92 -0
  273. package/dist/ui/settings-confirm.js +7 -2
  274. package/dist/ui/settings-dialogs.js +40 -12
  275. package/dist/ui/settings-effects.js +4 -3
  276. package/dist/ui/settings-guests.js +117 -0
  277. package/dist/ui/settings-learn.js +25 -0
  278. package/dist/ui/settings-model.js +15 -6
  279. package/dist/ui/settings-mods.js +23 -9
  280. package/dist/ui/settings-owner-effects.js +15 -13
  281. package/dist/ui/settings-strings.js +122 -42
  282. package/dist/ui/settings-web-authority.js +91 -0
  283. package/dist/ui/shared/agend-auth.js +1 -1
  284. package/dist/ui/shared/app-i18n.js +11 -2
  285. package/dist/ui/shared/app-prefs.js +17 -4
  286. package/dist/ui/shared/app-route.js +19 -1
  287. package/dist/ui/shared/app-session.js +2 -0
  288. package/dist/ui/shared/app-shell.js +174 -30
  289. package/dist/ui/shared/app-store.js +26 -1
  290. package/dist/ui/shared/app-stream.js +5 -1
  291. package/dist/ui/shared/app-themes.js +140 -7
  292. package/dist/ui/shared/app.css +705 -20
  293. package/dist/ui/shared/app.js +26 -15
  294. package/dist/ui/shared/card-strings.js +8 -0
  295. package/dist/ui/shared/effect-app-runtime.js +68 -19
  296. package/dist/ui/shared/effect-chat-runtime.js +16 -3
  297. package/dist/ui/shared/effect-frame.js +26 -7
  298. package/dist/ui/shared/effect-geometry.js +24 -9
  299. package/dist/ui/shared/effect-owner-source.js +8 -1
  300. package/dist/ui/shared/effect-policy.js +21 -7
  301. package/dist/ui/shared/effect-profiler.js +31 -9
  302. package/dist/ui/shared/effect-registry.js +97 -50
  303. package/dist/ui/shared/effect-stock-snapshot.js +9 -0
  304. package/dist/ui/shared/effect-stock-trust.js +62 -10
  305. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  306. package/dist/ui/shared/effect-trial-source.js +1 -1
  307. package/dist/ui/shared/effect-trial-stage.js +54 -31
  308. package/dist/ui/shared/effect-trial.css +40 -0
  309. package/dist/ui/shared/fx-motion.js +67 -0
  310. package/dist/ui/shared/identity-card.js +100 -11
  311. package/dist/ui/shared/identity-color.js +14 -10
  312. package/dist/ui/shared/instance-nav.js +179 -23
  313. package/dist/ui/shared/owner-css.js +30 -1
  314. package/dist/ui/shared/panel-view.js +24 -4
  315. package/dist/ui/shared/tokens.css +9 -3
  316. package/dist/ui/shared/ui-icons.js +2 -0
  317. package/dist/ui/shared/ui-menu.js +20 -7
  318. package/dist/ui/shared/ui-toast.js +16 -3
  319. package/dist/ui/shared/usage-dialog.js +34 -3
  320. package/dist/ui/shared/view-strings.js +72 -0
  321. package/dist/ui/shared/web-authorize.js +198 -0
  322. package/dist/ui/web-terminal/terminal.css +4 -4
  323. package/dist/ui/web-terminal/terminal.html +1 -1
  324. package/dist/update-launch.d.ts +9 -0
  325. package/dist/update-launch.js +38 -12
  326. package/dist/update-launch.js.map +1 -1
  327. package/dist/usage/format-rich.js +6 -3
  328. package/dist/usage/format-rich.js.map +1 -1
  329. package/dist/usage/i18n-keys.d.ts +1 -1
  330. package/dist/usage/i18n-keys.js +2 -1
  331. package/dist/usage/i18n-keys.js.map +1 -1
  332. package/dist/usage/i18n.d.ts +13 -1
  333. package/dist/usage/i18n.js +29 -0
  334. package/dist/usage/i18n.js.map +1 -1
  335. package/dist/usage/providers.d.ts +81 -2
  336. package/dist/usage/providers.js +240 -38
  337. package/dist/usage/providers.js.map +1 -1
  338. package/dist/usage/usage-api.d.ts +5 -1
  339. package/dist/usage/usage-api.js +184 -58
  340. package/dist/usage/usage-api.js.map +1 -1
  341. package/dist/usage-report.d.ts +158 -0
  342. package/dist/usage-report.js +226 -0
  343. package/dist/usage-report.js.map +1 -0
  344. package/dist/usage-rollup.d.ts +51 -0
  345. package/dist/usage-rollup.js +168 -0
  346. package/dist/usage-rollup.js.map +1 -0
  347. package/dist/user-systemd.d.ts +24 -0
  348. package/dist/user-systemd.js +75 -0
  349. package/dist/user-systemd.js.map +1 -0
  350. package/dist/view-api.d.ts +39 -0
  351. package/dist/view-api.js +69 -6
  352. package/dist/view-api.js.map +1 -1
  353. package/dist/web-ambient.d.ts +72 -0
  354. package/dist/web-ambient.js +161 -0
  355. package/dist/web-ambient.js.map +1 -0
  356. package/dist/web-api.d.ts +98 -14
  357. package/dist/web-api.js +1027 -83
  358. package/dist/web-api.js.map +1 -1
  359. package/dist/web-appearance-policy.d.ts +4 -6
  360. package/dist/web-appearance-policy.js +5 -12
  361. package/dist/web-appearance-policy.js.map +1 -1
  362. package/dist/web-appearance-tools.d.ts +3 -0
  363. package/dist/web-appearance-tools.js +22 -3
  364. package/dist/web-appearance-tools.js.map +1 -1
  365. package/dist/web-appearance-view.d.ts +27 -2
  366. package/dist/web-appearance-view.js +129 -14
  367. package/dist/web-appearance-view.js.map +1 -1
  368. package/dist/web-appearance.d.ts +17 -3
  369. package/dist/web-appearance.js +85 -8
  370. package/dist/web-appearance.js.map +1 -1
  371. package/dist/web-asset-parts.d.ts +2 -2
  372. package/dist/web-asset-parts.js +3 -3
  373. package/dist/web-asset-parts.js.map +1 -1
  374. package/dist/web-auth.js +3 -1
  375. package/dist/web-auth.js.map +1 -1
  376. package/dist/web-backend-login.d.ts +130 -0
  377. package/dist/web-backend-login.js +225 -0
  378. package/dist/web-backend-login.js.map +1 -0
  379. package/dist/web-card.d.ts +14 -0
  380. package/dist/web-card.js +40 -6
  381. package/dist/web-card.js.map +1 -1
  382. package/dist/web-channel-echo.d.ts +7 -1
  383. package/dist/web-channel-echo.js +6 -2
  384. package/dist/web-channel-echo.js.map +1 -1
  385. package/dist/web-chat-history.d.ts +83 -0
  386. package/dist/web-chat-history.js +137 -5
  387. package/dist/web-chat-history.js.map +1 -1
  388. package/dist/web-commands.d.ts +6 -1
  389. package/dist/web-commands.js +1 -1
  390. package/dist/web-commands.js.map +1 -1
  391. package/dist/web-effect-bootstrap.d.ts +12 -0
  392. package/dist/web-effect-bootstrap.js +49 -0
  393. package/dist/web-effect-bootstrap.js.map +1 -0
  394. package/dist/web-effect-bundle.js +46 -1
  395. package/dist/web-effect-bundle.js.map +1 -1
  396. package/dist/web-effect-playback.d.ts +30 -0
  397. package/dist/web-effect-playback.js +71 -0
  398. package/dist/web-effect-playback.js.map +1 -0
  399. package/dist/web-effect-profiles.d.ts +4 -4
  400. package/dist/web-effect-service.d.ts +11 -4
  401. package/dist/web-effect-service.js +25 -4
  402. package/dist/web-effect-service.js.map +1 -1
  403. package/dist/web-effect-tools.d.ts +1 -0
  404. package/dist/web-effect-tools.js +4 -4
  405. package/dist/web-effect-tools.js.map +1 -1
  406. package/dist/web-effect-trials.d.ts +2 -2
  407. package/dist/web-effect-trials.js +3 -3
  408. package/dist/web-effect-trials.js.map +1 -1
  409. package/dist/web-effect-upload.js +1 -1
  410. package/dist/web-effect-upload.js.map +1 -1
  411. package/dist/web-effect-worker.d.ts +1 -1
  412. package/dist/web-effect-worker.js +2 -2
  413. package/dist/web-effect-worker.js.map +1 -1
  414. package/dist/web-effect.d.ts +1 -1
  415. package/dist/web-guest-access.d.ts +126 -0
  416. package/dist/web-guest-access.js +238 -0
  417. package/dist/web-guest-access.js.map +1 -0
  418. package/dist/web-guest-rooms.d.ts +82 -0
  419. package/dist/web-guest-rooms.js +135 -0
  420. package/dist/web-guest-rooms.js.map +1 -0
  421. package/dist/web-host-guard.d.ts +11 -0
  422. package/dist/web-host-guard.js +11 -0
  423. package/dist/web-host-guard.js.map +1 -1
  424. package/dist/web-identity-color.d.ts +1 -1
  425. package/dist/web-identity-color.js +2 -2
  426. package/dist/web-identity-color.js.map +1 -1
  427. package/dist/web-identity-palette.d.ts +1 -1
  428. package/dist/web-identity-palette.js +2 -2
  429. package/dist/web-identity-palette.js.map +1 -1
  430. package/dist/web-image-meta.d.ts +13 -0
  431. package/dist/web-image-meta.js +62 -0
  432. package/dist/web-image-meta.js.map +1 -1
  433. package/dist/web-libavoid.d.ts +14 -0
  434. package/dist/web-libavoid.js +46 -0
  435. package/dist/web-libavoid.js.map +1 -0
  436. package/dist/web-login.d.ts +47 -0
  437. package/dist/web-login.js +115 -0
  438. package/dist/web-login.js.map +1 -1
  439. package/dist/web-mod-agent.d.ts +1 -1
  440. package/dist/web-mod-agent.js +7 -5
  441. package/dist/web-mod-agent.js.map +1 -1
  442. package/dist/web-mod-bundle.d.ts +9 -1
  443. package/dist/web-mod-bundle.js +62 -3
  444. package/dist/web-mod-bundle.js.map +1 -1
  445. package/dist/web-mod-export.d.ts +9 -0
  446. package/dist/web-mod-export.js +21 -0
  447. package/dist/web-mod-export.js.map +1 -1
  448. package/dist/web-mod-store.d.ts +7 -1
  449. package/dist/web-mod-store.js +19 -6
  450. package/dist/web-mod-store.js.map +1 -1
  451. package/dist/web-owner-css-store.d.ts +44 -1
  452. package/dist/web-owner-css-store.js +70 -9
  453. package/dist/web-owner-css-store.js.map +1 -1
  454. package/dist/web-prefs.d.ts +14 -0
  455. package/dist/web-prefs.js +37 -4
  456. package/dist/web-prefs.js.map +1 -1
  457. package/dist/web-preview.d.ts +6 -2
  458. package/dist/web-preview.js +4 -2
  459. package/dist/web-preview.js.map +1 -1
  460. package/dist/web-returning-device.d.ts +41 -0
  461. package/dist/web-returning-device.js +57 -5
  462. package/dist/web-returning-device.js.map +1 -1
  463. package/dist/web-session-authorization.d.ts +132 -0
  464. package/dist/web-session-authorization.js +193 -0
  465. package/dist/web-session-authorization.js.map +1 -0
  466. package/dist/web-session.d.ts +70 -10
  467. package/dist/web-session.js +137 -22
  468. package/dist/web-session.js.map +1 -1
  469. package/dist/web-settings-patch.d.ts +49 -2
  470. package/dist/web-settings-patch.js +145 -1
  471. package/dist/web-settings-patch.js.map +1 -1
  472. package/dist/web-settings-tools.d.ts +94 -0
  473. package/dist/web-settings-tools.js +301 -0
  474. package/dist/web-settings-tools.js.map +1 -0
  475. package/dist/web-shell-routes.d.ts +1 -1
  476. package/dist/web-shell-routes.js +1 -1
  477. package/dist/web-shell-routes.js.map +1 -1
  478. package/dist/web-terminal-http.js +15 -2
  479. package/dist/web-terminal-http.js.map +1 -1
  480. package/dist/web-theme-store.d.ts +39 -0
  481. package/dist/web-theme-store.js +122 -11
  482. package/dist/web-theme-store.js.map +1 -1
  483. package/dist/web-theme.d.ts +31 -0
  484. package/dist/web-theme.js +99 -2
  485. package/dist/web-theme.js.map +1 -1
  486. package/dist/web-upload.d.ts +13 -0
  487. package/dist/web-upload.js +39 -3
  488. package/dist/web-upload.js.map +1 -1
  489. package/dist/windows-shortcuts.d.ts +78 -0
  490. package/dist/windows-shortcuts.js +203 -0
  491. package/dist/windows-shortcuts.js.map +1 -0
  492. package/dist/workflow-templates/default.md +4 -1
  493. package/dist/yaml-shared-nodes.d.ts +25 -0
  494. package/dist/yaml-shared-nodes.js +185 -0
  495. package/dist/yaml-shared-nodes.js.map +1 -0
  496. package/launcher/agend-install.cjs +553 -0
  497. package/launcher/install-admission.cjs +4 -39
  498. package/launcher/postinstall.cjs +14 -12
  499. package/package.json +4 -2
  500. package/scripts/preinstall-guard.cjs +2 -5
  501. package/templates/windows-shortcuts.ps1 +181 -0
  502. package/launcher/old-updater-note.cjs +0 -48
package/dist/web-api.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { webHumanOrigin } from "./human-delivery-receipts.js";
2
+ import { classifyWebPeer, confirmAfterBody, noAuthority } from "./authority/web-authority.js";
3
+ import { guestChatRefusal, guestCommandText, guestDeliveries, guestFrameData, guestHistory, guestMessages, guestPollBody, guestPrefs, guestRequestOf, guestSeesMessage, guestStatus } from "./web-guest-access.js";
2
4
  import { withSpawnHoldReply } from "./spawn-hold-reply.js";
3
5
  import { binaryProbe } from "./binary-probe.js";
4
6
  import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
@@ -10,6 +12,7 @@ import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
10
12
  import { canonicalText } from "./web-theme.js";
11
13
  import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
12
14
  import { gatewayRequestContext } from "./web-request-context.js";
15
+ import { crossInstanceVisibility } from "./cross-instance-notice.js";
13
16
  import { permitWebContinuation } from "./web-continuation.js";
14
17
  /**
15
18
  * Web UI HTTP API handler.
@@ -19,7 +22,8 @@ import { formatWebChannelEcho } from "./web-channel-echo.js";
19
22
  import { t } from "./locale.js";
20
23
  import { isSafeInstanceName } from "./web-shell-routes.js";
21
24
  import { MAX_COMMAND_ARGS } from "./web-commands.js";
22
- import { sendPanelHtml } from "./web-host-guard.js";
25
+ import { ROUTE_WORKER_CSP, ROUTE_WORKER_PATH, sendPanelHtml } from "./web-host-guard.js";
26
+ import { libavoidFile } from "./web-libavoid.js";
23
27
  import { shellRoute } from "./web-shell-routes.js";
24
28
  import { createReadStream, createWriteStream, readFileSync } from "node:fs";
25
29
  import { join, dirname } from "node:path";
@@ -27,11 +31,12 @@ import { fileURLToPath } from "node:url";
27
31
  import { CreateInstanceArgs, CreateScheduleArgs, UpdateScheduleArgs, validateArgs } from "./outbound-schemas.js";
28
32
  import { scheduleView } from "./web-schedules.js";
29
33
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
34
+ import { CARD_PRIORITIES, CARD_SIZES, CARD_STATUSES, TaskBoardError } from "./scheduler/task-board.js";
30
35
  import { readStatuslineModel, resolveInstanceContext } from "./topic-commands.js";
31
36
  import { z } from "zod";
32
37
  import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
33
38
  import { dashboardOriginFor } from "./web-preview.js";
34
- import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
39
+ import { newWebMessageId, parseLastEventId, webChatReplyRef, webChatReplyText } from "./web-chat-history.js";
35
40
  import { attachmentDelivery, createPartFile, displayName, fileTooLargeText, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment, UPLOAD_LIMITS, UPLOAD_MAX_MS, UPLOAD_STALL_MS, UploadSniffer, uploadLimits, wellFormed } from "./web-upload.js";
36
41
  import { getAgendHome } from "./paths.js";
37
42
  import { OPERATOR, isOwnerEffectKey } from "./web-prefs.js";
@@ -42,6 +47,8 @@ import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
42
47
  import { measureSyncWork } from "./sync-work-attribution.js";
43
48
  import { resolveTz } from "./tz-utils.js";
44
49
  import { filesPlayInChat } from "./web-effect.js";
50
+ import { QUERY_MAX_CHARS, RESULT_MAX, SEARCH_SOURCES } from "./search/search-store.js";
51
+ import { xmsgShown } from "./search/search-docs.js";
45
52
  // ── Strict public-facing schemas ────────────────────────────────────────────
46
53
  // web-api endpoints must reject unknown fields so the dashboard cannot inject
47
54
  // internal-only flags that would reach handleCreate/scheduler/config writers.
@@ -56,11 +63,70 @@ const TaskUpdateSchema = z.object({
56
63
  action: z.enum(["claim", "complete", "update"]).optional(),
57
64
  assignee: z.string().max(128).optional(),
58
65
  result: z.string().max(MAX_TEXT).optional(),
59
- status: z.string().max(64).optional(),
66
+ // A status the task board knows: each carries its rules now (scheduler/task-board.ts).
67
+ status: z.enum(CARD_STATUSES).optional(),
60
68
  title: z.string().max(512).optional(),
61
69
  description: z.string().max(MAX_TEXT).optional(),
62
70
  priority: z.string().max(64).optional(),
63
71
  }).strict();
72
+ // The task board (scheduler/task-board.ts). Coarse bounds here; the board checks each field's own limit.
73
+ const BOARD_TEXT = 8_192;
74
+ const CardRef = z.string().min(1).max(64);
75
+ /** Who handed a card out: an instance, the person writing ({ kind: "person" }), or nobody. */
76
+ const DispatcherSchema = z.union([z.object({ kind: z.literal("instance"), name: z.string().min(1).max(128) }).strict(), z.object({ kind: z.literal("person") }).strict()]).nullable();
77
+ const CardCreateSchema = z.object({
78
+ kind: z.enum(["main", "sub"]).optional(),
79
+ project: z.union([z.number().int(), z.string().max(256)]).nullable().optional(),
80
+ parent: CardRef.nullable().optional(),
81
+ title: z.string().max(512),
82
+ goal: z.string().max(BOARD_TEXT).optional(),
83
+ scope: z.string().max(BOARD_TEXT).optional(),
84
+ acceptance: z.array(z.string().max(1024)).max(50).optional(),
85
+ context: z.string().max(BOARD_TEXT).optional(),
86
+ size: z.enum(CARD_SIZES).nullable().optional(),
87
+ priority: z.enum(CARD_PRIORITIES).optional(),
88
+ dependsOn: z.array(CardRef).max(50).optional(),
89
+ assignee: z.string().max(128).nullable().optional(),
90
+ dispatcher: DispatcherSchema.optional(),
91
+ needsReview: z.boolean().optional(),
92
+ }).strict();
93
+ const CardPatchSchema = z.object({
94
+ version: z.number().int().optional(),
95
+ title: z.string().max(512).optional(),
96
+ goal: z.string().max(BOARD_TEXT).optional(),
97
+ scope: z.string().max(BOARD_TEXT).optional(),
98
+ context: z.string().max(BOARD_TEXT).optional(),
99
+ state: z.string().max(BOARD_TEXT).optional(),
100
+ result: z.string().max(BOARD_TEXT).optional(),
101
+ blockedReason: z.string().max(BOARD_TEXT).optional(),
102
+ size: z.enum(CARD_SIZES).nullable().optional(),
103
+ priority: z.enum(CARD_PRIORITIES).optional(),
104
+ assignee: z.string().max(128).nullable().optional(),
105
+ dispatcher: DispatcherSchema.optional(),
106
+ needsReview: z.boolean().optional(),
107
+ dependsOn: z.array(CardRef).max(50).optional(),
108
+ acceptance: z.array(z.string().max(1024)).max(50).optional(),
109
+ addAcceptance: z.array(z.string().max(1024)).max(50).optional(),
110
+ check: z.array(z.number().int()).max(50).optional(),
111
+ uncheck: z.array(z.number().int()).max(50).optional(),
112
+ projectId: z.number().int().nullable().optional(),
113
+ log: z.string().max(BOARD_TEXT).optional(),
114
+ comment: z.string().max(MAX_TEXT).optional(),
115
+ }).strict();
116
+ const CardMoveSchema = z.object({
117
+ to: z.enum(CARD_STATUSES),
118
+ reason: z.string().max(BOARD_TEXT).optional(),
119
+ result: z.string().max(BOARD_TEXT).optional(),
120
+ comment: z.string().max(MAX_TEXT).optional(),
121
+ version: z.number().int().optional(),
122
+ }).strict();
123
+ const CardReorderSchema = z.object({ after: CardRef.nullable().optional(), before: CardRef.nullable().optional() }).strict();
124
+ const ProjectCreateSchema = z.object({ name: z.string().max(256), description: z.string().max(BOARD_TEXT).optional() }).strict();
125
+ const ProjectPatchSchema = z.object({
126
+ name: z.string().max(256).optional(),
127
+ description: z.string().max(BOARD_TEXT).optional(),
128
+ archived: z.boolean().optional(),
129
+ }).strict();
64
130
  const TeamCreateSchema = z.object({
65
131
  name: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/),
66
132
  members: z.array(z.string().min(1).max(256)).min(1).max(256),
@@ -88,6 +154,8 @@ const SendMessageSchema = z.object({
88
154
  message: z.string().max(MAX_TEXT),
89
155
  /** Ids from POST /ui/upload, for this instance, not yet sent. */
90
156
  attachments: z.array(z.string().regex(/^[0-9a-f]{32}$/)).max(UPLOAD_LIMITS.maxFiles).optional(),
157
+ /** #2272: the message of this chat this one replies to — its row only; the server reads what it quotes. */
158
+ replyTo: z.object({ boot: z.string().regex(/^[0-9a-f]{1,32}$/), id: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER) }).strict().optional(),
91
159
  }).strict().refine(v => v.message.trim().length > 0 || (v.attachments?.length ?? 0) > 0, { message: "a message or at least one file", path: ["message"] });
92
160
  /** An instance name that is safe as one path segment (no separator, no NUL, not a dot name). */
93
161
  const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "." && name !== "..";
@@ -97,11 +165,41 @@ const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "."
97
165
  * the eviction the dead entry leaks forever, and without the try/catch a
98
166
  * single dead client breaks delivery to every client iterated after it.
99
167
  */
168
+ /** #1920 phase 1b: the open streams that are a guest's, and whose — each event is projected for them (guestFrameData). */
169
+ const streamGuests = new WeakMap();
170
+ /**
171
+ * #1920 §5.2: one event for the guests' streams only, each as projected for it — a message that has just become what
172
+ * its channel saw (an admin's page already has it, unchanged).
173
+ */
174
+ export function broadcastToGuests(clients, event, data, onError) {
175
+ const guests = [...clients].filter((c) => streamGuests.has(c));
176
+ if (!guests.length)
177
+ return;
178
+ const live = new Set(guests);
179
+ broadcastSseEvent(live, event, data, onError);
180
+ for (const c of guests)
181
+ if (!live.has(c))
182
+ clients.delete(c); // the ones it ended (their session went)
183
+ }
100
184
  export function broadcastSseEvent(clients, event, data, onError, id) {
101
185
  const payload = sseFrame(event, data, id);
102
186
  const dead = [];
103
187
  for (const client of clients) {
104
188
  try {
189
+ // An admin's stream gets these bytes; a guest's, the event as projected for its scope now, or nothing (no id: it
190
+ // never replays chat, so a cursor of the admin's stream is not its to hold).
191
+ const guest = streamGuests.get(client);
192
+ if (guest) {
193
+ // The exact session that opened it ended (revoked, expired, rotated away): nothing more, and the stream goes.
194
+ if (!guest.current()) {
195
+ dead.push(client);
196
+ continue;
197
+ }
198
+ const shown = guestFrameData(event, data, guest.scope(), guest);
199
+ if (shown !== undefined)
200
+ client.write(sseFrame(event, shown));
201
+ continue;
202
+ }
105
203
  client.write(payload);
106
204
  }
107
205
  catch (err) {
@@ -141,10 +239,19 @@ async function parseBody(req) {
141
239
  }
142
240
  /** Send JSON response. */
143
241
  function json(res, status, data) {
242
+ jsonText(res, status, JSON.stringify(data));
243
+ }
244
+ /** A JSON answer already serialized (a body kept for reuse). */
245
+ function jsonText(res, status, body) {
144
246
  res.setHeader("Content-Type", "application/json");
145
247
  res.writeHead(status);
146
- res.end(JSON.stringify(data));
248
+ res.end(body);
147
249
  }
250
+ /**
251
+ * #2296: the last dashboard board read, serialized, per board: its key (revision, done=all, minute) and body. One per
252
+ * board (the newest revision replaces the previous), so it holds one board's JSON at most.
253
+ */
254
+ const boardBodies = new WeakMap();
148
255
  /**
149
256
  * #1630 (#1635 review): an answer that waited on something (a list, a download) is sent only if the request is still
150
257
  * allowed — the session not revoked meanwhile, a public link's exposure still current. Otherwise 401, and nothing.
@@ -248,7 +355,7 @@ function handleOwnerCss(req, res, url, ctx, path, method) {
248
355
  json(res, removed.ok ? 200 : removed.status, removed.ok ? { ok: true } : { error: removed.error });
249
356
  return true;
250
357
  }
251
- if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
358
+ if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/shown", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
252
359
  json(res, 404, { error: "not found" });
253
360
  return true;
254
361
  }
@@ -297,6 +404,17 @@ function handleOwnerCss(req, res, url, ctx, path, method) {
297
404
  announce();
298
405
  json(res, 200, { sha: saved.sha, previous: saved.previous, trial: saved.trial });
299
406
  }
407
+ else if (path === "/ui/owner-css/shown") {
408
+ // #2198: this page shows the trial (its stylesheet loaded): an agent's waiting trial starts its keep window now.
409
+ const shown = store.shown(person, body.trial);
410
+ if (!shown.ok) {
411
+ json(res, shown.status, { error: shown.error });
412
+ return;
413
+ }
414
+ if (shown.started)
415
+ announce(); // the person's other devices take the new deadline
416
+ json(res, 200, { remaining_ms: shown.remaining_ms });
417
+ }
300
418
  else if (path === "/ui/owner-css/keep") {
301
419
  const kept = store.keep(person, body.trial);
302
420
  if (!kept.ok) {
@@ -382,7 +500,7 @@ function handleEffectTrials(req, res, url, ctx, path, method) {
382
500
  css = value;
383
501
  }
384
502
  catch { /* unavailable is never off */ }
385
- // CSS remains a checked runtime value. The authority refuses anything except off on start/read.
503
+ // CSS is diagnostic for Try; its host-owned shadow shell isolates controls and local paint.
386
504
  const actor = Object.freeze({ via: "session", surface: "local", tier: "admin", sessionHash: v.session.idHash, epoch: v.session.handle, person, ownerCss: css });
387
505
  return { actor, record: v.session, sessions };
388
506
  };
@@ -477,6 +595,104 @@ function handleEffectTrials(req, res, url, ctx, path, method) {
477
595
  catch { /* response ended */ } });
478
596
  return true;
479
597
  }
598
+ /**
599
+ * #2233: signing a backend in from the dashboard (web-backend-login.ts). A signed-in admin session on the local surface
600
+ * only — never the CLI's header token (an agent's), a guest, a read session or the public link (refused before the body
601
+ * is read, and again after it). Every answer is no-store: the starter's view carries the terminal's access code.
602
+ */
603
+ function handleBackendLogin(req, res, url, ctx, path, method) {
604
+ res.setHeader("Cache-Control", "private, no-store");
605
+ res.setHeader("X-Content-Type-Options", "nosniff");
606
+ const reading = method === "GET";
607
+ if (gatewayRequestContext(req)) {
608
+ json(res, reading ? 404 : 403, { error: "sign_in_not_over_public_link" });
609
+ return true;
610
+ }
611
+ const who = () => {
612
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
613
+ if (v.kind !== "allow")
614
+ return { error: WEB_TOKEN_INVALID_MESSAGE, status: 401 };
615
+ if (v.via !== "session" || v.session.tier !== "admin" || v.session.surface !== "local")
616
+ return { error: "sign_in_needs_a_local_admin", status: 403 };
617
+ return { handle: v.session.handle, label: v.session.label };
618
+ };
619
+ const first = who();
620
+ if ("error" in first) {
621
+ json(res, first.status, { error: first.error });
622
+ return true;
623
+ }
624
+ const logins = ctx.webBackendLogins;
625
+ if (!logins) {
626
+ json(res, 404, { error: "sign_in_unavailable" });
627
+ return true;
628
+ }
629
+ const action = /^\/ui\/backend-login\/(check|start|cancel)$/.exec(path)?.[1];
630
+ if (reading && path === "/ui/backend-login" && [...url.searchParams.keys()].length === 0) {
631
+ json(res, 200, { login: logins.view(first.handle), targets: ctx.backendSignInTargets?.() ?? [] });
632
+ return true;
633
+ }
634
+ if (method !== "POST" || !action) {
635
+ json(res, 404, { error: "not_found" });
636
+ return true;
637
+ }
638
+ (async () => {
639
+ let bytes;
640
+ try {
641
+ bytes = await readBoundedWebBody(req, 4 * 1024);
642
+ }
643
+ catch {
644
+ json(res, 413, { error: "sign_in_body_bound" });
645
+ return;
646
+ }
647
+ if (!permitWebContinuation(req, res, ctx))
648
+ return;
649
+ const now = who();
650
+ if ("error" in now) {
651
+ json(res, now.status, { error: now.error });
652
+ return;
653
+ }
654
+ if (now.handle !== first.handle) {
655
+ json(res, 409, { error: "sign_in_session_changed" });
656
+ return;
657
+ }
658
+ let body;
659
+ try {
660
+ body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
661
+ }
662
+ catch {
663
+ json(res, 400, { error: "sign_in_json" });
664
+ return;
665
+ }
666
+ if (action === "check") {
667
+ const parsed = z.strictObject({ backend: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/i), profile: z.string().min(1).max(64).optional() }).safeParse(body);
668
+ if (!parsed.success) {
669
+ json(res, 400, { error: "sign_in_args" });
670
+ return;
671
+ }
672
+ json(res, 200, await logins.check(now, parsed.data.backend, parsed.data.profile));
673
+ }
674
+ else if (action === "start") {
675
+ const parsed = z.strictObject({ confirm: z.string().regex(/^[0-9a-f]{32}$/), tunnel: z.boolean().optional() }).safeParse(body);
676
+ if (!parsed.success) {
677
+ json(res, 400, { error: "sign_in_args" });
678
+ return;
679
+ }
680
+ const result = await logins.begin(now, parsed.data.confirm, parsed.data.tunnel === true);
681
+ json(res, "error" in result ? 409 : 200, result);
682
+ }
683
+ else {
684
+ if (!z.strictObject({}).safeParse(body).success) {
685
+ json(res, 400, { error: "sign_in_args" });
686
+ return;
687
+ }
688
+ json(res, 200, { message: await logins.cancel() });
689
+ }
690
+ })().catch(() => { try {
691
+ json(res, 500, { error: "sign_in_request_failed" });
692
+ }
693
+ catch { /* response ended */ } });
694
+ return true;
695
+ }
480
696
  /** #1629 B2: effect routes require a human session; route bodies never supply an actor or reference holder. */
481
697
  function handleEffects(req, res, url, ctx, path, method) {
482
698
  res.setHeader("Cache-Control", "private, no-store");
@@ -484,7 +700,12 @@ function handleEffects(req, res, url, ctx, path, method) {
484
700
  const reading = method === "GET";
485
701
  // Reports are bounded untrusted diagnostics, not an appearance or admission write (r2 §6).
486
702
  const profilePost = method === "POST" && /^\/ui\/effects\/profile\/[0-9a-f]{32}$/.test(path);
487
- if (gatewayRequestContext(req)) {
703
+ const gateway = gatewayRequestContext(req), guest = guestRequestOf(req);
704
+ const instanceQuery = url.searchParams.get("instance"), keys = [...url.searchParams.keys()];
705
+ const chatRead = reading && (path === "/ui/effects/placements" || /^\/ui\/effects\/resource\/[0-9a-f]{32}$/.test(path))
706
+ && keys.length === 2 && keys.includes("instance") && keys.includes("ownerEpoch") && !!instanceQuery && instanceQuery.length <= 128;
707
+ // Public viewing grants current Chat reads, never ordinary library/owner/Trial access or writes.
708
+ if (gateway && (!chatRead || !gateway.isCurrent())) {
488
709
  json(res, reading ? 404 : 403, { error: "effects_not_over_public_link" });
489
710
  return true;
490
711
  }
@@ -496,7 +717,9 @@ function handleEffects(req, res, url, ctx, path, method) {
496
717
  return { status: 403, error: "effects_need_a_signed_in_person" };
497
718
  if (!reading && !profilePost && verdict.session.tier !== "admin")
498
719
  return { status: 403, error: "effects_read_only" };
499
- return { person: webPersonKey(verdict.session.owner), canWrite: verdict.session.tier === "admin", epoch: verdict.session.handle };
720
+ if (verdict.session.tier === "guest" && (!chatRead || !guest || !guest.current() || !guest.scope().has(instanceQuery)))
721
+ return { status: 404, error: "not_found" };
722
+ return { person: webPersonKey(verdict.session.owner), canWrite: verdict.session.tier === "admin", epoch: verdict.session.handle, guest: verdict.session.tier === "guest" };
500
723
  };
501
724
  const first = who();
502
725
  if ("status" in first) {
@@ -539,9 +762,9 @@ function handleEffects(req, res, url, ctx, path, method) {
539
762
  else if (path === "/ui/effects/owner" && instance === undefined && slot === null)
540
763
  answer(service.ownerSelections(first.person));
541
764
  else if (path === "/ui/effects/placements" && instance !== undefined)
542
- answer(service.placements(first.person, instance));
765
+ answer(service.placements(first.person, instance, !!gateway || first.guest));
543
766
  else if (idPath?.[1] === "resource")
544
- answer(slot === null ? service.resource(first.person, idPath[2], instance) : service.ownerResource(first.person, idPath[2], slot));
767
+ answer(slot === null ? service.resource(first.person, idPath[2], instance, !!gateway || first.guest) : service.ownerResource(first.person, idPath[2], slot));
545
768
  else if (idPath?.[1] === "profile" && instance === undefined)
546
769
  answer(service.profile(first.person, idPath[2]));
547
770
  else
@@ -595,8 +818,13 @@ function handleEffects(req, res, url, ctx, path, method) {
595
818
  answer(service.remove(now.person, body));
596
819
  else if (path === "/ui/effects/share")
597
820
  answer(service.share(now.person, body));
598
- else
599
- answer(service.report(now.person, idPath[2], body, instance));
821
+ else {
822
+ if (body && typeof body === "object" && body.kind === "playback" && (url.searchParams.getAll("instance").length !== 1 || slot !== null)) {
823
+ json(res, 400, { error: "playback_report" });
824
+ return;
825
+ }
826
+ answer(service.report(now.person, idPath[2], body, instance, now.epoch));
827
+ }
600
828
  })().catch(() => { try {
601
829
  json(res, 500, { error: "effect_request_failed" });
602
830
  }
@@ -805,7 +1033,7 @@ export function exportMod(ctx, person, body) {
805
1033
  return { ok: false, status: 400, error: "title", detail: "1..60 characters on one line" };
806
1034
  const parts = {};
807
1035
  let omitted = 0;
808
- const wantsInstance = inc.theme === true || inc.identity === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
1036
+ const wantsInstance = inc.theme === true || inc.identity === true || inc.ambient === true || (isObj(inc.widgets) && AGENT_WIDGET_SLOTS.some(s => inc.widgets[s] === true));
809
1037
  let name = null;
810
1038
  if (wantsInstance) {
811
1039
  if (typeof body.instance !== "string" || !ctx.isKnownInstance?.(body.instance) || !ctx.themeStore)
@@ -832,6 +1060,11 @@ export function exportMod(ctx, person, body) {
832
1060
  if (isObj(inc.widgets) && inc.widgets[slot] === true && w)
833
1061
  (parts.widgets ??= {})[slot] = { spec: w.spec, data: w.data };
834
1062
  }
1063
+ // #2117: its ambients, whoever set them (a person exports what is on the chat).
1064
+ if (inc.ambient === true) {
1065
+ const specs = Object.fromEntries(Object.entries(rec.placements.ambient).flatMap(([k, a]) => a ? [[k, a.spec]] : []));
1066
+ parts.ambient = { specs, picture: id => { const a = rec.assets.find(x => x.id === id); const b = store.asset(name, id); return a && b ? { bytes: b.bytes, kind: a.kind } : null; } };
1067
+ }
835
1068
  }
836
1069
  if (isObj(inc.widgets) && inc.widgets.sidebar === true && ctx.webPrefs) {
837
1070
  const v = ctx.webPrefs.read(OPERATOR).values["widget.sidebar"];
@@ -871,6 +1104,81 @@ function webPersonOf(req, url, ctx) {
871
1104
  const who = webAccountOf(req, url, ctx);
872
1105
  return who ? ctx.webPerson(who.owner) : null;
873
1106
  }
1107
+ /**
1108
+ * #1939 S1: whether this request carries `web-session` authority, asked as it stands now — on arrival without an instance,
1109
+ * after the body with the one it names (then confirmAfterBody joins the two). Never touches the session.
1110
+ */
1111
+ function webAuthorityOf(req, url, ctx, instance) {
1112
+ if (!ctx.webAuthority)
1113
+ return { authority: noAuthority(0), refusal: "not_session" };
1114
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1115
+ return ctx.webAuthority({ session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req),
1116
+ peer: classifyWebPeer(req), ...(instance !== undefined ? { instance } : {}) });
1117
+ }
1118
+ /**
1119
+ * #2122: what let this request in, asked now — its session (by handle, under the token it was signed in with) or the
1120
+ * header token; null when nothing does any more (the gate also refuses a public link that is no longer the current
1121
+ * one). Equal before and after an await: the same admission still holds. Never touches the session.
1122
+ */
1123
+ /** #2293: an admin's admission — the header token or an admin session — else null (a guest's or a read-only session's). */
1124
+ function adminAdmissionOf(req, url, ctx) {
1125
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1126
+ if (v.kind !== "allow" || (v.via === "session" && v.session.tier !== "admin"))
1127
+ return null;
1128
+ return admissionOf(req, url, ctx);
1129
+ }
1130
+ const SEARCH_LIMIT_DEFAULT = 50;
1131
+ /**
1132
+ * #2293: GET /ui/search's parameters: q (1–QUERY_MAX_CHARS), instance (repeated), team (its members join `instance`),
1133
+ * source (repeated: chat, xmsg, task), since / until (epoch ms), mine=1, limit (1–RESULT_MAX).
1134
+ */
1135
+ function parseSearchQuery(url, ctx) {
1136
+ const p = url.searchParams;
1137
+ const q = (p.get("q") ?? "").trim();
1138
+ if (!q || q.length > QUERY_MAX_CHARS)
1139
+ return { error: `q required (1-${QUERY_MAX_CHARS} chars)` };
1140
+ const instances = new Set(p.getAll("instance").filter(n => n.length > 0 && n.length <= 128));
1141
+ const team = p.get("team");
1142
+ if (team) {
1143
+ const def = ctx.fleetConfig?.teams && Object.hasOwn(ctx.fleetConfig.teams, team) ? ctx.fleetConfig.teams[team] : undefined;
1144
+ if (!def)
1145
+ return { error: "unknown team" };
1146
+ for (const member of def.members ?? [])
1147
+ instances.add(member);
1148
+ if (!instances.size)
1149
+ return { error: "team has no members" };
1150
+ }
1151
+ const sources = p.getAll("source");
1152
+ if (sources.some(s => !SEARCH_SOURCES.includes(s)))
1153
+ return { error: `source must be one of: ${SEARCH_SOURCES.join(", ")}` };
1154
+ const time = (name) => {
1155
+ const raw = p.get(name);
1156
+ if (raw === null || raw === "")
1157
+ return undefined;
1158
+ const n = Number(raw);
1159
+ return Number.isSafeInteger(n) && n >= 0 ? n : null;
1160
+ };
1161
+ const since = time("since"), until = time("until");
1162
+ if (since === null || until === null)
1163
+ return { error: "since/until must be epoch milliseconds" };
1164
+ const limitRaw = p.get("limit");
1165
+ const limit = limitRaw === null ? SEARCH_LIMIT_DEFAULT : Number(limitRaw);
1166
+ if (!Number.isInteger(limit) || limit < 1 || limit > RESULT_MAX)
1167
+ return { error: `limit must be 1-${RESULT_MAX}` };
1168
+ return {
1169
+ filters: { q, limit, ...(instances.size ? { instances: [...instances] } : {}), ...(sources.length ? { sources: sources } : {}),
1170
+ ...(since !== undefined ? { since } : {}), ...(until !== undefined ? { until } : {}) },
1171
+ mine: p.get("mine") === "1",
1172
+ };
1173
+ }
1174
+ function admissionOf(req, url, ctx) {
1175
+ const gateway = gatewayRequestContext(req);
1176
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1177
+ if (v.kind !== "allow")
1178
+ return null;
1179
+ const via = v.via === "session" ? `session:${v.session.handle}:${v.session.tokenEpoch}` : "token";
1180
+ return gateway ? `${via}@${gateway.exposureId}` : via;
1181
+ }
874
1182
  /** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
875
1183
  function webPersonOr401(req, res, url, ctx) {
876
1184
  const p = webPersonOf(req, url, ctx);
@@ -878,15 +1186,115 @@ function webPersonOr401(req, res, url, ctx) {
878
1186
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
879
1187
  return p;
880
1188
  }
1189
+ /**
1190
+ * Who a dashboard request acts as on the task board. The person only through a signed-in local session: the header
1191
+ * token is the CLI's or a script's (any agent on this machine can read the token file), and the temporary public link
1192
+ * carries no verified authority (docs/web-dashboard.md) — either is a script, held to the rules an instance is held to,
1193
+ * so neither confirms work waiting for the person.
1194
+ */
1195
+ function boardActor(req, url, ctx, p) {
1196
+ if (gatewayRequestContext(req))
1197
+ return { kind: "script", name: "public-link" };
1198
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1199
+ return v.kind === "allow" && v.via === "session" ? { kind: "person", name: p.name } : { kind: "script", name: "web-token" };
1200
+ }
1201
+ /**
1202
+ * A board refusal as the dashboard reads it: the board's sentence, its code and the status that fits, and — when the
1203
+ * person can meet it — the key and values the page says it with in its own language.
1204
+ */
1205
+ function boardError(res, err) {
1206
+ if (res.headersSent)
1207
+ return;
1208
+ if (err instanceof TaskBoardError) {
1209
+ const status = err.code === "not_found" ? 404 : err.code === "forbidden" ? 403 : err.code === "conflict" ? 409 : 400;
1210
+ json(res, status, { error: err.message, code: err.code, ...(err.key ? { key: err.key, args: err.args } : {}) });
1211
+ return;
1212
+ }
1213
+ json(res, 400, { error: err.message });
1214
+ }
1215
+ /**
1216
+ * One board write from the dashboard: the body read and checked, the request still allowed, the person asked for
1217
+ * again now the body is in (#1827), then the write as that person. The board's own routes take a signed-in session
1218
+ * only (instances use their task tool, scripts the old /ui/tasks routes).
1219
+ */
1220
+ function boardWrite(req, res, url, ctx, board, schema, write) {
1221
+ void (async () => {
1222
+ try {
1223
+ const body = await parseBody(req);
1224
+ if (!permitWebContinuation(req, res, ctx))
1225
+ return;
1226
+ const parsed = parseOrReject(schema, body, res);
1227
+ if (!parsed)
1228
+ return;
1229
+ const person = webPersonOr401(req, res, url, ctx);
1230
+ if (!person)
1231
+ return;
1232
+ const actor = boardActor(req, url, ctx, person);
1233
+ if (actor.kind !== "person") {
1234
+ json(res, 403, { error: "The task board takes changes from a signed-in dashboard", code: "forbidden", key: "signed_in_only" });
1235
+ return;
1236
+ }
1237
+ json(res, 200, write(parsed, actor));
1238
+ }
1239
+ catch (err) {
1240
+ boardError(res, err);
1241
+ }
1242
+ })();
1243
+ }
881
1244
  /** #1827: what the page's profile card reads — the name they chose (or null), the one in use, and how agents know them. */
882
1245
  function profileBody(p) {
883
1246
  return { name: p.set ? p.name : null, shown: p.name, id: p.userId, bound: p.bound, ...(p.platformName !== undefined ? { platform_name: p.platformName } : {}) };
884
1247
  }
885
1248
  /** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
886
1249
  function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
887
- /**
888
- * Handle a Web UI request. Returns true if handled, false to pass through.
889
- */
1250
+ const statusTicks = new WeakMap();
1251
+ function subscribeStatusTick(ctx, stream) {
1252
+ let tick = statusTicks.get(ctx);
1253
+ if (!tick)
1254
+ statusTicks.set(ctx, tick = { streams: new Set(), timer: null });
1255
+ const t = tick;
1256
+ t.streams.add(stream);
1257
+ t.timer ??= setInterval(() => statusTick(ctx, t), ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
1258
+ return () => {
1259
+ t.streams.delete(stream);
1260
+ if (!t.streams.size && t.timer) {
1261
+ clearInterval(t.timer);
1262
+ t.timer = null;
1263
+ }
1264
+ };
1265
+ }
1266
+ function statusTick(ctx, t) {
1267
+ const live = [...t.streams].filter((s) => s.alive());
1268
+ if (!live.length)
1269
+ return;
1270
+ let status, admin = null;
1271
+ const guests = new Map();
1272
+ try {
1273
+ status = measureSyncWork("web.sseStatus", () => ctx.getUiStatus());
1274
+ }
1275
+ catch {
1276
+ return;
1277
+ } // nothing to send this tick; the next one tries again
1278
+ const frameFor = (s) => {
1279
+ if (!s.scope)
1280
+ return admin ??= `event: status\ndata: ${JSON.stringify(status)}\n\n`;
1281
+ const scope = s.scope(), key = [...scope].sort().join("\u0000");
1282
+ let frame = guests.get(key);
1283
+ if (frame === undefined)
1284
+ guests.set(key, frame = `event: status\ndata: ${JSON.stringify(guestStatus(status, scope))}\n\n`);
1285
+ return frame;
1286
+ };
1287
+ for (const s of live) {
1288
+ let frame;
1289
+ try {
1290
+ frame = frameFor(s);
1291
+ }
1292
+ catch {
1293
+ continue;
1294
+ }
1295
+ s.write(frame);
1296
+ }
1297
+ }
890
1298
  export function handleWebRequest(req, res, url, ctx) {
891
1299
  const path = url.pathname;
892
1300
  const method = req.method ?? "GET";
@@ -937,6 +1345,9 @@ export function handleWebRequest(req, res, url, ctx) {
937
1345
  try {
938
1346
  const js = readFileSync(join(__dirname, "ui", fileName), "utf-8");
939
1347
  res.setHeader("Content-Type", "application/javascript; charset=utf-8");
1348
+ // #2182: the route worker's own, narrower-reaching policy (ROUTE_WORKER_CSP); every other file keeps the page's.
1349
+ if (path === ROUTE_WORKER_PATH)
1350
+ res.setHeader("Content-Security-Policy", ROUTE_WORKER_CSP);
940
1351
  res.writeHead(200);
941
1352
  res.end(js);
942
1353
  }
@@ -945,8 +1356,34 @@ export function handleWebRequest(req, res, url, ctx) {
945
1356
  }
946
1357
  return true;
947
1358
  }
1359
+ // #2182: libavoid (libavoid-js, LGPL-2.1-or-later) — the npm package's own two files, unmodified, at a versioned path.
1360
+ const vendor = method === "GET" ? libavoidFile(path) : null;
1361
+ if (vendor) {
1362
+ try {
1363
+ const body = readFileSync(vendor.file);
1364
+ res.writeHead(200, { "Content-Type": vendor.type, "Cache-Control": "private, max-age=31536000, immutable" });
1365
+ res.end(body);
1366
+ }
1367
+ catch {
1368
+ json(res, 404, { error: "File not found" });
1369
+ }
1370
+ return true;
1371
+ }
948
1372
  // ── Backend detection ─────────────────────────────────
1373
+ // #2072: the model a Settings field offers — the cached catalog of that backend (that instance's account's). Read only.
1374
+ if (method === "GET" && path === "/ui/models") {
1375
+ const backend = url.searchParams.get("backend") ?? "";
1376
+ const instance = url.searchParams.get("instance");
1377
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(backend) || (instance !== null && (instance.length < 1 || instance.length > 256))) {
1378
+ json(res, 400, { error: "backend (and optional instance) required" });
1379
+ return true;
1380
+ }
1381
+ json(res, 200, { backend, models: ctx.modelCatalogFor?.(backend, instance) ?? [] });
1382
+ return true;
1383
+ }
949
1384
  if (method === "GET" && path === "/ui/backends") {
1385
+ // #2072 (#2098 review P3-3): the backend an agent created with "fleet default" runs, so its model list can be shown.
1386
+ const defaultBackend = ctx.fleetConfig?.defaults?.backend ?? "claude-code";
950
1387
  const BACKENDS = [
951
1388
  { name: "claude-code", binary: "claude" },
952
1389
  { name: "codex", binary: "codex" },
@@ -956,6 +1393,16 @@ export function handleWebRequest(req, res, url, ctx) {
956
1393
  { name: "grok", binary: "grok" },
957
1394
  { name: "muse", binary: "muse" },
958
1395
  ];
1396
+ // #2072: the published availability snapshot — installed, signed in, new enough — when the fleet has one.
1397
+ if (ctx.backendAvailabilityRows) {
1398
+ const binaryOf = new Map(BACKENDS.map(b => [b.name, b]));
1399
+ const backends = ctx.backendAvailabilityRows().filter(r => binaryOf.has(r.backend)).map(r => ({
1400
+ name: r.backend, binary: binaryOf.get(r.backend).binary, installed: r.state !== "not_installed" && r.path !== null, path: r.path ?? "",
1401
+ deprecated: binaryOf.get(r.backend).deprecated ?? false, state: r.state, minVersion: r.minVersion, ...(r.state === "unknown" ? { unknown: true } : {}),
1402
+ }));
1403
+ json(res, 200, { backends, defaultBackend });
1404
+ return true;
1405
+ }
959
1406
  // #1490: seven sequential 2 s `execFileSync("which")` calls used to block the event loop here. The shared probe
960
1407
  // runs `which` as bounded async children and reuses an answer for BINARY_PROBE_TTL_MS. `unknown`: no answer in
961
1408
  // time (shown as not installed, as the old timeout was).
@@ -965,7 +1412,7 @@ export function handleWebRequest(req, res, url, ctx) {
965
1412
  return { name: b.name, binary: b.binary, installed: path !== null, path: path ?? "", deprecated: b.deprecated ?? false,
966
1413
  ...(found.known ? {} : { unknown: true }) };
967
1414
  })).then((backends) => { if (!res.headersSent)
968
- json(res, 200, { backends }); }, () => { if (!res.headersSent)
1415
+ json(res, 200, { backends, defaultBackend }); }, () => { if (!res.headersSent)
969
1416
  json(res, 500, { error: "backend probe failed" }); });
970
1417
  return true;
971
1418
  }
@@ -978,9 +1425,24 @@ export function handleWebRequest(req, res, url, ctx) {
978
1425
  // that arrived while the stream was silent is not skipped by the cursor this answer hands out (#1251
979
1426
  // review). The page keeps one entry per boot+id, so what it already loaded from /ui/history is not doubled.
980
1427
  if (method === "GET" && path === "/ui/poll") {
1428
+ // #1920: a guest's poll is projected for its scope (web-guest-access.ts) — the same rule as its stream.
1429
+ const guest = guestRequestOf(req);
981
1430
  const history = ctx.webChatHistory;
982
1431
  const after = url.searchParams.get("after");
983
1432
  const fresh = after === null || after === ""; // a cursor that is not ours in shape still gets nothing
1433
+ if (guest) {
1434
+ // #2085 review: and what became the channel's after its cursor (an older row, its copy sent since), once each.
1435
+ json(res, 200, guestPollBody(ctx.getUiStatus(), guest.scope(), Date.now(), guest, after, () => {
1436
+ if (!history)
1437
+ return [];
1438
+ if (fresh)
1439
+ return history.after(0);
1440
+ const cursor = parseLastEventId(after);
1441
+ const rows = [...history.replayFor(cursor), ...history.seenAfter(cursor)];
1442
+ return rows.filter((m, i) => rows.findIndex((x) => x.boot === m.boot && x.id === m.id) === i);
1443
+ }));
1444
+ return true;
1445
+ }
984
1446
  json(res, 200, {
985
1447
  status: ctx.getUiStatus(),
986
1448
  messages: history ? (fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
@@ -1013,30 +1475,53 @@ export function handleWebRequest(req, res, url, ctx) {
1013
1475
  });
1014
1476
  // #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
1015
1477
  // poll down by (one write: the frames a connect sends are unchanged in number).
1016
- res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n${sseClock()}`);
1478
+ // #1920: a guest's stream: its projected status now and with each heartbeat, its replay and ticks as it may see them
1479
+ // (§5.2), then only what guestFrameData lets through — no prompts or Needs you.
1480
+ const guest = guestRequestOf(req);
1481
+ if (guest) {
1482
+ streamGuests.set(res, guest);
1483
+ guest.streamOpened?.();
1484
+ }
1485
+ const statusFrame = () => `event: status\ndata: ${JSON.stringify(guest ? guestStatus(ctx.getUiStatus(), guest.scope()) : ctx.getUiStatus())}\n\n`;
1486
+ res.write(`${statusFrame()}${sseClock()}`);
1017
1487
  // An EventSource that reconnects says what it last saw: send what it missed, before anything new.
1018
1488
  const lastSeen = parseLastEventId(req.headers["last-event-id"]);
1019
1489
  if (lastSeen !== null && ctx.webChatHistory) {
1020
1490
  const history = ctx.webChatHistory;
1021
- for (const m of history.replayFor(lastSeen))
1022
- res.write(sseFrame("message", m, history.cursorOf(m)));
1491
+ if (guest) {
1492
+ // Its own projection, with no cursor: an admin's ids are not a guest's to hold (it reloads its history instead).
1493
+ for (const m of guestMessages(history.replayFor(lastSeen), guest, guest.scope()))
1494
+ res.write(sseFrame("message", m));
1495
+ }
1496
+ else {
1497
+ for (const m of history.replayFor(lastSeen))
1498
+ res.write(sseFrame("message", m, history.cursorOf(m)));
1499
+ }
1023
1500
  }
1024
1501
  // Ticks change messages already sent and carry no cursor: every (re)connect gets where each one is now, so a
1025
1502
  // report that fell into a gap is not lost until a reload (#1253 review). No id: it is not a message.
1026
- if (ctx.webChatHistory)
1027
- res.write(`event: deliveries\ndata: ${JSON.stringify(ctx.webChatHistory.deliveries())}\n\n`);
1503
+ if (ctx.webChatHistory) {
1504
+ const ticks = guest ? guestDeliveries(ctx.webChatHistory, guest, guest.scope()) : ctx.webChatHistory.deliveries();
1505
+ res.write(`event: deliveries\ndata: ${JSON.stringify(ticks)}\n\n`);
1506
+ }
1028
1507
  // The same for the fleet prompts (C4): one posted, answered or expired during a gap is caught up here.
1029
- if (ctx.listWebPrompts)
1508
+ if (!guest && ctx.listWebPrompts)
1030
1509
  res.write(`event: prompts\ndata: ${JSON.stringify(ctx.listWebPrompts())}\n\n`);
1031
1510
  // #1386: and "Needs you" as it is now; changes follow as `needs` events.
1032
- if (ctx.needsYouItems)
1511
+ if (!guest && ctx.needsYouItems)
1033
1512
  res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
1034
1513
  ctx.sseClients.add(res);
1035
- const interval = setInterval(() => {
1514
+ // #1956: the heartbeat's status comes from the one build every open stream shares each tick (statusTick), with
1515
+ // this stream's own projection; this stream's checks stay its own.
1516
+ const unsubscribe = subscribeStatusTick(ctx, {
1036
1517
  // A stream authorized once must not outlive the authorization. Re-checked
1037
1518
  // without counting as activity, or an open tab would keep an idle session
1038
1519
  // alive forever; a revoked, expired or rotated-away session ends here.
1039
- if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false })) {
1520
+ // #2049 review: a guest's stream also needs the exact session that opened it — another cookie on the same request
1521
+ // still authenticating does not keep a revoked device's stream (with its scope) alive.
1522
+ alive: () => {
1523
+ if (isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) && (!guest || guest.current()))
1524
+ return true;
1040
1525
  // Close the connection too, not just the response: an ended response leaves the socket idle in
1041
1526
  // keep-alive, still held open by whoever's session just ended.
1042
1527
  const socket = req.socket;
@@ -1045,23 +1530,23 @@ export function handleWebRequest(req, res, url, ctx) {
1045
1530
  }
1046
1531
  catch { /* already closed */ }
1047
1532
  cleanup();
1048
- return;
1049
- }
1050
- try {
1051
- // Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
1052
- res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`) + sseClock());
1533
+ return false;
1534
+ },
1535
+ scope: guest ? () => guest.scope() : null,
1536
+ write: (frame) => { try {
1537
+ res.write(frame + sseClock());
1053
1538
  }
1054
1539
  catch {
1055
1540
  cleanup();
1056
- }
1057
- }, ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
1541
+ } },
1542
+ });
1058
1543
  let cleanedUp = false;
1059
1544
  const cleanup = () => {
1060
1545
  if (cleanedUp)
1061
1546
  return;
1062
1547
  cleanedUp = true;
1063
1548
  ctx.sseClients.delete(res);
1064
- clearInterval(interval);
1549
+ unsubscribe();
1065
1550
  };
1066
1551
  // The *response's* `close` is the one that means the stream is over: it fires when the connection
1067
1552
  // goes away or the response is finished. The request's `close` means "the request has been read"
@@ -1083,7 +1568,10 @@ export function handleWebRequest(req, res, url, ctx) {
1083
1568
  json(res, 400, { error: "instance (1-128 chars) and limit (1-500) required" });
1084
1569
  return true;
1085
1570
  }
1086
- json(res, 200, { messages: ctx.webChatHistory?.list(instance, limit) ?? [], boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
1571
+ // #1920 §5.2: a guest gets what the channel showed of it, and its own messages (the gate checked the scope).
1572
+ const guest = guestRequestOf(req);
1573
+ const messages = !ctx.webChatHistory ? [] : guest ? guestHistory(ctx.webChatHistory, instance, limit, guest, guest.scope()) : ctx.webChatHistory.list(instance, limit);
1574
+ json(res, 200, { messages, boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
1087
1575
  return true;
1088
1576
  }
1089
1577
  // ── #1218: the Log tab ─────────────────────────────────
@@ -1138,6 +1626,16 @@ export function handleWebRequest(req, res, url, ctx) {
1138
1626
  const fileMatch = path.match(/^\/ui\/file\/([^/]+)$/);
1139
1627
  if (method === "GET" && fileMatch) {
1140
1628
  const id = fileMatch[1];
1629
+ // #1920: a guest gets a file only when its stored instance is in scope — "no such file" otherwise, like an unknown id
1630
+ // (an id from another instance's chat is useless).
1631
+ const guest = guestRequestOf(req);
1632
+ if (guest) {
1633
+ const served = isFileId(id) ? ctx.webFiles?.servedFile?.(id) ?? null : null;
1634
+ if (!served || !guest.scope().has(served.instance)) {
1635
+ json(res, 404, { error: "No such file" });
1636
+ return true;
1637
+ }
1638
+ }
1141
1639
  // #1589: audio and video, played in the page — one byte range at a time (206), by id only, never a whole video in
1142
1640
  // memory. Every other type is answered exactly as before.
1143
1641
  const media = isFileId(id) ? ctx.webFiles?.mediaOf?.(id) ?? null : null;
@@ -1169,9 +1667,10 @@ export function handleWebRequest(req, res, url, ctx) {
1169
1667
  return true;
1170
1668
  }
1171
1669
  // ── #1629: agents' themes — read by every page (the public link included: it views them), changed by a person ──
1172
- // GET /ui/themes: { rev, themes, locked, widgets } when the status frame's themes_rev moves (passive, web-auth.ts).
1670
+ // GET /ui/themes: { rev, themes, locked, widgets, ambient (#2117) } when the status frame's themes_rev moves (passive, web-auth.ts).
1173
1671
  // GET /ui/img/theme/<instance>/<id>: a theme's picture (same headers as #1630's images; the id is the content hash).
1174
- // POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out): a person's, from an admin session — never the CLI token, never the public
1672
+ // POST /ui/themes/<instance>/reset | lock | widget (r4-4: { slot } — take an agent's widget out) | ambient (#2117: { spec } sets one,
1673
+ // { which? } alone clears that kind or both): a person's, from an admin session — never the CLI token, never the public
1175
1674
  // link (absent from its manifest, and refused here too).
1176
1675
  if (method === "GET" && path === "/ui/themes") {
1177
1676
  const store = ctx.themeStore;
@@ -1179,7 +1678,7 @@ export function handleWebRequest(req, res, url, ctx) {
1179
1678
  json(res, 404, { error: "Themes are not kept on this fleet" });
1180
1679
  return true;
1181
1680
  }
1182
- json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets() });
1681
+ json(res, 200, { rev: store.rev(), themes: store.themes(), locked: store.lockedNames(), widgets: store.widgets(), ambient: store.ambients() });
1183
1682
  return true;
1184
1683
  }
1185
1684
  const themeImage = path.match(/^\/ui\/img\/theme\/([^/]+)\/([0-9a-f]{32})$/);
@@ -1205,7 +1704,7 @@ export function handleWebRequest(req, res, url, ctx) {
1205
1704
  res.end(image.bytes);
1206
1705
  return true;
1207
1706
  }
1208
- const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget)$/);
1707
+ const themeWrite = path.match(/^\/ui\/themes\/([^/]+)\/(reset|lock|widget|ambient)$/);
1209
1708
  if (method === "POST" && themeWrite) {
1210
1709
  const service = ctx.webAppearance;
1211
1710
  if (!service) {
@@ -1257,7 +1756,8 @@ export function handleWebRequest(req, res, url, ctx) {
1257
1756
  }
1258
1757
  const r = themeWrite[2] === "reset" ? service.humanReset(name)
1259
1758
  : themeWrite[2] === "widget" ? service.humanRemoveWidget(name, body.slot)
1260
- : typeof body.locked === "boolean" ? service.humanLock(name, body.locked) : { error: "locked: true or false", status: 400 };
1759
+ : themeWrite[2] === "ambient" ? service.humanSetAmbient(name, body.spec ?? null, body.which)
1760
+ : typeof body.locked === "boolean" ? service.humanLock(name, body.locked) : { error: "locked: true or false", status: 400 };
1261
1761
  if (typeof r.error === "string") {
1262
1762
  json(res, typeof r.status === "number" ? r.status : 503, { error: r.error });
1263
1763
  return;
@@ -1441,6 +1941,7 @@ export function handleWebRequest(req, res, url, ctx) {
1441
1941
  return true;
1442
1942
  }
1443
1943
  const click = ctx.clickWebReplyButton.bind(ctx);
1944
+ const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
1444
1945
  (async () => {
1445
1946
  let body;
1446
1947
  try {
@@ -1457,13 +1958,29 @@ export function handleWebRequest(req, res, url, ctx) {
1457
1958
  json(res, 400, { error: "instance, id and index required" });
1458
1959
  return;
1459
1960
  }
1961
+ // #1920 step 2b: a guest clicks only on a reply it may see (§5.2), of an in-scope instance, while guests may chat.
1962
+ const guest = guestRequestOf(req);
1963
+ const refused = guestChatRefusal(guest, instance);
1964
+ if (refused) {
1965
+ json(res, refused.status, { error: refused.error });
1966
+ return;
1967
+ }
1968
+ if (guest) {
1969
+ const row = guest.history?.()?.findByButtons(instance, id);
1970
+ if (!row || !guestSeesMessage(row, guest, guest.scope())) {
1971
+ json(res, 404, { error: "not found" });
1972
+ return;
1973
+ }
1974
+ }
1460
1975
  // #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
1461
1976
  // theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
1462
1977
  // gate (a session revoked meanwhile) clicks nothing as anyone.
1463
1978
  const person = webPersonOr401(req, res, url, ctx);
1464
1979
  if (!person)
1465
1980
  return;
1466
- const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name });
1981
+ // #1920 review: a guest's choice reaches the agent marked as a guest's, as its typed messages do.
1982
+ const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
1983
+ const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name, ...(guest ? { guest: true } : {}), authority });
1467
1984
  json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
1468
1985
  })().catch(err => {
1469
1986
  ctx.logger.error({ err: err.message }, "Web reply-button click failed");
@@ -1595,6 +2112,9 @@ export function handleWebRequest(req, res, url, ctx) {
1595
2112
  // Canvas effects are absent from the public gateway manifest and independently refused above.
1596
2113
  if (path === "/ui/effect-trials" || path.startsWith("/ui/effect-trials/"))
1597
2114
  return handleEffectTrials(req, res, url, ctx, path, method);
2115
+ // #2233: backend sign-in — absent from the public manifest and the guest table, and refused here too.
2116
+ if (path === "/ui/backend-login" || path.startsWith("/ui/backend-login/"))
2117
+ return handleBackendLogin(req, res, url, ctx, path, method);
1598
2118
  if (path === "/ui/effects" || path.startsWith("/ui/effects/"))
1599
2119
  return handleEffects(req, res, url, ctx, path, method);
1600
2120
  // #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
@@ -1641,12 +2161,57 @@ export function handleWebRequest(req, res, url, ctx) {
1641
2161
  // #1269: an instance's chat command (/ctx, /compact, /clear, /model, …) — a session write (CSRF, checked by the
1642
2162
  // gate), run through the platforms' own handlers and command table. The public link is told it is one (/save is
1643
2163
  // refused there).
2164
+ // ── #1939 S2b: this browser's authorization — what the app shows (GET), and "Authorize this browser" (POST: posts the
2165
+ // Confirm card in the account's DM). Judged on this request (§3), never touching the session.
2166
+ if (path === "/ui/authorization" && (method === "GET" || method === "POST")) {
2167
+ const judge = () => {
2168
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
2169
+ return { session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req), peer: classifyWebPeer(req) };
2170
+ };
2171
+ res.setHeader("Cache-Control", "no-store");
2172
+ if (method === "GET") {
2173
+ if (!ctx.webSessionAuthorization) {
2174
+ json(res, 404, { error: "not available" });
2175
+ return true;
2176
+ }
2177
+ json(res, 200, ctx.webSessionAuthorization(judge()));
2178
+ return true;
2179
+ }
2180
+ if (!ctx.requestSessionAuthorization) {
2181
+ json(res, 404, { error: "not available" });
2182
+ return true;
2183
+ }
2184
+ const ask = ctx.requestSessionAuthorization.bind(ctx);
2185
+ (async () => {
2186
+ try {
2187
+ await parseBody(req);
2188
+ }
2189
+ catch {
2190
+ json(res, 400, { error: "Invalid JSON" });
2191
+ return;
2192
+ }
2193
+ if (!permitWebContinuation(req, res, ctx))
2194
+ return;
2195
+ const r = await ask(judge()); // asked again now the body is in
2196
+ // A refusal is an answer the page shows (pending, three this hour, why not), not a failed request: 200 with
2197
+ // ok: false — a 4xx would be logged as an error in the browser's console every time.
2198
+ json(res, 200, r);
2199
+ })().catch(err => {
2200
+ ctx.logger.error({ err: err.message }, "Web authorization request failed");
2201
+ try {
2202
+ json(res, 500, { error: "Request failed" });
2203
+ }
2204
+ catch { /* already answered */ }
2205
+ });
2206
+ return true;
2207
+ }
1644
2208
  if (method === "POST" && path === "/ui/command") {
1645
2209
  if (!ctx.webCommand) {
1646
2210
  json(res, 404, { error: "Not available" });
1647
2211
  return true;
1648
2212
  }
1649
2213
  const run = ctx.webCommand.bind(ctx);
2214
+ const arrival = webAuthorityOf(req, url, ctx); // #1939 §3: on arrival, and again after the body
1650
2215
  (async () => {
1651
2216
  let body;
1652
2217
  try {
@@ -1675,7 +2240,8 @@ export function handleWebRequest(req, res, url, ctx) {
1675
2240
  const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
1676
2241
  if (!person)
1677
2242
  return;
1678
- const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person });
2243
+ const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, url, ctx, instance));
2244
+ const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person, authority });
1679
2245
  json(res, r.status, r.body);
1680
2246
  })().catch(err => {
1681
2247
  ctx.logger.error({ err: err.message }, "Web command failed");
@@ -1700,11 +2266,17 @@ export function handleWebRequest(req, res, url, ctx) {
1700
2266
  return true;
1701
2267
  }
1702
2268
  // Own keys only: "constructor" is in every object and names no instance.
1703
- const known = (ctx.fleetConfig ? Object.hasOwn(ctx.fleetConfig.instances, name) : false) || ctx.daemons.has(name);
2269
+ const known = registeredChatInstance(ctx, name) || ctx.daemons.has(name);
1704
2270
  if (!ctx.cancelInstance || !known) {
1705
2271
  json(res, 404, { error: `Instance not found: ${name}` });
1706
2272
  return true;
1707
2273
  }
2274
+ // #1920 step 2b: a guest's Cancel is the platform button's (anyone allowed there), while guests may chat.
2275
+ const cancelRefused = guestChatRefusal(guestRequestOf(req), name);
2276
+ if (cancelRefused) {
2277
+ json(res, cancelRefused.status, { error: cancelRefused.error });
2278
+ return true;
2279
+ }
1708
2280
  if (!ctx.cancelInstance(name)) {
1709
2281
  json(res, 409, { error: `${name} is not running` });
1710
2282
  return true;
@@ -1927,6 +2499,11 @@ export function handleWebRequest(req, res, url, ctx) {
1927
2499
  json(res, 200, { tasks });
1928
2500
  return true;
1929
2501
  }
2502
+ // #2246: the public link writes no task — the old list's create and update are board writes now (see below).
2503
+ if (method === "POST" && (path === "/ui/tasks" || /^\/ui\/tasks\/[^/]+$/.test(path)) && gatewayRequestContext(req)) {
2504
+ json(res, 403, { error: "task_board_not_over_public_link", code: "forbidden", key: "not_over_public_link" });
2505
+ return true;
2506
+ }
1930
2507
  if (method === "POST" && path === "/ui/tasks") {
1931
2508
  if (!ctx.scheduler) {
1932
2509
  json(res, 500, { error: "Scheduler not initialized" });
@@ -1949,7 +2526,7 @@ export function handleWebRequest(req, res, url, ctx) {
1949
2526
  priority: parsed.priority,
1950
2527
  assignee: parsed.assignee,
1951
2528
  created_by: person.name, // #1827
1952
- });
2529
+ }, boardActor(req, url, ctx, person));
1953
2530
  json(res, 200, task);
1954
2531
  }
1955
2532
  catch (err) {
@@ -1958,15 +2535,144 @@ export function handleWebRequest(req, res, url, ctx) {
1958
2535
  })();
1959
2536
  return true;
1960
2537
  }
2538
+ // ── Task board (scheduler/task-board.ts) ──────────────
2539
+ // Reads are the session's; every write is the signed-in person's, through the same rules an instance's tools use.
2540
+ if (path === "/ui/tasks/board" || path === "/ui/tasks/search" || path.startsWith("/ui/tasks/cards") || path.startsWith("/ui/tasks/projects")) {
2541
+ const board = ctx.scheduler?.db.board;
2542
+ if (!board) {
2543
+ json(res, 503, { error: "The task board is not available" });
2544
+ return true;
2545
+ }
2546
+ // The temporary public link reads the board and changes nothing on it: confirming, sending back, cancelling and
2547
+ // assigning are the person's authority, which the public link never carries (docs/web-dashboard.md).
2548
+ const gateway = gatewayRequestContext(req);
2549
+ if (gateway && method !== "GET") {
2550
+ json(res, 403, { error: "task_board_not_over_public_link", code: "forbidden", key: "not_over_public_link" });
2551
+ return true;
2552
+ }
2553
+ // …nor shows the cards of instances kept out of sight (cross_instance_visibility: hidden).
2554
+ const fleet = ctx.fleetConfig;
2555
+ const hiddenName = (n) => !!gateway && typeof n === "string" && !!n && !!ctx.fleetConfig?.instances?.[n] && crossInstanceVisibility(fleet, n) === "hidden";
2556
+ const hidden = (c) => [c.assignee, c.createdBy, c.dispatcher?.name].some(hiddenName);
2557
+ // #2303 review: what a visible card shows of the cards around it follows the same rule — its children, its main
2558
+ // task (a search hit's too) — and so do the words and the history a hidden instance left on it.
2559
+ // …and a hidden instance's words on a visible card are not counted, named or found there (#2304 review).
2560
+ const scope = { leaveOutAuthors: gateway ? Object.keys(ctx.fleetConfig?.instances ?? {}).filter(hiddenName) : [] };
2561
+ const hiddenRef = (ref) => { const c = ref ? board.findCard(ref) : undefined; return !!c && hidden(c); };
2562
+ const mentionsHidden = (v) => (typeof v === "string" ? hiddenName(v) : !!v && typeof v === "object" && Object.values(v).some(mentionsHidden));
2563
+ const shown = (d) => !gateway ? d : {
2564
+ ...d,
2565
+ parent: d.card.parentId && hiddenRef(d.card.parentId) ? null : d.parent,
2566
+ children: d.children.filter((c) => !hidden(c)),
2567
+ comments: d.comments.filter((e) => !hiddenName(e.author)),
2568
+ logs: d.logs.filter((e) => !hiddenName(e.author)),
2569
+ history: d.history.filter((e) => !hiddenName(e.actor) && !mentionsHidden(e.data)),
2570
+ };
2571
+ const card = /^\/ui\/tasks\/cards\/([^/]+)(?:\/(move|reorder))?$/.exec(path);
2572
+ const project = /^\/ui\/tasks\/projects\/(\d+)$/.exec(path);
2573
+ try {
2574
+ const ref = card ? decodeURIComponent(card[1]) : "";
2575
+ if (method === "GET" && path === "/ui/tasks/board") {
2576
+ // A page that has this revision already is told so, not sent the board again.
2577
+ const rev = board.revision();
2578
+ if (url.searchParams.get("rev") === String(rev)) {
2579
+ json(res, 200, { rev, unchanged: true });
2580
+ return true;
2581
+ }
2582
+ const allDone = url.searchParams.get("done") === "all";
2583
+ // #2296: every open Tasks tab reads the board again after each change; on the dashboard they all get the same
2584
+ // board for a revision, so it is built once per (revision, done=all, minute) — the minute for the 14-day
2585
+ // window. The public link's reads are filtered per request (hidden instances) and are built each time.
2586
+ const key = gateway ? null : `${rev}:${allDone ? 1 : 0}:${Math.floor(Date.now() / 60_000)}`;
2587
+ const kept = key ? boardBodies.get(board) : undefined;
2588
+ if (kept && kept.key === key) {
2589
+ jsonText(res, 200, kept.body);
2590
+ return true;
2591
+ }
2592
+ const cards = board.boardCards(undefined, allDone).filter((c) => !hidden(c));
2593
+ const body = JSON.stringify({ rev, cards: board.views(cards, scope), projects: board.listProjects(true), stats: board.projectStats() });
2594
+ if (key)
2595
+ boardBodies.set(board, { key, body });
2596
+ jsonText(res, 200, body);
2597
+ return true;
2598
+ }
2599
+ if (method === "GET" && path === "/ui/tasks/search") {
2600
+ const names = new Map(board.listProjects(true).map((p) => [p.id, p.name]));
2601
+ const hits = board.search(url.searchParams.get("q") ?? "", Number(url.searchParams.get("limit")) || 20, scope).filter((h) => !hidden(h.card));
2602
+ const views = board.views(hits.map((h) => h.card), scope);
2603
+ const results = hits.map(({ card: c, match }, i) => ({
2604
+ card: views[i],
2605
+ match,
2606
+ project: c.projectId === null ? null : names.get(c.projectId) ?? null,
2607
+ parentTitle: c.parent && !hiddenRef(c.parent) ? board.findCard(c.parent)?.title ?? null : null,
2608
+ }));
2609
+ json(res, 200, { results });
2610
+ return true;
2611
+ }
2612
+ if (method === "GET" && card && !card[2]) {
2613
+ const found = board.getCard(ref);
2614
+ if (hidden(found)) {
2615
+ json(res, 404, { error: "no such card", code: "not_found" });
2616
+ return true;
2617
+ }
2618
+ json(res, 200, shown(board.detail(ref, scope)));
2619
+ return true;
2620
+ }
2621
+ if (method === "POST" && path === "/ui/tasks/cards") {
2622
+ boardWrite(req, res, url, ctx, board, CardCreateSchema, (spec, person) => {
2623
+ const { cards, said } = board.createCards(person, { cards: [spec] });
2624
+ return { card: board.view(cards[0]), notes: said };
2625
+ });
2626
+ return true;
2627
+ }
2628
+ if (method === "POST" && card) {
2629
+ if (card[2] === "move") {
2630
+ boardWrite(req, res, url, ctx, board, CardMoveSchema, ({ to, ...move }, person) => {
2631
+ const { card: moved, said } = board.moveCard(person, ref, to, move);
2632
+ return { card: board.view(moved), notes: said };
2633
+ });
2634
+ }
2635
+ else if (card[2] === "reorder") {
2636
+ boardWrite(req, res, url, ctx, board, CardReorderSchema, ({ after, before }, person) => ({ card: board.view(board.reorder(person, ref, after ?? null, before ?? null)) }));
2637
+ }
2638
+ else {
2639
+ boardWrite(req, res, url, ctx, board, CardPatchSchema, (patch, person) => {
2640
+ const { card: updated, said } = board.updateCard(person, ref, patch);
2641
+ return { card: board.view(updated), notes: said };
2642
+ });
2643
+ }
2644
+ return true;
2645
+ }
2646
+ if (method === "POST" && path === "/ui/tasks/projects") {
2647
+ boardWrite(req, res, url, ctx, board, ProjectCreateSchema, (input, person) => ({ project: board.createProject(person, input) }));
2648
+ return true;
2649
+ }
2650
+ if (method === "POST" && project) {
2651
+ boardWrite(req, res, url, ctx, board, ProjectPatchSchema, (patch, person) => ({ project: board.updateProject(person, Number(project[1]), patch) }));
2652
+ return true;
2653
+ }
2654
+ }
2655
+ catch (err) {
2656
+ // A malformed %-escape in the card's address is the caller's mistake, not the server's.
2657
+ if (err instanceof URIError) {
2658
+ json(res, 400, { error: "bad card reference", code: "invalid" });
2659
+ return true;
2660
+ }
2661
+ boardError(res, err);
2662
+ return true;
2663
+ }
2664
+ json(res, 404, { error: "Not found" });
2665
+ return true;
2666
+ }
1961
2667
  const taskMatch = path.match(/^\/ui\/tasks\/(.+)$/);
1962
2668
  if (method === "POST" && taskMatch) {
1963
2669
  if (!ctx.scheduler) {
1964
2670
  json(res, 500, { error: "Scheduler not initialized" });
1965
2671
  return true;
1966
2672
  }
1967
- const id = decodeURIComponent(taskMatch[1]);
1968
2673
  (async () => {
1969
2674
  try {
2675
+ const id = decodeURIComponent(taskMatch[1]);
1970
2676
  const body = await parseBody(req);
1971
2677
  if (!permitWebContinuation(req, res, ctx))
1972
2678
  return;
@@ -1974,19 +2680,22 @@ export function handleWebRequest(req, res, url, ctx) {
1974
2680
  if (!parsed)
1975
2681
  return;
1976
2682
  let result;
2683
+ // #1827: the person, who is also the default assignee of a claim; the task board's review gate needs to know
2684
+ // a person (not an instance) made the move.
2685
+ const person = webPersonOr401(req, res, url, ctx);
2686
+ if (!person)
2687
+ return;
2688
+ const by = boardActor(req, url, ctx, person);
1977
2689
  if (parsed.action === "claim") {
1978
- const person = parsed.assignee ? null : webPersonOr401(req, res, url, ctx); // #1827: the default is the person
1979
- if (!parsed.assignee && !person)
1980
- return;
1981
- result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name);
2690
+ result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name, by);
1982
2691
  }
1983
2692
  else if (parsed.action === "complete") {
1984
- result = ctx.scheduler.db.completeTask(id, parsed.result);
2693
+ result = ctx.scheduler.db.completeTask(id, parsed.result, by);
1985
2694
  }
1986
2695
  else {
1987
2696
  // Strip action before passing remaining fields to updateTask
1988
2697
  const { action: _a, ...rest } = parsed;
1989
- result = ctx.scheduler.db.updateTask(id, rest);
2698
+ result = ctx.scheduler.db.updateTask(id, rest, by);
1990
2699
  }
1991
2700
  json(res, 200, result);
1992
2701
  }
@@ -2114,6 +2823,130 @@ export function handleWebRequest(req, res, url, ctx) {
2114
2823
  }
2115
2824
  return true;
2116
2825
  }
2826
+ // #2293: global search — an admin's only: the header token or an admin session. Never over the public link (the gateway
2827
+ // does not list it either), never a guest (not in GUEST_ROUTES). Asked on arrival and again before the answer is sent.
2828
+ if (method === "GET" && (path === "/ui/search" || path === "/ui/search/context")) {
2829
+ const refusal = () => {
2830
+ if (gatewayRequestContext(req))
2831
+ return [404, "search_not_over_public_link"];
2832
+ if (guestRequestOf(req))
2833
+ return [403, "guest_forbidden"];
2834
+ if (ctx.fleetConfig?.web?.search?.enabled === false || !ctx.searchIndex || ctx.searchIndex.status() === "unavailable")
2835
+ return [404, "search_unavailable"];
2836
+ return null;
2837
+ };
2838
+ const first = refusal();
2839
+ if (first) {
2840
+ json(res, first[0], { error: first[1] });
2841
+ return true;
2842
+ }
2843
+ const admitted = adminAdmissionOf(req, url, ctx);
2844
+ if (!admitted) {
2845
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
2846
+ return true;
2847
+ }
2848
+ const index = ctx.searchIndex;
2849
+ // cross_instance_visibility "hidden" (fleet-traffic's rule): a message to or from such a name is not found.
2850
+ const visibility = () => ctx.searchVisibility?.() ?? { hidden: [] };
2851
+ const shown = (doc) => xmsgShown(doc, visibility());
2852
+ const finish = (status, body) => {
2853
+ const now = refusal();
2854
+ if (now)
2855
+ return json(res, now[0], { error: now[1] });
2856
+ if (adminAdmissionOf(req, url, ctx) !== admitted)
2857
+ return json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
2858
+ json(res, status, body);
2859
+ };
2860
+ const failed = (reason) => finish(reason === "timeout" || reason === "busy" ? 503 : 404, { error: `search_${reason}` });
2861
+ if (path === "/ui/search/context") {
2862
+ const key = url.searchParams.get("key") ?? "";
2863
+ if (!key || key.length > 300) {
2864
+ json(res, 400, { error: "key required" });
2865
+ return true;
2866
+ }
2867
+ void index.context(key).then(r => {
2868
+ if (!r.ok)
2869
+ return failed(r.reason);
2870
+ const doc = r.value?.doc;
2871
+ // A message to or from an instance that is hidden now is not shown, as in the search itself.
2872
+ if (!doc || !shown(doc))
2873
+ return finish(404, { error: "not_found" });
2874
+ finish(200, { ...r.value, building: r.building });
2875
+ }, () => failed("unavailable"));
2876
+ return true;
2877
+ }
2878
+ const parsed = parseSearchQuery(url, ctx);
2879
+ if ("error" in parsed) {
2880
+ json(res, 400, { error: parsed.error });
2881
+ return true;
2882
+ }
2883
+ if (parsed.mine) {
2884
+ const person = webPersonOf(req, url, ctx);
2885
+ if (!person) {
2886
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
2887
+ return true;
2888
+ }
2889
+ parsed.filters.mine = [person.voterKey];
2890
+ }
2891
+ const v = visibility();
2892
+ if ("hidden" in v)
2893
+ parsed.filters.hidden = v.hidden;
2894
+ else
2895
+ parsed.filters.xmsgAmong = v.among;
2896
+ void index.query(parsed.filters).then(r => {
2897
+ if (!r.ok)
2898
+ return failed(r.reason);
2899
+ // A chat row still in the history opens in place (#m=); one that has left it opens as the index's copy.
2900
+ const history = ctx.webChatHistory;
2901
+ // The visibility as it is now (#2295 review): a name hidden while the query ran is left out too.
2902
+ const now = visibility();
2903
+ const hits = r.value.hits.filter(h => xmsgShown(h, now)).map(h => h.source !== "chat" ? h
2904
+ : { ...h, live: !!history?.find(h.instance, String(h.ref.boot ?? ""), Number(h.ref.id)) });
2905
+ finish(200, { hits, limited: r.value.limited, building: r.building });
2906
+ }, () => failed("unavailable"));
2907
+ return true;
2908
+ }
2909
+ // #2122: the usage report. Signed-in sessions by default (guests never: not in GUEST_ROUTES); hidden with the rest of
2910
+ // usage by web.usage_panel: false; over the public link only when web.usage_report_on_public_link is true. Answered
2911
+ // from the ledgers and rollups as they are; a catch-up runs behind. The page's CSV is built from this same answer.
2912
+ if (method === "GET" && path === "/ui/usage-report") {
2913
+ // Who may have it, asked on arrival and again before it is sent (the report is awaited: a switch turned off, a
2914
+ // session signed out or a public link ended meanwhile gets nothing). The same boundary both times, no new gate.
2915
+ const refusal = () => {
2916
+ if (!ctx.usageReport || ctx.fleetConfig?.web?.usage_panel === false)
2917
+ return [404, "Not available"];
2918
+ if (gatewayRequestContext(req) && ctx.fleetConfig?.web?.usage_report_on_public_link !== true)
2919
+ return [403, "usage_report_not_over_public_link"];
2920
+ return null;
2921
+ };
2922
+ // Over the public link, where to turn it on: Settings → Web on the dashboard reached directly (this computer's own
2923
+ // address — the public link's Settings only reads). Never the public origin's own Settings, which cannot change it.
2924
+ const answer = ([status, error]) => json(res, status, {
2925
+ error, ...(status === 403 ? { settings: `http://localhost:${ctx.fleetConfig?.health_port ?? 19280}/settings/web` } : {})
2926
+ });
2927
+ const first = refusal();
2928
+ if (first) {
2929
+ answer(first);
2930
+ return true;
2931
+ }
2932
+ const admitted = admissionOf(req, url, ctx);
2933
+ if (!admitted) {
2934
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
2935
+ return true;
2936
+ }
2937
+ void import("./usage-report.js").then(({ usageRange, usageGroup }) => ctx.usageReport({
2938
+ range: usageRange(url.searchParams.get("range"), url.searchParams.get("from") ?? undefined, url.searchParams.get("to") ?? undefined),
2939
+ group: usageGroup(url.searchParams.get("group")),
2940
+ })).then((report) => {
2941
+ const now = refusal();
2942
+ if (now)
2943
+ return answer(now);
2944
+ if (admissionOf(req, url, ctx) !== admitted)
2945
+ return json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
2946
+ json(res, 200, report);
2947
+ }, (err) => { ctx.logger.error({ err }, "usage report failed"); json(res, 500, { error: "Usage report failed" }); });
2948
+ return true;
2949
+ }
2117
2950
  // #1468: the prompt-cache expiry analysis. Opening it is a person's navigation, like any Fleet tab (it counts as
2118
2951
  // use); the page never re-reads it on a timer. Answered from the ledgers as they are; a catch-up runs behind.
2119
2952
  if (method === "GET" && path === "/ui/cache") {
@@ -2131,6 +2964,12 @@ export function handleWebRequest(req, res, url, ctx) {
2131
2964
  json(res, 200, ctx.orgChart?.() ?? { general: [], teams: [], instances: {} });
2132
2965
  return true;
2133
2966
  }
2967
+ // #1918: the org canvas's traffic on opening, and the public link's (which has no stream) on its poll cadence. The
2968
+ // fleet works it out at most once every two seconds and caps it, whoever asks how often.
2969
+ if (method === "GET" && path === "/ui/traffic") {
2970
+ json(res, 200, ctx.trafficSnapshot?.() ?? { now: new Date().toISOString(), items: [], delegations: [] });
2971
+ return true;
2972
+ }
2134
2973
  if (method === "GET" && path === "/ui/teams") {
2135
2974
  const teams = ctx.fleetConfig?.teams ?? {};
2136
2975
  json(res, 200, { teams });
@@ -2309,6 +3148,17 @@ export function handleWebRequest(req, res, url, ctx) {
2309
3148
  json(res, 404, { error: "not found" });
2310
3149
  return true;
2311
3150
  }
3151
+ /**
3152
+ * #2272: the line a web reply's channel copy opens with — the quoted sender and first words when that message reached
3153
+ * the channel (its members saw it); a web-only one is only named, never copied out to the channel.
3154
+ */
3155
+ export function webReplyEchoLine(quoted) {
3156
+ if (!quoted.channel)
3157
+ return t("web.echo_reply_web_only");
3158
+ const ref = webChatReplyRef(quoted);
3159
+ const words = ref.text.slice(0, 120) || (ref.media === "photo" ? "[photo]" : ref.media ? "[file]" : "");
3160
+ return t("web.echo_reply", quoted.sender.slice(0, 80), words);
3161
+ }
2312
3162
  /** Keep the echo in a single platform message, including its attachment names. */
2313
3163
  function webEchoPreview(message, names) {
2314
3164
  const text = wellFormed(message.slice(0, 500));
@@ -2317,8 +3167,30 @@ function webEchoPreview(message, names) {
2317
3167
  const truncated = message.length > 500 || preview.length > 1400;
2318
3168
  return wellFormed(preview.slice(0, 1400)) + (truncated ? ` … (${t("web.echo_full_text")})` : "");
2319
3169
  }
3170
+ /** Registration and liveness are different: a paused CLI may have neither daemon nor IPC. */
3171
+ function registeredChatInstance(ctx, name) {
3172
+ return (!!ctx.fleetConfig && Object.hasOwn(ctx.fleetConfig.instances, name)) || ctx.isClassicInstance?.(name) === true;
3173
+ }
3174
+ function uploadRecipient(ctx, name) {
3175
+ return registeredChatInstance(ctx, name)
3176
+ && (ctx.instanceIpcClients.has(name) || ctx.getInstanceStatus?.(name) === "paused");
3177
+ }
3178
+ /** Closed, path-free explanations; arbitrary launch errors stay in the host log. */
3179
+ function webDeliveryFailure(err) {
3180
+ const message = err instanceof Error ? err.message : "";
3181
+ if (/No warm slot is free/.test(message))
3182
+ return { reason: "wake_capacity", error: "No slot is free to wake this instance. Retry when another instance is idle." };
3183
+ if (/login|log in|sign in|\bauth(?:entication|orization)?\b|credential|api.?key/i.test(message))
3184
+ return { reason: "wake_credentials", error: "The instance could not sign in. Check its account on the host, then retry." };
3185
+ if (/timed? out|timeout/i.test(message))
3186
+ return { reason: "wake_timeout", error: "The instance did not become ready in time. Retry, or check its status on the host." };
3187
+ if (/Cannot wake stopped|no longer configured/.test(message))
3188
+ return { reason: "instance_stopped", error: "This instance was stopped or removed. Start it on the host before retrying." };
3189
+ return { reason: "delivery_unavailable", error: "The instance could not receive this message. Check its status on the host, then retry." };
3190
+ }
2320
3191
  /** Handle POST /ui/send — extracted for readability. */
2321
3192
  function handleSendMessage(req, res, ctx) {
3193
+ const arrival = webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx); // #1939 §3: again after the body
2322
3194
  let body = "";
2323
3195
  let size = 0;
2324
3196
  req.on("data", (chunk) => {
@@ -2341,9 +3213,30 @@ function handleSendMessage(req, res, ctx) {
2341
3213
  json(res, 400, { error: `${issue.path.join(".") || "body"}: ${issue.message}` });
2342
3214
  return;
2343
3215
  }
2344
- const { instance, message: typed, attachments = [] } = parsed.data;
2345
- const ipc = ctx.instanceIpcClients.get(instance);
2346
- if (!ipc) {
3216
+ const { instance, message: typed, attachments = [], replyTo } = parsed.data;
3217
+ // #1920 step 2b: a guest writes only to an in-scope instance, while guests may chat, and never a command.
3218
+ const guest = guestRequestOf(req);
3219
+ const refused = guestChatRefusal(guest, instance);
3220
+ if (refused) {
3221
+ json(res, refused.status, { error: refused.error });
3222
+ return;
3223
+ }
3224
+ if (guest && guestCommandText(typed)) {
3225
+ json(res, 403, { error: "guest_no_commands" });
3226
+ return;
3227
+ }
3228
+ // #2272: a reply names a message this chat still keeps — and, for a guest, one it may see. What it quotes is read
3229
+ // from that row here, never taken from the page.
3230
+ let quoted;
3231
+ if (replyTo) {
3232
+ quoted = ctx.webChatHistory?.find(instance, replyTo.boot, replyTo.id);
3233
+ if (!quoted || quoted.effectTrial || (guest && !guestSeesMessage(quoted, guest, guest.scope()))) {
3234
+ json(res, 409, { error: t("web.reply_gone"), reason: "reply_gone" });
3235
+ return;
3236
+ }
3237
+ }
3238
+ const registered = registeredChatInstance(ctx, instance);
3239
+ if (!registered && !ctx.instanceIpcClients.has(instance)) {
2347
3240
  json(res, 404, { error: `Instance not found: ${instance}` });
2348
3241
  return;
2349
3242
  }
@@ -2366,6 +3259,12 @@ function handleSendMessage(req, res, ctx) {
2366
3259
  json(res, 403, { error: t("web.raw_public_refused") });
2367
3260
  return;
2368
3261
  }
3262
+ // Recheck the same admission all the way through a wake, before the facade's actual IPC handoff.
3263
+ const requestUrl = new URL(req.url ?? "/", "http://localhost");
3264
+ const admission = admissionOf(req, requestUrl, ctx);
3265
+ const stillCurrent = () => !res.destroyed && admission !== null && admissionOf(req, requestUrl, ctx) === admission
3266
+ && (!guest || (guest.current() && !guestChatRefusal(guest, instance)))
3267
+ && (registered ? registeredChatInstance(ctx, instance) : ctx.instanceIpcClients.has(instance));
2369
3268
  const message = typed;
2370
3269
  // #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
2371
3270
  // body is in: a session revoked meanwhile sends nothing (not as the operator).
@@ -2375,6 +3274,8 @@ function handleSendMessage(req, res, ctx) {
2375
3274
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
2376
3275
  return;
2377
3276
  }
3277
+ // #1939 S1: what the receipt records about who authorized it (observe only: agents still read web as recognition).
3278
+ const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx, instance));
2378
3279
  const ts = new Date().toISOString();
2379
3280
  // The id the agent is given, and the one its delivery reports come back under: the page's ticks.
2380
3281
  const messageId = newWebMessageId();
@@ -2387,13 +3288,17 @@ function handleSendMessage(req, res, ctx) {
2387
3288
  const topicId = ctx.fleetConfig?.instances[instance]?.topic_id;
2388
3289
  const canEcho = ctx.fleetConfig?.web?.echo_to_channel !== false
2389
3290
  && !ctx.isClassicInstance?.(instance) && syncAdapter && groupId && topicId != null;
2390
- // Add no runtime credentials or local attachment paths to the display copy.
2391
- const preview = webEchoPreview(message, files.map(f => f.name));
2392
- // ClassicBot echo (#1320 part B): same ordering lane, per-entry
2393
- // opt-in resolved at send time inside sendClassicWebEcho.
3291
+ // Add no runtime credentials or local attachment paths to the display copy. A reply opens with the line it
3292
+ // answers (#2272) — quoted only if that message reached the channel; a web-only one is named, never copied out.
3293
+ const quoteLine = quoted ? webReplyEchoLine(quoted) : "";
3294
+ const preview = (quoteLine ? `${quoteLine}\n` : "") + webEchoPreview(message, files.map(f => f.name));
3295
+ // ClassicBot echo (#1320 part B): same ordering lane, each room's setting (#2054: on unless it says false, or
3296
+ // the fleet-wide switch is off for a room that is unset) resolved at send time inside sendClassicWebEcho.
2394
3297
  const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
2395
3298
  const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
2396
- await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
3299
+ const posted = await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), person.name);
3300
+ if (posted > 0)
3301
+ ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the room saw it
2397
3302
  }) : undefined;
2398
3303
  const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
2399
3304
  // A replacement adapter or edited binding is not the route we reserved.
@@ -2401,13 +3306,15 @@ function handleSendMessage(req, res, ctx) {
2401
3306
  || (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
2402
3307
  || ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
2403
3308
  return;
2404
- return syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
3309
+ const sent = await syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
3310
+ ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the topic saw it
3311
+ return sent;
2405
3312
  }) : undefined;
2406
3313
  let delivered;
2407
3314
  try {
2408
3315
  const deliver = (name, payload) => ctx.deliverHumanToInstance
2409
- ? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(), typed)
2410
- : ctx.deliverToInstance(name, payload);
3316
+ ? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed, { stillCurrent })
3317
+ : ctx.deliverToInstance(name, payload, { stillCurrent });
2411
3318
  delivered = await deliver(instance, {
2412
3319
  type: "fleet_inbound",
2413
3320
  content: delivery.text,
@@ -2419,8 +3326,11 @@ function handleSendMessage(req, res, ctx) {
2419
3326
  ts,
2420
3327
  thread_id: topicId != null ? String(topicId) : "",
2421
3328
  source: "web",
3329
+ ...(guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]" (authority none)
2422
3330
  adapter_id: syncAdapter?.id,
2423
3331
  ...delivery.meta,
3332
+ // #2272: `(reply_to: "…")`, as a Discord or Telegram reply gives the agent.
3333
+ ...(quoted ? { reply_to_text: webChatReplyText(quoted) } : {}),
2424
3334
  },
2425
3335
  });
2426
3336
  }
@@ -2432,7 +3342,7 @@ function handleSendMessage(req, res, ctx) {
2432
3342
  settleClassicEcho?.(false);
2433
3343
  ctx.webFiles?.release(files); // not delivered: the same ids can be sent again
2434
3344
  ctx.logger.error({ err, instance }, "Web message delivery failed");
2435
- json(res, 503, { error: "Instance delivery failed" });
3345
+ json(res, 503, webDeliveryFailure(err));
2436
3346
  return;
2437
3347
  }
2438
3348
  if (delivered === false) {
@@ -2440,7 +3350,7 @@ function handleSendMessage(req, res, ctx) {
2440
3350
  settleClassicEcho?.(false);
2441
3351
  ctx.webFiles?.release(files);
2442
3352
  ctx.logger.error({ instance }, "Web message not delivered (the instance went away or restarted)");
2443
- json(res, 503, { error: "Instance delivery failed" });
3353
+ json(res, 503, { reason: "delivery_changed", error: "The session or instance changed before this message was sent. Sign in again if needed, then retry." });
2444
3354
  return;
2445
3355
  }
2446
3356
  settleEcho?.(true);
@@ -2455,7 +3365,9 @@ function handleSendMessage(req, res, ctx) {
2455
3365
  ctx.webFiles?.commit(files);
2456
3366
  ctx.lastInboundUser.set(instance, person.name);
2457
3367
  ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
2458
- ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
3368
+ // #1920: the account that wrote it (a guest sees its own); channel-visible once its copy was sent (above).
3369
+ ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId, ...(person.bound ? { from: person.voterKey } : {}),
3370
+ ...(quoted ? { replyTo: webChatReplyRef(quoted) } : {}) });
2459
3371
  json(res, 200, { sent: true, messageId });
2460
3372
  }
2461
3373
  catch {
@@ -2479,7 +3391,14 @@ function handleUpload(req, res, url, ctx) {
2479
3391
  json(res, 404, { error: "uploads are not available" });
2480
3392
  return;
2481
3393
  }
2482
- if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !ctx.instanceIpcClients.has(instance)) {
3394
+ // #1920 step 2b: a guest's upload is for an in-scope instance's message, while guests may chat.
3395
+ const guestRefused = guestChatRefusal(guestRequestOf(req), instance);
3396
+ if (guestRefused) {
3397
+ req.resume();
3398
+ json(res, guestRefused.status, { error: guestRefused.error });
3399
+ return;
3400
+ }
3401
+ if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !uploadRecipient(ctx, instance)) {
2483
3402
  json(res, 404, { error: `Instance not found: ${instance}` });
2484
3403
  return;
2485
3404
  }
@@ -2542,11 +3461,9 @@ function handleUpload(req, res, url, ctx) {
2542
3461
  req.on("error", () => drop()); // the client went away
2543
3462
  req.on("close", () => { if (!over && req.complete === false)
2544
3463
  drop(); });
2545
- // The instance is checked again now the whole body is here: it may have been deleted (and its files
2546
- // forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
2547
- // Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
2548
- // in fleet.yaml, or as a ClassicBot room in classicBot.yaml. Asked when the body ends and again just before storing.
2549
- const deliverable = () => ctx.instanceIpcClients.has(instance) && (!!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true);
3464
+ // Recheck registration and delivery readiness after both waits. A paused recipient may have no IPC;
3465
+ // staging a file never wakes it. A stopped or deleted recipient must not regain an upload id.
3466
+ const deliverable = () => uploadRecipient(ctx, instance);
2550
3467
  const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
2551
3468
  req.on("end", () => {
2552
3469
  if (over)
@@ -2673,14 +3590,22 @@ function prefsViewer(req, url, ctx) {
2673
3590
  const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
2674
3591
  if (v.kind !== "allow")
2675
3592
  return null;
3593
+ // #1920: a guest is a guest on every listener. The public link's page and reads too — before, a guest signed in through
3594
+ // it (#2094) read the host's preferences unfiltered: the sidebar's order and favourites, naming instances out of its
3595
+ // scope. Its account (phase 3) names it on its page.
3596
+ const guest = v.via === "session" && v.session?.tier === "guest" ? { guest: true, ...(v.session.owner ? { owner: v.session.owner } : {}) } : {};
2676
3597
  if (!gatewayRequestContext(req) && v.via === "session" && v.session) {
2677
3598
  const person = webPersonKey(v.session.owner);
2678
- return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session" };
3599
+ return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session", ...guest };
2679
3600
  }
2680
- return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via };
3601
+ return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via, ...guest };
2681
3602
  }
2682
3603
  const prefsScope = (v) => ({ ownerScope: v?.scope ?? "none", ownerEpoch: v?.epoch ?? null });
2683
- const prefsView = (prefs, v) => ({ ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) });
3604
+ // #1920: a guest reads the host's look and its own, never a preference that names an instance (guestPrefs).
3605
+ const prefsView = (prefs, v) => {
3606
+ const view = { ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) };
3607
+ return v?.guest ? guestPrefs(view) : view;
3608
+ };
2684
3609
  function shellHtmlTag(ctx, mode, viewer) {
2685
3610
  if (mode !== "full" || !ctx.webPrefs)
2686
3611
  return '<html lang="en">';
@@ -2694,26 +3619,40 @@ function shellHtmlTag(ctx, mode, viewer) {
2694
3619
  * the public link, the anonymous View reader, the CLI's header token or the sign-in and setup pages (other panels).
2695
3620
  */
2696
3621
  function shellOwnerCss(ctx, mode, viewer) {
2697
- return mode === "full" && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
3622
+ return mode === "full" && !viewer?.guest && viewer?.person !== null && viewer?.person !== undefined && ctx.ownerCss ? ctx.ownerCss.active(viewer.person) : null;
3623
+ }
3624
+ function guestBodyAttrs(ctx, owner, attr) {
3625
+ const badge = ctx.guestBadge?.(owner);
3626
+ return ` data-tier="guest"${badge ? ` data-guest-name="${attr(badge.name)}" data-guest-platform="${attr(badge.platform)}"` : ""}`;
3627
+ }
3628
+ /** A separate session fence for Chat playback: public/guest viewers acquire no owner-pref identity. */
3629
+ function shellEffect(req, ctx, mode) {
3630
+ if (mode !== "full" || typeof ctx.effectBoot !== "string" || !/^[0-9a-f]{32}$/.test(ctx.effectBoot))
3631
+ return null;
3632
+ const v = evaluateWebRequest(req, new URL(req.url ?? "/", "http://localhost"), ctx.webToken ?? null, ctx.webSessions, { touch: false });
3633
+ if (v.kind !== "allow" || v.via !== "session" || !v.session || !/^[a-f0-9]{16}$/.test(v.session.handle))
3634
+ return null;
3635
+ return { boot: ctx.effectBoot, epoch: v.session.handle, scope: gatewayRequestContext(req) || v.session.tier === "guest" ? "chat" : "owner" };
2698
3636
  }
2699
3637
  function shellBodyTag(req, ctx, mode, viewer) {
2700
3638
  const p = shellPreview(req, ctx, mode);
2701
- const ownerCss = shellOwnerCss(ctx, mode, viewer);
2702
- let ownerCssState = "unknown";
2703
- if (mode === "full" && viewer?.person != null && ctx.ownerCss) {
3639
+ const ownerCss = shellOwnerCss(ctx, mode, viewer), effect = shellEffect(req, ctx, mode);
3640
+ let ownerCssState = mode === "full" && (gatewayRequestContext(req) || viewer?.guest) ? "off" : "unknown";
3641
+ if (mode === "full" && !viewer?.guest && viewer?.person != null && ctx.ownerCss) {
2704
3642
  ownerCssState = ctx.ownerCss.effectState(viewer.person);
2705
3643
  if (ownerCssState === "off" && ownerCss !== null || ownerCssState === "on" && ownerCss === null)
2706
3644
  ownerCssState = "unknown";
2707
3645
  }
2708
3646
  const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
2709
3647
  return `<body data-mode="${mode}"${mode === "full" && gatewayRequestContext(req) ? ' data-web-transport="poll"' : ""} data-dashboard-origin="${attr(p?.dashboardOrigin)}" data-preview-origin="${attr(p?.previewOrigin)}"`
3648
+ + `${effect ? ` data-effect-boot="${effect.boot}" data-effect-epoch="${effect.epoch}" data-effect-scope="${effect.scope}"` : ""}`
2710
3649
  + ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}"`
2711
3650
  // #1554: no availability asked (the public link, or not the full app) still says why, in the page's language.
2712
3651
  + ` data-preview-reason-code="${attr(p ? p.code ?? "" : gatewayRequestContext(req) ? "publicLink" : "notOffered")}"`
2713
3652
  // #1723: why the public link's preview tunnel failed (a fixed reason the page words), when it did.
2714
3653
  + `${p?.detail ? ` data-preview-detail="${attr(p.detail)}"` : ""}`
2715
- // #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔.
2716
- + ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
3654
+ // #1523 N3: whether the fleet serves /api/ai-usage (web.usage_panel), for every page's ◔ — never to a guest (#1920).
3655
+ + ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false || viewer?.guest ? "0" : "1"}"`
2717
3656
  // #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
2718
3657
  + ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
2719
3658
  // #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
@@ -2724,7 +3663,9 @@ function shellBodyTag(req, ctx, mode, viewer) {
2724
3663
  // #1629 r4 §3: the person's owner CSS in force (a sha), linked by the page unless this device or this load says not.
2725
3664
  + ` data-owner-css-state="${ownerCssState}"${ownerCss ? ` data-owner-css="${attr(ownerCss)}"` : ""}`
2726
3665
  // #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
2727
- + `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}>`;
3666
+ + `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}`
3667
+ // #1920 phase 3: a guest's page — the shell shows only what a guest may use, and who it is signed in as.
3668
+ + `${mode === "full" && viewer?.guest && viewer.owner ? guestBodyAttrs(ctx, viewer.owner, attr) : ""}>`;
2728
3669
  }
2729
3670
  /** The app shell page for one entry, under the panels' CSP; it may frame exactly <preview origin>/frame, and only
2730
3671
  * when this load chose one. */
@@ -2740,8 +3681,11 @@ export function serveAppShell(req, res, ctx, mode) {
2740
3681
  const mediaSrc = own ? `${own}/ui/file/` : "'none'";
2741
3682
  // A public-link page frames what its exposure's preview names (the quick-tunnel wildcard: its host is not known when
2742
3683
  // the page may load); every other page frames exactly the origin this load chose (#1723).
2743
- const frameSrc = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin
2744
- ? `${p.previewOrigin}/frame${mode === "full" && !gatewayRequestContext(req) ? ` ${p.previewOrigin}/effect` : ""}` : null;
3684
+ const previewFrames = p && "frameSrc" in p ? p.frameSrc : p?.previewOrigin ? `${p.previewOrigin}/frame` : null;
3685
+ // This fixed trusted document is independent of HTML preview. Its own response confines all Worker execution.
3686
+ const effectOrigin = gatewayRequestContext(req)?.expectedOrigin ?? dashboardOriginFor(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req));
3687
+ const effectFrame = shellEffect(req, ctx, mode) && effectOrigin ? `${effectOrigin}/effect-bootstrap` : null;
3688
+ const frameSrc = [previewFrames, effectFrame].filter(Boolean).join(" ");
2745
3689
  sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
2746
3690
  }
2747
3691
  catch {