@songsid/agend 2.1.12 → 2.2.0-alpha.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 (358) hide show
  1. package/README.md +2 -2
  2. package/README.zh-TW.md +2 -2
  3. package/dist/auth/one-time-code.d.ts +19 -0
  4. package/dist/auth/one-time-code.js +49 -0
  5. package/dist/auth/one-time-code.js.map +1 -0
  6. package/dist/auth-api.d.ts +52 -0
  7. package/dist/auth-api.js +346 -0
  8. package/dist/auth-api.js.map +1 -0
  9. package/dist/backend/claude-code.d.ts +4 -0
  10. package/dist/backend/claude-code.js +97 -0
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +59 -30
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/kiro-agent.d.ts +66 -0
  15. package/dist/backend/kiro-agent.js +302 -0
  16. package/dist/backend/kiro-agent.js.map +1 -0
  17. package/dist/backend/kiro-identity.d.ts +80 -0
  18. package/dist/backend/kiro-identity.js +537 -0
  19. package/dist/backend/kiro-identity.js.map +1 -0
  20. package/dist/backend/kiro-v2-store-worker.d.ts +1 -0
  21. package/dist/backend/kiro-v2-store-worker.js +87 -0
  22. package/dist/backend/kiro-v2-store-worker.js.map +1 -0
  23. package/dist/backend/kiro-v2-store.d.ts +54 -0
  24. package/dist/backend/kiro-v2-store.js +131 -0
  25. package/dist/backend/kiro-v2-store.js.map +1 -0
  26. package/dist/backend/kiro-v3-identity.d.ts +5 -0
  27. package/dist/backend/kiro-v3-identity.js +22 -0
  28. package/dist/backend/kiro-v3-identity.js.map +1 -1
  29. package/dist/backend/kiro.d.ts +83 -7
  30. package/dist/backend/kiro.js +418 -91
  31. package/dist/backend/kiro.js.map +1 -1
  32. package/dist/backend/types.d.ts +45 -3
  33. package/dist/backend/types.js.map +1 -1
  34. package/dist/channel/adapters/discord.d.ts +1 -0
  35. package/dist/channel/adapters/discord.js +29 -10
  36. package/dist/channel/adapters/discord.js.map +1 -1
  37. package/dist/channel/adapters/telegram.d.ts +1 -0
  38. package/dist/channel/adapters/telegram.js +13 -1
  39. package/dist/channel/adapters/telegram.js.map +1 -1
  40. package/dist/channel/types.d.ts +9 -1
  41. package/dist/classic-channel-manager.d.ts +18 -10
  42. package/dist/classic-channel-manager.js +45 -18
  43. package/dist/classic-channel-manager.js.map +1 -1
  44. package/dist/cli.js +225 -127
  45. package/dist/cli.js.map +1 -1
  46. package/dist/command-table.d.ts +5 -3
  47. package/dist/command-table.js +29 -27
  48. package/dist/command-table.js.map +1 -1
  49. package/dist/config-validator.js +46 -0
  50. package/dist/config-validator.js.map +1 -1
  51. package/dist/config.js +12 -0
  52. package/dist/config.js.map +1 -1
  53. package/dist/connection-secrets.d.ts +6 -0
  54. package/dist/connection-secrets.js +4 -0
  55. package/dist/connection-secrets.js.map +1 -1
  56. package/dist/daemon.d.ts +89 -4
  57. package/dist/daemon.js +661 -77
  58. package/dist/daemon.js.map +1 -1
  59. package/dist/darwin-memory.d.ts +6 -1
  60. package/dist/darwin-memory.js +17 -6
  61. package/dist/darwin-memory.js.map +1 -1
  62. package/dist/delivery-consumed-watch.d.ts +85 -0
  63. package/dist/delivery-consumed-watch.js +244 -0
  64. package/dist/delivery-consumed-watch.js.map +1 -0
  65. package/dist/delivery-outbox.d.ts +61 -0
  66. package/dist/delivery-outbox.js +120 -2
  67. package/dist/delivery-outbox.js.map +1 -1
  68. package/dist/delivery-reconciliation.d.ts +14 -5
  69. package/dist/delivery-reconciliation.js +27 -4
  70. package/dist/delivery-reconciliation.js.map +1 -1
  71. package/dist/delivery-reconciliation.test.js +35 -1
  72. package/dist/delivery-reconciliation.test.js.map +1 -1
  73. package/dist/event-loop-watch.d.ts +28 -0
  74. package/dist/event-loop-watch.js +75 -3
  75. package/dist/event-loop-watch.js.map +1 -1
  76. package/dist/fleet-context.d.ts +22 -2
  77. package/dist/fleet-level-config.d.ts +2 -2
  78. package/dist/fleet-level-config.js +6 -1
  79. package/dist/fleet-level-config.js.map +1 -1
  80. package/dist/fleet-manager.d.ts +293 -12
  81. package/dist/fleet-manager.js +2364 -449
  82. package/dist/fleet-manager.js.map +1 -1
  83. package/dist/host-memory.d.ts +4 -0
  84. package/dist/host-memory.js.map +1 -1
  85. package/dist/instance-config-impact.js +5 -0
  86. package/dist/instance-config-impact.js.map +1 -1
  87. package/dist/instance-lifecycle.d.ts +10 -5
  88. package/dist/instance-lifecycle.js +162 -53
  89. package/dist/instance-lifecycle.js.map +1 -1
  90. package/dist/locale.js +150 -16
  91. package/dist/locale.js.map +1 -1
  92. package/dist/login-controller.d.ts +2 -0
  93. package/dist/login-controller.js +17 -5
  94. package/dist/login-controller.js.map +1 -1
  95. package/dist/memory-pressure.d.ts +7 -8
  96. package/dist/memory-pressure.js +76 -15
  97. package/dist/memory-pressure.js.map +1 -1
  98. package/dist/monotonic-budget.d.ts +2 -0
  99. package/dist/monotonic-budget.js +28 -0
  100. package/dist/monotonic-budget.js.map +1 -0
  101. package/dist/needs-you-hub.d.ts +114 -0
  102. package/dist/needs-you-hub.js +370 -0
  103. package/dist/needs-you-hub.js.map +1 -0
  104. package/dist/needs-you-live.d.ts +116 -0
  105. package/dist/needs-you-live.js +228 -0
  106. package/dist/needs-you-live.js.map +1 -0
  107. package/dist/needs-you.d.ts +132 -0
  108. package/dist/needs-you.js +164 -0
  109. package/dist/needs-you.js.map +1 -0
  110. package/dist/node-version-guard.d.ts +30 -0
  111. package/dist/node-version-guard.js +50 -0
  112. package/dist/node-version-guard.js.map +1 -0
  113. package/dist/operator-control-paths.d.ts +4 -0
  114. package/dist/operator-control-paths.js +24 -0
  115. package/dist/operator-control-paths.js.map +1 -0
  116. package/dist/outbound-handlers.d.ts +2 -0
  117. package/dist/outbound-handlers.js +9 -2
  118. package/dist/outbound-handlers.js.map +1 -1
  119. package/dist/outbound-schemas.d.ts +5 -5
  120. package/dist/profile-control.js +4 -20
  121. package/dist/profile-control.js.map +1 -1
  122. package/dist/public-web-gateway.d.ts +18 -0
  123. package/dist/public-web-gateway.js +141 -0
  124. package/dist/public-web-gateway.js.map +1 -0
  125. package/dist/public-web-link.d.ts +53 -0
  126. package/dist/public-web-link.js +175 -0
  127. package/dist/public-web-link.js.map +1 -0
  128. package/dist/quickstart-api.d.ts +8 -1
  129. package/dist/quickstart-api.js +86 -48
  130. package/dist/quickstart-api.js.map +1 -1
  131. package/dist/quickstart.d.ts +1 -0
  132. package/dist/quickstart.js +6 -2
  133. package/dist/quickstart.js.map +1 -1
  134. package/dist/scheduler/db.d.ts +7 -1
  135. package/dist/scheduler/db.js +38 -0
  136. package/dist/scheduler/db.js.map +1 -1
  137. package/dist/scheduler/index.d.ts +1 -1
  138. package/dist/scheduler/retry-label.d.ts +11 -0
  139. package/dist/scheduler/retry-label.js +24 -0
  140. package/dist/scheduler/retry-label.js.map +1 -0
  141. package/dist/scheduler/scheduler.d.ts +55 -2
  142. package/dist/scheduler/scheduler.js +220 -8
  143. package/dist/scheduler/scheduler.js.map +1 -1
  144. package/dist/scheduler/types.d.ts +22 -0
  145. package/dist/scheduler/types.js.map +1 -1
  146. package/dist/secret-store.d.ts +5 -0
  147. package/dist/secret-store.js +20 -0
  148. package/dist/secret-store.js.map +1 -1
  149. package/dist/service-activation.d.ts +110 -0
  150. package/dist/service-activation.js +302 -0
  151. package/dist/service-activation.js.map +1 -0
  152. package/dist/service-installer.d.ts +7 -0
  153. package/dist/service-installer.js +7 -4
  154. package/dist/service-installer.js.map +1 -1
  155. package/dist/service-restart-selection.d.ts +6 -5
  156. package/dist/service-restart-selection.js +6 -5
  157. package/dist/service-restart-selection.js.map +1 -1
  158. package/dist/settings-api.d.ts +10 -2
  159. package/dist/settings-api.js +175 -72
  160. package/dist/settings-api.js.map +1 -1
  161. package/dist/settings-baseline.d.ts +20 -0
  162. package/dist/settings-baseline.js +104 -0
  163. package/dist/settings-baseline.js.map +1 -0
  164. package/dist/settings-change.d.ts +21 -0
  165. package/dist/settings-change.js +133 -0
  166. package/dist/settings-change.js.map +1 -0
  167. package/dist/settings-confirmation.d.ts +105 -0
  168. package/dist/settings-confirmation.js +232 -0
  169. package/dist/settings-confirmation.js.map +1 -0
  170. package/dist/settings-control.d.ts +41 -0
  171. package/dist/settings-control.js +294 -0
  172. package/dist/settings-control.js.map +1 -0
  173. package/dist/settings-effect.d.ts +24 -0
  174. package/dist/settings-effect.js +169 -0
  175. package/dist/settings-effect.js.map +1 -0
  176. package/dist/settings-http-confirmation.d.ts +29 -0
  177. package/dist/settings-http-confirmation.js +123 -0
  178. package/dist/settings-http-confirmation.js.map +1 -0
  179. package/dist/settings-instance-patch.d.ts +4 -0
  180. package/dist/settings-instance-patch.js +26 -0
  181. package/dist/settings-instance-patch.js.map +1 -0
  182. package/dist/settings-request-capability.d.ts +16 -0
  183. package/dist/settings-request-capability.js +32 -0
  184. package/dist/settings-request-capability.js.map +1 -0
  185. package/dist/settings-transaction.d.ts +65 -0
  186. package/dist/settings-transaction.js +223 -0
  187. package/dist/settings-transaction.js.map +1 -0
  188. package/dist/setup-auth.d.ts +10 -18
  189. package/dist/setup-auth.js +14 -38
  190. package/dist/setup-auth.js.map +1 -1
  191. package/dist/setup-form.d.ts +1 -1
  192. package/dist/setup-form.js +75 -18
  193. package/dist/setup-form.js.map +1 -1
  194. package/dist/setup-guide.d.ts +13 -0
  195. package/dist/setup-guide.js +25 -0
  196. package/dist/setup-guide.js.map +1 -0
  197. package/dist/setup-host.d.ts +16 -0
  198. package/dist/setup-host.js +197 -32
  199. package/dist/setup-host.js.map +1 -1
  200. package/dist/setup-wizard.js +3 -0
  201. package/dist/setup-wizard.js.map +1 -1
  202. package/dist/slash-authz.d.ts +17 -3
  203. package/dist/slash-authz.js +5 -0
  204. package/dist/slash-authz.js.map +1 -1
  205. package/dist/spawn-gate.js +3 -3
  206. package/dist/spawn-gate.js.map +1 -1
  207. package/dist/statusline-watcher.d.ts +5 -0
  208. package/dist/statusline-watcher.js +2 -0
  209. package/dist/statusline-watcher.js.map +1 -1
  210. package/dist/steer-capability.d.ts +6 -0
  211. package/dist/steer-capability.js +8 -0
  212. package/dist/steer-capability.js.map +1 -1
  213. package/dist/sync-work-attribution.d.ts +14 -0
  214. package/dist/sync-work-attribution.js +43 -8
  215. package/dist/sync-work-attribution.js.map +1 -1
  216. package/dist/tips.js +10 -5
  217. package/dist/tips.js.map +1 -1
  218. package/dist/tmux-control.d.ts +35 -5
  219. package/dist/tmux-control.js +291 -57
  220. package/dist/tmux-control.js.map +1 -1
  221. package/dist/tmux-manager.d.ts +20 -6
  222. package/dist/tmux-manager.js +90 -47
  223. package/dist/tmux-manager.js.map +1 -1
  224. package/dist/tmux-read.d.ts +59 -0
  225. package/dist/tmux-read.js +213 -0
  226. package/dist/tmux-read.js.map +1 -0
  227. package/dist/topic-commands.d.ts +21 -10
  228. package/dist/topic-commands.js +191 -58
  229. package/dist/topic-commands.js.map +1 -1
  230. package/dist/transcript-delta-reader.js +2 -1
  231. package/dist/transcript-delta-reader.js.map +1 -1
  232. package/dist/tunnel/cloudflared-install.d.ts +2 -0
  233. package/dist/tunnel/cloudflared-install.js +1 -1
  234. package/dist/tunnel/cloudflared-install.js.map +1 -1
  235. package/dist/tunnel/purpose-lane.d.ts +14 -0
  236. package/dist/tunnel/purpose-lane.js +43 -0
  237. package/dist/tunnel/purpose-lane.js.map +1 -0
  238. package/dist/turn-reply-guard.d.ts +2 -0
  239. package/dist/turn-reply-guard.js +10 -1
  240. package/dist/turn-reply-guard.js.map +1 -1
  241. package/dist/types.d.ts +33 -0
  242. package/dist/ui/app.html +18 -0
  243. package/dist/ui/chat-render.js +478 -0
  244. package/dist/ui/chat-store.js +289 -0
  245. package/dist/ui/chat-strings.js +69 -0
  246. package/dist/ui/chat-thread.js +209 -0
  247. package/dist/ui/chat-tour.js +116 -0
  248. package/dist/ui/fleet-org.js +142 -0
  249. package/dist/ui/panel-chat.js +418 -0
  250. package/dist/ui/panel-fleet.js +413 -0
  251. package/dist/ui/panel-needs.js +202 -0
  252. package/dist/ui/panel-settings.js +750 -0
  253. package/dist/ui/preview.js +266 -0
  254. package/dist/ui/settings-apply.js +239 -0
  255. package/dist/ui/settings-confirm.js +186 -0
  256. package/dist/ui/settings-dialogs.js +546 -0
  257. package/dist/ui/settings-model.js +277 -0
  258. package/dist/ui/settings-strings.js +652 -0
  259. package/dist/ui/settings-wizard.js +140 -0
  260. package/dist/ui/shared/agend-auth.js +93 -0
  261. package/dist/ui/shared/app-ctx.js +80 -0
  262. package/dist/ui/shared/app-html.js +9 -0
  263. package/dist/ui/shared/app-i18n.js +95 -0
  264. package/dist/ui/shared/app-nav.js +73 -0
  265. package/dist/ui/shared/app-needs.js +114 -0
  266. package/dist/ui/shared/app-route.js +73 -0
  267. package/dist/ui/shared/app-session.js +66 -0
  268. package/dist/ui/shared/app-shell.js +344 -0
  269. package/dist/ui/shared/app-store.js +51 -0
  270. package/dist/ui/shared/app-stream.js +201 -0
  271. package/dist/ui/shared/app.css +676 -0
  272. package/dist/ui/shared/app.js +88 -0
  273. package/dist/ui/shared/fonts/OFL.txt +92 -0
  274. package/dist/ui/shared/fonts/README.md +21 -0
  275. package/dist/ui/shared/fonts/inter.woff2 +0 -0
  276. package/dist/ui/shared/htm.module.js +1 -0
  277. package/dist/ui/shared/panel-view.js +655 -0
  278. package/dist/ui/shared/preact-hooks.module.js +2 -0
  279. package/dist/ui/shared/preact.module.js +2 -0
  280. package/dist/ui/shared/signin.js +130 -0
  281. package/dist/ui/shared/theme.js +29 -0
  282. package/dist/ui/shared/tokens.css +153 -0
  283. package/dist/ui/shared/ui-confirm.js +43 -0
  284. package/dist/ui/shared/ui-dialog.js +45 -0
  285. package/dist/ui/shared/ui-icons.js +61 -0
  286. package/dist/ui/shared/ui-menu.js +39 -0
  287. package/dist/ui/shared/ui-states.js +20 -0
  288. package/dist/ui/shared/ui-toast.js +18 -0
  289. package/dist/ui/shared/vendor/htm.LICENSE +202 -0
  290. package/dist/ui/shared/vendor/preact.LICENSE +21 -0
  291. package/dist/ui/shared/vendor/vendor.json +39 -0
  292. package/dist/ui/shared/view-strings.js +309 -0
  293. package/dist/ui/signin.html +51 -0
  294. package/dist/update-check.d.ts +6 -2
  295. package/dist/update-check.js +12 -3
  296. package/dist/update-check.js.map +1 -1
  297. package/dist/update-install.d.ts +71 -0
  298. package/dist/update-install.js +162 -0
  299. package/dist/update-install.js.map +1 -0
  300. package/dist/upgrade-notices.d.ts +17 -0
  301. package/dist/upgrade-notices.js +93 -0
  302. package/dist/upgrade-notices.js.map +1 -0
  303. package/dist/usage/statusline-usage.d.ts +2 -0
  304. package/dist/usage/statusline-usage.js +14 -10
  305. package/dist/usage/statusline-usage.js.map +1 -1
  306. package/dist/view-api.d.ts +6 -1
  307. package/dist/view-api.js +62 -51
  308. package/dist/view-api.js.map +1 -1
  309. package/dist/web-api.d.ts +71 -14
  310. package/dist/web-api.js +492 -77
  311. package/dist/web-api.js.map +1 -1
  312. package/dist/web-auth.d.ts +71 -27
  313. package/dist/web-auth.js +169 -69
  314. package/dist/web-auth.js.map +1 -1
  315. package/dist/web-body.d.ts +3 -0
  316. package/dist/web-body.js +55 -0
  317. package/dist/web-body.js.map +1 -0
  318. package/dist/web-channel-echo.d.ts +9 -0
  319. package/dist/web-channel-echo.js +55 -0
  320. package/dist/web-channel-echo.js.map +1 -0
  321. package/dist/web-chat-history.d.ts +125 -0
  322. package/dist/web-chat-history.js +184 -0
  323. package/dist/web-chat-history.js.map +1 -0
  324. package/dist/web-continuation.d.ts +7 -0
  325. package/dist/web-continuation.js +22 -0
  326. package/dist/web-continuation.js.map +1 -0
  327. package/dist/web-host-guard.d.ts +34 -0
  328. package/dist/web-host-guard.js +57 -1
  329. package/dist/web-host-guard.js.map +1 -1
  330. package/dist/web-login.d.ts +72 -0
  331. package/dist/web-login.js +132 -0
  332. package/dist/web-login.js.map +1 -0
  333. package/dist/web-org.d.ts +55 -0
  334. package/dist/web-org.js +67 -0
  335. package/dist/web-org.js.map +1 -0
  336. package/dist/web-preview.d.ts +77 -0
  337. package/dist/web-preview.js +240 -0
  338. package/dist/web-preview.js.map +1 -0
  339. package/dist/web-request-context.d.ts +10 -0
  340. package/dist/web-request-context.js +13 -0
  341. package/dist/web-request-context.js.map +1 -0
  342. package/dist/web-session.d.ts +167 -0
  343. package/dist/web-session.js +389 -0
  344. package/dist/web-session.js.map +1 -0
  345. package/dist/web-shell-routes.d.ts +59 -0
  346. package/dist/web-shell-routes.js +96 -0
  347. package/dist/web-shell-routes.js.map +1 -0
  348. package/dist/web-upload.d.ts +183 -0
  349. package/dist/web-upload.js +468 -0
  350. package/dist/web-upload.js.map +1 -0
  351. package/dist/website-tips-renderer.d.ts +2 -2
  352. package/dist/website-tips-renderer.js +3 -3
  353. package/dist/website-tips-renderer.js.map +1 -1
  354. package/package.json +8 -5
  355. package/scripts/preinstall-guard.cjs +36 -0
  356. package/dist/ui/dashboard.html +0 -774
  357. package/dist/ui/settings.html +0 -2165
  358. package/dist/ui/view.html +0 -1139
