@songsid/agend 2.3.0-beta.9 → 2.3.1

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 (743) hide show
  1. package/README.md +16 -8
  2. package/README.zh-TW.md +16 -8
  3. package/dist/adapter-world.js.map +1 -1
  4. package/dist/agend-home.d.ts +5 -0
  5. package/dist/agend-home.js +7 -0
  6. package/dist/agend-home.js.map +1 -0
  7. package/dist/agent-cli-instructions.md +7 -1
  8. package/dist/agent-cli.js +41 -0
  9. package/dist/agent-cli.js.map +1 -1
  10. package/dist/agent-endpoint.d.ts +9 -0
  11. package/dist/agent-endpoint.js +23 -0
  12. package/dist/agent-endpoint.js.map +1 -1
  13. package/dist/agent-file-read.js +1 -1
  14. package/dist/agent-file-read.js.map +1 -1
  15. package/dist/agent-whats-new.d.ts +16 -0
  16. package/dist/agent-whats-new.js +53 -12
  17. package/dist/agent-whats-new.js.map +1 -1
  18. package/dist/apply-job.js.map +1 -1
  19. package/dist/atomic-write.js.map +1 -1
  20. package/dist/auth-api.d.ts +12 -0
  21. package/dist/auth-api.js +77 -22
  22. package/dist/auth-api.js.map +1 -1
  23. package/dist/authority/authority-state.d.ts +4 -0
  24. package/dist/authority/authority-state.js +17 -8
  25. package/dist/authority/authority-state.js.map +1 -1
  26. package/dist/authority/session-authorizations.js.map +1 -1
  27. package/dist/authority/web-authority.d.ts +16 -8
  28. package/dist/authority/web-authority.js +19 -5
  29. package/dist/authority/web-authority.js.map +1 -1
  30. package/dist/backend/antigravity.d.ts +2 -2
  31. package/dist/backend/antigravity.js +2 -1
  32. package/dist/backend/antigravity.js.map +1 -1
  33. package/dist/backend/claude-code.js +13 -4
  34. package/dist/backend/claude-code.js.map +1 -1
  35. package/dist/backend/codex-metadata.d.ts +8 -0
  36. package/dist/backend/codex-metadata.js +74 -17
  37. package/dist/backend/codex-metadata.js.map +1 -1
  38. package/dist/backend/codex-package.d.ts +30 -0
  39. package/dist/backend/codex-package.js +133 -0
  40. package/dist/backend/codex-package.js.map +1 -0
  41. package/dist/backend/codex-session-lookup.js.map +1 -1
  42. package/dist/backend/codex.d.ts +2 -0
  43. package/dist/backend/codex.js +25 -5
  44. package/dist/backend/codex.js.map +1 -1
  45. package/dist/backend/credential-profile.d.ts +2 -0
  46. package/dist/backend/credential-profile.js +2 -0
  47. package/dist/backend/credential-profile.js.map +1 -1
  48. package/dist/backend/effort-metadata.js.map +1 -1
  49. package/dist/backend/grok.js +58 -3
  50. package/dist/backend/grok.js.map +1 -1
  51. package/dist/backend/kiro-engine-ledger.js.map +1 -1
  52. package/dist/backend/kiro-kas-store.js.map +1 -1
  53. package/dist/backend/kiro-v2-store.js.map +1 -1
  54. package/dist/backend/kiro-v3-identity.js.map +1 -1
  55. package/dist/backend/kiro.d.ts +9 -1
  56. package/dist/backend/kiro.js +102 -1
  57. package/dist/backend/kiro.js.map +1 -1
  58. package/dist/backend/launch-effort.js.map +1 -1
  59. package/dist/backend/mock.js.map +1 -1
  60. package/dist/backend/muse.d.ts +20 -2
  61. package/dist/backend/muse.js +154 -16
  62. package/dist/backend/muse.js.map +1 -1
  63. package/dist/backend/opencode-permission.js +1 -1
  64. package/dist/backend/opencode-permission.js.map +1 -1
  65. package/dist/backend/opencode.js +2 -1
  66. package/dist/backend/opencode.js.map +1 -1
  67. package/dist/backend/session-signals.js.map +1 -1
  68. package/dist/backend-availability.js.map +1 -1
  69. package/dist/backend-command.d.ts +2 -0
  70. package/dist/backend-command.js +3 -1
  71. package/dist/backend-command.js.map +1 -1
  72. package/dist/backend-outage.js.map +1 -1
  73. package/dist/binary-probe.js +1 -1
  74. package/dist/binary-probe.js.map +1 -1
  75. package/dist/bug-report-cli.d.ts +30 -0
  76. package/dist/bug-report-cli.js +139 -0
  77. package/dist/bug-report-cli.js.map +1 -0
  78. package/dist/bug-report.d.ts +47 -0
  79. package/dist/bug-report.js +137 -0
  80. package/dist/bug-report.js.map +1 -0
  81. package/dist/bundled-skills.d.ts +18 -0
  82. package/dist/bundled-skills.js +38 -0
  83. package/dist/bundled-skills.js.map +1 -0
  84. package/dist/cache-service.js +1 -1
  85. package/dist/cache-service.js.map +1 -1
  86. package/dist/channel/adapters/discord-client-retirement.d.ts +5 -0
  87. package/dist/channel/adapters/discord-client-retirement.js +57 -0
  88. package/dist/channel/adapters/discord-client-retirement.js.map +1 -0
  89. package/dist/channel/adapters/discord.d.ts +4 -0
  90. package/dist/channel/adapters/discord.js +57 -12
  91. package/dist/channel/adapters/discord.js.map +1 -1
  92. package/dist/channel/adapters/telegram.d.ts +7 -1
  93. package/dist/channel/adapters/telegram.js +57 -49
  94. package/dist/channel/adapters/telegram.js.map +1 -1
  95. package/dist/channel/mcp-server.js +21 -4
  96. package/dist/channel/mcp-server.js.map +1 -1
  97. package/dist/channel/mcp-tools.d.ts +3 -1
  98. package/dist/channel/mcp-tools.js +41 -4
  99. package/dist/channel/mcp-tools.js.map +1 -1
  100. package/dist/channel/message-queue.js.map +1 -1
  101. package/dist/channel/sticker-download.js.map +1 -1
  102. package/dist/channel/tool-router.js.map +1 -1
  103. package/dist/child-process.d.ts +21 -0
  104. package/dist/child-process.js +54 -0
  105. package/dist/child-process.js.map +1 -0
  106. package/dist/classic-channel-manager.js +3 -2
  107. package/dist/classic-channel-manager.js.map +1 -1
  108. package/dist/cli-entry.js.map +1 -1
  109. package/dist/cli-flags.js.map +1 -1
  110. package/dist/cli.d.ts +1 -0
  111. package/dist/cli.js +448 -313
  112. package/dist/cli.js.map +1 -1
  113. package/dist/command-table.d.ts +1 -1
  114. package/dist/command-table.js +1 -1
  115. package/dist/command-table.js.map +1 -1
  116. package/dist/compact-watch.js.map +1 -1
  117. package/dist/completion-install.js.map +1 -1
  118. package/dist/completion.js.map +1 -1
  119. package/dist/config-validator.d.ts +6 -0
  120. package/dist/config-validator.js +81 -8
  121. package/dist/config-validator.js.map +1 -1
  122. package/dist/config.d.ts +15 -3
  123. package/dist/config.js +41 -10
  124. package/dist/config.js.map +1 -1
  125. package/dist/connection-secrets.js.map +1 -1
  126. package/dist/context-guardian.js.map +1 -1
  127. package/dist/coredump-filter.js.map +1 -1
  128. package/dist/cpu-profile.js.map +1 -1
  129. package/dist/cross-instance-envelope.js.map +1 -1
  130. package/dist/cross-instance-notice.d.ts +7 -0
  131. package/dist/cross-instance-notice.js +10 -0
  132. package/dist/cross-instance-notice.js.map +1 -1
  133. package/dist/daemon.d.ts +52 -1
  134. package/dist/daemon.js +385 -27
  135. package/dist/daemon.js.map +1 -1
  136. package/dist/daily-summary.js.map +1 -1
  137. package/dist/darwin-memory.js +1 -1
  138. package/dist/darwin-memory.js.map +1 -1
  139. package/dist/dashboard-live.d.ts +8 -0
  140. package/dist/dashboard-live.js +35 -0
  141. package/dist/dashboard-live.js.map +1 -0
  142. package/dist/dashboard-permissions.d.ts +69 -0
  143. package/dist/dashboard-permissions.js +138 -0
  144. package/dist/dashboard-permissions.js.map +1 -0
  145. package/dist/decision-list.js.map +1 -1
  146. package/dist/delivery-consumed-watch.js.map +1 -1
  147. package/dist/delivery-outbox.d.ts +11 -0
  148. package/dist/delivery-outbox.js +52 -2
  149. package/dist/delivery-outbox.js.map +1 -1
  150. package/dist/delivery-outbox.test.js.map +1 -1
  151. package/dist/delivery-reconciliation.test.js.map +1 -1
  152. package/dist/diagnostic-redaction.d.ts +10 -0
  153. package/dist/diagnostic-redaction.js +167 -0
  154. package/dist/diagnostic-redaction.js.map +1 -0
  155. package/dist/doctor.d.ts +8 -0
  156. package/dist/doctor.js +52 -0
  157. package/dist/doctor.js.map +1 -1
  158. package/dist/e2e-isolation.js.map +1 -1
  159. package/dist/event-log.js.map +1 -1
  160. package/dist/export-import.js +7 -5
  161. package/dist/export-import.js.map +1 -1
  162. package/dist/fleet-context.d.ts +1 -1
  163. package/dist/fleet-control-audit.js.map +1 -1
  164. package/dist/fleet-label.js.map +1 -1
  165. package/dist/fleet-level-config.d.ts +1 -1
  166. package/dist/fleet-level-config.js +2 -0
  167. package/dist/fleet-level-config.js.map +1 -1
  168. package/dist/fleet-lock.d.ts +6 -0
  169. package/dist/fleet-lock.js +22 -2
  170. package/dist/fleet-lock.js.map +1 -1
  171. package/dist/fleet-manager.d.ts +283 -15
  172. package/dist/fleet-manager.js +1825 -331
  173. package/dist/fleet-manager.js.map +1 -1
  174. package/dist/fleet-process-handlers.d.ts +18 -0
  175. package/dist/fleet-process-handlers.js +70 -0
  176. package/dist/fleet-process-handlers.js.map +1 -0
  177. package/dist/fleet-startup.d.ts +14 -0
  178. package/dist/fleet-startup.js +30 -0
  179. package/dist/fleet-startup.js.map +1 -0
  180. package/dist/fleet-traffic.js.map +1 -1
  181. package/dist/forged-envelope-scan.d.ts +0 -20
  182. package/dist/forged-envelope-scan.js +3 -2
  183. package/dist/forged-envelope-scan.js.map +1 -1
  184. package/dist/full-restart.d.ts +5 -5
  185. package/dist/full-restart.js +5 -5
  186. package/dist/full-restart.js.map +1 -1
  187. package/dist/general-knowledge/skills/bug-report/SKILL.md +31 -0
  188. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +52 -11
  189. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +5 -1
  190. package/dist/general-knowledge/skills/fleet-config/SKILL.md +3 -1
  191. package/dist/general-knowledge/skills/fleet-restart/SKILL.md +8 -9
  192. package/dist/general-knowledge/skills/instance-lifecycle/SKILL.md +11 -0
  193. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  194. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +1 -1
  195. package/dist/general-knowledge/skills/scheduling/SKILL.md +4 -0
  196. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +321 -0
  197. package/dist/general-knowledge/skills/web-appearance/SKILL.md +24 -4
  198. package/dist/general-knowledge/skills/web-dashboard/SKILL.canvas-worker-off.md +554 -0
  199. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +156 -32
  200. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  201. package/dist/general-knowledge/steering/core-rules.md +1 -1
  202. package/dist/host-memory.js.map +1 -1
  203. package/dist/idle-gate-log.d.ts +19 -0
  204. package/dist/idle-gate-log.js +15 -0
  205. package/dist/idle-gate-log.js.map +1 -0
  206. package/dist/instance-config-impact.js +25 -4
  207. package/dist/instance-config-impact.js.map +1 -1
  208. package/dist/instance-lifecycle.d.ts +11 -1
  209. package/dist/instance-lifecycle.js +97 -25
  210. package/dist/instance-lifecycle.js.map +1 -1
  211. package/dist/instructions.d.ts +12 -6
  212. package/dist/instructions.js +19 -10
  213. package/dist/instructions.js.map +1 -1
  214. package/dist/kiro-compact-pane.js.map +1 -1
  215. package/dist/kiro-db-reader.js.map +1 -1
  216. package/dist/kiro-engine-status.js.map +1 -1
  217. package/dist/kiro-log-gate.js.map +1 -1
  218. package/dist/kiro-transcript-lane.js.map +1 -1
  219. package/dist/locale.js +450 -20
  220. package/dist/locale.js.map +1 -1
  221. package/dist/log-service.js +1 -0
  222. package/dist/log-service.js.map +1 -1
  223. package/dist/log-steps.d.ts +1 -0
  224. package/dist/log-steps.js +15 -0
  225. package/dist/log-steps.js.map +1 -1
  226. package/dist/logger.js +3 -2
  227. package/dist/logger.js.map +1 -1
  228. package/dist/login-controller.d.ts +45 -0
  229. package/dist/login-controller.js +101 -5
  230. package/dist/login-controller.js.map +1 -1
  231. package/dist/login-flows.js +1 -1
  232. package/dist/login-manager.js.map +1 -1
  233. package/dist/login-shell.js +3 -1
  234. package/dist/login-shell.js.map +1 -1
  235. package/dist/ls-attach-resolve.d.ts +96 -0
  236. package/dist/ls-attach-resolve.js +180 -0
  237. package/dist/ls-attach-resolve.js.map +1 -0
  238. package/dist/ls-rows.d.ts +12 -0
  239. package/dist/ls-rows.js +83 -4
  240. package/dist/ls-rows.js.map +1 -1
  241. package/dist/memory-pressure.js.map +1 -1
  242. package/dist/muse-usage-relay.js.map +1 -1
  243. package/dist/needs-you-hub.d.ts +98 -1
  244. package/dist/needs-you-hub.js +313 -44
  245. package/dist/needs-you-hub.js.map +1 -1
  246. package/dist/needs-you-live.d.ts +5 -2
  247. package/dist/needs-you-live.js +6 -3
  248. package/dist/needs-you-live.js.map +1 -1
  249. package/dist/needs-you.d.ts +68 -3
  250. package/dist/needs-you.js +39 -4
  251. package/dist/needs-you.js.map +1 -1
  252. package/dist/network-family.js.map +1 -1
  253. package/dist/operator-control-paths.js +3 -4
  254. package/dist/operator-control-paths.js.map +1 -1
  255. package/dist/outbound-handlers.d.ts +5 -2
  256. package/dist/outbound-handlers.js +81 -24
  257. package/dist/outbound-handlers.js.map +1 -1
  258. package/dist/outbound-schemas.d.ts +106 -60
  259. package/dist/outbound-schemas.js +33 -10
  260. package/dist/outbound-schemas.js.map +1 -1
  261. package/dist/outbox-open-error.js.map +1 -1
  262. package/dist/pane-context-cache.js.map +1 -1
  263. package/dist/paths.js.map +1 -1
  264. package/dist/private-dir.js.map +1 -1
  265. package/dist/probe-worker-pool.js.map +1 -1
  266. package/dist/profile-control.js.map +1 -1
  267. package/dist/prompt-file-ref.js.map +1 -1
  268. package/dist/provider-probe.js.map +1 -1
  269. package/dist/provider-secret-registry.d.ts +5 -3
  270. package/dist/provider-secret-registry.js +8 -2
  271. package/dist/provider-secret-registry.js.map +1 -1
  272. package/dist/public-link-auto-open.d.ts +32 -0
  273. package/dist/public-link-auto-open.js +86 -0
  274. package/dist/public-link-auto-open.js.map +1 -0
  275. package/dist/public-link-owner.d.ts +10 -0
  276. package/dist/public-link-owner.js +36 -0
  277. package/dist/public-link-owner.js.map +1 -0
  278. package/dist/public-link-progress.d.ts +12 -1
  279. package/dist/public-link-progress.js +5 -10
  280. package/dist/public-link-progress.js.map +1 -1
  281. package/dist/public-preview.js.map +1 -1
  282. package/dist/public-tunnel-config.d.ts +11 -0
  283. package/dist/public-tunnel-config.js +68 -0
  284. package/dist/public-tunnel-config.js.map +1 -0
  285. package/dist/public-web-gateway.d.ts +2 -0
  286. package/dist/public-web-gateway.js +19 -6
  287. package/dist/public-web-gateway.js.map +1 -1
  288. package/dist/public-web-link.d.ts +48 -3
  289. package/dist/public-web-link.js +177 -51
  290. package/dist/public-web-link.js.map +1 -1
  291. package/dist/quickstart-api.d.ts +9 -0
  292. package/dist/quickstart-api.js +82 -0
  293. package/dist/quickstart-api.js.map +1 -1
  294. package/dist/quickstart.d.ts +5 -0
  295. package/dist/quickstart.js +40 -25
  296. package/dist/quickstart.js.map +1 -1
  297. package/dist/reply-buttons-controller.js.map +1 -1
  298. package/dist/reply-dedup.js.map +1 -1
  299. package/dist/resource-report.js +1 -1
  300. package/dist/resource-report.js.map +1 -1
  301. package/dist/restart-guard.js.map +1 -1
  302. package/dist/restart-progress.d.ts +80 -1
  303. package/dist/restart-progress.js +306 -21
  304. package/dist/restart-progress.js.map +1 -1
  305. package/dist/rollout-index.js.map +1 -1
  306. package/dist/runtime-cpu-profile.js.map +1 -1
  307. package/dist/scheduler/db.d.ts +40 -13
  308. package/dist/scheduler/db.js +123 -89
  309. package/dist/scheduler/db.js.map +1 -1
  310. package/dist/scheduler/scheduler.js.map +1 -1
  311. package/dist/scheduler/task-board-text.d.ts +26 -0
  312. package/dist/scheduler/task-board-text.js +140 -0
  313. package/dist/scheduler/task-board-text.js.map +1 -0
  314. package/dist/scheduler/task-board.d.ts +730 -0
  315. package/dist/scheduler/task-board.js +2179 -0
  316. package/dist/scheduler/task-board.js.map +1 -0
  317. package/dist/scheduler/types.d.ts +6 -1
  318. package/dist/sd-notify.js +2 -1
  319. package/dist/sd-notify.js.map +1 -1
  320. package/dist/search/search-docs.d.ts +58 -0
  321. package/dist/search/search-docs.js +63 -0
  322. package/dist/search/search-docs.js.map +1 -0
  323. package/dist/search/search-index.d.ts +149 -0
  324. package/dist/search/search-index.js +453 -0
  325. package/dist/search/search-index.js.map +1 -0
  326. package/dist/search/search-store.d.ts +122 -0
  327. package/dist/search/search-store.js +236 -0
  328. package/dist/search/search-store.js.map +1 -0
  329. package/dist/search/search-worker.d.ts +1 -0
  330. package/dist/search/search-worker.js +163 -0
  331. package/dist/search/search-worker.js.map +1 -0
  332. package/dist/secret-file.js.map +1 -1
  333. package/dist/secret-store.d.ts +2 -0
  334. package/dist/secret-store.js +21 -0
  335. package/dist/secret-store.js.map +1 -1
  336. package/dist/service-installer.d.ts +98 -1
  337. package/dist/service-installer.js +246 -11
  338. package/dist/service-installer.js.map +1 -1
  339. package/dist/service-owner.d.ts +44 -0
  340. package/dist/service-owner.js +48 -0
  341. package/dist/service-owner.js.map +1 -0
  342. package/dist/service-plan.d.ts +39 -2
  343. package/dist/service-plan.js +188 -6
  344. package/dist/service-plan.js.map +1 -1
  345. package/dist/session-oom-stop.d.ts +45 -0
  346. package/dist/session-oom-stop.js +259 -0
  347. package/dist/session-oom-stop.js.map +1 -0
  348. package/dist/session-oom.d.ts +34 -0
  349. package/dist/session-oom.js +251 -0
  350. package/dist/session-oom.js.map +1 -0
  351. package/dist/settings-api.d.ts +14 -1
  352. package/dist/settings-api.js +74 -13
  353. package/dist/settings-api.js.map +1 -1
  354. package/dist/settings-baseline.js.map +1 -1
  355. package/dist/settings-change.d.ts +5 -0
  356. package/dist/settings-change.js +167 -6
  357. package/dist/settings-change.js.map +1 -1
  358. package/dist/settings-confirmation.d.ts +9 -1
  359. package/dist/settings-confirmation.js +18 -8
  360. package/dist/settings-confirmation.js.map +1 -1
  361. package/dist/settings-control.js +2 -1
  362. package/dist/settings-control.js.map +1 -1
  363. package/dist/settings-effect.js +29 -4
  364. package/dist/settings-effect.js.map +1 -1
  365. package/dist/settings-http-confirmation.d.ts +13 -0
  366. package/dist/settings-http-confirmation.js +15 -3
  367. package/dist/settings-http-confirmation.js.map +1 -1
  368. package/dist/settings-instance-patch.js +13 -1
  369. package/dist/settings-instance-patch.js.map +1 -1
  370. package/dist/settings-request-capability.d.ts +4 -1
  371. package/dist/settings-request-capability.js +11 -3
  372. package/dist/settings-request-capability.js.map +1 -1
  373. package/dist/settings-transaction.js.map +1 -1
  374. package/dist/setup-auth.d.ts +1 -0
  375. package/dist/setup-auth.js +2 -1
  376. package/dist/setup-auth.js.map +1 -1
  377. package/dist/setup-form.d.ts +1 -1
  378. package/dist/setup-form.js +23 -7
  379. package/dist/setup-form.js.map +1 -1
  380. package/dist/setup-host.d.ts +6 -1
  381. package/dist/setup-host.js +15 -4
  382. package/dist/setup-host.js.map +1 -1
  383. package/dist/setup-wizard.js +6 -3
  384. package/dist/setup-wizard.js.map +1 -1
  385. package/dist/spawn-gate.d.ts +7 -0
  386. package/dist/spawn-gate.js +45 -12
  387. package/dist/spawn-gate.js.map +1 -1
  388. package/dist/start-attempt.d.ts +11 -0
  389. package/dist/start-attempt.js +29 -0
  390. package/dist/start-attempt.js.map +1 -0
  391. package/dist/start-failure-chat.d.ts +37 -0
  392. package/dist/start-failure-chat.js +142 -0
  393. package/dist/start-failure-chat.js.map +1 -0
  394. package/dist/start-failure-notice.d.ts +4 -0
  395. package/dist/start-failure-notice.js +20 -0
  396. package/dist/start-failure-notice.js.map +1 -0
  397. package/dist/start-failure.d.ts +171 -0
  398. package/dist/start-failure.js +348 -0
  399. package/dist/start-failure.js.map +1 -0
  400. package/dist/startup-memory-deadline.d.ts +23 -0
  401. package/dist/startup-memory-deadline.js +55 -0
  402. package/dist/startup-memory-deadline.js.map +1 -0
  403. package/dist/statusline-watcher.js.map +1 -1
  404. package/dist/storm-window.js.map +1 -1
  405. package/dist/systemd-peer-bus.js.map +1 -1
  406. package/dist/target-queue-worker.js.map +1 -1
  407. package/dist/task-board-summary.d.ts +101 -0
  408. package/dist/task-board-summary.js +177 -0
  409. package/dist/task-board-summary.js.map +1 -0
  410. package/dist/task-board-tool.d.ts +25 -0
  411. package/dist/task-board-tool.js +155 -0
  412. package/dist/task-board-tool.js.map +1 -0
  413. package/dist/task-board-waker.d.ts +51 -0
  414. package/dist/task-board-waker.js +141 -0
  415. package/dist/task-board-waker.js.map +1 -0
  416. package/dist/telegram-api-root.d.ts +1 -0
  417. package/dist/telegram-api-root.js +40 -0
  418. package/dist/telegram-api-root.js.map +1 -0
  419. package/dist/tips.js.map +1 -1
  420. package/dist/tmux-control.js +1 -1
  421. package/dist/tmux-control.js.map +1 -1
  422. package/dist/tmux-exec-helper.d.ts +104 -0
  423. package/dist/tmux-exec-helper.js +412 -0
  424. package/dist/tmux-exec-helper.js.map +1 -0
  425. package/dist/tmux-manager.d.ts +1 -0
  426. package/dist/tmux-manager.js +29 -13
  427. package/dist/tmux-manager.js.map +1 -1
  428. package/dist/tmux-read.js +2 -2
  429. package/dist/tmux-read.js.map +1 -1
  430. package/dist/tool-permissions.js.map +1 -1
  431. package/dist/tool-progress.js.map +1 -1
  432. package/dist/topic-archiver.js.map +1 -1
  433. package/dist/topic-commands.d.ts +13 -0
  434. package/dist/topic-commands.js +56 -33
  435. package/dist/topic-commands.js.map +1 -1
  436. package/dist/topic-link.js.map +1 -1
  437. package/dist/transcript-delta-reader.js.map +1 -1
  438. package/dist/transcript-jsonl.js.map +1 -1
  439. package/dist/transcript-monitor.js +5 -0
  440. package/dist/transcript-monitor.js.map +1 -1
  441. package/dist/transcript-sources.d.ts +2 -0
  442. package/dist/transcript-sources.js +4 -0
  443. package/dist/transcript-sources.js.map +1 -1
  444. package/dist/tunnel/cloudflared-install.js +3 -1
  445. package/dist/tunnel/cloudflared-install.js.map +1 -1
  446. package/dist/tunnel/cloudflared.d.ts +7 -16
  447. package/dist/tunnel/cloudflared.js +100 -13
  448. package/dist/tunnel/cloudflared.js.map +1 -1
  449. package/dist/tunnel/lease.js +3 -2
  450. package/dist/tunnel/lease.js.map +1 -1
  451. package/dist/tunnel/manager.js.map +1 -1
  452. package/dist/tunnel/named-diagnostics.d.ts +13 -0
  453. package/dist/tunnel/named-diagnostics.js +52 -0
  454. package/dist/tunnel/named-diagnostics.js.map +1 -0
  455. package/dist/tunnel/public-fetch.js.map +1 -1
  456. package/dist/tunnel/purpose-lane.js.map +1 -1
  457. package/dist/tunnel/types.d.ts +4 -1
  458. package/dist/tunnel/types.js.map +1 -1
  459. package/dist/types.d.ts +56 -2
  460. package/dist/ui/app-effects.js +23 -5
  461. package/dist/ui/app.html +2 -0
  462. package/dist/ui/backend-signin.js +297 -0
  463. package/dist/ui/chat-effects.js +10 -3
  464. package/dist/ui/chat-msg-actions.js +176 -0
  465. package/dist/ui/chat-render.js +62 -18
  466. package/dist/ui/chat-store.js +218 -48
  467. package/dist/ui/chat-strings.js +40 -8
  468. package/dist/ui/chat-thread.js +119 -10
  469. package/dist/ui/effect-trial-offer.js +2 -31
  470. package/dist/ui/effects-waker.js +2 -0
  471. package/dist/ui/fleet-canvas-layout.js +13 -4
  472. package/dist/ui/fleet-canvas.js +69 -28
  473. package/dist/ui/fleet-office-editor.js +149 -0
  474. package/dist/ui/fleet-office-model.js +358 -0
  475. package/dist/ui/fleet-office-pack.js +199 -0
  476. package/dist/ui/fleet-office-skin.js +474 -0
  477. package/dist/ui/fleet-office-store.js +49 -0
  478. package/dist/ui/fleet-office-zones.js +177 -0
  479. package/dist/ui/fleet-office.js +434 -0
  480. package/dist/ui/fleet-org.js +18 -5
  481. package/dist/ui/fleet-route-scene.js +272 -0
  482. package/dist/ui/fleet-route-worker.js +21 -0
  483. package/dist/ui/fleet-route.js +88 -0
  484. package/dist/ui/fleet-task-card.js +436 -0
  485. package/dist/ui/fleet-task-common.js +482 -0
  486. package/dist/ui/fleet-task-work.js +107 -0
  487. package/dist/ui/fleet-tasks.js +569 -0
  488. package/dist/ui/fleet-traffic.js +81 -18
  489. package/dist/ui/fleet-usage.js +36 -2
  490. package/dist/ui/guest-refused.html +41 -0
  491. package/dist/ui/icons/icon-192.png +0 -0
  492. package/dist/ui/icons/icon-512.png +0 -0
  493. package/dist/ui/image-lightbox.js +45 -1
  494. package/dist/ui/image-zoom.js +242 -0
  495. package/dist/ui/panel-chat.js +214 -57
  496. package/dist/ui/panel-details.js +2 -0
  497. package/dist/ui/panel-fleet.js +32 -71
  498. package/dist/ui/panel-log.js +3 -3
  499. package/dist/ui/panel-needs.js +23 -6
  500. package/dist/ui/panel-settings.js +166 -33
  501. package/dist/ui/preview-panel.js +2 -1
  502. package/dist/ui/search-dialog.js +350 -0
  503. package/dist/ui/settings-appearance.js +170 -10
  504. package/dist/ui/settings-apply.js +3 -3
  505. package/dist/ui/settings-confirm.js +7 -2
  506. package/dist/ui/settings-dialogs.js +46 -21
  507. package/dist/ui/settings-discord.js +6 -2
  508. package/dist/ui/settings-guests.js +2 -1
  509. package/dist/ui/settings-learn.js +1 -0
  510. package/dist/ui/settings-model.js +51 -18
  511. package/dist/ui/settings-mods.js +8 -3
  512. package/dist/ui/settings-owner-effects.js +6 -4
  513. package/dist/ui/settings-strings.js +186 -6
  514. package/dist/ui/settings-token.js +3 -1
  515. package/dist/ui/settings-web-authority.js +13 -5
  516. package/dist/ui/settings-wizard.js +13 -7
  517. package/dist/ui/shared/agend-auth.js +2 -0
  518. package/dist/ui/shared/app-i18n.js +12 -5
  519. package/dist/ui/shared/app-needs.js +2 -2
  520. package/dist/ui/shared/app-prefs.js +15 -0
  521. package/dist/ui/shared/app-route.js +18 -0
  522. package/dist/ui/shared/app-shell.js +189 -28
  523. package/dist/ui/shared/app-store.js +3 -2
  524. package/dist/ui/shared/app-stream.js +5 -1
  525. package/dist/ui/shared/app-themes.js +116 -1
  526. package/dist/ui/shared/app.css +668 -48
  527. package/dist/ui/shared/app.js +9 -1
  528. package/dist/ui/shared/card-strings.js +4 -0
  529. package/dist/ui/shared/effect-app-runtime.js +7 -5
  530. package/dist/ui/shared/effect-chat-runtime.js +2 -7
  531. package/dist/ui/shared/effect-cursor.js +3 -2
  532. package/dist/ui/shared/effect-display-metadata.js +39 -0
  533. package/dist/ui/shared/effect-owner-source.js +8 -1
  534. package/dist/ui/shared/effect-registry.js +14 -4
  535. package/dist/ui/shared/effect-runtime-loader.js +3 -1
  536. package/dist/ui/shared/effect-stock-trust.js +5 -5
  537. package/dist/ui/shared/feature-flags.js +127 -0
  538. package/dist/ui/shared/guest-refused.js +35 -0
  539. package/dist/ui/shared/header-tools.js +2 -2
  540. package/dist/ui/shared/identity-card.js +26 -8
  541. package/dist/ui/shared/identity-color.js +17 -10
  542. package/dist/ui/shared/instance-nav.js +183 -28
  543. package/dist/ui/shared/owner-css.js +30 -1
  544. package/dist/ui/shared/panel-view.js +14 -29
  545. package/dist/ui/shared/text-undo.js +210 -0
  546. package/dist/ui/shared/tokens.css +4 -0
  547. package/dist/ui/shared/ui-dialog.js +5 -4
  548. package/dist/ui/shared/ui-icons.js +4 -0
  549. package/dist/ui/shared/ui-menu.js +20 -7
  550. package/dist/ui/shared/ui-toast.js +16 -3
  551. package/dist/ui/shared/usage-dialog.js +216 -16
  552. package/dist/ui/shared/view-strings.js +70 -4
  553. package/dist/ui/shared/web-authorize.js +50 -1
  554. package/dist/ui/signin.html +2 -0
  555. package/dist/ui/web-terminal/terminal.html +10 -10
  556. package/dist/ui/web-terminal/terminal.js +58 -12
  557. package/dist/update-check.js.map +1 -1
  558. package/dist/update-dispatch.js +1 -1
  559. package/dist/update-dispatch.js.map +1 -1
  560. package/dist/update-install.js.map +1 -1
  561. package/dist/update-launch.d.ts +10 -1
  562. package/dist/update-launch.js +39 -14
  563. package/dist/update-launch.js.map +1 -1
  564. package/dist/update-marker.js.map +1 -1
  565. package/dist/update-preflight.js +1 -1
  566. package/dist/update-preflight.js.map +1 -1
  567. package/dist/update-progress.d.ts +3 -1
  568. package/dist/update-progress.js +3 -1
  569. package/dist/update-progress.js.map +1 -1
  570. package/dist/usage/format-rich.js +6 -3
  571. package/dist/usage/format-rich.js.map +1 -1
  572. package/dist/usage/i18n-keys.d.ts +1 -1
  573. package/dist/usage/i18n-keys.js +2 -1
  574. package/dist/usage/i18n-keys.js.map +1 -1
  575. package/dist/usage/i18n.d.ts +13 -1
  576. package/dist/usage/i18n.js +29 -0
  577. package/dist/usage/i18n.js.map +1 -1
  578. package/dist/usage/providers.d.ts +89 -2
  579. package/dist/usage/providers.js +247 -41
  580. package/dist/usage/providers.js.map +1 -1
  581. package/dist/usage/statusline-usage.js.map +1 -1
  582. package/dist/usage/usage-api.d.ts +5 -1
  583. package/dist/usage/usage-api.js +185 -58
  584. package/dist/usage/usage-api.js.map +1 -1
  585. package/dist/usage-report.js.map +1 -1
  586. package/dist/usage-rollup.js +1 -1
  587. package/dist/usage-rollup.js.map +1 -1
  588. package/dist/user-systemd.d.ts +24 -0
  589. package/dist/user-systemd.js +75 -0
  590. package/dist/user-systemd.js.map +1 -0
  591. package/dist/view-api.d.ts +7 -0
  592. package/dist/view-api.js +16 -4
  593. package/dist/view-api.js.map +1 -1
  594. package/dist/wake-coordinator.js.map +1 -1
  595. package/dist/web-ambient.js.map +1 -1
  596. package/dist/web-api.d.ts +58 -11
  597. package/dist/web-api.js +1104 -71
  598. package/dist/web-api.js.map +1 -1
  599. package/dist/web-app-icon-worker.d.ts +1 -0
  600. package/dist/web-app-icon-worker.js +16 -0
  601. package/dist/web-app-icon-worker.js.map +1 -0
  602. package/dist/web-app-icon.d.ts +109 -0
  603. package/dist/web-app-icon.js +257 -0
  604. package/dist/web-app-icon.js.map +1 -0
  605. package/dist/web-appearance-policy.js.map +1 -1
  606. package/dist/web-appearance-tools.d.ts +1 -0
  607. package/dist/web-appearance-tools.js +7 -3
  608. package/dist/web-appearance-tools.js.map +1 -1
  609. package/dist/web-appearance-view.js.map +1 -1
  610. package/dist/web-appearance.d.ts +4 -10
  611. package/dist/web-appearance.js +7 -4
  612. package/dist/web-appearance.js.map +1 -1
  613. package/dist/web-auth.d.ts +8 -4
  614. package/dist/web-auth.js +33 -21
  615. package/dist/web-auth.js.map +1 -1
  616. package/dist/web-backend-login.d.ts +130 -0
  617. package/dist/web-backend-login.js +225 -0
  618. package/dist/web-backend-login.js.map +1 -0
  619. package/dist/web-card.js.map +1 -1
  620. package/dist/web-channel-echo.js.map +1 -1
  621. package/dist/web-chat-history.d.ts +118 -8
  622. package/dist/web-chat-history.js +220 -25
  623. package/dist/web-chat-history.js.map +1 -1
  624. package/dist/web-commands.d.ts +5 -1
  625. package/dist/web-commands.js +1 -1
  626. package/dist/web-commands.js.map +1 -1
  627. package/dist/web-cookie-names.d.ts +5 -0
  628. package/dist/web-cookie-names.js +12 -0
  629. package/dist/web-cookie-names.js.map +1 -0
  630. package/dist/web-effect-bundle.d.ts +2 -2
  631. package/dist/web-effect-library.js.map +1 -1
  632. package/dist/web-effect-playback.js.map +1 -1
  633. package/dist/web-effect-profiles.d.ts +16 -16
  634. package/dist/web-effect-profiles.js +3 -2
  635. package/dist/web-effect-profiles.js.map +1 -1
  636. package/dist/web-effect-service.js.map +1 -1
  637. package/dist/web-effect-tools.js.map +1 -1
  638. package/dist/web-effect-trials.js.map +1 -1
  639. package/dist/web-effect-upload.js.map +1 -1
  640. package/dist/web-effect.d.ts +6 -6
  641. package/dist/web-effect.js.map +1 -1
  642. package/dist/web-experimental-http.d.ts +2 -0
  643. package/dist/web-experimental-http.js +12 -0
  644. package/dist/web-experimental-http.js.map +1 -0
  645. package/dist/web-experimental.d.ts +11 -0
  646. package/dist/web-experimental.js +49 -0
  647. package/dist/web-experimental.js.map +1 -0
  648. package/dist/web-guest-access.d.ts +4 -2
  649. package/dist/web-guest-access.js +22 -10
  650. package/dist/web-guest-access.js.map +1 -1
  651. package/dist/web-guest-rooms.js.map +1 -1
  652. package/dist/web-host-guard.d.ts +11 -0
  653. package/dist/web-host-guard.js +11 -0
  654. package/dist/web-host-guard.js.map +1 -1
  655. package/dist/web-icons.d.ts +33 -1
  656. package/dist/web-icons.js +83 -11
  657. package/dist/web-icons.js.map +1 -1
  658. package/dist/web-identity-color.js.map +1 -1
  659. package/dist/web-image-meta.d.ts +13 -0
  660. package/dist/web-image-meta.js +62 -0
  661. package/dist/web-image-meta.js.map +1 -1
  662. package/dist/web-images.js.map +1 -1
  663. package/dist/web-libavoid.d.ts +14 -0
  664. package/dist/web-libavoid.js +46 -0
  665. package/dist/web-libavoid.js.map +1 -0
  666. package/dist/web-login.js.map +1 -1
  667. package/dist/web-mentions.js.map +1 -1
  668. package/dist/web-mod-agent.js.map +1 -1
  669. package/dist/web-mod-store.d.ts +2 -0
  670. package/dist/web-mod-store.js +10 -0
  671. package/dist/web-mod-store.js.map +1 -1
  672. package/dist/web-office-store.d.ts +104 -0
  673. package/dist/web-office-store.js +236 -0
  674. package/dist/web-office-store.js.map +1 -0
  675. package/dist/web-owner-css-store.d.ts +44 -1
  676. package/dist/web-owner-css-store.js +70 -9
  677. package/dist/web-owner-css-store.js.map +1 -1
  678. package/dist/web-owner-css.js.map +1 -1
  679. package/dist/web-png.d.ts +33 -0
  680. package/dist/web-png.js +329 -0
  681. package/dist/web-png.js.map +1 -0
  682. package/dist/web-prefs.d.ts +6 -0
  683. package/dist/web-prefs.js +29 -3
  684. package/dist/web-prefs.js.map +1 -1
  685. package/dist/web-preview.d.ts +2 -0
  686. package/dist/web-preview.js +2 -0
  687. package/dist/web-preview.js.map +1 -1
  688. package/dist/web-profile.js.map +1 -1
  689. package/dist/web-reactions.d.ts +35 -0
  690. package/dist/web-reactions.js +47 -0
  691. package/dist/web-reactions.js.map +1 -0
  692. package/dist/web-request-context.d.ts +5 -0
  693. package/dist/web-request-context.js.map +1 -1
  694. package/dist/web-returning-device.d.ts +4 -2
  695. package/dist/web-returning-device.js +13 -6
  696. package/dist/web-returning-device.js.map +1 -1
  697. package/dist/web-schedules.js.map +1 -1
  698. package/dist/web-send-dedupe.d.ts +36 -0
  699. package/dist/web-send-dedupe.js +69 -0
  700. package/dist/web-send-dedupe.js.map +1 -0
  701. package/dist/web-session-authorization.js.map +1 -1
  702. package/dist/web-session.js +1 -1
  703. package/dist/web-session.js.map +1 -1
  704. package/dist/web-settings-patch.d.ts +3 -1
  705. package/dist/web-settings-patch.js +11 -5
  706. package/dist/web-settings-patch.js.map +1 -1
  707. package/dist/web-settings-tools.d.ts +6 -0
  708. package/dist/web-settings-tools.js +52 -17
  709. package/dist/web-settings-tools.js.map +1 -1
  710. package/dist/web-terminal-http.js +15 -2
  711. package/dist/web-terminal-http.js.map +1 -1
  712. package/dist/web-terminal.js +1 -1
  713. package/dist/web-terminal.js.map +1 -1
  714. package/dist/web-theme-store.d.ts +2 -2
  715. package/dist/web-theme-store.js +6 -3
  716. package/dist/web-theme-store.js.map +1 -1
  717. package/dist/web-theme.d.ts +29 -0
  718. package/dist/web-theme.js +89 -1
  719. package/dist/web-theme.js.map +1 -1
  720. package/dist/web-upload.d.ts +18 -1
  721. package/dist/web-upload.js +45 -7
  722. package/dist/web-upload.js.map +1 -1
  723. package/dist/web-widget.js.map +1 -1
  724. package/dist/webhook-emitter.js.map +1 -1
  725. package/dist/windows-shortcuts.d.ts +78 -0
  726. package/dist/windows-shortcuts.js +203 -0
  727. package/dist/windows-shortcuts.js.map +1 -0
  728. package/dist/workflow-templates/default.md +3 -0
  729. package/dist/working-state-journal.d.ts +68 -0
  730. package/dist/working-state-journal.js +93 -0
  731. package/dist/working-state-journal.js.map +1 -0
  732. package/dist/ws-server.js.map +1 -1
  733. package/dist/yaml-shared-nodes.d.ts +25 -0
  734. package/dist/yaml-shared-nodes.js +185 -0
  735. package/dist/yaml-shared-nodes.js.map +1 -0
  736. package/launcher/agend-install.cjs +828 -0
  737. package/launcher/install-admission.cjs +4 -39
  738. package/launcher/postinstall.cjs +14 -12
  739. package/launcher/private-home.cjs +43 -0
  740. package/package.json +16 -13
  741. package/scripts/preinstall-guard.cjs +2 -5
  742. package/templates/windows-shortcuts.ps1 +181 -0
  743. package/launcher/old-updater-note.cjs +0 -48
