@songsid/agend 2.2.0-beta.8 → 2.2.0

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 (239) hide show
  1. package/README.md +40 -24
  2. package/README.zh-TW.md +40 -24
  3. package/dist/agent-endpoint.js +3 -0
  4. package/dist/agent-endpoint.js.map +1 -1
  5. package/dist/agent-whats-new.js +7 -1
  6. package/dist/agent-whats-new.js.map +1 -1
  7. package/dist/auth-api.d.ts +3 -1
  8. package/dist/auth-api.js +15 -1
  9. package/dist/auth-api.js.map +1 -1
  10. package/dist/backend/antigravity.d.ts +33 -0
  11. package/dist/backend/antigravity.js +126 -18
  12. package/dist/backend/antigravity.js.map +1 -1
  13. package/dist/backend/claude-code.d.ts +3 -0
  14. package/dist/backend/claude-code.js +9 -0
  15. package/dist/backend/claude-code.js.map +1 -1
  16. package/dist/backend/codex-metadata.d.ts +33 -0
  17. package/dist/backend/codex-metadata.js +122 -18
  18. package/dist/backend/codex-metadata.js.map +1 -1
  19. package/dist/backend/codex.d.ts +3 -0
  20. package/dist/backend/codex.js +17 -1
  21. package/dist/backend/codex.js.map +1 -1
  22. package/dist/backend/effort-metadata.d.ts +37 -1
  23. package/dist/backend/effort-metadata.js +86 -15
  24. package/dist/backend/effort-metadata.js.map +1 -1
  25. package/dist/backend/grok.d.ts +4 -0
  26. package/dist/backend/grok.js +14 -0
  27. package/dist/backend/grok.js.map +1 -1
  28. package/dist/backend/kiro.d.ts +18 -1
  29. package/dist/backend/kiro.js +35 -8
  30. package/dist/backend/kiro.js.map +1 -1
  31. package/dist/backend/launch-effort.d.ts +61 -0
  32. package/dist/backend/launch-effort.js +170 -0
  33. package/dist/backend/launch-effort.js.map +1 -0
  34. package/dist/backend/muse.d.ts +17 -0
  35. package/dist/backend/muse.js +86 -16
  36. package/dist/backend/muse.js.map +1 -1
  37. package/dist/backend/types.d.ts +40 -1
  38. package/dist/backend/types.js +12 -1
  39. package/dist/backend/types.js.map +1 -1
  40. package/dist/backend-command.d.ts +91 -0
  41. package/dist/backend-command.js +122 -0
  42. package/dist/backend-command.js.map +1 -0
  43. package/dist/channel/adapters/discord.d.ts +12 -0
  44. package/dist/channel/adapters/discord.js +49 -2
  45. package/dist/channel/adapters/discord.js.map +1 -1
  46. package/dist/channel/adapters/telegram.d.ts +18 -0
  47. package/dist/channel/adapters/telegram.js +24 -3
  48. package/dist/channel/adapters/telegram.js.map +1 -1
  49. package/dist/channel/mcp-tools.js +2 -0
  50. package/dist/channel/mcp-tools.js.map +1 -1
  51. package/dist/channel/tool-router.d.ts +2 -0
  52. package/dist/channel/tool-router.js +1 -1
  53. package/dist/channel/tool-router.js.map +1 -1
  54. package/dist/channel/types.d.ts +16 -0
  55. package/dist/cli.js +124 -24
  56. package/dist/cli.js.map +1 -1
  57. package/dist/command-table.d.ts +2 -2
  58. package/dist/command-table.js +5 -2
  59. package/dist/command-table.js.map +1 -1
  60. package/dist/compact-watch.d.ts +86 -0
  61. package/dist/compact-watch.js +288 -0
  62. package/dist/compact-watch.js.map +1 -0
  63. package/dist/config-validator.js +39 -0
  64. package/dist/config-validator.js.map +1 -1
  65. package/dist/config.d.ts +1 -0
  66. package/dist/config.js +2 -1
  67. package/dist/config.js.map +1 -1
  68. package/dist/daemon.d.ts +13 -1
  69. package/dist/daemon.js +160 -13
  70. package/dist/daemon.js.map +1 -1
  71. package/dist/delivery-outbox.d.ts +8 -1
  72. package/dist/delivery-outbox.js +6 -2
  73. package/dist/delivery-outbox.js.map +1 -1
  74. package/dist/doctor.js +7 -0
  75. package/dist/doctor.js.map +1 -1
  76. package/dist/fleet-context.d.ts +7 -0
  77. package/dist/fleet-manager.d.ts +160 -10
  78. package/dist/fleet-manager.js +837 -121
  79. package/dist/fleet-manager.js.map +1 -1
  80. package/dist/forged-envelope-scan.d.ts +4 -3
  81. package/dist/forged-envelope-scan.js +4 -3
  82. package/dist/forged-envelope-scan.js.map +1 -1
  83. package/dist/general-knowledge/skills/model-discovery/SKILL.md +4 -1
  84. package/dist/install-lock.d.ts +18 -1
  85. package/dist/install-lock.js +30 -3
  86. package/dist/install-lock.js.map +1 -1
  87. package/dist/instance-avatar.d.ts +5 -4
  88. package/dist/instance-avatar.js +12 -8
  89. package/dist/instance-avatar.js.map +1 -1
  90. package/dist/instance-lifecycle.js +3 -1
  91. package/dist/instance-lifecycle.js.map +1 -1
  92. package/dist/instructions.js +2 -2
  93. package/dist/instructions.js.map +1 -1
  94. package/dist/kiro-compact-pane.d.ts +38 -0
  95. package/dist/kiro-compact-pane.js +57 -0
  96. package/dist/kiro-compact-pane.js.map +1 -0
  97. package/dist/kiro-log-gate.d.ts +42 -0
  98. package/dist/kiro-log-gate.js +39 -0
  99. package/dist/kiro-log-gate.js.map +1 -0
  100. package/dist/launchd-handoff.d.ts +43 -0
  101. package/dist/launchd-handoff.js +56 -0
  102. package/dist/launchd-handoff.js.map +1 -0
  103. package/dist/locale.js +202 -30
  104. package/dist/locale.js.map +1 -1
  105. package/dist/log-service.js +10 -2
  106. package/dist/log-service.js.map +1 -1
  107. package/dist/log-steps.d.ts +26 -0
  108. package/dist/log-steps.js +215 -3
  109. package/dist/log-steps.js.map +1 -1
  110. package/dist/login-shell.d.ts +21 -0
  111. package/dist/login-shell.js +98 -0
  112. package/dist/login-shell.js.map +1 -0
  113. package/dist/outbound-handlers.d.ts +27 -0
  114. package/dist/outbound-handlers.js +120 -36
  115. package/dist/outbound-handlers.js.map +1 -1
  116. package/dist/outbound-schemas.d.ts +12 -0
  117. package/dist/outbound-schemas.js +15 -4
  118. package/dist/outbound-schemas.js.map +1 -1
  119. package/dist/provider-probe.d.ts +29 -0
  120. package/dist/provider-probe.js +198 -2
  121. package/dist/provider-probe.js.map +1 -1
  122. package/dist/public-web-gateway.d.ts +2 -0
  123. package/dist/public-web-gateway.js +11 -1
  124. package/dist/public-web-gateway.js.map +1 -1
  125. package/dist/quickstart-api.d.ts +1 -1
  126. package/dist/quickstart-api.js +8 -6
  127. package/dist/quickstart-api.js.map +1 -1
  128. package/dist/quickstart.d.ts +12 -0
  129. package/dist/quickstart.js +36 -6
  130. package/dist/quickstart.js.map +1 -1
  131. package/dist/reply-buttons-controller.d.ts +86 -5
  132. package/dist/reply-buttons-controller.js +271 -7
  133. package/dist/reply-buttons-controller.js.map +1 -1
  134. package/dist/reply-buttons.d.ts +48 -3
  135. package/dist/reply-buttons.js +105 -10
  136. package/dist/reply-buttons.js.map +1 -1
  137. package/dist/reply-polls.d.ts +85 -0
  138. package/dist/reply-polls.js +154 -0
  139. package/dist/reply-polls.js.map +1 -0
  140. package/dist/restart-guard.d.ts +6 -3
  141. package/dist/restart-guard.js +6 -3
  142. package/dist/restart-guard.js.map +1 -1
  143. package/dist/restart-progress.d.ts +0 -2
  144. package/dist/restart-progress.js +0 -2
  145. package/dist/restart-progress.js.map +1 -1
  146. package/dist/sd-notify.d.ts +22 -1
  147. package/dist/sd-notify.js +63 -10
  148. package/dist/sd-notify.js.map +1 -1
  149. package/dist/service-activation.d.ts +15 -7
  150. package/dist/service-activation.js +42 -26
  151. package/dist/service-activation.js.map +1 -1
  152. package/dist/service-installer.d.ts +24 -0
  153. package/dist/service-installer.js +49 -1
  154. package/dist/service-installer.js.map +1 -1
  155. package/dist/service-plan.d.ts +11 -0
  156. package/dist/service-plan.js +23 -0
  157. package/dist/service-plan.js.map +1 -1
  158. package/dist/systemd-peer-bus.d.ts +95 -0
  159. package/dist/systemd-peer-bus.js +430 -0
  160. package/dist/systemd-peer-bus.js.map +1 -0
  161. package/dist/systemd-runtime.d.ts +27 -1
  162. package/dist/systemd-runtime.js +69 -7
  163. package/dist/systemd-runtime.js.map +1 -1
  164. package/dist/tool-permissions.js +3 -3
  165. package/dist/tool-permissions.js.map +1 -1
  166. package/dist/topic-commands.d.ts +68 -2
  167. package/dist/topic-commands.js +141 -27
  168. package/dist/topic-commands.js.map +1 -1
  169. package/dist/transcript-jsonl.d.ts +7 -2
  170. package/dist/transcript-jsonl.js +13 -7
  171. package/dist/transcript-jsonl.js.map +1 -1
  172. package/dist/transcript-sources.js +6 -1
  173. package/dist/transcript-sources.js.map +1 -1
  174. package/dist/types.d.ts +8 -0
  175. package/dist/ui/chat-commands.js +1 -0
  176. package/dist/ui/chat-render.js +55 -10
  177. package/dist/ui/chat-store.js +69 -5
  178. package/dist/ui/chat-strings.js +28 -16
  179. package/dist/ui/chat-thread.js +132 -14
  180. package/dist/ui/chat-tour.js +17 -14
  181. package/dist/ui/panel-chat.js +16 -10
  182. package/dist/ui/panel-fleet.js +15 -2
  183. package/dist/ui/panel-log.js +55 -15
  184. package/dist/ui/panel-needs.js +10 -7
  185. package/dist/ui/panel-settings.js +58 -7
  186. package/dist/ui/settings-dialogs.js +11 -6
  187. package/dist/ui/settings-model.js +3 -1
  188. package/dist/ui/settings-strings.js +24 -10
  189. package/dist/ui/settings-token.js +7 -0
  190. package/dist/ui/settings-wizard.js +5 -3
  191. package/dist/ui/shared/app-i18n.js +1 -1
  192. package/dist/ui/shared/app-stream.js +4 -1
  193. package/dist/ui/shared/app.css +52 -10
  194. package/dist/ui/shared/header-tools.js +34 -14
  195. package/dist/ui/shared/instance-switch.js +5 -2
  196. package/dist/ui/shared/panel-view.js +15 -4
  197. package/dist/ui/shared/signin.js +15 -9
  198. package/dist/ui/shared/ui-icons.js +1 -0
  199. package/dist/ui/shared/view-strings.js +10 -2
  200. package/dist/ui/signin.html +8 -3
  201. package/dist/unrecognised-prompt.js +2 -2
  202. package/dist/unrecognised-prompt.js.map +1 -1
  203. package/dist/update-check.d.ts +31 -0
  204. package/dist/update-check.js +47 -0
  205. package/dist/update-check.js.map +1 -1
  206. package/dist/update-launch.d.ts +48 -1
  207. package/dist/update-launch.js +159 -5
  208. package/dist/update-launch.js.map +1 -1
  209. package/dist/usage/providers.d.ts +28 -8
  210. package/dist/usage/providers.js +96 -41
  211. package/dist/usage/providers.js.map +1 -1
  212. package/dist/view-api.js +26 -9
  213. package/dist/view-api.js.map +1 -1
  214. package/dist/web-api.d.ts +44 -2
  215. package/dist/web-api.js +265 -57
  216. package/dist/web-api.js.map +1 -1
  217. package/dist/web-chat-history.d.ts +26 -1
  218. package/dist/web-chat-history.js +47 -8
  219. package/dist/web-chat-history.js.map +1 -1
  220. package/dist/web-commands.d.ts +39 -3
  221. package/dist/web-commands.js +22 -5
  222. package/dist/web-commands.js.map +1 -1
  223. package/dist/web-login.d.ts +14 -0
  224. package/dist/web-login.js +4 -2
  225. package/dist/web-login.js.map +1 -1
  226. package/dist/web-prefs.d.ts +18 -0
  227. package/dist/web-prefs.js +36 -4
  228. package/dist/web-prefs.js.map +1 -1
  229. package/dist/web-profile.d.ts +32 -0
  230. package/dist/web-profile.js +132 -0
  231. package/dist/web-profile.js.map +1 -0
  232. package/dist/web-session.d.ts +14 -0
  233. package/dist/web-session.js +10 -0
  234. package/dist/web-session.js.map +1 -1
  235. package/dist/web-upload.d.ts +119 -4
  236. package/dist/web-upload.js +252 -39
  237. package/dist/web-upload.js.map +1 -1
  238. package/launcher/install-admission.cjs +61 -6
  239. package/package.json +3 -3
