@mirrowel/opencode-openshare 0.2.0-dev.2 → 0.2.1

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.
package/dist/core.js CHANGED
@@ -43,7 +43,7 @@
43
43
  // secret must never reach viewers.
44
44
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
45
45
  import { createHash } from "node:crypto";
46
- import { join } from "node:path";
46
+ import { isAbsolute, join } from "node:path";
47
47
  import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
48
48
  import { cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js";
49
49
  import { queueBindingWrite } from "./binding-queue.js";
@@ -139,6 +139,14 @@ function shareFlag(info) {
139
139
  }
140
140
  return { enabled: true, opts: Object.keys(opts).length ? opts : undefined };
141
141
  }
142
+ /** The LIVE engine's resolved target urls (shared module state: the TUI
143
+ * entry reads this so surfaces that gate on "is anything configured" agree
144
+ * with what the engine actually targets - file config alone can miss
145
+ * plugin-options-layer config the TUI cannot see). */
146
+ let liveTargets = [];
147
+ export function engineTargetUrls() {
148
+ return liveTargets;
149
+ }
142
150
  export function createEngine(deps) {
143
151
  let cfg = loadConfig(deps.pluginDir, deps.options);
144
152
  const mode = resolveShareMode(cfg, deps);
@@ -173,8 +181,16 @@ export function createEngine(deps) {
173
181
  log(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`);
174
182
  const resolveTargets = () => {
175
183
  const merged = {};
176
- if (cfg.workerUrl)
184
+ const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0;
185
+ // layer-aware legacy rule: a FILE workerUrl beside a FILE servers
186
+ // group would silently dual-home the session (live-caught surprise);
187
+ // an ENV workerUrl is the highest-priority layer and always joins.
188
+ const envWorker = !!process.env.OPENSHARE_WORKER_URL;
189
+ if (cfg.workerUrl && (!hasServers || envWorker))
177
190
  merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
191
+ if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers[cfg.workerUrl]) {
192
+ log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`);
193
+ }
178
194
  if (cfg.servers)
179
195
  for (const [u, e] of Object.entries(cfg.servers))
180
196
  merged[u] = { ...merged[u], ...e };
@@ -184,6 +200,7 @@ export function createEngine(deps) {
184
200
  return chosen.map((url) => ({ url, entry: merged[url] }));
185
201
  };
186
202
  let targets = resolveTargets();
203
+ liveTargets = targets.map((t) => t.url);
187
204
  // ---- per-server credentials (identity) ----------------------------------
188
205
  // Each server enrolls INDEPENDENTLY: a legacy root CREATE_TOKEN works
189
206
  // forever (server-side bootstrap); an osk_ user token is the normal
@@ -385,7 +402,8 @@ export function createEngine(deps) {
385
402
  badSignal: false,
386
403
  lastSentStatus: null,
387
404
  closed: false,
388
- prefsSent: false,
405
+ lastError: null,
406
+ ttlClamped: null,
389
407
  binding: null,
390
408
  bindingQueried: false,
391
409
  optOuts: new Set(),
@@ -406,6 +424,7 @@ export function createEngine(deps) {
406
424
  spoolRoot = spoolDirFor(cfg);
407
425
  const before = new Set(targets.map((t) => t.url));
408
426
  targets = resolveTargets();
427
+ liveTargets = targets.map((t) => t.url);
409
428
  seedCredentials();
410
429
  clampToPolicy();
411
430
  if (debugEvents) {
@@ -626,6 +645,10 @@ export function createEngine(deps) {
626
645
  const value = { v: 2, servers, enabled: true };
627
646
  if (mode === "manual" && st.opts)
628
647
  value.opts = st.opts;
648
+ if (st.lastError)
649
+ value.lastError = st.lastError;
650
+ if (st.ttlClamped)
651
+ value.ttlClamped = st.ttlClamped;
629
652
  st.binding = value;
630
653
  st.wroteBinding = true;
631
654
  await writeMetadata(st, value);
@@ -887,7 +910,11 @@ export function createEngine(deps) {
887
910
  // Append: CI reads the file after each run; keeping every share
888
911
  // URL lets one file serve a whole workflow (the legacy original
889
912
  // overwrote with the latest only).
890
- const file = cfg.urlFile || join(deps.directory ?? process.cwd(), "openshare.urls.txt");
913
+ const file = cfg.urlFile
914
+ ? isAbsolute(cfg.urlFile)
915
+ ? cfg.urlFile
916
+ : join(deps.directory ?? process.cwd(), cfg.urlFile)
917
+ : join(deps.directory ?? process.cwd(), "openshare.urls.txt");
891
918
  try {
892
919
  appendFileSync(file, url + "\n");
893
920
  if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
@@ -896,18 +923,57 @@ export function createEngine(deps) {
896
923
  log(`url file: ${file}`);
897
924
  }
898
925
  }
899
- catch { }
926
+ catch (e) {
927
+ // a CI pipeline reads this file after the process exits; a silent
928
+ // failure means the run's share URL is simply gone
929
+ logError(`url file write failed (${file}):`, e?.message || e);
930
+ }
900
931
  }
901
932
  };
933
+ let halfAuthWarned = false;
902
934
  const authFrame = (st) => {
903
935
  // always send both (empty strings included): attach with empty values
904
- // REMOVES protection — an intentional owner action. Session opts
936
+ // REMOVES protection - an intentional owner action. Session opts
905
937
  // override the global config when set.
906
938
  const login = st.opts?.authLogin ?? cfg.authLogin ?? "";
907
939
  const password = st.opts?.authPassword ?? cfg.authPassword ?? "";
940
+ // both-or-none: the server builds a gate only from a full pair, so a
941
+ // half pair silently means "no gate" - refuse to send one and say so
942
+ if ((login === "") !== (password === "")) {
943
+ if (!halfAuthWarned) {
944
+ halfAuthWarned = true;
945
+ logError("read auth needs BOTH a login and a password - gate disabled until fixed");
946
+ }
947
+ return { authLogin: "", authPassword: "" };
948
+ }
908
949
  return { authLogin: login, authPassword: password };
909
950
  };
910
951
  const ttlFor = (st) => st.opts?.ttlDays ?? cfg.ttlDays;
952
+ /** Apply a metadata opts update. On a CHANGED opts set for a session with
953
+ * live sockets, re-send the attach frame over each open socket: the
954
+ * worker's attach path applies read-auth retroactively (rotating viewer
955
+ * tokens on change) and now also honors an explicit ttlDays override -
956
+ * so settings heal immediately instead of waiting for a reconnect.
957
+ * Re-attach is idempotent (no once-guard on init; earlySyncs empty at
958
+ * steady state) and revalidates the token. */
959
+ const applyOpts = (st, opts) => {
960
+ if (!opts)
961
+ return;
962
+ const prev = st.opts ? JSON.stringify(st.opts) : "";
963
+ st.opts = opts;
964
+ if (prev === JSON.stringify(opts))
965
+ return;
966
+ for (const [url, r] of st.remotes) {
967
+ if (!r.shareId || !r.secret || !r.wsReady)
968
+ continue;
969
+ const attach = { id: r.shareId, secret: r.secret, ...authFrame(st) };
970
+ if (st.opts?.ttlDays)
971
+ attach.ttlDays = st.opts.ttlDays;
972
+ const sent = wsSend(r, { op: "init", attach });
973
+ if (sent)
974
+ log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlDays ? ` (ttl ${st.opts.ttlDays}d)` : ""}`);
975
+ }
976
+ };
911
977
  /** Best-effort cleanup of a share we are about to abandon (refused
912
978
  * attach, mismatch recreate). Prevents husk litter; failures are fine —
913
979
  * the TTL sweeper collects strays eventually. */
@@ -1000,7 +1066,7 @@ export function createEngine(deps) {
1000
1066
  st.remotes.delete(url);
1001
1067
  }
1002
1068
  // collisions resolved by the TUI: the binding entry for the server
1003
- // was removed — re-arm unless the same patch also opted out
1069
+ // was removed - re-arm unless the same patch also opted out
1004
1070
  for (const r of [...st.remotes.values()]) {
1005
1071
  if (!r.collided || st.optOuts.has(r.url))
1006
1072
  continue;
@@ -1016,6 +1082,29 @@ export function createEngine(deps) {
1016
1082
  r.announced = false;
1017
1083
  queueConnect(st, r);
1018
1084
  }
1085
+ // re-share after "leave as-is": the entry kept its id/secret and the
1086
+ // opt-out marker is gone - re-arm a remote that ATTACHES the same
1087
+ // page (same URL), then backfill so the frozen gap catches up
1088
+ // (dedupe hashes describe the OLD remote set, not this revival)
1089
+ let rearmed = false;
1090
+ for (const [url, e] of Object.entries(servers)) {
1091
+ if (st.optOuts.has(url) || st.remotes.has(url))
1092
+ continue;
1093
+ const ee = e;
1094
+ if (typeof ee.id !== "string" || typeof ee.secret !== "string" || !ee.id)
1095
+ continue;
1096
+ const r = remoteFor(st, url);
1097
+ r.shareId = ee.id;
1098
+ r.secret = ee.secret;
1099
+ r.ackedRev = typeof ee.rev === "number" ? ee.rev : 0;
1100
+ log(`re-share: re-arming ${url.slice(0, 24)} on share ${ee.id}`);
1101
+ queueConnect(st, r);
1102
+ rearmed = true;
1103
+ }
1104
+ if (rearmed) {
1105
+ st.sentHash.clear();
1106
+ chain = chain.then(() => backfill(st)).catch((e) => logError("re-share backfill error", e?.message || e));
1107
+ }
1019
1108
  }
1020
1109
  catch { }
1021
1110
  };
@@ -1277,8 +1366,10 @@ export function createEngine(deps) {
1277
1366
  const err = String(frame.error ?? "");
1278
1367
  if (/frozen/i.test(err)) {
1279
1368
  // owner frozen: every control path is severed server-side.
1280
- // Re-enrolling would orphan the shares — surface and stop.
1369
+ // Re-enrolling would orphan the shares - surface and stop.
1281
1370
  r.authBlocked = true;
1371
+ st.lastError = `share frozen by the hoster (owner frozen) - contact the server admin`;
1372
+ scheduleBindingWrite(st);
1282
1373
  logError(`${r.url}: share frozen by the hoster (owner frozen) - edits refused; contact the server admin`);
1283
1374
  try {
1284
1375
  r.ws?.close(4000, "frozen");
@@ -1333,8 +1424,10 @@ export function createEngine(deps) {
1333
1424
  if (/limit|too many|rate/i.test(err)) {
1334
1425
  // quota/429 ("max shares reached", "daily create limit", the
1335
1426
  // anonymous caps): an immediate re-init on the same socket is a
1336
- // hot loop against the server — back off through the normal
1427
+ // hot loop against the server - back off through the normal
1337
1428
  // reconnect cadence instead
1429
+ st.lastError = `create refused: ${frame.error ?? "rate limited"}`;
1430
+ scheduleBindingWrite(st);
1338
1431
  logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff");
1339
1432
  r.connecting = false;
1340
1433
  try {
@@ -1346,6 +1439,8 @@ export function createEngine(deps) {
1346
1439
  return;
1347
1440
  }
1348
1441
  logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
1442
+ st.lastError = `init refused: ${frame.error ?? "unknown"}`;
1443
+ scheduleBindingWrite(st);
1349
1444
  deleteAbandoned(r);
1350
1445
  r.shareId = null;
1351
1446
  r.secret = null;
@@ -1401,6 +1496,21 @@ export function createEngine(deps) {
1401
1496
  applyPolicy(frame.policy, r.url);
1402
1497
  r.intentionalClose = false;
1403
1498
  touch(st);
1499
+ // the ack carries the RESOLVED ttl: surface a server/token clamp to
1500
+ // the binding once, and a successful handshake clears any stale
1501
+ // refusal reason the TUI may be showing
1502
+ if (typeof frame.ttlDays === "number") {
1503
+ const wanted = ttlFor(st);
1504
+ const clamp = wanted != null && frame.ttlDays !== wanted ? { requested: wanted, effective: frame.ttlDays } : null;
1505
+ if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
1506
+ st.ttlClamped = clamp;
1507
+ scheduleBindingWrite(st);
1508
+ }
1509
+ }
1510
+ if (st.lastError) {
1511
+ st.lastError = null;
1512
+ scheduleBindingWrite(st);
1513
+ }
1404
1514
  announceUrl(st, r);
1405
1515
  // first binding write is immediate (the TUI waits on the URL in
1406
1516
  // session metadata); ack-driven rev updates stay debounced
@@ -2343,10 +2453,6 @@ export function createEngine(deps) {
2343
2453
  // activates anything on its own
2344
2454
  const st = sessions.get(parent);
2345
2455
  if (st?.activated && !st.closed) {
2346
- if (!st.prefsSent) {
2347
- st.prefsSent = true;
2348
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2349
- }
2350
2456
  if (info)
2351
2457
  enqueue(st, itemFor("session", redact(info), sid));
2352
2458
  }
@@ -2358,11 +2464,7 @@ export function createEngine(deps) {
2358
2464
  if (st?.activated && !st.closed) {
2359
2465
  // already sharing: opts updates apply, flag removal handled below
2360
2466
  if (flag.opts)
2361
- st.opts = flag.opts;
2362
- if (!st.prefsSent) {
2363
- st.prefsSent = true;
2364
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2365
- }
2467
+ applyOpts(st, flag.opts);
2366
2468
  if (info)
2367
2469
  enqueue(st, itemFor("session", redact(info), sid));
2368
2470
  if (t === "session.updated")
@@ -2370,11 +2472,6 @@ export function createEngine(deps) {
2370
2472
  return;
2371
2473
  }
2372
2474
  activate(sid, flag.opts, info);
2373
- const fresh = sessions.get(sid);
2374
- if (fresh && !fresh.prefsSent) {
2375
- fresh.prefsSent = true;
2376
- enqueue(fresh, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2377
- }
2378
2475
  return;
2379
2476
  }
2380
2477
  if (st?.activated && !st.closed && t === "session.updated") {
@@ -2431,10 +2528,6 @@ export function createEngine(deps) {
2431
2528
  }
2432
2529
  if (!st)
2433
2530
  return;
2434
- if (!st.prefsSent) {
2435
- st.prefsSent = true;
2436
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2437
- }
2438
2531
  if (info)
2439
2532
  enqueue(st, itemFor("session", redact(info), sid));
2440
2533
  if (t === "session.updated" && st.activated && !st.closed) {
@@ -2701,7 +2794,7 @@ export function createEngine(deps) {
2701
2794
  if (flushed && typeof flushed.then === "function")
2702
2795
  await flushed.catch(() => { });
2703
2796
  // dispose with a mid-handshake socket: the deferred pending
2704
- // would die with the process — land it over HTTP instead
2797
+ // would die with the process - land it over HTTP instead
2705
2798
  for (const r of st.remotes.values()) {
2706
2799
  if (!r.wsReady && r.shareId && r.pending.length) {
2707
2800
  await flushViaHttp(st, r).catch(() => { });
@@ -2722,6 +2815,26 @@ export function createEngine(deps) {
2722
2815
  st.metaTimer = null;
2723
2816
  await writeBinding(st).catch(() => { });
2724
2817
  }
2818
+ // URL-at-exit barrier: a very short `opencode run` can exit before
2819
+ // the create handshake finishes, and CI reads the urlFile the
2820
+ // moment the process ends. Bounded 5s wait for every share that is
2821
+ // mid-handshake or not yet announced (a dead worker costs exactly
2822
+ // the cap; a healthy one is instant).
2823
+ const barrierStart = Date.now();
2824
+ while (Date.now() - barrierStart < 5000) {
2825
+ let pendingAnnounce = false;
2826
+ for (const st of sessions.values()) {
2827
+ for (const r of st.remotes.values()) {
2828
+ if (r.authBlocked || r.collided)
2829
+ continue;
2830
+ if ((r.shareId && !r.announced) || (!r.shareId && (r.ws || r.connecting)))
2831
+ pendingAnnounce = true;
2832
+ }
2833
+ }
2834
+ if (!pendingAnnounce)
2835
+ break;
2836
+ await new Promise((res) => setTimeout(res, 100));
2837
+ }
2725
2838
  for (const st of sessions.values()) {
2726
2839
  for (const r of st.remotes.values()) {
2727
2840
  try {
@@ -17,7 +17,7 @@ export declare function adminConsole(host: ShareHostApi, pinnedUrl?: string): Pr
17
17
  * ~25-row dump was unreadable (owner-directed regroup 2026-10-19).
18
18
  * Save semantics unchanged: root Exit saves when dirty; submenu Back
19
19
  * just returns. */
20
- export declare function settingsDialog(host: ShareHostApi, startAtSession?: string): Promise<void>;
20
+ export declare function settingsDialog(host: ShareHostApi, startAtSession?: string, startGroup?: "session"): Promise<void>;
21
21
  /** Onboarding shown by /share when no server is connected: connect flow
22
22
  * first (URL + optional code = enrolled + active), full settings as the
23
23
  * fallback for manual configuration. */
package/dist/settings.js CHANGED
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Sections: Connection, Sharing, Read auth, Advanced, This session.
7
7
  import { configSourceMap, moduleDir, writeCandidates } from "./config.js";
8
- import { adminClient, adminTokenFor, currentConfig, deleteServerEntry, disconnectAtServer, enrollAtServer, hostOf, isConfigured, normServerUrl, probeAuthMode, probeWorker, patchSessionBinding, readBinding, readBindings, resolveRoot, rotateAtServer, runUnshareFlow, saveConfig, saveServerEntry, serverEntries, setShareFlag, shareWithConsent, workerDeleteShare, } from "./share-flow.js";
8
+ import { adminClient, adminTokenFor, currentConfig, deleteServerEntry, disconnectAtServer, enrollAtServer, hostOf, isConfigured, normServerUrl, probeAuthMode, probeWorker, patchSessionBinding, readBinding, readBindings, resolveRoot, rotateAtServer, runUnshareFlow, saveConfig, saveServerEntry, meClient, serverEntries, setShareOpts, shareWithConsent, workerDeleteShare, } from "./share-flow.js";
9
9
  import { DIALOG_WIDTH_COLUMNS, kvHeightPercentOf, kvWidthOf, nextDialogWidth, UI_HEIGHT_PERCENT_KV, UI_WIDTH_KV, clampHeightPercent } from "./ui-prefs.js";
10
10
  /** description suffix: `[from <source>]` when a key is overridden by a
11
11
  * file/env higher in the chain than the write target. */
@@ -58,11 +58,11 @@ const LABELS = {
58
58
  },
59
59
  shareMode: {
60
60
  label: "Share mode",
61
- help: '"all" shares every session; "manual" needs /share. Default: TUI presence.',
61
+ help: '"all" shares every session; "manual" needs /share. Default: TUI presence. Takes effect after opencode restarts.',
62
62
  kind: "text",
63
63
  options: ["auto", "all", "manual"],
64
64
  section: "Sharing",
65
- info: 'all: every NEW session shares automatically (CI posture). Sessions that existed before the plugin started are never auto-shared — share those explicitly. manual: sessions stay private until you run /share.\n\nDefault (empty): TUI presence decides — installing the TUI part means manual, server-only means all.',
65
+ info: 'all: every NEW session shares automatically (CI posture). Sessions that existed before the plugin started are never auto-shared - share those explicitly. manual: sessions stay private until you run /share.\n\nDefault (empty): TUI presence decides - installing the TUI part means manual, server-only means all.\n\nNOTE: the mode is resolved once at plugin load. Changing it here saves immediately but only takes effect after opencode restarts - until then new sessions keep following the OLD mode.',
66
66
  def: "auto (TUI presence)",
67
67
  },
68
68
  debounceMs: {
@@ -97,15 +97,6 @@ const LABELS = {
97
97
  info: "How long pages live before the server's sweeper deletes them. Capped server-side: the deployment's MAX_TTL_DAYS and the token's maxTtlDays limit both apply.\n\nA per-session override lives in the This-session section; it is sent at create/attach time.",
98
98
  def: "worker cap (90 on the reference deployment)",
99
99
  },
100
- openTasksInNewTab: {
101
- label: "Open tasks in new tab",
102
- help: "Viewer: task rows open subagent pages in a new tab.",
103
- kind: "text",
104
- options: ["true", "false"],
105
- section: "Sharing",
106
- info: "Viewer behavior only: clicking a task (subagent) row opens its sub-page in a new browser tab instead of navigating. true/false.",
107
- def: "true",
108
- },
109
100
  authLogin: {
110
101
  label: "Read auth: login",
111
102
  help: "Optional read gate for share pages. Empty removes protection on the next attach.",
@@ -165,10 +156,8 @@ function helpForKey(key, cfg, sources) {
165
156
  function displayValue(key, cfg) {
166
157
  if (key === "createToken" || key === "authPassword")
167
158
  return mask(String(cfg[key] ?? ""));
168
- if (key === "openTasksInNewTab")
169
- return String(cfg.openTasksInNewTab !== false);
170
159
  if (key === "announceStderr")
171
- return String(cfg.announceStderr !== false);
160
+ return cfg.announceStderr !== false ? "on" : "off";
172
161
  const value = cfg[key];
173
162
  return value === undefined || value === "" ? "(not set)" : String(value);
174
163
  }
@@ -194,7 +183,7 @@ async function editKey(host, cfg, key) {
194
183
  const next = { ...cfg };
195
184
  if (key === "shareMode")
196
185
  next[key] = picked === "auto" ? undefined : picked;
197
- else if (key === "openTasksInNewTab" || key === "announceStderr")
186
+ else if (key === "announceStderr")
198
187
  next[key] = picked === "true";
199
188
  else
200
189
  next[key] = picked;
@@ -294,6 +283,8 @@ async function serverMenu(host, url) {
294
283
  options.push({ title: "Enroll now", value: "enroll", description: holdsRoot ? "join as a user via code/public enrollment" : "get a user token immediately" });
295
284
  if (mine.entry.token && !holdsRoot)
296
285
  options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" });
286
+ if (mine.entry.token && !holdsRoot)
287
+ options.push({ title: "Your account", value: "account", description: "limits, token expiry, your shares" });
297
288
  if (mine.entry.token && !holdsRoot)
298
289
  options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true });
299
290
  options.push({
@@ -309,12 +300,16 @@ async function serverMenu(host, url) {
309
300
  if (choice === undefined || choice === "__back")
310
301
  return;
311
302
  if (choice === "active") {
312
- for (const other of entries) {
313
- if (other.url !== url)
314
- saveServerEntry(other.url, { active: undefined });
315
- }
303
+ // make-primary WITHOUT collapsing fan-out: other explicit actives
304
+ // stay active (the engine targets every active entry); silently
305
+ // deactivating them destroyed hand-built multi-server setups
316
306
  saveServerEntry(url, { active: true });
317
- host.ui.toast({ variant: "success", title: "OpenShare", message: `${url} is now the active server.` });
307
+ const stillActive = serverEntries().filter((s) => s.active);
308
+ host.ui.toast({
309
+ variant: "success",
310
+ title: "OpenShare",
311
+ message: stillActive.length > 1 ? `${url} is primary - ${stillActive.length} servers stay ACTIVE (fan-out): new sessions mirror to all of them.` : `${url} is now the active server.`,
312
+ });
318
313
  return;
319
314
  }
320
315
  if (choice === "adopt") {
@@ -367,6 +362,32 @@ async function serverMenu(host, url) {
367
362
  }
368
363
  continue;
369
364
  }
365
+ if (choice === "account") {
366
+ const me = meClient(url, mine.entry.token ?? "");
367
+ const [meRes, sharesRes] = await Promise.all([me.me(), me.myShares()]);
368
+ if (!meRes.ok) {
369
+ await showInfo(host.ui, { title: "Account", message: `Could not read your account: ${meRes.error ?? "error"}.\nIf the token was revoked server-side, use Enroll now instead.` });
370
+ continue;
371
+ }
372
+ const d = meRes.data;
373
+ const lim = d.token?.limits ?? {};
374
+ const fmt = (v) => (v ? String(v) : "unlimited");
375
+ const lines = [
376
+ `User: ${d.label} (${d.userId})`,
377
+ `Token: ${d.token?.label ?? "?"} - expires ${d.token?.expiresAt ? new Date(d.token.expiresAt).toISOString().slice(0, 10) : "never"}`,
378
+ `Limits: live shares ${fmt(lim.maxLiveShares)} - max TTL ${fmt(lim.maxTtlDays)}d - daily creates ${fmt(lim.dailyCreates)}`,
379
+ ];
380
+ if (sharesRes.ok) {
381
+ const shares = sharesRes.data?.shares ?? [];
382
+ lines.push(`Shares: ${shares.length} (${shares.filter((s) => s.status === "live").length} live)`);
383
+ for (const s of shares.slice(0, 10))
384
+ lines.push(` ${s.id} - ${s.status} - ${new Date(s.createdAt).toISOString().slice(0, 10)}`);
385
+ if (shares.length > 10)
386
+ lines.push(` ...and ${shares.length - 10} more`);
387
+ }
388
+ await showInfo(host.ui, { title: "Your account", message: lines.join("\n") });
389
+ continue;
390
+ }
370
391
  if (choice === "rotate") {
371
392
  const res = await rotateAtServer(url, mine.entry.token ?? "");
372
393
  if (res.ok && res.token) {
@@ -379,7 +400,12 @@ async function serverMenu(host, url) {
379
400
  continue;
380
401
  }
381
402
  if (choice === "disconnect") {
382
- const sure = await showConfirm(host.ui, { title: "Disconnect", message: `Revoke the token at ${url} and forget it?\nYour shares stay (read-only for you until TTL).`, confirmLabel: "Disconnect", danger: true });
403
+ const sure = await showConfirm(host.ui, {
404
+ title: "Disconnect",
405
+ message: `Revoke the token at ${url} and forget it?\nRevoking your last token freezes control of ALL your shares there (even with the share secrets) - pages stay readable until their TTL; only the server admin can restore control.`,
406
+ confirmLabel: "Disconnect",
407
+ danger: true,
408
+ });
383
409
  if (!sure)
384
410
  continue;
385
411
  const res = await disconnectAtServer(url, mine.entry.token ?? "");
@@ -813,7 +839,7 @@ async function adminSharesMenu(host, client, url, shares, users) {
813
839
  * ~25-row dump was unreadable (owner-directed regroup 2026-10-19).
814
840
  * Save semantics unchanged: root Exit saves when dirty; submenu Back
815
841
  * just returns. */
816
- export async function settingsDialog(host, startAtSession) {
842
+ export async function settingsDialog(host, startAtSession, startGroup) {
817
843
  let cfg = currentConfig();
818
844
  let dirty = false;
819
845
  // Write target (owner rule): global settings home by default; any other
@@ -967,17 +993,25 @@ export async function settingsDialog(host, startAtSession) {
967
993
  }
968
994
  }
969
995
  const binding = bindings[0];
996
+ if (binding?.lastError || binding?.ttlClamped) {
997
+ const bits = [];
998
+ if (binding?.ttlClamped)
999
+ bits.push(`TTL clamped ${binding.ttlClamped.requested}d -> ${binding.ttlClamped.effective}d by server/token policy`);
1000
+ if (binding?.lastError)
1001
+ bits.push(`last share error: ${binding.lastError}`);
1002
+ rows.push({ title: "Share status notes", value: "__notes", description: bits[0].slice(0, 60), disabled: true, help: `Engine-recorded notes for this session's share(s):\n\n${bits.join("\n")}\n\nThe clamp note appears when the server or your token's policy reduced a requested TTL. The error note is the engine's last create/attach refusal (quota, freeze, rate limit) - it clears on the next successful sync.` });
1003
+ }
970
1004
  rows.push({
971
1005
  title: "Session TTL override (days)",
972
1006
  value: "session:ttl",
973
1007
  description: binding?.opts?.ttlDays !== undefined ? String(binding.opts.ttlDays) : "(worker default)",
974
- help: `Per-session share lifetime. Overrides the config ttlDays for THIS session's pages; still capped by the server's MAX_TTL_DAYS and the token's maxTtlDays. Applied at the next create/attach.\n\nCurrent: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days` : "(none — config default)"}`,
1008
+ help: `Per-session share lifetime. Overrides the config ttlDays for THIS session's pages; still capped by the server's MAX_TTL_DAYS and the token's maxTtlDays. On a live share it applies immediately (shortening may end an older page at once); otherwise it is used when you share.\n\nCurrent: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days` : "(none — config default)"}`,
975
1009
  });
976
1010
  rows.push({
977
1011
  title: "Session read auth",
978
1012
  value: "session:auth",
979
1013
  description: binding?.opts?.authLogin ? `${binding.opts.authLogin} / ****` : "(config default)",
980
- help: `Login gate for THIS session's page only — viewers must log in (7-day cookie, lockout). Overrides the config read auth. Applied at the next create/attach; removing the login removes the gate.\n\nCurrent: ${binding?.opts?.authLogin ? `${binding.opts.authLogin} / (password set)` : "(none — config default applies)"}`,
1014
+ help: `Login gate for THIS session's page only — viewers must log in (7-day cookie, lockout). Overrides the config read auth; a login and password are both required (or neither). On a live share it applies immediately; otherwise when you share. Removing the login removes the gate.\n\nCurrent: ${binding?.opts?.authLogin ? `${binding.opts.authLogin} / (password set)` : "(none — config default applies)"}`,
981
1015
  });
982
1016
  return rows;
983
1017
  };
@@ -1161,11 +1195,11 @@ export async function settingsDialog(host, startAtSession) {
1161
1195
  });
1162
1196
  if (!pick || pick === "__cancel")
1163
1197
  return;
1164
- for (const s of serverEntries())
1165
- if (s.url !== pick)
1166
- saveServerEntry(s.url, { active: undefined });
1198
+ // fan-out add: activate the picked server WITHOUT deactivating the
1199
+ // current ones - this session (and new sessions) mirror everywhere
1167
1200
  saveServerEntry(pick, { active: true });
1168
- host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated — this session mirrors there within seconds.` });
1201
+ const nActive = serverEntries().filter((s) => s.active).length;
1202
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated (${nActive} active) - this session mirrors there within seconds.` });
1169
1203
  return;