package/dist/web-api.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { canvasWorkerEnabled, CANVAS_WORKER_EXPERIMENTAL_MESSAGE } from "./web-experimental.js";
2
+ import { isCanvasWorkerHttpPath } from "./web-experimental-http.js";
1
3
  import { webHumanOrigin } from "./human-delivery-receipts.js";
2
4
  import { classifyWebPeer, confirmAfterBody, noAuthority } from "./authority/web-authority.js";
3
5
  import { guestChatRefusal, guestCommandText, guestDeliveries, guestFrameData, guestHistory, guestMessages, guestPollBody, guestPrefs, guestRequestOf, guestSeesMessage, guestStatus } from "./web-guest-access.js";
@@ -5,6 +7,8 @@ import { withSpawnHoldReply } from "./spawn-hold-reply.js";
5
7
  import { binaryProbe } from "./binary-probe.js";
6
8
  import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
7
9
  import { readBoundedWebBody } from "./web-body.js";
10
+ import { APP_ICON_MAX_BYTES } from "./web-app-icon.js";
11
+ import { withAppIconLinks } from "./web-icons.js";
8
12
  import { MOD_MAX_BYTES } from "./web-mod-bundle.js";
9
13
  import { buildModBundle } from "./web-mod-export.js";
10
14
  import { AGENT_WIDGET_SLOTS, checkWidgetData, checkWidgetSpec } from "./web-widget.js";
