@songsid/agend 2.3.0-beta.5 → 2.3.0-beta.7

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 (180) 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 +12 -6
  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.js +2 -0
  35. package/dist/daemon.js.map +1 -1
  36. package/dist/delivery-outbox.d.ts +8 -0
  37. package/dist/delivery-outbox.js +7 -1
  38. package/dist/delivery-outbox.js.map +1 -1
  39. package/dist/fleet-manager.d.ts +109 -2
  40. package/dist/fleet-manager.js +280 -12
  41. package/dist/fleet-manager.js.map +1 -1
  42. package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +2 -1
  43. package/dist/general-knowledge/skills/multi-channel/SKILL.md +2 -1
  44. package/dist/general-knowledge/skills/web-appearance/SKILL.md +81 -25
  45. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +55 -8
  46. package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
  47. package/dist/human-delivery-receipts.d.ts +14 -0
  48. package/dist/human-delivery-receipts.js +42 -6
  49. package/dist/human-delivery-receipts.js.map +1 -1
  50. package/dist/instance-names.d.ts +4 -0
  51. package/dist/instance-names.js +12 -0
  52. package/dist/instance-names.js.map +1 -0
  53. package/dist/instructions.d.ts +8 -1
  54. package/dist/instructions.js +9 -2
  55. package/dist/instructions.js.map +1 -1
  56. package/dist/locale.js +52 -0
  57. package/dist/locale.js.map +1 -1
  58. package/dist/outbound-handlers.d.ts +6 -0
  59. package/dist/outbound-handlers.js +45 -1
  60. package/dist/outbound-handlers.js.map +1 -1
  61. package/dist/outbound-schemas.d.ts +57 -1
  62. package/dist/outbound-schemas.js +33 -1
  63. package/dist/outbound-schemas.js.map +1 -1
  64. package/dist/public-web-gateway.js +8 -2
  65. package/dist/public-web-gateway.js.map +1 -1
  66. package/dist/rollout-index.d.ts +5 -0
  67. package/dist/rollout-index.js +26 -5
  68. package/dist/rollout-index.js.map +1 -1
  69. package/dist/settings-api.d.ts +6 -0
  70. package/dist/settings-api.js +50 -1
  71. package/dist/settings-api.js.map +1 -1
  72. package/dist/settings-change.js +27 -0
  73. package/dist/settings-change.js.map +1 -1
  74. package/dist/settings-effect.js +8 -1
  75. package/dist/settings-effect.js.map +1 -1
  76. package/dist/tool-permissions.d.ts +0 -7
  77. package/dist/tool-permissions.js +73 -5
  78. package/dist/tool-permissions.js.map +1 -1
  79. package/dist/topic-commands.d.ts +2 -1
  80. package/dist/topic-commands.js +2 -4
  81. package/dist/topic-commands.js.map +1 -1
  82. package/dist/types.d.ts +13 -2
  83. package/dist/ui/app-effects.js +43 -25
  84. package/dist/ui/chat-ambient.js +306 -0
  85. package/dist/ui/chat-effect-posters.js +6 -7
  86. package/dist/ui/chat-effects.js +38 -15
  87. package/dist/ui/chat-strings.js +17 -4
  88. package/dist/ui/panel-chat.js +15 -7
  89. package/dist/ui/panel-settings.js +4 -1
  90. package/dist/ui/settings-appearance.js +8 -0
  91. package/dist/ui/settings-mods.js +19 -6
  92. package/dist/ui/settings-owner-effects.js +3 -3
  93. package/dist/ui/settings-strings.js +28 -0
  94. package/dist/ui/settings-web-authority.js +91 -0
  95. package/dist/ui/shared/app-session.js +2 -0
  96. package/dist/ui/shared/app-shell.js +2 -1
  97. package/dist/ui/shared/app-themes.js +24 -6
  98. package/dist/ui/shared/app.css +42 -8
  99. package/dist/ui/shared/effect-app-runtime.js +67 -18
  100. package/dist/ui/shared/effect-chat-runtime.js +16 -3
  101. package/dist/ui/shared/effect-frame.js +17 -3
  102. package/dist/ui/shared/effect-policy.js +18 -4
  103. package/dist/ui/shared/effect-registry.js +11 -18
  104. package/dist/ui/shared/effect-stock-trust.js +2 -2
  105. package/dist/ui/shared/effect-trial-stage.js +18 -7
  106. package/dist/ui/shared/fx-motion.js +58 -0
  107. package/dist/ui/shared/web-authorize.js +151 -0
  108. package/dist/usage-report.d.ts +147 -0
  109. package/dist/usage-report.js +195 -0
  110. package/dist/usage-report.js.map +1 -0
  111. package/dist/usage-rollup.d.ts +51 -0
  112. package/dist/usage-rollup.js +168 -0
  113. package/dist/usage-rollup.js.map +1 -0
  114. package/dist/web-ambient.d.ts +72 -0
  115. package/dist/web-ambient.js +154 -0
  116. package/dist/web-ambient.js.map +1 -0
  117. package/dist/web-api.d.ts +32 -0
  118. package/dist/web-api.js +143 -18
  119. package/dist/web-api.js.map +1 -1
  120. package/dist/web-appearance-tools.d.ts +3 -0
  121. package/dist/web-appearance-tools.js +18 -3
  122. package/dist/web-appearance-tools.js.map +1 -1
  123. package/dist/web-appearance-view.d.ts +14 -2
  124. package/dist/web-appearance-view.js +32 -5
  125. package/dist/web-appearance-view.js.map +1 -1
  126. package/dist/web-appearance.d.ts +15 -1
  127. package/dist/web-appearance.js +77 -1
  128. package/dist/web-appearance.js.map +1 -1
  129. package/dist/web-asset-parts.d.ts +2 -2
  130. package/dist/web-asset-parts.js +3 -3
  131. package/dist/web-asset-parts.js.map +1 -1
  132. package/dist/web-auth.js +3 -1
  133. package/dist/web-auth.js.map +1 -1
  134. package/dist/web-effect-bootstrap.d.ts +12 -0
  135. package/dist/web-effect-bootstrap.js +49 -0
  136. package/dist/web-effect-bootstrap.js.map +1 -0
  137. package/dist/web-effect-bundle.js +46 -1
  138. package/dist/web-effect-bundle.js.map +1 -1
  139. package/dist/web-effect-playback.d.ts +30 -0
  140. package/dist/web-effect-playback.js +71 -0
  141. package/dist/web-effect-playback.js.map +1 -0
  142. package/dist/web-effect-profiles.d.ts +4 -4
  143. package/dist/web-effect-service.d.ts +11 -4
  144. package/dist/web-effect-service.js +25 -4
  145. package/dist/web-effect-service.js.map +1 -1
  146. package/dist/web-effect-tools.d.ts +1 -0
  147. package/dist/web-effect-tools.js +2 -1
  148. package/dist/web-effect-tools.js.map +1 -1
  149. package/dist/web-effect-worker.d.ts +1 -1
  150. package/dist/web-effect-worker.js +2 -2
  151. package/dist/web-effect-worker.js.map +1 -1
  152. package/dist/web-guest-access.js +3 -1
  153. package/dist/web-guest-access.js.map +1 -1
  154. package/dist/web-mod-agent.js +5 -1
  155. package/dist/web-mod-agent.js.map +1 -1
  156. package/dist/web-mod-bundle.d.ts +9 -1
  157. package/dist/web-mod-bundle.js +62 -3
  158. package/dist/web-mod-bundle.js.map +1 -1
  159. package/dist/web-mod-export.d.ts +9 -0
  160. package/dist/web-mod-export.js +21 -0
  161. package/dist/web-mod-export.js.map +1 -1
  162. package/dist/web-mod-store.d.ts +2 -1
  163. package/dist/web-mod-store.js +13 -6
  164. package/dist/web-mod-store.js.map +1 -1
  165. package/dist/web-session-authorization.d.ts +132 -0
  166. package/dist/web-session-authorization.js +193 -0
  167. package/dist/web-session-authorization.js.map +1 -0
  168. package/dist/web-session.d.ts +5 -0
  169. package/dist/web-session.js +16 -0
  170. package/dist/web-session.js.map +1 -1
  171. package/dist/web-settings-patch.d.ts +24 -2
  172. package/dist/web-settings-patch.js +47 -1
  173. package/dist/web-settings-patch.js.map +1 -1
  174. package/dist/web-settings-tools.d.ts +8 -0
  175. package/dist/web-settings-tools.js +14 -1
  176. package/dist/web-settings-tools.js.map +1 -1
  177. package/dist/web-theme-store.d.ts +39 -0
  178. package/dist/web-theme-store.js +122 -11
  179. package/dist/web-theme-store.js.map +1 -1
  180. package/package.json +1 -1
