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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/agent-cli.js +6 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/auth-api.js +2 -0
  4. package/dist/auth-api.js.map +1 -1
  5. package/dist/backend/claude-code.d.ts +39 -2
  6. package/dist/backend/claude-code.js +115 -31
  7. package/dist/backend/claude-code.js.map +1 -1
  8. package/dist/backend/credential-profile.d.ts +33 -3
  9. package/dist/backend/credential-profile.js +107 -5
  10. package/dist/backend/credential-profile.js.map +1 -1
  11. package/dist/backend/types.d.ts +1 -1
  12. package/dist/cache-service.d.ts +2 -0
  13. package/dist/cache-service.js +2 -1
  14. package/dist/cache-service.js.map +1 -1
  15. package/dist/channel/adapters/discord.d.ts +14 -1
  16. package/dist/channel/adapters/discord.js +42 -0
  17. package/dist/channel/adapters/discord.js.map +1 -1
  18. package/dist/channel/adapters/telegram.d.ts +18 -3
  19. package/dist/channel/adapters/telegram.js +66 -10
  20. package/dist/channel/adapters/telegram.js.map +1 -1
  21. package/dist/channel/telegram-markdown.d.ts +6 -0
  22. package/dist/channel/telegram-markdown.js +104 -0
  23. package/dist/channel/telegram-markdown.js.map +1 -0
  24. package/dist/channel/tool-router.js +5 -4
  25. package/dist/channel/tool-router.js.map +1 -1
  26. package/dist/channel/types.d.ts +27 -4
  27. package/dist/config-validator.js +10 -5
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/daemon.d.ts +108 -0
  30. package/dist/daemon.js +432 -32
  31. package/dist/daemon.js.map +1 -1
  32. package/dist/delivery-outbox.d.ts +29 -7
  33. package/dist/delivery-outbox.js +134 -16
  34. package/dist/delivery-outbox.js.map +1 -1
  35. package/dist/delivery-reconciliation.d.ts +18 -1
  36. package/dist/delivery-reconciliation.js +67 -2
  37. package/dist/delivery-reconciliation.js.map +1 -1
  38. package/dist/fleet-context.d.ts +1 -0
  39. package/dist/fleet-manager.d.ts +81 -4
  40. package/dist/fleet-manager.js +452 -85
  41. package/dist/fleet-manager.js.map +1 -1
  42. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +43 -10
  43. package/dist/host-memory.d.ts +10 -0
  44. package/dist/host-memory.js +33 -1
  45. package/dist/host-memory.js.map +1 -1
  46. package/dist/instance-lifecycle.d.ts +25 -6
  47. package/dist/instance-lifecycle.js +73 -26
  48. package/dist/instance-lifecycle.js.map +1 -1
  49. package/dist/locale.js +48 -18
  50. package/dist/locale.js.map +1 -1
  51. package/dist/login-controller.d.ts +20 -3
  52. package/dist/login-controller.js +71 -19
  53. package/dist/login-controller.js.map +1 -1
  54. package/dist/login-flows.d.ts +5 -2
  55. package/dist/login-flows.js +5 -4
  56. package/dist/login-flows.js.map +1 -1
  57. package/dist/memory-pressure.d.ts +13 -1
  58. package/dist/memory-pressure.js +50 -10
  59. package/dist/memory-pressure.js.map +1 -1
  60. package/dist/outbound-handlers.d.ts +63 -0
  61. package/dist/outbound-handlers.js +121 -46
  62. package/dist/outbound-handlers.js.map +1 -1
  63. package/dist/outbound-schemas.d.ts +3 -2
  64. package/dist/outbound-schemas.js +3 -1
  65. package/dist/outbound-schemas.js.map +1 -1
  66. package/dist/public-web-gateway.js +8 -5
  67. package/dist/public-web-gateway.js.map +1 -1
  68. package/dist/settings-api.js +10 -4
  69. package/dist/settings-api.js.map +1 -1
  70. package/dist/settings-change.js +2 -2
  71. package/dist/spawn-gate.js +5 -1
  72. package/dist/spawn-gate.js.map +1 -1
  73. package/dist/spawn-hold-reply.d.ts +12 -0
  74. package/dist/spawn-hold-reply.js +39 -0
  75. package/dist/spawn-hold-reply.js.map +1 -0
  76. package/dist/tips.js +1 -1
  77. package/dist/tips.js.map +1 -1
  78. package/dist/topic-commands.js +9 -2
  79. package/dist/topic-commands.js.map +1 -1
  80. package/dist/transcript-delta-reader.d.ts +3 -3
  81. package/dist/transcript-delta-reader.js +8 -8
  82. package/dist/transcript-delta-reader.js.map +1 -1
  83. package/dist/turn-resume.d.ts +2 -0
  84. package/dist/turn-resume.js +1 -1
  85. package/dist/turn-resume.js.map +1 -1
  86. package/dist/types.d.ts +2 -0
  87. package/dist/ui/chat-mentions.js +63 -0
  88. package/dist/ui/chat-render.js +25 -0
  89. package/dist/ui/chat-store.js +1 -1
  90. package/dist/ui/chat-strings.js +12 -12
  91. package/dist/ui/chat-thread.js +48 -11
  92. package/dist/ui/panel-chat.js +20 -9
  93. package/dist/ui/panel-details.js +2 -0
  94. package/dist/ui/panel-fleet.js +51 -22
  95. package/dist/ui/panel-settings.js +18 -9
  96. package/dist/ui/preview-panel.js +48 -14
  97. package/dist/ui/preview.js +5 -1
  98. package/dist/ui/settings-strings.js +6 -2
  99. package/dist/ui/settings-token.js +1 -1
  100. package/dist/ui/shared/app-i18n.js +35 -13
  101. package/dist/ui/shared/app-prefs.js +249 -0
  102. package/dist/ui/shared/app-shell.js +17 -4
  103. package/dist/ui/shared/app-store.js +5 -1
  104. package/dist/ui/shared/app.css +41 -7
  105. package/dist/ui/shared/app.js +6 -1
  106. package/dist/ui/shared/header-tools.js +55 -5
  107. package/dist/ui/shared/instance-nav.js +131 -38
  108. package/dist/ui/shared/panel-view.js +6 -0
  109. package/dist/ui/shared/theme.js +39 -7
  110. package/dist/ui/shared/ui-icons.js +1 -0
  111. package/dist/ui/shared/usage-dialog.js +13 -4
  112. package/dist/ui/shared/view-strings.js +26 -2
  113. package/dist/unrecognised-prompt.d.ts +23 -0
  114. package/dist/unrecognised-prompt.js +74 -0
  115. package/dist/unrecognised-prompt.js.map +1 -0
  116. package/dist/view-api.js +5 -0
  117. package/dist/view-api.js.map +1 -1
  118. package/dist/web-api.d.ts +13 -1
  119. package/dist/web-api.js +218 -18
  120. package/dist/web-api.js.map +1 -1
  121. package/dist/web-auth.js +3 -2
  122. package/dist/web-auth.js.map +1 -1
  123. package/dist/web-mentions.d.ts +44 -0
  124. package/dist/web-mentions.js +75 -0
  125. package/dist/web-mentions.js.map +1 -0
  126. package/dist/web-prefs.d.ts +63 -0
  127. package/dist/web-prefs.js +263 -0
  128. package/dist/web-prefs.js.map +1 -0
  129. package/dist/web-session.js +1 -1
  130. package/dist/web-session.js.map +1 -1
  131. package/package.json +1 -1