@@ -12,6 +16,7 @@ import { checkOwnerCss, ownerCssAssetRefs } from "./web-owner-css.js";
12
16
  import { canonicalText } from "./web-theme.js";
13
17
  import { EFFECT_WIRE_LIMIT } from "./web-effect-upload.js";
14
18
  import { gatewayRequestContext } from "./web-request-context.js";
19
+ import { crossInstanceVisibility } from "./cross-instance-notice.js";
15
20
  import { permitWebContinuation } from "./web-continuation.js";
16
21
  /**
17
22
  * Web UI HTTP API handler.
@@ -21,7 +26,8 @@ import { formatWebChannelEcho } from "./web-channel-echo.js";
21
26
  import { t } from "./locale.js";
22
27
  import { isSafeInstanceName } from "./web-shell-routes.js";
23
28
  import { MAX_COMMAND_ARGS } from "./web-commands.js";
24
- import { sendPanelHtml } from "./web-host-guard.js";
29
+ import { ROUTE_WORKER_CSP, ROUTE_WORKER_PATH, sendPanelHtml } from "./web-host-guard.js";
30
+ import { libavoidFile } from "./web-libavoid.js";
25
31
  import { shellRoute } from "./web-shell-routes.js";
26
32
  import { createReadStream, createWriteStream, readFileSync } from "node:fs";
27
33
  import { join, dirname } from "node:path";
@@ -29,11 +35,12 @@ import { fileURLToPath } from "node:url";
29
35
  import { CreateInstanceArgs, CreateScheduleArgs, UpdateScheduleArgs, validateArgs } from "./outbound-schemas.js";
30
36
  import { scheduleView } from "./web-schedules.js";
31
37
  import { DEFAULT_SCHEDULER_CONFIG } from "./scheduler/index.js";
38
+ import { CARD_PRIORITIES, CARD_SIZES, CARD_STATUSES, TaskBoardError } from "./scheduler/task-board.js";
32
39
  import { readStatuslineModel, resolveInstanceContext } from "./topic-commands.js";
33
40
  import { z } from "zod";
34
41
  import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
35
42
  import { dashboardOriginFor } from "./web-preview.js";
36
- import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
43
+ import { WEB_CHAT_CARDS_OVERFLOW, newWebMessageId, parseLastEventId, webChatReplyRef, webChatReplyText } from "./web-chat-history.js";
37
44
  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";
38
45
  import { getAgendHome } from "./paths.js";
39
46
  import { OPERATOR, isOwnerEffectKey } from "./web-prefs.js";
@@ -44,6 +51,9 @@ import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
44
51
  import { measureSyncWork } from "./sync-work-attribution.js";
45
52
  import { resolveTz } from "./tz-utils.js";
46
53
  import { filesPlayInChat } from "./web-effect.js";
54
+ import { QUERY_MAX_CHARS, RESULT_MAX, SEARCH_SOURCES } from "./search/search-store.js";
55
+ import { xmsgShown } from "./search/search-docs.js";
56
+ import { WebSendDedupe } from "./web-send-dedupe.js";
47
57
  // ── Strict public-facing schemas ────────────────────────────────────────────
48
58
  // web-api endpoints must reject unknown fields so the dashboard cannot inject
49
59
  // internal-only flags that would reach handleCreate/scheduler/config writers.
@@ -58,11 +68,79 @@ const TaskUpdateSchema = z.object({
58
68
  action: z.enum(["claim", "complete", "update"]).optional(),
59
69
  assignee: z.string().max(128).optional(),
60
70
  result: z.string().max(MAX_TEXT).optional(),
61
- status: z.string().max(64).optional(),
71
+ // A status the task board knows: each carries its rules now (scheduler/task-board.ts).
72
+ status: z.enum(CARD_STATUSES).optional(),
62
73
  title: z.string().max(512).optional(),
63
74
  description: z.string().max(MAX_TEXT).optional(),
64
75
  priority: z.string().max(64).optional(),
65
76
  }).strict();
77
+ // The task board (scheduler/task-board.ts). Coarse bounds here; the board checks each field's own limit.
78
+ const BOARD_TEXT = 8_192;
79
+ const CardRef = z.string().min(1).max(64);
80
+ /** Who handed a card out: an instance, the person writing ({ kind: "person" }), or nobody. */
81
+ 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();
82
+ const CardCreateSchema = z.object({
83
+ kind: z.enum(["main", "sub"]).optional(),
84
+ project: z.union([z.number().int(), z.string().max(256)]).nullable().optional(),
85
+ parent: CardRef.nullable().optional(),
86
+ title: z.string().max(512),
87
+ goal: z.string().max(BOARD_TEXT).optional(),
88
+ scope: z.string().max(BOARD_TEXT).optional(),
89
+ acceptance: z.array(z.string().max(1024)).max(50).optional(),
90
+ context: z.string().max(BOARD_TEXT).optional(),
91
+ size: z.enum(CARD_SIZES).nullable().optional(),
92
+ priority: z.enum(CARD_PRIORITIES).optional(),
93
+ dependsOn: z.array(CardRef).max(50).optional(),
94
+ assignee: z.string().max(128).nullable().optional(),
95
+ dispatcher: DispatcherSchema.optional(),
96
+ needsReview: z.boolean().optional(),
97
+ }).strict();
98
+ const CardPatchSchema = z.object({
99
+ version: z.number().int().optional(),
100
+ title: z.string().max(512).optional(),
101
+ goal: z.string().max(BOARD_TEXT).optional(),
102
+ scope: z.string().max(BOARD_TEXT).optional(),
103
+ context: z.string().max(BOARD_TEXT).optional(),
104
+ state: z.string().max(BOARD_TEXT).optional(),
105
+ result: z.string().max(BOARD_TEXT).optional(),
106
+ blockedReason: z.string().max(BOARD_TEXT).optional(),
107
+ size: z.enum(CARD_SIZES).nullable().optional(),
108
+ priority: z.enum(CARD_PRIORITIES).optional(),
109
+ assignee: z.string().max(128).nullable().optional(),
110
+ dispatcher: DispatcherSchema.optional(),
111
+ needsReview: z.boolean().optional(),
112
+ dependsOn: z.array(CardRef).max(50).optional(),
113
+ acceptance: z.array(z.string().max(1024)).max(50).optional(),
114
+ addAcceptance: z.array(z.string().max(1024)).max(50).optional(),
115
+ check: z.array(z.number().int()).max(50).optional(),
116
+ uncheck: z.array(z.number().int()).max(50).optional(),
117
+ projectId: z.number().int().nullable().optional(),
118
+ log: z.string().max(BOARD_TEXT).optional(),
119
+ comment: z.string().max(MAX_TEXT).optional(),
120
+ }).strict();
121
+ const CardMoveSchema = z.object({
122
+ to: z.enum(CARD_STATUSES),
123
+ reason: z.string().max(BOARD_TEXT).optional(),
124
+ result: z.string().max(BOARD_TEXT).optional(),
125
+ comment: z.string().max(MAX_TEXT).optional(),
126
+ version: z.number().int().optional(),
127
+ }).strict();
128
+ const CardReorderSchema = z.object({ after: CardRef.nullable().optional(), before: CardRef.nullable().optional() }).strict();
129
+ const ProjectCreateSchema = z.object({ name: z.string().max(256), description: z.string().max(BOARD_TEXT).optional(), leadInstance: z.string().max(256).nullable().optional() }).strict();
130
+ /** #2352: a project's lead must be one of the fleet's instances now (the board keeps any name, as for an assignee). */
131
+ function checkLeadIsInstance(ctx, lead) {
132
+ const name = lead?.trim();
133
+ if (name && !(ctx.fleetConfig?.instances && Object.hasOwn(ctx.fleetConfig.instances, name))) {
134
+ throw new TaskBoardError("invalid", `"${name.slice(0, 64)}" is not one of the fleet's instances`, "lead_not_instance");
135
+ }
136
+ }
137
+ const ProjectPatchSchema = z.object({
138
+ name: z.string().max(256).optional(),
139
+ description: z.string().max(BOARD_TEXT).optional(),
140
+ archived: z.boolean().optional(),
141
+ /** #2352: the instance whose channel lists the project's waiting cards (null or "": General). */
142
+ leadInstance: z.string().max(256).nullable().optional(),
143
+ }).strict();
66
144
  const TeamCreateSchema = z.object({
67
145
  name: z.string().min(1).max(128).regex(/^[A-Za-z0-9._-]+$/),
68
146
  members: z.array(z.string().min(1).max(256)).min(1).max(256),
@@ -90,6 +168,11 @@ const SendMessageSchema = z.object({
90
168
  message: z.string().max(MAX_TEXT),
91
169
  /** Ids from POST /ui/upload, for this instance, not yet sent. */
92
170
  attachments: z.array(z.string().regex(/^[0-9a-f]{32}$/)).max(UPLOAD_LIMITS.maxFiles).optional(),
171
+ /** #2272: the message of this chat this one replies to — its row only; the server reads what it quotes. */
172
+ 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(),
173
+ /** The sending page's own id for its pending bubble: echoed on the live event only (never recorded), so that page
174
+ * swaps its "sending" bubble for the real one even when the stream beats this answer. */
175
+ clientId: z.string().regex(/^[A-Za-z0-9_-]{8,64}$/).optional(),
93
176
  }).strict().refine(v => v.message.trim().length > 0 || (v.attachments?.length ?? 0) > 0, { message: "a message or at least one file", path: ["message"] });
94
177
  /** An instance name that is safe as one path segment (no separator, no NUL, not a dot name). */
95
178
  const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "." && name !== "..";
@@ -101,6 +184,68 @@ const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "."
101
184
  */
102
185
  /** #1920 phase 1b: the open streams that are a guest's, and whose — each event is projected for them (guestFrameData). */
103
186
  const streamGuests = new WeakMap();
187
+ /**
188
+ * The task board's rule over the public link: an instance kept out of sight (cross_instance_visibility: hidden) is not
189
+ * shown, nor a card that names one as its assignee, creator or dispatcher. Off the link nothing is hidden. Read when
190
+ * asked: the fleet's config as it is now.
191
+ */
192
+ function boardHiding(ctx, gateway) {
193
+ const fleet = ctx.fleetConfig;
194
+ const hiddenName = (n) => gateway && typeof n === "string" && !!n && !!ctx.fleetConfig?.instances?.[n] && crossInstanceVisibility(fleet, n) === "hidden";
195
+ const hidden = (c) => [c.assignee, c.createdBy, c.dispatcher?.name].some(hiddenName);
196
+ return { hiddenName, hidden };
197
+ }
198
+ /**
199
+ * Chat history as the public link may see it (#2341 review): a status line that names board cards (the daily summary)
200
+ * is withheld while one of them is hidden there now — or no longer on the board — read when asked, so a card hidden
201
+ * after the line was written is withheld too. Off the link, as is. The cards are read in one query per answer.
202
+ */
203
+ function visibleMessages(ctx, gateway, messages) {
204
+ if (!gateway)
205
+ return messages;
206
+ // #2341 review r2: a line that named too many cards to keep them all is withheld whole.
207
+ messages = messages.filter((m) => !(m.cards ?? []).includes(WEB_CHAT_CARDS_OVERFLOW));
208
+ const named = [...new Set(messages.flatMap((m) => m.cards ?? []))];
209
+ if (!named.length)
210
+ return messages;
211
+ const board = ctx.scheduler?.db.board;
212
+ const { hidden } = boardHiding(ctx, true);
213
+ let found;
214
+ try {
215
+ found = new Map((board ? board.cardsByCode(named) : []).map((c) => [c.code, c]));
216
+ }
217
+ catch {
218
+ found = new Map();
219
+ }
220
+ const withheld = new Set(named.filter((code) => { const c = found.get(code); return !c || hidden(c); }));
221
+ return messages.filter((m) => !(m.cards ?? []).some((code) => withheld.has(code)));
222
+ }
223
+ /**
224
+ * Needs you as the public link may see it (#2306 review): a task item whose card the board hides there — or a card
225
+ * the board no longer has — is left out, with its title and question; everything else as listed. Off the link, as is.
226
+ * The link reads Needs you by /ui/poll only: its page has no stream (/ui/events answers it 404).
227
+ */
228
+ function visibleNeeds(ctx, gateway, items) {
229
+ if (!gateway)
230
+ return items;
231
+ const board = ctx.scheduler?.db.board;
232
+ const { hidden } = boardHiding(ctx, true);
233
+ return items.filter((i) => {
234
+ const item = i;
235
+ if (item.type !== "task")
236
+ return true;
237
+ const code = item.card?.code;
238
+ if (!board || typeof code !== "string")
239
+ return false;
240
+ try {
241
+ const card = board.findCard(code);
242
+ return !!card && !hidden(card);
243
+ }
244
+ catch {
245
+ return false;
246
+ }
247
+ });
248
+ }
104
249
  /**
105
250
  * #1920 §5.2: one event for the guests' streams only, each as projected for it — a message that has just become what
106
251
  * its channel saw (an admin's page already has it, unchanged).
@@ -173,10 +318,21 @@ async function parseBody(req) {
173
318
  }
174
319
  /** Send JSON response. */
175
320
  function json(res, status, data) {
321
+ jsonText(res, status, JSON.stringify(data));
322
+ }
323
+ /** A JSON answer already serialized (a body kept for reuse). */
324
+ function jsonText(res, status, body) {
176
325
  res.setHeader("Content-Type", "application/json");
177
326
  res.writeHead(status);
178
- res.end(JSON.stringify(data));
327
+ res.end(body);
179
328
  }
329
+ /**
330
+ * #2296: the last dashboard board read, serialized, per board: its key (revision, done=all, minute) and body. One per
331
+ * board (the newest revision replaces the previous), so it holds one board's JSON at most.
332
+ */
333
+ const boardBodies = new WeakMap();
334
+ /** #2316 review: the same for who works on what (GET /ui/tasks/work), per (revision, names asked for). */
335
+ const workBodies = new WeakMap();
180
336
  /**
181
337
  * #1630 (#1635 review): an answer that waited on something (a list, a download) is sent only if the request is still
182
338
  * allowed — the session not revoked meanwhile, a public link's exposure still current. Otherwise 401, and nothing.
@@ -189,6 +345,108 @@ function stillAuthorized(req, res, url, ctx) {
189
345
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
190
346
  return false;
191
347
  }
348
+ /**
349
+ * #2308: the app icon. GET: its state, for the settings card (a signed-in person's session). PUT (one PNG, at most
350
+ * 5 MiB) and DELETE (back to the bundled icon): a signed-in admin's session only — never the CLI's token, a read-only
351
+ * session, a guest or the public link; checked again once the body has arrived.
352
+ */
353
+ /** An app icon refusal as the page reads it: its code, the English detail and, when named, the detail's key (#2390). */
354
+ function appIconRefusal(r) {
355
+ return { error: r.error, ...(r.detail ? { detail: r.detail } : {}), ...(r.detail_key ? { detail_key: r.detail_key } : {}), ...(r.detail_args ? { detail_args: r.detail_args } : {}) };
356
+ }
357
+ function handleAppIcon(req, res, url, ctx, method) {
358
+ const store = ctx.appIcon;
359
+ if (!store) {
360
+ json(res, 404, { error: "app_icon_not_kept", detail: "The app icon is not kept on this fleet" });
361
+ return true;
362
+ }
363
+ const reading = method === "GET" || method === "HEAD";
364
+ if (!reading && method !== "PUT" && method !== "DELETE") {
365
+ res.setHeader("Allow", "GET, PUT, DELETE");
366
+ json(res, 405, { error: "method not allowed" });
367
+ return true;
368
+ }
369
+ if (gatewayRequestContext(req)) {
370
+ json(res, reading ? 404 : 403, { error: "app_icon_not_over_public_link" });
371
+ return true;
372
+ }
373
+ const who = () => {
374
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
375
+ if (verdict.kind !== "allow")
376
+ return { status: 401, error: WEB_TOKEN_INVALID_MESSAGE };
377
+ if (verdict.via !== "session" || !verdict.session || verdict.session.tier === "guest")
378
+ return { status: 403, error: "app_icon_needs_a_signed_in_person" };
379
+ if (!reading && verdict.session.tier !== "admin")
380
+ return { status: 403, error: "app_icon_admin_only" };
381
+ return { person: webPersonKey(verdict.session.owner) };
382
+ };
383
+ const first = who();
384
+ if ("status" in first) {
385
+ json(res, first.status, { error: first.error });
386
+ return true;
387
+ }
388
+ const state = () => { const s = store.current(); return s ? { custom: true, version: s.version, updatedAt: s.updatedAt } : { custom: false, version: "default", updatedAt: null }; };
389
+ if (reading) {
390
+ json(res, 200, state());
391
+ return true;
392
+ }
393
+ if (method === "DELETE") {
394
+ const reset = store.reset();
395
+ if (!reset.ok) {
396
+ json(res, reset.status, appIconRefusal(reset));
397
+ return true;
398
+ }
399
+ json(res, 200, state());
400
+ return true;
401
+ }
402
+ // Still allowed, still answering, the store still open: asked after each await (the body; the conversion).
403
+ const again = () => {
404
+ if (!permitWebContinuation(req, res, ctx))
405
+ return null;
406
+ if (res.destroyed)
407
+ return null;
408
+ const now = who();
409
+ if ("status" in now) {
410
+ json(res, now.status, { error: now.error });
411
+ return null;
412
+ }
413
+ return now.person;
414
+ };
415
+ (async () => {
416
+ let bytes;
417
+ try {
418
+ bytes = await readBoundedWebBody(req, APP_ICON_MAX_BYTES);
419
+ }
420
+ catch {
421
+ json(res, 413, { error: "app_icon_too_large", detail: "at most 5 MiB", detail_key: "max_size", detail_args: [5] });
422
+ return;
423
+ }
424
+ if (!again())
425
+ return;
426
+ // #2308 review: decoding and making the sizes is seconds of CPU at worst — a worker thread's, not the fleet loop's.
427
+ const prepared = await store.prepare(bytes);
428
+ if (!prepared.ok) {
429
+ json(res, prepared.status, appIconRefusal(prepared));
430
+ return;
431
+ }
432
+ const person = again();
433
+ if (!person)
434
+ return;
435
+ const saved = store.commit(prepared.prepared, person);
436
+ if (!saved.ok) {
437
+ json(res, saved.status, appIconRefusal(saved));
438
+ return;
439
+ }
440
+ json(res, 200, state());
441
+ })().catch((err) => {
442
+ ctx.logger.error({ err: err.message }, "App icon request failed");
443
+ try {
444
+ json(res, 500, { error: "app_icon_failed", detail: "App icon request failed" });
445
+ }
446
+ catch { /* already answered */ }
447
+ });
448
+ return true;
449
+ }
192
450
  /**
193
451
  * #1629 r4 §3: the owner-CSS routes (see their call site). `who()`, asked first and again after any body: the person a
194
452
  * signed-in human session is, or the status to answer.
@@ -280,7 +538,7 @@ function handleOwnerCss(req, res, url, ctx, path, method) {
280
538
  json(res, removed.ok ? 200 : removed.status, removed.ok ? { ok: true } : { error: removed.error });
281
539
  return true;
282
540
  }
283
- if (method !== "POST" || !["/ui/owner-css", "/ui/owner-css/keep", "/ui/owner-css/revert", "/ui/owner-css/restore", "/ui/owner-css/asset"].includes(path)) {
541
+ 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)) {
284
542
  json(res, 404, { error: "not found" });
285
543
  return true;
286
544
  }
@@ -329,6 +587,17 @@ function handleOwnerCss(req, res, url, ctx, path, method) {
329
587
  announce();
330
588
  json(res, 200, { sha: saved.sha, previous: saved.previous, trial: saved.trial });
331
589
  }
590
+ else if (path === "/ui/owner-css/shown") {
591
+ // #2198: this page shows the trial (its stylesheet loaded): an agent's waiting trial starts its keep window now.
592
+ const shown = store.shown(person, body.trial);
593
+ if (!shown.ok) {
594
+ json(res, shown.status, { error: shown.error });
595
+ return;
596
+ }
597
+ if (shown.started)
598
+ announce(); // the person's other devices take the new deadline
599
+ json(res, 200, { remaining_ms: shown.remaining_ms });
600
+ }
332
601
  else if (path === "/ui/owner-css/keep") {
333
602
  const kept = store.keep(person, body.trial);
334
603
  if (!kept.ok) {
@@ -475,6 +744,10 @@ function handleEffectTrials(req, res, url, ctx, path, method) {
475
744
  json(res, now.status, { error: now.error });
476
745
  return;
477
746
  }
747
+ if (!canvasWorkerEnabled(ctx.fleetConfig?.web)) {
748
+ json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
749
+ return;
750
+ }
478
751
  if (now.record !== first.record || now.sessions !== first.sessions || now.actor.sessionHash !== first.actor.sessionHash
479
752
  || now.actor.person !== first.actor.person || now.actor.epoch !== first.actor.epoch) {
480
753
  json(res, 409, { error: "trials_person_changed" });
@@ -509,6 +782,104 @@ function handleEffectTrials(req, res, url, ctx, path, method) {
509
782
  catch { /* response ended */ } });