@@ -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
+ }
@@ -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 [];
@@ -569,6 +569,20 @@ html.lb-open .scroller { overflow: hidden; }
569
569
  .th-bg { position: absolute; inset: 0; width: 100%; height: 100%; opacity: var(--th-bg-opacity, 1); pointer-events: none; z-index: 0; user-select: none; }
570
570
  .th-bg-cover { object-fit: cover; }
571
571
  .th-bg-contain { object-fit: contain; }
572
+ /* #2117: an agent's ambient effect (chat-ambient.js). The loop sits behind the thread (z 0, under the scroller and the
573
+ dock at z 1); a burst follows the dock in the tree at z 1, so it shows over the messages and under the header (z 2).
574
+ Neither ever takes a pointer, focus or selection; positions and sizes are set through the CSSOM. */
575
+ .chat-main.has-amb { position: relative; }
576
+ .chat-main.has-amb > .amb-layer, .chat-main.has-amb > .amb-burst { position: absolute; inset: 0; overflow: hidden; pointer-events: none; user-select: none; contain: strict; }
577
+ .chat-main.has-amb > .amb-layer { z-index: 0; }
578
+ .chat-main.has-amb > .amb-burst { z-index: 1; }
579
+ .amb-p { position: absolute; left: 0; top: 0; line-height: 1; color: var(--accent); pointer-events: none; will-change: transform, opacity; }
580
+ img.amb-p { object-fit: contain; }
581
+ .s-mod-glyph { width: 24px; height: 24px; object-fit: contain; }
582
+ /* #2117: the one-time "reduced motion; effects are on" note, a strip above the thread. */
583
+ .chat-main > .amb-note { position: relative; z-index: 1; display: flex; align-items: center; gap: var(--s-2); flex-wrap: wrap; margin: var(--s-2) var(--s-3) 0; padding: var(--s-1) var(--s-2); border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--surface); color: var(--text-2); font-size: 13px; }
584
+ .chat-main > .amb-note .amb-note-off { border: 0; background: none; padding: 0; color: var(--accent); text-decoration: underline; cursor: pointer; font: inherit; min-height: 32px; }
585
+ .chat-main > .amb-note .amb-note-x { margin-left: auto; min-width: 32px; min-height: 32px; border: 0; background: none; color: var(--text-2); cursor: pointer; }
572
586
  .pv-divider { flex: 0 0 8px; cursor: col-resize; background: var(--bg); border-left: 1px solid var(--border); touch-action: none; position: relative; }
573
587
  .pv-divider::after { content: ""; position: absolute; top: 50%; left: 2px; width: 3px; height: 32px; margin-top: -16px; border-radius: 2px; background: var(--border-strong); }
