@songsid/agend 2.2.0 → 2.3.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (298) hide show
  1. package/dist/agent-cli.js +248 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.d.ts +2 -0
  4. package/dist/agent-endpoint.js +25 -0
  5. package/dist/agent-endpoint.js.map +1 -1
  6. package/dist/agent-file-read.d.ts +35 -0
  7. package/dist/agent-file-read.js +144 -0
  8. package/dist/agent-file-read.js.map +1 -0
  9. package/dist/agent-whats-new.js +8 -0
  10. package/dist/agent-whats-new.js.map +1 -1
  11. package/dist/atomic-write.js +9 -1
  12. package/dist/atomic-write.js.map +1 -1
  13. package/dist/auth-api.js +17 -0
  14. package/dist/auth-api.js.map +1 -1
  15. package/dist/backend/antigravity.js +3 -3
  16. package/dist/backend/antigravity.js.map +1 -1
  17. package/dist/backend/claude-code.js +5 -1
  18. package/dist/backend/claude-code.js.map +1 -1
  19. package/dist/backend/launch-effort.js +1 -1
  20. package/dist/backend/launch-effort.js.map +1 -1
  21. package/dist/backend/muse.js +10 -2
  22. package/dist/backend/muse.js.map +1 -1
  23. package/dist/backend/types.d.ts +8 -0
  24. package/dist/backend/types.js +10 -0
  25. package/dist/backend/types.js.map +1 -1
  26. package/dist/cache-analysis.d.ts +47 -1
  27. package/dist/cache-analysis.js +68 -8
  28. package/dist/cache-analysis.js.map +1 -1
  29. package/dist/cache-report-tool.d.ts +17 -0
  30. package/dist/cache-report-tool.js +40 -0
  31. package/dist/cache-report-tool.js.map +1 -0
  32. package/dist/cache-service.d.ts +15 -0
  33. package/dist/cache-service.js +26 -1
  34. package/dist/cache-service.js.map +1 -1
  35. package/dist/channel/adapters/discord.d.ts +2 -0
  36. package/dist/channel/adapters/discord.js +17 -2
  37. package/dist/channel/adapters/discord.js.map +1 -1
  38. package/dist/channel/adapters/telegram.d.ts +8 -1
  39. package/dist/channel/adapters/telegram.js +74 -4
  40. package/dist/channel/adapters/telegram.js.map +1 -1
  41. package/dist/channel/mcp-tools.js +36 -0
  42. package/dist/channel/mcp-tools.js.map +1 -1
  43. package/dist/channel/types.d.ts +15 -0
  44. package/dist/cli.js +7 -3
  45. package/dist/cli.js.map +1 -1
  46. package/dist/config-validator.js +22 -2
  47. package/dist/config-validator.js.map +1 -1
  48. package/dist/daemon.js +244 -20
  49. package/dist/daemon.js.map +1 -1
  50. package/dist/fleet-context.d.ts +5 -0
  51. package/dist/fleet-manager.d.ts +92 -0
  52. package/dist/fleet-manager.js +410 -29
  53. package/dist/fleet-manager.js.map +1 -1
  54. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +7 -0
  55. package/dist/general-knowledge/skills/web-appearance/SKILL.md +367 -0
  56. package/dist/instance-avatar.js +3 -0
  57. package/dist/instance-avatar.js.map +1 -1
  58. package/dist/instance-config-impact.js +3 -0
  59. package/dist/instance-config-impact.js.map +1 -1
  60. package/dist/instance-lifecycle.js +9 -4
  61. package/dist/instance-lifecycle.js.map +1 -1
  62. package/dist/needs-you-hub.d.ts +4 -0
  63. package/dist/needs-you-hub.js +31 -6
  64. package/dist/needs-you-hub.js.map +1 -1
  65. package/dist/needs-you.d.ts +1 -13
  66. package/dist/needs-you.js +3 -37
  67. package/dist/needs-you.js.map +1 -1
  68. package/dist/outbound-handlers.d.ts +7 -0
  69. package/dist/outbound-handlers.js +65 -10
  70. package/dist/outbound-handlers.js.map +1 -1
  71. package/dist/outbound-schemas.d.ts +74 -0
  72. package/dist/outbound-schemas.js +56 -0
  73. package/dist/outbound-schemas.js.map +1 -1
  74. package/dist/public-web-gateway.js +14 -0
  75. package/dist/public-web-gateway.js.map +1 -1
  76. package/dist/repo-worktree-guard.d.ts +36 -0
  77. package/dist/repo-worktree-guard.js +143 -0
  78. package/dist/repo-worktree-guard.js.map +1 -0
  79. package/dist/scheduler/scheduler.d.ts +70 -1
  80. package/dist/scheduler/scheduler.js +256 -17
  81. package/dist/scheduler/scheduler.js.map +1 -1
  82. package/dist/service-installer.d.ts +2 -1
  83. package/dist/service-installer.js +18 -3
  84. package/dist/service-installer.js.map +1 -1
  85. package/dist/settings-api.js +74 -1
  86. package/dist/settings-api.js.map +1 -1
  87. package/dist/settings-change.js +8 -0
  88. package/dist/settings-change.js.map +1 -1
  89. package/dist/settings-effect.js +16 -2
  90. package/dist/settings-effect.js.map +1 -1
  91. package/dist/settings-http-confirmation.js +5 -0
  92. package/dist/settings-http-confirmation.js.map +1 -1
  93. package/dist/tool-permissions.d.ts +5 -0
  94. package/dist/tool-permissions.js +61 -2
  95. package/dist/tool-permissions.js.map +1 -1
  96. package/dist/topic-commands.d.ts +10 -6
  97. package/dist/topic-commands.js +21 -17
  98. package/dist/topic-commands.js.map +1 -1
  99. package/dist/topic-link.d.ts +128 -0
  100. package/dist/topic-link.js +269 -0
  101. package/dist/topic-link.js.map +1 -0
  102. package/dist/types.d.ts +13 -0
  103. package/dist/ui/app-effects.js +187 -0
  104. package/dist/ui/chat-effect-posters.js +42 -0
  105. package/dist/ui/chat-effects.js +72 -0
  106. package/dist/ui/chat-live-replies.js +58 -0
  107. package/dist/ui/chat-render.js +4 -4
  108. package/dist/ui/chat-store.js +9 -3
  109. package/dist/ui/chat-strings.js +15 -0
  110. package/dist/ui/chat-thread.js +47 -21
  111. package/dist/ui/chat-tour.js +1 -1
  112. package/dist/ui/effect-trial-controller.js +119 -0
  113. package/dist/ui/effect-trial-offer.js +31 -0
  114. package/dist/ui/effect-trial-panel.js +37 -0
  115. package/dist/ui/fleet-cache.js +23 -1
  116. package/dist/ui/image-lightbox.js +1 -1
  117. package/dist/ui/panel-chat.js +85 -17
  118. package/dist/ui/panel-details.js +18 -5
  119. package/dist/ui/panel-fleet.js +3 -3
  120. package/dist/ui/panel-log.js +6 -6
  121. package/dist/ui/panel-needs.js +3 -3
  122. package/dist/ui/panel-settings.js +8 -5
  123. package/dist/ui/preview-panel.js +25 -9
  124. package/dist/ui/settings-appearance.js +325 -0
  125. package/dist/ui/settings-effects.js +201 -0
  126. package/dist/ui/settings-mods.js +257 -0
  127. package/dist/ui/settings-owner-effects.js +80 -0
  128. package/dist/ui/settings-strings.js +245 -0
  129. package/dist/ui/shared/agend-auth.js +27 -3
  130. package/dist/ui/shared/agent-widget.js +248 -0
  131. package/dist/ui/shared/app-i18n.js +2 -0
  132. package/dist/ui/shared/app-prefs.js +30 -10
  133. package/dist/ui/shared/app-route.js +23 -1
  134. package/dist/ui/shared/app-session.js +1 -1
  135. package/dist/ui/shared/app-shell.js +29 -16
  136. package/dist/ui/shared/app-store.js +2 -0
  137. package/dist/ui/shared/app-stream.js +12 -7
  138. package/dist/ui/shared/app-themes.js +188 -0
  139. package/dist/ui/shared/app.css +225 -3
  140. package/dist/ui/shared/app.js +6 -0
  141. package/dist/ui/shared/card-strings.js +28 -0
  142. package/dist/ui/shared/effect-app-runtime.js +248 -0
  143. package/dist/ui/shared/effect-baseline.js +64 -0
  144. package/dist/ui/shared/effect-chat-runtime.js +154 -0
  145. package/dist/ui/shared/effect-cursor.js +125 -0
  146. package/dist/ui/shared/effect-frame.js +127 -0
  147. package/dist/ui/shared/effect-geometry.js +122 -0
  148. package/dist/ui/shared/effect-host-style.js +10 -0
  149. package/dist/ui/shared/effect-owner-regions.js +74 -0
  150. package/dist/ui/shared/effect-owner-source.js +115 -0
  151. package/dist/ui/shared/effect-policy.js +137 -0
  152. package/dist/ui/shared/effect-poster-reader.js +98 -0
  153. package/dist/ui/shared/effect-posters.js +117 -0
  154. package/dist/ui/shared/effect-profiler.js +162 -0
  155. package/dist/ui/shared/effect-profiles.js +65 -0
  156. package/dist/ui/shared/effect-registry.js +496 -0
  157. package/dist/ui/shared/effect-runtime-loader.js +234 -0
  158. package/dist/ui/shared/effect-safety.js +153 -0
  159. package/dist/ui/shared/effect-stock-snapshot.js +34 -0
  160. package/dist/ui/shared/effect-stock-trust.js +165 -0
  161. package/dist/ui/shared/effect-trial-source.js +103 -0
  162. package/dist/ui/shared/effect-trial-stage.js +147 -0
  163. package/dist/ui/shared/header-tools.js +1 -1
  164. package/dist/ui/shared/identity-card.js +202 -0
  165. package/dist/ui/shared/identity-color.js +261 -0
  166. package/dist/ui/shared/instance-nav.js +37 -19
  167. package/dist/ui/shared/owner-css.js +221 -0
  168. package/dist/ui/shared/owner-effect-prefs.js +117 -0
  169. package/dist/ui/shared/panel-view.js +81 -8
  170. package/dist/ui/shared/signin.js +2 -0
  171. package/dist/ui/shared/ui-dialog.js +1 -1
  172. package/dist/ui/shared/ui-icons.js +2 -0
  173. package/dist/ui/shared/ui-menu.js +1 -1
  174. package/dist/ui/shared/ui-toast.js +1 -1
  175. package/dist/ui/shared/usage-dialog.js +14 -2
  176. package/dist/ui/shared/view-strings.js +36 -2
  177. package/dist/ui/signin.html +1 -0
  178. package/dist/usage/providers.d.ts +3 -0
  179. package/dist/usage/providers.js +4 -3
  180. package/dist/usage/providers.js.map +1 -1
  181. package/dist/view-api.d.ts +29 -0
  182. package/dist/view-api.js +188 -8
  183. package/dist/view-api.js.map +1 -1
  184. package/dist/web-api.d.ts +42 -0
  185. package/dist/web-api.js +898 -36
  186. package/dist/web-api.js.map +1 -1
  187. package/dist/web-appearance-limits.d.ts +82 -0
  188. package/dist/web-appearance-limits.js +83 -0
  189. package/dist/web-appearance-limits.js.map +1 -0
  190. package/dist/web-appearance-policy.d.ts +66 -0
  191. package/dist/web-appearance-policy.js +94 -0
  192. package/dist/web-appearance-policy.js.map +1 -0
  193. package/dist/web-appearance-tools.d.ts +50 -0
  194. package/dist/web-appearance-tools.js +105 -0
  195. package/dist/web-appearance-tools.js.map +1 -0
  196. package/dist/web-appearance-view.d.ts +99 -0
  197. package/dist/web-appearance-view.js +163 -0
  198. package/dist/web-appearance-view.js.map +1 -0
  199. package/dist/web-appearance.d.ts +79 -0
  200. package/dist/web-appearance.js +298 -0
  201. package/dist/web-appearance.js.map +1 -0
  202. package/dist/web-asset-parts.d.ts +93 -0
  203. package/dist/web-asset-parts.js +133 -0
  204. package/dist/web-asset-parts.js.map +1 -0
  205. package/dist/web-auth.js +17 -1
  206. package/dist/web-auth.js.map +1 -1
  207. package/dist/web-card.d.ts +96 -0
  208. package/dist/web-card.js +232 -0
  209. package/dist/web-card.js.map +1 -0
  210. package/dist/web-chat-history.d.ts +13 -0
  211. package/dist/web-chat-history.js +23 -2
  212. package/dist/web-chat-history.js.map +1 -1
  213. package/dist/web-effect-bundle.d.ts +28 -0
  214. package/dist/web-effect-bundle.js +126 -0
  215. package/dist/web-effect-bundle.js.map +1 -0
  216. package/dist/web-effect-canonical.d.ts +8 -0
  217. package/dist/web-effect-canonical.js +27 -0
  218. package/dist/web-effect-canonical.js.map +1 -0
  219. package/dist/web-effect-library.d.ts +114 -0
  220. package/dist/web-effect-library.js +530 -0
  221. package/dist/web-effect-library.js.map +1 -0
  222. package/dist/web-effect-poster.d.ts +8 -0
  223. package/dist/web-effect-poster.js +52 -0
  224. package/dist/web-effect-poster.js.map +1 -0
  225. package/dist/web-effect-profiles.d.ts +118 -0
  226. package/dist/web-effect-profiles.js +180 -0
  227. package/dist/web-effect-profiles.js.map +1 -0
  228. package/dist/web-effect-service.d.ts +86 -0
  229. package/dist/web-effect-service.js +314 -0
  230. package/dist/web-effect-service.js.map +1 -0
  231. package/dist/web-effect-tools.d.ts +47 -0
  232. package/dist/web-effect-tools.js +129 -0
  233. package/dist/web-effect-tools.js.map +1 -0
  234. package/dist/web-effect-trials.d.ts +86 -0
  235. package/dist/web-effect-trials.js +204 -0
  236. package/dist/web-effect-trials.js.map +1 -0
  237. package/dist/web-effect-upload.d.ts +27 -0
  238. package/dist/web-effect-upload.js +185 -0
  239. package/dist/web-effect-upload.js.map +1 -0
  240. package/dist/web-effect-worker.d.ts +8 -0
  241. package/dist/web-effect-worker.js +170 -0
  242. package/dist/web-effect-worker.js.map +1 -0
  243. package/dist/web-effect.d.ts +103 -0
  244. package/dist/web-effect.js +153 -0
  245. package/dist/web-effect.js.map +1 -0
  246. package/dist/web-identity-color.d.ts +87 -0
  247. package/dist/web-identity-color.js +209 -0
  248. package/dist/web-identity-color.js.map +1 -0
  249. package/dist/web-identity-palette.d.ts +113 -0
  250. package/dist/web-identity-palette.js +84 -0
  251. package/dist/web-identity-palette.js.map +1 -0
  252. package/dist/web-image-meta.d.ts +25 -0
  253. package/dist/web-image-meta.js +200 -0
  254. package/dist/web-image-meta.js.map +1 -0
  255. package/dist/web-mod-agent.d.ts +50 -0
  256. package/dist/web-mod-agent.js +205 -0
  257. package/dist/web-mod-agent.js.map +1 -0
  258. package/dist/web-mod-bundle.d.ts +149 -0
  259. package/dist/web-mod-bundle.js +470 -0
  260. package/dist/web-mod-bundle.js.map +1 -0
  261. package/dist/web-mod-export.d.ts +59 -0
  262. package/dist/web-mod-export.js +79 -0
  263. package/dist/web-mod-export.js.map +1 -0
  264. package/dist/web-mod-store.d.ts +283 -0
  265. package/dist/web-mod-store.js +625 -0
  266. package/dist/web-mod-store.js.map +1 -0
  267. package/dist/web-org.d.ts +3 -3
  268. package/dist/web-org.js +11 -17
  269. package/dist/web-org.js.map +1 -1
  270. package/dist/web-owner-css-store.d.ts +180 -0
  271. package/dist/web-owner-css-store.js +384 -0
  272. package/dist/web-owner-css-store.js.map +1 -0
  273. package/dist/web-owner-css.d.ts +58 -0
  274. package/dist/web-owner-css.js +552 -0
  275. package/dist/web-owner-css.js.map +1 -0
  276. package/dist/web-prefs.d.ts +48 -0
  277. package/dist/web-prefs.js +209 -33
  278. package/dist/web-prefs.js.map +1 -1
  279. package/dist/web-preview.d.ts +9 -1
  280. package/dist/web-preview.js +24 -7
  281. package/dist/web-preview.js.map +1 -1
  282. package/dist/web-settings-patch.d.ts +18 -0
  283. package/dist/web-settings-patch.js +51 -0
  284. package/dist/web-settings-patch.js.map +1 -1
  285. package/dist/web-shell-routes.d.ts +1 -1
  286. package/dist/web-shell-routes.js +1 -1
  287. package/dist/web-shell-routes.js.map +1 -1
  288. package/dist/web-theme-store.d.ts +218 -0
  289. package/dist/web-theme-store.js +516 -0
  290. package/dist/web-theme-store.js.map +1 -0
  291. package/dist/web-theme.d.ts +121 -0
  292. package/dist/web-theme.js +407 -0
  293. package/dist/web-theme.js.map +1 -0
  294. package/dist/web-widget.d.ts +72 -0
  295. package/dist/web-widget.js +245 -0
  296. package/dist/web-widget.js.map +1 -0
  297. package/package.json +1 -1
  298. package/templates/systemd.service.ejs +8 -0