@@ -258,9 +258,10 @@ export function SettingsPanel({ route, navKey }) {
258
258
  }
259
259
 
260
260
  /**
261
- * #1604: what this browser chooses for itself — moved here from the sidebar footer, each with the storage it always had:
262
- * theme (theme.js), language (app-i18n.js), HTML previews on this device (preview.js, through the app's confirm), and
263
- * the chat tour (replayed on the chat it points at).
261
+ * #1604, #1665: Preferences. Synced across the person's devices (kept on the host, app-prefs.js): theme (theme.js),
262
+ * language (app-i18n.js) and HTML previews (preview.js, through the app's confirm). On the public link they are
263
+ * read-only: a change there stays in that browser, and it says so. This device only: the chat tour (replayed on the
264
+ * chat it points at), and the layout choices named there (their controls are where they always were).
264
265
  */
265
266
  export function DeviceSection() {
266
267
  const P = () => globalThis.AgendPreview;
@@ -291,11 +292,15 @@ export function DeviceSection() {
291
292
  };
292
293
  setTimeout(go, 0);
293
294
  };
294
- return html`<section class="card s-device"><h3>${tn("sec_device")}</h3>
295
- <p class="sub">${tn("deviceLead")}</p>
295
+ const publicLink = typeof document !== "undefined" && document.body && document.body.dataset.publicLink === "1";
296
+ return html`<section class="card s-device s-synced"><h3>${tn("syncedTitle")}</h3>
297
+ <p class="sub">${publicLink ? tn("syncedPublicLink") : tn("syncedLead")}</p>
296
298
  <${DevicePrefs} labels=${true} />
297
299
  ${P() ? html`<label class="check"><input type="checkbox" checked=${on} onChange=${(e) => { const want = e.target.checked; setPreviewOptIn(want).then(setOn); }} />
298
300
  <span>${t("chat.previews")}</span></label>` : null}
301
+ </section>
302
+ <section class="card s-device s-local"><h3>${tn("deviceTitle")}</h3>
303
+ <p class="sub">${tn("deviceLead")}</p>
299
304
  <div class="s-device-row"><button type="button" id="tourBtn" class="btn" title=${t("chat.tourBtnTitle")} onClick=${replay}><${Icon} name="info" size=${16} />${t("chat.tour")}</button></div>
300
305
  </section>`;
301
306
  }
@@ -367,11 +372,13 @@ function makeCtx(data, setData, stage, unstage, reload, reloadLive, alive) {
367
372
  async pauseWake(name, action) {
368
373
  const res = await api(`/api/settings/instances/${encodeURIComponent(name)}/${action}`, { method: "POST" }).catch(() => ({ ok: false, body: {} }));
369
374
  if (!res.ok) toast((res.body && res.body.error) || tn("actionFailed", action), false);
375
+ else if (res.body && res.body.held) toast(res.body.message);
370
376
  await reloadLive();
371
377
  },
372
378
  async startStop(name, running) {
373
379
  const res = await api(running ? `/stop/${encodeURIComponent(name)}` : `/api/instance/${encodeURIComponent(name)}/start`, { method: "POST" }).catch(() => ({ ok: false, body: {} }));
374
380
  if (!res.ok) toast(`${tn("actionFailed", running ? tn("stop") : tn("start"))}: ${(res.body && res.body.error) || res.status || ""}`, false);
381
+ else if (res.body && res.body.held) toast(res.body.message);
375
382
  await new Promise((r) => setTimeout(r, 800));
376
383
  await reloadLive();
377
384
  },
@@ -726,7 +733,9 @@ function WebSection({ ctx, staged }) {
726
733
  // Empty = the default (null in the write: the key is removed from fleet.yaml).
727
734
  const value = (v) => (v.trim() === "" ? null : Number(v));
728
735
  const hours = (v) => { const n = value(v); return n !== null && (!Number.isFinite(n) || n < 1 || n > 720) ? "sessionRange" : ""; };
729
- const abs = value(f.abs) ?? 12, idle = value(f.idle) ?? 2;
736
+ // The defaults (#1670: 48 h / 48 h, DEFAULT_SESSION_POLICY); an idle time left blank follows a shorter session, as
737
+ // the store and the validator apply it — so only an idle time that is typed can exceed the session length.
738
+ const abs = value(f.abs) ?? 48, idle = value(f.idle) ?? Math.min(48, abs);
730
739
  const errors = {
731
740
  ttl: !Number.isInteger(Number(f.ttl)) || Number(f.ttl) < 1 || Number(f.ttl) > 480 ? "publicTtl" : "",
732
741
  abs: hours(f.abs),
@@ -771,9 +780,9 @@ function WebSection({ ctx, staged }) {
771
780
  <section class="card"><h3>${tn("webSessionTitle")}</h3>
772
781
  <p class="note">${tn("webSessionHint")}</p>
773
782
  <div class="grid2">
774
- <div class="field"><label for="webSessionAbsolute">${tn("sessionAbsolute")}</label><input id="webSessionAbsolute" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours", 12)}
783
+ <div class="field"><label for="webSessionAbsolute">${tn("sessionAbsolute")}</label><input id="webSessionAbsolute" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours", 48)}
775
784
  class=${errors.abs ? "invalid" : ""} value=${f.abs} onInput=${(e) => set("abs")(e.target.value)} /><${Feedback} error=${errors.abs} /></div>
776
- <div class="field"><label for="webSessionIdle">${tn("sessionIdle")}</label><input id="webSessionIdle" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours", 2)}
785
+ <div class="field"><label for="webSessionIdle">${tn("sessionIdle")}</label><input id="webSessionIdle" type="number" min="1" max="720" step="any" placeholder=${tn("defaultHours", 48)}
777
786
  class=${errors.idle ? "invalid" : ""} value=${f.idle} onInput=${(e) => set("idle")(e.target.value)} /><${Feedback} error=${errors.idle} /></div>
778
787
  </div>
779
788
  </section>
@@ -840,7 +849,7 @@ function Developer({ ctx }) {
840
849
  <button type="button" class="btn btn-sm" title=${tn("copy")} onClick=${copy}><${Icon} name="copy" size=${14} /><span class="lbl">${tn("copy")}</span></button>
841
850
  <button type="button" class="btn btn-sm" title=${tn("devDownload")} onClick=${download}><${Icon} name="download" size=${14} /><span class="lbl">${tn("devDownload")}</span></button>
842
851
  </div>
843
- ${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck="false" wrap="off" value=${draft} onInput=${(e) => setDraft(e.target.value)}></textarea>
852
+ ${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck=${false} wrap="off" value=${draft} onInput=${(e) => setDraft(e.target.value)}></textarea>
844
853
  <div class="save-row"><button type="button" class="btn btn-primary" onClick=${save}>${tn("devApply")}</button><span class="note">${tn("devApplyHint")}</span></div>`
