@mirrowel/opencode-openshare 0.2.0 → 0.3.0-dev.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/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, fmtHours, parseTtlHours, hostOf, isConfigured, normServerUrl, probeAuthMode, probeWorker, patchSessionBinding, patchShareOpts, 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: {
@@ -89,22 +89,13 @@ const LABELS = {
89
89
  info: 'How the model name appears on the share page. "$Provider/$Model" renders e.g. "closedrouter/zai/glm-5.3". Any literal text is used as-is; empty string hides the model line entirely.',
90
90
  def: '"$Provider/$Model"',
91
91
  },
92
- ttlDays: {
93
- label: "TTL (days)",
94
- help: "Share lifetime; the worker caps it at its MAX_TTL_DAYS.",
92
+ ttlHours: {
93
+ label: "TTL (hours)",
94
+ help: "Share lifetime in hours (2d writes 48); capped server-side.",
95
95
  kind: "number",
96
96
  section: "Sharing",
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
- def: "worker cap (90 on the reference deployment)",
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",
97
+ info: "How long pages live before the server's sweeper deletes them, in HOURS. Capped server-side: the deployment's MAX_TTL_HOURS (legacy MAX_TTL_DAYS converted) and the token's limit both apply; the floor is one hour.\n\nA per-session override lives in the This-session section; it is sent at create/attach time. The session prompt takes 6h / 2d style input.",
98
+ def: "worker cap (2160h = 90d on the reference deployment)",
108
99
  },
109
100
  authLogin: {
110
101
  label: "Read auth: login",
@@ -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
  }
@@ -176,8 +165,8 @@ async function editKey(host, cfg, key) {
176
165
  const meta = LABELS[key];
177
166
  if (!meta)
178
167
  return undefined;
179
- // selection-backed settings (owner: "setting share mode i need to enter it
180
- // manually? No selection?") — fixed vocabularies become menus
168
+ // selection-backed settings: fixed vocabularies become menus instead of
169
+ // asking for a typed value
181
170
  if (meta.options) {
182
171
  const current = String(cfg[key] ?? "");
183
172
  const picked = await showSelect(host.ui, {
@@ -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;
@@ -225,10 +214,14 @@ async function editKey(host, cfg, key) {
225
214
  }
226
215
  return next;
227
216
  }
228
- /** Connect (add + enroll) a server — the /share first-run flow and the
217
+ /** Connect (add + enroll) a server - the /share first-run flow and the
229
218
  * settings "Connect to a server" row share it. Prompts the URL, probes
230
- * the auth mode, prompts a code for private servers, enrolls, persists
231
- * the token and makes it the active server. */
219
+ * the auth mode, then ASKS ONCE: onboard to an account, or stay anonymous
220
+ * (public servers only; anonymous shares sit under the server's global
221
+ * caps). Onboarding detects whether a code is needed and only then prompts
222
+ * for one (a code already in config is used without asking). The outcome is
223
+ * remembered per server: a token means onboarded, an anonymous flag means
224
+ * declined. */
232
225
  export async function connectServerFlow(host) {
233
226
  const raw = await showPrompt(host.ui, { title: "Share server URL", placeholder: "https://your-openshare-host" });
234
227
  if (raw === undefined || raw === "")
@@ -236,33 +229,84 @@ export async function connectServerFlow(host) {
236
229
  const url = normServerUrl(raw);
237
230
  if (!/^https?:\/\//.test(url))
238
231
  return { ok: false, message: "URL must start with http:// or https://" };
239
- host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}…` });
232
+ host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` });
240
233
  const probe = await probeAuthMode(url);
241
234
  if (!probe.ok)
242
235
  return { ok: false, message: `${url}: ${probe.detail}` };
243
- let code = "";
244
- if (probe.mode === "private") {
245
- const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_… (from the hoster)" });
236
+ const existing = serverEntries().find((sv) => sv.url === url)?.entry ?? {};
237
+ const deactivateOthers = () => {
238
+ for (const other of serverEntries()) {
239
+ if (other.url !== url)
240
+ saveServerEntry(other.url, { active: undefined });
241
+ }
242
+ };
243
+ if (probe.mode === "public") {
244
+ // the ask: an account is a real thing (own shares, own limits,
245
+ // revocable credentials) - but anonymous sharing works too
246
+ const picked = await showSelect(host.ui, {
247
+ title: `${url} - account?`,
248
+ options: [
249
+ {
250
+ value: "onboard",
251
+ title: "Onboard (get an account)",
252
+ description: "your own user, tokens and share limits; survives the server's anonymous caps",
253
+ help: "An account gives you a revocable identity with your own share registry and limits. Depending on the server you may need a one-time access code from the hoster - you'll be asked only if it's required.",
254
+ },
255
+ {
256
+ value: "anon",
257
+ title: "Stay anonymous",
258
+ description: "share without an account; counts against the server-wide anonymous limits",
259
+ help: "No identity, no token to manage. Shares are ownerless: whoever holds the share's secret controls them, and the server may throttle anonymous creates globally. You can onboard later from Servers -> the server -> Enroll now.",
260
+ },
261
+ ],
262
+ });
263
+ if (picked === "anon") {
264
+ saveServerEntry(url, { ...(existing.token ? {} : { anonymous: true }), label: existing.label, active: true });
265
+ return { ok: true, message: `Connected to ${url} anonymously - sharing works, no account.`, url };
266
+ }
267
+ if (picked === undefined || picked === null)
268
+ return { ok: false, message: "Cancelled." };
269
+ // onboard: try without a code (codeless servers), fall through to
270
+ // the code prompt exactly when the server demands one
271
+ let code = existing.code ?? "";
272
+ if (!code) {
273
+ const free = await enrollAtServer(url, "");
274
+ if (free.ok) {
275
+ deactivateOthers();
276
+ saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined, active: true });
277
+ return { ok: true, message: `Connected to ${url} as "${free.label}" - sharing goes there now.`, url };
278
+ }
279
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
280
+ if (typed === undefined)
281
+ return { ok: false, message: "Cancelled - this server needs a code to onboard." };
282
+ code = typed.trim();
283
+ }
284
+ const res = await enrollAtServer(url, code);
285
+ if (!res.ok)
286
+ return { ok: false, message: `Enrollment refused: ${res.error}` };
287
+ deactivateOthers();
288
+ saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, anonymous: undefined, active: true });
289
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
290
+ }
291
+ // private server: a code is required, full stop
292
+ let code = existing.code ?? "";
293
+ if (!code) {
294
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
246
295
  if (typed === undefined)
247
- return { ok: false, message: "Cancelled — private servers need a code." };
296
+ return { ok: false, message: "Cancelled - private servers need a code." };
248
297
  code = typed.trim();
249
298
  }
250
299
  const res = await enrollAtServer(url, code);
251
300
  if (!res.ok)
252
301
  return { ok: false, message: `Enrollment refused: ${res.error}` };
253
- // single active server: deactivate the others in the settings home,
254
- // then persist the new membership as active
255
- for (const other of serverEntries()) {
256
- if (other.url !== url)
257
- saveServerEntry(other.url, { active: undefined });
258
- }
302
+ deactivateOthers();
259
303
  saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true });