@@ -0,0 +1,58 @@
1
+ // #1629: transient host notifications, never message content or replay/history authority.
2
+ const own = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
3
+ function fields(value, keys, closed = false) {
4
+ try {
5
+ if (!value || typeof value !== "object" || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return null;
6
+ if (closed && Reflect.ownKeys(value).length !== keys.length) return null;
7
+ const out = {};
8
+ for (const k of keys) { const d = Object.getOwnPropertyDescriptor(value, k); if (!d || !own(d, "value")) return null; out[k] = d.value; }
9
+ return out;
10
+ } catch { return null; }
11
+ }
12
+ const identity = x => !!x && typeof x.boot === "string" && /^[0-9a-f]{1,32}$/.test(x.boot)
13
+ && Number.isSafeInteger(x.id) && x.id > 0 && typeof x.instance === "string" && x.instance.length >= 1 && x.instance.length <= 128;
14
+ const MAX_AGE = 5000, MAX_BOOTS = 64, MAX_PENDING = 64;
15
+ export function createLiveAgentReplies(now) {
16
+ const seen = new Map(), pending = new Map(), listeners = new Set();
17
+ let last = null, revoked = false;
18
+ function time() {
19
+ if (revoked) return null;
20
+ let t; try { t = now(); } catch { t = null; }
21
+ if (typeof t !== "number" || !Number.isFinite(t) || t < 0 || last !== null && t < last) { revoked = true; pending.clear(); return null; }
22
+ last = t;
23
+ for (const [key, item] of pending) if (t - item.at > MAX_AGE) pending.delete(key);
24
+ return t;
25
+ }
26
+ function ingress(extra, t) {
27
+ const e = fields(extra, ["source", "receivedAt"], true);
28
+ return e && e.source === "sse" && typeof e.receivedAt === "number" && Number.isFinite(e.receivedAt) && e.receivedAt >= 0 && e.receivedAt <= t && t - e.receivedAt <= MAX_AGE ? e.receivedAt : null;
29
+ }
30
+ return {
31
+ // Record every accepted ingress, including history/poll, before its message-array merge/eviction.
32
+ message(value, extra) {
33
+ const m = fields(value, ["boot", "id", "instance"]); if (!identity(m)) return;
34
+ const role = fields(value, ["role"])?.role;
35
+ const t = time(); if (t === null) return;
36
+ const key = `${m.boot}:${m.id}`, previous = seen.get(m.boot) || 0;
37
+ pending.delete(key);
38
+ if (!seen.has(m.boot) && seen.size >= MAX_BOOTS) { revoked = true; pending.clear(); return; }
39
+ seen.set(m.boot, Math.max(previous, m.id));
40
+ const at = ingress(extra, t);
41
+ if (m.id <= previous || role !== "agent" || at === null) return;
42
+ // Reconnect replay has no receipts: discard old candidates, never the durable-in-page watermark.
43
+ if (pending.size >= MAX_PENDING) pending.delete(pending.keys().next().value);
44
+ pending.set(key, { instance: m.instance, at });
45
+ },
46
+ receipt(value, extra) {
47
+ const r = fields(value, ["v", "boot", "id", "instance"], true); if (!identity(r) || r.v !== 1) return false;
48
+ const t = time(); if (t === null) return false;
49
+ const at = ingress(extra, t); if (at === null) return false;
50
+ const key = `${r.boot}:${r.id}`, candidate = pending.get(key); pending.delete(key);
51
+ if (!candidate || candidate.instance !== r.instance || at < candidate.at || t - candidate.at > MAX_AGE) return false;
52
+ const event = Object.freeze({ v: 1, boot: r.boot, id: r.id, instance: r.instance });
53
+ for (const fn of [...listeners]) { try { fn(event); } catch { /* one host observer cannot suppress another */ } }
54
+ return true;
55
+ },
56
+ subscribe(fn) { if (typeof fn !== "function") return () => {}; listeners.add(fn); return () => listeners.delete(fn); },
57
+ };
58
+ }
@@ -36,7 +36,7 @@
36
36
  }