845
854
  : html`<pre class="s-yaml">${text}</pre>`}
846
855
  ${msg ? html`<p class=${`feedback${msg.error ? " error" : ""}`} role="status">${msg.error || msg.text}</p>` : null}
@@ -1,11 +1,14 @@
1
1
  // #1481: an agent's HTML preview (#1306) in a panel beside the conversation — a split with a draggable divider on a
2
2
  // wide screen, a full-screen sheet on a narrow one. Nothing about the preview itself changes here: the frame is the one
3
3
  // preview.js makes (mountPreview: the preview origin, sandbox allow-scripts only), started by AgendPreview.start like a
4
- // card's, only told to fill the panel. It runs on a click, as a card's does:
5
- // - "Open in panel" on an idle card opens the panel with its Preview button — the panel runs nothing by itself;
4
+ // card's, only told to fill the panel. It runs on a person's click, as a card's does — and "Open in panel" is that click:
5
+ // - on an idle card it opens the panel and starts the preview there, as if Preview were clicked in the panel (#1704);
6
+ // a device that has not allowed previews gets the "Allow previews" offer instead, and allowing it starts the run;
7
+ // where previews cannot run (web.preview: false, a public link), the panel opens with nothing running;
6
8
  // - on a card whose preview is running, the same button moves that run to the panel (stopped in the card, started in
7
9
  // the panel: a moved frame reloads anyway);
8
- // - a newer version of the block in a later reply is offered, never swapped in, and offered with Preview to click.
10
+ // - nothing runs by itself: not when the page loads, not when the panel is shown again, and a newer version of the
11
+ // block in a later reply is offered, never swapped in, and shown with Preview to click.
9
12
  // Closing the panel or leaving the chat stops its frame; one preview per page still holds (start() stops any other).
10
13
  import { html, useEffect, useRef, useState } from "/assets/app-html.js";
11
14
  import { t } from "/assets/app-i18n.js";
@@ -19,18 +22,29 @@ const P = () => globalThis.AgendPreview;
19
22
 
20
23
  // ── The panel's state, one per page (the chat shows one instance at a time) ──
21
24
 
22
- /** open: { key, instance, msgKey, n | att, code, sender, ts } | null (`att`: an attached .html file, #1306 Q4); run: start it on mount (a moved run). */
25
+ /**
26
+ * open: { key, instance, msgKey, n | att, code, sender, ts } | null (`att`: an attached .html file, #1306 Q4);
27
+ * run: start it on mount — the card's click, as a token no later open ever reuses (0: nothing to run).
28
+ */
23
29
  let state = { open: null, run: 0 };
30
+ let runs = 0, version = 0, claims = 0;
24
31
  const subs = new Set();
25
- function set(next) { state = next; for (const fn of [...subs]) { try { fn(state); } catch { /* one listener's error stops no other */ } } }
32
+ function set(next) { state = next; version++; for (const fn of [...subs]) { try { fn(state); } catch { /* one listener's error stops no other */ } } }
26
33
  export const panelStore = { get: () => state, subscribe(fn) { subs.add(fn); return () => { subs.delete(fn); }; } };
34
+ /** Moves on every open and close, and on every claim: what a card's click compares when its HTML arrives late. */
35
+ export const panelVersion = () => version;
36
+ /** A click that will show something in the panel (an attachment's HTML still to read): from now on it is the newest —
37
+ * any earlier click still reading is dropped when its answer comes. Returns the version the click owns. */
38
+ export function claimPanel() { claims++; return ++version; }
39
+ /** How many clicks have claimed: an inline Preview still reading asks only whether a newer click came (not a close). */
40
+ export const panelClaims = () => claims;
27
41
 
28
42
  /** The frame's key in preview.js: its own, so the card that opened it keeps its own state. */
29
43
  export const frameKey = (key) => `panel:${key}`;
30
- /** Show `spec` in the panel; `run`: start it there (a click that moves a running preview). The block shown before stops. */
44
+ /** Show `spec` in the panel; `run`: start it there (the card's "Open in panel" click). The block shown before stops. */
31
45
  export function openPanel(spec, { run = false } = {}) {
32
46
  if (state.open && P()) P().stop(frameKey(state.open.key), "stopped", "");
33
- set({ open: { ...spec }, run: run ? state.run + 1 : 0 });
47
+ set({ open: { ...spec }, run: run ? ++runs : 0 });
34
48
  }
35
49
  /** Close the panel: its frame stops first. */