574
588
  .pv-divider:hover::after, .pv-divider:focus-visible::after, .chat-split.resizing .pv-divider::after { background: var(--accent); }
@@ -677,6 +691,15 @@ html.lb-open .scroller { overflow: hidden; }
677
691
  .s-guests .guest-actions { display: flex; flex-wrap: wrap; gap: var(--s-1); margin-top: var(--s-1); }
678
692
  .s-guests .guest-narrow-edit { margin-top: var(--s-2); }
679
693
  .s-guests .guest-away { color: var(--text-3); }
694
+ /* #1939 S2b: this browser's authorization, under the composer and in the session menu (one compact line). */
695
+ .webauth-chip { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-1) var(--s-2); margin: var(--s-1) 0 0; font-size: var(--fs-xs); }
696
+ .webauth-chip .webauth-ok { color: var(--ok); }
697
+ .webauth-chip .btn-sm { min-height: 24px; }
698
+ /* #1939 S2a: Settings → Web → Web authority. */
699
+ .s-web-authority .s-va-sessions { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s-2); }
700
+ .s-web-authority .s-va-sessions li { padding: var(--s-2) var(--s-3); border: 1px solid var(--border); border-radius: var(--r-sm); overflow-wrap: anywhere; }
701
+ .s-web-authority .s-va-warn { background: var(--warn-bg); color: var(--warn); border: 1px solid var(--warn-line); border-radius: var(--r-sm); padding: var(--s-1) var(--s-2); }
702
+ .s-web-authority .save-row { flex-wrap: wrap; }
680
703
  /* #1920: a guest's note under the composer — on a phone too, where the key hints are hidden. */
681
704
  .guest-note { margin: 2px 0 0; font-size: var(--fs-xs); color: var(--text-3); text-align: center; }
682
705
  .pending-files { display: flex; flex-wrap: wrap; gap: var(--s-2); padding: var(--s-2) var(--s-2) 0; }
@@ -1448,15 +1471,21 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1448
1471
 
1449
1472
  .chat-card-toggle { display: flex; align-items: center; gap: var(--s-2); width: 100%; min-height: 32px; padding: 0 var(--s-4); border: 0; background: none; color: var(--text-2); font: inherit; font-size: var(--fs-xs); text-align: left; cursor: pointer; }
1450
1473
  .chat-card-peek { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-3); }
1474
+ /* A phone: the portrait stays on the text's row, smaller (the user's ask: never a portrait alone on a line with the text
1475
+ under it); the S / M / L control takes the line below. The chat header's card is trimmed further: a smaller portrait
1476
+ and banner, and its tagline and bio cut to a few lines (Details and View show them whole). */
1451
1477
  @media (max-width: 479px) {
1452
- .id-card.card-s { --id-h: 120px; --id-bmin: 80px; --id-bmax: 120px; }
1453
- .id-card.card-m { --id-h: 180px; --id-bmin: 100px; --id-bmax: 150px; }
1454
- .id-card.card-l { --id-h: 240px; --id-bmin: 120px; --id-bmax: 200px; }
1455
- .id-card.card-c { --id-bmin: 100px; --id-bmax: 140px; }
1456
- .id-card.card-c.bs-s { --id-bmin: 80px; --id-bmax: 110px; }
1457
- .id-card.card-c.bs-l { --id-bmin: 110px; --id-bmax: 150px; }
1458
- .id-row { flex-wrap: wrap; }
1459
- .id-text { flex-basis: 100%; }
1478
+ .id-card.card-s { --id-h: 96px; --id-bmin: 80px; --id-bmax: 120px; }
1479
+ .id-card.card-m { --id-h: 128px; --id-bmin: 100px; --id-bmax: 150px; }
1480
+ .id-card.card-l { --id-h: 168px; --id-bmin: 120px; --id-bmax: 200px; }
1481
+ .id-card.card-c { --id-h: 64px; --id-bmin: 80px; --id-bmax: 110px; }
1482
+ .id-card.card-c.bs-s { --id-bmin: 80px; --id-bmax: 100px; }
1483
+ .id-card.card-c.bs-l { --id-bmin: 96px; --id-bmax: 130px; }
1484
+ .id-row { flex-wrap: wrap; gap: var(--s-2) var(--s-3); padding: var(--s-2) var(--s-3); }
1485
+ .card-size { flex-basis: 100%; justify-content: flex-end; }
1486
+ .id-card.card-c .id-tagline, .id-card.card-c .id-bio { display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; }
1487
+ .id-card.card-c .id-tagline { -webkit-line-clamp: 2; line-clamp: 2; }
1488
+ .id-card.card-c .id-bio { -webkit-line-clamp: 3; line-clamp: 3; }
1460
1489
  }
1461
1490
  /* #1629 r4 §1: the card in View (above the profile row) and its part of Edit profile. */
1462
1491
  .v-id { flex-shrink: 0; padding: var(--s-2) var(--s-4) 0; background: var(--bg); }
@@ -1514,3 +1543,8 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1514
1543
  .effect-trial-head > span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
1515
1544
  .effect-trial-body { flex: 1; min-height: 0; display: flex; padding: var(--s-3); }
1516
1545
  .effect-trial-stage { position: relative; z-index: 0; flex: 1; min-width: 0; min-height: 0; contain: paint; overflow: clip; padding: 0; border: 0; outline: none; box-shadow: none; }
1546
+
1547
+ /* #2115: diagnostics remain inside the stock protected panel header, never in an effect plane. */
1548
+ .panel-head:has(> .chat-fx-notice) { flex-wrap: wrap; }
1549
+ .chat-fx-notice { order: 10; flex: 1 1 100%; min-width: 0; max-width: 100%; font-size: var(--fs-sm); overflow-wrap: anywhere; }
1550
+ .chat-fx-notice > span { display: block; }
@@ -1,4 +1,5 @@
1
1
  /** #1629: app-owned pool; Chat contributes a lease, never another registry/loader. */