package/dist/web-api.js CHANGED
@@ -1,3 +1,15 @@
1
+ import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
2
+ import { readBoundedWebBody } from "./web-body.js";
3
+ import { gatewayRequestContext } from "./web-request-context.js";
4
+ import { permitWebContinuation } from "./web-continuation.js";
5
+ /**
6
+ * Web UI HTTP API handler.
7
+ * All /ui/* routes are handled here, extracted from fleet-manager.ts.
8
+ */
9
+ import { formatWebChannelEcho } from "./web-channel-echo.js";
10
+ import { t } from "./locale.js";
11
+ import { sendPanelHtml } from "./web-host-guard.js";
12
+ import { shellRoute } from "./web-shell-routes.js";
1
13
  import { readFileSync } from "node:fs";
2
14
  import { join, dirname } from "node:path";
3
15
  import { fileURLToPath } from "node:url";
@@ -5,8 +17,12 @@ import { execFileSync } from "node:child_process";
5
17
  import { CreateInstanceArgs, validateArgs } from "./outbound-schemas.js";
6
18
  import { readStatuslineModel, resolveInstanceContext } from "./topic-commands.js";
7
19
  import { z } from "zod";
8
- import { isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
20
+ import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
21
+ import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
22
+ import { attachmentDelivery, displayName, INLINE_MIME, isFileId, publicAttachment, sniffUpload, UPLOAD_LIMITS, wellFormed } from "./web-upload.js";
23
+ import { getAgendHome } from "./paths.js";
9
24
  import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
25
+ import { measureSyncWork } from "./sync-work-attribution.js";
10
26
  // ── Strict public-facing schemas ────────────────────────────────────────────
11
27
  // web-api endpoints must reject unknown fields so the dashboard cannot inject
12
28
  // internal-only flags that would reach handleCreate/scheduler/config writers.
@@ -51,16 +67,20 @@ const ConfigUpdateSchema = z.object({
51
67
  }).strict();
52
68
  const SendMessageSchema = z.object({
53
69
  instance: z.string().min(1).max(128),
54
- message: z.string().min(1).max(MAX_TEXT),
55
- }).strict();
70
+ message: z.string().max(MAX_TEXT),
71
+ /** Ids from POST /ui/upload, for this instance, not yet sent. */
72
+ attachments: z.array(z.string().regex(/^[0-9a-f]{32}$/)).max(UPLOAD_LIMITS.maxFiles).optional(),
73
+ }).strict().refine(v => v.message.trim().length > 0 || (v.attachments?.length ?? 0) > 0, { message: "a message or at least one file", path: ["message"] });
74
+ /** An instance name that is safe as one path segment (no separator, no NUL, not a dot name). */
75
+ const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "." && name !== "..";
56
76
  /**
57
77
  * Push a single SSE frame to every client. If a client throws (closed socket
58
78
  * after a network drop, etc.), evict it from the set and continue — without
59
79
  * the eviction the dead entry leaks forever, and without the try/catch a
60
80
  * single dead client breaks delivery to every client iterated after it.
61
81
  */