36
50
  export function closePanel() {
@@ -105,7 +119,7 @@ const timeOf = (ts) => (ts ? new Date(ts).toLocaleTimeString([], { hour: "2-digi
105
119
  * props: name (the instance), msgs() → its messages, subscribe(fn) → unsubscribe (the chat store's), keyOf(x),
106
120
  * reveal(msgKey) (the thread shows that message), download(code).
107
121
  */
108
- /** allow(): this device's opt-in through the chat's one path (setPreviewOptIn: the confirm, then setOptIn) → the choice. */
122
+ /** allow(commit): this device's opt-in through the chat's one path (setPreviewOptIn: the confirm, then commit(setOptIn)) → the choice. */
109
123
  export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, download, allow }) {
110
124
  const [st, setSt] = useState(state);
111
125
  const [view, setView] = useState("preview");
@@ -113,13 +127,17 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
113
127
  const [newer, setNewer] = useState(null);
114
128
  const [newerNote, setNewerNote] = useState("");
115
129
  const reading = useRef(null); // the newer version whose file is being read (one at a time)
130
+ const pending = useRef(0); // #1704: the card's click, waiting for this device's Allow (the st.run it came with)
131
+ const ownAllow = useRef(0); // while this panel's own Allow commits its choice: the click it answers (not cancelled by it)
116
132
  const holder = useRef(null), divider = useRef(null), panel = useRef(null);
117
133
  const open = st.open && st.open.instance === name ? st.open : null;
118
134
 
119
135
  useEffect(() => panelStore.subscribe(setSt), []);
120
136
  // This device's choice (here or in another tab) decides whether Preview is offered: re-read it when it changes.
137
+ // A click waiting for Allow is cancelled by any other change of that choice (Never, a card's menu, another tab):
138
+ // after it, running needs a new click — only this panel's own Allow runs the click it was offered for.
121
139
  const [, choice] = useState(0);
122
- useEffect(() => (P() ? P().onChange(() => choice(n => n + 1)) : undefined), []);
140
+ useEffect(() => (P() ? P().onChange(() => { if (pending.current && ownAllow.current !== pending.current) pending.current = 0; choice(n => n + 1); }) : undefined), []);
123
141
  // Leaving the chat (another instance, another panel) closes the panel: its frame stops with the thread's.
124
142
  useEffect(() => () => closePanel(), []);
125
143
 
@@ -130,11 +148,17 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
130
148
  P().start(frameKey(open.key), holder.current, open.code, ui, { fill: true });
131
149
  };
132
150
  const stop = () => { if (open) P().stop(frameKey(open.key), "stopped", ""); };
133
- // A new block in the panel: what ran before is gone (openPanel stopped it); a moved run starts here.
151
+ // A new block in the panel: what ran before is gone (openPanel stopped it); the card's click starts it here — or,
152
+ // where it cannot run, waits as `pending`: only the Allow below can use it (offered to a device that has not opted
153
+ // in), only while the panel shows what that click opened (st.run is a token no later open reuses), and any other
154
+ // change of this device's choice cancels it.
134
155
  useEffect(() => {
135
156
  setRun({ state: "idle", reason: "" });
136
157
  setView("preview");
137
- if (open && st.run) start();
158
+ if (open && st.run) {
159
+ if (P().availability().ok) start();
160
+ else pending.current = st.run;
161
+ }
138
162
  // Focus goes to the panel's close control that is on screen (Back on a phone, × beside the thread).
139
163
  if (panel.current) { const f = [...panel.current.querySelectorAll(".pv-panel-close")].find(b => b.offsetParent !== null); if (f) f.focus(); }
140
164
  }, [open && open.key, st.run]);
@@ -204,11 +228,21 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
204
228
  if (!open) return null;
205
229
  const going = run.state === "starting" || run.state === "running";
206
230
  const a = P().availability();
207
- // #1554: a device that has not allowed previews is offered the same "Allow previews" here (the same confirm); Preview
208
- // is then enabled — still a click. Why a run stopped (e.g. switched off in another tab) is still said first.
231
+ // #1554: a device that has not allowed previews is offered the same "Allow previews" here (the same confirm). Allowing
232
+ // runs the block the card's click opened (#1704); otherwise Preview is then enabled — still a click. Why a run stopped
233
+ // (e.g. switched off in another tab) is still said first.
209
234
  const askOptIn = !going && !a.ok && a.why === "optin" && typeof allow === "function";
210
235
  const note = run.reason || (going ? (run.state === "starting" ? t("chat.pvStarting") : "") : a.ok ? t("chat.pvPanelIdle") : askOptIn ? t("chat.pvWhyOptinPanel") : a.reason);
211
- const allowHere = async () => { if (await allow()) setRun({ state: "idle", reason: "" }); };
236
+ // Allowed here: the click that opened the panel runs now — if it is still waiting (no other change of the choice
237
+ // cancelled it, even while the confirm was open: only the change this Allow commits is its own) and the panel still
238
+ // shows what it opened (the confirm is a wait: the panel may have moved on meanwhile).
239
+ const allowHere = async () => {
240
+ const want = pending.current;
241
+ const yes = await allow((apply) => { ownAllow.current = want; try { apply(); } finally { ownAllow.current = 0; } });
242
+ if (!yes) return;
243
+ setRun({ state: "idle", reason: "" });
244
+ if (want && pending.current === want && panelStore.get().run === want) start();
245
+ };
212
246
  const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
213
247
  return html`<div class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
214
248
  aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>
@@ -197,11 +197,15 @@
197
197
  * opts.fill (#1481, the side panel): the frame fills its holder — the same frame from mountPreview, only sized by the
198
198
  * panel instead of by the frame's height messages, which it then ignores.
199
199
  */
200
+ /** How many previews this page has started: a click whose HTML arrives late asks whether another started meanwhile. */
201
+ var starts = 0;
202
+ function startCount() { return starts; }
200
203
  function start(key, holder, html, ui, opts) {
201
204
  var a = availability();
202
205
  if (!a.ok) { ui.state("unavailable", a.reason); return false; }
203
206
  if (typeof html !== "string" || utf8Bytes(html) > LIMITS.maxBytes) { ui.state("unavailable", say("pvHtmlOver", "This HTML is over 1 MiB; it is not previewed.")); return false; }
204
207
  stopAll("another");
208
+ starts++;
205
209
  var doc = holder.ownerDocument;
206
210
  var iframe = mountPreview(doc);
207
211
  var card = {
@@ -330,7 +334,7 @@
330
334
 
331
335
  return {
332
336
  init: init, availability: availability, optedIn: optedIn, setOptIn: setOptIn, never: never, setNever: setNever, onChange: onChange, listeners: listeners, onStorage: onStorage,
333
- start: start, stop: stop, stopAll: stopAll, stopIn: stopIn, running: running, liveCount: liveCount, liveFrame: liveFrame,
337
+ start: start, startCount: startCount, stop: stop, stopAll: stopAll, stopIn: stopIn, running: running, liveCount: liveCount, liveFrame: liveFrame,
334
338
  mountPreview: mountPreview, onMessage: onMessage, BANNER: BANNER, LIMITS: LIMITS,
335
339
  /** The banner every running preview carries, in the page's language (#1554); BANNER is its English. */
336
340
  banner: function () { return say("pvBanner", BANNER); },
@@ -217,7 +217,9 @@ register("settings", {
217
217
  concurrencyWarning: "May exceed CPU capacity on smaller hosts",
218
218
  staggerRange: "Must be between 0 and 30000",
219
219
  staggerWarning: "Very low values can cause startup spikes",
220
- sec_device: "This device", deviceLead: "Choices for this browser only — other devices keep their own.",
220
+ sec_device: "Preferences", syncedTitle: "Synced across your devices", syncedLead: "Changes here apply on every device where you are signed in.",
221
+ syncedPublicLink: "On the public link these are read-only: a change here stays in this browser.",
222
+ deviceTitle: "This device only", deviceLead: "The tour, text size, view fit, the sidebar shown or hidden, panel widths and desktop notifications stay on each device.",
221
223
  sec_agents: "Agents",
222
224
  sec_bots: "Connections",
223
225
  sec_classic: "ClassicBot",
@@ -580,7 +582,9 @@ register("settings", {
580
582
  concurrencyWarning: "在較小的主機上可能超出 CPU 負荷",
581
583
  staggerRange: "必須介於 0 到 30000",
582
584
  staggerWarning: "數值太低可能造成啟動尖峰",
583
- sec_device: "這台裝置", deviceLead: "只套用在這個瀏覽器,其他裝置保留各自的設定。",
585
+ sec_device: "偏好設定", syncedTitle: "在你所有的裝置同步", syncedLead: "這裡的變更會套用到你登入的每一台裝置。",
586
+ syncedPublicLink: "公開連結只能讀取這些設定:在這裡變更只會留在這個瀏覽器。",
587
+ deviceTitle: "只限這台裝置", deviceLead: "導覽、文字大小、視圖寬度、側欄顯示或隱藏、面板寬度與桌面通知,每台裝置各自保留。",
584
588
  sec_agents: "Agent",
585
589
  sec_bots: "連線",
586
590
  sec_classic: "ClassicBot",
@@ -31,7 +31,7 @@ export function TokenField({ id, platform, value, onInput, identity, busy, onVer
31
31
  const [shown, setShown] = useState(false);
32
32
  return html`<div class="field token-field"><label for=${id}>${tn("wizardToken")}</label>
33
33
  <div class="token-row">
34
- <input id=${id} type=${shown ? "text" : "password"} autocomplete="off" autocapitalize="off" spellcheck="false" disabled=${!!busy}
34
+ <input id=${id} type=${shown ? "text" : "password"} autocomplete="off" autocapitalize="off" spellcheck=${false} disabled=${!!busy}
35
35
  placeholder=${platform === "discord" ? "MTA…" : "123456:ABC-DEF…"} value=${value} onInput=${(e) => onInput(e.target.value.trim())} />
36
36
  <button type="button" class="icon-btn" aria-pressed=${shown ? "true" : "false"} title=${shown ? tn("tokenHide") : tn("tokenShow")}
37
37
  aria-label=${shown ? tn("tokenHide") : tn("tokenShow")} onClick=${() => setShown(!shown)}><${Icon} name="eye" size=${16} /></button>
@@ -1,14 +1,33 @@
1
1
  // #1408 §4: one dictionary for the app, namespaced (app.*, chat.*, fleet.*, …). A panel registers its own namespace
2
2
  // when it loads; t("chat.send") resolves it in the chosen language and falls back to English, then to the key.
3
- // The language is this browser's choice (agend_lang, the key every panel has used), else the device's.
3
+ // The language is the person's choice — kept on the host since #1665 and cached in this browser as agend_lang, the key
4
+ // every panel has used — else the device's.
4
5
 
5
6
  const tables = { en: {}, "zh-TW": {} };
6
7
  const listeners = new Set();
7
8
 
8
- function stored() {
9
- try { const v = localStorage.getItem("agend_lang"); return v === "en" || v === "zh-TW" ? v : null; } catch { return null; }
9
+ /** The device's own language: what applies when none is chosen. */
10
+ export function deviceLanguage() { return typeof navigator !== "undefined" && /^zh/i.test(navigator.language || "") ? "zh-TW" : "en"; }
11
+ function cacheLang(v) { try { if (v) localStorage.setItem("agend_lang", v); else localStorage.removeItem("agend_lang"); } catch { /* this page only */ } }
12
+ /**
13
+ * #1665: the signed-in page carries the host's values (<html data-prefs>) — the same rule as theme.js: the host's
14
+ * language is this page's (the browser's copy is refreshed, best effort); none chosen on the host and this device in
15
+ * step with it: the device's language; not in step yet, or no data-prefs: this browser's copy, else the device's.
16
+ */
17
+ function initialLang() {
18
+ let d = null;
19
+ try { d = JSON.parse(document.documentElement.getAttribute("data-prefs") || "null"); } catch { d = null; }
20
+ if (d && typeof d === "object" && d.values && typeof d.values === "object") {
21
+ const host = d.values.lang;
22
+ if (host === "en" || host === "zh-TW") { cacheLang(host); return host; }
23
+ let inStep = false;
24
+ try { inStep = localStorage.getItem("agend_prefs_synced") === "1"; } catch { inStep = false; }
25
+ if (inStep) { cacheLang(null); return deviceLanguage(); }
26
+ }
27
+ try { const v = localStorage.getItem("agend_lang"); if (v === "en" || v === "zh-TW") return v; } catch { /* none */ }
28
+ return deviceLanguage();
10
29
  }
11
- let current = stored() || (typeof navigator !== "undefined" && /^zh/i.test(navigator.language || "") ? "zh-TW" : "en");
30
+ let current = initialLang();
12
31
 
13
32
  /** Add a namespace: `register("chat", { en: {...}, "zh-TW": {...} })`. */
14
33
  export function register(ns, byLang) {
@@ -19,11 +38,14 @@ export function register(ns, byLang) {
19
38
 
20
39
  export function lang() { return current; }
21
40
 
22
- /** Choose a language for this browser; every subscriber re-renders (a new navigation lease, §4). */
23
- export function setLang(next) {
41
+ /**
42
+ * Choose a language; every subscriber re-renders (a new navigation lease, §4). `{ store: false }`: this page only —
43
+ * the host's "none chosen" arriving from another device (app-prefs.js), which leaves no copy in this browser.
44
+ */
45
+ export function setLang(next, opts = {}) {
24
46
  if (next !== "en" && next !== "zh-TW") return;
25
47
  current = next;
26
- try { localStorage.setItem("agend_lang", next); } catch { /* this page only */ }
48
+ if (opts.store !== false) cacheLang(next);
27
49
  if (typeof document !== "undefined" && document.documentElement) document.documentElement.lang = next === "zh-TW" ? "zh-Hant" : "en";
28
50
  for (const fn of listeners) fn(next);
29
51
  }
@@ -38,12 +60,12 @@ export function t(key, ...values) {
38
60
 
39
61
  register("app", {
40
62
  en: {
41
- brand: "AgEnD", fleet: "Fleet", view: "View", settings: "Settings", instances: "Instances", newInstance: "New instance",
63
+ brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet overview", view: "View", settings: "Settings", instances: "Instances", newInstance: "New instance",
42
64
  collapse: "Hide the sidebar", expand: "Show the sidebar", openMenu: "Open the sidebar", closeMenu: "Close the sidebar",
43
65
  theme: "Theme", themeSystem: "System", themeLight: "Light", themeDark: "Dark", language: "Language",
44
- previews: "Allow HTML previews on this device", tour: "Tour", tourTitle: "Show the web chat tour again",
66
+ previews: "Allow HTML previews", tour: "Tour", tourTitle: "Show the web chat tour again",
45
67
  needsYou: "needs you", approxNote: "Read from the terminal, so approximate", chat: "Chat", details: "Details", instanceViews: "Views of {0}",
46
- textSize: "Text size: {0}", textSize_s: "S", textSize_m: "M", textSize_l: "L", textSize_fit: "Fit", usage: "Usage",
68
+ textSize: "Text size: {0}", textSize_s: "S", textSize_m: "M", textSize_l: "L", textSize_fit: "Fit", usage: "Usage", usageHide: "Hide usage",
47
69
  statusRunning: "running", statusStopped: "stopped", statusPaused: "paused", statusCrashed: "crashed", statusWorking: "working", statusStuck: "looks stuck", statusIdle: "idle",
48
70
  connReconnecting: "Can't reach AgEnD — it may be restarting. Reconnecting…", connReconnectingIn: "Can't reach AgEnD — it may be restarting. Trying again in {0} s.",
49
71
  hydrating: "Loading what is open now…", hydrateFailed: "Could not load what is open now (prompts, ticks).",
@@ -67,12 +89,12 @@ register("app", {
67
89
  needs_reason_hang: "Not responding", needs_reason_exited: "Exited", needs_reason_assist: "Waiting at its terminal", needs_reason_permission: "Permission needed", needs_reason_dangerous_command: "Dangerous command waiting", needs_reason_login: "Sign-in needed", needs_reason_dialog: "Waiting at a dialog", needs_reason_terminal_input: "May be waiting for input", needs_reason_auth_paused: "Paused: sign-in needed", needs_reason_crashed: "Crashed", needs_reason_delivery_uncertain: "Could not confirm delivery", needs_reason_delivery_failed: "Delivery failed",
68
90
  },
69
91
  "zh-TW": {
70
- brand: "AgEnD", fleet: "Fleet", view: "檢視", settings: "設定", instances: "Instances", newInstance: "新增 instance",
92
+ brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet 概覽", view: "檢視", settings: "設定", instances: "Instances", newInstance: "新增 instance",
71
93
  collapse: "隱藏側邊欄", expand: "顯示側邊欄", openMenu: "打開側邊欄", closeMenu: "關閉側邊欄",
72
94
  theme: "主題", themeSystem: "跟隨系統", themeLight: "淺色", themeDark: "深色", language: "語言",
73
- previews: "允許這台裝置預覽 HTML", tour: "導覽", tourTitle: "再看一次網頁聊天導覽",
95
+ previews: "允許預覽 HTML", tour: "導覽", tourTitle: "再看一次網頁聊天導覽",
74
96
  needsYou: "等你回應", approxNote: "從終端畫面判讀,僅供參考", chat: "聊天", details: "詳細資訊", instanceViews: "{0} 的檢視",
75
- textSize: "字級:{0}", textSize_s: "小", textSize_m: "中", textSize_l: "大", textSize_fit: "剛好填滿", usage: "用量",
97
+ textSize: "字級:{0}", textSize_s: "小", textSize_m: "中", textSize_l: "大", textSize_fit: "剛好填滿", usage: "用量", usageHide: "隱藏用量",
76
98
  statusRunning: "執行中", statusStopped: "已停止", statusPaused: "已暫停", statusCrashed: "已當掉", statusWorking: "工作中", statusStuck: "似乎卡住", statusIdle: "閒置",
77
99
  connReconnecting: "連不上 AgEnD——可能正在重新啟動。正在重新連線…", connReconnectingIn: "連不上 AgEnD——可能正在重新啟動。{0} 秒後再試。",
78
100
  hydrating: "正在載入目前的狀態…", hydrateFailed: "無法載入目前的狀態(提示、送達標記)。",
@@ -0,0 +1,249 @@
1
+ // #1665: the web preferences that follow the person between devices — kept on the host (/ui/prefs), with this browser's
2
+ // localStorage, under the keys every panel already reads, as the cache. Design: the comment on #1665.
3
+ //
4
+ // - Synced: theme, language, the sidebar (order, folds, filter, favorites — #1664) and the HTML-preview opt-in. The tour and the first-run
5
+ // card stay on each device, as every layout choice does.
6
+ // - The signed-in page is served with the host's values (<html data-prefs>). This module, imported before any panel
7
+ // reads its key, copies them into the cache: the host wins once it has a value — and, on a device already in step
8
+ // with it, a key the host does not have is the default (its stale copy here is cleared). theme.js and the
9
+ // dictionary did the same for the theme and language, before paint.
10
+ // - A change made here is cached at once and sent to the host (a short debounce gathers a burst, such as typing in the
11
+ // filter). Writes go one at a time; a key stays this page's own from the change until its write has ended, so no
12
+ // answer — a read or another write — that started before can put an older value back. Every answer carries the
13
+ // host's revision, and one older than what this page already has is ignored.
14
+ // - The public link, and the anonymous View reader, have no host write: theirs stays local.
15
+ // - The first load on a device (no agend_prefs_synced yet) uploads what it had locally for the keys the host does not
16
+ // have yet, each with onlyIfUnset — the first device to arrive wins — then reads the host's values back. Only once
17
+ // every upload got an answer and that read succeeded is the device marked: anything else tries again next load.
18
+ // - The status frame's prefs_rev: another device changed something; the host's values are read again and applied. A
19
+ // revision counts as seen only once its read succeeded (a failed one is tried again, at most every few seconds).
20
+ import { deviceLanguage, lang, setLang } from "./app-i18n.js";
21
+ import { appStore } from "./app-store.js";
22
+
23
+ const SYNCED_FLAG = "agend_prefs_synced";
24
+ const RETRY_MS = 5000;
25
+ const json = { toLocal: (v) => JSON.stringify(v), fromLocal: (s) => { try { return s == null ? undefined : JSON.parse(s); } catch { return undefined; } } };
26
+ /** Each synced preference: its cache key, and how a host value is cached (null: no entry) and read back. */
27
+ export const SYNCED = {
28
+ theme: { ls: "agend_theme", toLocal: (v) => (v === "light" || v === "dark" ? v : null), fromLocal: (s) => (s === "light" || s === "dark" ? s : undefined) },
29
+ lang: { ls: "agend_lang", toLocal: (v) => (v === "en" || v === "zh-TW" ? v : null), fromLocal: (s) => (s === "en" || s === "zh-TW" ? s : undefined) },
30
+ "nav.order": { ls: "agend_view_sidebar_order", ...json },
31
+ "nav.collapsed": { ls: "agend_instance_collapsed", ...json },
32
+ "nav.filter": { ls: "agend_instance_filter", ...json },
33
+ "nav.favorites": { ls: "agend_favorites", ...json }, // #1664
34
+ "preview.optIn": { ls: "agend_html_preview", toLocal: (v) => (v ? "on" : null), fromLocal: (s) => (s === "on" ? true : undefined) },
35
+ };
36
+ const own = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
37
+ const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
38
+
39
+ function readLocal(key) { try { return localStorage.getItem(key); } catch { return null; } }
40
+ function writeLocal(key, s) { try { if (s == null) localStorage.removeItem(key); else localStorage.setItem(key, s); } catch { /* this page only */ } }
41
+ function cache(key, value) { const k = SYNCED[key]; writeLocal(k.ls, value == null ? null : k.toLocal(value)); }
42
+ const inStep = () => readLocal(SYNCED_FLAG) === "1";
43
+
44
+ function pageHost() {
45
+ try {
46
+ const d = JSON.parse(document.documentElement.getAttribute("data-prefs") || "null");
47
+ return d && typeof d === "object" && typeof d.rev === "number" && d.values && typeof d.values === "object" ? d : null;
48
+ } catch { return null; }
49
+ }
50
+
51
+ const initial = typeof document !== "undefined" ? pageHost() : null;
52
+ let rev = initial ? initial.rev : null;
53
+ let host = initial ? initial.values : null;
54
+ const pending = new Map(); // key → value chosen here, not sent yet
55
+ const mine = new Map(); // key → value chosen here whose write is not over (pending or out)
56
+ const inflight = new Map(); // key → writes sent and not answered yet
57
+ let flushTimer = null;
58
+ let writes = Promise.resolve(); // one write at a time, in the order they were made
59
+ const listeners = new Set();
60
+
61
+ // The host's values into the cache, now — before the sidebar, the tour or the first-run card read their keys. A key
62
+ // the host does not have: the default, on a device in step with the host; this device's own (to upload) otherwise.
63
+ if (host) {
64
+ const step = inStep();
65
+ for (const key of Object.keys(SYNCED)) {
66
+ if (own(host, key)) cache(key, host[key]);
67
+ else if (step) cache(key, null);
68
+ }
69
+ }
70
+
71
+ /** This page may change the host's preferences: the signed-in app, not over the public link. */
72
+ export function prefsWritable() {
73
+ return !!initial && !(typeof document !== "undefined" && document.body && document.body.dataset.publicLink === "1");
74
+ }
75
+ /**
76
+ * The value in force for a key, in memory — never only in this browser's storage (a full storage must not bring back
77
+ * an old value): this page's own choice while its write is not over; else the host's; else, on a device in step with
78
+ * the host, the default ({ value: undefined }). null: not known here (no host values on this page, or a device not yet
79
+ * in step) — the caller reads this browser's copy.
80
+ */
81
+ export function prefInForce(key) {
82
+ if (!own(SYNCED, key)) return null;
83
+ if (mine.has(key)) return { value: mine.get(key) };
84
+ if (host && own(host, key)) return { value: host[key] };
85
+ if (initial && inStep()) return { value: undefined };
86
+ return null;
87
+ }
88
+ /** The host's value for a key as this page last saw it (undefined: not set there, or not known). */
89
+ export function hostPref(key) { return host && own(host, key) ? host[key] : undefined; }
90
+ /** Called with the keys another device changed, after they are cached and applied here. Returns the unsubscribe. */
91
+ export function onPrefsChanged(fn) { listeners.add(fn); return () => listeners.delete(fn); }
92
+
93
+ /** A host answer ({ values, rev }): a real view of the host (not an unreadable store's defaults)? */
94
+ const isView = (body) => !!body && typeof body.rev === "number" && !!body.values && typeof body.values === "object" && !body.unreadable;
95
+ /** Take a view as this page's — unless it is older than the one it has. True: taken. */
96
+ function take(body) {
97
+ if (!isView(body) || (rev !== null && body.rev < rev)) return false;
98
+ rev = body.rev;
99
+ host = body.values;
100
+ return true;
101
+ }
102
+
103
+ /**
104
+ * A preference chosen on this page: cached now, and kept on the host (a burst within 400 ms is one write). Not writable
105
+ * (the public link, the anonymous reader): this browser only, as before.
106
+ */
107
+ export function savePref(key, value) {
108
+ if (!own(SYNCED, key)) return;
109
+ cache(key, value);
110
+ if (!prefsWritable()) return;
111
+ pending.set(key, value);
112
+ mine.set(key, value);
113
+ clearTimeout(flushTimer);
114
+ flushTimer = setTimeout(flush, 400);
115
+ }
116
+
117
+ /** One write. ok: the host took it (its answer is a view; an older one than this page has is not taken). */
118
+ async function post(set, onlyIfUnset) {
119
+ const res = await fetch("/ui/prefs", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify(onlyIfUnset ? { set, onlyIfUnset: true } : { set }) });
121
+ const body = await res.json().catch(() => null);
122
+ const ok = res.ok && isView(body);
123
+ if (ok) take(body);
124
+ return { ok, status: res.status };
125
+ }
126
+
127
+ function flush() {
128
+ flushTimer = null;
129
+ if (!pending.size) return writes;
130
+ const set = Object.fromEntries(pending);
131
+ const keys = [...pending.keys()];
132
+ pending.clear();
133
+ for (const k of keys) inflight.set(k, (inflight.get(k) || 0) + 1);
134
+ writes = writes.then(async () => {
135
+ let ok = false;
136
+ try { ok = (await post(set, false)).ok; } catch { ok = false; }
137
+ const released = new Set();
138
+ for (const k of keys) {
139
+ const n = (inflight.get(k) || 1) - 1;
140
+ if (n) inflight.set(k, n); else inflight.delete(k);
141
+ if (!n && !pending.has(k)) { mine.delete(k); released.add(k); }
142
+ }
143
+ // Each key whose last write this was, and not changed here again since, now shows the newest host view this page
144
+ // has — usually its own value; another device's newer one if that arrived while the write was out (held back then,
145
+ // and an older answer to this write cannot overrule it); refused or not sent, what the host has (read back first).
146
+ // Always applied: what the page shows came from this page's own choice, not from the cache, so the cache matching
147
+ // the host (a storage that refused the choice) says nothing about the screen.
148
+ if (!ok) await refresh();
149
+ align(released, { force: true });
150
+ });
151
+ return writes;
152
+ }
153
+
154
+ /**
155
+ * Read the host's values again and apply the ones that differ from this page's — not a key still being changed here
156
+ * (unsent, or its write not answered yet), and nothing from an answer older than the view this page has. True: the read
157
+ * succeeded (taken, or older than what this page already has — nothing to do).
158
+ */
159
+ export async function refresh() {
160
+ if (!initial) return false;
161
+ let body = null;
162
+ try {
163
+ const res = await fetch("/ui/prefs", { credentials: "same-origin" });
164
+ body = res.ok ? await res.json() : null;
165
+ } catch { body = null; }
166
+ if (!isView(body)) return false;
167
+ const before = new Map(Object.keys(SYNCED).map((key) => [key, own(host, key) ? SYNCED[key].toLocal(host[key]) : null]));
168
+ if (take(body)) align(Object.keys(SYNCED), { before });
169
+ return true;
170
+ }
171
+
172
+ /**
173
+ * Show the host view this page has for these keys — except a key still being changed here (unsent, or out). A key is
174
+ * applied when its host value moved in memory (`before`: the host view before this read), when this browser's copy
175
+ * differs, or always (`force`: this page's own write just ended). The copy alone never decides: a storage that refused
176
+ * a write keeps an old copy that may equal the host while the screen shows something else.
177
+ */
178
+ function align(keys, opts = {}) {
179
+ const changed = new Set();
180
+ for (const key of keys) {
181
+ if (pending.has(key) || inflight.has(key)) continue;
182
+ const k = SYNCED[key];
183
+ const want = own(host, key) ? k.toLocal(host[key]) : null;
184
+ const copyDiffers = readLocal(k.ls) !== want;
185
+ const moved = !!opts.before && opts.before.get(key) !== want;
186
+ if (!copyDiffers && !moved && !opts.force) continue;
187
+ if (copyDiffers) writeLocal(k.ls, want);
188
+ changed.add(key);
189
+ }
190
+ if (changed.size) apply(changed);
191
+ }
192
+
193
+ function apply(changed) {
194
+ // Only what differs from what the page shows (a key re-applied after its own write may already be in force).
195
+ const T = globalThis.AgendTheme;
196
+ if (changed.has("theme") && typeof T !== "undefined") { const v = hostPref("theme") || "system"; if (typeof T.get !== "function" || T.get() !== v) T.set(v); }
197
+ if (changed.has("lang")) {
198
+ const l = hostPref("lang");
199
+ // None chosen any more: the device's own language, on this page only (no copy is kept for it).
200
+ if (l) { if (l !== lang()) setLang(l); } else if (deviceLanguage() !== lang()) setLang(deviceLanguage(), { store: false });
201
+ }
202
+ const P = globalThis.AgendPreview;
203
+ if (changed.has("preview.optIn") && P && typeof P.setOptIn === "function") {
204
+ const on = hostPref("preview.optIn") === true;
205
+ if (typeof P.optedIn !== "function" || P.optedIn() !== on) P.setOptIn(on);
206
+ }
207
+ for (const fn of [...listeners]) { try { fn(changed); } catch { /* one listener's error stops no other */ } }
208
+ }
209
+
210
+ /**
211
+ * Once per device: upload what this browser had for the keys the host has not got yet (each on its own, onlyIfUnset — a
212
+ * value the host refuses as invalid is simply not taken), then adopt the host's values. Marked only when every upload
213
+ * was answered (taken, or refused as invalid) and the read back succeeded; a request with no answer, a server error, a
214
+ * refused session or an unreadable store leaves the device unmarked, so the next load tries again.
215
+ */
216
+ export async function migratePrefs() {
217
+ if (!prefsWritable() || inStep()) return false;
218
+ for (const key of Object.keys(SYNCED)) {
219
+ if (own(host, key)) continue;
220
+ const local = SYNCED[key].fromLocal(readLocal(SYNCED[key].ls));
221
+ if (local === undefined) continue;
222
+ let r;
223
+ try { r = await post({ [key]: local }, true); } catch { return false; }
224
+ if (!r.ok && r.status !== 400) return false;
225
+ }
226
+ if (!(await refresh())) return false;
227
+ writeLocal(SYNCED_FLAG, "1");
228
+ return true;
229
+ }
230
+
231
+ /** The app's boot ("full" mode): the first-load migration, then another device's changes at each status frame. */
232
+ export function bootPrefs() {
233
+ if (!initial) return;
234
+ // Each new revision the frames announce, read once (the store changes many times a second); one announced while a read
235
+ // is out is read as soon as it ends. Seen only once its read succeeded: after a failed read the next try waits for a
236
+ // later frame, at least RETRY_MS on.
237
+ let seen = rev, busy = false, failedAt = -Infinity;
238
+ const check = () => {
239
+ const r = appStore.get().prefsRev;
240
+ if (typeof r !== "number" || r === seen || busy) return;
241
+ if (r === rev) { seen = r; return; }
242
+ if (now() - failedAt < RETRY_MS) return;
243
+ busy = true;
244
+ refresh().then((ok) => { if (ok) seen = r; else failedAt = now(); }, () => { failedAt = now(); })
245
+ .finally(() => { busy = false; check(); });
246
+ };
247
+ appStore.subscribe(check);
248
+ migratePrefs();
249
+ }