260
- return { ok: true, message: `Connected to ${url} as "${res.label}" — sharing goes there now.`, url };
304
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
261
305
  }
262
306
  /** Per-server submenu: identity status + membership actions. Root-token
263
- * holders get "Get a user token" (admin mints their own user identity —
264
- * root stays as adminToken; being admin and being a user are NOT mutually
265
- * exclusive, live-caught). */
307
+ * holders get "Get a user token" (the admin mints their own user identity;
308
+ * root stays as adminToken, since being admin and being a user are not
309
+ * mutually exclusive). */
266
310
  async function serverMenu(host, url) {
267
311
  while (true) {
268
312
  const entries = serverEntries();
@@ -276,7 +320,9 @@ async function serverMenu(host, url) {
276
320
  ? `token ${mask(mine.entry.token)}`
277
321
  : mine.entry.code
278
322
  ? "no token (code saved, enrolls on demand)"
279
- : "no token (public server, enrolls on demand)";
323
+ : mine.entry.anonymous
324
+ ? "anonymous (declined onboarding)"
325
+ : "no token (public server, shares anonymously)";
280
326
  const options = [
281
327
  { title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} · ${cred}${mine.active ? " · ACTIVE" : ""}`, disabled: true },
282
328
  ];
@@ -294,6 +340,8 @@ async function serverMenu(host, url) {
294
340
  options.push({ title: "Enroll now", value: "enroll", description: holdsRoot ? "join as a user via code/public enrollment" : "get a user token immediately" });
295
341
  if (mine.entry.token && !holdsRoot)
296
342
  options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" });
343
+ if (mine.entry.token && !holdsRoot)
344
+ options.push({ title: "Your account", value: "account", description: "limits, token expiry, your shares" });
297
345
  if (mine.entry.token && !holdsRoot)
298
346
  options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true });
299
347
  options.push({
@@ -309,12 +357,16 @@ async function serverMenu(host, url) {
309
357
  if (choice === undefined || choice === "__back")
310
358
  return;
311
359
  if (choice === "active") {
312
- for (const other of entries) {
313
- if (other.url !== url)
314
- saveServerEntry(other.url, { active: undefined });
315
- }
360
+ // make-primary WITHOUT collapsing fan-out: other explicit actives
361
+ // stay active (the engine targets every active entry); silently
362
+ // deactivating them destroyed hand-built multi-server setups
316
363
  saveServerEntry(url, { active: true });
317
- host.ui.toast({ variant: "success", title: "OpenShare", message: `${url} is now the active server.` });
364
+ const stillActive = serverEntries().filter((s) => s.active);
365
+ host.ui.toast({
366
+ variant: "success",
367
+ title: "OpenShare",
368
+ 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.`,
369
+ });
318
370
  return;
319
371
  }
320
372
  if (choice === "adopt") {
@@ -349,8 +401,20 @@ async function serverMenu(host, url) {
349
401
  continue;
350
402
  }
351
403
  let code = mine.entry.code ?? "";
352
- if (probe.mode === "private" && !code) {
353
- const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_… (from the hoster)" });
404
+ if (!code) {
405
+ // codeless first: only prompt when the server actually demands
406
+ // a code (public + codeless-on boards silently, like connect)
407
+ const free = await enrollAtServer(url, "");
408
+ if (free.ok) {
409
+ saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined });
410
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${free.label}".` });
411
+ continue;
412
+ }
413
+ if (!/code required/i.test(free.error ?? "")) {
414
+ await showInfo(host.ui, { title: "Enrollment refused", message: free.error ?? "unknown error" });
415
+ continue;
416
+ }
417
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
354
418
  if (typed === undefined)
355
419
  continue;
356
420
  code = typed.trim();
@@ -359,7 +423,7 @@ async function serverMenu(host, url) {
359
423
  }
360
424
  const res = await enrollAtServer(url, code);
361
425
  if (res.ok) {
362
- saveServerEntry(url, { token: res.token, label: res.label });
426
+ saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined });
363
427
  host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` });
364
428
  }
365
429
  else {
@@ -367,6 +431,32 @@ async function serverMenu(host, url) {
367
431
  }
368
432
  continue;
369
433
  }
434
+ if (choice === "account") {
435
+ const me = meClient(url, mine.entry.token ?? "");
436
+ const [meRes, sharesRes] = await Promise.all([me.me(), me.myShares()]);
437
+ if (!meRes.ok) {
438
+ 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.` });
439
+ continue;
440
+ }
441
+ const d = meRes.data;
442
+ const lim = d.token?.limits ?? {};
443
+ const fmt = (v) => (v ? String(v) : "unlimited");
444
+ const lines = [
445
+ `User: ${d.label} (${d.userId})`,
446
+ `Token: ${d.token?.label ?? "?"} - expires ${d.token?.expiresAt ? new Date(d.token.expiresAt).toISOString().slice(0, 10) : "never"}`,
447
+ `Limits: live shares ${fmt(lim.maxLiveShares)} - max TTL ${fmt(lim.maxTtlDays)}d - daily creates ${fmt(lim.dailyCreates)}`,
448
+ ];
449
+ if (sharesRes.ok) {
450
+ const shares = sharesRes.data?.shares ?? [];
451
+ lines.push(`Shares: ${shares.length} (${shares.filter((s) => s.status === "live").length} live)`);
452
+ for (const s of shares.slice(0, 10))
453
+ lines.push(` ${s.id} - ${s.status} - ${new Date(s.createdAt).toISOString().slice(0, 10)}`);
454
+ if (shares.length > 10)
455
+ lines.push(` ...and ${shares.length - 10} more`);
456
+ }
457
+ await showInfo(host.ui, { title: "Your account", message: lines.join("\n") });
458
+ continue;
459
+ }
370
460
  if (choice === "rotate") {
371
461
  const res = await rotateAtServer(url, mine.entry.token ?? "");
372
462
  if (res.ok && res.token) {
@@ -379,7 +469,12 @@ async function serverMenu(host, url) {
379
469
  continue;
380
470
  }
381
471
  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 });
