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

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 (189) hide show
  1. package/dist/agent-whats-new.js +2 -0
  2. package/dist/agent-whats-new.js.map +1 -1
  3. package/dist/auth-api.d.ts +30 -1
  4. package/dist/auth-api.js +42 -12
  5. package/dist/auth-api.js.map +1 -1
  6. package/dist/backend/claude-code.d.ts +2 -2
  7. package/dist/backend/claude-code.js +5 -4
  8. package/dist/backend/claude-code.js.map +1 -1
  9. package/dist/backend/types.d.ts +6 -0
  10. package/dist/backend/types.js +15 -0
  11. package/dist/backend/types.js.map +1 -1
  12. package/dist/backend-availability.d.ts +120 -0
  13. package/dist/backend-availability.js +396 -0
  14. package/dist/backend-availability.js.map +1 -0
  15. package/dist/backend-command.d.ts +21 -3
  16. package/dist/backend-command.js +23 -4
  17. package/dist/backend-command.js.map +1 -1
  18. package/dist/channel/adapters/discord.d.ts +7 -0
  19. package/dist/channel/adapters/discord.js +35 -0
  20. package/dist/channel/adapters/discord.js.map +1 -1
  21. package/dist/channel/mcp-tools.js +8 -4
  22. package/dist/channel/mcp-tools.js.map +1 -1
  23. package/dist/channel/types.d.ts +6 -0
  24. package/dist/classic-channel-manager.d.ts +8 -3
  25. package/dist/classic-channel-manager.js +10 -3
  26. package/dist/classic-channel-manager.js.map +1 -1
  27. package/dist/config-validator.js +73 -2
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/delivery-outbox.js +5 -0
  30. package/dist/delivery-outbox.js.map +1 -1
  31. package/dist/fleet-context.d.ts +10 -0
  32. package/dist/fleet-manager.d.ts +238 -5
  33. package/dist/fleet-manager.js +878 -71
  34. package/dist/fleet-manager.js.map +1 -1
  35. package/dist/fleet-traffic.d.ts +38 -0
  36. package/dist/fleet-traffic.js +96 -0
  37. package/dist/fleet-traffic.js.map +1 -0
  38. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +13 -5
  39. package/dist/general-knowledge/skills/multi-channel/SKILL.md +1 -1
  40. package/dist/general-knowledge/skills/web-appearance/SKILL.md +56 -11
  41. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +398 -0
  42. package/dist/human-delivery-receipts.js +2 -1
  43. package/dist/human-delivery-receipts.js.map +1 -1
  44. package/dist/locale.js +48 -8
  45. package/dist/locale.js.map +1 -1
  46. package/dist/memory-hogs.d.ts +27 -0
  47. package/dist/memory-hogs.js +124 -0
  48. package/dist/memory-hogs.js.map +1 -0
  49. package/dist/outbound-handlers.d.ts +2 -0
  50. package/dist/outbound-handlers.js +40 -1
  51. package/dist/outbound-handlers.js.map +1 -1
  52. package/dist/outbound-schemas.d.ts +92 -0
  53. package/dist/outbound-schemas.js +55 -1
  54. package/dist/outbound-schemas.js.map +1 -1
  55. package/dist/public-web-gateway.js +1 -1
  56. package/dist/public-web-gateway.js.map +1 -1
  57. package/dist/public-web-link.d.ts +9 -0
  58. package/dist/public-web-link.js +8 -0
  59. package/dist/public-web-link.js.map +1 -1
  60. package/dist/quickstart-api.d.ts +10 -1
  61. package/dist/quickstart-api.js +12 -4
  62. package/dist/quickstart-api.js.map +1 -1
  63. package/dist/reply-buttons-controller.d.ts +2 -0
  64. package/dist/reply-buttons-controller.js +1 -1
  65. package/dist/reply-buttons-controller.js.map +1 -1
  66. package/dist/tmux-manager.d.ts +5 -0
  67. package/dist/tmux-manager.js +16 -0
  68. package/dist/tmux-manager.js.map +1 -1
  69. package/dist/tool-permissions.js +4 -0
  70. package/dist/tool-permissions.js.map +1 -1
  71. package/dist/topic-commands.d.ts +5 -0
  72. package/dist/topic-commands.js +16 -0
  73. package/dist/topic-commands.js.map +1 -1
  74. package/dist/types.d.ts +25 -1
  75. package/dist/ui/app-effects.js +33 -9
  76. package/dist/ui/chat-effects.js +62 -4
  77. package/dist/ui/chat-strings.js +40 -4
  78. package/dist/ui/chat-thread.js +17 -6
  79. package/dist/ui/effect-trial-panel.js +19 -16
  80. package/dist/ui/fleet-canvas-layout.js +111 -0
  81. package/dist/ui/fleet-canvas.js +489 -0
  82. package/dist/ui/fleet-org.js +27 -12
  83. package/dist/ui/fleet-traffic.js +224 -0
  84. package/dist/ui/panel-chat.js +12 -5
  85. package/dist/ui/panel-details.js +2 -1
  86. package/dist/ui/panel-fleet.js +32 -11
  87. package/dist/ui/panel-settings.js +6 -4
  88. package/dist/ui/preview.js +3 -2
  89. package/dist/ui/settings-appearance.js +34 -18
  90. package/dist/ui/settings-catalog.js +89 -0
  91. package/dist/ui/settings-dialogs.js +40 -12
  92. package/dist/ui/settings-effects.js +4 -3
  93. package/dist/ui/settings-learn.js +25 -0
  94. package/dist/ui/settings-model.js +6 -2
  95. package/dist/ui/settings-mods.js +4 -3
  96. package/dist/ui/settings-owner-effects.js +8 -8
  97. package/dist/ui/settings-strings.js +40 -38
  98. package/dist/ui/shared/app-i18n.js +2 -2
  99. package/dist/ui/shared/app-prefs.js +1 -1
  100. package/dist/ui/shared/app-shell.js +23 -1
  101. package/dist/ui/shared/app-store.js +12 -1
  102. package/dist/ui/shared/app-stream.js +2 -1
  103. package/dist/ui/shared/app.css +186 -11
  104. package/dist/ui/shared/card-strings.js +4 -0
  105. package/dist/ui/shared/effect-app-runtime.js +1 -1
  106. package/dist/ui/shared/effect-frame.js +10 -5
  107. package/dist/ui/shared/effect-geometry.js +24 -9
  108. package/dist/ui/shared/effect-policy.js +2 -1
  109. package/dist/ui/shared/effect-registry.js +71 -17
  110. package/dist/ui/shared/effect-stock-trust.js +23 -2
  111. package/dist/ui/shared/effect-trial-shadow.js +62 -0
  112. package/dist/ui/shared/effect-trial-stage.js +15 -9
  113. package/dist/ui/shared/effect-trial.css +40 -0
  114. package/dist/ui/shared/identity-card.js +75 -4
  115. package/dist/ui/shared/identity-color.js +3 -3
  116. package/dist/ui/shared/instance-nav.js +20 -4
  117. package/dist/ui/shared/panel-view.js +24 -4
  118. package/dist/ui/shared/view-strings.js +22 -0
  119. package/dist/view-api.d.ts +32 -0
  120. package/dist/view-api.js +55 -4
  121. package/dist/view-api.js.map +1 -1
  122. package/dist/web-api.d.ts +19 -4
  123. package/dist/web-api.js +178 -20
  124. package/dist/web-api.js.map +1 -1
  125. package/dist/web-appearance-policy.d.ts +4 -6
  126. package/dist/web-appearance-policy.js +5 -12
  127. package/dist/web-appearance-policy.js.map +1 -1
  128. package/dist/web-appearance-tools.js +5 -1
  129. package/dist/web-appearance-tools.js.map +1 -1
  130. package/dist/web-appearance-view.d.ts +14 -1
  131. package/dist/web-appearance-view.js +98 -10
  132. package/dist/web-appearance-view.js.map +1 -1
  133. package/dist/web-appearance.d.ts +2 -2
  134. package/dist/web-appearance.js +5 -6
  135. package/dist/web-appearance.js.map +1 -1
  136. package/dist/web-auth.js +1 -1
  137. package/dist/web-auth.js.map +1 -1
  138. package/dist/web-card.d.ts +14 -0
  139. package/dist/web-card.js +40 -6
  140. package/dist/web-card.js.map +1 -1
  141. package/dist/web-channel-echo.d.ts +7 -1
  142. package/dist/web-channel-echo.js +6 -2
  143. package/dist/web-channel-echo.js.map +1 -1
  144. package/dist/web-chat-history.d.ts +40 -0
  145. package/dist/web-chat-history.js +66 -3
  146. package/dist/web-chat-history.js.map +1 -1
  147. package/dist/web-commands.d.ts +1 -0
  148. package/dist/web-effect-tools.js +2 -3
  149. package/dist/web-effect-tools.js.map +1 -1
  150. package/dist/web-effect-trials.d.ts +2 -2
  151. package/dist/web-effect-trials.js +3 -3
  152. package/dist/web-effect-trials.js.map +1 -1
  153. package/dist/web-guest-access.d.ts +125 -0
  154. package/dist/web-guest-access.js +232 -0
  155. package/dist/web-guest-access.js.map +1 -0
  156. package/dist/web-guest-rooms.d.ts +82 -0
  157. package/dist/web-guest-rooms.js +135 -0
  158. package/dist/web-guest-rooms.js.map +1 -0
  159. package/dist/web-identity-palette.d.ts +1 -1
  160. package/dist/web-identity-palette.js +2 -2
  161. package/dist/web-identity-palette.js.map +1 -1
  162. package/dist/web-login.d.ts +47 -0
  163. package/dist/web-login.js +115 -0
  164. package/dist/web-login.js.map +1 -1
  165. package/dist/web-mod-agent.d.ts +1 -1
  166. package/dist/web-mod-agent.js +2 -4
  167. package/dist/web-mod-agent.js.map +1 -1
  168. package/dist/web-mod-store.d.ts +5 -0
  169. package/dist/web-mod-store.js +6 -0
  170. package/dist/web-mod-store.js.map +1 -1
  171. package/dist/web-prefs.d.ts +8 -0
  172. package/dist/web-prefs.js +9 -0
  173. package/dist/web-prefs.js.map +1 -1
  174. package/dist/web-preview.d.ts +6 -2
  175. package/dist/web-preview.js +4 -2
  176. package/dist/web-preview.js.map +1 -1
  177. package/dist/web-returning-device.d.ts +39 -0
  178. package/dist/web-returning-device.js +46 -5
  179. package/dist/web-returning-device.js.map +1 -1
  180. package/dist/web-session.d.ts +58 -10
  181. package/dist/web-session.js +104 -22
  182. package/dist/web-session.js.map +1 -1
  183. package/dist/web-settings-tools.d.ts +81 -0
  184. package/dist/web-settings-tools.js +257 -0
  185. package/dist/web-settings-tools.js.map +1 -0
  186. package/dist/web-theme.d.ts +2 -0
  187. package/dist/web-theme.js +10 -1
  188. package/dist/web-theme.js.map +1 -1
  189. package/package.json +1 -1