package/dist/web-api.js CHANGED
@@ -15,7 +15,7 @@ import { isSafeInstanceName } from "./web-shell-routes.js";
15
15
  import { MAX_COMMAND_ARGS } from "./web-commands.js";
16
16
  import { sendPanelHtml } from "./web-host-guard.js";
17
17
  import { shellRoute } from "./web-shell-routes.js";
18
- import { readFileSync } from "node:fs";
18
+ import { createReadStream, createWriteStream, readFileSync } from "node:fs";
19
19
  import { join, dirname } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { CreateInstanceArgs, CreateScheduleArgs, UpdateScheduleArgs, validateArgs } from "./outbound-schemas.js";
@@ -26,9 +26,10 @@ import { z } from "zod";
26
26
  import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
27
27
  import { dashboardOriginFor } from "./web-preview.js";
28
28
  import { newWebMessageId, parseLastEventId } from "./web-chat-history.js";
29
- import { attachmentDelivery, displayName, INLINE_MIME, isFileId, MEDIA_RANGE_MAX, publicAttachment, sniffUpload, UPLOAD_LIMITS, wellFormed } from "./web-upload.js";
29
+ 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";
30
30
  import { getAgendHome } from "./paths.js";
31
31
  import { OPERATOR } from "./web-prefs.js";