37
37
 
38
38
  function link(href, label) {
39
- return '<a href="' + href + '" target="_blank" rel="noopener noreferrer">' + label + "</a>";
39
+ return '<a href="' + href + '" target="_blank" rel="noopener noreferrer" data-trust="link">' + label + "</a>";
40
40
  }
41
41
 
42
42
  // Placeholders survive the later passes untouched (they contain no Markdown characters).
@@ -594,8 +594,8 @@
594
594
  if (cards && isHtmlAttachment(a)) after.push('<div class="html-card" data-att="' + a.id + '"></div>');
595
595
  var url = "/ui/file/" + a.id;
596
596
  var kind = inline ? attachmentPreviewType(a) : null;
597
- if (kind === "video") after.push('<div class="att-media"><video class="att-video" controls preload="metadata" src="' + url + '"></video></div>');
598
- else if (kind === "audio") after.push('<div class="att-media"><audio class="att-audio" controls preload="metadata" src="' + url + '"></audio></div>');
597
+ if (kind === "video") after.push('<div data-trust="media" class="att-media"><video class="att-video" controls preload="metadata" src="' + url + '"></video></div>');
598
+ else if (kind === "audio") after.push('<div data-trust="media" class="att-media"><audio class="att-audio" controls preload="metadata" src="' + url + '"></audio></div>');
599
599
  else if (kind) after.push('<div class="text-card" data-att="' + a.id + '" data-type="' + kind + '"></div>');
600
600
  var name = escapeHtml(a.name || "file");
601
601
  if (a.kind === "photo") {
@@ -604,7 +604,7 @@
604
604
  out.push('<a class="att-file" href="' + url + '" download="' + name + '">📎 ' + name + ' <span class="att-size">' + escapeHtml(formatSize(a.size)) + "</span></a>");
605
605
  }
606
606
  });
607
- return (out.length ? '<div class="atts">' + out.join("") + "</div>" : "") + after.join("");
607
+ return (out.length ? '<div data-trust="attachment" class="atts">' + out.join("") + "</div>" : "") + after.join("");
608
608
  }
609
609
 