package/dist/web-api.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { webHumanOrigin } from "./human-delivery-receipts.js";
2
+ import { guestChatRefusal, guestCommandText, guestDeliveries, guestFrameData, guestHistory, guestMessages, guestPollBody, guestPrefs, guestRequestOf, guestSeesMessage, guestStatus } from "./web-guest-access.js";
2
3
  import { withSpawnHoldReply } from "./spawn-hold-reply.js";
3
4
  import { binaryProbe } from "./binary-probe.js";
4
5
  import { settingsRequestExecution, settingsWrite, isSettingsReplay } from "./settings-request-capability.js";
@@ -97,11 +98,41 @@ const safeInstanceSegment = (name) => /^[^\\/\x00]+$/.test(name) && name !== "."
97
98
  * the eviction the dead entry leaks forever, and without the try/catch a
98
99
  * single dead client breaks delivery to every client iterated after it.
99
100
  */
101
+ /** #1920 phase 1b: the open streams that are a guest's, and whose — each event is projected for them (guestFrameData). */
102
+ const streamGuests = new WeakMap();
103
+ /**
104
+ * #1920 §5.2: one event for the guests' streams only, each as projected for it — a message that has just become what
105
+ * its channel saw (an admin's page already has it, unchanged).
106
+ */
107
+ export function broadcastToGuests(clients, event, data, onError) {
108
+ const guests = [...clients].filter((c) => streamGuests.has(c));
109
+ if (!guests.length)
110
+ return;
111
+ const live = new Set(guests);
112
+ broadcastSseEvent(live, event, data, onError);
113
+ for (const c of guests)
114
+ if (!live.has(c))
115
+ clients.delete(c); // the ones it ended (their session went)
116
+ }
100
117
  export function broadcastSseEvent(clients, event, data, onError, id) {
101
118
  const payload = sseFrame(event, data, id);
102
119
  const dead = [];
103
120
  for (const client of clients) {
104
121
  try {
122
+ // An admin's stream gets these bytes; a guest's, the event as projected for its scope now, or nothing (no id: it
123
+ // never replays chat, so a cursor of the admin's stream is not its to hold).
124
+ const guest = streamGuests.get(client);
125
+ if (guest) {
126
+ // The exact session that opened it ended (revoked, expired, rotated away): nothing more, and the stream goes.
127
+ if (!guest.current()) {
128
+ dead.push(client);
129
+ continue;
130
+ }
131
+ const shown = guestFrameData(event, data, guest.scope(), guest);
132
+ if (shown !== undefined)
133
+ client.write(sseFrame(event, shown));
134
+ continue;
135
+ }
105
136
  client.write(payload);
106
137
  }
107
138
  catch (err) {
@@ -382,7 +413,7 @@ function handleEffectTrials(req, res, url, ctx, path, method) {
382
413
  css = value;
383
414
  }
384
415
  catch { /* unavailable is never off */ }
385
- // CSS remains a checked runtime value. The authority refuses anything except off on start/read.
416
+ // CSS is diagnostic for Try; its host-owned shadow shell isolates controls and local paint.
386
417
  const actor = Object.freeze({ via: "session", surface: "local", tier: "admin", sessionHash: v.session.idHash, epoch: v.session.handle, person, ownerCss: css });
387
418
  return { actor, record: v.session, sessions };
388
419
  };
@@ -946,6 +977,17 @@ export function handleWebRequest(req, res, url, ctx) {
946
977
  return true;
947
978
  }
948
979
  // ── Backend detection ─────────────────────────────────
980
+ // #2072: the model a Settings field offers — the cached catalog of that backend (that instance's account's). Read only.
981
+ if (method === "GET" && path === "/ui/models") {
982
+ const backend = url.searchParams.get("backend") ?? "";
983
+ const instance = url.searchParams.get("instance");
984
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(backend) || (instance !== null && (instance.length < 1 || instance.length > 256))) {
985
+ json(res, 400, { error: "backend (and optional instance) required" });
986
+ return true;
987
+ }
988
+ json(res, 200, { backend, models: ctx.modelCatalogFor?.(backend, instance) ?? [] });
989
+ return true;
990
+ }
949
991
  if (method === "GET" && path === "/ui/backends") {
950
992
  const BACKENDS = [
951
993
  { name: "claude-code", binary: "claude" },
@@ -956,6 +998,16 @@ export function handleWebRequest(req, res, url, ctx) {
956
998
  { name: "grok", binary: "grok" },
957
999
  { name: "muse", binary: "muse" },
958
1000
  ];
1001
+ // #2072: the published availability snapshot — installed, signed in, new enough — when the fleet has one.
1002
+ if (ctx.backendAvailabilityRows) {
1003
+ const binaryOf = new Map(BACKENDS.map(b => [b.name, b]));
1004
+ const backends = ctx.backendAvailabilityRows().filter(r => binaryOf.has(r.backend)).map(r => ({
1005
+ name: r.backend, binary: binaryOf.get(r.backend).binary, installed: r.state !== "not_installed" && r.path !== null, path: r.path ?? "",
1006
+ deprecated: binaryOf.get(r.backend).deprecated ?? false, state: r.state, minVersion: r.minVersion, ...(r.state === "unknown" ? { unknown: true } : {}),
1007
+ }));
1008
+ json(res, 200, { backends });
1009
+ return true;
1010
+ }
959
1011
  // #1490: seven sequential 2 s `execFileSync("which")` calls used to block the event loop here. The shared probe
960
1012
  // runs `which` as bounded async children and reuses an answer for BINARY_PROBE_TTL_MS. `unknown`: no answer in
961
1013
  // time (shown as not installed, as the old timeout was).
@@ -978,9 +1030,24 @@ export function handleWebRequest(req, res, url, ctx) {
978
1030
  // that arrived while the stream was silent is not skipped by the cursor this answer hands out (#1251
979
1031
  // review). The page keeps one entry per boot+id, so what it already loaded from /ui/history is not doubled.
980
1032
  if (method === "GET" && path === "/ui/poll") {
1033
+ // #1920: a guest's poll is projected for its scope (web-guest-access.ts) — the same rule as its stream.
1034
+ const guest = guestRequestOf(req);
981
1035
  const history = ctx.webChatHistory;
982
1036
  const after = url.searchParams.get("after");
983
1037
  const fresh = after === null || after === ""; // a cursor that is not ours in shape still gets nothing
1038
+ if (guest) {
1039
+ // #2085 review: and what became the channel's after its cursor (an older row, its copy sent since), once each.
1040
+ json(res, 200, guestPollBody(ctx.getUiStatus(), guest.scope(), Date.now(), guest, after, () => {
1041
+ if (!history)
1042
+ return [];
1043
+ if (fresh)
1044
+ return history.after(0);
1045
+ const cursor = parseLastEventId(after);
1046
+ const rows = [...history.replayFor(cursor), ...history.seenAfter(cursor)];
1047
+ return rows.filter((m, i) => rows.findIndex((x) => x.boot === m.boot && x.id === m.id) === i);
1048
+ }));
1049
+ return true;
1050
+ }
984
1051
  json(res, 200, {
985
1052
  status: ctx.getUiStatus(),
986
1053
  messages: history ? (fresh ? history.after(0) : history.replayFor(parseLastEventId(after))) : [],
@@ -1013,30 +1080,49 @@ export function handleWebRequest(req, res, url, ctx) {
1013
1080
  });
1014
1081
  // #1823 (#1830 review): with the status, the server's clock — now and with every heartbeat — that the page counts a
1015
1082
  // poll down by (one write: the frames a connect sends are unchanged in number).
1016
- res.write(`event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n${sseClock()}`);
1083
+ // #1920: a guest's stream: its projected status now and with each heartbeat, its replay and ticks as it may see them
1084
+ // (§5.2), then only what guestFrameData lets through — no prompts or Needs you.
1085
+ const guest = guestRequestOf(req);
1086
+ if (guest) {
1087
+ streamGuests.set(res, guest);
1088
+ guest.streamOpened?.();
1089
+ }
1090
+ const statusFrame = () => `event: status\ndata: ${JSON.stringify(guest ? guestStatus(ctx.getUiStatus(), guest.scope()) : ctx.getUiStatus())}\n\n`;
1091
+ res.write(`${statusFrame()}${sseClock()}`);
1017
1092
  // An EventSource that reconnects says what it last saw: send what it missed, before anything new.
1018
1093
  const lastSeen = parseLastEventId(req.headers["last-event-id"]);
1019
1094
  if (lastSeen !== null && ctx.webChatHistory) {
1020
1095
  const history = ctx.webChatHistory;
1021
- for (const m of history.replayFor(lastSeen))
1022
- res.write(sseFrame("message", m, history.cursorOf(m)));
1096
+ if (guest) {
1097
+ // Its own projection, with no cursor: an admin's ids are not a guest's to hold (it reloads its history instead).
1098
+ for (const m of guestMessages(history.replayFor(lastSeen), guest, guest.scope()))
1099
+ res.write(sseFrame("message", m));
1100
+ }
1101
+ else {
1102
+ for (const m of history.replayFor(lastSeen))
1103
+ res.write(sseFrame("message", m, history.cursorOf(m)));
1104
+ }
1023
1105
  }
1024
1106
  // Ticks change messages already sent and carry no cursor: every (re)connect gets where each one is now, so a
1025
1107
  // report that fell into a gap is not lost until a reload (#1253 review). No id: it is not a message.
1026
- if (ctx.webChatHistory)
1027
- res.write(`event: deliveries\ndata: ${JSON.stringify(ctx.webChatHistory.deliveries())}\n\n`);
1108
+ if (ctx.webChatHistory) {
1109
+ const ticks = guest ? guestDeliveries(ctx.webChatHistory, guest, guest.scope()) : ctx.webChatHistory.deliveries();
1110
+ res.write(`event: deliveries\ndata: ${JSON.stringify(ticks)}\n\n`);
1111
+ }
1028
1112
  // The same for the fleet prompts (C4): one posted, answered or expired during a gap is caught up here.
1029
- if (ctx.listWebPrompts)
1113
+ if (!guest && ctx.listWebPrompts)
1030
1114
  res.write(`event: prompts\ndata: ${JSON.stringify(ctx.listWebPrompts())}\n\n`);
1031
1115
  // #1386: and "Needs you" as it is now; changes follow as `needs` events.
1032
- if (ctx.needsYouItems)
1116
+ if (!guest && ctx.needsYouItems)
1033
1117
  res.write(`event: needs\ndata: ${JSON.stringify({ items: ctx.needsYouItems() })}\n\n`);
1034
1118
  ctx.sseClients.add(res);
1035
1119
  const interval = setInterval(() => {
1036
1120
  // A stream authorized once must not outlive the authorization. Re-checked
1037
1121
  // without counting as activity, or an open tab would keep an idle session
1038
1122
  // alive forever; a revoked, expired or rotated-away session ends here.
1039
- if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false })) {
1123
+ // #2049 review: a guest's stream also needs the exact session that opened it — another cookie on the same request
1124
+ // still authenticating does not keep a revoked device's stream (with its scope) alive.
1125
+ if (!isWebRequestAuthorized(req, url, ctx.webToken, ctx.webSessions, { touch: false }) || (guest && !guest.current())) {
1040
1126
  // Close the connection too, not just the response: an ended response leaves the socket idle in
1041
1127
  // keep-alive, still held open by whoever's session just ended.
1042
1128
  const socket = req.socket;
@@ -1049,7 +1135,7 @@ export function handleWebRequest(req, res, url, ctx) {
1049
1135
  }
1050
1136
  try {
1051
1137
  // Every open dashboard tab, every 10 s: the status of every instance, serialized on the fleet's loop (#1235).
1052
- res.write(measureSyncWork("web.sseStatus", () => `event: status\ndata: ${JSON.stringify(ctx.getUiStatus())}\n\n`) + sseClock());
1138
+ res.write(measureSyncWork("web.sseStatus", statusFrame) + sseClock());
1053
1139
  }
1054
1140
  catch {
1055
1141
  cleanup();
@@ -1083,7 +1169,10 @@ export function handleWebRequest(req, res, url, ctx) {
1083
1169
  json(res, 400, { error: "instance (1-128 chars) and limit (1-500) required" });
1084
1170
  return true;
1085
1171
  }
1086
- json(res, 200, { messages: ctx.webChatHistory?.list(instance, limit) ?? [], boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
1172
+ // #1920 §5.2: a guest gets what the channel showed of it, and its own messages (the gate checked the scope).
1173
+ const guest = guestRequestOf(req);
1174
+ const messages = !ctx.webChatHistory ? [] : guest ? guestHistory(ctx.webChatHistory, instance, limit, guest, guest.scope()) : ctx.webChatHistory.list(instance, limit);
1175
+ json(res, 200, { messages, boot: ctx.webChatHistory?.boot ?? null, lastId: ctx.webChatHistory?.lastId ?? 0 });
1087
1176
  return true;
1088
1177
  }
1089
1178
  // ── #1218: the Log tab ─────────────────────────────────
@@ -1138,6 +1227,16 @@ export function handleWebRequest(req, res, url, ctx) {
1138
1227
  const fileMatch = path.match(/^\/ui\/file\/([^/]+)$/);
1139
1228
  if (method === "GET" && fileMatch) {
1140
1229
  const id = fileMatch[1];
1230
+ // #1920: a guest gets a file only when its stored instance is in scope — "no such file" otherwise, like an unknown id
1231
+ // (an id from another instance's chat is useless).
1232
+ const guest = guestRequestOf(req);
1233
+ if (guest) {
1234
+ const served = isFileId(id) ? ctx.webFiles?.servedFile?.(id) ?? null : null;
1235
+ if (!served || !guest.scope().has(served.instance)) {
1236
+ json(res, 404, { error: "No such file" });
1237
+ return true;
1238
+ }
1239
+ }
1141
1240
  // #1589: audio and video, played in the page — one byte range at a time (206), by id only, never a whole video in
1142
1241
  // memory. Every other type is answered exactly as before.
1143
1242
  const media = isFileId(id) ? ctx.webFiles?.mediaOf?.(id) ?? null : null;
@@ -1457,13 +1556,28 @@ export function handleWebRequest(req, res, url, ctx) {
1457
1556
  json(res, 400, { error: "instance, id and index required" });
1458
1557
  return;
1459
1558
  }
1559
+ // #1920 step 2b: a guest clicks only on a reply it may see (§5.2), of an in-scope instance, while guests may chat.
1560
+ const guest = guestRequestOf(req);
1561
+ const refused = guestChatRefusal(guest, instance);
1562
+ if (refused) {
1563
+ json(res, refused.status, { error: refused.error });
1564
+ return;
1565
+ }
1566
+ if (guest) {
1567
+ const row = guest.history?.()?.findByButtons(instance, id);
1568
+ if (!row || !guestSeesMessage(row, guest, guest.scope())) {
1569
+ json(res, 404, { error: "not found" });
1570
+ return;
1571
+ }
1572
+ }
1460
1573
  // #1823/#1827: the click is this page's person's — on a poll their vote (one per person, on every device of
1461
1574
  // theirs), on one-click buttons their choice — under their web id and name. A request that no longer passes the
1462
1575
  // gate (a session revoked meanwhile) clicks nothing as anyone.
1463
1576
  const person = webPersonOr401(req, res, url, ctx);
1464
1577
  if (!person)
1465
1578
  return;
1466
- const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name });
1579
+ // #1920 review: a guest's choice reaches the agent marked as a guest's, as its typed messages do.
1580
+ const r = await click(instance, id, index, { key: person.voterKey, userId: person.userId, name: person.name, ...(guest ? { guest: true } : {}) });
1467
1581
  json(res, r.status, r.status === 200 ? { answered: true, ...(r.options ? { options: r.options } : {}) } : { error: r.error ?? "Refused", ...(r.status === 409 ? { gone: true } : {}) });
1468
1582
  })().catch(err => {
1469
1583
  ctx.logger.error({ err: err.message }, "Web reply-button click failed");
@@ -1705,6 +1819,12 @@ export function handleWebRequest(req, res, url, ctx) {
1705
1819
  json(res, 404, { error: `Instance not found: ${name}` });
1706
1820
  return true;
1707
1821
  }
1822
+ // #1920 step 2b: a guest's Cancel is the platform button's (anyone allowed there), while guests may chat.
1823
+ const cancelRefused = guestChatRefusal(guestRequestOf(req), name);
1824
+ if (cancelRefused) {
1825
+ json(res, cancelRefused.status, { error: cancelRefused.error });
1826
+ return true;
1827
+ }
1708
1828
  if (!ctx.cancelInstance(name)) {
1709
1829
  json(res, 409, { error: `${name} is not running` });
1710
1830
  return true;
@@ -2131,6 +2251,12 @@ export function handleWebRequest(req, res, url, ctx) {
2131
2251
  json(res, 200, ctx.orgChart?.() ?? { general: [], teams: [], instances: {} });
2132
2252
  return true;
2133
2253
  }
2254
+ // #1918: the org canvas's traffic on opening, and the public link's (which has no stream) on its poll cadence. The
2255
+ // fleet works it out at most once every two seconds and caps it, whoever asks how often.
2256
+ if (method === "GET" && path === "/ui/traffic") {
2257
+ json(res, 200, ctx.trafficSnapshot?.() ?? { now: new Date().toISOString(), items: [] });
2258
+ return true;
2259
+ }
2134
2260
  if (method === "GET" && path === "/ui/teams") {
2135
2261
  const teams = ctx.fleetConfig?.teams ?? {};
2136
2262
  json(res, 200, { teams });
@@ -2342,6 +2468,17 @@ function handleSendMessage(req, res, ctx) {
2342
2468
  return;
2343
2469
  }
2344
2470
  const { instance, message: typed, attachments = [] } = parsed.data;
2471
+ // #1920 step 2b: a guest writes only to an in-scope instance, while guests may chat, and never a command.
2472
+ const guest = guestRequestOf(req);
2473
+ const refused = guestChatRefusal(guest, instance);
2474
+ if (refused) {
2475
+ json(res, refused.status, { error: refused.error });
2476
+ return;
2477
+ }
2478
+ if (guest && guestCommandText(typed)) {
2479
+ json(res, 403, { error: "guest_no_commands" });
2480
+ return;
2481
+ }
2345
2482
  const ipc = ctx.instanceIpcClients.get(instance);
2346
2483
  if (!ipc) {
2347
2484
  json(res, 404, { error: `Instance not found: ${instance}` });
@@ -2389,11 +2526,13 @@ function handleSendMessage(req, res, ctx) {
2389
2526
  && !ctx.isClassicInstance?.(instance) && syncAdapter && groupId && topicId != null;
2390
2527
  // Add no runtime credentials or local attachment paths to the display copy.
2391
2528
  const preview = webEchoPreview(message, files.map(f => f.name));
2392
- // ClassicBot echo (#1320 part B): same ordering lane, per-entry
2393
- // opt-in resolved at send time inside sendClassicWebEcho.
2529
+ // ClassicBot echo (#1320 part B): same ordering lane, each room's setting (#2054: on unless it says false, or
2530
+ // the fleet-wide switch is off for a room that is unset) resolved at send time inside sendClassicWebEcho.
2394
2531
  const canClassicEcho = ctx.isClassicInstance?.(instance) === true && !!ctx.sendClassicWebEcho;
2395
2532
  const settleClassicEcho = canClassicEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
2396
- await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), person.name);
2533
+ const posted = await ctx.sendClassicWebEcho(instance, formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), person.name);
2534
+ if (posted > 0)
2535
+ ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the room saw it
2397
2536
  }) : undefined;
2398
2537
  const settleEcho = canEcho ? ctx.reserveWebChannelEcho?.(instance, async () => {
2399
2538
  // A replacement adapter or edited binding is not the route we reserved.
@@ -2401,7 +2540,9 @@ function handleSendMessage(req, res, ctx) {
2401
2540
  || (ctx.getGroupIdForInstance?.(instance) ?? String(ctx.fleetConfig?.channel?.group_id ?? "")) !== groupId
2402
2541
  || ctx.fleetConfig?.instances[instance]?.topic_id !== topicId)
2403
2542
  return;
2404
- return syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text")), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
2543
+ const sent = await syncAdapter.sendText(String(groupId), formatWebChannelEcho(person.name, preview, t("web.echo_full_text"), { guest: !!guest }), { threadId: String(topicId), format: "text", allowedMentions: { parse: [] } });
2544
+ ctx.webMessageInChannel?.(instance, messageId); // #1920 §5.2: the topic saw it
2545
+ return sent;
2405
2546
  }) : undefined;
2406
2547
  let delivered;
2407
2548
  try {
@@ -2419,6 +2560,7 @@ function handleSendMessage(req, res, ctx) {
2419
2560
  ts,
2420
2561
  thread_id: topicId != null ? String(topicId) : "",
2421
2562
  source: "web",
2563
+ ...(guest ? { web_guest: "1" } : {}), // #1920: "[user:… via web (guest), id:…]" (authority none)
2422
2564
  adapter_id: syncAdapter?.id,
2423
2565
  ...delivery.meta,
2424
2566
  },
@@ -2455,7 +2597,8 @@ function handleSendMessage(req, res, ctx) {
2455
2597
  ctx.webFiles?.commit(files);
2456
2598
  ctx.lastInboundUser.set(instance, person.name);
2457
2599
  ctx.eventLog?.logActivity("message", person.name, (message || `[${files.length} file(s)]`).slice(0, 200), instance);
2458
- ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId });
2600
+ // #1920: the account that wrote it (a guest sees its own); channel-visible once its copy was sent (above).
2601
+ ctx.emitSseEvent("message", { instance, sender: person.name, role: "user", text: message, ts, attachments: files.map(publicAttachment), messageId, ...(person.bound ? { from: person.voterKey } : {}) });
2459
2602
  json(res, 200, { sent: true, messageId });
2460
2603
  }
2461
2604
  catch {
@@ -2479,6 +2622,13 @@ function handleUpload(req, res, url, ctx) {
2479
2622
  json(res, 404, { error: "uploads are not available" });
2480
2623
  return;
2481
2624
  }
2625
+ // #1920 step 2b: a guest's upload is for an in-scope instance's message, while guests may chat.
2626
+ const guestRefused = guestChatRefusal(guestRequestOf(req), instance);
2627
+ if (guestRefused) {
2628
+ req.resume();
2629
+ json(res, guestRefused.status, { error: guestRefused.error });
2630
+ return;
2631
+ }
2482
2632
  if (!instance || instance.length > 128 || !safeInstanceSegment(instance) || !ctx.instanceIpcClients.has(instance)) {
2483
2633
  json(res, 404, { error: `Instance not found: ${instance}` });
2484
2634
  return;
@@ -2673,14 +2823,22 @@ function prefsViewer(req, url, ctx) {
2673
2823
  const v = evaluateWebRequest(req, url, ctx.webToken ?? null, ctx.webSessions, { touch: false });
2674
2824
  if (v.kind !== "allow")
2675
2825
  return null;
2826
+ // #1920: a guest is a guest on every listener. The public link's page and reads too — before, a guest signed in through
2827
+ // it (#2094) read the host's preferences unfiltered: the sidebar's order and favourites, naming instances out of its
2828
+ // scope.
2829
+ const guest = v.via === "session" && v.session?.tier === "guest" ? { guest: true } : {};
2676
2830
  if (!gatewayRequestContext(req) && v.via === "session" && v.session) {
2677
2831
  const person = webPersonKey(v.session.owner);
2678
- return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session" };
2832
+ return { person, epoch: v.session.handle, scope: person === OPERATOR ? "operator" : "person", canWrite: v.session.tier === "admin", via: "session", ...guest };
2679
2833
  }
2680
- return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via };
2834
+ return { person: null, epoch: null, scope: "none", canWrite: v.via !== "session" || v.session?.tier === "admin", via: v.via, ...guest };
2681
2835
  }
2682
2836
  const prefsScope = (v) => ({ ownerScope: v?.scope ?? "none", ownerEpoch: v?.epoch ?? null });
2683
- const prefsView = (prefs, v) => ({ ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) });
2837
+ // #1920: a guest reads the host's look and its own, never a preference that names an instance (guestPrefs).
2838
+ const prefsView = (prefs, v) => {
2839
+ const view = { ...prefs.readForViewer(v?.person ?? null), ...prefsScope(v) };
2840
+ return v?.guest ? guestPrefs(view) : view;
2841
+ };
2684
2842
  function shellHtmlTag(ctx, mode, viewer) {
2685
2843
  if (mode !== "full" || !ctx.webPrefs)
2686
2844
  return '<html lang="en">';