32
+ import { WEB_NAME_FALLBACK } from "./web-profile.js";
32
33
  import { commitTeamCreate, commitTeamUpdate } from "./outbound-handlers.js";
33
34
  import { parseMentionQuery } from "./web-mentions.js";
34
35
  import { authorizeExplicitInstanceRemoval } from "./instance-removal.js";
@@ -149,6 +150,37 @@ function stillAuthorized(req, res, url, ctx) {
149
150
  json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
150
151
  return false;
151
152
  }
153
+ /**
154
+ * #1827: whose a request is, asked NOW (after its body arrived: a session revoked, expired or rotated away meanwhile is
155
+ * no one — `null`, never the operator). An authorized session is the account it was signed in as, or (an `agend web`
156
+ * session, the CLI's header token) none: the operator. For recognition only; nothing here authorizes anything.
157
+ */
158
+ function webAccountOf(req, url, ctx) {
159
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
160
+ if (verdict.kind !== "allow")
161
+ return null;
162
+ return verdict.via === "session" && verdict.session?.owner ? { owner: verdict.session.owner } : {};
163
+ }
164
+ function webPersonOf(req, url, ctx) {
165
+ // No people kept here: everyone is "web-user", as before, and there is no identity to protect.
166
+ if (!ctx.webPerson)
167
+ return { key: OPERATOR, bound: false, userId: WEB_NAME_FALLBACK, voterKey: "web:operator", name: WEB_NAME_FALLBACK, set: false };
168
+ const who = webAccountOf(req, url, ctx);
169
+ return who ? ctx.webPerson(who.owner) : null;
170
+ }
171
+ /** #1827: the person, or the request is answered 401 here (it is no longer authorized) and null is returned. */
172
+ function webPersonOr401(req, res, url, ctx) {
173
+ const p = webPersonOf(req, url, ctx);
174
+ if (!p)
175
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
176
+ return p;
177
+ }
178
+ /** #1827: what the page's profile card reads — the name they chose (or null), the one in use, and how agents know them. */
179
+ function profileBody(p) {
180
+ return { name: p.set ? p.name : null, shown: p.name, id: p.userId, bound: p.bound, ...(p.platformName !== undefined ? { platform_name: p.platformName } : {}) };
181
+ }
182
+ /** #1823: an SSE frame with the server's clock (no id: it is not a message, and a reconnect gets a fresh one). */
183
+ function sseClock() { return `event: clock\ndata: ${JSON.stringify({ now: Date.now() })}\n\n`; }
152
184
  /**
153
185
  * Handle a Web UI request. Returns true if handled, false to pass through.
154
186
  */