1170
1204
  }
1171
1205
  if (choice === "session:unshare") {
@@ -1180,18 +1214,29 @@ export async function settingsDialog(host, startAtSession) {
1180
1214
  if (how === undefined || how === "__cancel")
1181
1215
  return;
1182
1216
  if (how === "leave") {
1183
- const all = await readBindings(host.client, root);
1217
+ const allB = await readBindings(host.client, root);
1184
1218
  const okPatch = await patchSessionBinding(host.client, root, (servers) => {
1185
- for (const b of all) {
1219
+ for (const b of allB) {
1186
1220
  if (!b.server)
1187
1221
  continue;
1188
- delete servers[b.server];
1189
- servers[b.server] = { optedOut: true };
1222
+ // keep id/secret on the opt-out marker: the snapshot stays
1223
+ // deletable later and re-sharing reuses the binding instead
1224
+ // of stalling on a secret-less marker (live-caught as "can
1225
+ // never unshare or re-share after leave-as-is")
1226
+ servers[b.server] = { ...servers[b.server], id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true };
1190
1227
  }
1191
1228
  });
1192
- host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is — snapshots until their TTLs." : "Could not update the binding." });
1229
+ host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is — snapshots until their TTLs (delete stays available here)." : "Could not update the binding." });
1193
1230
  return;
1194
1231
  }
1232
+ const sureAll = await showConfirm(host.ui, {
1233
+ title: "Delete every page?",
1234
+ message: `Permanently delete ${live.length} share page(s) and stop sharing?\nEvery link stops working for everyone. This cannot be undone.`,
1235
+ confirmLabel: "Delete every page",
1236
+ danger: true,
1237
+ });
1238
+ if (!sureAll)
1239
+ return;
1195
1240
  const result = await runUnshareFlow(host, startAtSession);
1196
1241
  host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message });
1197
1242
  return;
@@ -1211,17 +1256,30 @@ export async function settingsDialog(host, startAtSession) {
1211
1256
  }
1212
1257
  opts.ttlDays = Math.floor(days);
1213
1258
  }
