@songsid/agend 2.3.3 → 2.3.4-beta.2

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 (143) hide show
  1. package/README.md +3 -3
  2. package/README.zh-TW.md +3 -3
  3. package/dist/agent-cli-instructions.md +1 -1
  4. package/dist/auth-api.d.ts +16 -3
  5. package/dist/auth-api.js +22 -6
  6. package/dist/auth-api.js.map +1 -1
  7. package/dist/authority/web-authority.d.ts +15 -1
  8. package/dist/authority/web-authority.js +27 -10
  9. package/dist/authority/web-authority.js.map +1 -1
  10. package/dist/backend/claude-code.js +11 -1
  11. package/dist/backend/claude-code.js.map +1 -1
  12. package/dist/backend/codex.js +12 -0
  13. package/dist/backend/codex.js.map +1 -1
  14. package/dist/backend/types.d.ts +7 -0
  15. package/dist/backend/types.js.map +1 -1
  16. package/dist/channel/adapters/discord.js +13 -0
  17. package/dist/channel/adapters/discord.js.map +1 -1
  18. package/dist/chat-log-ids.d.ts +48 -0
  19. package/dist/chat-log-ids.js +182 -0
  20. package/dist/chat-log-ids.js.map +1 -0
  21. package/dist/classic-channel-manager.d.ts +8 -1
  22. package/dist/classic-channel-manager.js +24 -2
  23. package/dist/classic-channel-manager.js.map +1 -1
  24. package/dist/config-validator.js +18 -1
  25. package/dist/config-validator.js.map +1 -1
  26. package/dist/daemon.js +8 -1
  27. package/dist/daemon.js.map +1 -1
  28. package/dist/env-file.d.ts +2 -0
  29. package/dist/env-file.js +13 -8
  30. package/dist/env-file.js.map +1 -1
  31. package/dist/fleet-manager.d.ts +42 -1
  32. package/dist/fleet-manager.js +206 -23
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +1 -1
  35. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +4 -0
  36. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  37. package/dist/instance-config-impact.js +1 -0
  38. package/dist/instance-config-impact.js.map +1 -1
  39. package/dist/instance-lifecycle.d.ts +2 -0
  40. package/dist/instance-lifecycle.js +23 -7
  41. package/dist/instance-lifecycle.js.map +1 -1
  42. package/dist/instructions.d.ts +1 -1
  43. package/dist/instructions.js +1 -1
  44. package/dist/instructions.js.map +1 -1
  45. package/dist/interaction-observation.d.ts +1 -0
  46. package/dist/interaction-observation.js +1 -1
  47. package/dist/interaction-observation.js.map +1 -1
  48. package/dist/locale.js +48 -0
  49. package/dist/locale.js.map +1 -1
  50. package/dist/next-steps.d.ts +12 -2
  51. package/dist/next-steps.js +19 -3
  52. package/dist/next-steps.js.map +1 -1
  53. package/dist/office-share-service.d.ts +57 -0
  54. package/dist/office-share-service.js +76 -11
  55. package/dist/office-share-service.js.map +1 -1
  56. package/dist/office-share.d.ts +8 -0
  57. package/dist/office-share.js +34 -0
  58. package/dist/office-share.js.map +1 -1
  59. package/dist/outbound-schemas.js +1 -1
  60. package/dist/outbound-schemas.js.map +1 -1
  61. package/dist/page-share.d.ts +122 -0
  62. package/dist/page-share.js +199 -0
  63. package/dist/page-share.js.map +1 -0
  64. package/dist/public-preview.d.ts +4 -0
  65. package/dist/public-preview.js +5 -4
  66. package/dist/public-preview.js.map +1 -1
  67. package/dist/public-web-gateway.js +5 -0
  68. package/dist/public-web-gateway.js.map +1 -1
  69. package/dist/quickstart-api.d.ts +5 -0
  70. package/dist/quickstart-api.js +1 -1
  71. package/dist/quickstart-api.js.map +1 -1
  72. package/dist/quickstart.d.ts +40 -0
  73. package/dist/quickstart.js +174 -28
  74. package/dist/quickstart.js.map +1 -1
  75. package/dist/remote-card-answer.js +2 -0
  76. package/dist/remote-card-answer.js.map +1 -1
  77. package/dist/remote-card-prompt.js +4 -2
  78. package/dist/remote-card-prompt.js.map +1 -1
  79. package/dist/selection-card.d.ts +21 -0
  80. package/dist/selection-card.js +129 -0
  81. package/dist/selection-card.js.map +1 -0
  82. package/dist/settings-api.js +4 -0
  83. package/dist/settings-api.js.map +1 -1
  84. package/dist/settings-change.js +5 -0
  85. package/dist/settings-change.js.map +1 -1
  86. package/dist/settings-effect.js +2 -0
  87. package/dist/settings-effect.js.map +1 -1
  88. package/dist/setup-host.js +2 -2
  89. package/dist/setup-host.js.map +1 -1
  90. package/dist/setup-wizard.js +2 -2
  91. package/dist/setup-wizard.js.map +1 -1
  92. package/dist/types.d.ts +10 -0
  93. package/dist/ui/chat-store.js +8 -1
  94. package/dist/ui/chat-strings.js +38 -2
  95. package/dist/ui/chat-thread.js +52 -10
  96. package/dist/ui/page-share-dialog.js +191 -0
  97. package/dist/ui/panel-chat.js +14 -0
  98. package/dist/ui/panel-notices.js +98 -0
  99. package/dist/ui/panel-settings.js +9 -2
  100. package/dist/ui/preview-pages.js +41 -0
  101. package/dist/ui/preview-panel-actions.js +161 -0
  102. package/dist/ui/preview-panel.js +209 -28
  103. package/dist/ui/preview-versions.js +155 -0
  104. package/dist/ui/preview.js +88 -3
  105. package/dist/ui/settings-registry.js +1 -0
  106. package/dist/ui/settings-strings.js +4 -0
  107. package/dist/ui/shared/app-i18n.js +2 -2
  108. package/dist/ui/shared/app-route.js +4 -0
  109. package/dist/ui/shared/app-shell.js +5 -3
  110. package/dist/ui/shared/app-store.js +71 -1
  111. package/dist/ui/shared/app-stream.js +1 -1
  112. package/dist/ui/shared/app.css +57 -3
  113. package/dist/ui/shared/app.js +22 -1
  114. package/dist/ui/shared/signin.js +12 -1
  115. package/dist/ui/shared/ui-icons.js +3 -0
  116. package/dist/ui/shared/web-authorize.js +3 -1
  117. package/dist/web-api.d.ts +26 -0
  118. package/dist/web-api.js +180 -1
  119. package/dist/web-api.js.map +1 -1
  120. package/dist/web-auth.js +7 -1
  121. package/dist/web-auth.js.map +1 -1
  122. package/dist/web-login.d.ts +2 -0
  123. package/dist/web-login.js +3 -1
  124. package/dist/web-login.js.map +1 -1
  125. package/dist/web-notices.d.ts +51 -0
  126. package/dist/web-notices.js +118 -0
  127. package/dist/web-notices.js.map +1 -0
  128. package/dist/web-only-fleet.d.ts +22 -0
  129. package/dist/web-only-fleet.js +13 -0
  130. package/dist/web-only-fleet.js.map +1 -0
  131. package/dist/web-preview.d.ts +32 -3
  132. package/dist/web-preview.js +52 -19
  133. package/dist/web-preview.js.map +1 -1
  134. package/dist/web-session.d.ts +7 -0
  135. package/dist/web-session.js +2 -0
  136. package/dist/web-session.js.map +1 -1
  137. package/dist/web-settings-patch.d.ts +3 -1
  138. package/dist/web-settings-patch.js +4 -1
  139. package/dist/web-settings-patch.js.map +1 -1
  140. package/dist/web-shell-routes.d.ts +2 -0
  141. package/dist/web-shell-routes.js +3 -0
  142. package/dist/web-shell-routes.js.map +1 -1
  143. package/package.json +1 -1