@@ -261,6 +293,8 @@ export function handleWebRequest(req, res, url, ctx) {
261
293
  // #1577: the newest message this answer accounts for — after a restart, the last restored one until this process
262
294
  // records its own (`<boot>-0` would be after(0): the whole restored history again on every poll).
263
295
  cursor: history ? history.pollCursor() : null,
296
+ // #1823 (#1830 review): the server's clock now, for the page's poll countdown (a poll's deadline is the server's).
297
+ now: Date.now(),
264
298
  });
265
299
  return true;
266
300
  }
@@ -274,7 +308,9 @@ export function handleWebRequest(req, res, url, ctx) {
274
308
  "Cache-Control": "no-cache",
275
309
  Connection: "keep-alive",
276
310
  });
277
- res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`);
311
+ // #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
312
+ // poll down by (one write: the frames a connect sends are unchanged in number).
313
+ res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n${sseClock()}`);
278
314
  // An EventSource that reconnects says what it last saw: send what it missed, before anything new.
279
315
  const lastSeen = parseLastEventId(req.headers["last-event-id"]);
280
316
  if (lastSeen !== null && ctx.webChatHistory) {
@@ -310,7 +346,7 @@ export function handleWebRequest(req, res, url, ctx) {
310
346
  }
311
347
  try {
312
348
  // Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
313
- res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`));
349
+ res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`) + sseClock());
314
350
  }
315
351
  catch {
316
352
  cleanup();
@@ -406,12 +442,14 @@ export function handleWebRequest(req, res, url, ctx) {
406
442
  serveMediaRange(req, res, ctx.webFiles, id, media);
407
443
  return true;
408
444
  }
409
- const got = isFileId(id) ? ctx.webFiles?.read(id) : null;
410
- if (!got) {
445
+ // #1937: a file over MEDIA_RANGE_MAX streams from the descriptor its checks were made on — never whole in memory.
446
+ const big = isFileId(id) && (ctx.webFiles?.servedFile?.(id)?.size ?? 0) > MEDIA_RANGE_MAX ? ctx.webFiles?.open?.(id) ?? null : null;
447
+ const got = big ? null : isFileId(id) ? ctx.webFiles?.read(id) : null;
448
+ const file = big?.file ?? got?.file;
449
+ if (!file) {
411
450
  json(res, 404, { error: "No such file" });
412
451
  return true;
413
452
  }
414
- const { file, bytes } = got;
415
453
  const inline = INLINE_MIME.has(file.mime);
416
454
  // Only the four image types are shown in the page; anything else is a download, never rendered.
417
455
  res.setHeader("Content-Type", inline ? file.mime : (file.mime.startsWith("text/") ? "text/plain; charset=utf-8" : "application/octet-stream"));
@@ -419,8 +457,12 @@ export function handleWebRequest(req, res, url, ctx) {
419
457
  res.setHeader("X-Content-Type-Options", "nosniff");
420
458
  res.setHeader("Cache-Control", "private, no-store");
421
459
  res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
460
+ if (big) {
461
+ streamWhole(res, big);
462
+ return true;
463
+ }
422
464
  res.writeHead(200);
423
- res.end(bytes);
465
+ res.end(got.bytes);
424
466
  return true;
425
467
  }
426
468
  // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
@@ -527,6 +569,63 @@ export function handleWebRequest(req, res, url, ctx) {
527
569
  });
528
570
  return true;
529
571
  }