610
610
  return {
@@ -8,6 +8,7 @@
8
8
  // Requests and #1374: the history of an instance is read once per page, when a person first opens its chat (a
9
9
  // navigation — it counts as use, as the old click did). Coming back to it reads nothing; the stream keeps it current.
10
10
  import "./chat-render.js";
11
+ import { createLiveAgentReplies } from "./chat-live-replies.js";
11
12
 
12
13
  const R = () => globalThis.AgendChatRender;
13
14
 
@@ -56,6 +57,7 @@ export function createChatStore(deps) {
56
57
  const pastedTexts = new WeakMap();
57
58
  const now = deps.now || (() => (typeof performance !== "undefined" ? performance.now() : Date.now()));
58
59
  const t = deps.t, tf = deps.t;
60
+ const liveReplies = createLiveAgentReplies(now);
59
61
 
60
62
  /** Tell subscribers what changed: `kind` "msgs" (the thread redraws) or "state" (everything else). */
61
63
  function changed(instance, kind = "state") { for (const fn of [...subs]) fn(instance, kind); }
@@ -99,8 +101,9 @@ export function createChatStore(deps) {
99
101
  function noteServerClock(c) {
100
102
  if (c && typeof c.now === "number" && Number.isFinite(c.now)) s.clockSkew = c.now - Date.now();
101
103
  }
102
- function ingest(m) {
104
+ function ingest(m, extra) {
103
105
  if (!m || typeof m.instance !== "string") return;
106
+ liveReplies.message(m, extra);
104
107
  const before = s.msgs[m.instance];
105
108
  s.msgs[m.instance] = R().mergeMessages(before, [m], 500);
106
109
  if (m.instance === s.current && s.msgs[m.instance] !== before && m.role !== "status" && !isUser(m, m.instance) && (s.msgs[m.instance] || []).length > (before || []).length) deps.announce(tf("chat.a11yReplied", m.sender));
@@ -147,6 +150,7 @@ export function createChatStore(deps) {
147
150
  const r = await api("GET", `/ui/history?instance=${encodeURIComponent(name)}&limit=200`);
148
151
  if (!r || !Array.isArray(r.messages)) { s.historyRead.delete(name); return true; }
149
152
  // Kept even when the person has moved on: the store is page-lived, and the answer belongs to `name`.
153
+ for (const m of r.messages) liveReplies.message(m);
150
154
  s.msgs[name] = R().mergeMessages(s.msgs[name], r.messages, 500);
151
155
  changed(name, "msgs");
152
156
  } catch { s.historyRead.delete(name); /* the live stream still works; the next visit tries again */ }
@@ -162,7 +166,7 @@ export function createChatStore(deps) {
162
166
  // A prompt already held (being answered, or claimed from Needs you) keeps its busy/answered state: a repeat of it
163
167
  // only refreshes its text and buttons (#1463 review).
164
168
  if (!had) s.prompts[p.nonce] = { ...p, resolved: false, busy: false };
165
- else if (!had.resolved) Object.assign(had, { text: p.text, actions: p.actions, expiresAt: p.expiresAt });
169
+ else if (!had.resolved) Object.assign(had, { text: p.text, links: p.links, actions: p.actions, expiresAt: p.expiresAt });
166
170
  if (p.instance === s.current) changed(p.instance);
167
171
  else if (fresh) deps.toast(tf("chat.promptFor", p.instance, p.text), false);
168
172
  }
@@ -174,7 +178,7 @@ export function createChatStore(deps) {
174
178
  for (const p of list) {
175
179
  const had = s.prompts[p.nonce];
176
180
  if (!had) s.prompts[p.nonce] = { ...p, resolved: false, busy: false };
177
- else if (!had.resolved) Object.assign(had, { text: p.text, actions: p.actions, expiresAt: p.expiresAt });
181
+ else if (!had.resolved) Object.assign(had, { text: p.text, links: p.links, actions: p.actions, expiresAt: p.expiresAt });
178
182
  }
179
183
  for (const n of Object.keys(s.prompts)) if (!s.prompts[n].resolved && !open.has(n)) s.prompts[n].resolved = true;
180
184
  changed(null);
@@ -380,6 +384,7 @@ export function createChatStore(deps) {
380
384
  stream.on("status", applyStatus);
381
385
  stream.on("activity", applyActivity);
382
386
  stream.on("message", ingest);
387
+ stream.on("agent_reply_live", liveReplies.receipt);
383
388
  stream.on("delivery", (d) => applyDeliveries([d]));
384
389
  stream.on("deliveries", applyDeliveries);
385
390
  stream.on("prompt", onPrompt);
@@ -393,6 +398,7 @@ export function createChatStore(deps) {
393
398
  state: s,
394
399
  subscribe(fn) { subs.add(fn); return () => subs.delete(fn); },
395
400
  setCurrent(name) { s.current = name; },
401
+ onAgentReply: liveReplies.subscribe,
396
402
  attach, applyStatus, applyActivity, ingest, applyDeliveries, openHistory,
397
403
  onPrompt, applyPrompts, resolvePrompt, answerPrompt, answerByNonce, holdPrompt, releasePrompt, promptsFor,
398
404
  applyReplyButtons, clickReplyButton,
@@ -3,6 +3,8 @@ import { register } from "/assets/app-i18n.js";
3
3
 
4
4
  register("chat", {
5
5
  en: {
6
+ effectTrial: "Effect trial", effectTrialLocal: "Try requires a local admin, enabled previews and custom CSS off.",
7
+ effectTrialStart: "Try", effectTrialExpired: "This trial offer has expired.", effectTrialUnavailable: "Trial unavailable: access or playback settings may have changed.",
6
8
  cmdConfirm: "Clear", cmdConfirmBackend: "Switch and restart", cmdRunning: "Running /{0}…", cmdNotCommand: "Not a command here — Enter sends it as a message.", cmdDismiss: "Dismiss", cmdCommands: "Commands",
7
9
  compactRunning: "Compacting…", compactDone: "✅ Conversation compacted", compactUnknown: "This backend does not report when compaction ends.",
8
10
  compactTimeout: "No sign that compaction finished within {0} minutes — check the agent's own chat.",
@@ -90,9 +92,17 @@ register("chat", {
90
92
  dBindingNone: "not bound (web only)", dPrimary: "the first connection", dTags: "Tags", dReadOnly: "Read only — change it in Settings.",
91
93
  dEditInSettings: "Edit in Settings", dPause: "Pause", dWake: "Wake", dPaused: "{0} paused", dWoken: "{0} is waking up", dActionFailed: "That did not work — try again.",
92
94
  dNoActivity: "No activity in the last hour.", dWorking: "Working…",
95
+ // #1629: a person's control over an agent's own web theme.
96
+ themeLock: "Lock theme", themeUnlock: "Unlock theme", themeReset: "Reset theme…", themeResetGo: "Reset",
97
+ themeResetConfirm: "Reset {0}'s theme to the default? Its colours, picture and working label go; a lock stays.",
98
+ themeResetDone: "{0}'s theme was reset.", themeLocked: "{0}'s theme is locked: the agent can't change it.", themeUnlocked: "{0}'s theme is unlocked.",
99
+ themeFailed: "The theme was not changed ({0}).",
100
+ widgetRemoveHeader: "Remove the chat header widget", widgetRemoveDetails: "Remove the Details widget", widgetRemoved: "{0}'s widget was removed.",
93
101
  configured: "(configured)", fleetDefault: "(fleet default)",
94
102
  },
95
103
  "zh-TW": {
104
+ effectTrial: "效果試播", effectTrialLocal: "需由本機管理員啟用預覽並關閉自訂 CSS,才能試播。",
105
+ effectTrialStart: "試播", effectTrialExpired: "試播邀請已過期。", effectTrialUnavailable: "無法試播:存取權或播放設定可能已變更。",
96
106
  cmdConfirm: "清空", cmdConfirmBackend: "切換並重新啟動", cmdRunning: "執行 /{0} 中…", cmdNotCommand: "這裡沒有這個指令——按 Enter 會當成一般訊息送出。", cmdDismiss: "關閉", cmdCommands: "指令",
97
107
  compactRunning: "壓縮中…", compactDone: "✅ 對話已壓縮", compactUnknown: "這個後端不會回報壓縮何時完成。",
98
108
  compactTimeout: "{0} 分鐘內沒有看到壓縮完成的紀錄,請到代理自己的對話確認。",
@@ -178,6 +188,11 @@ register("chat", {
178
188
  dBindingNone: "未綁定(只用網頁)", dPrimary: "第一個連線", dTags: "標籤", dReadOnly: "這裡只能檢視——請到設定修改。",
179
189
  dEditInSettings: "到設定編輯", dPause: "暫停", dWake: "喚醒", dPaused: "{0} 已暫停", dWoken: "{0} 正在喚醒", dActionFailed: "沒有成功,請再試一次。",
180
190
  dNoActivity: "最近一小時沒有活動。", dWorking: "處理中…",
191
+ themeLock: "鎖定主題", themeUnlock: "解除主題鎖定", themeReset: "重設主題…", themeResetGo: "重設",
192
+ themeResetConfirm: "要把 {0} 的主題重設為預設嗎?顏色、圖片和處理中文字都會移除;鎖定會保留。",
193
+ themeResetDone: "已重設 {0} 的主題。", themeLocked: "已鎖定 {0} 的主題:agent 無法變更。", themeUnlocked: "已解除 {0} 的主題鎖定。",
194
+ themeFailed: "主題沒有變更({0})。",
195
+ widgetRemoveHeader: "移除聊天標頭的小工具", widgetRemoveDetails: "移除詳細資料頁的小工具", widgetRemoved: "已移除 {0} 的小工具。",
181
196
  configured: "(設定值)", fleetDefault: "(fleet 預設)",
182
197
  },
183
198
  });
@@ -8,8 +8,10 @@
8
8
  // Unrelated updates (a new message, another message's tick) stop nothing (#1306 §6.2, #1408 §4).
9
9
  import "./chat-render.js";
10
10
  import "./preview.js";
11
+ import { effectTrialMessage } from "./effect-trial-offer.js";
11
12
  import { loadHtmlAttachment } from "./html-attachment.js";
12
13
  import { chatTime, chatTimeFull, onDayChange } from "./chat-time.js";
14
+ import { textLinkSegments } from "/assets/app-route.js";
13
15
 
14
16
  const R = () => globalThis.AgendChatRender;
15
17
  /** #1589: the public link shows attachments as downloads only (its page says so: <body data-public-link="1">). */
@@ -68,7 +70,7 @@ export function createThread(list, scroller, opts) {
68
70
  if (el.textContent !== v) el.textContent = v;
69
71
  }
70
72
  // #1770: "today" ends at the fleet's midnight; the labels are rewritten then (and on every render), while there are any.
71
- let offDay = null;
73
+ let offDay = null, trialTimer = null;
72
74
  function armDay() {
73
75
  if (nodes.size && !offDay) offDay = onDayChange(() => { for (const v of nodes.values()) timeLabel(v.node); });
74
76
  else if (!nodes.size && offDay) { offDay(); offDay = null; }
@@ -80,11 +82,13 @@ export function createThread(list, scroller, opts) {
80
82
  // #1770: the label ("17:38", "Yesterday 17:38", "10/11 17:38") depends on today, so it is not part of the html a node
81
83
  // is kept by: it is written into the node (timeLabels) — crossing midnight relabels in place and reloads no preview.
82
84
  const time = x.ts ? `<span class="time" data-ts="${escAttr(x.ts)}" title="${escAttr(chatTimeFull(x.ts))}"></span>` : `<span class="time"></span>`;
85
+ const trial = effectTrialMessage(x);
86
+ if (trial) return trialHtml(trial, time, x);
83
87
  if (x.role === "status") return statusHtml(x, time);
84
88
  const u = opts.isUser(x);
85
89
  // renderMarkdown escapes the whole text before it adds any tag of its own (chat-render.js).
86
90
  const ticks = u && x.delivery ? R().deliveryHtml(x.delivery, TICKS()) : "";
87
- const tools = u ? "" : `<div class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
91
+ const tools = u ? "" : `<div data-trust="thread-control" class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
88
92
  const buttons = x.role === "agent" ? replyButtonsHtml(x.buttons) : "";
89
93
  // A person's click on a reply button shows as the button's label, never the line the agent was given (its value).
90
94
  const clicked = x.role === "user" && x.click && typeof x.click.label === "string";
@@ -97,10 +101,21 @@ export function createThread(list, scroller, opts) {
97
101
  function clickHtml(click) {
98
102
  const inner = `<span class="click-mark" aria-hidden="true">🔘</span><span class="sr-only">${escAttr(tr("chat.clicked"))} </span>${escAttr(click.label)}`;
99
103
  const of = typeof click.of === "string" && /^[0-9a-f]{32}$/.test(click.of) ? click.of : null;
100
- return `<div class="click-chip">${of
104
+ return `<div class="click-chip" data-trust="thread-control">${of
101
105
  ? `<button type="button" class="click-link" data-act="showButtons" data-arg="${escAttr(of)}" title="${escAttr(tr("chat.clickShow"))}">${inner}</button>`
102
106
  : `<span class="click-text">${inner}</span>`}</div>`;
103
107
  }
108
+ function trialNow() { try { const t = opts.serverNow ? opts.serverNow() : Date.now(); return Number.isFinite(t) ? t : Infinity; } catch { return Infinity; } }
109
+ function trialHtml(offer, time, message) {
110
+ const expired = trialNow() >= offer.expiresAt, available = !PUBLIC_LINK() && !expired && opts.canTryEffect?.(message) === true;
111
+ const button = available ? `<button type="button" class="btn btn-sm effect-trial-start" data-act="effectTrial" data-arg="${escAttr(msgKey(message))}">${escAttr(tr("chat.effectTrialStart"))}</button>` : "";
112
+ return `<div class="msg status-line"><div class="meta">${time}</div><div class="body"><div class="effect-trial-offer" data-trust="thread-control"><strong>${escAttr(tr("chat.effectTrial"))}: ${escAttr(offer.title)}</strong><div>${escAttr(tr(expired ? "chat.effectTrialExpired" : "chat.effectTrialLocal"))}</div>${button}</div></div></div>`;
113
+ }
114
+ function armTrials() {
115
+ if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
116
+ const now = trialNow(), next = msgs.map(x => effectTrialMessage(x)).filter(x => x && x.expiresAt > now).reduce((n, x) => Math.min(n, x.expiresAt), Infinity);
117
+ if (Number.isFinite(next)) trialTimer = setTimeout(() => { trialTimer = null; render(msgs); }, Math.max(1, Math.min(2147483647, next - now)));
118
+ }
104
119
  /**
105
120
  * #1672: a status line (another instance's message, a daemon notice) is muted, read-only plain text — never Markdown,
106
121
  * a preview card, a button or a person's bubble. Checked before isUser, which reads the sender: a fleet name need not
@@ -111,10 +126,12 @@ export function createThread(list, scroller, opts) {
111
126
  const lines = R().lineCount(text);
112
127
  const manyLines = lines > STATUS_FOLD_LINES;
113
128
  const more = manyLines || text.length > STATUS_FOLD_CHARS
114
- ? `<button type="button" class="st-more" data-act="toggleFold"${manyLines ? ` data-lines="${escAttr(lines)}"` : ""}>${escAttr(manyLines ? trf("chat.showAll", lines) : tr("chat.showMore"))}</button>`
129
+ ? `<button data-trust="thread-control" type="button" class="st-more" data-act="toggleFold"${manyLines ? ` data-lines="${escAttr(lines)}"` : ""}>${escAttr(manyLines ? trf("chat.showAll", lines) : tr("chat.showMore"))}</button>`
115
130
  : "";
116
- const tools = `<div class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
117
- return `<div class="msg status-line${more ? " folded" : ""}"><div class="meta">${time}</div><div class="body"><div class="st-text">${escAttr(text)}</div>${more}</div>${tools}</div>`;
131
+ const tools = `<div data-trust="thread-control" class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
132
+ // #1990: the other instance's name opens its chat (an in-app link the router takes: no target).
133
+ const body = textLinkSegments(text, x.links).map(sg => sg.href ? `<a data-trust="link" class="st-link" href="${escAttr(sg.href)}">${escAttr(sg.text)}</a>` : escAttr(sg.text)).join("");
134
+ return `<div class="msg status-line${more ? " folded" : ""}"><div class="meta">${time}</div><div class="body"><div class="st-text">${body}</div>${more}</div>${tools}</div>`;
118
135
  }
119
136
  /**
120
137
  * #1266: an agent reply's buttons. Labels are text (escaped, never Markdown); the click names the set and the index,
@@ -130,7 +147,7 @@ export function createThread(list, scroller, opts) {
130
147
  return `<button type="button" class="btn btn-sm rb-btn${chosen ? " chosen" : ""}" data-act="replyButton" data-arg="${escAttr(`${b.id}:${i}`)}"${off ? " disabled" : ""}>${escAttr(chosen ? `✓ ${label}` : label)}</button>`;
131
148
  }).join("");
132
149
  const note = b.state === "chosen" ? trf("chat.rbChosen", b.by || "") : b.state === "expired" ? tr("chat.rbExpired") : "";
133
- return `<div class="rb-row" role="group" aria-label="${escAttr(tr("chat.rbGroup"))}">${items}${note ? `<span class="rb-note">${escAttr(note)}</span>` : ""}</div>`;
150
+ return `<div data-trust="reply" class="rb-row" role="group" aria-label="${escAttr(tr("chat.rbGroup"))}">${items}${note ? `<span class="rb-note">${escAttr(note)}</span>` : ""}</div>`;
134
151
  }
135
152
  /**
136
153
  * #1823: a poll. Anyone here may click while it is open; the page marks its own vote (opts.pollMine), which no one else
@@ -151,9 +168,9 @@ export function createThread(list, scroller, opts) {
151
168
  ? escAttr(trf("chat.pollClosed", p.answered))
152
169
  : `${escAttr(trf("chat.pollAnswered", p.answered))} · ⏳ <span class="poll-left" data-deadline="${escAttr(p.deadline)}" title="${escAttr(tr("chat.pollLeft"))}"></span>`;
153
170
  const voters = closed && Array.isArray(p.voters)
154
- ? `<ul class="poll-voters" aria-label="${escAttr(tr("chat.pollVoters"))}">${b.labels.map((label, i) => `<li>${escAttr(`${label}: ${(p.voters[i] || []).join(", ") || "—"}`)}</li>`).join("")}</ul>`
171
+ ? `<ul data-trust="reply" class="poll-voters" aria-label="${escAttr(tr("chat.pollVoters"))}">${b.labels.map((label, i) => `<li>${escAttr(`${label}: ${(p.voters[i] || []).join(", ") || "—"}`)}</li>`).join("")}</ul>`
155
172
  : "";
156
- return `<div class="rb-row poll" role="group" aria-label="${escAttr(tr("chat.pollGroup"))}">${items}<span class="rb-note">${status}</span></div>${voters}`;
173
+ return `<div data-trust="reply" class="rb-row poll" role="group" aria-label="${escAttr(tr("chat.pollGroup"))}">${items}<span class="rb-note">${status}</span></div>${voters}`;
157
174
  }
158
175
  /** #1823: the time left on this node's open polls, now. */
159
176
  function pollLeft(node) {
@@ -187,7 +204,7 @@ export function createThread(list, scroller, opts) {
187
204
  const doc = list.ownerDocument;
188
205
  for (const pre of root.querySelectorAll(".md pre")) {
189
206
  const box = doc.createElement("div"); box.className = "codeblock";
190
- const head = doc.createElement("div"); head.className = "cb-head";
207
+ const head = doc.createElement("div"); head.className = "cb-head"; head.setAttribute("data-trust", "thread-control");
191
208
  const lang = doc.createElement("span"); lang.className = "cb-lang"; lang.textContent = pre.dataset.lang || "text";
192
209
  const wrap = doc.createElement("button"); wrap.type = "button"; wrap.className = "chip-btn"; wrap.dataset.act = "toggleWrap"; wrap.textContent = tr("chat.wrap");
193
210
  const copy = doc.createElement("button"); copy.type = "button"; copy.className = "chip-btn"; copy.dataset.act = "copyCode"; copy.textContent = tr("chat.copy");
@@ -196,7 +213,7 @@ export function createThread(list, scroller, opts) {
196
213
  const lines = R().lineCount(pre.textContent);
197
214
  if (lines > R().CODE_FOLD_LINES) {
198
215
  box.classList.add("folded");
199
- const more = doc.createElement("button"); more.type = "button"; more.className = "cb-more"; more.dataset.act = "toggleFold";
216
+ const more = doc.createElement("button"); more.type = "button"; more.className = "cb-more"; more.setAttribute("data-trust", "thread-control"); more.dataset.act = "toggleFold";
200
217
  more.dataset.lines = String(lines); more.textContent = trf("chat.showAll", lines);
201
218
  box.append(more);
202
219
  }
@@ -214,7 +231,7 @@ export function createThread(list, scroller, opts) {
214
231
  if (!fence) continue;
215
232
  ph.textContent = "";
216
233
  if (ph.classList.contains("truncated") || !fence.terminated) {
217
- const note = doc.createElement("div"); note.className = "pv-note"; note.textContent = tr("chat.pvTruncated"); ph.append(note);
234
+ const note = doc.createElement("div"); note.className = "pv-note"; note.setAttribute("data-trust", "thread-control"); note.textContent = tr("chat.pvTruncated"); ph.append(note);
218
235
  continue;
219
236
  }
220
237
  buildCard(ph, x, `${msgKey(x)}:f${n}`, { n, code: fence.code }, stoppedKeys);
@@ -244,12 +261,12 @@ export function createThread(list, scroller, opts) {
244
261
  const doc = list.ownerDocument;
245
262
  const el = (tag, cls, text) => { const e = doc.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
246
263
  ph.textContent = "";
247
- const head = el("div", "tc-head");
264
+ const head = el("div", "tc-head"); head.setAttribute("data-trust", "thread-control");
248
265
  const label = el("span", "tc-label", att.name);
249
266
  const show = el("button", "btn btn-sm tc-show", tr("chat.tcShow")); show.type = "button";
250
267
  const dl = el("a", "btn btn-sm tc-dl", tr("chat.tcDownload")); dl.href = `/ui/file/${att.id}`; dl.setAttribute("download", att.name);
251
268
  head.append(label, show, dl);
252
- const note = el("div", "pv-note");
269
+ const note = el("div", "pv-note"); note.setAttribute("data-trust", "thread-control");
253
270
  const body = el("div", "tc-body"); body.hidden = true;
254
271
  ph.append(head, note, body);
255
272
  let text = null, reading = false, all = false;
@@ -262,7 +279,8 @@ export function createThread(list, scroller, opts) {
262
279
  // Formatting is built only within TEXT_PREVIEW_NODES: the markup is measured as a string first, never as DOM.
263
280
  const plain = () => {
264
281
  const pre = el("pre", "tc-pre"), code = el("code", null, src);
265
- pre.append(code); body.append(el("div", "pv-note", tr("chat.tcPlain")), pre);
282
+ const note = el("div", "pv-note", tr("chat.tcPlain")); note.setAttribute("data-trust", "thread-control");
283
+ pre.append(code); body.append(note, pre);
266
284
  };
267
285
  if (type === "md") {
268
286
  const html = R().renderMarkdown(src);
@@ -282,7 +300,7 @@ export function createThread(list, scroller, opts) {
282
300
  }
283
301
  }
284
302
  if (cut) {
285
- const more = el("button", "btn btn-sm tc-more", trf("chat.tcShowAll", lines.length)); more.type = "button";
303
+ const more = el("button", "btn btn-sm tc-more", trf("chat.tcShowAll", lines.length)); more.setAttribute("data-trust", "thread-control"); more.type = "button";
286
304
  more.onclick = () => { all = true; draw(); };
287
305
  body.append(more);
288
306
  }
@@ -343,7 +361,7 @@ export function createThread(list, scroller, opts) {
343
361
  const el = (tag, cls, text) => { const e = doc.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
344
362
  const att = source.att || null;
345
363
  let code = att ? null : source.code;
346
- const head = el("div", "pv-head");
364
+ const head = el("div", "pv-head"); head.setAttribute("data-trust", "thread-control");
347
365
  const label = el("span", "pv-label", att ? `${tr("chat.pvHtml")} · ${att.name}` : tr("chat.pvHtml"));
348
366
  const run = el("button", "btn btn-sm pv-run", tr("chat.pvPreview")); run.type = "button";
349
367
  const stopB = el("button", "btn btn-sm pv-stop", tr("chat.pvStop")); stopB.type = "button"; stopB.hidden = true;
@@ -353,15 +371,15 @@ export function createThread(list, scroller, opts) {
353
371
  const panel = opts.panel || null;
354
372
  const toPanel = panel ? el("button", "btn btn-sm btn-ghost pv-open", tr("chat.pvOpenPanel")) : null;
355
373
  if (toPanel) toPanel.type = "button";
356
- const menu = el("details", "pv-menu");
374
+ const menu = el("details", "pv-menu"); menu.setAttribute("data-trust", "preview-menu");
357
375
  const sum = el("summary", null, "⋯"); sum.title = tr("chat.pvMenu"); sum.setAttribute("aria-label", tr("chat.pvMenu"));
358
376
  const optB = el("button", "chip-btn pv-opt"); optB.type = "button";
359
377
  const neverB = el("button", "chip-btn pv-never"); neverB.type = "button";
360
378
  menu.append(sum, optB, neverB);
361
379
  head.append(label, run, stopB, ...(toPanel ? [toPanel] : []), ...(dl ? [dl] : []), menu);
362
- const note = el("div", "pv-note");
380
+ const note = el("div", "pv-note"); note.setAttribute("data-trust", "thread-control");
363
381
  // #1568: the banner, with its "I understand" (hidden until the next sign-in; the frame's border stays).
364
- const banner = el("div", "pv-banner"); banner.hidden = true;
382
+ const banner = el("div", "pv-banner"); banner.setAttribute("data-trust", "thread-control"); banner.hidden = true;
365
383
  const ack = el("button", "btn btn-sm btn-ghost pv-ack", P().bannerAckLabel()); ack.type = "button"; ack.title = P().bannerAckTitle();
366
384
  banner.append(el("span", "pv-banner-text", P().banner()), ack);
367
385
  const holder = el("div", "pv-holder");
@@ -493,6 +511,7 @@ export function createThread(list, scroller, opts) {
493
511
  function render(next, renderOpts = {}) {
494
512
  const stick = pinned || nearBottom();
495
513
  msgs = next || [];
514
+ armTrials();
496
515
  if (!msgs.length) {
497
516
  for (const v of nodes.values()) stopIn(v.node);
498
517
  nodes.clear();
@@ -531,7 +550,7 @@ export function createThread(list, scroller, opts) {
531
550
  const stopped = have ? stopIn(have.node) : [];
532
551
  const node = msgNode(html, x, stopped);
533
552
  if (have) have.node.replaceWith(node); else added++;
534
- have = { html, node, x }; nodes.set(key, have);
553
+ have = { html, node, x, trialButton: node.querySelector(".effect-trial-offer > button.effect-trial-start") }; nodes.set(key, have);
535
554
  } else { have.x = x; timeLabel(have.node); }
536
555
  const want = prev ? prev.nextSibling : list.firstChild;
537
556
  // A node already in the list is being moved: its preview stops first. A new node (still in its template) has none.
@@ -605,12 +624,19 @@ export function createThread(list, scroller, opts) {
605
624
  else if (act === "replyButton") {
606
625
  const m = /^([0-9a-f]{32}):(\d{1,2})$/.exec(el.dataset.arg || "");
607
626
  if (m && opts.clickReplyButton) opts.clickReplyButton(m[1], Number(m[2]));
627
+ } else if (act === "effectTrial") {
628
+ // Only the current host-produced keyed card and a real local click can request admission. No token is read from DOM.
629
+ const key = el.dataset.arg, row = msgs.find(x => msgKey(x) === key), owner = nodes.get(key), node = owner?.node, offer = effectTrialMessage(row);
630
+ if (PUBLIC_LINK() || e.defaultPrevented || e.isTrusted !== true || owner?.trialButton !== el || el.className !== "btn btn-sm effect-trial-start" || !node?.contains(el) || el.closest(".effect-trial-offer")?.getAttribute("data-trust") !== "thread-control"
631
+ || !offer || trialNow() >= offer.expiresAt || opts.canTryEffect?.(row) !== true) return;
632
+ opts.startEffectTrial?.(row);
608
633
  } else if (act === "showButtons") { const x = msgs.find(y => y.buttons && y.buttons.id === el.dataset.arg); if (x) reveal(msgKey(x)); }
609
634
  }
610
635
 
611
636
  /** The thread goes (the chat left, or another instance opened): every preview in it stops first. */
612
637
  function dispose() {
613
638
  disposed = true;
639
+ if (trialTimer !== null) clearTimeout(trialTimer); trialTimer = null;
614
640
  if (ro) ro.disconnect();
615
641
  if (P()) P().stopAll("leave");
616
642
  nodes.clear();
@@ -58,7 +58,7 @@ function show(i) {
58
58
  if (spot) spot.classList.add("tour-spot");
59
59
  let card = byId("tour");
60
60
  if (!card) {
61
- card = document.createElement("section"); card.id = "tour"; card.className = "tour";
61
+ card = document.createElement("section"); card.id = "tour"; card.className = "tour"; card.setAttribute("data-trust", "tour");
62
62
  card.setAttribute("role", "dialog"); card.setAttribute("aria-labelledby", "tourTitle");
63
63
  card.addEventListener("click", onCardClick);
64
64
  document.body.appendChild(card);
@@ -0,0 +1,119 @@
1
+ /** #1629: explicit-click control lane. Responses never grant authority; Start reauthorizes on the server. */
2
+ import { effectTrialMessage } from "./effect-trial-offer.js";
3
+ import { checkEffectTrialStart } from "/assets/effect-runtime-loader.js";
4
+ import { chatEffectIdentity } from "/assets/effect-chat-runtime.js";
5
+ const owners = new WeakSet(), safe = fn => { try { return fn(); } catch { return null; } };
6
+ const LIMIT = 128 * 1024;
7
+ export function createEffectTrialController({ window: host, current, allowed, onStart, onChange = () => {} }) {
8
+ let dead = false, active = null, physical = null, previous = null, broken = false, clockBusy = false;
9
+ const cleanup = [], releases = [], identity = safe(() => chatEffectIdentity(host));
10
+ const tell = () => { if (!dead) safe(onChange); };
11
+ function clock() {
12
+ if (broken || clockBusy) { broken = true; return null; }
13
+ clockBusy = true; const t = safe(() => host.performance.now()); clockBusy = false;
14
+ if (broken || typeof t !== "number" || !Number.isFinite(t) || t < 0 || t > Number.MAX_SAFE_INTEGER || previous !== null && t < previous) { broken = true; return null; }
15
+ return previous = t;
16
+ }
17
+ function eligible(op) {
18
+ if (dead || broken || !op || active !== op || op.dead || op.checking || host.AgendAuth?.sessionEnded?.() !== false) return false;
19
+ op.checking = true;
20
+ try {
21
+ const t = clock();
22
+ if (t === null || t < op.began || t >= op.until || safe(() => current(op.instance) === true && allowed() === true) !== true) return false;
23
+ const after = clock();
24
+ return !dead && !op.dead && active === op && after !== null && after >= op.began && after < op.until;
25
+ } finally { op.checking = false; }
26
+ }
27
+ function end(op, reason = "stopped") {
28
+ if (!op || op.dead) return false;
29
+ op.dead = true; if (active === op) active = null; safe(() => op.resolve?.(false));
30
+ safe(() => host.clearTimeout(op.timer)); safe(() => op.controller.abort());
31
+ if (op.reply && !op.stopQueued) { op.stopQueued = true; cleanup.push(op); }
32
+ // Physical paint is removed before callbacks, cleanup I/O or another explicit click.
33
+ safe(() => op.dispose?.()); op.dispose = null; tell(); drain(); return true;
34
+ }
35
+ function arm(op) {
36
+ safe(() => host.clearTimeout(op.timer));
37
+ if (!eligible(op)) { end(op, "trial_ended"); return; }
38
+ const t = clock(); if (t === null || t >= op.until) { end(op, "trial_expired"); return; }
39
+ op.timer = host.setTimeout(() => { if (!op.dead) arm(op); }, Math.max(1, Math.ceil(op.until - t)));
40
+ }
41
+ async function read(response) {
42
+ let reader = null;
43
+ try {
44
+ if (!response || response.redirected || response.status !== 200 || !/^application\/json(?:\s*;|$)/i.test(response.headers.get("content-type") || "")) return null;
45
+ const length = response.headers.get("content-length"); if (length !== null && (!/^\d+$/.test(length) || Number(length) > LIMIT)) return null;
46
+ reader = response.body?.getReader(); if (!reader) return null;
47
+ const chunks = []; let size = 0;
48
+ for (;;) { const r = await reader.read(); if (r.done) break; if (!(r.value instanceof Uint8Array) || (size += r.value.byteLength) > LIMIT) return null; chunks.push(r.value); }
49
+ const bytes = new Uint8Array(size); let offset = 0; for (const c of chunks) { bytes.set(c, offset); offset += c.byteLength; }
50
+ return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
51
+ } catch { return null; }
52
+ finally { try { if (reader) await reader.cancel(); else await response?.body?.cancel(); } catch { /* retained until cancellation settles */ } }
53
+ }
54
+ function drain() {
55
+ if (physical || !cleanup.length) return;
56
+ const op = cleanup.shift(), token = {}; physical = token; const controller = new host.AbortController();
57
+ const timer = host.setTimeout(() => controller.abort(), 10000);
58
+ // Stop remains permitted after CSS/gate/session changes. It cannot mount or mutate appearance.
59
+ Promise.resolve().then(async () => {
60
+ const response = await host.fetch(`/ui/effect-trials/${op.reply.trial}/stop?ownerEpoch=${identity.epoch}`, {
61
+ method: "POST", credentials: "same-origin", cache: "no-store", redirect: "error", headers: { "Content-Type": "application/json" }, body: "{}", signal: controller.signal,
62
+ });
63
+ try { await response.body?.cancel(); } catch { /* no response fields are consumed */ }
64
+ }).catch(() => {}).finally(() => { safe(() => host.clearTimeout(timer)); if (physical === token) physical = null; tell(); drain(); });
65
+ }
66
+ function dispose() {
67
+ if (dead) return; dead = true; end(active); for (const off of releases.splice(0)) safe(off); tell();
68
+ }
69
+ const api = { ok: false, start: async () => false, stop: () => end(active), sync() { const op = active; if (op && !eligible(op)) end(op, "trial_ended"); },
70
+ snapshot: () => Object.freeze({ busy: !!physical || cleanup.length > 0, phase: active ? active.reply ? "running" : "starting" : "idle" }), dispose };
71
+ if (!identity || owners.has(host) || typeof current !== "function" || typeof allowed !== "function" || typeof onStart !== "function"
72
+ || typeof host.AgendAuth?.trialSession !== "function" || typeof host.AgendAuth?.onSessionEnd !== "function") return Object.freeze(api);
73
+ owners.add(host);
74
+ try {
75
+ const off = host.AgendAuth.onSessionEnd(dispose);
76
+ if (dead) { safe(off); return Object.freeze(api); }
77
+ releases.push(off);
78
+ for (const [target, type, fn] of [[host, "blur", () => end(active)], [host, "pagehide", dispose], [host.document, "visibilitychange", () => { if (host.document.visibilityState !== "visible") end(active); }]]) {
79
+ target.addEventListener(type, fn); releases.push(() => target.removeEventListener(type, fn));
80
+ }
81
+ } catch { dispose(); return Object.freeze(api); }
82
+ if (dead) return Object.freeze(api);
83
+ api.ok = true;
84
+ api.start = async message => {
85
+ if (dead || active || physical || cleanup.length) return false;
86
+ const offer = safe(() => effectTrialMessage(message)), instance = safe(() => message.instance), began = clock();
87
+ if (!offer || began === null || began + 10000 > Number.MAX_SAFE_INTEGER || safe(() => host.Date.now() < offer.expiresAt) !== true) return false;
88
+ const op = { instance, offer, began, until: began + 10000, controller: new host.AbortController(), timer: null, reply: null, dead: false, stopQueued: false, dispose: null };
89
+ active = op; const token = {}; physical = token;
90
+ const cancelled = new Promise(resolve => { op.resolve = resolve; });
91
+ const work = Promise.resolve().then(async () => {
92
+ if (!eligible(op)) return false;
93
+ arm(op); const session = await host.AgendAuth.trialSession();
94
+ if (!eligible(op) || !session || session.localAdmin !== true || session.epoch !== identity.epoch) return false;
95
+ const response = await host.fetch(`/ui/effect-trials/start?ownerEpoch=${identity.epoch}`, {
96
+ method: "POST", credentials: "same-origin", cache: "no-store", redirect: "error", headers: { "Content-Type": "application/json" },
97
+ body: JSON.stringify({ offer: offer.offer, instance }), signal: op.controller.signal,
98
+ });
99
+ const reply = checkEffectTrialStart(await read(response), offer.offer, offer.id);
100
+ if (!reply) return false;
101
+ op.reply = reply;
102
+ if (op.dead && !op.stopQueued) { op.stopQueued = true; cleanup.push(op); }
103
+ if (reply.offer.title !== offer.title || reply.offer.expiresAt !== offer.expiresAt) return false;
104
+ if (!eligible(op)) { if (!op.stopQueued) { op.stopQueued = true; cleanup.push(op); } return false; }
105
+ op.until = began + reply.remainingMs;
106
+ if (op.until > Number.MAX_SAFE_INTEGER || !eligible(op)) return false;
107
+ arm(op);
108
+ const handle = Object.freeze({ reply, startedAt: began, instance, current: () => eligible(op), stop: () => end(op), });
109
+ const disposer = onStart(handle);
110
+ if (typeof disposer !== "function") return false;
111
+ if (!eligible(op)) { safe(disposer); return false; }
112
+ op.dispose = disposer; tell(); return eligible(op);
113
+ }).catch(() => false).then(ok => { if (!ok) end(op); return ok; }).finally(() => { if (physical === token) physical = null; tell(); drain(); });
114
+ // The logical caller may stop immediately, while the physical request/cancel still owns the lane.
115
+ tell();
116
+ return Promise.race([work, cancelled]);
117
+ };
118
+ return Object.freeze(api);
119
+ }
@@ -0,0 +1,31 @@
1
+ /** #1629: closed host display metadata. Neither this object nor its token grants a trial read. */
2
+ const KEYS = ["v", "offer", "id", "title", "expiresAt"], ID = /^[0-9a-f]{32}$/;
3
+ const FORBIDDEN = /(?!\u200d)[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
4
+ const VISIBLE = /[^\p{M}\p{C}\p{Z}\u115f\u1160\u3164\uffa0\u2800]/u;
5
+ let graphemes = null;
6
+ export function checkEffectTrialOffer(value) {
7
+ try {
8
+ if (!value || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return null;
9
+ const keys = Reflect.ownKeys(value);
10
+ if (keys.length !== KEYS.length || keys.some(k => typeof k !== "string" || !KEYS.includes(k))) return null;
11
+ const x = Object.create(null);
12
+ for (const key of KEYS) {
13
+ const d = Object.getOwnPropertyDescriptor(value, key);
14
+ if (!d || !("value" in d) || !d.enumerable) return null;
15
+ x[key] = d.value;
16
+ }
17
+ if (x.v !== 1 || typeof x.offer !== "string" || !ID.test(x.offer) || typeof x.id !== "string" || !ID.test(x.id)
18
+ || typeof x.title !== "string" || x.title.length > 400 || x.title.normalize("NFC") !== x.title || x.title.trim() !== x.title
19
+ || FORBIDDEN.test(x.title) || !VISIBLE.test(x.title) || !Number.isSafeInteger(x.expiresAt) || x.expiresAt < 0) return null;
20
+ graphemes ??= new Intl.Segmenter(undefined, { granularity: "grapheme" });
21
+ let count = 0; for (const _part of graphemes.segment(x.title)) if (++count > 40) return null;
22
+ if (count < 1) return null;
23
+ return Object.freeze({ v: 1, offer: x.offer, id: x.id, title: x.title, expiresAt: x.expiresAt });
24
+ } catch { return null; }
25
+ }
26
+ /** Role and sender are host fields, never inferred from a message's text. Start must separately authorize. */
27
+ export function effectTrialMessage(message) {
28
+ if (!message || message.role !== "status" || message.sender !== "agend" || typeof message.instance !== "string"
29
+ || message.instance.length < 1 || message.instance.length > 128) return null;
30
+ return checkEffectTrialOffer(message.effectTrial);
31
+ }
@@ -0,0 +1,37 @@
1
+ /** #1629: fixed host producer. Creates no frame or request; the admitted app controller owns its lease. */
2
+ import { html, useLayoutEffect, useRef } from "/assets/app-html.js";
3
+ import { register, t } from "/assets/app-i18n.js";
4
+ import { createEffectTrialStage } from "/assets/effect-trial-stage.js";
5
+ register("effectTrial", {
6
+ en: { label: "Trial", stop: "Stop trial", close: "Close", note: "A bounded sample. Nothing is applied or saved." },
7
+ "zh-TW": { label: "試播", stop: "停止試播", close: "關閉", note: "僅在此區塊試播,不會套用或儲存。" },
8
+ });
9
+ /** The caller owns authenticated admission, lease/deadline and shared pool. No script/HTML is rendered here. */
10
+ export function EffectTrialPanel({ window: host = globalThis.window, binding, title, current, ownerCss, onStage, onStop, onClose }) {
11
+ const panel = useRef(null), stage = useRef(null), life = useRef(null), latest = useRef(null);
12
+ latest.current = { current, ownerCss, onStage, onStop, onClose };
13
+ const stop = close => {
14
+ const entry = life.current; if (!entry || (close ? entry.closed : entry.dead)) return;
15
+ if (close) entry.closed = true;
16
+ if (!entry.dead) { entry.dead = true; entry.api?.dispose(); }
17
+ try { (close ? entry.onClose : entry.onStop)?.("stopped"); } catch { /* lease already ended */ }
18
+ };
19
+ useLayoutEffect(() => {
20
+ const entry = { dead: false, closed: false, api: null, onStop, onClose }; life.current = entry;
21
+ const ended = reason => { if (entry.dead) return; entry.dead = true; try { entry.onStop?.(reason); } catch { /* never revive */ } };
22
+ try {
23
+ entry.api = createEffectTrialStage({ window: host, panel: panel.current, stage: stage.current, binding,
24
+ current: own => !entry.dead && life.current === entry && latest.current.current?.(own) === true,
25
+ ownerCss: () => latest.current.ownerCss?.(), onStop: ended });
26
+ if (!entry.api) ended("stage_unavailable");
27
+ else { latest.current.onStage?.(entry.api); }
28
+ } catch { entry.api?.dispose(); ended("stage_unavailable"); }
29
+ return () => { entry.api?.dispose(); entry.dead = true; if (life.current === entry) life.current = null; };
30
+ }, [host, binding]);
31
+ return html`<aside ref=${panel} data-trust="preview" class="pv-panel effect-trial-panel" aria-label=${t("effectTrial.label")}>
32
+ <header class="effect-trial-head"><strong>${t("effectTrial.label")}</strong><span>${typeof title === "string" ? title : ""}</span><button type="button" class="btn" onClick=${() => stop(true)}>${t("effectTrial.close")}</button></header>
33
+ <div class="effect-trial-controls"><button type="button" class="btn" onClick=${() => stop(false)}>${t("effectTrial.stop")}</button></div>
34
+ <div class="effect-trial-body"><div ref=${stage} class="effect-trial-stage" aria-hidden="true"></div></div>
35
+ <p class="effect-trial-note">${t("effectTrial.note")}</p>
36
+ </aside>`;
37
+ }