510
783
  return true;
511
784
  }
785
+ /**
786
+ * #2233: signing a backend in from the dashboard (web-backend-login.ts). A signed-in admin session on the local surface
787
+ * only — never the CLI's header token (an agent's), a guest, a read session or the public link (refused before the body
788
+ * is read, and again after it). Every answer is no-store: the starter's view carries the terminal's access code.
789
+ */
790
+ function handleBackendLogin(req, res, url, ctx, path, method) {
791
+ res.setHeader("Cache-Control", "private, no-store");
792
+ res.setHeader("X-Content-Type-Options", "nosniff");
793
+ const reading = method === "GET";
794
+ if (gatewayRequestContext(req)) {
795
+ json(res, reading ? 404 : 403, { error: "sign_in_not_over_public_link" });
796
+ return true;
797
+ }
798
+ const who = () => {
799
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
800
+ if (v.kind !== "allow")
801
+ return { error: WEB_TOKEN_INVALID_MESSAGE, status: 401 };
802
+ if (v.via !== "session" || v.session.tier !== "admin" || v.session.surface !== "local")
803
+ return { error: "sign_in_needs_a_local_admin", status: 403 };
804
+ return { handle: v.session.handle, label: v.session.label };
805
+ };
806
+ const first = who();
807
+ if ("error" in first) {
808
+ json(res, first.status, { error: first.error });
809
+ return true;
810
+ }
811
+ const logins = ctx.webBackendLogins;
812
+ if (!logins) {
813
+ json(res, 404, { error: "sign_in_unavailable" });
814
+ return true;
815
+ }
816
+ const action = /^\/ui\/backend-login\/(check|start|cancel)$/.exec(path)?.[1];
817
+ if (reading && path === "/ui/backend-login" && [...url.searchParams.keys()].length === 0) {
818
+ json(res, 200, { login: logins.view(first.handle), targets: ctx.backendSignInTargets?.() ?? [] });
819
+ return true;
820
+ }
821
+ if (method !== "POST" || !action) {
822
+ json(res, 404, { error: "not_found" });
823
+ return true;
824
+ }
825
+ (async () => {
826
+ let bytes;
827
+ try {
828
+ bytes = await readBoundedWebBody(req, 4 * 1024);
829
+ }
830
+ catch {
831
+ json(res, 413, { error: "sign_in_body_bound" });
832
+ return;
833
+ }
834
+ if (!permitWebContinuation(req, res, ctx))
835
+ return;
836
+ const now = who();
837
+ if ("error" in now) {
838
+ json(res, now.status, { error: now.error });
839
+ return;
840
+ }
841
+ if (now.handle !== first.handle) {
842
+ json(res, 409, { error: "sign_in_session_changed" });
843
+ return;
844
+ }
845
+ let body;
846
+ try {
847
+ body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
848
+ }
849
+ catch {
850
+ json(res, 400, { error: "sign_in_json" });
851
+ return;
852
+ }
853
+ if (action === "check") {
854
+ 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);
855
+ if (!parsed.success) {
856
+ json(res, 400, { error: "sign_in_args" });
857
+ return;
858
+ }
859
+ json(res, 200, await logins.check(now, parsed.data.backend, parsed.data.profile));
860
+ }
861
+ else if (action === "start") {
862
+ const parsed = z.strictObject({ confirm: z.string().regex(/^[0-9a-f]{32}$/), tunnel: z.boolean().optional() }).safeParse(body);
863
+ if (!parsed.success) {
864
+ json(res, 400, { error: "sign_in_args" });
865
+ return;
866
+ }
867
+ const result = await logins.begin(now, parsed.data.confirm, parsed.data.tunnel === true);
868
+ json(res, "error" in result ? 409 : 200, result);
869
+ }
870
+ else {
871
+ if (!z.strictObject({}).safeParse(body).success) {
872
+ json(res, 400, { error: "sign_in_args" });
873
+ return;
874
+ }
875
+ json(res, 200, { message: await logins.cancel() });
876
+ }
877
+ })().catch(() => { try {
878
+ json(res, 500, { error: "sign_in_request_failed" });
879
+ }
880
+ catch { /* response ended */ } });
881
+ return true;
882
+ }
512
883
  /** #1629 B2: effect routes require a human session; route bodies never supply an actor or reference holder. */