572
+ // #1827: the person's own name — the one agents and chats see for their web messages. Their own profile, so the public
573
+ // link may read and set it too (profile edits are allowed there); a read-only session may as well: it is about them.
574
+ if (path === "/ui/profile" && method === "GET") {
575
+ if (!ctx.webPerson) {
576
+ json(res, 404, { error: "Profiles are not kept on this fleet" });
577
+ return true;
578
+ }
579
+ const p = webPersonOr401(req, res, url, ctx);
580
+ if (p)
581
+ json(res, 200, profileBody(p));
582
+ return true;
583
+ }
584
+ if (path === "/ui/profile" && method === "POST") {
585
+ if (!ctx.setWebPersonName) {
586
+ json(res, 404, { error: "Profiles are not kept on this fleet" });
587
+ return true;
588
+ }
589
+ const set = ctx.setWebPersonName.bind(ctx);
590
+ (async () => {
591
+ let body;
592
+ try {
593
+ body = await parseBody(req);
594
+ }
595
+ catch {
596
+ json(res, 400, { error: "Invalid JSON" });
597
+ return;
598
+ }
599
+ if (!permitWebContinuation(req, res, ctx))
600
+ return;
601
+ if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "name")) {
602
+ json(res, 400, { error: "name required (text, or null to clear)" });
603
+ return;
604
+ }
605
+ // Whose: asked once the body is in. A session revoked, expired or rotated away meanwhile writes nothing — it is
606
+ // not the operator (#1833 review).
607
+ const who = webAccountOf(req, url, ctx);
608
+ if (!who) {
609
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
610
+ return;
611
+ }
612
+ const r = set(who.owner, body.name);
613
+ if (!r.ok) {
614
+ json(res, r.status, { error: r.error });
615
+ return;
616
+ }
617
+ // The person's other devices read it at their next status (the prefs rev moved).
618
+ ctx.emitSseEvent("status", ctx.getUiStatus());
619
+ json(res, 200, profileBody(r.person));
620
+ })().catch(err => {
621
+ ctx.logger.error({ err: err.message }, "Saving the web name failed");
622
+ try {
623
+ json(res, 500, { error: "Saving the name failed" });
624
+ }
625
+ catch { /* already answered */ }
626
+ });
627
+ return true;
628
+ }
530
629
  // #1266: a click on an agent reply's button. The set id travels in the body; the public link may click too (a click
531
630
  // is a message, and the public link may send messages).
532
631
  if (method === "POST" && path === "/ui/reply-button") {
@@ -551,8 +650,14 @@ export function handleWebRequest(req, res, url, ctx) {
551
650
  json(res, 400, { error: "instance, id and index required" });
552
651
  return;
553
652
  }
554
- const r = await click(instance, id, index);
555
- json(res, r.status, r.status === 200 ? { answered: true } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
653
+ // #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
654
+ // theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
655
+ // gate (a session revoked meanwhile) clicks nothing as anyone.
656
+ const person = webPersonOr401(req, res, url, ctx);
657
+ if (!person)
658
+ return;
659
+ const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name });
660
+ json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
556
661
  })().catch(err => {
557
662
  ctx.logger.error({ err: err.message }, "Web reply-button click failed");
558
663
  try {
@@ -728,7 +833,10 @@ export function handleWebRequest(req, res, url, ctx) {
728
833
  json(res, 400, { error: "invalid confirmation" });
729
834
  return;
730
835
  }
731
- const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req) });
836
+ const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
837
+ if (!person)
838
+ return;
839
+ const r = await run({ instance, command, args: str(body.args, MAX_COMMAND_ARGS), confirm: body.confirm }, { publicLink: !!gatewayRequestContext(req), person });
732
840
  json(res, r.status, r.body);