62
- export function broadcastSseEvent(clients, event, data, onError) {
63
- const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
82
+ export function broadcastSseEvent(clients, event, data, onError, id) {
83
+ const payload = sseFrame(event, data, id);
64
84
  const dead = [];
65
85
  for (const client of clients) {
66
86
  try {
@@ -79,6 +99,10 @@ export function broadcastSseEvent(clients, event, data, onError) {
79
99
  catch { /* socket already gone */ }
80
100
  }
81
101
  }
102
+ /** One SSE frame; `id` (a chat message's) lets a reconnecting EventSource say what it last saw. */
103
+ export function sseFrame(event, data, id) {
104
+ return `${id !== undefined ? `id: ${id}\n` : ""}event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
105
+ }
82
106
  function parseOrReject(schema, data, res) {
83
107
  const r = schema.safeParse(data);
84
108
  if (!r.success) {
@@ -91,20 +115,11 @@ function parseOrReject(schema, data, res) {
91
115
  }
92
116
  const __filename = fileURLToPath(import.meta.url);
93
117
  const __dirname = dirname(__filename);
118
+ /** How often a stream is refreshed, and how often it re-checks that its session still stands. */
119
+ export const SSE_HEARTBEAT_MS = 10_000;
94
120
  /** Parse JSON body from request. */
95
- function parseBody(req) {
96
- return new Promise((resolve, reject) => {
97
- let body = "";
98
- req.on("data", (chunk) => { body += chunk.toString(); });
99
- req.on("end", () => {
100
- try {
101
- resolve(JSON.parse(body));
102
- }
103
- catch {
104
- reject(new Error("Invalid JSON"));
105
- }
106
- });
107
- });
121
+ async function parseBody(req) {
122
+ return JSON.parse((await readBoundedWebBody(req, 512 * 1024)).toString("utf8"));
108
123
  }
109
124
  /** Send JSON response. */
110
125
  function json(res, status, data) {
@@ -119,11 +134,22 @@ export function handleWebRequest(req, res, url, ctx) {
119
134
  const path = url.pathname;
120
135
  const method = req.method ?? "GET";
121
136
  // Auth check for all /ui routes. Defence in depth behind the fleet-manager
122
- // gate, which has already redeemed any `?token=` for a session cookie — so
123
- // this accepts the cookie and the header too, and an unset token closes the
124
- // panel instead of comparing null against a missing credential.
137
+ // gate: the same session cookie or header token, and an unset token closes
138
+ // the panel instead of comparing null against a missing credential.
125
139
  if (path.startsWith("/ui")) {
126
- if (!isWebRequestAuthorized(req, url, ctx.webToken)) {
140
+ const approved = isSettingsReplay(req) && method === "POST"
141
+ && (path === "/ui/config" || path === "/ui/instances" || /^\/ui\/instances\/[^/]+\/delete$/.test(path))
142
+ ? settingsRequestExecution(req) : undefined;
143
+ if (approved) {
144
+ try {
145
+ approved.assert();
146
+ }
147
+ catch {
148
+ json(res, 409, { error: "settings_execution_stale" });
149
+ return true;
150
+ }
151
+ }
152
+ else if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: !isPassiveWebRead(method, path) })) {
127
153
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
128
154
  return true;
129
155
  }
@@ -132,16 +158,16 @@ export function handleWebRequest(req, res, url, ctx) {
132
158
  return false;
133
159
  }
134
160
  // ── Static files ───────────────────────────────────────
135
- if (method === "GET" && path === "/ui") {
136
- try {
137
- const html = readFileSync(join(__dirname, "ui", "dashboard.html"), "utf-8");
138
- res.setHeader("Content-Type", "text/html; charset=utf-8");
139
- res.writeHead(200);
140
- res.end(html);
141
- }
142
- catch {
143
- json(res, 500, { error: "dashboard.html not found" });
144
- }
161
+ // #1408: the app shell, for exactly the paths the classifier names (src/web-shell-routes.ts). One page for every
162
+ // route; the client router mounts the panel. A malformed instance name is a 400, an unknown one is the shell (the
163
+ // panel says "not found"), so the response never tells which names exist.
164
+ const shell = shellRoute(method, path);
165
+ if (shell?.kind === "malformed") {
166
+ json(res, 400, { error: "invalid instance name" });
167
+ return true;
168
+ }
169
+ if (shell?.kind === "shell") {
170
+ serveAppShell(req, res, ctx, "full");
145
171
  return true;
146
172
  }
147
173
  // Serve JS modules
@@ -190,22 +216,83 @@ export function handleWebRequest(req, res, url, ctx) {
190
216
  return true;
191
217
  }
192
218
  // ── SSE ────────────────────────────────────────────────
219
+ // The same data as the stream, over plain requests, for a path that cannot carry SSE (Cloudflare Quick
220
+ // Tunnels do not; a buffering proxy looks like a mute server). `after` is the same `<boot>-<id>` cursor
221
+ // as the stream's Last-Event-ID, so polling and the stream can take turns without a message twice or
222
+ // a gap — and a cursor from before a fleet restart gets the new process's backlog. No cursor yet (the page
223
+ // has not seen a single stream message): everything still retained, like a restart's backlog, so a message
224
+ // that arrived while the stream was silent is not skipped by the cursor this answer hands out (#1251
225
+ // review). The page keeps one entry per boot+id, so what it already loaded from /ui/history is not doubled.
226
+ if (method === "GET" && path === "/ui/poll") {
227
+ const history = ctx.webChatHistory;
228
+ const after = url.searchParams.get("after");
229
+ const fresh = after === null || after === ""; // a cursor that is not ours in shape still gets nothing
230
+ json(res, 200, {
231
+ status: ctx.getUiStatus(),
232
+ messages: history ? (fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
233
+ // The ticks too, so polling never has to re-read a chat's history — that read would count as the person's
234
+ // activity; this poll does not (isPassiveWebRead).
235
+ deliveries: history ? history.deliveries() : [],
236
+ // And the fleet prompts open on the dashboard (C4): prompt events are stream-only too.
237
+ prompts: ctx.listWebPrompts?.() ?? [],
238
+ // #1386: "Needs you" rides the passive channels only — no endpoint of its own to poll.
239
+ needs: ctx.needsYouItems?.() ?? [],
240
+ cursor: history ? `${history.boot}-${history.lastId}` : null,
241
+ });
242
+ return true;
243
+ }
193
244
  if (method === "GET" && path === "/ui/events") {
245
+ if (gatewayRequestContext(req)) {
246
+ json(res, 404, { error: "Use polling on the public link" });
247
+ return true;
248
+ }
194
249
  res.writeHead(200, {
195
250
  "Content-Type": "text/event-stream",
196
251
  "Cache-Control": "no-cache",
197
252
  Connection: "keep-alive",
198
253
  });
199
254
  res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`);
255
+ // An EventSource that reconnects says what it last saw: send what it missed, before anything new.
256
+ const lastSeen = parseLastEventId(req.headers["last-event-id"]);
257
+ if (lastSeen !== null && ctx.webChatHistory) {
258
+ const history = ctx.webChatHistory;
259
+ for (const m of history.replayFor(lastSeen))
260
+ res.write(sseFrame("message", m, history.cursorOf(m)));
261
+ }
262
+ // Ticks change messages already sent and carry no cursor: every (re)connect gets where each one is now, so a
263
+ // report that fell into a gap is not lost until a reload (#1253 review). No id: it is not a message.
264
+ if (ctx.webChatHistory)
265
+ res.write(`event: deliveries\ndata: ${JSON.stringify(ctx.webChatHistory.deliveries())}\n\n`);
266
+ // The same for the fleet prompts (C4): one posted, answered or expired during a gap is caught up here.
267
+ if (ctx.listWebPrompts)
268
+ res.write(`event: prompts\ndata: ${JSON.stringify(ctx.listWebPrompts())}\n\n`);
269
+ // #1386: and "Needs you" as it is now; changes follow as `needs` events.
270
+ if (ctx.needsYouItems)
271
+ res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
200
272
  ctx.sseClients.add(res);
201
273
  const interval = setInterval(() => {
274
+ // A stream authorized once must not outlive the authorization. Re-checked
275
+ // without counting as activity, or an open tab would keep an idle session
276
+ // alive forever; a revoked, expired or rotated-away session ends here.
277
+ if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false })) {
278
+ // Close the connection too, not just the response: an ended response leaves the socket idle in
279
+ // keep-alive, still held open by whoever's session just ended.
280
+ const socket = req.socket;
281
+ try {
282
+ res.end(() => socket?.destroy());
283
+ }
284
+ catch { /* already closed */ }
285
+ cleanup();
286
+ return;
287
+ }
202
288
  try {
203
- res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`);
289
+ // Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
290
+ res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`));
204
291
  }