2
+ import { effectPlaybackReason, EFFECT_BLOCK_REASONS } from "./effect-policy.js";
2
3
  import { createEffectTrialSource } from "./effect-trial-source.js";
3
4
  import { createOwnerEffectSource } from "./effect-owner-source.js";
4
5
  import { createEffectRuntimeLoader } from "./effect-runtime-loader.js";
@@ -6,7 +7,7 @@ import { createEffectRegistry } from "./effect-registry.js";
6
7
  import { createEffectSafety } from "./effect-safety.js";
7
8
  import { stockEffectTrust } from "./effect-stock-trust.js";
8
9
  import { createEffectProfileCollector } from "./effect-profiles.js";
9
- import { chatEffectIdentity, createEffectWriter } from "./effect-chat-runtime.js";
10
+ import { effectPlaybackIdentity, createEffectWriter } from "./effect-chat-runtime.js";
10
11
 
11
12
  const episodes = new WeakSet();
12
13
 
@@ -14,7 +15,8 @@ const episodes = new WeakSet();
14
15
  export function createAppEffectRuntime({ window: host, current, observe, onChange = () => {}, onDispose = () => {}, resolveOwnerTarget = null, onOwnerUnavailable = () => {}, factories = {} }) {
15
16
  let dead = false, error = null, loader = null, registry = null, writer = null, source = null, personal = null, trial = null, pending = null, lastMetadata = null, serial = 0, revision = 0, stamp = null, syncing = false, queued = false;
16
17
  const bindings = new Map(), profiles = new Map();
17
- const identity = chatEffectIdentity(host), auth = host?.AgendAuth;
18
+ let lastContext = null, playbackUnavailable = null;
19
+ const identity = effectPlaybackIdentity(host), auth = host?.AgendAuth;
18
20
  const safe = fn => { try { return fn(); } catch { return null; } };
19
21
  const alive = () => { if (dead) return false; const value = safe(() => current() === true && !auth.sessionEnded()); return !dead && value === true; };
20
22
  const sourceAlive = s => {
@@ -25,15 +27,49 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
25
27
  const placementIdentity = p => JSON.stringify([p.by, p.manifest.layer, p.params]);
26
28
  const clock = () => host.performance.now();
27
29
  let offAuth = null;
28
- const snapshot = s => ({ error: error ?? s?.error ?? null, rows: registry?.snapshot().filter(row => s?.bindings.get(row.id)?.key === row.key) ?? [], effects: s?.metadata?.effects ?? [], loading: !!s && !s.dead && s.metadata === null && !s.error });
30
+ const snapshot = s => {
31
+ const effects = s?.metadata?.effects ?? [], known = registry?.snapshot?.() ?? [];
32
+ const rows = effects.map(p => {
33
+ const b = s.bindings.get(p.id), row = known.find(r => b && r.key === b.key && r.id === p.id);
34
+ if (row && !s.error && !playbackUnavailable) return row.reason == null && row.phase == null ? { ...row, reason: "calibrating" } : row;
35
+ return { id: p.id, key: b?.key ?? null, verdict: "off", phase: null, quality: null,
36
+ reason: s.error ?? playbackUnavailable ?? effectPlaybackReason(lastContext) ?? (!p.manifest.regions.includes("chat") ? "region_unavailable" : "runtime_unavailable") };
37
+ });
38
+ return { error: error ?? s?.error ?? null, rows, effects, loading: !!s && !s.dead && s.metadata === null && !s.error };
39
+ };
29
40
  function tell() {
30
41
  const s = source, value = snapshot(s); if (dead) return;
31
- if (sourceAlive(s)) safe(() => s.onChange(value));
42
+ if (sourceAlive(s)) {
43
+ const next = JSON.stringify(value);
44
+ if (next !== s.lastSnapshot) { s.lastSnapshot = next; safe(() => s.onChange(value)); }
45
+ }
32
46
  if (!dead) safe(() => onChange(snapshot(source)));
33
47
  }
48
+ function reportPlayback(s) {
49
+ if (!sourceAlive(s) || identity.scope !== "owner") return;
50
+ const now = safe(clock);
51
+ if (!Number.isFinite(now) || now < 0 || s.reportClock !== null && now < s.reportClock) { s.reporter.dispose(); return; }
52
+ s.reportClock = now;
53
+ const value = snapshot(s);
54
+ for (const id of s.reported.keys()) if (!value.effects.some(p => p.id === id)) s.reported.delete(id);
55
+ for (const row of value.rows) {
56
+ if (!sourceAlive(s)) return;
57
+ const status = row.phase === "running" && row.reason == null ? "playing" : "blocked";
58
+ const reason = status === "playing" ? null : EFFECT_BLOCK_REASONS.includes(row.reason) ? row.reason : "unknown";
59
+ const device = lastContext?.constrained === true ? "constrained" : "standard";
60
+ const next = JSON.stringify([status, reason, device]), last = s.reported.get(row.id);
61
+ if (last && (now - last.at < 10000 || next === last.value && now - last.at < 60000)) continue;
62
+ s.reported.set(row.id, { at: now, value: next });
63
+ void s.reporter.post(`/ui/effects/profile/${row.id}?instance=${encodeURIComponent(s.name)}&ownerEpoch=${identity.epoch}`,
64
+ { v: 1, kind: "playback", device, status, reason });
65
+ }
66
+ // No server polling or unbounded retries: at most one observation per effect / 10s, a 60s heartbeat.
67
+ if (sourceAlive(s)) s.reportTimer = host.setTimeout(() => reportPlayback(s), 10000);
68
+ }
34
69
  function discard(s) {
35
70
  if (!s || s.dead) return;
36
71
  s.dead = true; s.generation++; s.metadata = null;
72
+ safe(() => host.clearTimeout(s.reportTimer)); safe(() => s.reporter?.dispose());
37
73
  if (source === s) source = null;
38
74
  safe(() => trial?.stopFor(s));
39
75
  for (const b of s.bindings.values()) bindings.delete(b.key); s.bindings.clear();
@@ -63,7 +99,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
63
99
  writer = createEffectWriter(host, alive);
64
100
  loader = make("loader", createEffectRuntimeLoader)({ window: host, ownerEpoch: identity.epoch, current: alive, onRevoke: () => fail("runtime_unavailable") });
65
101
  if (!loader.ok || typeof loader.placementsSettled !== "function" || typeof loader.resourceSettled !== "function") throw Error("runtime_unavailable");
66
- registry = make("registry", createEffectRegistry)({ window: host, origin: host.document.body.dataset.previewOrigin, boot: host.document.body.dataset.previewBoot,
102
+ registry = make("registry", createEffectRegistry)({ window: host, origin: host.location?.origin, boot: host.document.body.dataset.effectBoot,
67
103
  load: async (descriptor, signal) => {
68
104
  const b = bindingFor(descriptor.key, descriptor.id);
69
105
  if (!bindingAlive(b) || descriptor.id !== b.id || signal.aborted) return null;
@@ -78,7 +114,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
78
114
  stockSnapshot: ({ frameRoot }) => make("stock", stockEffectTrust)(host, { frameRoot, ownerCss: observe().ownerCss }),
79
115
  onChange() { try { tell(); emit(); } catch { fail("runtime_unavailable"); } },
80
116
  onMount(owner) {
81
- const b = bindingFor(owner.key, owner.id); if (!bindingAlive(b) || b.id !== owner.id || !owner.current()) return;
117
+ const b = bindingFor(owner.key, owner.id); if (identity.scope !== "owner" || !bindingAlive(b) || b.id !== owner.id || !owner.current()) return;
82
118
  const collector = make("collector", createEffectProfileCollector)({ key: owner.key, id: owner.id, device: owner.device, clock });
83
119
  const began = safe(clock);
84
120
  if (collector.ok && Number.isFinite(began) && began >= 0 && bindingAlive(b) && owner.current()) profiles.set(owner, { binding: b, collector, began, attempted: null }); else collector.dispose?.();
@@ -95,17 +131,23 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
95
131
  void writer.post(`/ui/effects/profile/${owner.id}?${query}ownerEpoch=${identity.epoch}`, report.profile);
96
132
  }, clock,
97
133
  });
98
- if (!registry.ok) throw Error("runtime_unavailable");
99
- trial = make("trialSource", createEffectTrialSource)({ window: host, current: () => {
134
+ if (!registry.ok) {
135
+ // Keep authenticated placement metadata available for explanations. This inert adapter cannot create,
136
+ // load, resume, send to, or authorize a frame; a failed paint subsystem is never permission.
137
+ playbackUnavailable = "unsupported_host";
138
+ safe(() => registry?.dispose?.());
139
+ registry = { ok: true, frameRoot: null, update() {}, snapshot: () => [], publish() {}, resume: () => false, dispose() {} };
140
+ }
141
+ if (identity.scope === "owner") trial = make("trialSource", createEffectTrialSource)({ window: host, current: () => {
100
142
  if (!alive()) return false; const v = observe(), c = v.context;
101
- return alive() && c.enabled === true && c.show === true && c.trialReady === true
143
+ return alive() && c.enabled === true && c.show === true && c.transportReady === true
102
144
  && c.safe === false && c.publicLink === false && c.reducedMotion === false && c.saveData === false && c.hidden === false;
103
145
  }, ownerCurrent: (s, name) => sourceAlive(s) && s.name === name,
104
146
  stock: () => registry.frameRoot ? make("stock", stockEffectTrust)(host, { frameRoot: registry.frameRoot, ownerCss: observe().ownerCss }) : null,
105
147
  readResource: (spec, signal) => typeof loader.trialResourceSettled === "function" ? loader.trialResourceSettled(spec, signal) : Promise.resolve(null),
106
148
  onChange() { revision++; stamp = null; sync(); tell(); },
107
149
  });
108
- if (typeof resolveOwnerTarget === "function") {
150
+ if (identity.scope === "owner" && typeof resolveOwnerTarget === "function") {
109
151
  if (typeof loader.ownerSettled !== "function") throw Error("runtime_unavailable");
110
152
  personal = createOwnerEffectSource({ current: alive,
111
153
  readMetadata: signal => loader.ownerSettled(signal), readResource: (spec, signal) => loader.resourceSettled(spec, signal), resolveTarget: resolveOwnerTarget,
@@ -139,7 +181,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
139
181
  }
140
182
  function refuseSource(s) {
141
183
  if (!sourceAlive(s)) return;
142
- s.error = "resource_unavailable"; s.metadata = null; s.generation++;
184
+ s.error = "resource_unavailable"; s.generation++;
143
185
  for (const b of s.bindings.values()) bindings.delete(b.key); s.bindings.clear(); revision++; stamp = null; sync(); tell();
144
186
  }
145
187
  function queueSync() {
@@ -156,6 +198,7 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
156
198
  if (!alive() || revision !== before || s !== source) { if (alive()) queueSync(); return; }
157
199
  const entries = [...(personal?.descriptors() ?? []), ...(trial?.descriptors() ?? []), ...descriptors(s)];
158
200
  if (!alive() || revision !== before || s !== source) { stamp = null; if (alive()) queueSync(); return; }
201
+ lastContext = { ...value.context };
159
202
  const next = JSON.stringify([revision, value.context, entries.map(d => [d.key, d.paused])]);
160
203
  if (stamp !== next) {
161
204
  if (!alive() || revision !== before || s !== source) { stamp = null; if (alive()) queueSync(); return; }
@@ -164,12 +207,15 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
164
207
  if (!alive() || revision !== before || s !== source) { stamp = null; if (alive()) queueSync(); return; }
165
208
  emit();
166
209
  const c = value.context;
167
- if (!pending && c.enabled && c.show && c.previewReady && c.optIn && !c.safe && !c.publicLink && !c.reducedMotion && !c.saveData && !c.hidden) {
168
- const ownerNeeds = personal?.needsMetadata() === true, chatNeeds = sourceAlive(s) && !s.error && !s.metadata;
210
+ // Metadata describes placements, not executable resources. Its existing session/epoch/current and
211
+ // shared settled-lane fences apply even when this viewer has declined playback. No code is fetched here.
212
+ if (!pending) {
213
+ const ownerNeeds = identity.scope === "owner" && !c.publicLink && c.enabled && c.show && c.transportReady && !c.safe && !c.reducedMotion && !c.saveData && !c.hidden && personal?.needsMetadata() === true, chatNeeds = sourceAlive(s) && !s.error && !s.metadata;
169
214
  if (!alive() || revision !== before || s !== source) { if (alive()) queueSync(); return; }
170
215
  if (ownerNeeds && (!chatNeeds || lastMetadata !== "owner")) void readPersonal();
171
216
  else if (chatNeeds) void read(s);
172
217
  }
218
+ tell();
173
219
  } catch { fail("runtime_unavailable"); }
174
220
  finally { syncing = false; }
175
221
  }
@@ -216,14 +262,17 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
216
262
  for (const b of s.bindings.values()) bindings.delete(b.key); s.bindings.clear();
217
263
  if (pending?.source === s) safe(() => pending.controller.abort()); revision++; stamp = null; sync(); tell();
218
264
  }
219
- function publishReply(s, event) { if (sourceAlive(s) && !s.error && event.instance === s.name) registry.publish("reply", {}, s.name); }
265
+ function publishReply(s, event) { if (host.document.body.dataset.webTransport !== "poll" && sourceAlive(s) && !s.error && event.instance === s.name) registry.publish("reply", {}, s.name); }
220
266
  function attachChat(options) {
221
267
  discard(source); const expected = revision; sync();
222
268
  if (!alive() || revision !== expected || source !== null) return null;
223
269
  const { name, element, current: ownCurrent, observe: ownObserve, onChange: ownChange = () => {} } = options ?? {};
224
270
  if (!alive() || typeof name !== "string" || name.length < 1 || name.length > 128 || !element || element.isConnected !== true || typeof ownCurrent !== "function" || typeof ownObserve !== "function" || safe(() => ownCurrent() === true) !== true || revision !== expected || source !== null) return null;
225
271
  const s = { name, element, current: ownCurrent, observe: ownObserve, onChange: ownChange, serial: ++serial, generation: 0, dead: false, error: null, metadata: null, bindings: new Map(), paused: new Map() };
226
- source = s; revision++; stamp = null;
272
+ source = s;
273
+ s.reporter = createEffectWriter(host, () => sourceAlive(s)); s.reported = new Map(); s.reportClock = null;
274
+ s.reportTimer = host.setTimeout(() => reportPlayback(s), 10000);
275
+ revision++; stamp = null;
227
276
  const placement = id => sourceAlive(s) && !s.error ? s.metadata?.effects.find(p => p.id === id) : null;
228
277
  const changed = () => changedSource(s);
229
278
  const api = { ok: true, sync,
@@ -231,14 +280,14 @@ export function createAppEffectRuntime({ window: host, current, observe, onChang
231
280
  reply(event) { publishReply(s, event); },
232
281
  pause(id) { const p = placement(id); if (!p) return false; s.paused.set(id, placementIdentity(p)); revision++; sync(); return true; },
233
282
  resume(id) { const p = placement(id); if (!p) return false; s.paused.delete(id); revision++; sync(); const b = s.bindings.get(id); return bindingAlive(b) && registry.resume(b.key); },
234
- async remove(id) { const p = placement(id); if (!p) return false; const generation = s.generation; const ok = await writer.post("/ui/effects/remove", { instance: s.name, id }); if (!sourceAlive(s) || generation !== s.generation) return false; if (ok) changed(); return ok; },
283
+ async remove(id) { const p = placement(id); if (identity.scope !== "owner" || !p) return false; const generation = s.generation; const ok = await writer.post("/ui/effects/remove", { instance: s.name, id }); if (!sourceAlive(s) || generation !== s.generation) return false; if (ok) changed(); return ok; },
235
284
  snapshot: () => sourceAlive(s) ? snapshot(s) : { error: null, rows: [], effects: [], loading: false },
236
285
  dispose() { if (source !== s) return; discard(s); sync(); },
237
286
  };
238
287
  sync(); tell(); return sourceAlive(s) ? api : null;
239
288
  }
240
- return { ok: true, sync, attachChat, changedOwner, ownerSnapshot,
241
- attachTrial(options) { const s = source; return sourceAlive(s) ? trial.attach(options, s) : null; },
289
+ return { ok: true, playbackAvailable: playbackUnavailable === null, sync, attachChat, changedOwner, ownerSnapshot,
290
+ attachTrial(options) { const s = source; return trial && !playbackUnavailable && sourceAlive(s) ? trial.attach(options, s) : null; },
242
291
  stopTrial() { trial?.stop(); },
243
292
  trialSnapshot() { return { rows: registry?.snapshot().filter(row => trial?.binding(row.key, row.id)) ?? [] }; },
244
293
  cursorSelection: () => personal?.cursorSelection() ?? null, cursorImageSettled,
@@ -46,10 +46,23 @@ export function createEffectWriter(host, current) {
46
46
  export function chatEffectIdentity(host) {
47
47
  try {
48
48
  const b = host.document.body.dataset, x = JSON.parse(host.document.documentElement.getAttribute("data-prefs") || "null");
49
- if (b.mode !== "full" || b.publicLink === "1" || b.webTransport === "poll" || !["person", "operator"].includes(x?.ownerScope) || x.unreadable === true || typeof x.ownerEpoch !== "string" || !/^[a-f0-9]{16}$/.test(x.ownerEpoch)) return null;
49
+ if (b.mode !== "full" || b.tier === "guest" || b.publicLink === "1" || b.webTransport === "poll" || !["person", "operator"].includes(x?.ownerScope) || x.unreadable === true || typeof x.ownerEpoch !== "string" || !/^[a-f0-9]{16}$/.test(x.ownerEpoch)) return null;
50
50
  return Object.freeze({ epoch: x.ownerEpoch, scope: x.ownerScope });
51
51
  } catch { return null; }
52
52
  }
53
+ /** Chat-only public/guest capability stays separate from personal preferences and management. */
54
+ export function effectPlaybackIdentity(host) {
55
+ try {
56
+ const b = host.document.body.dataset;
57
+ if (b.mode !== "full" || typeof b.effectEpoch !== "string" || !/^[a-f0-9]{16}$/.test(b.effectEpoch)) return null;
58
+ if (b.effectScope === "owner") {
59
+ const own = chatEffectIdentity(host);
60
+ return own?.epoch === b.effectEpoch ? Object.freeze({ epoch: b.effectEpoch, scope: "owner" }) : null;
61
+ }
62
+ if (b.effectScope === "chat" && (b.publicLink === "1" && b.webTransport === "poll" || b.tier === "guest")) return Object.freeze({ epoch: b.effectEpoch, scope: "chat" });
63
+ } catch { /* no session capability */ }
64
+ return null;
65
+ }
53
66
  export function chatEffectState(state, name) {
54
67
  const inst = state.instances?.find(i => i.name === name);
55
68
  if (!inst) return null;
@@ -81,7 +94,7 @@ export function createChatEffectRuntime({ window: host, name, element, current,
81
94
  writer = createEffectWriter(host, alive);
82
95
  loader = make("loader", createEffectRuntimeLoader)({ window: host, ownerEpoch: identity.epoch, current: alive });
83
96
  if (!loader.ok) throw Error("runtime_unavailable");
84
- registry = make("registry", createEffectRegistry)({ window: host, origin: host.document.body.dataset.previewOrigin, boot: host.document.body.dataset.previewBoot,
97
+ registry = make("registry", createEffectRegistry)({ window: host, origin: host.location?.origin, boot: host.document.body.dataset.effectBoot,
85
98
  load: async (d, signal) => {
86
99
  const mine = generation, value = await loader.resource({ id: d.id, instance: name }, signal);
87
100
  if (!alive() || mine !== generation || signal.aborted) return null;
@@ -125,7 +138,7 @@ export function createChatEffectRuntime({ window: host, name, element, current,
125
138
  const stamp = JSON.stringify(ctx);
126
139
  if (stamp !== lastContext) { lastContext = stamp; registry.update(descriptors(), ctx); }
127
140
  emit();
128
- if (!metadata && !active && ctx.enabled && ctx.show && ctx.previewReady && ctx.optIn && !ctx.safe && !ctx.publicLink && !ctx.reducedMotion && !ctx.saveData && !ctx.hidden) void read();
141
+ if (!metadata && !active && ctx.enabled && ctx.show && ctx.transportReady && !ctx.safe && !ctx.publicLink && !ctx.reducedMotion && !ctx.saveData && !ctx.hidden) void read();
129
142
  }
130
143
  async function read() {
131
144
  if (!alive() || error || active) return;
@@ -39,12 +39,26 @@ export function effectTransportReady(host, origin, boot) {
39
39
  } catch { return false; }
40
40
  }
41
41
 
42
+ /** #2118: same URL origin, opaque realm. This helper does not alter product admission or HTML preview consent. */
43
+ export function effectBootstrapReady(host, origin, boot) {
44
+ try {
45
+ const target = new URL(origin), page = new URL(host.location.origin);
46
+ return origin === target.origin && target.origin === page.origin && ["http:", "https:"].includes(target.protocol)
47
+ && typeof boot === "string" && /^[0-9a-f]{32}$/.test(boot);
48
+ } catch { return false; }
49
+ }
50
+
42
51
  /**
43
52
  * All options come from the host, never from an agent event or manifest origin. The registry supplies an admitted
44
53
  * root and geometry in a later integration. This primitive alone does not implement caps, clipping or profiling.
45
54
  */
46
- export function createEffectFrame({ window: host, root, origin, boot, render, clock = () => performance.now(), onReady = () => {}, onRemove = () => {} } = {}) {
47
- if (!effectTransportReady(host, origin, boot)) return { ok: false, reason: "preview_unknown" };
55
+ export function createEffectFrame(options = {}) { return ownedEffectFrame(options, false); }
56
+
57
+ /** Product transport: the same-listener trusted document still has an opaque sandboxed realm. */
58
+ export function createOpaqueEffectFrame(options = {}) { return ownedEffectFrame(options, true); }
59
+
60
+ function ownedEffectFrame({ window: host, root, origin, boot, render, clock = () => performance.now(), onReady = () => {}, onRemove = () => {} } = {}, sameListener) {
61
+ if (!(sameListener ? effectBootstrapReady : effectTransportReady)(host, origin, boot)) return { ok: false, reason: sameListener ? "bootstrap_unknown" : "preview_unknown" };
48
62
  const payload = snapshotRender(render);
49
63
  if (!payload || !host || !root || root.ownerDocument !== host.document || typeof root.append !== "function" || typeof host.MessageChannel !== "function" || typeof clock !== "function" || typeof onReady !== "function" || typeof onRemove !== "function") return { ok: false, reason: "unsupported" };
50
64
  let mounted;
@@ -122,7 +136,7 @@ export function createEffectFrame({ window: host, root, origin, boot, render, cl
122
136
  host.addEventListener("message", hello);
123
137
  frame.addEventListener("load", load);
124
138
  timer = host.setTimeout(deadline, 10000);
125
- try { frame.src = `${origin}/effect`; root.append(frame); } catch { remove("mount_failed"); }
139
+ try { frame.src = origin + (sameListener ? "/effect-bootstrap" : "/effect"); root.append(frame); } catch { remove("mount_failed"); }
126
140
  return { ok: true, element: frame, state, remove,
127
141
  send(event) {
128
142
  if (!ready || !current() || !maySendEffectEvent(event, { instance: payload.instance, wants: payload.wants })) return false;
@@ -58,6 +58,19 @@ export function constrainedDevice(signals) {
58
58
  return !!signals && (finite(signals.memory) && signals.memory > 0 && signals.memory <= 4 || finite(signals.cores) && signals.cores > 0 && signals.cores <= 4 || signals.coarse === true && finite(signals.width) && signals.width > 0 && signals.width <= 900);
59
59
  }
60
60
 
61
+ export const EFFECT_BLOCK_REASONS = Object.freeze([
62
+ "transport_unavailable", "preview_unavailable", "preview_not_consented", "unsupported_host", "safe_mode", "reduced_motion", "save_data", "hidden", "disabled", "public_link",
63
+ "baseline_unsupported", "baseline_unavailable", "baseline_timeout", "calibrating", "clock_unavailable", "gates_unknown", "owner_css", "stock_overlay", "modal",
64
+ "safety_off", "safety_unknown", "interrupted", "paused", "host_budget", "frame_limit", "front_limit", "offscreen", "region_unavailable", "resource_unavailable", "resource_timeout", "runtime_unavailable", "unknown",
65
+ ]);
66
+
67
+ /** Diagnostic names only; the admission policy below still validates every required flag. */
68
+ export function effectPlaybackReason(c) {
69
+ if (!c || ["safe", "publicLink", "reducedMotion", "saveData", "hidden", "enabled", "show", "transportReady"].some(k => typeof c[k] !== "boolean")) return "gates_unknown";
70
+ return c.safe ? "safe_mode" : c.reducedMotion ? "reduced_motion" : c.saveData ? "save_data"
71
+ : c.hidden ? "hidden" : !c.enabled ? "unsupported_host" : !c.show ? "disabled" : !c.transportReady ? "transport_unavailable" : null;
72
+ }
73
+
61
74
  const OWNER_ORDER = Object.freeze({ page: 0, header: 1, sidebar: 2, box: 3 });
62
75
  /** Recomputed from host descriptors; queued/off/unknown descriptors never get a frame reservation. */
63
76
  export function allocateEffectFrames(descriptors, context) {
@@ -65,15 +78,15 @@ export function allocateEffectFrames(descriptors, context) {
65
78
  const keys = new Set(), duplicate = new Set();
66
79
  for (const d of descriptors) if (d && typeof d.key === "string") { if (keys.has(d.key)) duplicate.add(d.key); keys.add(d.key); }
67
80
  const result = descriptors.map(d => ({ key: d?.key, verdict: "off", reason: "unknown", queuePos: null }));
68
- const gates = ["safe", "publicLink", "reducedMotion", "saveData", "hidden", "enabled", "show", "previewReady", "optIn", "constrained", "modal"];
81
+ const gates = ["safe", "publicLink", "reducedMotion", "saveData", "hidden", "enabled", "show", "transportReady", "constrained", "modal"];
69
82
  if (gates.some(k => typeof context[k] !== "boolean") || context.openInstance !== null && !instanceName(context.openInstance)) return result;
70
83
  let global = null;
71
84
  if (context.safe === true) global = "safe_mode";
72
- else if (context.publicLink === true) global = "public_link";
73
85
  else if (context.reducedMotion === true) global = "reduced_motion";
74
86
  else if (context.saveData === true) global = "save_data";
75
87
  else if (context.hidden === true) global = "hidden";
76
- else if (context.enabled !== true || context.show !== true) global = "disabled";
88
+ else if (context.enabled !== true) global = "unsupported_host";
89
+ else if (context.show !== true) global = "disabled";
77
90
 
78
91
  const candidates = [];
79
92
  for (let i = 0; i < descriptors.length; i++) {
@@ -87,7 +100,8 @@ export function allocateEffectFrames(descriptors, context) {
87
100
  if (!owner && d.region !== "chat") { r.reason = "scope"; continue; }
88
101
  if (d.region === "chat" && (!instanceName(d.instance) || d.instance !== context.openInstance)) { r.reason = "closed_chat"; continue; }
89
102
  if (global) { r.reason = global; continue; }
90
- if (trial ? context.trialReady !== true : context.previewReady !== true || context.optIn !== true) { r.reason = "preview_unavailable"; continue; }
103
+ if (context.publicLink && (d.region !== "chat" || trial)) { r.reason = "public_link"; continue; }
104
+ if (!context.transportReady) { r.reason = "transport_unavailable"; continue; }
91
105
  if (d.paused === true) { r.reason = "paused"; continue; }
92
106
  if (d.visible !== true) { r.reason = "offscreen"; continue; }
93
107
  if (d.layer === "front" && context.modal === true && !trial) { r.reason = "modal"; continue; }