733
841
  })().catch(err => {
734
842
  ctx.logger.error({ err: err.message }, "Web command failed");
@@ -762,7 +870,7 @@ export function handleWebRequest(req, res, url, ctx) {
762
870
  json(res, 409, { error: `${name} is not running` });
763
871
  return true;
764
872
  }
765
- ctx.eventLog?.logActivity("cancel", "web-user", "stop the current reply", name);
873
+ ctx.eventLog?.logActivity("cancel", webPersonOf(req, url, ctx)?.name ?? WEB_NAME_FALLBACK, "stop the current reply", name);
766
874
  json(res, 200, { cancelled: name });
767
875
  return true;
768
876
  }
@@ -993,12 +1101,15 @@ export function handleWebRequest(req, res, url, ctx) {
993
1101
  const parsed = parseOrReject(TaskCreateSchema, body, res);
994
1102
  if (!parsed)
995
1103
  return;
1104
+ const person = webPersonOr401(req, res, url, ctx); // #1827: asked again, now the body is in
1105
+ if (!person)
1106
+ return;
996
1107
  const task = ctx.scheduler.db.createTask({
997
1108
  title: parsed.title,
998
1109
  description: parsed.description,
999
1110
  priority: parsed.priority,
1000
1111
  assignee: parsed.assignee,
1001
- created_by: "web-user",
1112
+ created_by: person.name, // #1827
1002
1113
  });
1003
1114
  json(res, 200, task);
1004
1115
  }
@@ -1025,7 +1136,10 @@ export function handleWebRequest(req, res, url, ctx) {
1025
1136
  return;
1026
1137
  let result;
1027
1138
  if (parsed.action === "claim") {
1028
- result = ctx.scheduler.db.claimTask(id, parsed.assignee || "web-user");
1139
+ const person = parsed.assignee ? null : webPersonOr401(req, res, url, ctx); // #1827: the default is the person
1140
+ if (!parsed.assignee && !person)
1141
+ return;
1142
+ result = ctx.scheduler.db.claimTask(id, parsed.assignee || person.name);
1029
1143
  }
1030
1144
  else if (parsed.action === "complete") {
1031
1145
  result = ctx.scheduler.db.completeTask(id, parsed.result);
@@ -1414,6 +1528,14 @@ function handleSendMessage(req, res, ctx) {
1414
1528
  return;
1415
1529
  }
1416
1530
  const message = typed;
1531
+ // #1827: who is writing — the name agents and chats see, and the id they tell people apart by. Asked now that the
1532
+ // body is in: a session revoked meanwhile sends nothing (not as the operator).
1533
+ const person = webPersonOf(req, new URL(req.url ?? "/", "http://localhost"), ctx);
1534
+ if (!person) {
1535
+ ctx.webFiles?.release(files);
1536
+ json(res, 401, { error: WEB_TOKEN_INVALID_MESSAGE });
1537
+ return;
1538
+ }
1417
1539
  const ts = new Date().toISOString();
1418
1540
  // The id the agent is given, and the one its delivery reports come back under: the page's ticks.
1419
1541
  const messageId = newWebMessageId();
@@ -1432,7 +1554,7 @@ function handleSendMessage(req, res, ctx) {
1432
1554
  // opt-in resolved at send time inside sendClassicWebEcho.
1433
1555
  const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
1434
1556
  const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
1435
- await ctx.sendClassicWebEcho(instance, formatWebChannelEcho("web-user", preview, t("web.echo_full_text")));
1557
+ await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
1436
1558
  }) : undefined;
1437
1559
  const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
1438
1560
  // A replacement adapter or edited binding is not the route we reserved.
@@ -1440,7 +1562,7 @@ function handleSendMessage(req, res, ctx) {
1440
1562
  || (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
1441
1563
  || ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
1442
1564
  return;
1443
- return syncAdapter.sendText(String(groupId), formatWebChannelEcho("web-user", preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
1565
+ return syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
1444
1566
  }) : undefined;
1445
1567
  let delivered;
1446
1568
  try {
@@ -1454,7 +1576,7 @@ function handleSendMessage(req, res, ctx) {
1454
1576
  meta: {
1455
1577
  chat_id: groupId ? String(groupId) : "",
1456
1578
  message_id: messageId,
1457
- user: "web-user", user_id: "web-user",
1579
+ user: person.name, user_id: person.userId,
1458
1580
  ts,
1459
1581
  thread_id: topicId != null ? String(topicId) : "",
1460
1582
  source: "web",
@@ -1492,9 +1614,9 @@ function handleSendMessage(req, res, ctx) {
1492
1614
  ctx.reserveWebChannelEcho?.(instance, () => startProgress(instance), { copy: false })(true);
1493
1615
  }
1494
1616
  ctx.webFiles?.commit(files);
1495
- ctx.lastInboundUser.set(instance, "web-user");
1496
- ctx.eventLog?.logActivity("message", "web-user", (message || `[${files.length} file(s)]`).slice(0, 200), instance);
1497
- ctx.emitSseEvent("message", { instance, sender: "web-user", role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
1617
+ ctx.lastInboundUser.set(instance, person.name);
1618
+ ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
1619
+ ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
1498
1620
  json(res, 200, { sent: true, messageId });
1499
1621
  }
1500
1622
  catch {
@@ -1506,6 +1628,11 @@ function handleSendMessage(req, res, ctx) {
1506
1628
  * One file for one instance's chat. The body is the file itself (Content-Type is ignored: the type is
1507
1629
  * read from the bytes), `X-Agend-Filename` its name for the label only. Stored in the instance's
1508
1630
  * workspace inbox under a name chosen here; answered with the id the message will name.
1631
+ *
1632
+ * #1937: streamed to a part file in that inbox (web-pending-….part) as it arrives — never held whole in memory — and
1633
+ * counted by the bytes that came, whatever Content-Length said. The limits are the ones in force for this request
1634
+ * (uploadLimits(): the config, and the public link's own cap). An upload that goes UPLOAD_STALL_MS without a byte, or
1635
+ * runs past UPLOAD_MAX_MS, is dropped; any upload that does not end in a stored file takes its part file with it.
1509
1636
  */
1510
1637
  function handleUpload(req, res, url, ctx) {
1511
1638
  const instance = url.searchParams.get("instance") ?? "";
@@ -1517,57 +1644,112 @@ function handleUpload(req, res, url, ctx) {
1517
1644
  json(res, 404, { error: `Instance not found: ${instance}` });
1518
1645
  return;
1519
1646
  }
1647
+ const limits = uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined });
1648
+ const tooLarge = () => json(res, 413, { error: fileTooLargeText(limits), max_bytes: limits.maxFileBytes, ...(limits.publicLinkCapped ? { public_link: true } : {}) });
1520
1649
  const declared = Number(req.headers["content-length"] ?? NaN);
1521
- if (Number.isFinite(declared) && declared > UPLOAD_LIMITS.maxFileBytes) {
1522
- json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
1650
+ if (Number.isFinite(declared) && declared > limits.maxFileBytes) {
1651
+ tooLarge();
1652
+ req.resume();
1653
+ return;
1654
+ }
1655
+ let part, fd, discard;
1656
+ try {
1657
+ ({ path: part, fd, discard } = createPartFile(join(getAgendHome(), "workspaces", instance, "inbox")));
1658
+ }
1659
+ catch (err) {
1660
+ ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
1661
+ json(res, 500, { error: "the file could not be stored" });
1523
1662
  req.resume();
1524
1663
  return;
1525
1664
  }
1526
- const chunks = [];
1665
+ const out = createWriteStream(part, { fd, autoClose: true });
1666
+ const sniffer = new UploadSniffer();
1527
1667
  let size = 0;
1528
- let refused = false;
1668
+ let over = false; // ended without a stored file: no further write, and the part file goes
1669
+ let stall;
1670
+ const clocks = () => { clearTimeout(stall); clearTimeout(deadline); };
1671
+ /** Stop here: answer (when the client is still there), remove the part file, and stop a body still arriving. */
1672
+ const drop = (answer) => {
1673
+ if (over)
1674
+ return;
1675
+ over = true;
1676
+ clocks();
1677
+ answer?.();
1678
+ out.destroy();
1679
+ discard();
1680
+ if (!req.complete)
1681
+ req.destroy();
1682
+ };
1683
+ const armStall = () => { clearTimeout(stall); stall = setTimeout(() => drop(() => json(res, 408, { error: "the upload stalled — attach the file again" })), UPLOAD_STALL_MS); stall.unref?.(); };
1684
+ const deadline = setTimeout(() => drop(() => json(res, 408, { error: "the upload took too long — attach the file again" })), UPLOAD_MAX_MS);
1685
+ deadline.unref?.();
1686
+ armStall();
1687
+ out.on("error", (err) => { ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored"); drop(() => json(res, 500, { error: "the file could not be stored" })); });
1688
+ out.on("drain", () => { if (!over)
1689
+ req.resume?.(); });
1529
1690
  req.on("data", (c) => {
1530
- if (refused)
1691
+ if (over)
1531
1692
  return;
1532
1693
  size += c.length;
1533
- if (size > UPLOAD_LIMITS.maxFileBytes) {
1534
- refused = true;
1535
- json(res, 413, { error: `a file can be at most ${UPLOAD_LIMITS.maxFileBytes / 1024 / 1024} MB` });
1536
- req.destroy();
1694
+ if (size > limits.maxFileBytes) {
1695
+ drop(tooLarge);
1537
1696
  return;
1538
1697
  }
1539
- chunks.push(c);
1698
+ armStall();
1699
+ sniffer.push(c);
1700
+ if (!out.write(c))
1701
+ req.pause?.();
1540
1702
  });
1541
- req.on("error", () => { });
1703
+ req.on("error", () => drop()); // the client went away
1704
+ req.on("close", () => { if (!over && req.complete === false)
1705
+ drop(); });
1706
+ // The instance is checked again now the whole body is here: it may have been deleted (and its files
1707
+ // forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
1708
+ // Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
1709
+ // in fleet.yaml, or as a ClassicBot room in classicBot.yaml. Asked when the body ends and again just before storing.
1710
+ const deliverable = () => ctx.instanceIpcClients.has(instance) && (!!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true);
1711
+ const gone = () => drop(() => json(res, 404, { error: `Instance not found: ${instance}` }));
1542
1712
  req.on("end", () => {
1543
- if (!permitWebContinuation(req, res, ctx))
1713
+ if (over)
1544
1714
  return;
1545
- if (refused)
1715
+ clocks();
1716
+ if (!permitWebContinuation(req, res, ctx)) {
1717
+ drop();
1546
1718
  return;
1547
- const bytes = Buffer.concat(chunks);
1719
+ }
1548
1720
  const name = displayName(typeof req.headers["x-agend-filename"] === "string" ? req.headers["x-agend-filename"] : "", "file");
1549
- const type = sniffUpload(bytes, name);
1721
+ const type = sniffer.result(name);
1550
1722
  if (!type) {
1551
- json(res, 415, { error: "unsupported file type — images (PNG, JPEG, GIF, WebP), PDF and text files only" });
1723
+ drop(() => json(res, 415, { error: "the file is empty" }));
1552
1724
  return;
1553
1725
  }
1554
- // The instance is checked again now the whole body is here: it may have been deleted (and its files
1555
- // forgotten) or stopped while the upload streamed in; storing now would bring its id back (#1252 review).
1556
- // Still deliverable means: its IPC is up (a paused one is woken by the send) and it is still registered —
1557
- // in fleet.yaml, or as a ClassicBot room in classicBot.yaml.
1558
- const registered = !!ctx.fleetConfig?.instances?.[instance] || ctx.isClassicInstance?.(instance) === true;
1559
- if (!ctx.instanceIpcClients.has(instance) || !registered) {
1560
- json(res, 404, { error: `Instance not found: ${instance}` });
1726
+ if (!deliverable()) {
1727
+ gone();
1561
1728
  return;
1562
1729
  }
1563
- try {
1564
- const entry = ctx.webFiles.storeUpload({ instance, inboxDir: join(getAgendHome(), "workspaces", instance, "inbox"), bytes, name, type });
1565
- json(res, 200, publicAttachment(entry));
1566
- }
1567
- catch (err) {
1568
- ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
1569
- json(res, 500, { error: "the file could not be stored" });
1570
- }
1730
+ out.end(() => {
1731
+ if (over)
1732
+ return;
1733
+ // The write's finish is another wait (Prism #1941 P2-1): the session or the public link may have ended in it.
1734
+ if (!permitWebContinuation(req, res, ctx)) {
1735
+ drop();
1736
+ return;
1737
+ }
1738
+ if (!deliverable()) {
1739
+ gone();
1740
+ return;
1741
+ }
1742
+ over = true;
1743
+ try {
1744
+ const entry = ctx.webFiles.storeUploadedFile({ instance, partPath: part, name, type });
1745
+ json(res, 200, publicAttachment(entry));
1746
+ }
1747
+ catch (err) {
1748
+ discard();
1749
+ ctx.logger.error({ err: err.message, instance }, "Web upload could not be stored");
1750
+ json(res, 500, { error: "the file could not be stored" });
1751
+ }
1752
+ });
1571
1753
  });
1572
1754
  }
1573
1755
  function shellPreview(req, ctx, mode) {
@@ -1585,6 +1767,15 @@ function shellPreview(req, ctx, mode) {
1585
1767
  * No Range, several, or one not in that form → 200 with the whole file, as every file is served (the Download link).
1586
1768
  * Inline, nosniff, no caching, and the route's own sandbox policy, as for every file.
1587
1769
  */
1770
+ /** #1937: a whole file (headers already set) streamed from the descriptor its checks were made on: 200, its length. */
1771
+ function streamWhole(res, opened) {
1772
+ res.setHeader("Content-Length", String(opened.file.size));
1773
+ res.writeHead(200);
1774
+ const stream = createReadStream("", { fd: opened.fd, start: 0, end: Math.max(0, opened.file.size - 1), autoClose: true });
1775
+ stream.on("error", () => res.destroy());
1776
+ res.on("close", () => stream.destroy());
1777
+ stream.pipe(res);
1778
+ }
1588
1779
  function serveMediaRange(req, res, files, id, file) {
1589
1780
  const total = file.size;
1590
1781
  const header = typeof req.headers.range === "string" ? req.headers.range.trim() : "";
@@ -1608,20 +1799,34 @@ function serveMediaRange(req, res, files, id, file) {
1608
1799
  }
1609
1800
  }
1610
1801
  end = ranged ? Math.min(end, total - 1, start + MEDIA_RANGE_MAX - 1) : total - 1;
1802
+ const mediaHeaders = () => {
1803
+ res.setHeader("Content-Type", file.mime);
1804
+ res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
1805
+ res.setHeader("Accept-Ranges", "bytes");
1806
+ res.setHeader("X-Content-Type-Options", "nosniff");
1807
+ res.setHeader("Cache-Control", "private, no-store");
1808
+ res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
1809
+ };
1810
+ // #1937 (Prism #1941 P3-2): the whole of a file over MEDIA_RANGE_MAX (no Range asked) streams, as /ui/file's do.
1811
+ if (!ranged && total > MEDIA_RANGE_MAX) {
1812
+ const opened = files.open(id);
1813
+ if (!opened) {
1814
+ json(res, 404, { error: "No such file" });
1815
+ return;
1816
+ }
1817
+ mediaHeaders();
1818
+ streamWhole(res, opened);
1819
+ return;
1820
+ }
1611
1821
  const got = files.readSlice(id, start, end);
1612
1822
  if (!got || (!ranged && got.bytes.length !== total)) {
1613
1823
  json(res, 404, { error: "No such file" });
1614
1824
  return;
1615
1825
  }
1616
- res.setHeader("Content-Type", file.mime);
1617
- res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(wellFormed(file.name))}`);
1618
- res.setHeader("Accept-Ranges", "bytes");
1826
+ mediaHeaders();
1619
1827
  if (ranged)
1620
1828
  res.setHeader("Content-Range", `bytes ${got.start}-${got.end}/${total}`);
1621
1829
  res.setHeader("Content-Length", String(got.bytes.length));
1622
- res.setHeader("X-Content-Type-Options", "nosniff");
1623
- res.setHeader("Cache-Control", "private, no-store");
1624
- res.setHeader("Content-Security-Policy", "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
1625
1830
  res.writeHead(ranged ? 206 : 200);
1626
1831
  res.end(got.bytes);
1627
1832
  }
@@ -1653,6 +1858,9 @@ function shellBodyTag(req, ctx, mode) {
1653
1858
  + ` data-usage-panel="${ctx.fleetConfig?.web?.usage_panel === false ? "0" : "1"}"`
1654
1859
  // #1218: whether the Log tab is on (web.log_tab; off by default): the tab says so and offers to turn it on.
1655
1860
  + ` data-log-tab="${ctx.fleetConfig?.web?.log_tab === true ? "1" : "0"}"`
1861
+ // #1937: the upload limits in force for this page (the config's; the public link's own cap on it) — what the chat
1862
+ // checks before sending a file, and the numbers its words say. The server checks again on every upload.
1863
+ + ` data-upload-limits="${attr(JSON.stringify(uploadLimits(ctx.fleetConfig?.web, { publicLink: gatewayRequestContext(req) !== undefined })))}"`
1656
1864
  // #1770: the fleet's zone (chat-logs' and fleet.log's), in which the chat reads its message times and days.
1657
1865
  + ` data-fleet-tz="${attr(resolveTz())}"`
1658
1866
  // #1589: the public link shows attachments as downloads only (no inline text or media): its scope is unchanged.