205
292
  catch {
206
293
  cleanup();
207
294
  }
208
- }, 10_000);
295
+ }, ctx.sseHeartbeatMs ?? SSE_HEARTBEAT_MS);
209
296
  let cleanedUp = false;
210
297
  const cleanup = () => {
211
298
  if (cleanedUp)
@@ -214,20 +301,171 @@ export function handleWebRequest(req, res, url, ctx) {
214
301
  ctx.sseClients.delete(res);
215
302
  clearInterval(interval);
216
303
  };
217
- // `close` covers normal disconnects; `error` covers network resets that
218
- // never deliver a clean FIN. Without both, dead clients accumulate in
304
+ // The *response's* `close` is the one that means the stream is over: it fires when the connection
305
+ // goes away or the response is finished. The request's `close` means "the request has been read"
306
+ // — on newer Node that can be as soon as it is consumed, which would drop a live stream from
307
+ // sseClients and stop its session re-check while the response is still open. `error` covers
308
+ // network resets that never deliver a clean FIN. Without these, dead clients accumulate in
219
309
  // sseClients and the heartbeat interval keeps firing forever.
220
- req.on("close", cleanup);
310
+ res.on("close", cleanup);
221
311
  req.on("error", cleanup);
222
312
  res.on("error", cleanup);
223
313
  return true;
224
314
  }