472
+ const sure = await showConfirm(host.ui, {
473
+ title: "Disconnect",
474
+ 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.`,
475
+ confirmLabel: "Disconnect",
476
+ danger: true,
477
+ });
383
478
  if (!sure)
384
479
  continue;
385
480
  const res = await disconnectAtServer(url, mine.entry.token ?? "");
@@ -543,9 +638,9 @@ async function withBusy(host, title, work) {
543
638
  catch { }
544
639
  doneBusy?.();
545
640
  }
546
- /** Server info, the readable version (live-caught: the raw JSON policy line
547
- * escaped the dialog border): formatted policy lines, auth explained in
548
- * words, YOUR standing on this server, and follow-up actions. */
641
+ /** Server info, the readable version (the raw JSON policy line escapes
642
+ * the dialog border): formatted policy lines, auth explained in words,
643
+ * YOUR standing on this server, and follow-up actions. */
549
644
  async function serverInfoScreen(host, url, client) {
550
645
  const h = await client.health();
551
646
  if (!h.ok) {
@@ -809,16 +904,15 @@ async function adminSharesMenu(host, client, url, shares, users) {
809
904
  }
810
905
  }
811
906
  /** The settings dialog: a compact ROOT of group entries (with live
812
- * summaries) opening focused submenus. Two-level by design — one flat
813
- * ~25-row dump was unreadable (owner-directed regroup 2026-10-19).
814
- * Save semantics unchanged: root Exit saves when dirty; submenu Back
815
- * just returns. */
816
- export async function settingsDialog(host, startAtSession) {
907
+ * summaries) opening focused submenus. Two-level by design: one flat ~25-row
908
+ * dump is unreadable. Save semantics unchanged: root Exit saves when dirty;
909
+ * submenu Back just returns. */
910
+ export async function settingsDialog(host, startAtSession, startGroup) {
817
911
  let cfg = currentConfig();
818
912
  let dirty = false;
819
- // Write target (owner rule): global settings home by default; any other
820
- // existing file in the read chain may be chosen explicitly. The TUI
821
- // never creates deploy-time override files.
913
+ // Write target: global settings home by default; any other existing
914
+ // file in the read chain may be chosen explicitly. The TUI never
915
+ // creates deploy-time override files.
822
916
  const candidates = writeCandidates(moduleDir());
823
917
  let writeTarget = candidates[0];
824
918
  const sources = configSourceMap(moduleDir());
@@ -967,17 +1061,25 @@ export async function settingsDialog(host, startAtSession) {
967
1061
  }
968
1062
  }
969
1063
  const binding = bindings[0];
1064
+ if (binding?.lastError || binding?.ttlClamped) {
1065
+ const bits = [];
1066
+ if (binding?.ttlClamped)
1067
+ bits.push(`TTL clamped ${binding.ttlClamped.requested}d -> ${binding.ttlClamped.effective}d by server/token policy`);
1068
+ if (binding?.lastError)
1069
+ bits.push(`last share error: ${binding.lastError}`);
1070
+ 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.` });
1071
+ }
970
1072
  rows.push({
971
1073
  title: "Session TTL override (days)",
972
1074
  value: "session:ttl",
973
- 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)"}`,
1075
+ description: binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(worker default)",
1076
+ help: `Per-session share lifetime in HOURS (prompt accepts 6h / 2d / 48). Overrides the config TTL for THIS session's pages; still capped server-side. 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?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(none — config default)"}`,
975
1077
  });