@@ -23,6 +23,10 @@
23
23
  resend: "Send me a new code", resent: "A new code was sent to you privately in chat. Enter it above.",
24
24
  resend_wait: "A code was asked for a moment ago — wait a little and try again.",
25
25
  resend_refused: "A new code could not be sent. Ask for one with /web.",
26
+ // #2910 D5: a web-only fleet (no chat connection): there is no /web to send — the code comes from the AgEnD computer.
27
+ hint_web: "On the AgEnD computer, run agend web --code to get a sign-in code.",
28
+ refused_web: "The code is invalid or no longer works. Run agend web --code on the AgEnD computer for a new one.",
29
+ resend_refused_web: "A new code could not be sent. Run agend web --code on the AgEnD computer for one.",
26
30
  },
27
31
  "zh-TW": {
28
32
  checking: "正在確認登入狀態…", title: "登入 AgEnD", lead: "輸入登入碼。",
@@ -37,10 +41,17 @@
37
41
  resend: "傳一組新的登入碼給我", resent: "新的登入碼已私訊給你,請在上方輸入。",
38
42
  resend_wait: "剛剛才要過登入碼,請稍候再試。",
39
43
  resend_refused: "無法傳送新的登入碼。請用 /web 再要一個。",
44
+ hint_web: "在 AgEnD 電腦上執行 agend web --code 取得登入碼。",
45
+ refused_web: "登入碼無效或已失效。請在 AgEnD 電腦上執行 agend web --code 取得新碼。",
46
+ resend_refused_web: "無法傳送新的登入碼。請在 AgEnD 電腦上執行 agend web --code 取得。",
40
47
  },