315
+ // ── Chat history ───────────────────────────────────────
316
+ if (method === "GET" && path === "/ui/history") {
317
+ const instance = url.searchParams.get("instance") ?? "";
318
+ const limitRaw = url.searchParams.get("limit");
319
+ const limit = limitRaw === null ? 200 : Number(limitRaw);
320
+ if (instance.length === 0 || instance.length > 128 || !Number.isInteger(limit) || limit < 1 || limit > 500) {
321
+ json(res, 400, { error: "instance (1-128 chars) and limit (1-500) required" });
322
+ return true;
323
+ }
324
+ json(res, 200, { messages: ctx.webChatHistory?.list(instance, limit) ?? [], boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
325
+ return true;
326
+ }
225
327
  // ── Send message ───────────────────────────────────────
226
328
  if (method === "POST" && path === "/ui/send") {
227
329
  handleSendMessage(req, res, ctx);
228
330
  return true;
229
331
  }
332
+ // ── Files (C2): upload one file for an instance; fetch a file by the id the fleet issued for it ──
333
+ if (method === "POST" && path === "/ui/upload") {
334
+ handleUpload(req, res, url, ctx);
335
+ return true;
336
+ }
337
+ const fileMatch = path.match(/^\/ui\/file\/([^/]+)$/);
338
+ if (method === "GET" && fileMatch) {
339
+ const id = fileMatch[1];
340
+ const got = isFileId(id) ? ctx.webFiles?.read(id) : null;
341
+ if (!got) {
342
+ json(res, 404, { error: "No such file" });
343
+ return true;
344
+ }
345
+ const { file, bytes } = got;
346
+ const inline = INLINE_MIME.has(file.mime);
347
+ // Only the four image types are shown in the page; anything else is a download, never rendered.
348
+ res.setHeader("Content-Type", inline ? file.mime : (file.mime.startsWith("text/") ? "text/plain; charset=utf-8" : "application/octet-stream"));
349
+ res.setHeader("Content-Disposition", `${inline ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
350
+ res.setHeader("X-Content-Type-Options", "nosniff");
351
+ res.setHeader("Cache-Control", "private, no-store");
352
+ res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
353
+ res.writeHead(200);
354
+ res.end(bytes);
355
+ return true;
356
+ }
357
+ // ── Fleet prompts (C4): the hang / exit / interactive-prompt buttons, answerable here too ──
358
+ if (method === "GET" && path === "/ui/prompts") {
359
+ json(res, 200, { prompts: ctx.listWebPrompts?.() ?? [] });
360
+ return true;
361
+ }
362
+ // The nonce is the capability, so it travels in the body: never in a URL a log or a history could keep.
363
+ if (method === "POST" && path === "/ui/prompt") {
364
+ if (!ctx.clickWebPrompt) {
365
+ json(res, 404, { error: "No prompts here" });
366
+ return true;
367
+ }
368
+ const click = ctx.clickWebPrompt.bind(ctx);
369
+ (async () => {
370
+ let body;
371
+ try {
372
+ body = await parseBody(req);
373
+ }
374
+ catch {
375
+ json(res, 400, { error: "Invalid JSON" });
376
+ return;
377
+ }
378
+ if (!permitWebContinuation(req, res, ctx))
379
+ return;
380
+ if (!body || typeof body !== "object") {
381
+ json(res, 400, { error: "instance, nonce and action required" });
382
+ return;
383
+ }
384
+ const { instance, nonce, action } = body;
385
+ if (typeof instance !== "string" || typeof nonce !== "string" || typeof action !== "string") {
386
+ json(res, 400, { error: "instance, nonce and action required" });
387
+ return;
388
+ }
389
+ const r = await click(instance, nonce, action);
390
+ // 409: answered elsewhere first, expired, or never open — the page drops the buttons on `gone`.
391
+ json(res, r.status, r.status === 200 ? { answered: true } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
392
+ })().catch(err => {
393
+ ctx.logger.error({ err: err.message }, "Web prompt answer failed");
394
+ try {
395
+ json(res, 500, { error: "Prompt answer failed" });
396
+ }
397
+ catch { /* already answered */ }
398
+ });
399
+ return true;
400
+ }
401
+ // #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
402
+ // token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
403
+ if (method === "POST" && path === "/ui/needs/ack") {
404
+ if (!ctx.acknowledgeNeedsItem) {
405
+ json(res, 404, { error: "Nothing to acknowledge here" });
406
+ return true;
407
+ }
408
+ const acknowledge = ctx.acknowledgeNeedsItem.bind(ctx);
409
+ (async () => {
410
+ let body;
411
+ try {
412
+ body = await parseBody(req);
413
+ }
414
+ catch {
415
+ json(res, 400, { error: "Invalid JSON" });
416
+ return;
417
+ }
418
+ if (!permitWebContinuation(req, res, ctx))
419
+ return;
420
+ const id = body && typeof body === "object" ? body.id : undefined;
421
+ if (typeof id !== "string") {
422
+ json(res, 400, { error: "id required" });
423
+ return;
424
+ }
425
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
426
+ if (verdict.kind !== "allow") {
427
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
428
+ return;
429
+ }
430
+ const principal = verdict.via === "session" && verdict.session ? `web:${verdict.session.handle}` : "cli";
431
+ const r = acknowledge(id, principal);
432
+ json(res, r.status, r.status === 200 ? { acknowledged: true, message: r.message } : { error: r.message });
433
+ })().catch(err => {
434
+ ctx.logger.error({ err: err.message }, "Needs you acknowledge failed");
435
+ try {
436
+ json(res, 500, { error: "Acknowledge failed" });
437
+ }
438
+ catch { /* already answered */ }
439
+ });
440
+ return true;
441
+ }
230
442
  // ── Instance operations ────────────────────────────────
443
+ // Stop the agent's current reply — what Telegram's cancel button and /cancel do: Esc into the CLI, and the
444
+ // messages still waiting for it are dropped. Not /ui/stop, which stops the instance's process.
445
+ const cancelMatch = path.match(/^\/ui\/cancel\/([^/]+)$/);
446
+ if (method === "POST" && cancelMatch) {
447
+ let name;
448
+ try {
449
+ name = decodeURIComponent(cancelMatch[1]);
450
+ }
451
+ catch {
452
+ json(res, 400, { error: "Bad instance name" });
453
+ return true;
454
+ }
455
+ // Own keys only: "constructor" is in every object and names no instance.
456
+ const known = (ctx.fleetConfig ? Object.hasOwn(ctx.fleetConfig.instances, name) : false) || ctx.daemons.has(name);
457
+ if (!ctx.cancelInstance || !known) {
458
+ json(res, 404, { error: `Instance not found: ${name}` });
459
+ return true;
460
+ }
461
+ if (!ctx.cancelInstance(name)) {
462
+ json(res, 409, { error: `${name} is not running` });
463
+ return true;
464
+ }
465
+ ctx.eventLog?.logActivity("cancel", "web-user", "stop the current reply", name);
466
+ json(res, 200, { cancelled: name });
467
+ return true;
468
+ }
231
469
  const stopMatch = path.match(/^\/ui\/stop\/(.+)$/);
232
470
  if (method === "POST" && stopMatch) {
233
471
  const name = decodeURIComponent(stopMatch[1]);
@@ -278,11 +516,17 @@ export function handleWebRequest(req, res, url, ctx) {
278
516
  (async () => {
279
517
  try {
280
518
  const body = await parseBody(req);
519
+ if (!permitWebContinuation(req, res, ctx))
520
+ return;
281
521
  if (body.confirm !== `delete ${name}`) {
282
522
  json(res, 400, { error: `Confirmation required: { "confirm": "delete ${name}" }` });
283
523
  return;
284
524
  }
285
- await ctx.removeInstance(name, authorizeExplicitInstanceRemoval("dashboard-confirmed"));
525
+ const execution = settingsRequestExecution(req);
526
+ if (execution)
527
+ await ctx.removeInstance(name, authorizeExplicitInstanceRemoval("dashboard-confirmed"), execution);
528
+ else
529
+ await ctx.removeInstance(name, authorizeExplicitInstanceRemoval("dashboard-confirmed"));
286
530
  ctx.emitSseEvent("status", ctx.getUiStatus());
287
531
  json(res, 200, { deleted: name });
288
532
  }
@@ -364,6 +608,8 @@ export function handleWebRequest(req, res, url, ctx) {
364
608
  (async () => {
365
609
  try {
366
610
  const body = await parseBody(req);
611
+ if (!permitWebContinuation(req, res, ctx))
612
+ return;
367
613
  const v = validateArgs(CreateInstanceArgs, body, "create_instance");
368
614
  if (!v.ok) {
369
615
  json(res, 400, { error: v.error });
@@ -371,7 +617,12 @@ export function handleWebRequest(req, res, url, ctx) {
371
617
  }
372
618
  let result = null;
373
619
  let error;
374
- await ctx.lifecycle.handleCreate(v.data, (r, e) => { result = r; error = e; });
620
+ const respond = (r, e) => { result = r; error = e; };
621
+ const execution = settingsRequestExecution(req);
622
+ if (execution)
623
+ await ctx.lifecycle.handleCreate(v.data, respond, undefined, execution);
624
+ else
625
+ await ctx.lifecycle.handleCreate(v.data, respond);
375
626
  if (error) {
376
627
  json(res, 400, { error });
377
628
  }
@@ -404,6 +655,8 @@ export function handleWebRequest(req, res, url, ctx) {
404
655
  (async () => {
405
656
  try {
406
657
  const body = await parseBody(req);
658
+ if (!permitWebContinuation(req, res, ctx))
659
+ return;
407
660
  const parsed = parseOrReject(TaskCreateSchema, body, res);
408
661
  if (!parsed)
409
662
  return;
@@ -432,6 +685,8 @@ export function handleWebRequest(req, res, url, ctx) {
432
685
  (async () => {
433
686
  try {
434
687
  const body = await parseBody(req);
688
+ if (!permitWebContinuation(req, res, ctx))
689
+ return;
435
690
  const parsed = parseOrReject(TaskUpdateSchema, body, res);
436
691
  if (!parsed)
437
692
  return;
@@ -472,6 +727,8 @@ export function handleWebRequest(req, res, url, ctx) {
472
727
  (async () => {
473
728
  try {
474
729
  const body = await parseBody(req);
730
+ if (!permitWebContinuation(req, res, ctx))
731
+ return;
475
732
  const parsed = parseOrReject(ScheduleCreateSchema, body, res);
476
733
  if (!parsed)
477
734
  return;
@@ -500,6 +757,12 @@ export function handleWebRequest(req, res, url, ctx) {
500
757
  return true;
501
758
  }
502
759
  // ── Teams ──────────────────────────────────────────────
760
+ // #1389: the org chart. Opening it is a person's navigation (it counts as use, like a Fleet tab); nothing re-reads
761
+ // it on a timer — the live state comes over the stream the page already has.
762
+ if (method === "GET" && path === "/ui/org") {
763
+ json(res, 200, ctx.orgChart?.() ?? { general: [], teams: [], instances: {} });
764
+ return true;
765
+ }
503
766
  if (method === "GET" && path === "/ui/teams") {
504
767
  const teams = ctx.fleetConfig?.teams ?? {};
505
768
  json(res, 200, { teams });
@@ -509,6 +772,8 @@ export function handleWebRequest(req, res, url, ctx) {
509
772
  (async () => {
510
773
  try {
511
774
  const body = await parseBody(req);
775
+ if (!permitWebContinuation(req, res, ctx))
776
+ return;
512
777
  const parsed = parseOrReject(TeamCreateSchema, body, res);
513
778
  if (!parsed)
514
779
  return;
@@ -569,6 +834,8 @@ export function handleWebRequest(req, res, url, ctx) {
569
834
  (async () => {
570
835
  try {
571
836
  const body = await parseBody(req);
837
+ if (!permitWebContinuation(req, res, ctx))
838
+ return;
572
839
  const parsed = parseOrReject(ConfigUpdateSchema, body, res);
573
840
  if (!parsed)
574
841
  return;
@@ -578,26 +845,28 @@ export function handleWebRequest(req, res, url, ctx) {
578
845
  return;
579
846
  }
580
847
  const ch = config.channel;
581
- // Update channel settings
582
- if (parsed.channel && ch) {
583
- if (parsed.channel.group_id != null)
584
- config.channel.group_id = parsed.channel.group_id;
585
- if (parsed.channel.access)
586
- config.channel.access = parsed.channel.access;
587
- }
588
- // Update defaults
589
- if (parsed.defaults) {
590
- const d = config.defaults;
591
- if (parsed.defaults.backend)
592
- d.backend = parsed.defaults.backend;
593
- if (parsed.defaults.model)
594
- d.model = parsed.defaults.model;
595
- }
596
- // Update project_roots
597
- if (parsed.project_roots) {
598
- config.project_roots = parsed.project_roots;
599
- }
600
- ctx.saveFleetConfig();
848
+ settingsWrite(req, () => {
849
+ // Update channel settings
850
+ if (parsed.channel && ch) {
851
+ if (parsed.channel.group_id != null)
852
+ config.channel.group_id = parsed.channel.group_id;
853
+ if (parsed.channel.access)
854
+ config.channel.access = parsed.channel.access;
855
+ }
856
+ // Update defaults
857
+ if (parsed.defaults) {
858
+ const d = config.defaults;
859
+ if (parsed.defaults.backend)
860
+ d.backend = parsed.defaults.backend;
861
+ if (parsed.defaults.model)
862
+ d.model = parsed.defaults.model;
863
+ }
864
+ // Update project_roots
865
+ if (parsed.project_roots) {
866
+ config.project_roots = parsed.project_roots;
867
+ }
868
+ ctx.saveFleetConfig();
869
+ });
601
870
  const needsRestart = parsed.channel?.group_id != null;
602
871
  json(res, 200, { saved: true, needs_restart: needsRestart });
603
872
  }
@@ -611,6 +880,14 @@ export function handleWebRequest(req, res, url, ctx) {
611
880
  json(res, 404, { error: "not found" });
612
881
  return true;
613
882
  }
883
+ /** Keep the echo in a single platform message, including its attachment names. */
884
+ function webEchoPreview(message, names) {
885
+ const text = wellFormed(message.slice(0, 500));
886
+ const files = names.length ? `[📎 ${names.map(name => displayName(name, "attachment")).join(", ")}]` : "";
887
+ const preview = [text, files].filter(Boolean).join(" ");
888
+ const truncated = message.length > 500 || preview.length > 1400;
889
+ return wellFormed(preview.slice(0, 1400)) + (truncated ? ` … (${t("web.echo_full_text")})` : "");
890
+ }
614
891
  /** Handle POST /ui/send — extracted for readability. */
615
892
  function handleSendMessage(req, res, ctx) {
616
893
  let body = "";
@@ -625,6 +902,8 @@ function handleSendMessage(req, res, ctx) {
625
902
  body += chunk.toString();
626
903
  });
627
904
  req.on("end", async () => {
905
+ if (!permitWebContinuation(req, res, ctx))
906
+ return;
628
907
  try {
629
908
  const raw = JSON.parse(body);
630
909
  const parsed = SendMessageSchema.safeParse(raw);
@@ -633,55 +912,191 @@ function handleSendMessage(req, res, ctx) {
633
912
  json(res, 400, { error: `${issue.path.join(".") || "body"}: ${issue.message}` });
634
913
  return;
635
914
  }
636
- const { instance, message } = parsed.data;
915
+ const { instance, message: typed, attachments = [] } = parsed.data;
637
916
  const ipc = ctx.instanceIpcClients.get(instance);
638
917
  if (!ipc) {
639
918
  json(res, 404, { error: `Instance not found: ${instance}` });
640
919
  return;
641
920
  }
921
+ let files = [];
922
+ if (attachments.length > 0) {
923
+ const taken = ctx.webFiles?.takeForMessage(instance, attachments);
924
+ if (!taken || !taken.ok) {
925
+ json(res, 400, { error: taken && !taken.ok ? taken.error : "files are not available here" });
926
+ return;
927
+ }
928
+ files = taken.entries;
929
+ }
930
+ // The same tags and meta a Telegram photo/document produces, so the agent needs nothing new.
931
+ const delivery = attachmentDelivery(typed, files);
932
+ const message = typed;
642
933
  const ts = new Date().toISOString();
934
+ // The id the agent is given, and the one its delivery reports come back under: the page's ticks.
935
+ const messageId = newWebMessageId();
643
936
  // Use real Telegram context so daemon's lastChatId/lastThreadId are set,
644
937
  // enabling reply tool even when first message comes from Web UI.
645
- // Pure Web UI mode (no channel config) leaves these empty — TODO: needs
646
- // a separate reply path for that case.
647
- const groupId = ctx.fleetConfig?.channel?.group_id;
938
+ // Pure Web UI mode (no channel config) leaves these empty; the agent's reply then comes back to the
939
+ // web chat alone (FleetManager's web-only reply sink).
940
+ const syncAdapter = ctx.getAdapterForInstance ? ctx.getAdapterForInstance(instance) : ctx.adapter;
941
+ const groupId = ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "");
648
942
  const topicId = ctx.fleetConfig?.instances[instance]?.topic_id;
943
+ const canEcho = ctx.fleetConfig?.web?.echo_to_channel !== false
944
+ && !ctx.isClassicInstance?.(instance) && syncAdapter && groupId && topicId != null;
945
+ // Add no runtime credentials or local attachment paths to the display copy.
946
+ const preview = webEchoPreview(message, files.map(f => f.name));
947
+ // ClassicBot echo (#1320 part B): same ordering lane, per-entry
948
+ // opt-in resolved at send time inside sendClassicWebEcho.
949
+ const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
950
+ const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
951
+ await ctx.sendClassicWebEcho(instance, formatWebChannelEcho("web-user", preview, t("web.echo_full_text")));
952
+ }) : undefined;
953
+ const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
954
+ // A replacement adapter or edited binding is not the route we reserved.
955
+ if ((ctx.getAdapterForInstance ? ctx.getAdapterForInstance(instance) : ctx.adapter) !== syncAdapter
956
+ || (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
957
+ || ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
958
+ return;
959
+ return syncAdapter.sendText(String(groupId), formatWebChannelEcho("web-user", preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
960
+ }) : undefined;
961
+ let delivered;
649
962
  try {
650
- await ctx.deliverToInstance(instance, {
963
+ delivered = await ctx.deliverToInstance(instance, {
651
964
  type: "fleet_inbound",
652
- content: message,
965
+ content: delivery.text,
653
966
  targetSession: instance,
654
967
  meta: {
655
968
  chat_id: groupId ? String(groupId) : "",
656
- message_id: `web-${Date.now()}`,
969
+ message_id: messageId,
657
970
  user: "web-user", user_id: "web-user",
658
971
  ts,
659
972
  thread_id: topicId != null ? String(topicId) : "",
660
973
  source: "web",
974
+ adapter_id: syncAdapter?.id,
975
+ ...delivery.meta,
661
976
  },
662
977
  });
663
978
  }
664
979
  catch (err) {
980
+ settleEcho?.(false);
981
+ // A throw must release the Classic ordering lane too: otherwise the
982
+ // next accepted echo queues behind a reservation that never settles
983
+ // and is dropped by expiry (#1330 R2).
984
+ settleClassicEcho?.(false);
985
+ ctx.webFiles?.release(files); // not delivered: the same ids can be sent again
665
986
  ctx.logger.error({ err, instance }, "Web message delivery failed");
666
987
  json(res, 503, { error: "Instance delivery failed" });
667
988
  return;
668
989
  }
990
+ if (delivered === false) {
991
+ settleEcho?.(false);
992
+ settleClassicEcho?.(false);
993
+ ctx.webFiles?.release(files);
994
+ ctx.logger.error({ instance }, "Web message not delivered (the instance went away or restarted)");
995
+ json(res, 503, { error: "Instance delivery failed" });
996
+ return;
997
+ }
998
+ settleEcho?.(true);
999
+ settleClassicEcho?.(true);
1000
+ ctx.webFiles?.commit(files);
669
1001
  ctx.lastInboundUser.set(instance, "web-user");
670
- ctx.eventLog?.logActivity("message", "web-user", message.slice(0, 200), instance);
671
- ctx.emitSseEvent("message", { instance, sender: "web-user", text: message, ts });
672
- // Sync to Telegram/Discord
673
- const syncAdapter = ctx.getAdapterForInstance?.(instance) ?? ctx.adapter;
674
- const syncGroupId = ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "");
675
- if (syncAdapter && syncGroupId) {
676
- const topicId = ctx.fleetConfig?.instances[instance]?.topic_id;
677
- const preview = message.length > 500 ? message.slice(0, 500) + " [...]" : message;
678
- syncAdapter.sendText(syncGroupId, `🌐 web-user: ${preview}`, { threadId: topicId != null ? String(topicId) : undefined }).catch(() => ctx.logger.debug({}, "Web→Channel sync failed"));
679
- }
680
- json(res, 200, { sent: true });
1002
+ ctx.eventLog?.logActivity("message", "web-user", (message || `[${files.length} file(s)]`).slice(0, 200), instance);
1003
+ ctx.emitSseEvent("message", { instance, sender: "web-user", role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
1004
+ json(res, 200, { sent: true, messageId });
681
1005
  }
682
1006
  catch {
683
1007
  json(res, 400, { error: "Invalid JSON" });
684
1008
  }
685
1009
  });
686
1010
  }
1011
+ /**
1012
+ * One file for one instance's chat. The body is the file itself (Content-Type is ignored: the type is
1013
+ * read from the bytes), `X-Agend-Filename` its name for the label only. Stored in the instance's
1014
+ * workspace inbox under a name chosen here; answered with the id the message will name.
1015
+ */
1016
+ function handleUpload(req, res, url, ctx) {
1017
+ const instance = url.searchParams.get("instance") ?? "";
1018
+ if (!ctx.webFiles) {
1019
+ json(res, 404, { error: "uploads are not available" });
1020
+ return;
1021
+ }
1022
+ if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !ctx.instanceIpcClients.has(instance)) {
1023
+ json(res, 404, { error: `Instance not found: ${instance}` });
1024
+ return;
1025
+ }
1026
+ const declared = Number(req.headers["content-length"] ?? NaN);
1027
+ if (Number.isFinite(declared) && declared > UPLOAD_LIMITS.maxFileBytes) {
1028
+ json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
1029
+ req.resume();
1030
+ return;
1031
+ }
1032
+ const chunks = [];
1033
+ let size = 0;
1034
+ let refused = false;
1035
+ req.on("data", (c) => {
1036
+ if (refused)
1037
+ return;
1038
+ size += c.length;
1039
+ if (size > UPLOAD_LIMITS.maxFileBytes) {
1040
+ refused = true;
1041
+ json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
1042
+ req.destroy();
1043
+ return;
1044
+ }
1045
+ chunks.push(c);
1046
+ });
1047
+ req.on("error", () => { });
1048
+ req.on("end", () => {
1049
+ if (!permitWebContinuation(req, res, ctx))
1050
+ return;
1051
+ if (refused)
1052
+ return;
1053
+ const bytes = Buffer.concat(chunks);
1054
+ const name = displayName(typeof req.headers["x-agend-filename"] === "string" ? req.headers["x-agend-filename"] : "", "file");
1055
+ const type = sniffUpload(bytes, name);
1056
+ if (!type) {
1057
+ json(res, 415, { error: "unsupported file type — images (PNG, JPEG, GIF, WebP), PDF and text files only" });
1058
+ return;
1059
+ }
1060
+ // The instance is checked again now the whole body is here: it may have been deleted (and its files
1061
+ // forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
1062
+ // Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
1063
+ // in fleet.yaml, or as a ClassicBot room in classicBot.yaml.
1064
+ const registered = !!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true;
1065
+ if (!ctx.instanceIpcClients.has(instance) || !registered) {
1066
+ json(res, 404, { error: `Instance not found: ${instance}` });
1067
+ return;
1068
+ }
1069
+ try {
1070
+ const entry = ctx.webFiles.storeUpload({ instance, inboxDir: join(getAgendHome(), "workspaces", instance, "inbox"), bytes, name, type });
1071
+ json(res, 200, publicAttachment(entry));
1072
+ }
1073
+ catch (err) {
1074
+ ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
1075
+ json(res, 500, { error: "the file could not be stored" });
1076
+ }
1077
+ });
1078
+ }
1079
+ function shellPreview(req, ctx, mode) {
1080
+ if (mode !== "full" || gatewayRequestContext(req))
1081
+ return null;
1082
+ return ctx.previewForUi?.(typeof req.headers.host === "string" ? req.headers.host : undefined, isSecureRequest(req)) ?? null;
1083
+ }
1084
+ function shellBodyTag(req, ctx, mode) {
1085
+ const p = shellPreview(req, ctx, mode);
1086
+ const attr = (v) => String(v ?? "").replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
1087
+ return `<body data-mode="${mode}"${mode === "full" && gatewayRequestContext(req) ? ' data-web-transport="poll"' : ""} data-dashboard-origin="${attr(p?.dashboardOrigin)}" data-preview-origin="${attr(p?.previewOrigin)}"`
1088
+ + ` data-preview-boot="${attr(p?.previewOrigin ? p.boot : "")}" data-preview-reason="${attr(p ? p.reason : "Previews are not available on this fleet.")}">`;
1089
+ }
1090
+ /** The app shell page for one entry, under the panels' CSP; it may frame exactly <preview origin>/frame, and only
1091
+ * when this load chose one. */
1092
+ export function serveAppShell(req, res, ctx, mode) {
1093
+ try {
1094
+ const html = readFileSync(join(__dirname, "ui", "app.html"), "utf-8");
1095
+ const p = shellPreview(req, ctx, mode);
1096
+ sendPanelHtml(res, html.replace("<body>", shellBodyTag(req, ctx, mode)), 200, {}, p?.previewOrigin ? { frameSrc: `${p.previewOrigin}/frame` } : {});
1097
+ }
1098
+ catch {
1099
+ json(res, 500, { error: "app.html not found" });
1100
+ }
1101
+ }
687
1102
  //# sourceMappingURL=web-api.js.map