513
884
  function handleEffects(req, res, url, ctx, path, method) {
514
885
  res.setHeader("Cache-Control", "private, no-store");
@@ -612,6 +983,10 @@ function handleEffects(req, res, url, ctx, path, method) {
612
983
  json(res, now.status, { error: now.error });
613
984
  return;
614
985
  }
986
+ if (!canvasWorkerEnabled(ctx.fleetConfig?.web)) {
987
+ json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
988
+ return;
989
+ }
615
990
  if (now.person !== first.person || now.epoch !== first.epoch) {
616
991
  json(res, 409, { error: "effects_person_changed" });
617
992
  return;
@@ -733,6 +1108,10 @@ function handleMods(req, res, url, ctx, path, method) {
733
1108
  // So the export ACL judges what the file carries, not what was installed: it downloads only when this person may
734
1109
  // read every effect in it (their own claim on that content, or a shared one). A file without effects is any admin's.
735
1110
  const fx = mods.bundleEffects(fileMatch[1]);
1111
+ if (!canvasWorkerEnabled(ctx.fleetConfig?.web) && (fx === "unknown" || (fx?.length ?? 0) > 0)) {
1112
+ json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
1113
+ return true;
1114
+ }
736
1115
  if (fx === "unknown" || (fx ?? []).some(id => !ctx.modPlacedEffect?.(first.person, id))) {
737
1116
  json(res, 403, { error: "mod_effects_not_yours", detail: "this mod's file carries effects that are not yours to share: export your own look instead" });
738
1117
  return true;
@@ -863,7 +1242,7 @@ export function exportMod(ctx, person, body) {
863
1242
  // The effects the theme places travel with it, as the library keeps them (a person may export what is placed).
864
1243
  // Only effects this person may carry away travel (Sol's ACL); the rest are counted, never packaged.
865
1244
  // #2017: an older placement of an effect that doesn't play in chat stays out (a mod can't place it): counted.
866
- const effects = rec.placements.effects.flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e || !filesPlayInChat(e.files)) {
1245
+ const effects = (canvasWorkerEnabled(ctx.fleetConfig?.web) ? rec.placements.effects : []).flatMap(p => { const e = ctx.modPlacedEffect?.(person, p.id); if (!e || !filesPlayInChat(e.files)) {
867
1246
  omitted++;
868
1247
  return [];
869
1248
  } return [{ name: e.name, files: e.files, params: p.params ?? {} }]; });
@@ -929,13 +1308,71 @@ function webAuthorityOf(req, url, ctx, instance) {
929
1308
  return { authority: noAuthority(0), refusal: "not_session" };
930
1309
  const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
931
1310
  return ctx.webAuthority({ session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req),
932
- peer: classifyWebPeer(req), ...(instance !== undefined ? { instance } : {}) });
1311
+ peer: webPeerOf(req), ...(instance !== undefined ? { instance } : {}) });
1312
+ }
1313
+ /**
1314
+ * #1939 §3.4 / #2453 §0.2: where this request came from — the admin's named link by the gateway's own provenance (its
1315
+ * exact hostname), else what the TCP peer and Host say (a quick tunnel's request is `lan`, and refused as public_link).
1316
+ */
1317
+ export function webPeerOf(req) {
1318
+ return gatewayRequestContext(req)?.named === true ? "named_link" : classifyWebPeer(req);
933
1319
  }
934
1320
  /**
935
1321
  * #2122: what let this request in, asked now — its session (by handle, under the token it was signed in with) or the
936
1322
  * header token; null when nothing does any more (the gate also refuses a public link that is no longer the current
937
1323
  * one). Equal before and after an await: the same admission still holds. Never touches the session.
938
1324
  */
1325
+ /** #2293: an admin's admission — the header token or an admin session — else null (a guest's or a read-only session's). */
1326
+ function adminAdmissionOf(req, url, ctx) {
1327
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1328
+ if (v.kind !== "allow" || (v.via === "session" && v.session.tier !== "admin"))
1329
+ return null;
1330
+ return admissionOf(req, url, ctx);
1331
+ }
1332
+ const SEARCH_LIMIT_DEFAULT = 50;
1333
+ /**
1334
+ * #2293: GET /ui/search's parameters: q (1–QUERY_MAX_CHARS), instance (repeated), team (its members join `instance`),
1335
+ * source (repeated: chat, xmsg, task), since / until (epoch ms), mine=1, limit (1–RESULT_MAX).
1336
+ */
1337
+ function parseSearchQuery(url, ctx) {
1338
+ const p = url.searchParams;
1339
+ const q = (p.get("q") ?? "").trim();
1340
+ if (!q || q.length > QUERY_MAX_CHARS)
1341
+ return { error: `q required (1-${QUERY_MAX_CHARS} chars)` };
1342
+ const instances = new Set(p.getAll("instance").filter(n => n.length > 0 && n.length <= 128));
1343
+ const team = p.get("team");
1344
+ if (team) {
1345
+ const def = ctx.fleetConfig?.teams && Object.hasOwn(ctx.fleetConfig.teams, team) ? ctx.fleetConfig.teams[team] : undefined;
1346
+ if (!def)
1347
+ return { error: "unknown team" };
1348
+ for (const member of def.members ?? [])
1349
+ instances.add(member);
1350
+ if (!instances.size)
1351
+ return { error: "team has no members" };
1352
+ }
1353
+ const sources = p.getAll("source");
1354
+ if (sources.some(s => !SEARCH_SOURCES.includes(s)))
1355
+ return { error: `source must be one of: ${SEARCH_SOURCES.join(", ")}` };
1356
+ const time = (name) => {
1357
+ const raw = p.get(name);
1358
+ if (raw === null || raw === "")
1359
+ return undefined;
1360
+ const n = Number(raw);
1361
+ return Number.isSafeInteger(n) && n >= 0 ? n : null;
1362
+ };
1363
+ const since = time("since"), until = time("until");
1364
+ if (since === null || until === null)
1365
+ return { error: "since/until must be epoch milliseconds" };
1366
+ const limitRaw = p.get("limit");
1367
+ const limit = limitRaw === null ? SEARCH_LIMIT_DEFAULT : Number(limitRaw);
1368
+ if (!Number.isInteger(limit) || limit < 1 || limit > RESULT_MAX)
1369
+ return { error: `limit must be 1-${RESULT_MAX}` };
1370
+ return {
1371
+ filters: { q, limit, ...(instances.size ? { instances: [...instances] } : {}), ...(sources.length ? { sources: sources } : {}),
1372
+ ...(since !== undefined ? { since } : {}), ...(until !== undefined ? { until } : {}) },
1373
+ mine: p.get("mine") === "1",
1374
+ };
1375
+ }
939
1376
  function admissionOf(req, url, ctx) {
940
1377
  const gateway = gatewayRequestContext(req);
941
1378
  const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
@@ -951,18 +1388,122 @@ function webPersonOr401(req, res, url, ctx) {
951
1388
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
952
1389
  return p;
953
1390
  }
1391
+ /**
1392
+ * Who a dashboard request acts as on the task board. The person only through a signed-in local session: the header
1393
+ * token is the CLI's or a script's (any agent on this machine can read the token file), and the temporary public link
1394
+ * carries no verified authority (docs/web-dashboard.md) — either is a script, held to the rules an instance is held to,
1395
+ * so neither confirms work waiting for the person.
1396
+ */
1397
+ function boardActor(req, url, ctx, p) {
1398
+ if (gatewayRequestContext(req))
1399
+ return { kind: "script", name: "public-link" };
1400
+ const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1401
+ return v.kind === "allow" && v.via === "session" ? { kind: "person", name: p.name } : { kind: "script", name: "web-token" };
1402
+ }
1403
+ /**
1404
+ * A board refusal as the dashboard reads it: the board's sentence, its code and the status that fits, and — when the
1405
+ * person can meet it — the key and values the page says it with in its own language.
1406
+ */
1407
+ function boardError(res, err) {
1408
+ if (res.headersSent)
1409
+ return;
1410
+ if (err instanceof TaskBoardError) {
1411
+ const status = err.code === "not_found" ? 404 : err.code === "forbidden" ? 403 : err.code === "conflict" ? 409 : 400;
1412
+ json(res, status, { error: err.message, code: err.code, ...(err.key ? { key: err.key, args: err.args } : {}) });
1413
+ return;
1414
+ }
1415
+ json(res, 400, { error: err.message });
1416
+ }
1417
+ /**
1418
+ * One board write from the dashboard: the body read and checked, the request still allowed, the person asked for
1419
+ * again now the body is in (#1827), then the write as that person. The board's own routes take a signed-in session
1420
+ * only (instances use their task tool, scripts the old /ui/tasks routes).
1421
+ */
1422
+ function boardWrite(req, res, url, ctx, board, schema, write) {
1423
+ void (async () => {
1424
+ try {
1425
+ const body = await parseBody(req);
1426
+ if (!permitWebContinuation(req, res, ctx))
1427
+ return;
1428
+ const parsed = parseOrReject(schema, body, res);
1429
+ if (!parsed)
1430
+ return;
1431
+ const person = webPersonOr401(req, res, url, ctx);
1432
+ if (!person)
1433
+ return;
1434
+ const actor = boardActor(req, url, ctx, person);
1435
+ if (actor.kind !== "person") {
1436
+ json(res, 403, { error: "The task board takes changes from a signed-in dashboard", code: "forbidden", key: "signed_in_only" });
1437
+ return;
1438
+ }
1439
+ json(res, 200, write(parsed, actor));
1440
+ }
1441
+ catch (err) {
1442
+ boardError(res, err);
1443
+ }
1444
+ })();
1445
+ }
954
1446
  /** #1827: what the page's profile card reads — the name they chose (or null), the one in use, and how agents know them. */
955
1447
  function profileBody(p) {
956
1448
  return { name: p.set ? p.name : null, shown: p.name, id: p.userId, bound: p.bound, ...(p.platformName !== undefined ? { platform_name: p.platformName } : {}) };
957
1449
  }
958
1450
  /** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
959
1451
  function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
960
- /**
961
- * Handle a Web UI request. Returns true if handled, false to pass through.
962
- */
1452
+ const statusTicks = new WeakMap();
1453
+ function subscribeStatusTick(ctx, stream) {
1454
+ let tick = statusTicks.get(ctx);
1455
+ if (!tick)
1456
+ statusTicks.set(ctx, tick = { streams: new Set(), timer: null });
1457
+ const t = tick;
1458
+ t.streams.add(stream);
1459
+ t.timer ??= setInterval(() => statusTick(ctx, t), ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
1460
+ return () => {
1461
+ t.streams.delete(stream);
1462
+ if (!t.streams.size && t.timer) {
1463
+ clearInterval(t.timer);
1464
+ t.timer = null;
1465
+ }
1466
+ };
1467
+ }
1468
+ function statusTick(ctx, t) {
1469
+ const live = [...t.streams].filter((s) => s.alive());
1470
+ if (!live.length)
1471
+ return;
1472
+ let status, admin = null;
1473
+ const guests = new Map();
1474
+ try {
1475
+ status = measureSyncWork("web.sseStatus", () => ctx.getUiStatus());
1476
+ }
1477
+ catch {
1478
+ return;
1479
+ } // nothing to send this tick; the next one tries again
1480
+ const frameFor = (s) => {
1481
+ if (!s.scope)
1482
+ return admin ??= `event: status\ndata: ${JSON.stringify(status)}\n\n`;
1483
+ const scope = s.scope(), key = [...scope].sort().join("\u0000");
1484
+ let frame = guests.get(key);
1485
+ if (frame === undefined)
1486
+ guests.set(key, frame = `event: status\ndata: ${JSON.stringify(guestStatus(status, scope))}\n\n`);
1487
+ return frame;
1488
+ };
1489
+ for (const s of live) {
1490
+ let frame;
1491
+ try {
1492
+ frame = frameFor(s);
1493
+ }
1494
+ catch {
1495
+ continue;
1496
+ }
1497
+ s.write(frame);
1498
+ }
1499
+ }
963
1500
  export function handleWebRequest(req, res, url, ctx) {
964
1501
  const path = url.pathname;
965
1502
  const method = req.method ?? "GET";
1503
+ if (!canvasWorkerEnabled(ctx.fleetConfig?.web) && isCanvasWorkerHttpPath(path)) {
1504
+ json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
1505
+ return true;
1506
+ }
966
1507
  // Auth check for all /ui routes. Defence in depth behind the fleet-manager
967
1508
  // gate: the same session cookie or header token, and an unset token closes
968
1509
  // the panel instead of comparing null against a missing credential.
@@ -1010,6 +1551,9 @@ export function handleWebRequest(req, res, url, ctx) {
1010
1551
  try {
1011
1552
  const js = readFileSync(join(__dirname, "ui", fileName), "utf-8");
1012
1553
  res.setHeader("Content-Type", "application/javascript; charset=utf-8");
1554
+ // #2182: the route worker's own, narrower-reaching policy (ROUTE_WORKER_CSP); every other file keeps the page's.
1555
+ if (path === ROUTE_WORKER_PATH)
1556
+ res.setHeader("Content-Security-Policy", ROUTE_WORKER_CSP);
1013
1557
  res.writeHead(200);
1014
1558
  res.end(js);
1015
1559
  }
@@ -1018,6 +1562,19 @@ export function handleWebRequest(req, res, url, ctx) {
1018
1562
  }
1019
1563
  return true;
1020
1564
  }
1565
+ // #2182: libavoid (libavoid-js, LGPL-2.1-or-later) — the npm package's own two files, unmodified, at a versioned path.
1566
+ const vendor = method === "GET" ? libavoidFile(path) : null;
1567
+ if (vendor) {
1568
+ try {
1569
+ const body = readFileSync(vendor.file);
1570
+ res.writeHead(200, { "Content-Type": vendor.type, "Cache-Control": "private, max-age=31536000, immutable" });
1571
+ res.end(body);
1572
+ }
1573
+ catch {
1574
+ json(res, 404, { error: "File not found" });
1575
+ }
1576
+ return true;
1577
+ }
1021
1578
  // ── Backend detection ─────────────────────────────────
1022
1579
  // #2072: the model a Settings field offers — the cached catalog of that backend (that instance's account's). Read only.
1023
1580
  if (method === "GET" && path === "/ui/models") {
@@ -1080,30 +1637,31 @@ export function handleWebRequest(req, res, url, ctx) {
1080
1637
  const after = url.searchParams.get("after");
1081
1638
  const fresh = after === null || after === ""; // a cursor that is not ours in shape still gets nothing
1082
1639
  if (guest) {
1083
- // #2085 review: and what became the channel's after its cursor (an older row, its copy sent since), once each.
1640
+ // #2085 review: with what became the channel's after its cursor (an older row, its copy sent since), once each —
1641
+ // replayFor counts a row where it was seen (seenSeq), as for every reader (#1774).
1084
1642
  json(res, 200, guestPollBody(ctx.getUiStatus(), guest.scope(), Date.now(), guest, after, () => {
1085
1643
  if (!history)
1086
1644
  return [];
1087
- if (fresh)
1088
- return history.after(0);
1089
- const cursor = parseLastEventId(after);
1090
- const rows = [...history.replayFor(cursor), ...history.seenAfter(cursor)];
1091
- return rows.filter((m, i) => rows.findIndex((x) => x.boot === m.boot && x.id === m.id) === i);
1645
+ return fresh ? history.after(0) : history.replayFor(parseLastEventId(after));
1092
1646
  }));
1093
1647
  return true;
1094
1648
  }
1095
1649
  json(res, 200, {
1096
1650
  status: ctx.getUiStatus(),
1097
- messages: history ? (fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
1651
+ // #2341 review: over the public link, without a summary line that names a card hidden there.
1652
+ messages: history ? visibleMessages(ctx, !!gatewayRequestContext(req), fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
1098
1653
  // The ticks too, so polling never has to re-read a chat's history — that read would count as the person's
1099
1654
  // activity; this poll does not (isPassiveWebRead).
1100
1655
  deliveries: history ? history.deliveries() : [],
1101
1656
  // #1266: a reply's buttons that ended after the page saw the reply (the message itself is not sent again).
1102
1657
  reply_buttons: history ? history.buttonStates() : [],
1658
+ // #2305: the instance's reactions, likewise (a reaction is no new message).
1659
+ reactions: history ? history.reactionStates() : [],
1103
1660
  // And the fleet prompts open on the dashboard (C4): prompt events are stream-only too.
1104
1661
  prompts: ctx.listWebPrompts?.() ?? [],
1105
- // #1386: "Needs you" rides the passive channels only — no endpoint of its own to poll.
1106
- needs: ctx.needsYouItems?.() ?? [],
1662
+ // #1386: "Needs you" rides the passive channels only — no endpoint of its own to poll. Over the public link,
1663
+ // without the task board's hidden cards (#2306 review).
1664
+ needs: visibleNeeds(ctx, !!gatewayRequestContext(req), ctx.needsYouItems?.() ?? []),
1107
1665
  // #1577: the newest message this answer accounts for — after a restart, the last restored one until this process
1108
1666
  // records its own (`<boot>-0` would be after(0): the whole restored history again on every poll).
1109
1667
  cursor: history ? history.pollCursor() : null,
@@ -1160,13 +1718,17 @@ export function handleWebRequest(req, res, url, ctx) {
1160
1718
  if (!guest && ctx.needsYouItems)
1161
1719
  res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
1162
1720
  ctx.sseClients.add(res);
1163
- const interval = setInterval(() => {
1721
+ // #1956: the heartbeat's status comes from the one build every open stream shares each tick (statusTick), with
1722
+ // this stream's own projection; this stream's checks stay its own.
1723
+ const unsubscribe = subscribeStatusTick(ctx, {
1164
1724
  // A stream authorized once must not outlive the authorization. Re-checked
1165
1725
  // without counting as activity, or an open tab would keep an idle session
1166
1726
  // alive forever; a revoked, expired or rotated-away session ends here.
1167
1727
  // #2049 review: a guest's stream also needs the exact session that opened it — another cookie on the same request
1168
1728
  // still authenticating does not keep a revoked device's stream (with its scope) alive.
1169
- if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) || (guest && !guest.current())) {
1729
+ alive: () => {
1730
+ if (isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) && (!guest || guest.current()))
1731
+ return true;
1170
1732
  // Close the connection too, not just the response: an ended response leaves the socket idle in
1171
1733
  // keep-alive, still held open by whoever's session just ended.
1172
1734
  const socket = req.socket;
@@ -1175,23 +1737,23 @@ export function handleWebRequest(req, res, url, ctx) {
1175
1737
  }
1176
1738
  catch { /* already closed */ }
1177
1739
  cleanup();
1178
- return;
1179
- }
1180
- try {
1181
- // Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
1182
- res.write(measureSyncWork("web.sseStatus", statusFrame) + sseClock());
1740
+ return false;
1741
+ },
1742
+ scope: guest ? () => guest.scope() : null,
1743
+ write: (frame) => { try {
1744
+ res.write(frame + sseClock());
1183
1745
  }
1184
1746
  catch {
1185
1747
  cleanup();
1186
- }
1187
- }, ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
1748
+ } },
1749
+ });
1188
1750
  let cleanedUp = false;
1189
1751
  const cleanup = () => {
1190
1752
  if (cleanedUp)
1191
1753
  return;
1192
1754
  cleanedUp = true;
1193
1755
  ctx.sseClients.delete(res);
1194
- clearInterval(interval);
1756
+ unsubscribe();
1195
1757
  };
1196
1758
  // The *response's* `close` is the one that means the stream is over: it fires when the connection
1197
1759
  // goes away or the response is finished. The request's `close` means "the request has been read"
@@ -1215,7 +1777,8 @@ export function handleWebRequest(req, res, url, ctx) {
1215
1777
  }
1216
1778
  // #1920 §5.2: a guest gets what the channel showed of it, and its own messages (the gate checked the scope).
1217
1779
  const guest = guestRequestOf(req);
1218
- const messages = !ctx.webChatHistory ? [] : guest ? guestHistory(ctx.webChatHistory, instance, limit, guest, guest.scope()) : ctx.webChatHistory.list(instance, limit);
1780
+ const messages = !ctx.webChatHistory ? [] : guest ? guestHistory(ctx.webChatHistory, instance, limit, guest, guest.scope())
1781
+ : visibleMessages(ctx, !!gatewayRequestContext(req), ctx.webChatHistory.list(instance, limit));
1219
1782
  json(res, 200, { messages, boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
1220
1783
  return true;
1221
1784
  }
@@ -1417,6 +1980,100 @@ export function handleWebRequest(req, res, url, ctx) {
1417
1980
  });
1418
1981
  return true;
1419
1982
  }
1983
+ // ── Fleet → Org chart → Office: the offices people made (web-offices.json, web-office-store.ts) ──
1984
+ // GET /ui/offices: { rev, offices, readable } for whoever may open the org chart (the public link too: it reads the
1985
+ // chart; a guest's routes do not include it). PUT /ui/offices { offices, rev? }: a person's change, as a theme's reset
1986
+ // or lock is — an admin session only, never the CLI token, never the public link (absent from its manifest, and
1987
+ // refused here too); asked again once the body is in. Every page hears `offices` on the stream and reads again.
1988
+ if (method === "GET" && path === "/ui/offices") {
1989
+ const store = ctx.officeStore;
1990
+ if (!store) {
1991
+ json(res, 404, { error: "Offices are not kept on this fleet" });
1992
+ return true;
1993
+ }
1994
+ json(res, 200, store.list());
1995
+ return true;
1996
+ }
1997
+ if (method === "PUT" && path === "/ui/offices") {
1998
+ const store = ctx.officeStore;
1999
+ if (!store) {
2000
+ json(res, 404, { error: "Offices are not kept on this fleet" });
2001
+ return true;
2002
+ }
2003
+ if (gatewayRequestContext(req)) {
2004
+ json(res, 403, { error: "offices_not_over_public_link" });
2005
+ return true;
2006
+ }
2007
+ // The person (an admin session's account), or the status that refuses the request.
2008
+ const who = () => {
2009
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
2010
+ if (verdict.kind !== "allow")
2011
+ return { status: 401 };
2012
+ return verdict.via === "session" && verdict.session?.tier === "admin" ? { person: verdict.session.owner ? webPersonKey(verdict.session.owner) : "admin-session" } : { status: 403 };
2013
+ };
2014
+ const refuse = (status) => json(res, status, { error: status === 401 ? WEB_TOKEN_INVALID_MESSAGE : "offices_need_an_admin_session" });
2015
+ const first = who();
2016
+ if ("status" in first) {
2017
+ refuse(first.status);
2018
+ return true;
2019
+ }
2020
+ (async () => {
2021
+ let body;
2022
+ try {
2023
+ body = await parseBody(req);
2024
+ }
2025
+ catch {
2026
+ json(res, 400, { error: "Invalid JSON" });
2027
+ return;
2028
+ }
2029
+ if (!permitWebContinuation(req, res, ctx))
2030
+ return;
2031
+ const now = who();
2032
+ if ("status" in now) {
2033
+ refuse(now.status);
2034
+ return;
2035
+ }
2036
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
2037
+ json(res, 400, { error: "offices required" });
2038
+ return;
2039
+ }
2040
+ const extra = Object.keys(body).find(k => k !== "offices" && k !== "rev");
2041
+ if (extra !== undefined) {
2042
+ json(res, 400, { error: "offices_invalid", detail: `unknown key "${extra}"` });
2043
+ return;
2044
+ }
2045
+ // Office review: the rev the page read is required — a save without one would overwrite another person's unseen.
2046
+ if (body.rev === undefined) {
2047
+ json(res, 428, { error: "offices_rev_required", detail: "send the rev you read (GET /ui/offices)" });
2048
+ return;
2049
+ }
2050
+ if (!Number.isSafeInteger(body.rev)) {
2051
+ json(res, 400, { error: "offices_invalid", detail: "rev: an integer" });
2052
+ return;
2053
+ }
2054
+ const limiter = ctx.officeWrites, allowed = limiter ? limiter.check(now.person) : { ok: true };
2055
+ if (!allowed.ok) {
2056
+ res.setHeader("Retry-After", String(allowed.retry_after_s));
2057
+ json(res, 429, { error: "offices_rate_limited", retry_after_s: allowed.retry_after_s });
2058
+ return;
2059
+ }
2060
+ const r = store.replace(body.offices, body.rev);
2061
+ if (!r.ok) {
2062
+ json(res, r.status, { error: r.error, ...(r.detail ? { detail: r.detail } : {}) });
2063
+ return;
2064
+ }
2065
+ limiter?.record(now.person);
2066
+ ctx.emitSseEvent("offices", { rev: r.rev });
2067
+ json(res, 200, { rev: r.rev, offices: r.offices });
2068
+ })().catch(err => {
2069
+ ctx.logger.error({ err: err.message }, "Saving offices failed");
2070
+ try {
2071
+ json(res, 500, { error: "Saving offices failed" });
2072
+ }
2073
+ catch { /* already answered */ }
2074
+ });
2075
+ return true;
2076
+ }
1420
2077
  // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
1421
2078
  const imageMatch = path.match(/^\/ui\/img\/(bot|avatar|emoji)\/([^/]+)$/);
1422
2079
  if (method === "GET" && imageMatch) {
@@ -1709,6 +2366,10 @@ export function handleWebRequest(req, res, url, ctx) {
1709
2366
  return;
1710
2367
  }
1711
2368
  const personal = [...parsed.patch.keys()].some(isOwnerEffectKey);
2369
+ if (personal && !canvasWorkerEnabled(ctx.fleetConfig?.web)) {
2370
+ json(res, 403, { error: "canvas_worker_experimental", message: CANVAS_WORKER_EXPERIMENTAL_MESSAGE });
2371
+ return;
2372
+ }
1712
2373
  if (personal && now.person === null) {
1713
2374
  json(res, 403, { error: "prefs_need_a_signed_in_person" });
1714
2375
  return;
@@ -1748,6 +2409,9 @@ export function handleWebRequest(req, res, url, ctx) {
1748
2409
  if (path === "/ui/owner-css.css" || path === "/ui/owner-css" || path.startsWith("/ui/owner-css/")) {
1749
2410
  return handleOwnerCss(req, res, url, ctx, path, method);
1750
2411
  }
2412
+ // #2308: the app icon (handleAppIcon: its state for a signed-in person; changing it, an admin's session only).
2413
+ if (path === "/ui/app-icon")
2414
+ return handleAppIcon(req, res, url, ctx, method);
1751
2415
  // #1629 r4-5: mods — a person's import, uninstall and export (handleMods: sessions only, never the public link).
1752
2416
  if (path === "/ui/mods" || path.startsWith("/ui/mods/"))
1753
2417
  return handleMods(req, res, url, ctx, path, method);
@@ -1757,6 +2421,9 @@ export function handleWebRequest(req, res, url, ctx) {
1757
2421
  // Canvas effects are absent from the public gateway manifest and independently refused above.
1758
2422
  if (path === "/ui/effect-trials" || path.startsWith("/ui/effect-trials/"))
1759
2423
  return handleEffectTrials(req, res, url, ctx, path, method);
2424
+ // #2233: backend sign-in — absent from the public manifest and the guest table, and refused here too.
2425
+ if (path === "/ui/backend-login" || path.startsWith("/ui/backend-login/"))
2426
+ return handleBackendLogin(req, res, url, ctx, path, method);
1760
2427
  if (path === "/ui/effects" || path.startsWith("/ui/effects/"))
1761
2428
  return handleEffects(req, res, url, ctx, path, method);
1762
2429
  // #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
@@ -1808,7 +2475,7 @@ export function handleWebRequest(req, res, url, ctx) {
1808
2475
  if (path === "/ui/authorization" && (method === "GET" || method === "POST")) {
1809
2476
  const judge = () => {
1810
2477
  const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
1811
- return { session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req), peer: classifyWebPeer(req) };
2478
+ return { session: v.kind === "allow" && v.via === "session" ? v.session : null, gateway: !!gatewayRequestContext(req), peer: webPeerOf(req) };
1812
2479
  };
1813
2480
  res.setHeader("Cache-Control", "no-store");
1814
2481
  if (method === "GET") {
@@ -1908,7 +2575,7 @@ export function handleWebRequest(req, res, url, ctx) {
1908
2575
  return true;
1909
2576
  }
1910
2577
  // Own keys only: "constructor" is in every object and names no instance.
1911
- const known = (ctx.fleetConfig ? Object.hasOwn(ctx.fleetConfig.instances, name) : false) || ctx.daemons.has(name);
2578
+ const known = registeredChatInstance(ctx, name) || ctx.daemons.has(name);
1912
2579
  if (!ctx.cancelInstance || !known) {
1913
2580
  json(res, 404, { error: `Instance not found: ${name}` });
1914
2581
  return true;
@@ -1958,7 +2625,7 @@ export function handleWebRequest(req, res, url, ctx) {
1958
2625
  (async () => {
1959
2626
  let held = false;
1960
2627
  try {
1961
- await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.startInstance(name, config, topicMode ?? false));
2628
+ await withSpawnHoldReply(async (message) => { held = true; json(res, 202, { held: true, message, instance: name }); }, () => ctx.startInstance(name, config, topicMode ?? false, "fleet-topic", true));
1962
2629
  ctx.emitSseEvent("status", ctx.getUiStatus());
1963
2630
  if (!held)
1964
2631
  json(res, 200, { started: name });
@@ -2141,6 +2808,11 @@ export function handleWebRequest(req, res, url, ctx) {
2141
2808
  json(res, 200, { tasks });
2142
2809
  return true;
2143
2810
  }
2811
+ // #2246: the public link writes no task — the old list's create and update are board writes now (see below).
2812
+ if (method === "POST" && (path === "/ui/tasks" || /^\/ui\/tasks\/[^/]+$/.test(path)) && gatewayRequestContext(req)) {
2813
+ json(res, 403, { error: "task_board_not_over_public_link", code: "forbidden", key: "not_over_public_link" });
2814
+ return true;
2815
+ }
2144
2816
  if (method === "POST" && path === "/ui/tasks") {
2145
2817
  if (!ctx.scheduler) {
2146
2818
  json(res, 500, { error: "Scheduler not initialized" });
@@ -2163,7 +2835,7 @@ export function handleWebRequest(req, res, url, ctx) {
2163
2835
  priority: parsed.priority,
2164
2836
  assignee: parsed.assignee,
2165
2837
  created_by: person.name, // #1827
2166
- });
2838
+ }, boardActor(req, url, ctx, person));
2167
2839
  json(res, 200, task);
2168
2840
  }
2169
2841
  catch (err) {
@@ -2172,15 +2844,183 @@ export function handleWebRequest(req, res, url, ctx) {
2172
2844
  })();
2173
2845
  return true;
2174
2846
  }
2847
+ // ── Task board (scheduler/task-board.ts) ──────────────
2848
+ // Reads are the session's; every write is the signed-in person's, through the same rules an instance's tools use.
2849
+ if (path === "/ui/tasks/board" || path === "/ui/tasks/search" || path === "/ui/tasks/work" || path.startsWith("/ui/tasks/cards") || path.startsWith("/ui/tasks/projects")) {
2850
+ const board = ctx.scheduler?.db.board;
2851
+ if (!board) {
2852
+ json(res, 503, { error: "The task board is not available", code: "unavailable", key: "unavailable" });
2853
+ return true;
2854
+ }
2855
+ // The temporary public link reads the board and changes nothing on it: confirming, sending back, cancelling and
2856
+ // assigning are the person's authority, which the public link never carries (docs/web-dashboard.md).
2857
+ const gateway = gatewayRequestContext(req);
2858
+ if (gateway && method !== "GET") {
2859
+ json(res, 403, { error: "task_board_not_over_public_link", code: "forbidden", key: "not_over_public_link" });
2860
+ return true;
2861
+ }
2862
+ // …nor shows the cards of instances kept out of sight (cross_instance_visibility: hidden).
2863
+ const { hiddenName, hidden } = boardHiding(ctx, !!gateway);
2864
+ // #2303 review: what a visible card shows of the cards around it follows the same rule — its children, its main
2865
+ // task (a search hit's too) — and so do the words and the history a hidden instance left on it.
2866
+ // …and a hidden instance's words on a visible card are not counted, named or found there (#2304 review).
2867
+ const scope = { leaveOutAuthors: gateway ? Object.keys(ctx.fleetConfig?.instances ?? {}).filter(hiddenName) : [] };
2868
+ const hiddenRef = (ref) => { const c = ref ? board.findCard(ref) : undefined; return !!c && hidden(c); };
2869
+ const mentionsHidden = (v) => (typeof v === "string" ? hiddenName(v) : !!v && typeof v === "object" && Object.values(v).some(mentionsHidden));
2870
+ const shown = (d) => !gateway ? d : {
2871
+ ...d,
2872
+ parent: d.card.parentId && hiddenRef(d.card.parentId) ? null : d.parent,
2873
+ children: d.children.filter((c) => !hidden(c)),
2874
+ comments: d.comments.filter((e) => !hiddenName(e.author)),
2875
+ logs: d.logs.filter((e) => !hiddenName(e.author)),
2876
+ history: d.history.filter((e) => !hiddenName(e.actor) && !mentionsHidden(e.data)),
2877
+ };
2878
+ const card = /^\/ui\/tasks\/cards\/([^/]+)(?:\/(move|reorder))?$/.exec(path);
2879
+ const project = /^\/ui\/tasks\/projects\/(\d+)$/.exec(path);
2880
+ try {
2881
+ const ref = card ? decodeURIComponent(card[1]) : "";
2882
+ if (method === "GET" && path === "/ui/tasks/board") {
2883
+ // A page that has this revision already is told so, not sent the board again.
2884
+ const rev = board.revision();
2885
+ if (url.searchParams.get("rev") === String(rev)) {
2886
+ json(res, 200, { rev, unchanged: true });
2887
+ return true;
2888
+ }
2889
+ const allDone = url.searchParams.get("done") === "all";
2890
+ // #2296: every open Tasks tab reads the board again after each change; on the dashboard they all get the same
2891
+ // board for a revision, so it is built once per (revision, done=all, minute) — the minute for the 14-day
2892
+ // window. The public link's reads are filtered per request (hidden instances) and are built each time.
2893
+ const key = gateway ? null : `${rev}:${allDone ? 1 : 0}:${Math.floor(Date.now() / 60_000)}`;
2894
+ const kept = key ? boardBodies.get(board) : undefined;
2895
+ if (kept && kept.key === key) {
2896
+ jsonText(res, 200, kept.body);
2897
+ return true;
2898
+ }
2899
+ const cards = board.boardCards(undefined, allDone).filter((c) => !hidden(c));
2900
+ // #2352: over the public link a hidden lead is not named (the project reads as having none).
2901
+ const projects = board.listProjects(true).map((p) => (p.leadInstance && hiddenName(p.leadInstance) ? { ...p, leadInstance: null } : p));
2902
+ // #2355 review: the names a project's lead may be — the fleet's instances (a Classic-only room is not one); over the
2903
+ // public link, not a hidden one.
2904
+ const leads = Object.keys(ctx.fleetConfig?.instances ?? {}).filter((n) => !hiddenName(n)).sort();
2905
+ const body = JSON.stringify({ rev, cards: board.views(cards, scope), projects, leads, stats: board.projectStats() });
2906
+ if (key)
2907
+ boardBodies.set(board, { key, body });
2908
+ jsonText(res, 200, body);
2909
+ return true;
2910
+ }
2911
+ if (method === "GET" && path === "/ui/tasks/work") {
2912
+ // #2246 step 3: each instance's work on the board — what its `task mine` reads (myWork) — for the board's
2913
+ // Instance list and an instance's Details page (`?instance=`): the fleet's instances and anyone else a live
2914
+ // card is assigned to. Over the public link, not those kept out of sight, nor a card that names one. Every
2915
+ // name's work is one workOf() read and the cards one views() read, whatever the number of instances (#2316
2916
+ // review); on the dashboard the answer is built once per board revision and the names asked for, and shared
2917
+ // by every open drawer (the public link's is filtered per request, as the board's).
2918
+ const only = url.searchParams.get("instance");
2919
+ const names = (only ? [only] : [...new Set([...Object.keys(ctx.fleetConfig?.instances ?? {}), ...board.liveAssignees()])]).filter((n) => !hiddenName(n));
2920
+ const key = gateway ? null : `${board.revision()}:${only === null ? "*" : `=${only}`}:${names.join("\u0000")}`;
2921
+ const kept = key ? workBodies.get(board) : undefined;
2922
+ if (kept && kept.key === key) {
2923
+ jsonText(res, 200, kept.body);
2924
+ return true;
2925
+ }
2926
+ const byName = board.workOf(names);
2927
+ const works = names.map((name) => ({ name, w: byName.get(name) }));
2928
+ const all = new Map();
2929
+ for (const { w } of works)
2930
+ for (const c of [...w.doing, ...w.blocked, ...w.review, ...w.ready, ...w.waiting.map((x) => x.card), ...w.mains.map((x) => x.card)])
2931
+ if (!hidden(c))
2932
+ all.set(c.uuid, c);
2933
+ const viewOf = new Map(board.views([...all.values()], scope).map((v) => [v.uuid, v]));
2934
+ const drawn = (cards) => cards.flatMap((c) => (viewOf.has(c.uuid) ? [viewOf.get(c.uuid)] : []));
2935
+ const codes = (cards) => cards.filter((x) => all.has(x.card.uuid)).map((x) => x.card.code);
2936
+ const body = JSON.stringify({ instances: works.map(({ name, w }) => ({
2937
+ name,
2938
+ doing: drawn(w.doing), blocked: drawn(w.blocked), review: drawn(w.review), ready: drawn(w.ready),
2939
+ waiting: drawn(w.waiting.map((x) => x.card)), mains: drawn(w.mains.map((x) => x.card)),
2940
+ sentBack: codes(w.sentBack), newComments: codes(w.newComments),
2941
+ })) });
2942
+ if (key)
2943
+ workBodies.set(board, { key, body });
2944
+ jsonText(res, 200, body);
2945
+ return true;
2946
+ }
2947
+ if (method === "GET" && path === "/ui/tasks/search") {
2948
+ const names = new Map(board.listProjects(true).map((p) => [p.id, p.name]));
2949
+ const hits = board.search(url.searchParams.get("q") ?? "", Number(url.searchParams.get("limit")) || 20, scope).filter((h) => !hidden(h.card));
2950
+ const views = board.views(hits.map((h) => h.card), scope);
2951
+ const results = hits.map(({ card: c, match }, i) => ({
2952
+ card: views[i],
2953
+ match,
2954
+ project: c.projectId === null ? null : names.get(c.projectId) ?? null,
2955
+ parentTitle: c.parent && !hiddenRef(c.parent) ? board.findCard(c.parent)?.title ?? null : null,
2956
+ }));
2957
+ json(res, 200, { results });
2958
+ return true;
2959
+ }
2960
+ if (method === "GET" && card && !card[2]) {
2961
+ const found = board.getCard(ref);
2962
+ if (hidden(found)) {
2963
+ json(res, 404, { error: "no such card", code: "not_found" });
2964
+ return true;
2965
+ }
2966
+ json(res, 200, shown(board.detail(ref, scope)));
2967
+ return true;
2968
+ }
2969
+ if (method === "POST" && path === "/ui/tasks/cards") {
2970
+ boardWrite(req, res, url, ctx, board, CardCreateSchema, (spec, person) => {
2971
+ const { cards, said } = board.createCards(person, { cards: [spec] });
2972
+ return { card: board.view(cards[0]), notes: said };
2973
+ });
2974
+ return true;
2975
+ }
2976
+ if (method === "POST" && card) {
2977
+ if (card[2] === "move") {
2978
+ boardWrite(req, res, url, ctx, board, CardMoveSchema, ({ to, ...move }, person) => {
2979
+ const { card: moved, said } = board.moveCard(person, ref, to, move);
2980
+ return { card: board.view(moved), notes: said };
2981
+ });
2982
+ }
2983
+ else if (card[2] === "reorder") {
2984
+ boardWrite(req, res, url, ctx, board, CardReorderSchema, ({ after, before }, person) => ({ card: board.view(board.reorder(person, ref, after ?? null, before ?? null)) }));
2985
+ }
2986
+ else {
2987
+ boardWrite(req, res, url, ctx, board, CardPatchSchema, (patch, person) => {
2988
+ const { card: updated, said } = board.updateCard(person, ref, patch);
2989
+ return { card: board.view(updated), notes: said };
2990
+ });
2991
+ }
2992
+ return true;
2993
+ }
2994
+ if (method === "POST" && path === "/ui/tasks/projects") {
2995
+ boardWrite(req, res, url, ctx, board, ProjectCreateSchema, (input, person) => { checkLeadIsInstance(ctx, input.leadInstance); return { project: board.createProject(person, input) }; });
2996
+ return true;
2997
+ }
2998
+ if (method === "POST" && project) {
2999
+ boardWrite(req, res, url, ctx, board, ProjectPatchSchema, (patch, person) => { checkLeadIsInstance(ctx, patch.leadInstance); return { project: board.updateProject(person, Number(project[1]), patch) }; });
3000
+ return true;
3001
+ }
3002
+ }
3003
+ catch (err) {
3004
+ // A malformed %-escape in the card's address is the caller's mistake, not the server's.
3005
+ if (err instanceof URIError) {
3006
+ json(res, 400, { error: "bad card reference", code: "invalid" });
3007
+ return true;
3008
+ }
3009
+ boardError(res, err);
3010
+ return true;
3011
+ }
3012
+ json(res, 404, { error: "Not found" });
3013
+ return true;
3014
+ }
2175
3015
  const taskMatch = path.match(/^\/ui\/tasks\/(.+)$/);
2176
3016
  if (method === "POST" && taskMatch) {
2177
3017
  if (!ctx.scheduler) {
2178
3018
  json(res, 500, { error: "Scheduler not initialized" });
2179
3019
  return true;
2180
3020
  }
2181
- const id = decodeURIComponent(taskMatch[1]);
2182
3021
  (async () => {
2183
3022
  try {
3023
+ const id = decodeURIComponent(taskMatch[1]);
2184
3024
  const body = await parseBody(req);
2185
3025
  if (!permitWebContinuation(req, res, ctx))
2186
3026
  return;
@@ -2188,19 +3028,22 @@ export function handleWebRequest(req, res, url, ctx) {
2188
3028
  if (!parsed)
2189
3029
  return;
2190
3030
  let result;
3031
+ // #1827: the person, who is also the default assignee of a claim; the task board's review gate needs to know
3032
+ // a person (not an instance) made the move.
3033
+ const person = webPersonOr401(req, res, url, ctx);
3034
+ if (!person)
3035
+ return;
3036
+ const by = boardActor(req, url, ctx, person);
2191
3037
  if (parsed.action === "claim") {
2192
- const person = parsed.assignee ? null : webPersonOr401(req, res, url, ctx); // #1827: the default is the person
2193
- if (!parsed.assignee && !person)
2194
- return;
2195
- result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name);
3038
+ result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name, by);
2196
3039
  }
2197
3040
  else if (parsed.action === "complete") {
2198
- result = ctx.scheduler.db.completeTask(id, parsed.result);
3041
+ result = ctx.scheduler.db.completeTask(id, parsed.result, by);
2199
3042
  }
2200
3043
  else {
2201
3044
  // Strip action before passing remaining fields to updateTask
2202
3045
  const { action: _a, ...rest } = parsed;
2203
- result = ctx.scheduler.db.updateTask(id, rest);
3046
+ result = ctx.scheduler.db.updateTask(id, rest, by);
2204
3047
  }
2205
3048
  json(res, 200, result);
2206
3049
  }
@@ -2328,6 +3171,90 @@ export function handleWebRequest(req, res, url, ctx) {
2328
3171
  }
2329
3172
  return true;
2330
3173
  }
3174
+ // #2293: global search — an admin's only: the header token or an admin session. Never a guest (not in GUEST_ROUTES).
3175
+ // #2478 (the user's request): over the public link too, unless web.search.public_link is false. Asked on arrival and
3176
+ // again before the answer is sent.
3177
+ if (method === "GET" && (path === "/ui/search" || path === "/ui/search/context")) {
3178
+ const refusal = () => {
3179
+ if (gatewayRequestContext(req) && ctx.fleetConfig?.web?.search?.public_link === false)
3180
+ return [404, "search_not_over_public_link"];
3181
+ if (guestRequestOf(req))
3182
+ return [403, "guest_forbidden"];
3183
+ if (ctx.fleetConfig?.web?.search?.enabled === false || !ctx.searchIndex || ctx.searchIndex.status() === "unavailable")
3184
+ return [404, "search_unavailable"];
3185
+ return null;
3186
+ };
3187
+ const first = refusal();
3188
+ if (first) {
3189
+ json(res, first[0], { error: first[1] });
3190
+ return true;
3191
+ }
3192
+ const admitted = adminAdmissionOf(req, url, ctx);
3193
+ if (!admitted) {
3194
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
3195
+ return true;
3196
+ }
3197
+ const index = ctx.searchIndex;
3198
+ // cross_instance_visibility "hidden" (fleet-traffic's rule): a message to or from such a name is not found.
3199
+ const visibility = () => ctx.searchVisibility?.() ?? { hidden: [] };
3200
+ const shown = (doc) => xmsgShown(doc, visibility());
3201
+ const finish = (status, body) => {
3202
+ const now = refusal();
3203
+ if (now)
3204
+ return json(res, now[0], { error: now[1] });
3205
+ if (adminAdmissionOf(req, url, ctx) !== admitted)
3206
+ return json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
3207
+ json(res, status, body);
3208
+ };
3209
+ const failed = (reason) => finish(reason === "timeout" || reason === "busy" ? 503 : 404, { error: `search_${reason}` });
3210
+ if (path === "/ui/search/context") {
3211
+ const key = url.searchParams.get("key") ?? "";
3212
+ if (!key || key.length > 300) {
3213
+ json(res, 400, { error: "key required" });
3214
+ return true;
3215
+ }
3216
+ void index.context(key).then(r => {
3217
+ if (!r.ok)
3218
+ return failed(r.reason);
3219
+ const doc = r.value?.doc;
3220
+ // A message to or from an instance that is hidden now is not shown, as in the search itself.
3221
+ if (!doc || !shown(doc))
3222
+ return finish(404, { error: "not_found" });
3223
+ finish(200, { ...r.value, building: r.building });
3224
+ }, () => failed("unavailable"));
3225
+ return true;
3226
+ }
3227
+ const parsed = parseSearchQuery(url, ctx);
3228
+ if ("error" in parsed) {
3229
+ json(res, 400, { error: parsed.error });
3230
+ return true;
3231
+ }
3232
+ if (parsed.mine) {
3233
+ const person = webPersonOf(req, url, ctx);
3234
+ if (!person) {
3235
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
3236
+ return true;
3237
+ }
3238
+ parsed.filters.mine = [person.voterKey];
3239
+ }
3240
+ const v = visibility();
3241
+ if ("hidden" in v)
3242
+ parsed.filters.hidden = v.hidden;
3243
+ else
3244
+ parsed.filters.xmsgAmong = v.among;
3245
+ void index.query(parsed.filters).then(r => {
3246
+ if (!r.ok)
3247
+ return failed(r.reason);
3248
+ // A chat row still in the history opens in place (#m=); one that has left it opens as the index's copy.
3249
+ const history = ctx.webChatHistory;
3250
+ // The visibility as it is now (#2295 review): a name hidden while the query ran is left out too.
3251
+ const now = visibility();
3252
+ const hits = r.value.hits.filter(h => xmsgShown(h, now)).map(h => h.source !== "chat" ? h
3253
+ : { ...h, live: !!history?.find(h.instance, String(h.ref.boot ?? ""), Number(h.ref.id)) });
3254
+ finish(200, { hits, limited: r.value.limited, building: r.building });
3255
+ }, () => failed("unavailable"));
3256
+ return true;
3257
+ }
2331
3258
  // #2122: the usage report. Signed-in sessions by default (guests never: not in GUEST_ROUTES); hidden with the rest of
2332
3259
  // usage by web.usage_panel: false; over the public link only when web.usage_report_on_public_link is true. Answered
2333
3260
  // from the ledgers and rollups as they are; a catch-up runs behind. The page's CSV is built from this same answer.
@@ -2570,6 +3497,17 @@ export function handleWebRequest(req, res, url, ctx) {
2570
3497
  json(res, 404, { error: "not found" });
2571
3498
  return true;
2572
3499
  }
3500
+ /**
3501
+ * #2272: the line a web reply's channel copy opens with — the quoted sender and first words when that message reached
3502
+ * the channel (its members saw it); a web-only one is only named, never copied out to the channel.
3503
+ */
3504
+ export function webReplyEchoLine(quoted) {
3505
+ if (!quoted.channel)
3506
+ return t("web.echo_reply_web_only");
3507
+ const ref = webChatReplyRef(quoted);
3508
+ const words = ref.text.slice(0, 120) || (ref.media === "photo" ? "[photo]" : ref.media ? "[file]" : "");
3509
+ return t("web.echo_reply", quoted.sender.slice(0, 80), words);
3510
+ }
2573
3511
  /** Keep the echo in a single platform message, including its attachment names. */
2574
3512
  function webEchoPreview(message, names) {
2575
3513
  const text = wellFormed(message.slice(0, 500));
@@ -2578,7 +3516,36 @@ function webEchoPreview(message, names) {
2578
3516
  const truncated = message.length > 500 || preview.length > 1400;
2579
3517
  return wellFormed(preview.slice(0, 1400)) + (truncated ? ` … (${t("web.echo_full_text")})` : "");
2580
3518
  }
3519
+ /** Registration and liveness are different: a paused CLI may have neither daemon nor IPC. */
3520
+ function registeredChatInstance(ctx, name) {
3521
+ return (!!ctx.fleetConfig && Object.hasOwn(ctx.fleetConfig.instances, name)) || ctx.isClassicInstance?.(name) === true;
3522
+ }
3523
+ function uploadRecipient(ctx, name) {
3524
+ return registeredChatInstance(ctx, name)
3525
+ && (ctx.instanceIpcClients.has(name) || ctx.getInstanceStatus?.(name) === "paused");
3526
+ }
3527
+ /** Closed, path-free explanations; arbitrary launch errors stay in the host log. */
3528
+ function webDeliveryFailure(err) {
3529
+ const message = err instanceof Error ? err.message : "";
3530
+ if (/No warm slot is free/.test(message))
3531
+ return { reason: "wake_capacity", error: "No slot is free to wake this instance. Retry when another instance is idle." };
3532
+ if (/login|log in|sign in|\bauth(?:entication|orization)?\b|credential|api.?key/i.test(message))
3533
+ return { reason: "wake_credentials", error: "The instance could not sign in. Check its account on the host, then retry." };
3534
+ if (/timed? out|timeout/i.test(message))
3535
+ return { reason: "wake_timeout", error: "The instance did not become ready in time. Retry, or check its status on the host." };
3536
+ if (/Cannot wake stopped|no longer configured/.test(message))
3537
+ return { reason: "instance_stopped", error: "This instance was stopped or removed. Start it on the host before retrying." };
3538
+ return { reason: "delivery_unavailable", error: "The instance could not receive this message. Check its status on the host, then retry." };
3539
+ }
2581
3540
  /** Handle POST /ui/send — extracted for readability. */
3541
+ /** Each web server's own memory of the sends it accepted by clientId (web-send-dedupe.ts). */
3542
+ const webSendDedupes = new WeakMap();
3543
+ function webSendDedupeOf(ctx) {
3544
+ let d = webSendDedupes.get(ctx);
3545
+ if (!d)
3546
+ webSendDedupes.set(ctx, d = new WebSendDedupe());
3547
+ return d;
3548
+ }
2582
3549
  function handleSendMessage(req, res, ctx) {
2583
3550
  const arrival = webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx); // #1939 §3: again after the body
2584
3551
  let body = "";
@@ -2595,6 +3562,9 @@ function handleSendMessage(req, res, ctx) {
2595
3562
  req.on("end", async () => {
2596
3563
  if (!permitWebContinuation(req, res, ctx))
2597
3564
  return;
3565
+ // A claimed clientId is settled however this ends: with the messageId once accepted, else forgotten.
3566
+ let claim;
3567
+ let acceptedId = null;
2598
3568
  try {
2599
3569
  const raw = JSON.parse(body);
2600
3570
  const parsed = SendMessageSchema.safeParse(raw);
@@ -2603,7 +3573,7 @@ function handleSendMessage(req, res, ctx) {
2603
3573
  json(res, 400, { error: `${issue.path.join(".") || "body"}: ${issue.message}` });
2604
3574
  return;
2605
3575
  }
2606
- const { instance, message: typed, attachments = [] } = parsed.data;
3576
+ const { instance, message: typed, attachments = [], replyTo, clientId } = parsed.data;
2607
3577
  // #1920 step 2b: a guest writes only to an in-scope instance, while guests may chat, and never a command.
2608
3578
  const guest = guestRequestOf(req);
2609
3579
  const refused = guestChatRefusal(guest, instance);
@@ -2615,11 +3585,48 @@ function handleSendMessage(req, res, ctx) {
2615
3585
  json(res, 403, { error: "guest_no_commands" });
2616
3586
  return;
2617
3587
  }
2618
- const ipc = ctx.instanceIpcClients.get(instance);
2619
- if (!ipc) {
3588
+ // #2272: a reply names a message this chat still keeps — and, for a guest, one it may see. What it quotes is read
3589
+ // from that row here, never taken from the page.
3590
+ let quoted;
3591
+ if (replyTo) {
3592
+ quoted = ctx.webChatHistory?.find(instance, replyTo.boot, replyTo.id);
3593
+ if (!quoted || quoted.effectTrial || (guest && !guestSeesMessage(quoted, guest, guest.scope()))) {
3594
+ json(res, 409, { error: t("web.reply_gone"), reason: "reply_gone" });
3595
+ return;
3596
+ }
3597
+ }
3598
+ const registered = registeredChatInstance(ctx, instance);
3599
+ if (!registered && !ctx.instanceIpcClients.has(instance)) {
2620
3600
  json(res, 404, { error: `Instance not found: ${instance}` });
2621
3601
  return;
2622
3602
  }
3603
+ // #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
3604
+ // body is in: a session revoked meanwhile sends nothing (not as the operator).
3605
+ const person = webPersonOf(req, new URL(req.url ?? "/", "http://localhost"), ctx);
3606
+ if (!person) {
3607
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
3608
+ return;
3609
+ }
3610
+ // #2438 follow-up: the page's Retry of a send that already went (its answer and the live copy lost) is that send:
3611
+ // answered with its messageId, delivered once. One still going is waited for; one that failed is sent anew.
3612
+ if (clientId) {
3613
+ const key = WebSendDedupe.key(person.key, instance, clientId);
3614
+ for (;;) {
3615
+ const seen = webSendDedupeOf(ctx).claim(key);
3616
+ if (seen.kind === "going") {
3617
+ await seen.settled;
3618
+ if (res.destroyed)
3619
+ return;
3620
+ continue;
3621
+ }
3622
+ if (seen.kind === "accepted") {
3623
+ json(res, 200, { sent: true, messageId: seen.messageId, repeated: true });
3624
+ return;
3625
+ }
3626
+ claim = seen;
3627
+ break;
3628
+ }
3629
+ }
2623
3630
  let files = [];
2624
3631
  if (attachments.length > 0) {
2625
3632
  const taken = ctx.webFiles?.takeForMessage(instance, attachments);
@@ -2639,15 +3646,13 @@ function handleSendMessage(req, res, ctx) {
2639
3646
  json(res, 403, { error: t("web.raw_public_refused") });
2640
3647
  return;
2641
3648
  }
3649
+ // Recheck the same admission all the way through a wake, before the facade's actual IPC handoff.
3650
+ const requestUrl = new URL(req.url ?? "/", "http://localhost");
3651
+ const admission = admissionOf(req, requestUrl, ctx);
3652
+ const stillCurrent = () => !res.destroyed && admission !== null && admissionOf(req, requestUrl, ctx) === admission
3653
+ && (!guest || (guest.current() && !guestChatRefusal(guest, instance)))
3654
+ && (registered ? registeredChatInstance(ctx, instance) : ctx.instanceIpcClients.has(instance));
2642
3655
  const message = typed;
2643
- // #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
2644
- // body is in: a session revoked meanwhile sends nothing (not as the operator).
2645
- const person = webPersonOf(req, new URL(req.url ?? "/", "http://localhost"), ctx);
2646
- if (!person) {
2647
- ctx.webFiles?.release(files);
2648
- json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
2649
- return;
2650
- }
2651
3656
  // #1939 S1: what the receipt records about who authorized it (observe only: agents still read web as recognition).
2652
3657
  const { authority } = confirmAfterBody(arrival, webAuthorityOf(req, new URL(req.url ?? "/", "http://localhost"), ctx, instance));
2653
3658
  const ts = new Date().toISOString();
@@ -2662,15 +3667,23 @@ function handleSendMessage(req, res, ctx) {
2662
3667
  const topicId = ctx.fleetConfig?.instances[instance]?.topic_id;
2663
3668
  const canEcho = ctx.fleetConfig?.web?.echo_to_channel !== false
2664
3669
  && !ctx.isClassicInstance?.(instance) && syncAdapter && groupId && topicId != null;
2665
- // Add no runtime credentials or local attachment paths to the display copy.
2666
- const preview = webEchoPreview(message, files.map(f => f.name));
3670
+ // Add no runtime credentials or local attachment paths to the display copy. A reply opens with the line it
3671
+ // answers (#2272) — quoted only if that message reached the channel; a web-only one is named, never copied out.
3672
+ const quoteLine = quoted ? webReplyEchoLine(quoted) : "";
3673
+ const plainPreview = webEchoPreview(message, files.map(f => f.name));
3674
+ const preview = (quoteLine ? `${quoteLine}\n` : "") + plainPreview;
3675
+ // #2281: where the quoted message is on a platform. An echo to the same place is a native reply to it there, and
3676
+ // then needs no quoted line; anywhere else (or a message with no known post) keeps the line, as before.
3677
+ const quotedPosts = quoted ? ctx.webChatHistory?.postsOf(quoted) ?? [] : [];
3678
+ const echoText = (p) => formatWebChannelEcho(person.name, p, t("web.echo_full_text"), { guest: !!guest });
2667
3679
  // ClassicBot echo (#1320 part B): same ordering lane, each room's setting (#2054: on unless it says false, or
2668
3680
  // the fleet-wide switch is off for a room that is unset) resolved at send time inside sendClassicWebEcho.
2669
3681
  const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
2670
3682
  const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
2671
- const posted = await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), person.name);
3683
+ const copies = [];
3684
+ const posted = await ctx.sendClassicWebEcho(instance, echoText(preview), person.name, copies, quotedPosts.length ? { posts: quotedPosts, text: echoText(plainPreview) } : undefined);
2672
3685
  if (posted > 0)
2673
- ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the room saw it
3686
+ ctx.webMessageInChannel?.(instance, messageId, copies); // #1920 §5.2: the room saw it (#2305: its copies)
2674
3687
  }) : undefined;
2675
3688
  const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
2676
3689
  // A replacement adapter or edited binding is not the route we reserved.
@@ -2678,15 +3691,20 @@ function handleSendMessage(req, res, ctx) {
2678
3691
  || (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
2679
3692
  || ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
2680
3693
  return;
2681
- 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: [] } });
2682
- ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the topic saw it
3694
+ const native = quotedPosts.find(p => p.adapterId === syncAdapter.id && p.chatId === String(groupId) && (p.threadId ?? "") === String(topicId));
3695
+ const sent = await syncAdapter.sendText(String(groupId), echoText(native ? plainPreview : preview), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] }, ...(native ? { replyTo: native.messageId } : {}) });
3696
+ const copyId = sent && typeof sent === "object" && typeof sent.messageId === "string" ? sent.messageId : "";
3697
+ // #1920 §5.2: the topic saw it. #2305: and where it is, for a reaction to the web message (#2281: and a reply).
3698
+ const reacting = syncAdapter;
3699
+ ctx.webMessageInChannel?.(instance, messageId, copyId
3700
+ ? [{ adapter: reacting, adapterId: syncAdapter.id, chatId: String(groupId), threadId: String(topicId), messageId: copyId }] : []);
2683
3701
  return sent;
2684
3702
  }) : undefined;
2685
3703
  let delivered;
2686
3704
  try {
2687
3705
  const deliver = (name, payload) => ctx.deliverHumanToInstance
2688
- ? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed)
2689
- : ctx.deliverToInstance(name, payload);
3706
+ ? ctx.deliverHumanToInstance(name, payload, webHumanOrigin(false, authority), typed, { stillCurrent })
3707
+ : ctx.deliverToInstance(name, payload, { stillCurrent });
2690
3708
  delivered = await deliver(instance, {
2691
3709
  type: "fleet_inbound",
2692
3710
  content: delivery.text,
@@ -2701,6 +3719,8 @@ function handleSendMessage(req, res, ctx) {
2701
3719
  ...(guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]" (authority none)
2702
3720
  adapter_id: syncAdapter?.id,
2703
3721
  ...delivery.meta,
3722
+ // #2272: `(reply_to: "…")`, as a Discord or Telegram reply gives the agent.
3723
+ ...(quoted ? { reply_to_text: webChatReplyText(quoted) } : {}),
2704
3724
  },
2705
3725
  });
2706
3726
  }
@@ -2712,7 +3732,7 @@ function handleSendMessage(req, res, ctx) {
2712
3732
  settleClassicEcho?.(false);
2713
3733
  ctx.webFiles?.release(files); // not delivered: the same ids can be sent again
2714
3734
  ctx.logger.error({ err, instance }, "Web message delivery failed");
2715
- json(res, 503, { error: "Instance delivery failed" });
3735
+ json(res, 503, webDeliveryFailure(err));
2716
3736
  return;
2717
3737
  }
2718
3738
  if (delivered === false) {
@@ -2720,9 +3740,12 @@ function handleSendMessage(req, res, ctx) {
2720
3740
  settleClassicEcho?.(false);
2721
3741
  ctx.webFiles?.release(files);
2722
3742
  ctx.logger.error({ instance }, "Web message not delivered (the instance went away or restarted)");
2723
- json(res, 503, { error: "Instance delivery failed" });
3743
+ json(res, 503, { reason: "delivery_changed", error: "The session or instance changed before this message was sent. Sign in again if needed, then retry." });
2724
3744
  return;
2725
3745
  }
3746
+ // The agent has it: from here on a repeat of this clientId is this send, whatever fails after (the echo, the
3747
+ // stream's copy) — a Retry then gets this messageId, never a second delivery.
3748
+ acceptedId = messageId;
2726
3749
  settleEcho?.(true);
2727
3750
  settleClassicEcho?.(true);
2728
3751
  // #1666: the same cancel/progress bubble a platform inbound starts, in the topic the echo went to — a second turn
@@ -2736,11 +3759,20 @@ function handleSendMessage(req, res, ctx) {
2736
3759
  ctx.lastInboundUser.set(instance, person.name);
2737
3760
  ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
2738
3761
  // #1920: the account that wrote it (a guest sees its own); channel-visible once its copy was sent (above).
2739
- ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId, ...(person.bound ? { from: person.voterKey } : {}) });
3762
+ ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId, ...(person.bound ? { from: person.voterKey } : {}),
3763
+ ...(quoted ? { replyTo: webChatReplyRef(quoted) } : {}), ...(clientId ? { clientId } : {}) });
2740
3764
  json(res, 200, { sent: true, messageId });
2741
3765
  }
2742
3766
  catch {
2743
- json(res, 400, { error: "Invalid JSON" });
3767
+ // A failure after the agent has it is no failed send: the page is told it went (a Retry would only find it).
3768
+ if (res.headersSent) { /* answered already: the failure was in or after the answer */ }
3769
+ else if (acceptedId)
3770
+ json(res, 200, { sent: true, messageId: acceptedId });
3771
+ else
3772
+ json(res, 400, { error: "Invalid JSON" });
3773
+ }
3774
+ finally {
3775
+ claim?.settle(acceptedId);
2744
3776
  }
2745
3777
  });
2746
3778
  }
@@ -2767,7 +3799,7 @@ function handleUpload(req, res, url, ctx) {
2767
3799
  json(res, guestRefused.status, { error: guestRefused.error });
2768
3800
  return;
2769
3801
  }
2770
- if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !ctx.instanceIpcClients.has(instance)) {
3802
+ if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !uploadRecipient(ctx, instance)) {
2771
3803
  json(res, 404, { error: `Instance not found: ${instance}` });
2772
3804
  return;
2773
3805
  }
@@ -2830,11 +3862,9 @@ function handleUpload(req, res, url, ctx) {
2830
3862
  req.on("error", () => drop()); // the client went away
2831
3863
  req.on("close", () => { if (!over && req.complete === false)
2832
3864
  drop(); });
2833
- // The instance is checked again now the whole body is here: it may have been deleted (and its files
2834
- // forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
2835
- // Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
2836
- // in fleet.yaml, or as a ClassicBot room in classicBot.yaml. Asked when the body ends and again just before storing.
2837
- const deliverable = () => ctx.instanceIpcClients.has(instance) && (!!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true);
3865
+ // Recheck registration and delivery readiness after both waits. A paused recipient may have no IPC;
3866
+ // staging a file never wakes it. A stopped or deleted recipient must not regain an upload id.
3867
+ const deliverable = () => uploadRecipient(ctx, instance);
2838
3868
  const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
2839
3869
  req.on("end", () => {
2840
3870
  if (over)
@@ -3026,6 +4056,7 @@ function shellBodyTag(req, ctx, mode, viewer) {
3026
4056
  + ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false || viewer?.guest ? "0" : "1"}"`
3027
4057
  // #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
3028
4058
  + ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
4059
+ + ` data-canvas-worker="${canvasWorkerEnabled(ctx.fleetConfig?.web) ? "1" : "0"}"`
3029
4060
  // #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
3030
4061
  // checks before sending a file, and the numbers its words say. The server checks again on every upload.
3031
4062
  + ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
@@ -3035,6 +4066,8 @@ function shellBodyTag(req, ctx, mode, viewer) {
3035
4066
  + ` data-owner-css-state="${ownerCssState}"${ownerCss ? ` data-owner-css="${attr(ownerCss)}"` : ""}`
3036
4067
  // #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.
3037
4068
  + `${gatewayRequestContext(req) ? ' data-public-link="1"' : ""}`
4069
+ // #2478: search is offered over the public link unless web.search.public_link is false (the page then shows no entry).
4070
+ + `${gatewayRequestContext(req) && ctx.fleetConfig?.web?.search?.public_link === false ? ' data-search-link="0"' : ""}`
3038
4071
  // #1920 phase 3: a guest's page — the shell shows only what a guest may use, and who it is signed in as.
3039
4072
  + `${mode === "full" && viewer?.guest && viewer.owner ? guestBodyAttrs(ctx, viewer.owner, attr) : ""}>`;
3040
4073
  }
@@ -3057,7 +4090,7 @@ export function serveAppShell(req, res, ctx, mode) {
3057
4090
  const effectOrigin = gatewayRequestContext(req)?.expectedOrigin ?? dashboardOriginFor(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req));
3058
4091
  const effectFrame = shellEffect(req, ctx, mode) && effectOrigin ? `${effectOrigin}/effect-bootstrap` : null;
3059
4092
  const frameSrc = [previewFrames, effectFrame].filter(Boolean).join(" ");
3060
- sendPanelHtml(res, html.replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
4093
+ sendPanelHtml(res, withAppIconLinks(html).replace('<html lang="en">', shellHtmlTag(ctx, mode, viewer)).replace("<body>", shellBodyTag(req, ctx, mode, viewer)), 200, {}, { ...(frameSrc ? { frameSrc } : {}), mediaSrc });
3061
4094
  }
3062
4095
  catch {
3063
4096
  json(res, 500, { error: "app.html not found" });