41
48
  };
42
49
  const lang = /^zh/i.test(navigator.language || "") ? "zh-TW" : "en";
43
- const t = (k) => T[lang][k] || T.en[k] || k;
50
+ // #2910 D5: the server marks a web-only fleet's page (<body data-web-only="1">): its own words where it has them.
51
+ const webOnly = !!document.body && document.body.getAttribute("data-web-only") === "1";
52
+ const t = (k) => (webOnly && (T[lang][`${k}_web`] || T.en[`${k}_web`])) || T[lang][k] || T.en[k] || k;
53
+ // …and no "No chat bot?" help: the hint above already is it.
54
+ if (webOnly) document.querySelectorAll("details.help").forEach((d) => { if (d.querySelector && d.querySelector('[data-i18n="nobot"]')) d.hidden = true; });
44
55
  document.documentElement.lang = lang === "zh-TW" ? "zh-Hant" : "en";
45
56
 
46
57
  // An old dashboard link carries `?token=`. It is not a credential any more (the server ignored it and
@@ -60,6 +60,9 @@ const P = {
60
60
  panel: () => html`<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M14 4v16"/>`,
61
61
  back: () => html`<path d="M19 12H5M12 19l-7-7 7-7"/>`,
62
62
  forward: () => html`<path d="M5 12h14M12 5l7 7-7 7"/>`,
63
+ prev: () => html`<path d="M15 18l-6-6 6-6"/>`, // #2911: the preview panel's version step
64
+ next: () => html`<path d="M9 18l6-6-6-6"/>`,
65
+ diff: () => html`<path d="M12 3v8M8 7h8M8 17h8M5 21h14"/>`, // #2911: the preview panel's Changes
63
66
  // #1629: Settings → Appearance.
64
67
  palette: () => html`<path d="M12 3a9 9 0 1 0 0 18c1.1 0 1.7-.8 1.7-1.7 0-.5-.2-.9-.5-1.2-.3-.3-.5-.7-.5-1.2 0-.9.8-1.7 1.7-1.7H16a5 5 0 0 0 5-5c0-4-4-7.2-9-7.2z"/><circle cx="7.5" cy="10.5" r="1"/><circle cx="10.5" cy="7" r="1"/><circle cx="15" cy="7.5" r="1"/>`,
65
68
  book: () => html`<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5z"/><path d="M4 19.5A2.5 2.5 0 0 0 6.5 22H20v-5"/>`,
@@ -15,6 +15,7 @@ register("webauth", {
15
15
  reason_disabled: "Web authority is turned off.",
16
16
  reason_loopback_only: "Only this computer can authorize, and this browser is not on it.",
17
17
  reason_no_account: "This browser was not signed in as an account (agend web): sign in with /web to authorize.",
18
+ reason_host_sign_in: "To speak as this computer's admin, sign in again on this computer with agend web --code.",
18
19
  reason_not_admin: "Your account is not a fleet admin here.",
19
20
  reason_not_admin_session: "Guests can't authorize.",
20
21
  reason_token_rotated: "The dashboard's token changed: sign in again.",
@@ -43,6 +44,7 @@ register("webauth", {
43
44
  reason_disabled: "網頁授權已關閉。",
44
45
  reason_loopback_only: "只有這台電腦可以授權,而這個瀏覽器不在這台電腦上。",
45
46
  reason_no_account: "這個瀏覽器不是用帳號登入的(agend web):請用 /web 登入後再授權。",
47
+ reason_host_sign_in: "要以這台電腦的管理者身分發言,請在這台電腦上執行 agend web --code 重新登入。",
46
48
  reason_not_admin: "你的帳號在這裡不是 fleet 管理者。",
47
49
  reason_not_admin_session: "訪客無法授權。",
48
50
  reason_token_rotated: "儀表板的 token 已變更:請重新登入。",
@@ -66,7 +68,7 @@ register("webauth", {
66
68
  const tn = (k, ...v) => t(`webauth.${k}`, ...v);
67
69
  // The switch and "Only this computer" are changed in Settings on this computer — never over the public link (#2525 review).
68
70
  const fixable = (reason) => (reason === "disabled" || reason === "loopback_only") && globalThis.document?.body?.dataset?.publicLink !== "1";
69
- const REASONS = ["disabled", "loopback_only", "no_account", "not_admin", "not_admin_session", "token_rotated", "public_link", "named_link_off", "quick_link_off", "not_session"];
71
+ const REASONS = ["disabled", "loopback_only", "no_account", "host_sign_in", "not_admin", "not_admin_session", "token_rotated", "public_link", "named_link_off", "quick_link_off", "not_session"];
70
72
  // Until when: the time, with the day when it isn't today (an authorization lasts up to 48 h).
71
73
  const clock = (ms) => {
72
74
  const d = new Date(ms), today = new Date().toDateString() === d.toDateString();
package/dist/web-api.d.ts CHANGED
@@ -216,7 +216,23 @@ export interface WebApiContext {
216
216
  owner: string;
217
217
  }): Promise<import("./office-share-service.js").OfficeShareMade>;
218
218
  revoke(id: string): number;
219
+ /** #2911: page share links on the same tunnel (page-share.ts); absent: /ui/page-share is not offered. */
220
+ pagesEnabled?(): boolean;
221
+ pagesCdn?(): boolean;
222
+ listPages?(): import("./page-share.js").PageShare[];
223
+ createPage?(opts: {
224
+ html: string;
225
+ instance: string;
226
+ title: string;
227
+ owner: string;
228
+ from?: import("./page-share.js").PageShare["from"];
229
+ minutes?: number;
230
+ stillAllowed?(): boolean;
231
+ }): Promise<import("./office-share-service.js").PageShareMade>;
232
+ revokePage?(id: string): import("./page-share.js").PageShare[];
219
233
  };
234
+ /** #2911: a page share was made — said in that instance's channel (who, which page, for how long; never its URL). */
235
+ pageShareNotice?(who: string, share: import("./page-share.js").PageShare): void;
220
236
  /** #2636: a share was made or ended from the page — said in General (who, which office, until when). */
221
237
  officeShareNotice?(event: "created" | "revoked", who: string, share: import("./office-share.js").OfficeShare | null, count: number): void;
222
238
  /** #2692: the agents' skin-pack changes (approval mode, pending changes, log); absent: /ui/office-pack-changes is not offered. */
@@ -326,6 +342,16 @@ export interface WebApiContext {
326
342
  status: number;
327
343
  message: string;
328
344
  };
345
+ /** #2910 D2: a web-only fleet's Notices (web-notices.ts); null on a fleet whose General says them. */
346
+ webNoticesFeed?(): {
347
+ view(): {
348
+ items: unknown[];
349
+ seen: number;
350
+ unread: number;
351
+ };
352
+ markSeen(id: number): number;
353
+ unread(): number;
354
+ } | null;
329
355
  /** Answer one of them, exactly as a click on its platform button would. */
330
356
  /** `text`: #2675 — the typed answer, for the one action the page shows a text box for. */
331
357
  clickWebPrompt?(instance: string, nonce: string, action: string, text?: string): Promise<{
package/dist/web-api.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { CANVAS_WORKER_EXPERIMENTAL_MESSAGE } from "./web-experimental.js";
2
+ import { daemonTopicMode } from "./web-only-fleet.js";
2
3
  import { webHumanOrigin } from "./human-delivery-receipts.js";
3
4
  import { classifyWebPeer, confirmAfterBody, noAuthority } from "./authority/web-authority.js";
4
5
  import { guestChatRefusal, guestCommandText, guestDeliveries, GUEST_PREF_MAX_BYTES, GUEST_SEARCHES, guestFrameData, guestHistory, guestMessages, guestOwnPrefs, guestPollBody, guestPrefs, guestRequestOf, guestSeesMessage, guestStatus } from "./web-guest-access.js";
@@ -40,6 +41,7 @@ import { z } from "zod";
40
41
  import { evaluateWebRequest, isPassiveWebRead, isSecureRequest, isWebRequestAuthorized, WEB_TOKEN_INVALID_MESSAGE } from "./web-auth.js";
41
42
  import { dashboardOriginFor } from "./web-preview.js";
42
43
  import { WEB_CHAT_CARDS_OVERFLOW, newWebMessageId, parseLastEventId, webChatReplyRef, webChatReplyText } from "./web-chat-history.js";
44
+ import { PAGE_SHARE_LIMITS, htmlFences, htmlTitle } from "./page-share.js";
43
45
  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";
44
46
  import { getAgendHome } from "./paths.js";
45
47
  import { OPERATOR, isOwnerEffectKey } from "./web-prefs.js";
@@ -539,6 +541,140 @@ function handleOfficeShare(req, res, url, ctx, path, method) {
539
541
  });
540
542
  return true;
541
543
  }
544
+ /**
545
+ * #2911 item 8: page share links — GET /ui/page-share (the open ones, and whether this fleet offers them), POST
546
+ * { instance, boot, id, fence | att, minutes? } (a new one: the server reads the HTML itself — the n-th complete ```html
547
+ * fence of that agent message, or its .html attachment — and answers the URL once the tunnel is up), DELETE
548
+ * /ui/page-share/<id|all>. The same admin check as the Office share's (handleOfficeShare): an admin session, directly
549
+ * or over the /web public link; never the CLI token, a read-only session or a guest. Asked again once the body is in.
550
+ */
551
+ function handlePageShare(req, res, url, ctx, path, method) {
552
+ const service = ctx.officeShares;
553
+ if (!service?.createPage || !service.listPages || !service.revokePage || !service.pagesEnabled) {
554
+ json(res, 404, { error: "Page share links are not kept on this fleet" });
555
+ return true;
556
+ }
557
+ const one = /^\/ui\/page-share\/([0-9a-f]{8}|all)$/.exec(path);
558
+ const known = (method === "GET" && path === "/ui/page-share") || (method === "POST" && path === "/ui/page-share") || (method === "DELETE" && !!one);
559
+ if (!known) {
560
+ json(res, 404, { error: "not found" });
561
+ return true;
562
+ }
563
+ const who = () => {
564
+ const verdict = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
565
+ if (verdict.kind !== "allow")
566
+ return { status: 401 };
567
+ if (verdict.via !== "session" || verdict.session?.tier !== "admin")
568
+ return { status: 403 };
569
+ const owner = verdict.session.owner;
570
+ return { person: owner ? webPersonKey(owner) : "admin-session", name: owner ? ctx.webPerson?.(owner)?.name ?? owner.userId : "admin" };
571
+ };
572
+ const refuse = (status) => json(res, status, { error: status === 401 ? WEB_TOKEN_INVALID_MESSAGE : "page_share_needs_an_admin_session" });
573
+ const first = who();
574
+ if ("status" in first) {
575
+ refuse(first.status);
576
+ return true;
577
+ }
578
+ res.setHeader("Cache-Control", "no-store");
579
+ // `cdn`: whether a share made now may load the preview's CDN libraries (the confirm dialog says so).
580
+ if (method === "GET") {
581
+ json(res, 200, { enabled: service.pagesEnabled(), cdn: service.pagesCdn?.() === true, shares: service.listPages() });
582
+ return true;
583
+ }
584
+ if (method === "DELETE") {
585
+ // Its end is said in the instance's channel by the service's change listener (as an expiry is).
586
+ const n = service.revokePage(one[1]).length;
587
+ json(res, n ? 200 : 404, n ? { revoked: n } : { error: "not_found" });
588
+ return true;
589
+ }
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
+ const now = who();
602
+ if ("status" in now) {
603
+ refuse(now.status);
604
+ return;
605
+ }
606
+ const fence = body?.fence, att = body?.att;
607
+ if (!body || typeof body !== "object" || Array.isArray(body) || typeof body.instance !== "string" || !body.instance
608
+ || typeof body.boot !== "string" || !/^[0-9a-f]{1,32}$/.test(body.boot) || !Number.isSafeInteger(body.id) || body.id < 1
609
+ || (fence === undefined) === (att === undefined)
610
+ || (fence !== undefined && (!Number.isInteger(fence) || fence < 0 || fence > 999))
611
+ || (att !== undefined && (typeof att !== "string" || !isFileId(att)))
612
+ || (body.minutes !== undefined && typeof body.minutes !== "number")) {
613
+ json(res, 400, { error: "instance, boot, id, and fence (number) or att (file id); minutes? (number)" });
614
+ return;
615
+ }
616
+ const msg = ctx.webChatHistory?.find(body.instance, body.boot, body.id) ?? null;
617
+ if (!msg || msg.instance !== body.instance) {
618
+ json(res, 404, { error: "message_gone" });
619
+ return;
620
+ }
621
+ if (msg.role !== "agent") {
622
+ json(res, 400, { error: "not_an_agent_page" });
623
+ return;
624
+ }
625
+ let html, name = "";
626
+ if (fence !== undefined) {
627
+ const f = htmlFences(msg.text)[fence];
628
+ if (!f || !f.terminated) {
629
+ json(res, 404, { error: "page_gone" });
630
+ return;
631
+ }
632
+ html = f.code;
633
+ }
634
+ else {
635
+ const a = (msg.attachments ?? []).find((x) => !("gone" in x) && x.id === att);
636
+ if (!a || a.kind === "photo" || !/\.html?$/i.test(a.name.trim())) {
637
+ json(res, 404, { error: "page_gone" });
638
+ return;
639
+ }
640
+ const served = ctx.webFiles?.servedFile?.(a.id) ?? null;
641
+ if (!served) {
642
+ json(res, 404, { error: "page_gone" });
643
+ return;
644
+ }
645
+ if (served.size > PAGE_SHARE_LIMITS.maxBytes) {
646
+ json(res, 413, { error: "too_large" });
647
+ return;
648
+ }
649
+ const read = ctx.webFiles?.read?.(a.id) ?? null;
650
+ if (!read) {
651
+ json(res, 404, { error: "page_gone" });
652
+ return;
653
+ }
654
+ html = read.bytes.toString("utf8");
655
+ name = a.name;
656
+ }
657
+ const from = { boot: body.boot, id: body.id, ...(fence !== undefined ? { fence: fence } : { att: att }) };
658
+ // The caller is asked again once the tunnel is up (#2917 review P2): the same admin, the session still live.
659
+ const stillAllowed = () => { const currentCaller = who(); return !("status" in currentCaller) && currentCaller.person === now.person; };
660
+ const made = await service.createPage({ html, instance: body.instance, title: htmlTitle(html) || name, owner: now.person, from, stillAllowed,
661
+ ...(typeof body.minutes === "number" ? { minutes: body.minutes } : {}) });
662
+ if (!made.ok) {
663
+ const status = made.error === "disabled" ? 404 : made.error === "tunnel" ? 503 : made.error === "too_many" ? 409 : made.error === "too_large" ? 413 : made.error === "not_allowed" ? 403 : 400;
664
+ json(res, status, { error: made.error, ...(made.reason ? { reason: made.reason } : {}) });
665
+ return;
666
+ }
667
+ ctx.pageShareNotice?.(now.name, made.share);
668
+ json(res, 200, { share: made.share, url: made.url });
669
+ })().catch(err => {
670
+ ctx.logger.error({ err: err.message }, "Page share failed");
671
+ try {
672
+ json(res, 500, { error: "Page share failed" });
673
+ }
674
+ catch { /* already answered */ }
675
+ });
676
+ return true;
677
+ }
542
678
  /**
543
679
  * #2513: the Office view's skin packs (web-office-skins.ts). The reads are the offices' (GET /ui/offices): whoever may
544
680
  * open the org chart, the public link too (its manifest lists them; a guest's routes do not): the list, a pack's
@@ -2400,6 +2536,8 @@ export function handleWebRequest(req, res, url, ctx) {
2400
2536
  return handleOfficePackChanges(req, res, url, ctx, path, method);
2401
2537
  if (path === "/ui/office-share" || path.startsWith("/ui/office-share/"))
2402
2538
  return handleOfficeShare(req, res, url, ctx, path, method);
2539
+ if (path === "/ui/page-share" || path.startsWith("/ui/page-share/"))
2540
+ return handlePageShare(req, res, url, ctx, path, method);
2403
2541
  // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──
2404
2542
  const imageMatch = path.match(/^\/ui\/img\/(bot|avatar|emoji)\/([^/]+)$/);
2405
2543
  if (method === "GET" && imageMatch) {
@@ -2741,6 +2879,47 @@ export function handleWebRequest(req, res, url, ctx) {
2741
2879
  return handleBackendLogin(req, res, url, ctx, path, method);
2742
2880
  // #1386: Acknowledge a "Needs you" delivery item — a session write (CSRF, checked by the gate) or the CLI's header
2743
2881
  // token. Who did it is recorded by principal: the session's public handle, never the session id or a credential.
2882
+ // #2910 D2: a web-only fleet's Notices — what General would have said. Admins only (never a guest: not in
2883
+ // GUEST_ROUTES). GET /ui/notices/list lists them; POST /ui/notices/seen {id} marks everything up to it read (a session write).
2884
+ if (path === "/ui/notices/list" && method === "GET") {
2885
+ const feed = ctx.webNoticesFeed?.() ?? null;
2886
+ res.setHeader("Cache-Control", "no-store");
2887
+ json(res, 200, feed ? { enabled: true, ...feed.view() } : { enabled: false, items: [], seen: 0, unread: 0 });
2888
+ return true;
2889
+ }
2890
+ if (path === "/ui/notices/seen" && method === "POST") {
2891
+ (async () => {
2892
+ let body;
2893
+ try {
2894
+ body = await parseBody(req);
2895
+ }
2896
+ catch {
2897
+ json(res, 400, { error: "Invalid JSON" });
2898
+ return;
2899
+ }
2900
+ if (!permitWebContinuation(req, res, ctx))
2901
+ return;
2902
+ const id = body && typeof body === "object" ? body.id : undefined;
2903
+ if (typeof id !== "number" || !Number.isSafeInteger(id) || id < 0) {
2904
+ json(res, 400, { error: "id required" });
2905
+ return;
2906
+ }
2907
+ const feed = ctx.webNoticesFeed?.() ?? null;
2908
+ if (!feed) {
2909
+ json(res, 404, { error: "No notices here" });
2910
+ return;
2911
+ }
2912
+ const seen = feed.markSeen(id);
2913
+ json(res, 200, { seen, unread: feed.unread() });
2914
+ })().catch(err => {
2915
+ ctx.logger.error({ err: err.message }, "Notices mark-read failed");
2916
+ try {
2917
+ json(res, 500, { error: "Mark read failed" });
2918
+ }
2919
+ catch { /* already answered */ }
2920
+ });
2921
+ return true;
2922
+ }
2744
2923
  if (method === "POST" && path === "/ui/needs/ack") {
2745
2924
  if (!ctx.acknowledgeNeedsItem) {
2746
2925
  json(res, 404, { error: "Nothing to acknowledge here" });
@@ -2979,7 +3158,7 @@ export function handleWebRequest(req, res, url, ctx) {
2979
3158
  json(res, 404, { error: `Instance not found: ${name}` });
2980
3159
  return true;
2981
3160
  }
2982
- const topicMode = ctx.fleetConfig?.channel?.mode === "topic";
3161
+ const topicMode = daemonTopicMode(ctx.fleetConfig);
2983
3162
  (async () => {
2984
3163
  let held = false;
2985
3164
  try {