976
1078
  rows.push({
977
1079
  title: "Session read auth",
978
1080
  value: "session:auth",
979
1081
  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)"}`,
1082
+ 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
1083
  });
982
1084
  return rows;
983
1085
  };
@@ -1161,11 +1263,11 @@ export async function settingsDialog(host, startAtSession) {
1161
1263
  });
1162
1264
  if (!pick || pick === "__cancel")
1163
1265
  return;
1164
- for (const s of serverEntries())
1165
- if (s.url !== pick)
1166
- saveServerEntry(s.url, { active: undefined });
1266
+ // fan-out add: activate the picked server WITHOUT deactivating the
1267
+ // current ones - this session (and new sessions) mirror everywhere
1167
1268
  saveServerEntry(pick, { active: true });
1168
- host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated — this session mirrors there within seconds.` });
1269
+ const nActive = serverEntries().filter((s) => s.active).length;
1270
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated (${nActive} active) - this session mirrors there within seconds.` });
1169
1271
  return;
1170
1272
  }
1171
1273
  if (choice === "session:unshare") {
@@ -1180,48 +1282,79 @@ export async function settingsDialog(host, startAtSession) {
1180
1282
  if (how === undefined || how === "__cancel")
1181
1283
  return;
1182
1284
  if (how === "leave") {
1183
- const all = await readBindings(host.client, root);
1285
+ const allB = await readBindings(host.client, root);
1184
1286
  const okPatch = await patchSessionBinding(host.client, root, (servers) => {
1185
- for (const b of all) {
1287
+ for (const b of allB) {
1186
1288
  if (!b.server)
1187
1289
  continue;
1188
- delete servers[b.server];
1189
- servers[b.server] = { optedOut: true };
1290
+ // keep id/secret on the opt-out marker: the snapshot stays
1291
+ // deletable later, and re-sharing reuses the binding instead
1292
+ // of stalling on a secret-less marker that can never unshare
1293
+ // or re-share again.
1294
+ servers[b.server] = { ...servers[b.server], id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true };
1190
1295
  }
1191
1296
  });
1192
- host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is — snapshots until their TTLs." : "Could not update the binding." });
1297
+ 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
1298
  return;
1194
1299
  }
1300
+ const sureAll = await showConfirm(host.ui, {
1301
+ title: "Delete every page?",
1302
+ message: `Permanently delete ${live.length} share page(s) and stop sharing?\nEvery link stops working for everyone. This cannot be undone.`,
1303
+ confirmLabel: "Delete every page",
1304
+ danger: true,
1305
+ });
1306
+ if (!sureAll)
1307
+ return;
1195
1308
  const result = await runUnshareFlow(host, startAtSession);
1196
1309
  host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message });
1197
1310
  return;
1198
1311
  }
