@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
@@ -27,6 +27,20 @@
27
27
 
28
28
  const nativeFetch = window.fetch.bind(window);
29
29
  let sessionPending = null;
30
+ // Observation only: no server authority, owner/CSRF data or way to restore this page's episode.
31
+ let ended = null;
32
+ const endListeners = new Set();
33
+ function endSession(reason) {
34
+ if (ended) return;
35
+ ended = Object.freeze({ reason });
36
+ const listeners = [...endListeners]; endListeners.clear();
37
+ for (const fn of listeners) { try { fn(ended); } catch { /* one observer cannot delay another's cleanup */ } }
38
+ }
39
+ function onSessionEnd(fn) {
40
+ if (typeof fn !== "function") return () => {};
41
+ if (ended) { try { fn(ended); } catch { /* late observers fail closed */ } return () => {}; }
42
+ endListeners.add(fn); return () => endListeners.delete(fn);
43
+ }
30
44
 
31
45
  /**
32
46
  * This page's sign-in, read once from GET /auth/session: { csrf, handle } or null (no session, or the read failed —
@@ -36,8 +50,8 @@
36
50
  function session() {
37
51
  if (!sessionPending) {
38
52
  sessionPending = nativeFetch("/auth/session", { credentials: "same-origin", cache: "no-store" })
39
- .then((r) => (r.ok ? r.json() : null))
40
- .then((j) => (j && typeof j.csrf === "string" ? { csrf: j.csrf, handle: typeof j.handle === "string" ? j.handle : null } : null))
53
+ .then((r) => { if (r.status === 401) endSession("unauthorized"); return r.ok ? r.json() : null; })
54
+ .then((j) => (j && typeof j.csrf === "string" ? { csrf: j.csrf, handle: typeof j.handle === "string" ? j.handle : null, canWrite: j.tier === "admin", localAdmin: j.tier === "admin" && j.surface === "local" } : null))
41
55
  .catch(() => null)
42
56
  .then((value) => { if (!value) sessionPending = null; return value; });
43
57
  }
@@ -51,6 +65,8 @@
51
65
  bannerShown = true;
52
66
  const bar = document.createElement("div");
53
67
  bar.setAttribute("role", "alert");
68
+ bar.setAttribute("data-trust", "session-ended");
69
+ bar.className = "agend-session-ended";
54
70
  bar.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:2147483647;padding:10px 16px;background:#f85149;color:#fff;font:14px system-ui,sans-serif;text-align:center";
55
71
  const zh = /^zh/i.test(navigator.language || "");
56
72
  bar.append(document.createTextNode(zh ? "登入已結束。" : "Your session has ended. "));
@@ -77,15 +93,23 @@
77
93
  }
78
94
  }
79
95
  const response = await nativeFetch(input, init);
80
- if (sameOrigin && response.status === 401 && !target.pathname.startsWith("/auth/")) sessionEndedBanner();
96
+ if (sameOrigin && response.status === 401 && !target.pathname.startsWith("/auth/")) { endSession("unauthorized"); sessionEndedBanner(); }
81
97
  return response;
82
98
  };
83
99
 
84
100
  window.AgendAuth = {
85
101
  csrf,
102
+ /** Host runtimes subscribe before admission. Late subscribers observe the same closed episode immediately. */
103
+ onSessionEnd,
104
+ /** Display hint from the existing session read. Every write still requires server authorization. */
105
+ canWrite() { return session().then(s => !ended && s?.canWrite === true); },
106
+ /** Display hint from the same server session response; Start still reauthorizes. No CSRF is exposed here. */
107
+ trialSession() { return session().then(s => !ended && s?.handle && /^[a-f0-9]{16}$/.test(s.handle) ? Object.freeze({ epoch: s.handle, localAdmin: s.localAdmin === true }) : null); },
108
+ sessionEnded() { return ended !== null; },
86
109
  /** #1568: the session handle of this page's sign-in, or null when there is none. */
87
110
  sessionHandle() { return session().then((s) => (s && s.handle ? s.handle : null)); },
