@songsid/agend 2.3.0-beta.6 → 2.3.0-beta.8

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 (193) hide show
  1. package/dist/agent-cli-instructions.md +1 -1
  2. package/dist/agent-cli.js +21 -2
  3. package/dist/agent-cli.js.map +1 -1
  4. package/dist/agent-endpoint.js +4 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-file-read.js +68 -8
  7. package/dist/agent-file-read.js.map +1 -1
  8. package/dist/agent-whats-new.js +4 -0
  9. package/dist/agent-whats-new.js.map +1 -1
  10. package/dist/auth-api.js +3 -1
  11. package/dist/auth-api.js.map +1 -1
  12. package/dist/authority/authority-state.d.ts +38 -0
  13. package/dist/authority/authority-state.js +114 -0
  14. package/dist/authority/authority-state.js.map +1 -0
  15. package/dist/authority/session-authorizations.d.ts +43 -0
  16. package/dist/authority/session-authorizations.js +102 -0
  17. package/dist/authority/session-authorizations.js.map +1 -0
  18. package/dist/authority/web-authority.d.ts +31 -11
  19. package/dist/authority/web-authority.js +39 -12
  20. package/dist/authority/web-authority.js.map +1 -1
  21. package/dist/cache-ledger.d.ts +16 -3
  22. package/dist/cache-ledger.js +33 -13
  23. package/dist/cache-ledger.js.map +1 -1
  24. package/dist/cache-service.d.ts +55 -3
  25. package/dist/cache-service.js +152 -16
  26. package/dist/cache-service.js.map +1 -1
  27. package/dist/channel/mcp-tools.js +13 -7
  28. package/dist/channel/mcp-tools.js.map +1 -1
  29. package/dist/classic-channel-manager.d.ts +2 -2
  30. package/dist/classic-channel-manager.js +2 -6
  31. package/dist/classic-channel-manager.js.map +1 -1
  32. package/dist/config-validator.js +10 -4
  33. package/dist/config-validator.js.map +1 -1
  34. package/dist/daemon.d.ts +2 -0
  35. package/dist/daemon.js +31 -9
  36. package/dist/daemon.js.map +1 -1
  37. package/dist/delivery-outbox.d.ts +8 -0
  38. package/dist/delivery-outbox.js +7 -1
  39. package/dist/delivery-outbox.js.map +1 -1
  40. package/dist/fleet-manager.d.ts +109 -2
  41. package/dist/fleet-manager.js +280 -12
  42. package/dist/fleet-manager.js.map +1 -1
  43. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +2 -1
  44. package/dist/general-knowledge/skills/multi-channel/SKILL.md +2 -1
  45. package/dist/general-knowledge/skills/web-appearance/SKILL.md +89 -30
  46. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +62 -8
  47. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  48. package/dist/human-delivery-receipts.d.ts +14 -0
  49. package/dist/human-delivery-receipts.js +42 -6
  50. package/dist/human-delivery-receipts.js.map +1 -1
  51. package/dist/instance-config-impact.js +1 -0
  52. package/dist/instance-config-impact.js.map +1 -1
  53. package/dist/instance-names.d.ts +4 -0
  54. package/dist/instance-names.js +12 -0
  55. package/dist/instance-names.js.map +1 -0
  56. package/dist/instructions.d.ts +8 -1
  57. package/dist/instructions.js +9 -2
  58. package/dist/instructions.js.map +1 -1
  59. package/dist/locale.js +52 -0
  60. package/dist/locale.js.map +1 -1
  61. package/dist/outbound-handlers.d.ts +6 -0
  62. package/dist/outbound-handlers.js +49 -2
  63. package/dist/outbound-handlers.js.map +1 -1
  64. package/dist/outbound-schemas.d.ts +57 -1
  65. package/dist/outbound-schemas.js +34 -2
  66. package/dist/outbound-schemas.js.map +1 -1
  67. package/dist/public-web-gateway.js +8 -2
  68. package/dist/public-web-gateway.js.map +1 -1
  69. package/dist/rollout-index.d.ts +5 -0
  70. package/dist/rollout-index.js +26 -5
  71. package/dist/rollout-index.js.map +1 -1
  72. package/dist/settings-api.d.ts +6 -0
  73. package/dist/settings-api.js +54 -1
  74. package/dist/settings-api.js.map +1 -1
  75. package/dist/settings-change.js +31 -0
  76. package/dist/settings-change.js.map +1 -1
  77. package/dist/settings-effect.js +10 -1
  78. package/dist/settings-effect.js.map +1 -1
  79. package/dist/tool-permissions.d.ts +0 -7
  80. package/dist/tool-permissions.js +73 -5
  81. package/dist/tool-permissions.js.map +1 -1
  82. package/dist/topic-commands.d.ts +2 -1
  83. package/dist/topic-commands.js +2 -4
  84. package/dist/topic-commands.js.map +1 -1
  85. package/dist/types.d.ts +13 -2
  86. package/dist/ui/app-effects.js +46 -28
  87. package/dist/ui/chat-ambient.js +307 -0
  88. package/dist/ui/chat-effect-posters.js +8 -8
  89. package/dist/ui/chat-effects.js +51 -22
  90. package/dist/ui/chat-strings.js +21 -6
  91. package/dist/ui/chat-thread.js +1 -1
  92. package/dist/ui/fleet-usage.js +401 -0
  93. package/dist/ui/panel-chat.js +22 -9
  94. package/dist/ui/panel-fleet.js +6 -5
  95. package/dist/ui/panel-settings.js +10 -2
  96. package/dist/ui/settings-appearance.js +8 -0
  97. package/dist/ui/settings-mods.js +19 -6
  98. package/dist/ui/settings-owner-effects.js +3 -3
  99. package/dist/ui/settings-strings.js +30 -0
  100. package/dist/ui/settings-web-authority.js +91 -0
  101. package/dist/ui/shared/app-route.js +1 -1
  102. package/dist/ui/shared/app-session.js +2 -0
  103. package/dist/ui/shared/app-shell.js +2 -1
  104. package/dist/ui/shared/app-themes.js +24 -6
  105. package/dist/ui/shared/app.css +95 -0
  106. package/dist/ui/shared/effect-app-runtime.js +68 -19
  107. package/dist/ui/shared/effect-chat-runtime.js +16 -3
  108. package/dist/ui/shared/effect-frame.js +17 -3
  109. package/dist/ui/shared/effect-policy.js +18 -5
  110. package/dist/ui/shared/effect-profiler.js +31 -9
  111. package/dist/ui/shared/effect-registry.js +17 -41
  112. package/dist/ui/shared/effect-stock-trust.js +4 -4
  113. package/dist/ui/shared/effect-trial-stage.js +18 -7
  114. package/dist/ui/shared/fx-motion.js +67 -0
  115. package/dist/ui/shared/usage-dialog.js +8 -0
  116. package/dist/ui/shared/view-strings.js +4 -0
  117. package/dist/ui/shared/web-authorize.js +151 -0
  118. package/dist/usage-report.d.ts +158 -0
  119. package/dist/usage-report.js +226 -0
  120. package/dist/usage-report.js.map +1 -0
  121. package/dist/usage-rollup.d.ts +51 -0
  122. package/dist/usage-rollup.js +168 -0
  123. package/dist/usage-rollup.js.map +1 -0
  124. package/dist/web-ambient.d.ts +72 -0
  125. package/dist/web-ambient.js +154 -0
  126. package/dist/web-ambient.js.map +1 -0
  127. package/dist/web-api.d.ts +33 -0
  128. package/dist/web-api.js +148 -18
  129. package/dist/web-api.js.map +1 -1
  130. package/dist/web-appearance-tools.d.ts +3 -0
  131. package/dist/web-appearance-tools.js +18 -3
  132. package/dist/web-appearance-tools.js.map +1 -1
  133. package/dist/web-appearance-view.d.ts +14 -2
  134. package/dist/web-appearance-view.js +32 -5
  135. package/dist/web-appearance-view.js.map +1 -1
  136. package/dist/web-appearance.d.ts +15 -1
  137. package/dist/web-appearance.js +77 -1
  138. package/dist/web-appearance.js.map +1 -1
  139. package/dist/web-asset-parts.d.ts +2 -2
  140. package/dist/web-asset-parts.js +3 -3
  141. package/dist/web-asset-parts.js.map +1 -1
  142. package/dist/web-auth.js +3 -1
  143. package/dist/web-auth.js.map +1 -1
  144. package/dist/web-effect-bootstrap.d.ts +12 -0
  145. package/dist/web-effect-bootstrap.js +49 -0
  146. package/dist/web-effect-bootstrap.js.map +1 -0
  147. package/dist/web-effect-bundle.js +46 -1
  148. package/dist/web-effect-bundle.js.map +1 -1
  149. package/dist/web-effect-playback.d.ts +30 -0
  150. package/dist/web-effect-playback.js +71 -0
  151. package/dist/web-effect-playback.js.map +1 -0
  152. package/dist/web-effect-profiles.d.ts +4 -4
  153. package/dist/web-effect-service.d.ts +11 -4
  154. package/dist/web-effect-service.js +25 -4
  155. package/dist/web-effect-service.js.map +1 -1
  156. package/dist/web-effect-tools.d.ts +1 -0
  157. package/dist/web-effect-tools.js +2 -1
  158. package/dist/web-effect-tools.js.map +1 -1
  159. package/dist/web-effect-worker.d.ts +1 -1
  160. package/dist/web-effect-worker.js +2 -2
  161. package/dist/web-effect-worker.js.map +1 -1
  162. package/dist/web-guest-access.js +3 -1
  163. package/dist/web-guest-access.js.map +1 -1
  164. package/dist/web-mod-agent.js +5 -1
  165. package/dist/web-mod-agent.js.map +1 -1
  166. package/dist/web-mod-bundle.d.ts +9 -1
  167. package/dist/web-mod-bundle.js +62 -3
  168. package/dist/web-mod-bundle.js.map +1 -1
  169. package/dist/web-mod-export.d.ts +9 -0
  170. package/dist/web-mod-export.js +21 -0
  171. package/dist/web-mod-export.js.map +1 -1
  172. package/dist/web-mod-store.d.ts +2 -1
  173. package/dist/web-mod-store.js +13 -6
  174. package/dist/web-mod-store.js.map +1 -1
  175. package/dist/web-session-authorization.d.ts +132 -0
  176. package/dist/web-session-authorization.js +193 -0
  177. package/dist/web-session-authorization.js.map +1 -0
  178. package/dist/web-session.d.ts +5 -0
  179. package/dist/web-session.js +16 -0
  180. package/dist/web-session.js.map +1 -1
  181. package/dist/web-settings-patch.d.ts +26 -2
  182. package/dist/web-settings-patch.js +50 -1
  183. package/dist/web-settings-patch.js.map +1 -1
  184. package/dist/web-settings-tools.d.ts +8 -0
  185. package/dist/web-settings-tools.js +14 -1
  186. package/dist/web-settings-tools.js.map +1 -1
  187. package/dist/web-shell-routes.d.ts +1 -1
  188. package/dist/web-shell-routes.js +1 -1
  189. package/dist/web-shell-routes.js.map +1 -1
  190. package/dist/web-theme-store.d.ts +39 -0
  191. package/dist/web-theme-store.js +122 -11
  192. package/dist/web-theme-store.js.map +1 -1
  193. package/package.json +1 -1