1199
1312
  if (choice === "session:ttl") {
1200
- const raw = await showPrompt(host.ui, { title: "Session TTL (days)", placeholder: "days", value: current?.opts?.ttlDays !== undefined ? String(current.opts.ttlDays) : "" });
1313
+ const raw = await showPrompt(host.ui, { title: "Session TTL", placeholder: "hours: 6h, 2d or 48", value: current?.opts?.ttlHours !== undefined ? fmtHours(current.opts.ttlHours) : "" });
1201
1314
  if (raw === undefined)
1202
1315
  return;
1203
1316
  const opts = { ...(current?.opts ?? {}) };
1204
1317
  if (raw === "")
1205
- delete opts.ttlDays;
1318
+ delete opts.ttlHours;
1206
1319
  else {
1207
- const days = Number(raw);
1208
- if (!Number.isFinite(days) || days <= 0) {
1209
- host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of days." });
1320
+ const hours = parseTtlHours(raw);
1321
+ if (hours === undefined) {
1322
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be 6h / 2d style or a positive number of hours." });
1210
1323
  return;
1211
1324
  }
1212
- opts.ttlDays = Math.floor(days);
1325
+ opts.ttlHours = hours;
1213
1326
  }
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" });
1327
+ await setShareOpts(host.client, root, opts);
1328
+ const liveNow = live.length > 0;
1329
+ if (liveNow) {
1330
+ // REST push (#13): immediate on every server, socket or not
1331
+ const results = await Promise.all(live.map((b) => b.id && b.secret && b.server ? patchShareOpts(b.server, b.id, opts.ttlHours !== undefined ? { ttlHours: opts.ttlHours } : {}, b.secret) : Promise.resolve({ ok: false })));
1332
+ const failed = results.filter((r) => !r.ok).length;
1333
+ if (failed)
1334
+ host.ui.toast({ variant: "error", title: "OpenShare", message: `TTL applied on ${results.length - failed}/${results.length} server(s) - the rest sync on next connect.` });
1335
+ }
1336
+ host.ui.toast({
1337
+ variant: "success",
1338
+ title: "OpenShare",
1339
+ message: liveNow
1340
+ ? `Session TTL: ${opts.ttlHours !== undefined ? `${fmtHours(opts.ttlHours)} - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
1341
+ : `Session TTL: ${opts.ttlHours !== undefined ? fmtHours(opts.ttlHours) : "(default)"} - saved, applies when you share.`,
1342
+ });
1216
1343
  return;
1217
1344
  }
1218
1345
  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 ?? "" });