1214
- await setShareFlag(host.client, root, opts);
1215
- host.ui.toast({ variant: "success", title: "OpenShare", message: opts.ttlDays !== undefined ? `Session TTL: ${opts.ttlDays}d (applies at the next attach/create)` : "Session TTL override cleared" });
1259
+ await setShareOpts(host.client, root, opts);
1260
+ const liveNow = live.length > 0;
1261
+ host.ui.toast({
1262
+ variant: "success",
1263
+ title: "OpenShare",
1264
+ message: liveNow
1265
+ ? `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
1266
+ : `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d` : "(default)"} - saved, applies when you share.`,
1267
+ });
1216
1268
  return;
1217
1269
  }
1218
1270
  if (choice === "session:auth") {
1219
- const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = use config default", value: current?.opts?.authLogin ?? "" });
1271
+ const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = no read auth", value: current?.opts?.authLogin ?? "" });
1220
1272
  if (login === undefined)
1221
1273
  return;
1222
- const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "empty = no password" });
1274
+ const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: login === "" ? "(unused)" : "password (required with a login)" });
1223
1275
  if (password === undefined)
1224
1276
  return;
1277
+ // both-or-none: the server builds a gate only from a full pair, and
1278
+ // rendering half a pair would show protection that does not exist
1279
+ if ((login === "") !== (password === "")) {
1280
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "Read auth needs both a login and a password - or neither (no gate)." });
1281
+ return;
1282
+ }
1225
1283
  const opts = { ...(current?.opts ?? {}) };