88
111
  async signOut() {
112
+ endSession("sign_out");
89
113
  const value = await csrf();
90
114
  const r = await nativeFetch("/auth/logout", { method: "POST", credentials: "same-origin", headers: value ? { "X-Agend-CSRF": value } : {} });
91
115
  // A sign-out the server could not make durable is not quietly reported as done: say so before leaving.
@@ -0,0 +1,248 @@
1
+ // #1629 r4-4 (option A): widgets — an agent's in its own chat header and Details page, a person's in their sidebar —
2
+ // drawn by the page's own components from a closed declarative spec and its values (src/web-widget.ts). Nothing from a
3
+ // mod becomes markup, script, a handler, a URL or a style: text is text nodes, a number fills a <progress> or an SVG
4
+ // polyline's points, and tones are fixed class tokens. Colours are the theme's tokens (and a person's own CSS). The box
5
+ // always carries the page's own ✦ chip, so a widget never reads as AgEnD's chrome, and nothing in it can be clicked.
6
+ //
7
+ // What the host sends is checked again here with the same rules; a widget that fails is not drawn, and a value that
8
+ // fails shows "—". A `bind` is a value the page already has (the instance's own state, context and cost; the fleet's
9
+ // counts in the person's sidebar only) — nothing new is fetched for it.
10
+ import { html } from "./app-html.js";
11
+ import { register, t } from "./app-i18n.js";
12
+ import { statusFacet } from "./instance-nav.js";
13
+ import { themesShown, themesStore } from "./app-themes.js";
14
+ import { onPrefsChanged, prefInForce, savePref } from "./app-prefs.js";
15
+ import { createStore } from "./app-store.js";
16
+ import { safeMode } from "./owner-css.js";
17
+
18
+ register("widget", {
19
+ en: {
20
+ label: "Widget: {0}",
21
+ chip: "A widget — drawn by AgEnD from its owner's data",
22
+ byHuman: "placed by a person",
23
+ none: "—",
24
+ },
25
+ "zh-TW": {
26
+ label: "小工具:{0}",
27
+ chip: "小工具——由 AgEnD 依擁有者的資料繪製",
28
+ byHuman: "由人放置",
29
+ none: "—",
30
+ },
31
+ });
32
+
33
+ export const WIDGET_ITEM_TYPES = ["stat", "progress", "text", "list", "badge", "spark"];
34
+ export const WIDGET_TONES = ["ok", "warn", "bad", "info", "muted"];
35
+ const INSTANCE_BINDS = ["state", "context_pct", "cost_usd"];
36
+ const FLEET_BINDS = ["fleet.idle", "fleet.working", "fleet.needs_you", "fleet.stopped"];
37
+ export const SLOT_RULES = {
38
+ chat_header: { maxItems: 4, types: ["stat", "progress", "text", "badge"], binds: INSTANCE_BINDS },
39
+ details: { maxItems: 8, types: WIDGET_ITEM_TYPES, binds: INSTANCE_BINDS },
40
+ sidebar: { maxItems: 8, types: WIDGET_ITEM_TYPES, binds: FLEET_BINDS },
41
+ };
42
+ const BINDS_FOR = {
43
+ stat: ["context_pct", "cost_usd", ...FLEET_BINDS], progress: ["context_pct"], text: ["state"], badge: ["state"], list: [], spark: [],
44
+ };
45
+ const ITEM_KEYS = {
46
+ stat: ["type", "label", "key", "bind", "unit"], progress: ["type", "label", "key", "bind", "max"], text: ["type", "label", "key", "bind"],
47
+ list: ["type", "label", "key"], badge: ["type", "label", "key", "bind", "tone", "tones"], spark: ["type", "label", "key"],
48
+ };
49
+ const LABEL_REQUIRED = ["stat", "progress", "spark"];
50
+ const KEY = /^[a-z][a-z0-9_]{0,31}$/;
51
+ const NUMBER_MAX = 1e15;
52
+ const own = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
53
+ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
54
+
55
+ // One line of text, as the host's canonicalText: NFC, no control, format or separator character but the zero-width
56
+ // joiner, at least one character that draws something, 1..max graphemes.
57
+ const FORBIDDEN = /(?!‍)[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
58
+ const VISIBLE = /[^\p{M}\p{C}\p{Z}ᅟᅠㅤᅠ⠀]/u;
59
+ let graphemes = null;
60
+ export function lineOk(s, max) {
61
+ if (typeof s !== "string" || s.normalize("NFC") !== s || FORBIDDEN.test(s) || s.trim() !== s || !VISIBLE.test(s)) return false;
62
+ try { graphemes ??= new Intl.Segmenter(undefined, { granularity: "grapheme" }); } catch { return false; }
63
+ const n = [...graphemes.segment(s)].length;
64
+ return n >= 1 && n <= max;
65
+ }
66
+ const isNumber = (v) => typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= NUMBER_MAX;
67
+
68
+ /** Whether `v` is a value an item of `type` can show (web-widget.ts valueFits). */
69
+ export function valueFits(type, v) {
70
+ switch (type) {
71
+ case "stat": return isNumber(v) || lineOk(v, 24);
72
+ case "progress": return isNumber(v);
73
+ case "text": return lineOk(v, 120);
74
+ case "badge": return lineOk(v, 24);
75
+ case "list": return Array.isArray(v) && v.length >= 1 && v.length <= 6 && v.every((s) => lineOk(s, 60));
76
+ case "spark": return Array.isArray(v) && v.length >= 2 && v.length <= 48 && v.every(isNumber);
77
+ default: return false;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * A widget `{ spec, data, by? }` for `slot`, checked again: `{ widget }`, or `{ problem }` (where, and what) when its
83
+ * spec fails — then the whole widget is not drawn. A data value that fails is left out (shown as "—").
84
+ */
85
+ export function explainWidget(slot, w) {
86
+ const no = (problem) => ({ problem });
87
+ const rules = own(SLOT_RULES, slot) ? SLOT_RULES[slot] : null;
88
+ if (!rules) return no("slot");
89
+ if (!isObj(w) || !isObj(w.spec)) return no("spec: an object { v: 1, title, items }");
90
+ const s = w.spec;
91
+ const extra = Object.keys(s).find((k) => !["v", "title", "items"].includes(k));
92
+ if (extra) return no(`spec.${extra}: unknown key`);
93
+ if (s.v !== 1) return no("spec.v: must be 1");
94
+ if (!lineOk(s.title, 24)) return no("spec.title: 1..24 characters on one line");
95
+ if (!Array.isArray(s.items) || s.items.length < 1 || s.items.length > rules.maxItems) return no(`spec.items: 1..${rules.maxItems} items`);
96
+ const items = [];
97
+ for (const [i, it] of s.items.entries()) {
98
+ const at = `spec.items.${i}`;
99
+ if (!isObj(it) || !rules.types.includes(it.type)) return no(`${at}.type: ${rules.types.join(", ")}`);
100
+ const unknown = Object.keys(it).find((k) => !ITEM_KEYS[it.type].includes(k));
101
+ if (unknown) return no(`${at}.${unknown}: unknown key`);
102
+ if ((it.label !== undefined || LABEL_REQUIRED.includes(it.type)) && !lineOk(it.label, 24)) return no(`${at}.label: 1..24 characters`);
103
+ if ((it.key === undefined) === (it.bind === undefined)) return no(`${at}: exactly one of key or bind`);
104
+ if (it.key !== undefined && !(typeof it.key === "string" && KEY.test(it.key))) return no(`${at}.key: a-z, 0-9 and _`);
105
+ if (it.bind !== undefined && !(BINDS_FOR[it.type].includes(it.bind) && rules.binds.includes(it.bind))) return no(`${at}.bind: ${BINDS_FOR[it.type].filter((b) => rules.binds.includes(b)).join(", ") || "none here"}`);
106
+ if (it.unit !== undefined && !lineOk(it.unit, 8)) return no(`${at}.unit: 1..8 characters`);
107
+ if (it.max !== undefined && !(isNumber(it.max) && it.max > 0)) return no(`${at}.max: a number > 0`);
108
+ if (it.tone !== undefined && !WIDGET_TONES.includes(it.tone)) return no(`${at}.tone: ${WIDGET_TONES.join(", ")}`);
109
+ let tones;
110
+ if (it.tones !== undefined) {
111
+ if (!isObj(it.tones)) return no(`${at}.tones: { value: tone }`);
112
+ tones = new Map();
113
+ for (const [k, v] of Object.entries(it.tones)) { if (!lineOk(k, 24) || !WIDGET_TONES.includes(v)) return no(`${at}.tones: { value: ${WIDGET_TONES.join(" | ")} }`); tones.set(k, v); }
114
+ if (tones.size < 1 || tones.size > 8) return no(`${at}.tones: 1..8 entries`);
115
+ }
116
+ items.push({ type: it.type, label: it.label, key: it.key, bind: it.bind, unit: it.unit, max: it.max, tone: it.tone, tones });
117
+ }
118
+ const data = new Map();
119
+ if (isObj(w.data)) items.forEach((it, i) => { if (it.key && own(w.data, it.key) && valueFits(it.type, w.data[it.key])) data.set(`${i}`, w.data[it.key]); });
120
+ return { widget: { title: s.title, items, data, by: w.by === "human" ? "human" : "agent" } };
121
+ }
122
+ /** explainWidget's widget, or null. */
123
+ export function checkWidget(slot, w) { return explainWidget(slot, w).widget || null; }
124
+
125
+ /**
126
+ * What the host would refuse in a person's `{ spec, data }` for `slot` (its whole check, web-widget.ts and web-prefs.ts),
127
+ * or null — so the editor can say why before anything is saved.
128
+ */
129
+ export function widgetProblem(slot, v) {
130
+ if (!isObj(v) || Object.keys(v).length !== 2 || !own(v, "spec") || !own(v, "data")) return "{ \"spec\": …, \"data\": … }";
131
+ const r = explainWidget(slot, v);
132
+ if (r.problem) return r.problem;
133
+ if (!isObj(v.data)) return "data: an object { key: value }";
134
+ for (const [k, val] of Object.entries(v.data)) {
135
+ const users = r.widget.items.filter((it) => it.key === k);
136
+ if (!users.length) return `data.${k}: no item shows this key`;
137
+ const bad = users.find((it) => !valueFits(it.type, val));
138
+ if (bad) return `data.${k}: not a value a ${bad.type} shows`;
139
+ }
140
+ const bytes = (x) => new TextEncoder().encode(JSON.stringify(x)).length;
141
+ if (bytes(v.spec) > 2048) return "spec: at most 2048 bytes";
142
+ if (bytes(v.data) > 4096) return "data: at most 4096 bytes";
143
+ return null;
144
+ }
145
+
146
+ /** The values the page has for one instance: its state word (a sidebar facet), context % and cost. */
147
+ export function instanceBinds(inst, exec, awaiting) {
148
+ if (!inst) return {};
149
+ return {
150
+ state: statusFacet(inst, exec, awaiting),
151
+ context_pct: isNumber(inst.context_pct) ? Math.round(Math.max(0, Math.min(100, inst.context_pct))) : undefined,
152
+ cost_usd: isNumber(inst.cost) ? inst.cost : undefined,
153
+ };
154
+ }
155
+ /** The fleet's counts, for the person's sidebar widget only: how many instances are idle, working, need you, stopped. */
156
+ export function fleetBinds(instances, exec, awaiting) {
157
+ const n = { "fleet.idle": 0, "fleet.working": 0, "fleet.needs_you": 0, "fleet.stopped": 0 };
158
+ for (const it of Array.isArray(instances) ? instances : []) {
159
+ const f = statusFacet(it, exec && exec[it.name], awaiting && own(awaiting, it.name) ? awaiting[it.name] : null);
160
+ n[f === "needs" ? "fleet.needs_you" : f === "working" ? "fleet.working" : f === "idle" ? "fleet.idle" : "fleet.stopped"] += 1;
161
+ }
162
+ return n;
163
+ }
164
+
165
+ let numbers = null;
166
+ function fmt(v) {
167
+ try { numbers ??= new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }); return numbers.format(v); } catch { return String(v); }
168
+ }
169
+ /** An item's value: its data, or its bind; undefined when there is none (shown as "—"). */
170
+ function valueOf(item, i, w, binds) {
171
+ if (item.key) return w.data.get(`${i}`);
172
+ const v = binds && own(binds, item.bind) ? binds[item.bind] : undefined;
173
+ return item.bind === "state" ? (lineOk(v, 24) ? v : undefined) : isNumber(v) ? v : undefined;
174
+ }
175
+ function shown(item, v) {
176
+ if (v === undefined) return t("widget.none");
177
+ if (item.bind === "cost_usd") return `$${v.toFixed(2)}`;
178
+ if (item.bind === "context_pct") return `${v}%`;
179
+ return typeof v === "number" ? fmt(v) : v;
180
+ }
181
+ /** The sparkline's points in a 100×24 box (numbers only; nothing else reaches the SVG). */
182
+ export function sparkPoints(values) {
183
+ const lo = Math.min(...values), hi = Math.max(...values), span = hi - lo || 1;
184
+ return values.map((v, i) => `${((i / (values.length - 1)) * 100).toFixed(2)},${(22 - ((v - lo) / span) * 20).toFixed(2)}`).join(" ");
185
+ }
186
+
187
+ function Item({ item, v }) {
188
+ const label = item.label ? html`<span class="aw-label">${item.label}</span>` : null;
189
+ switch (item.type) {
190
+ case "stat":
191
+ return html`<div class="aw-item aw-stat">${label}<span class="aw-value">${shown(item, v)}${v !== undefined && item.unit ? html`<span class="aw-unit">${item.unit}</span>` : null}</span></div>`;
192
+ case "progress": {
193
+ const max = item.max ?? 100;
194
+ const pct = v === undefined ? null : Math.round((Math.max(0, Math.min(max, v)) / max) * 100);
195
+ // No value: no bar at all (a <progress> without one is the browser's animated "busy" bar), only "—".
196
+ return html`<div class="aw-item aw-progress">${label}${pct === null ? null : html`<progress class="aw-bar" max=${max} value=${Math.max(0, Math.min(max, v))} aria-label=${item.label}></progress>`}<span class="aw-value">${pct === null ? t("widget.none") : `${pct}%`}</span></div>`;
197
+ }
198
+ case "text":
199
+ return html`<div class="aw-item aw-text">${label}<span class="aw-value">${shown(item, v)}</span></div>`;
200
+ case "badge": {
201
+ const tone = v !== undefined && item.tones && item.tones.has(v) ? item.tones.get(v) : item.tone || "muted";
202
+ return html`<div class="aw-item aw-badge-item">${label}<span class=${`aw-badge aw-tone-${WIDGET_TONES.includes(tone) ? tone : "muted"}`}>${shown(item, v)}</span></div>`;
203
+ }
204
+ case "list":
205
+ return html`<div class="aw-item aw-list">${label}${v === undefined ? html`<span class="aw-value">${t("widget.none")}</span>` : html`<ul>${v.map((line, i) => html`<li key=${i}>${line}</li>`)}</ul>`}</div>`;
206
+ case "spark":
207
+ return html`<div class="aw-item aw-spark-item">${label}${v === undefined ? html`<span class="aw-value">${t("widget.none")}</span>`
208
+ : html`<svg class="aw-spark" viewBox="0 0 100 24" preserveAspectRatio="none" aria-hidden="true"><polyline points=${sparkPoints(v)} /></svg><span class="aw-value">${fmt(v[v.length - 1])}</span>`}</div>`;
209
+ default: return null;
210
+ }
211
+ }
212
+
213
+ /** One widget, checked (checkWidget's result), in `slot`. `binds`: instanceBinds or fleetBinds. */
214
+ export function AgentWidget({ slot, widget, binds, tools = null }) {
215
+ if (!widget) return null;
216
+ return html`<section data-trust="widget" class=${`aw aw-${slot}`} aria-label=${t("widget.label", widget.title)} data-widget-slot=${slot}>
217
+ <div class="aw-head"><span class="aw-chip" title=${t("widget.chip")}>✦ ${widget.title}</span>
218
+ ${widget.by === "human" ? html`<span class="aw-by">${t("widget.byHuman")}</span>` : null}${tools}</div>
219
+ <div class="aw-items">${widget.items.map((item, i) => html`<${Item} key=${i} item=${item} v=${valueOf(item, i, widget, binds)} />`)}</div>
220
+ </section>`;
221
+ }
222
+
223
+ /** An agent's widget in `slot` ("chat_header" | "details"), checked; null when none, or agents' looks are not shown here. */
224
+ export function widgetFor(name, slot) {
225
+ const s = themesStore.get();
226
+ if (!s.shown || !themesShown() || !own(s.widgets, name)) return null;
227
+ const slots = s.widgets[name];
228
+ return isObj(slots) && own(slots, slot) ? checkWidget(slot, slots[slot]) : null;
229
+ }
230
+
231
+ const SIDEBAR_KEY = "widget.sidebar";
232
+ const publicLink = () => typeof document !== "undefined" && !!document.body && document.body.dataset.publicLink === "1";
233
+ /** The person's own sidebar widget (a synced preference), raw; null when none. Never on the public link or in safe mode. */
234
+ export function sidebarWidgetRaw() {
235
+ if (publicLink() || safeMode()) return null;
236
+ const f = prefInForce(SIDEBAR_KEY);
237
+ let v = f ? f.value : undefined;
238
+ if (!f) { try { v = JSON.parse(localStorage.getItem("agend_widget_sidebar") || "null"); } catch { v = null; } }
239
+ return isObj(v) ? v : null;
240
+ }
241
+ /** The person's sidebar widget, checked; null when none. */
242
+ export function sidebarWidget() { const v = sidebarWidgetRaw(); return v ? checkWidget("sidebar", v) : null; }
243
+ /** Bumped when the person's sidebar widget changes (here, or on another device), so the sidebar redraws. */
244
+ export const sidebarWidgetStore = createStore({ n: 0 });
245
+ const bump = () => sidebarWidgetStore.set((s) => ({ n: s.n + 1 }));
246
+ onPrefsChanged((changed) => { if (changed.has(SIDEBAR_KEY)) bump(); });
247
+ /** Set (or with null, remove) the person's sidebar widget on all their devices. */
248
+ export function setSidebarWidget(value) { savePref(SIDEBAR_KEY, value); bump(); }
@@ -68,6 +68,7 @@ register("app", {
68
68
  previews: "Allow HTML previews", tour: "Tour", tourTitle: "Show the web chat tour again",
69
69
  needsYou: "needs you", approxNote: "Read from the terminal, so approximate", chat: "Chat", details: "Details", log: "Log", instanceViews: "Views of {0}",
70
70
  textSize: "Text size: {0}", textSize_s: "S", textSize_m: "M", textSize_l: "L", textSize_fit: "Fit", usage: "Usage", usageHide: "Hide usage",
71
+ ownerCssKeep: "Keep this custom CSS?", ownerCssKeepLeft: "Reverts in {0} s", ownerCssKeepYes: "Keep", ownerCssKeepNo: "Revert",
71
72
  statusRunning: "running", statusStopped: "stopped", statusPaused: "paused", statusCrashed: "crashed", statusWorking: "working", statusStuck: "looks stuck", statusIdle: "idle",
72
73
  connReconnecting: "Can't reach AgEnD — it may be restarting. Reconnecting…", connReconnectingIn: "Can't reach AgEnD — it may be restarting. Trying again in {0} s.",
73
74
  hydrating: "Loading what is open now…", hydrateFailed: "Could not load what is open now (prompts, ticks).",
@@ -99,6 +100,7 @@ register("app", {
99
100
  previews: "允許預覽 HTML", tour: "導覽", tourTitle: "再看一次網頁聊天導覽",
100
101
  needsYou: "等你回應", approxNote: "從終端畫面判讀,僅供參考", chat: "聊天", details: "詳細資訊", log: "紀錄", instanceViews: "{0} 的檢視",
101
102
  textSize: "字級:{0}", textSize_s: "小", textSize_m: "中", textSize_l: "大", textSize_fit: "填滿", usage: "用量", usageHide: "隱藏用量",
103
+ ownerCssKeep: "保留這份自訂 CSS?", ownerCssKeepLeft: "{0} 秒後還原", ownerCssKeepYes: "保留", ownerCssKeepNo: "還原",
102
104
  statusRunning: "執行中", statusStopped: "已停止", statusPaused: "已暫停", statusCrashed: "已當掉", statusWorking: "工作中", statusStuck: "似乎卡住", statusIdle: "閒置",
103
105
  connReconnecting: "連不上 AgEnD——可能正在重新啟動。正在重新連線…", connReconnectingIn: "連不上 AgEnD——可能正在重新啟動。{0} 秒後再試。",
104
106
  hydrating: "正在載入目前的狀態…", hydrateFailed: "無法載入目前的狀態(提示、送達標記)。",
@@ -33,13 +33,32 @@ export const SYNCED = {
33
33
  "nav.favorites": { ls: "agend_favorites", ...json }, // #1664
34
34
  "preview.optIn": { ls: "agend_html_preview", toLocal: (v) => (v ? "on" : null), fromLocal: (s) => (s === "on" ? true : undefined) },
35
35
  "names.primary": { ls: "agend_names_primary", toLocal: (v) => (v === "instance" ? v : null), fromLocal: (s) => (s === "instance" ? s : undefined) }, // #1722
36
+ // #1629 r2 (d): "Show agents' themes and effects" — on unless turned off (only "off" is cached).
37
+ "fx.show": { ls: "agend_fx_show", toLocal: (v) => (v === false ? "off" : null), fromLocal: (s) => (s === "off" ? false : undefined) },
38
+ // #1629 r4.1 Q3 (a): the identity card's size — every card's default (M unless chosen), and the cards set apart.
39
+ "card.size": { ls: "agend_card_size", toLocal: (v) => (v === "s" || v === "l" ? v : null), fromLocal: (s) => (s === "s" || s === "l" ? s : undefined) },
40
+ "card.sizeByInstance": { ls: "agend_card_sizes", ...json },
41
+ // #1629 r4-4: the person's own sidebar widget ({ spec, data }), drawn by the page's own components.
42
+ "widget.sidebar": { ls: "agend_widget_sidebar", ...json },
43
+ // #1958: where identity colours show by default (ring + bar unless chosen), and the host's switch to turn them off.
44
+ "color.show": { ls: "agend_color_show", ...json },
45
+ "color.off": { ls: "agend_color_off", toLocal: (v) => (v === true ? "on" : null), fromLocal: (s) => (s === "on" ? true : undefined) },
36
46
  };
47
+ // #1629: fixed personal scope. A separate session-bound owner-effect store synchronizes these, never this host cache.
48
+ export const OWNER_EFFECT_PREF_KEYS = Object.freeze(["fx.sidebar", "fx.header", "fx.page", "fx.box", "fx.cursor"]);
49
+ for (const spec of Object.values(SYNCED)) spec.scope = "host";
50
+ for (const key of OWNER_EFFECT_PREF_KEYS) SYNCED[key] = Object.freeze({ scope: "person", ls: null });
51
+ for (const spec of Object.values(SYNCED)) Object.freeze(spec);
52
+ Object.freeze(SYNCED);
53
+ const HOST_KEYS = Object.keys(SYNCED).filter(key => SYNCED[key].scope === "host");
54
+ const hostKey = key => own(SYNCED, key) && SYNCED[key].scope === "host";
55
+ const hostValues = values => Object.fromEntries(HOST_KEYS.filter(key => own(values, key)).map(key => [key, values[key]]));
37
56
  const own = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
38
57
  const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
39
58
 
40
59
  function readLocal(key) { try { return localStorage.getItem(key); } catch { return null; } }
41
60
  function writeLocal(key, s) { try { if (s == null) localStorage.removeItem(key); else localStorage.setItem(key, s); } catch { /* this page only */ } }
42
- function cache(key, value) { const k = SYNCED[key]; writeLocal(k.ls, value == null ? null : k.toLocal(value)); }
61
+ function cache(key, value) { if (!hostKey(key)) return; const k = SYNCED[key]; writeLocal(k.ls, value == null ? null : k.toLocal(value)); }
43
62
  const inStep = () => readLocal(SYNCED_FLAG) === "1";
44
63
 
45
64
  function pageHost() {
@@ -51,7 +70,7 @@ function pageHost() {
51
70
 
52
71
  const initial = typeof document !== "undefined" ? pageHost() : null;
53
72
  let rev = initial ? initial.rev : null;
54
- let host = initial ? initial.values : null;
73
+ let host = initial ? hostValues(initial.values) : null;
55
74
  const pending = new Map(); // key → value chosen here, not sent yet
56
75
  const mine = new Map(); // key → value chosen here whose write is not over (pending or out)
57
76
  const inflight = new Map(); // key → writes sent and not answered yet
@@ -63,7 +82,7 @@ const listeners = new Set();
63
82
  // the host does not have: the default, on a device in step with the host; this device's own (to upload) otherwise.
64
83
  if (host) {
65
84
  const step = inStep();
66
- for (const key of Object.keys(SYNCED)) {
85
+ for (const key of HOST_KEYS) {
67
86
  if (own(host, key)) cache(key, host[key]);
68
87
  else if (step) cache(key, null);
69
88
  }
@@ -80,14 +99,14 @@ export function prefsWritable() {
80
99
  * in step) — the caller reads this browser's copy.
81
100
  */
82
101
  export function prefInForce(key) {
83
- if (!own(SYNCED, key)) return null;
102
+ if (!hostKey(key)) return null;
84
103
  if (mine.has(key)) return { value: mine.get(key) };
85
104
  if (host && own(host, key)) return { value: host[key] };
86
105
  if (initial && inStep()) return { value: undefined };
87
106
  return null;
88
107
  }
89
108
  /** The host's value for a key as this page last saw it (undefined: not set there, or not known). */
90
- export function hostPref(key) { return host && own(host, key) ? host[key] : undefined; }
109
+ export function hostPref(key) { return hostKey(key) && host && own(host, key) ? host[key] : undefined; }
91
110
  /** Called with the keys another device changed, after they are cached and applied here. Returns the unsubscribe. */
92
111
  export function onPrefsChanged(fn) { listeners.add(fn); return () => listeners.delete(fn); }
93
112
 
@@ -97,7 +116,7 @@ const isView = (body) => !!body && typeof body.rev === "number" && !!body.values
97
116
  function take(body) {
98
117
  if (!isView(body) || (rev !== null && body.rev < rev)) return false;
99
118
  rev = body.rev;
100
- host = body.values;
119
+ host = hostValues(body.values);
101
120
  return true;
102
121
  }
103
122
 
@@ -106,7 +125,7 @@ function take(body) {
106
125
  * (the public link, the anonymous reader): this browser only, as before.
107
126
  */
108
127
  export function savePref(key, value) {
109
- if (!own(SYNCED, key)) return;
128
+ if (!hostKey(key)) return;
110
129
  cache(key, value);
111
130
  if (!prefsWritable()) return;
112
131
  pending.set(key, value);
@@ -165,8 +184,8 @@ export async function refresh() {
165
184
  body = res.ok ? await res.json() : null;
166
185
  } catch { body = null; }
167
186
  if (!isView(body)) return false;
168
- const before = new Map(Object.keys(SYNCED).map((key) => [key, own(host, key) ? SYNCED[key].toLocal(host[key]) : null]));
169
- if (take(body)) align(Object.keys(SYNCED), { before });
187
+ const before = new Map(HOST_KEYS.map((key) => [key, own(host, key) ? SYNCED[key].toLocal(host[key]) : null]));
188
+ if (take(body)) align(HOST_KEYS, { before });
170
189
  return true;
171
190
  }
172
191
 
@@ -179,6 +198,7 @@ export async function refresh() {
179
198
  function align(keys, opts = {}) {
180
199
  const changed = new Set();
181
200
  for (const key of keys) {
201
+ if (!hostKey(key)) continue;
182
202
  if (pending.has(key) || inflight.has(key)) continue;
183
203
  const k = SYNCED[key];
184
204
  const want = own(host, key) ? k.toLocal(host[key]) : null;
@@ -216,7 +236,7 @@ function apply(changed) {
216
236
  */
217
237
  export async function migratePrefs() {
218
238
  if (!prefsWritable() || inStep()) return false;
219
- for (const key of Object.keys(SYNCED)) {
239
+ for (const key of HOST_KEYS) {
220
240
  if (own(host, key)) continue;
221
241
  const local = SYNCED[key].fromLocal(readLocal(SYNCED[key].ls));
222
242
  if (local === undefined) continue;
@@ -2,7 +2,7 @@
2
2
  // a test runs both over one list of cases). Pure: no DOM, no history.
3
3
 
4
4
  export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "cache", "config"];
5
- export const SETTINGS_SECTIONS = ["device", "agents", "bots", "classic", "general", "web", "advanced"]; // #1604: device first
5
+ export const SETTINGS_SECTIONS = ["device", "appearance", "agents", "bots", "classic", "general", "web", "advanced"]; // #1604: device first
6
6
  const MAX_INSTANCE_NAME = 128;
7
7
  const PATH_SEPARATOR_OR_CONTROL = /[/\\\u0000-\u001f\u007f]/;
8
8
 
@@ -62,6 +62,28 @@ export function routePath(route) {
62
62
  if (route.panel === "log") return logPath(route.instance);
63
63
  return route.instance ? chatPath(route.instance) : "/ui";
64
64
  }
65
+ /**
66
+ * #1990: a text with the in-app links the server put on it (ranges of the text: { offset, length, url }) as segments —
67
+ * { text } or { text, href }. Only a route this table parses as an instance's chat or View becomes a link (its href
68
+ * rebuilt here); a range out of order, out of the text, or anything else stays text. No target: the router opens it.
69
+ */
70
+ export function textLinkSegments(text, links) {
71
+ const s = String(text == null ? "" : text);
72
+ const out = [];
73
+ let at = 0;
74
+ for (const l of Array.isArray(links) ? links.slice(0, 16) : []) {
75
+ if (!l || !Number.isInteger(l.offset) || !Number.isInteger(l.length) || typeof l.url !== "string") continue;
76
+ if (l.offset < at || l.length < 1 || l.offset + l.length > s.length || !/^\/[^?#\s]*$/.test(l.url)) continue;
77
+ const route = parseRoute(l.url);
78
+ if (!route || !route.instance || (route.panel !== "chat" && route.panel !== "view")) continue;
79
+ if (l.offset > at) out.push({ text: s.slice(at, l.offset) });
80
+ out.push({ text: s.slice(l.offset, l.offset + l.length), href: routePath(route) });
81
+ at = l.offset + l.length;
82
+ }
83
+ if (at < s.length || !out.length) out.push({ text: s.slice(at) });
84
+ return out;
85
+ }
86
+
65
87
  /** One string per route: a change of it is a new navigation (a new lease for the panel, §4). */
66
88
  export function routeKey(route) {
67
89
  if (!route) return "none";
@@ -49,7 +49,7 @@ export function SessionMenu() {
49
49
  <button ref=${btn} type="button" class="side-row" aria-haspopup="dialog" aria-expanded=${open ? "true" : "false"}
50
50
  onClick=${() => { const next = !open; setOpen(next); if (next) refresh(); }}>
51
51
  <span class=${`dot${me ? " ok" : ""}`} aria-hidden="true"></span><span class="grow">${label}</span>${me ? html`<span class="meta">${me.label}</span>` : null}</button>
52
- ${open ? html`<div ref=${pop} class="pop" role="dialog" aria-label=${t("app.session")} onKeyDown=${onKey}>
52
+ ${open ? html`<div data-trust="session" ref=${pop} class="pop" role="dialog" aria-label=${t("app.session")} onKeyDown=${onKey}>
53
53
  ${!me ? html`<h3>${t("app.signedOut")}</h3><a class="btn" href=${`/signin?next=${encodeURIComponent(location.pathname)}`}>${t("app.signIn")}</a>` : html`
54
54
  <h3>${t("app.signedIn")} · ${me.label}</h3>
55
55
  <p class="sub">${t("app.expires", clock(me.expiresAt), span(me.idleMs))}</p>