1346
+ const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = no read auth", value: current?.opts?.authLogin ?? "" });
1220
1347
  if (login === undefined)
1221
1348
  return;
1222
- const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "empty = no password" });
1349
+ const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: login === "" ? "(unused)" : "password (required with a login)" });
1223
1350
  if (password === undefined)
1224
1351
  return;
1352
+ // both-or-none: the server builds a gate only from a full pair, and
1353
+ // rendering half a pair would show protection that does not exist
1354
+ if ((login === "") !== (password === "")) {
1355
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "Read auth needs both a login and a password - or neither (no gate)." });
1356
+ return;
1357
+ }
1225
1358
  const opts = { ...(current?.opts ?? {}) };
1226
1359
  if (login === "") {
1227
1360
  delete opts.authLogin;
@@ -1229,13 +1362,18 @@ export async function settingsDialog(host, startAtSession) {
1229
1362
  }
1230
1363
  else {
1231
1364
  opts.authLogin = login;
1232
- if (password !== "")
1233
- opts.authPassword = password;
1234
- else
1235
- delete opts.authPassword;
1365
+ opts.authPassword = password;
1366
+ }
1367
+ await setShareOpts(host.client, root, opts);
1368
+ const liveNow2 = live.length > 0;
1369
+ if (liveNow2) {
1370
+ const pair = login === "" ? { authLogin: "", authPassword: "" } : { authLogin: login, authPassword: password };
1371
+ const results2 = await Promise.all(live.map((b) => b.id && b.secret && b.server ? patchShareOpts(b.server, b.id, pair, b.secret) : Promise.resolve({ ok: false })));
1372
+ const failed2 = results2.filter((r) => !r.ok).length;
1373
+ if (failed2)
1374
+ host.ui.toast({ variant: "error", title: "OpenShare", message: `Read auth applied on ${results2.length - failed2}/${results2.length} server(s) - the rest sync on next connect.` });
1236
1375
  }
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)." });
1376
+ 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
1377
  return;
1240
1378
  }
1241
1379
  }
@@ -1254,6 +1392,10 @@ export async function settingsDialog(host, startAtSession) {
1254
1392
  }
1255
1393
  };
1256
1394
  // ---- root: compact group list with live summaries --------------------------
1395
+ // /share on a shared session lands HERE, straight in the session menu -
1396
+ // one selection sooner (Back still reaches the full root)
1397
+ if (startAtSession && startGroup === "session")
1398
+ await groupMenu("session");
1257
1399
  const seconds = (ms, fallback) => `${Math.round((ms ?? fallback) / 100) / 10}s`;
1258
1400
  while (true) {
1259
1401
  const servers = serverEntries(cfg);
@@ -1280,7 +1422,7 @@ export async function settingsDialog(host, startAtSession) {
1280
1422
  });
1281
1423
  groups.push({
1282
1424
  id: "sharing",
1283
- summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${cfg.ttlDays ?? "server cap"}`,
1425
+ summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${fmtHours(cfg.ttlHours ?? 2160)}`,
1284
1426
  help: `What and how sessions stream: share mode (all/manual), sync cadence, the completed-delay, the model label on pages, share TTL, and the viewer's task-tab behavior.\n\nCadence values are clamped by the server's policy window.`,
1285
1427
  });
1286
1428
  groups.push({