@@ -44,6 +44,7 @@ import { startTour } from "./chat-tour.js";
44
44
  import "./chat-strings.js";
45
45
  import { AppearanceSection } from "./settings-appearance.js";
46
46
  import { GuestsCard } from "./settings-guests.js";
47
+ import { WebAuthorityCard } from "./settings-web-authority.js";
47
48
 
48
49
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
49
50
  const ICONS = { device: "user", appearance: "palette", agents: "bot", bots: "plug", classic: "room", general: "sliders", web: "globe", advanced: "code" };
@@ -772,17 +773,20 @@ export const WEB_READ_ONLY = [
772
773
  function WebSection({ ctx, staged }) {
773
774
  const { schema } = ctx;
774
775
  const web = ctx.fleet.web || {}, local = (web.session && web.session.local) || {}, term = ctx.fleet.web_terminal || {};
776
+ // Over the public link the fields read only; the Guests and Web authority cards are not offered (the server never
777
+ // serves their reads there).
775
778
  const overPublicLink = globalThis.document?.body?.dataset?.publicLink === "1";
776
779
  const original = useMemo(() => ({
777
780
  echo: web.echo_to_channel ?? true,
778
781
  log: web.log_tab === true, // #1218: off by default
782
+ usagePub: web.usage_report_on_public_link === true, // #2122: off by default
779
783
  pub: { allow_public: (web.public_link && web.public_link.allow_public) ?? true, ttl_minutes: (web.public_link && web.public_link.ttl_minutes) ?? 120, protocol: (web.public_link && web.public_link.protocol) ?? "http2" },
780
784
  session: { absolute_hours: local.absolute_hours ?? null, idle_hours: local.idle_hours ?? null },
781
785
  terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
782
786
  }), []);
783
787
  const text = (v) => (v === null || v === undefined ? "" : String(v));
784
788
  const [f, setF] = useState(() => ({
785
- echo: original.echo, log: original.log, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol,
789
+ echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol,
786
790
  abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours),
787
791
  termOn: original.terminal.enabled !== false, termTtl: text(original.terminal.ttl_minutes),
788
792
  }));
@@ -811,6 +815,7 @@ function WebSection({ ctx, staged }) {
811
815
  const body = {
812
816
  ...(f.echo !== original.echo ? { echo_to_channel: f.echo } : {}),
813
817
  ...(f.log !== original.log ? { log_tab: f.log } : {}),
818
+ ...(f.usagePub !== original.usagePub ? { usage_report_on_public_link: f.usagePub } : {}),
814
819
  ...(Object.keys(publicPatch).length ? { public_link: publicPatch } : {}),
815
820
  ...(Object.keys(sessionPatch).length ? { session: { local: sessionPatch } } : {}),
816
821
  ...(Object.keys(terminalPatch).length ? { web_terminal: terminalPatch } : {}),
@@ -818,6 +823,7 @@ function WebSection({ ctx, staged }) {
818
823
  const fields = [
819
824
  ...(body.echo_to_channel !== undefined ? ["web.echo_to_channel"] : []),
820
825
  ...(body.log_tab !== undefined ? ["web.log_tab"] : []),
826
+ ...(body.usage_report_on_public_link !== undefined ? ["web.usage_report_on_public_link"] : []),
821
827
  ...Object.keys(publicPatch).map((k) => `web.public_link.${k}`),
822
828
  ...Object.keys(sessionPatch).map((k) => `web.session.local.${k}`),
823
829
  ...Object.keys(terminalPatch).map((k) => `web_terminal.${k}`),
@@ -859,6 +865,8 @@ function WebSection({ ctx, staged }) {
859
865
  <p class="note">${tn("webEchoHint")}</p>
860
866
  <label class="check"><input id="webLogTab" type="checkbox" checked=${f.log} onChange=${(e) => set("log")(e.target.checked)} /><span>${tn("webLogTab")}</span><${Impact} schema=${schema} field="web.log_tab" /></label>
861
867
  <p class="note">${tn("webLogTabHint")}</p>
868
+ <label class="check"><input id="webUsagePublic" type="checkbox" checked=${f.usagePub} onChange=${(e) => set("usagePub")(e.target.checked)} /><span>${tn("webUsagePublic")}</span><${Impact} schema=${schema} field="web.usage_report_on_public_link" /></label>
869
+ <p class="note">${tn("webUsagePublicHint")}</p>
862
870
  <label class="check"><input id="publicWebLink" type="checkbox" checked=${f.pub} onChange=${(e) => set("pub")(e.target.checked)} /><span>${tn("publicLink")}</span><${Impact} schema=${schema} field="web.public_link.allow_public" /></label>
863
871
  <p class="note">${tn("publicLinkHint")}</p>
864
872
  <div class="grid2">
@@ -867,7 +875,7 @@ function WebSection({ ctx, staged }) {
867
875
  </div>
868
876
  </section>
869
877
  </fieldset>
870
- <${GuestsCard} ctx=${ctx} staged=${staged} disabled=${overPublicLink} />
878
+ ${overPublicLink ? null : html`<${GuestsCard} ctx=${ctx} staged=${staged} /><${WebAuthorityCard} ctx=${ctx} staged=${staged} />`}
871
879
  <section class="card s-web-readonly"><h3>${tn("webReadOnlyTitle")}</h3>
872
880
  <p class="note">${tn("webReadOnlyLead")}</p>
873
881
  <dl class="s-web-ro">${WEB_READ_ONLY.map((k) => html`<div key=${k}><dt><code>${k}</code></dt><dd>${shown(k)}</dd></div>`)}</dl>
@@ -10,6 +10,7 @@ import { Icon } from "/assets/ui-icons.js";
10
10
  import { toast } from "/assets/ui-toast.js";
11
11
  import { ownerCssOffHere, ownerCssTrial, safeMode, setOwnerCssActive, setOwnerCssOffHere, startOwnerCssTrial } from "/assets/owner-css.js";
12
12
  import { setThemesShown, themesStore } from "/assets/app-themes.js";
13
+ import { followsReducedMotion, onMotionChange, setFollowsReducedMotion } from "/assets/fx-motion.js";
13
14
  import { CARD_SIZES, cardSizeDefault, cardSizeStore, setCardSizeDefault } from "/assets/identity-card.js";
14
15
  import { AgentWidget, checkWidget, fleetBinds, setSidebarWidget, sidebarWidgetRaw, sidebarWidgetStore, widgetProblem } from "/assets/agent-widget.js";
15
16
  import { confirmedWrite } from "./settings-confirm.js";
@@ -87,6 +88,7 @@ function IdentityColorCard() {
87
88
  */
88
89
  export async function restoreAppearanceDefaults() {
89
90
  setThemesShown(true);
91
+ setFollowsReducedMotion(false);
90
92
  setCardSizeDefault("m");
91
93
  savePref("card.sizeByInstance", null);
92
94
  setSidebarWidget(null);
@@ -187,10 +189,16 @@ function CardLimitsCard() {
187
189
  /** #1629 r2 (d): whether this person's screens show agents' themes (and, as they come, effects) — synced. */
188
190
  function ThemesCard() {
189
191
  const s = useStore(themesStore);
192
+ // #2117: this device's own switch (not synced): effects play unless it follows the system's reduced-motion setting.
193
+ const [follow, setFollow] = useState(followsReducedMotion);
194
+ useEffect(() => onMotionChange(() => setFollow(followsReducedMotion())), []);
190
195
  return html`<section class="card s-appearance s-synced"><${CardTitle} title=${tn("themesTitle")} topic="themes" />
191
196
  <label class="check"><input type="checkbox" checked=${s.shown} disabled=${safeMode()} onChange=${(e) => setThemesShown(e.target.checked)} />
192
197
  <span>${tn("themesShow")}</span></label>
193
198
  <p class="sub">${safeMode() ? tn("themesSafeMode") : tn("themesShowLead")}</p>
199
+ <label class="check"><input type="checkbox" id="fx-follow-reduced-motion" checked=${follow} onChange=${(e) => setFollowsReducedMotion(e.target.checked)} />
200
+ <span>${tn("themesFollowMotion")}</span></label>
201
+ <p class="sub">${tn("themesFollowMotionLead")}</p>
194
202
  </section>`;
195
203
  }
196
204
 
@@ -41,9 +41,9 @@ function why(body) {
41
41
 
42
42
  /** The parts a plan offers, each with its default: everything but custom CSS. */
43
43
  export function defaultChoice(plan) {
44
- const c = { theme: false, identity: false, effects: false, ownerCss: false, widgets: {}, replace: false };
44
+ const c = { theme: false, identity: false, effects: false, ownerCss: false, ambient: false, widgets: {}, replace: false };
45
45
  for (const it of plan.items) {
46
- if (it.part === "theme" || it.part === "identity") c[it.part] = true;
46
+ if (it.part === "theme" || it.part === "identity" || it.part === "ambient") c[it.part] = true;
47
47
  else if (it.part === "widget") c.widgets[it.slot] = true;
48
48
  else if (it.part === "effect") c.effects = true;
49
49
  // ownerCss stays false: "read it before turning it on" (r4 §4).
@@ -100,6 +100,19 @@ function PlanItem({ it, choice, setChoice, binds, fleet }) {
100
100
  return html`<li class="s-mod-item">${tick(tn(`modPartWidget_${it.slot}`), !!choice.widgets[it.slot], (v) => setChoice({ ...choice, widgets: { ...choice.widgets, [it.slot]: v } }))}
101
101
  <div class="s-mod-detail s-mod-widget">${w ? html`<${AgentWidget} slot=${it.slot} widget=${w} binds=${it.slot === "sidebar" ? fleet : binds} />` : null}</div></li>`;
102
102
  }
103
+ if (it.part === "ambient") {
104
+ // #2117: each ambient as it will be drawn: its preset, glyphs (pictures shown) and colours; every field below.
105
+ const glyph = (g, i) => typeof g === "string" ? html`<span key=${i} class="s-mod-text">${g}</span>`
106
+ : html`<img key=${i} class="s-mod-glyph" src=${`/ui/mods/staged/${g.asset}`} alt="" loading="lazy" />`;
107
+ const one = (kind) => {
108
+ const a = it[kind];
109
+ if (!a || !Array.isArray(a.glyphs)) return null;
110
+ return html`<div key=${kind} class="s-mod-kv"><span class="k">${a.preset}</span><span class="v s-mod-sws">${a.glyphs.map(glyph)}${(a.colors || []).map((c) => html`<${Swatch} key=${c} color=${c} />`)}</span></div>`;
111
+ };
112
+ return html`<li class="s-mod-item">${tick(tn("modPartAmbient"), !!choice.ambient, (v) => setChoice({ ...choice, ambient: v }))}
113
+ <div class="s-mod-detail">${one("loop")}${one("click")}
114
+ <details class="s-mod-raw"><summary>${tn("modAllFields")}</summary><pre class="s-mod-css-text">${JSON.stringify({ loop: it.loop, click: it.click }, null, 2)}</pre></details></div></li>`;
115
+ }
103
116
  if (it.part === "effect") {
104
117
  return html`<li class="s-mod-item">${tick(tn("modPartEffect", it.title), choice.effects, (v) => setChoice({ ...choice, effects: v }))}
105
118
  <div class="s-mod-detail"><span>${kib(it.bytes)} · ${tn("modRegions", it.regions.join(", "))}</span>${it.wants.length ? html`<span>${tn("modWants", it.wants.join(", "))}</span>` : null}</div></li>`;
@@ -120,7 +133,7 @@ function Plan({ staged, onDone }) {
120
133
  const [target, setTarget] = useState("");
121
134
  const [busy, setBusy] = useState(false);
122
135
  const [error, setError] = useState("");
123
- const toInstance = choice.theme || choice.identity || !!choice.widgets.chat_header || !!choice.widgets.details;
136
+ const toInstance = choice.theme || choice.identity || choice.ambient || !!choice.widgets.chat_header || !!choice.widgets.details;
124
137
  const names = (app.instances || []).map((i) => i.name);
125
138
  const install = async () => {
126
139
  setBusy(true); setError("");
@@ -162,13 +175,13 @@ function Plan({ staged, onDone }) {
162
175
 
163
176
  function ExportForm() {
164
177
  const app = useStore(appStore);
165
- const [f, setF] = useState({ instance: "", name: "", version: "1.0.0", title: "", theme: true, identity: true, header: true, details: true, sidebar: false, ownerCss: false });
178
+ const [f, setF] = useState({ instance: "", name: "", version: "1.0.0", title: "", theme: true, identity: true, header: true, details: true, ambient: true, sidebar: false, ownerCss: false });
166
179
  const [error, setError] = useState("");
167
180
  const set = (k) => (e) => setF({ ...f, [k]: e.target.type === "checkbox" ? e.target.checked : e.target.value });
168
181
  const go = async () => {
169
182
  setError("");
170
183
  const body = { instance: f.instance || undefined, name: f.name, version: f.version, title: f.title,
171
- include: { theme: f.theme, identity: f.identity, ownerCss: f.ownerCss, widgets: { chat_header: f.header, details: f.details, sidebar: f.sidebar } } };
184
+ include: { theme: f.theme, identity: f.identity, ambient: f.ambient, ownerCss: f.ownerCss, widgets: { chat_header: f.header, details: f.details, sidebar: f.sidebar } } };
172
185
  let res;
173
186
  try { res = await fetch("/ui/mods/export", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); }
174
187
  catch { setError(tn("modFailed", "network")); return; }
@@ -182,7 +195,7 @@ function ExportForm() {
182
195
  return html`<details class="s-mod-export"><summary>${tn("modExport")}</summary>
183
196
  <p class="sub">${tn("modExportLead")}</p>
184
197
  <label class="field"><span>${tn("modExportInstance")}</span><select value=${f.instance} onChange=${set("instance")}><option value="">—</option>${(app.instances || []).map((i) => html`<option key=${i.name} value=${i.name}>${i.name}</option>`)}</select></label>
185
- <div class="s-mod-boxes">${box("theme", tn("modPartTheme"))}${box("identity", tn("modPartIdentity"))}${box("header", tn("modPartWidget_chat_header"))}${box("details", tn("modPartWidget_details"))}${box("sidebar", tn("modPartWidget_sidebar"))}${box("ownerCss", tn("modPartCss"))}</div>
198
+ <div class="s-mod-boxes">${box("theme", tn("modPartTheme"))}${box("identity", tn("modPartIdentity"))}${box("header", tn("modPartWidget_chat_header"))}${box("details", tn("modPartWidget_details"))}${box("ambient", tn("modPartAmbient"))}${box("sidebar", tn("modPartWidget_sidebar"))}${box("ownerCss", tn("modPartCss"))}</div>
186
199
  <label class="field"><span>${tn("modExportName")}</span><input value=${f.name} placeholder="my-look" onInput=${set("name")} /></label>
187
200
  <label class="field"><span>${tn("modExportVersion")}</span><input value=${f.version} onInput=${set("version")} /></label>
188
201
  <label class="field"><span>${tn("modExportTitle")}</span><input value=${f.title} onInput=${set("title")} /></label>
@@ -13,7 +13,7 @@ register("ownerEffects", {
13
13
  unknown: "Choices are unavailable. Reload to try again.", changed: "This page can no longer access these choices. Reload and sign in again.",
14
14
  unavailable: "No longer available", clear: "Clear", reload: "Reload choices", saved: "Saved.", failed: "Couldn't save. Reload choices and try again.",
15
15
  savedRefresh: "Saved, but the choices couldn't be refreshed. Reload to check.", readOnly: "Sign in as an admin to change these choices.",
16
- playback: "Plays once Effects and previews are allowed.",
16
+ playback: "Effects don't require HTML-preview permission.",
17
17
  pause: "Pause", resume: "Resume", running: "Running", cursorFailed: "The cursor could not be applied here. Clear or reload the choice to try again.", starting: "Starting", queued: "Waiting for a slot", paused: "Paused", off: "Playback is off or unavailable here.", budget: "Paused to keep the page responsive.", resumeFailed: "This effect cannot resume: the page or its safety history is not ready. Reload the page or choose another effect.",
18
18
  },
19
19
  "zh-TW": {
@@ -23,7 +23,7 @@ register("ownerEffects", {
23
23
  unknown: "目前無法取得選擇,請重新載入再試。", changed: "這個頁面已無法存取選擇,請重新載入並登入。",
24
24
  unavailable: "已無法使用", clear: "清除", reload: "重新載入選擇", saved: "已儲存。", failed: "無法儲存,請重新載入選擇再試。",
25
25
  savedRefresh: "已儲存,但無法更新畫面;請重新載入確認。", readOnly: "請以管理員身分登入才能修改選擇。",
26
- playback: "開啟效果並允許預覽後才會播放。",
26
+ playback: "效果不需要 HTML 預覽權限。",
27
27
  pause: "暫停", resume: "恢復", running: "播放中", cursorFailed: "這裡無法套用游標。可清除或重新載入選擇後再試。", starting: "啟動中", queued: "等待空位", paused: "已暫停", off: "這裡已關閉播放,或無法播放效果。", budget: "為維持頁面順暢,已暫停。", resumeFailed: "頁面或安全紀錄尚未就緒,無法恢復此效果。請重新載入頁面,或選擇其他效果。",
28
28
  },
29
29
  });
@@ -39,7 +39,7 @@ export function OwnerEffects({ catalog, busy = false }) {
39
39
  useEffect(() => { life.current.alive = true; const stop = watchOwnerEffects(); return () => { life.current.alive = false; life.current.seq++; stop(); }; }, []);
40
40
  useEffect(() => {
41
41
  let live = true; const entry = getAppEffects();
42
- const pull = () => { if (!live) return; const gates = entry?.gates(); setRuntime({ entry, snapshot: entry?.api?.ownerSnapshot?.(), cursor: entry?.cursorSnapshot?.(), available: !!entry && !entry.dead && gates.enabled && gates.show && gates.previewReady && gates.optIn && !gates.safe && !gates.publicLink && !gates.reducedMotion && !gates.saveData }); };
42
+ const pull = () => { if (!live) return; const gates = entry?.gates(); setRuntime({ entry, snapshot: entry?.api?.ownerSnapshot?.(), cursor: entry?.cursorSnapshot?.(), available: !!entry && !entry.dead && gates.enabled && gates.show && gates.transportReady && !gates.safe && !gates.publicLink && !gates.reducedMotion }); };
43
43
  const off = entry?.subscribe(pull), end = entry?.onEnd(pull); pull();
44
44
  return () => { live = false; off?.(); end?.(); };
45
45
  }, []);
@@ -15,6 +15,7 @@ register("settings", {
15
15
  publicProtocol: "Tunnel protocol",
16
16
  webEcho: "Web echo",
17
17
  webEchoHint: "Copies web messages to the bound Telegram or Discord topic. ClassicBot rooms follow it unless a room sets its own.",
18
+ webUsagePublic: "Usage report over the public link", webUsagePublicHint: "Fleet → Usage (up to 400 days of each agent's tokens, requests, messages and API-equivalent amounts) is offered over the temporary public link too. Off by default; guests never see it. Applies at once.",
18
19
  webLogTab: "Log tab: each agent's steps", webLogTabHint: "Shows each instance's prompts, replies, tool calls and results, and fleet events in its Log tab, read from its own transcript only while the tab is open. Secret values are masked. Also over the public link. Turning it on waits for a fleet admin's confirmation.",
19
20
  general: "General Settings",
20
21
  advanced: "Advanced",
@@ -316,6 +317,8 @@ register("settings", {
316
317
  themesTitle: "Agents' themes",
317
318
  themesShow: "Show agents' themes, effects and widgets",
318
319
  themesShowLead: "When off, every chat looks the same.",
320
+ themesFollowMotion: "Effects: follow the system's reduced-motion setting",
321
+ themesFollowMotionLead: "This device only: effects stay still while the system reduces motion.",
319
322
  themesSafeMode: "This page was opened with ?safe=1: agents' themes are not shown on it.",
320
323
  widgetTitle: "Your sidebar widget",
321
324
  widgetLead: "A small panel under your agent list, written as JSON.",
@@ -337,6 +340,7 @@ register("settings", {
337
340
  modReplace: "Replace the installed version {0}",
338
341
  modPartTheme: "Theme (colours, font, pictures)",
339
342
  modPartIdentity: "Card text (tagline and bio)",
343
+ modPartAmbient: "Ambient effect (falling, rising, twinkling or click glyphs)",
340
344
  modPartWidget_chat_header: "Chat header widget",
341
345
  modPartWidget_details: "Details page widget",
342
346
  modPartWidget_sidebar: "Your sidebar widget",
@@ -439,6 +443,17 @@ register("settings", {
439
443
  guestNarrow: "Narrow", guestNotReachable: "(no longer reachable)", guestReachesNothing: "This account reaches no agent from chat right now, so there is nothing to narrow.", guestNarrowLift: "Remove narrowing", guestNarrowHint: "Tick what this guest may still reach. Narrowing only takes away (it applies at once); widening or removing it waits for a fleet admin's confirmation.",
440
444
  guestSignOut: "Sign out", guestSignOutTitle: "Sign out this guest?", guestSignOutConfirm: "{0}'s guest sessions end now, their sign-in code stops working and their browsers are forgotten. They can sign in again with /web while guest sign-in is on.",
441
445
  guestSignedOut: "{0} is signed out.", guestSignedOutNotDurable: "{0} is signed out for now, but it could not be saved: a restart may bring their sessions back.", guestSignOutFailed: "That did not work — try again.",
446
+ vaTitle: "Web authority", vaReview: "Review web authority changes",
447
+ vaHint: "Whether a signed-in fleet admin's web messages carry their own account's authority, recorded in each message's delivery receipt. A session signed in with a code sent privately to that account, on the dashboard reached directly, is authorized once from Discord or Telegram; the public link and guests never are. Agents don't act on it yet: they still treat web messages as recognition only.",
448
+ vaLoadFailed: "Web authority could not be loaded.",
449
+ vaEnabled: "Web messages carry authority", vaEnabledHint: "On by default: a session carries authority only once its account confirms it from Discord or Telegram. Off: no web message carries authority, and any already given reads as none at once; authority given before it was turned off never comes back.",
450
+ vaStuckOff: "Web authority is off for now: its state could not be saved on this computer (see the fleet log). It comes back on by itself once it can be.",
451
+ vaLoopbackOnly: "Only this computer can authorize", vaLoopbackOnlyHint: "On: only a browser on this computer qualifies, not one on your network or tailnet, and authority already given from elsewhere reads as none. Turning it off again doesn't bring that back.",
452
+ vaLanWarning: "The dashboard also answers to {0} (web.allowed_hosts). Anyone who reaches it there with a stolen sign-in could give authority in your name. Turn on \"Only this computer can authorize\" unless you trust everyone who can reach it.",
453
+ vaHours: "An authorization lasts (hours, {0}–{1})", vaHoursRange: "Must be a number from 1 to 48",
454
+ vaSessionsTitle: "Authorized sessions", vaSessionsNone: "No session is authorized now.", vaSessionUntil: "Authorized {0} · until {1}",
455
+ vaRevoke: "Revoke web authority", vaRevokeTitle: "Revoke all web authority?", vaRevokeConfirm: "Every authority given from the web so far reads as none from now on, and every session's authorization ends. Nobody is signed out: a session carries authority again once it is authorized again.",
456
+ vaRevoked: "Web authority given so far is revoked.", vaRevokedNotDurable: "Revoked for now, but it could not be saved: web authority stays off until it can be, and a restart before then undoes the revocation.", vaRevokeFailed: "That did not work — try again.",
442
457
  webReadOnlyLead: "Read-only here: these decide what the dashboard listens on and exposes. Edit fleet.yaml, then run agend reload (web.preview_port and web.preview_origin take a restart of AgEnD).",
443
458
  notSet: "not set (default)",
444
459
  defaultsTitle: "Defaults",
@@ -548,6 +563,7 @@ register("settings", {
548
563
  publicProtocol: "Tunnel 協定",
549
564
  webEcho: "Web 同步",
550
565
  webEchoHint: "將網頁訊息複製到綁定的 Telegram 或 Discord 主題。ClassicBot 聊天室也照這個設定,除非該聊天室另外設定。",
566
+ webUsagePublic: "透過公開連結提供用量報告", webUsagePublicHint: "Fleet → 用量(最多 400 天、每個 agent 的 token、請求、訊息與依牌價換算的 API 等值)也會透過臨時公開連結提供。預設關閉;訪客一律看不到。變更立即生效。",
551
567
  webLogTab: "紀錄分頁:每個 agent 的步驟", webLogTabHint: "在每個 instance 的「紀錄」分頁顯示提示、回覆、工具呼叫與結果,以及 fleet 事件;只在分頁開著時從它自己的對話紀錄讀取。機密值會遮蔽。公開連結也看得到。開啟需 fleet admin 確認。",
552
568
  general: "一般設定",
553
569
  advanced: "進階",
@@ -823,6 +839,8 @@ register("settings", {
823
839
  themesTitle: "Agent 的主題",
824
840
  themesShow: "顯示 agent 的主題、效果與小工具",
825
841
  themesShowLead: "關閉時每個聊天看起來都一樣。",
842
+ themesFollowMotion: "效果:跟隨系統的「減少動態效果」設定",
843
+ themesFollowMotionLead: "只套用於這台裝置:系統減少動態效果時,效果保持靜止。",
826
844
  themesSafeMode: "這個頁面是用 ?safe=1 開啟的:不顯示 agent 的主題。",
827
845
  widgetTitle: "你的側邊欄小工具",
828
846
  widgetLead: "agent 清單下方的小面板,用 JSON 撰寫。",
@@ -844,6 +862,7 @@ register("settings", {
844
862
  modReplace: "取代已安裝的 {0} 版",
845
863
  modPartTheme: "主題(顏色、字型、圖片)",
846
864
  modPartIdentity: "身分卡文字(標語與簡介)",
865
+ modPartAmbient: "環境效果(飄落、上升、閃爍或點擊符號)",
847
866
  modPartWidget_chat_header: "聊天標頭小工具",
848
867
  modPartWidget_details: "詳細資料頁小工具",
849
868
  modPartWidget_sidebar: "你的側邊欄小工具",
@@ -946,6 +965,17 @@ register("settings", {
946
965
  guestNarrow: "縮小範圍", guestNotReachable: "(已無法使用)", guestReachesNothing: "這個帳號目前在聊天中沒有能用的 agent,沒有可以縮小的範圍。", guestNarrowLift: "取消縮小", guestNarrowHint: "勾選這位訪客仍可使用的 agent。縮小只會拿走權限(立即生效);放寬或取消縮小需 fleet admin 確認。",
947
966
  guestSignOut: "登出", guestSignOutTitle: "登出這位訪客?", guestSignOutConfirm: "{0} 的訪客 session 會立即結束,登入碼失效,記住的瀏覽器也會被忘記。訪客登入開啟期間,他可以再傳 /web 登入。",
948
967
  guestSignedOut: "已登出 {0}。", guestSignedOutNotDurable: "已暫時登出 {0},但無法保存:重新啟動後他的 session 可能會恢復。", guestSignOutFailed: "沒有成功,請再試一次。",
968
+ vaTitle: "網頁授權", vaReview: "檢視網頁授權變更",
969
+ vaHint: "已登入的 fleet 管理者從網頁送出的訊息,是否帶有他本人帳號的授權,記錄在每則訊息的送達收據中。用私下傳給該帳號的登入碼、透過直接連線的 dashboard 登入的 session,要在 Discord 或 Telegram 上授權一次;公開連結與訪客一律不行。目前 agent 還不會依它行動:仍只把網頁訊息當成辨識。",
970
+ vaLoadFailed: "無法載入網頁授權。",
971
+ vaEnabled: "網頁訊息帶有授權", vaEnabledHint: "預設開啟:session 要等它的帳號在 Discord 或 Telegram 上確認後才帶有授權。關閉:網頁訊息都不帶授權,已給出的授權立即視為無;關閉前給出的授權不會恢復。",
972
+ vaStuckOff: "網頁授權目前是關閉的:它的狀態無法存到這台電腦上(請看 fleet 紀錄)。可以存入後會自動恢復。",
973
+ vaLoopbackOnly: "只有這台電腦可以授權", vaLoopbackOnlyHint: "開啟:只有這台電腦上的瀏覽器符合,網路或 tailnet 上的不算,之前從其他地方給出的授權視為無。再關掉也不會讓那些授權恢復。",
974
+ vaLanWarning: "dashboard 也接受 {0} 的連線(web.allowed_hosts)。任何人只要從那裡連上、又拿到被盜的登入,就能以你的名義給出授權。除非你信任所有能連到那裡的人,否則請開啟「只有這台電腦可以授權」。",
975
+ vaHours: "一次授權的有效期(小時,{0}–{1})", vaHoursRange: "必須是 1 到 48 之間的數字",
976
+ vaSessionsTitle: "已授權的 session", vaSessionsNone: "目前沒有已授權的 session。", vaSessionUntil: "{0} 授權 · 到 {1}",
977
+ vaRevoke: "撤銷網頁授權", vaRevokeTitle: "撤銷所有網頁授權?", vaRevokeConfirm: "到目前為止從網頁給出的授權,從現在起都視為無,所有 session 的授權也會結束。不會登出任何人:session 再授權一次後,送出的訊息會再帶有授權。",
978
+ vaRevoked: "已撤銷到目前為止的網頁授權。", vaRevokedNotDurable: "已暫時撤銷,但無法保存:在可以保存之前網頁授權會保持關閉,而在那之前重新啟動會讓這次撤銷失效。", vaRevokeFailed: "沒有成功,請再試一次。",
949
979
  webReadOnlyLead: "這裡僅供檢視:這些設定決定儀表板監聽和公開的內容。請編輯 fleet.yaml 後執行 agend reload(web.preview_port 與 web.preview_origin 需要重新啟動 AgEnD)。",
950
980
  notSet: "未設定(預設)",
951
981
  defaultsTitle: "預設值",
@@ -0,0 +1,91 @@
1
+ // #1939 S2a: Settings → Web → Web authority — whether a signed-in admin's web messages carry their account's authority
2
+ // in delivery receipts (docs/design/1939-verified-web-sessions.md §0, §0.1, §3, §10): the server switch (on by default),
3
+ // "Only this computer can authorize", how long a session's authorization lasts, the sessions authorized now, and "Revoke
4
+ // web authority". Changes go through Review changes → Apply like every Settings edit, and apply at once (§0.1: none of
5
+ // them grants anything by itself — a session still needs its account's Confirm in Discord or Telegram).
6
+ import { html, useEffect, useState } from "/assets/app-html.js";
7
+ import { t } from "/assets/app-i18n.js";
8
+ import { toast } from "/assets/ui-toast.js";
9
+ import { confirmDialog } from "/assets/ui-confirm.js";
10
+ import { api } from "./settings-confirm.js";
11
+ import { Feedback } from "./settings-dialogs.js";
12
+ import { resetWebAuthorization } from "/assets/web-authorize.js";
13
+ import "./settings-strings.js";
14
+
15
+ const tn = (k, ...v) => t(`settings.${k}`, ...v);
16
+
17
+ /** What changed against what fleet.yaml says, as the PUT /api/settings/fleet/web body's `verified_authority` (null: nothing). */
18
+ export function verifiedAuthorityBody(original, f) {
19
+ const out = {};
20
+ if (f.enabled !== original.enabled) out.enabled = f.enabled ? null : false; // §0.1: on is the default
21
+ if (f.loopbackOnly !== original.loopbackOnly) out.loopback_only = f.loopbackOnly ? true : null;
22
+ if (f.hours !== original.hours) out.max_session_hours = f.hours === null ? null : f.hours;
23
+ return Object.keys(out).length ? out : null;
24
+ }
25
+
26
+ export function WebAuthorityCard({ ctx, staged, disabled = false }) {
27
+ const [overview, setOverview] = useState(null), [failed, setFailed] = useState(false);
28
+ const [original, setOriginal] = useState(null), [f, setF] = useState(null), [msg, setMsg] = useState(null);
29
+ const load = async () => {
30
+ // What changes here (a revoke, the switch) changes this browser's own line: the chat reads it again when it shows.
31
+ resetWebAuthorization();
32
+ let r = null;
33
+ try { r = await api("/api/settings/web-authority"); } catch { r = null; }
34
+ // Anything but the overview's shape (an older server, an error page) is "could not be loaded", never a crash.
35
+ const o = r && r.ok ? r.body : null;
36
+ if (!o || typeof o !== "object" || !o.state || !o.hours || !Array.isArray(o.lanHosts) || !Array.isArray(o.authorized)) { setFailed(true); return; }
37
+ const c = o.configured || {};
38
+ const orig = { enabled: c.enabled !== false, loopbackOnly: c.loopback_only === true, hours: typeof c.max_session_hours === "number" ? c.max_session_hours : null };
39
+ setOverview(o); setFailed(false);
40
+ setOriginal((prev) => prev || orig);
41
+ setF((prev) => prev || { ...orig });
42
+ };
43
+ useEffect(() => { load(); }, []);
44
+ const card = (body) => html`<section class="card s-web-authority"><h3>${tn("vaTitle")}</h3><p class="note">${tn("vaHint")}</p>${body}</section>`;
45
+ if (failed) return card(html`<p class="note" role="status">${tn("vaLoadFailed")} <button type="button" class="btn btn-sm" onClick=${load}>${t("app.retry")}</button></p>`);
46
+ if (!overview || !f) return card(html`<p class="note">${t("app.loading")}</p>`);
47
+
48
+ const set = (patch) => setF((x) => ({ ...x, ...patch }));
49
+ const hoursText = f.hours === null ? "" : String(f.hours);
50
+ const hoursError = f.hours !== null && (!Number.isFinite(f.hours) || f.hours < overview.hours.min || f.hours > overview.hours.max) ? "vaHoursRange" : "";
51
+ const stuckOff = f.enabled && original.enabled && !overview.state.enabled;
52
+ const warnHosts = !f.loopbackOnly && overview.lanHosts.length > 0;
53
+ const when = (ms) => new Date(ms).toLocaleString();
54
+
55
+ const review = () => {
56
+ if (hoursError) return;
57
+ const body = verifiedAuthorityBody(original, f);
58
+ if (body) ctx.stage("verified_authority", { label: tn("vaTitle"), impact: "now", request: { method: "PUT", url: "/api/settings/fleet/web", body: { verified_authority: body } } });
59
+ else if (staged.has("verified_authority")) ctx.unstage("verified_authority");
60
+ setMsg(tn("readyApply"));
61
+ };
62
+ const revoke = async () => {
63
+ const yes = await confirmDialog({ title: tn("vaRevokeTitle"), message: tn("vaRevokeConfirm"), confirmLabel: tn("vaRevoke"), danger: true });
64
+ if (!yes) return;
65
+ let r = null;
66
+ try { r = await api("/api/settings/web-authority/revoke", { method: "POST", body: "{}" }); } catch { r = null; }
67
+ if (r && r.ok) { toast(tn(r.body && r.body.durable === false ? "vaRevokedNotDurable" : "vaRevoked")); load(); }
68
+ else toast(tn("vaRevokeFailed"), false);
69
+ };
70
+
71
+ return card(html`<fieldset class="s-va-fields" disabled=${disabled}>
72
+ ${stuckOff ? html`<p class="note s-va-warn" role="status" id="vaStuckOff">${tn("vaStuckOff")}</p>` : null}
73
+ <label class="check"><input id="vaEnabled" type="checkbox" checked=${f.enabled} onChange=${(e) => set({ enabled: e.target.checked })} /><span>${tn("vaEnabled")}</span></label>
74
+ <p class="note">${tn("vaEnabledHint")}</p>
75
+ <label class="check"><input id="vaLoopbackOnly" type="checkbox" checked=${f.loopbackOnly} onChange=${(e) => set({ loopbackOnly: e.target.checked })} /><span>${tn("vaLoopbackOnly")}</span></label>
76
+ <p class="note">${tn("vaLoopbackOnlyHint")}</p>
77
+ ${warnHosts ? html`<p class="note s-va-warn" role="status" id="vaLanWarning">${tn("vaLanWarning", overview.lanHosts.join(", "))}</p>` : null}
78
+ <div class="field"><label for="vaHours">${tn("vaHours", overview.hours.min, overview.hours.max)}</label>
79
+ <input id="vaHours" type="number" min=${overview.hours.min} max=${overview.hours.max} step="any" placeholder=${tn("defaultHours", overview.hours.default)}
80
+ class=${hoursError ? "invalid" : ""} value=${hoursText} onInput=${(e) => set({ hours: e.target.value.trim() === "" ? null : Number(e.target.value) })} />
81
+ <${Feedback} error=${hoursError} /></div>
82
+ <h4>${tn("vaSessionsTitle")}</h4>
83
+ ${overview.authorized.length
84
+ ? html`<ul class="s-va-sessions" id="vaSessions">${overview.authorized.map((a) => html`<li key=${a.handle} data-handle=${a.handle}>
85
+ <strong>${a.label}</strong> <span class="note">${a.account}</span><br /><span class="note">${tn("vaSessionUntil", when(a.authorizedAt), when(a.until))}</span></li>`)}</ul>`
86
+ : html`<p class="note" id="vaSessionsNone">${tn("vaSessionsNone")}</p>`}
87
+ </fieldset>
88
+ <div class="save-row"><button type="button" class="btn btn-primary" id="vaReview" disabled=${disabled || !!hoursError} onClick=${review}>${tn("vaReview")}</button>
89
+ <button type="button" class="btn btn-danger" id="vaRevoke" disabled=${disabled} onClick=${revoke}>${tn("vaRevoke")}</button>
90
+ ${msg ? html`<span class="note" role="status">${msg}</span>` : null}</div>`);
91
+ }
@@ -1,7 +1,7 @@
1
1
  // #1408 §3: the app's routes on the client. The same table as the server's classifier (src/web-shell-routes.ts;
2
2
  // a test runs both over one list of cases). Pure: no DOM, no history.
3
3
 
4
- export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "cache", "config"];
4
+ export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "cache", "usage", "config"];
5
5
  export const SETTINGS_SECTIONS = ["device", "appearance", "agents", "bots", "classic", "general", "web", "advanced"]; // #1604: device first
6
6
  const MAX_INSTANCE_NAME = 128;
7
7
  const PATH_SEPARATOR_OR_CONTROL = /[/\\\u0000-\u001f\u007f]/;
@@ -4,6 +4,7 @@
4
4
  import { html, useEffect, useRef, useState } from "./app-html.js";
5
5
  import { Icon } from "./ui-icons.js";
6
6
  import { t, lang } from "./app-i18n.js";
7
+ import { AuthorizeChip } from "./web-authorize.js";
7
8
 
8
9
  async function getJson(url) {
9
10
  try {
@@ -53,6 +54,7 @@ export function SessionMenu() {
53
54
  ${!me ? html`<h3>${t("app.signedOut")}</h3><a class="btn" href=${`/signin?next=${encodeURIComponent(location.pathname)}`}>${t("app.signIn")}</a>` : html`
54
55
  <h3>${t("app.signedIn")} · ${me.label}</h3>
55
56
  <p class="sub">${t("app.expires", clock(me.expiresAt), span(me.idleMs))}</p>
57
+ <${AuthorizeChip} />
56
58
  ${rows.length > 1 ? html`<h3>${t("app.devices")}</h3><ul class="devices">${rows.map(s => html`<li key=${s.handle}>
57
59
  <span class="grow"><span class="name">${s.label}${s.current ? ` · ${t("app.thisDevice")}` : ""}</span><span class="when">${t("app.lastActive", ago(s.lastSeen))}</span></span>
58
60
  ${s.current ? null : html`<button type="button" class="btn btn-sm danger" onClick=${() => act("DELETE", `/auth/sessions/${encodeURIComponent(s.handle)}`, refresh)}>${t("app.signOut")}</button>`}</li>`)}</ul>` : null}
@@ -394,7 +394,7 @@ function PrefsPopover() {
394
394
  * The header every panel shows: ☰ (phone, or a collapsed sidebar), its title and status, then its actions. `nav`
395
395
  * (#1523 N2): an instance page's view switch — beside the title on a wide screen, a row of tabs under it on a phone.
396
396
  */
397
- export function PanelHeader({ title, sub, children, headingRef, nav }) {
397
+ export function PanelHeader({ title, sub, children, headingRef, nav, notice }) {
398
398
  const shell = useStore(shellStore);
399
399
  const canCreate = useCanCreate() && !!newInstanceOpener;
400
400
  return html`<header data-trust="header" class=${`panel-head${shell.collapsed ? " collapsed" : ""}`}>
@@ -404,6 +404,7 @@ export function PanelHeader({ title, sub, children, headingRef, nav }) {
404
404
  <div class="panel-title"><h1 ref=${headingRef} tabindex="-1">${title}</h1>${sub ? html`<div class="panel-sub">${sub}</div>` : null}</div>
405
405
  ${nav || null}
406
406
  <div class="panel-actions">${children}</div>
407
+ ${notice || null}
407
408
  </header>`;
408
409
  }
409
410
 
@@ -10,8 +10,11 @@ import { t } from "./app-i18n.js";
10
10
  import { toast } from "./ui-toast.js";
11
11
  import { confirmDialog } from "./ui-confirm.js";
12
12
 
13
- /** { rev, themes: { <name>: theme }, locked: [<name>], widgets: { <name>: { <slot>: widget } } } as GET /ui/themes said (rev -1: not read yet). */
14
- export const themesStore = createStore({ rev: -1, themes: {}, locked: [], widgets: {}, shown: true });
13
+ /**
14
+ * { rev, themes: { <name>: theme }, locked: [<name>], widgets: { <name>: { <slot>: widget } }, ambient: { <name>: { loop?, click? } } }
15
+ * as GET /ui/themes said (rev -1: not read yet).
16
+ */
17
+ export const themesStore = createStore({ rev: -1, themes: {}, locked: [], widgets: {}, ambient: {}, shown: true });
15
18
  const own = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
16
19
 
17
20
  const COLOR = /^#[0-9a-f]{6}$/;
@@ -30,14 +33,15 @@ function labelOk(s) {
30
33
  return n >= 1 && n <= 24;
31
34
  }
32
35
 
33
- // Public sessions cannot write host prefs. Keep their explicit switch apart from boot-cached host defaults.
36
+ // Public/guest sessions cannot write host prefs. Keep their explicit browser switch apart from boot-cached defaults.
37
+ // Keep the existing public key for compatibility; both read-only viewer surfaces share this device choice.
34
38
  const PUBLIC_FX_SHOW = "agend_public_fx_show";
35
39
  let publicFxChoice = (() => { try { const v = localStorage.getItem(PUBLIC_FX_SHOW); return v === "on" ? true : v === "off" ? false : null; } catch { return null; } })();
36
40
 
37
41
  /** Whether this page shows agents' themes now: the viewer's switch (default on), and never in safe mode. */
38
42
  export function themesShown() {
39
43
  if (safeMode()) return false;
40
- if (publicLink() && publicFxChoice !== null) return publicFxChoice;
44
+ if (localAppearanceViewer() && publicFxChoice !== null) return publicFxChoice;
41
45
  try {
42
46
  const f = prefInForce("fx.show"); // this page's choice, else the host's, else the default
43
47
  if (f) return f.value !== false;
@@ -46,7 +50,7 @@ export function themesShown() {
46
50
  }
47
51
  /** The viewer's switch (Settings → Appearance): kept with the person's preferences, and in force on this page at once. */
48
52
  export function setThemesShown(on) {
49
- if (publicLink()) {
53
+ if (localAppearanceViewer()) {
50
54
  publicFxChoice = !!on;
51
55
  try { localStorage.setItem(PUBLIC_FX_SHOW, on ? "on" : "off"); } catch { /* the in-memory choice still applies */ }
52
56
  } else savePref("fx.show", on ? null : false);
@@ -122,7 +126,8 @@ async function read(rev) {
122
126
  if (!body || typeof body !== "object") break;
123
127
  themesStore.set({ rev: typeof body.rev === "number" ? body.rev : want, themes: body.themes && typeof body.themes === "object" ? body.themes : {},
124
128
  locked: Array.isArray(body.locked) ? body.locked.filter((n) => typeof n === "string") : [],
125
- widgets: body.widgets && typeof body.widgets === "object" && !Array.isArray(body.widgets) ? body.widgets : {} });
129
+ widgets: body.widgets && typeof body.widgets === "object" && !Array.isArray(body.widgets) ? body.widgets : {},
130
+ ambient: body.ambient && typeof body.ambient === "object" && !Array.isArray(body.ambient) ? body.ambient : {} });
126
131
  if (want === wanted) break;
127
132
  }
128
133
  } finally { reading = false; }
@@ -144,6 +149,7 @@ export function startThemes() {
144
149
  }
145
150
 
146
151
  const publicLink = () => typeof document !== "undefined" && !!document.body && document.body.dataset.publicLink === "1";
152
+ const localAppearanceViewer = () => publicLink() || typeof document !== "undefined" && document.body?.dataset.tier === "guest";
147
153
 
148
154
  /** A person's Reset or Lock of an agent's theme (an admin session; never over the public link). */
149
155
  async function themeAction(name, what, body) {
@@ -169,6 +175,18 @@ async function removeWidget(name, slot) {
169
175
  else toast(t("chat.widgetRemoved", name));
170
176
  }
171
177
 
178
+ /** #2117: a person's Clear of an agent's ambient, one kind ("loop" or "click"). */
179
+ export async function clearAmbient(name, which) {
180
+ let r;
181
+ try {
182
+ const res = await fetch(`/ui/themes/${encodeURIComponent(name)}/ambient`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ which }) });
183
+ r = await res.json().catch(() => ({}));
184
+ if (!res.ok) r = { error: r && r.error ? r.error : `HTTP ${res.status}` };
185
+ } catch (err) { r = { error: err && err.message ? err.message : "network" }; }
186
+ if (r && r.error) toast(t("chat.themeFailed", r.error), false);
187
+ else toast(t("chat.ambientCleared", name));
188
+ }
189
+
172
190
  /** The chat's and Details' ⋯ entries for an agent's theme: Lock/Unlock, Reset… and its widgets' Remove — none over the public link. */
173
191
  export function themeMenuItems(name) {
174
192
  if (publicLink()) return [];