1226
1284
  if (login === "") {
1227
1285
  delete opts.authLogin;
@@ -1229,13 +1287,11 @@ export async function settingsDialog(host, startAtSession) {
1229
1287
  }
1230
1288
  else {
1231
1289
  opts.authLogin = login;
1232
- if (password !== "")
1233
- opts.authPassword = password;
1234
- else
1235
- delete opts.authPassword;
1290
+ opts.authPassword = password;
1236
1291
  }
1237
- await setShareFlag(host.client, root, opts);
1238
- host.ui.toast({ variant: "success", title: "OpenShare", message: "Session read auth saved (applies at the next attach/create)." });
1292
+ await setShareOpts(host.client, root, opts);
1293
+ const liveNow2 = live.length > 0;
1294
+ host.ui.toast({ variant: "success", title: "OpenShare", message: liveNow2 ? `Read auth ${login === "" ? "removed" : "saved"} - applied now.` : `Read auth ${login === "" ? "cleared" : "saved"} - applies when you share.` });
1239
1295
  return;
1240
1296
  }
1241
1297
  }
@@ -1254,6 +1310,10 @@ export async function settingsDialog(host, startAtSession) {
1254
1310
  }
1255
1311
  };
1256
1312
  // ---- root: compact group list with live summaries --------------------------
1313
+ // /share on a shared session lands HERE, straight in the session menu -
1314
+ // one selection sooner (Back still reaches the full root)
1315
+ if (startAtSession && startGroup === "session")
1316
+ await groupMenu("session");
1257
1317
  const seconds = (ms, fallback) => `${Math.round((ms ?? fallback) / 100) / 10}s`;
1258
1318
  while (true) {
1259
1319
  const servers = serverEntries(cfg);