@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/src/settings.ts CHANGED
@@ -15,12 +15,15 @@ import {
15
15
  deleteServerEntry,
16
16
  disconnectAtServer,
17
17
  enrollAtServer,
18
+ fmtHours,
19
+ parseTtlHours,
18
20
  hostOf,
19
21
  isConfigured,
20
22
  normServerUrl,
21
23
  probeAuthMode,
22
24
  probeWorker,
23
25
  patchSessionBinding,
26
+ patchShareOpts,
24
27
  readBinding,
25
28
  readBindings,
26
29
  resolveRoot,
@@ -28,8 +31,10 @@ import {
28
31
  runUnshareFlow,
29
32
  saveConfig,
30
33
  saveServerEntry,
34
+ meClient,
31
35
  serverEntries,
32
36
  setShareFlag,
37
+ setShareOpts,
33
38
  shareWithConsent,
34
39
  workerDeleteShare,
35
40
  type AdminShare,
@@ -113,11 +118,11 @@ const LABELS: Record<string, LabelMeta> = {
113
118
  },
114
119
  shareMode: {
115
120
  label: "Share mode",
116
- help: '"all" shares every session; "manual" needs /share. Default: TUI presence.',
121
+ help: '"all" shares every session; "manual" needs /share. Default: TUI presence. Takes effect after opencode restarts.',
117
122
  kind: "text",
118
123
  options: ["auto", "all", "manual"],
119
124
  section: "Sharing",
120
- 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.',
125
+ 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.',
121
126
  def: "auto (TUI presence)",
122
127
  },
123
128
  debounceMs: {
@@ -144,22 +149,13 @@ const LABELS: Record<string, LabelMeta> = {
144
149
  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.',
145
150
  def: '"$Provider/$Model"',
146
151
  },
147
- ttlDays: {
148
- label: "TTL (days)",
149
- help: "Share lifetime; the worker caps it at its MAX_TTL_DAYS.",
152
+ ttlHours: {
153
+ label: "TTL (hours)",
154
+ help: "Share lifetime in hours (2d writes 48); capped server-side.",
150
155
  kind: "number",
151
156
  section: "Sharing",
152
- 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.",
153
- def: "worker cap (90 on the reference deployment)",
154
- },
155
- openTasksInNewTab: {
156
- label: "Open tasks in new tab",
157
- help: "Viewer: task rows open subagent pages in a new tab.",
158
- kind: "text",
159
- options: ["true", "false"],
160
- section: "Sharing",
161
- info: "Viewer behavior only: clicking a task (subagent) row opens its sub-page in a new browser tab instead of navigating. true/false.",
162
- def: "true",
157
+ 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.",
158
+ def: "worker cap (2160h = 90d on the reference deployment)",
163
159
  },
164
160
  authLogin: {
165
161
  label: "Read auth: login",
@@ -218,8 +214,7 @@ function helpForKey(key: string, cfg: ShareConfig, sources: Record<string, strin
218
214
 
219
215
  function displayValue(key: string, cfg: ShareConfig): string {
220
216
  if (key === "createToken" || key === "authPassword") return mask(String(cfg[key] ?? ""))
221
- if (key === "openTasksInNewTab") return String(cfg.openTasksInNewTab !== false)
222
- if (key === "announceStderr") return String(cfg.announceStderr !== false)
217
+ if (key === "announceStderr") return cfg.announceStderr !== false ? "on" : "off"
223
218
  const value = (cfg as unknown as Record<string, unknown>)[key]
224
219
  return value === undefined || value === "" ? "(not set)" : String(value)
225
220
  }
@@ -227,8 +222,8 @@ function displayValue(key: string, cfg: ShareConfig): string {
227
222
  async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promise<ShareConfig | undefined> {
228
223
  const meta = LABELS[key]
229
224
  if (!meta) return undefined
230
- // selection-backed settings (owner: "setting share mode i need to enter it
231
- // manually? No selection?") — fixed vocabularies become menus
225
+ // selection-backed settings: fixed vocabularies become menus instead of
226
+ // asking for a typed value
232
227
  if (meta.options) {
233
228
  const current = String((cfg as unknown as Record<string, unknown>)[key] ?? "")
234
229
  const picked = await showSelect(host.ui, {
@@ -243,7 +238,7 @@ async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promi
243
238
  if (picked === undefined) return undefined
244
239
  const next = { ...cfg } as unknown as Record<string, unknown>
245
240
  if (key === "shareMode") next[key] = picked === "auto" ? undefined : picked
246
- else if (key === "openTasksInNewTab" || key === "announceStderr") next[key] = picked === "true"
241
+ else if (key === "announceStderr") next[key] = picked === "true"
247
242
  else next[key] = picked
248
243
  return next as ShareConfig
249
244
  }
@@ -270,39 +265,91 @@ async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promi
270
265
  return next as ShareConfig
271
266
  }
272
267
 
273
- /** Connect (add + enroll) a server — the /share first-run flow and the
268
+ /** Connect (add + enroll) a server - the /share first-run flow and the
274
269
  * settings "Connect to a server" row share it. Prompts the URL, probes
275
- * the auth mode, prompts a code for private servers, enrolls, persists
276
- * the token and makes it the active server. */
270
+ * the auth mode, then ASKS ONCE: onboard to an account, or stay anonymous
271
+ * (public servers only; anonymous shares sit under the server's global
272
+ * caps). Onboarding detects whether a code is needed and only then prompts
273
+ * for one (a code already in config is used without asking). The outcome is
274
+ * remembered per server: a token means onboarded, an anonymous flag means
275
+ * declined. */
277
276
  export async function connectServerFlow(host: ShareHostApi): Promise<{ ok: boolean; message: string; url?: string }> {
278
277
  const raw = await showPrompt(host.ui, { title: "Share server URL", placeholder: "https://your-openshare-host" })
279
278
  if (raw === undefined || raw === "") return { ok: false, message: "Cancelled." }
280
279
  const url = normServerUrl(raw)
281
280
  if (!/^https?:\/\//.test(url)) return { ok: false, message: "URL must start with http:// or https://" }
282
- host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}…` })
281
+ host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` })
283
282
  const probe = await probeAuthMode(url)
284
283
  if (!probe.ok) return { ok: false, message: `${url}: ${probe.detail}` }
285
- let code = ""
286
- if (probe.mode === "private") {
287
- const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_… (from the hoster)" })
288
- if (typed === undefined) return { ok: false, message: "Cancelled — private servers need a code." }
284
+ const existing = serverEntries().find((sv) => sv.url === url)?.entry ?? {}
285
+ const deactivateOthers = () => {
286
+ for (const other of serverEntries()) {
287
+ if (other.url !== url) saveServerEntry(other.url, { active: undefined })
288
+ }
289
+ }
290
+ if (probe.mode === "public") {
291
+ // the ask: an account is a real thing (own shares, own limits,
292
+ // revocable credentials) - but anonymous sharing works too
293
+ const picked = await showSelect(host.ui, {
294
+ title: `${url} - account?`,
295
+ options: [
296
+ {
297
+ value: "onboard",
298
+ title: "Onboard (get an account)",
299
+ description: "your own user, tokens and share limits; survives the server's anonymous caps",
300
+ 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.",
301
+ },
302
+ {
303
+ value: "anon",
304
+ title: "Stay anonymous",
305
+ description: "share without an account; counts against the server-wide anonymous limits",
306
+ 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.",
307
+ },
308
+ ],
309
+ })
310
+ if (picked === "anon") {
311
+ saveServerEntry(url, { ...(existing.token ? {} : { anonymous: true }), label: existing.label, active: true })
312
+ return { ok: true, message: `Connected to ${url} anonymously - sharing works, no account.`, url }
313
+ }
314
+ if (picked === undefined || picked === null) return { ok: false, message: "Cancelled." }
315
+ // onboard: try without a code (codeless servers), fall through to
316
+ // the code prompt exactly when the server demands one
317
+ let code = existing.code ?? ""
318
+ if (!code) {
319
+ const free = await enrollAtServer(url, "")
320
+ if (free.ok) {
321
+ deactivateOthers()
322
+ saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined, active: true })
323
+ return { ok: true, message: `Connected to ${url} as "${free.label}" - sharing goes there now.`, url }
324
+ }
325
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" })
326
+ if (typed === undefined) return { ok: false, message: "Cancelled - this server needs a code to onboard." }
327
+ code = typed.trim()
328
+ }
329
+ const res = await enrollAtServer(url, code)
330
+ if (!res.ok) return { ok: false, message: `Enrollment refused: ${res.error}` }
331
+ deactivateOthers()
332
+ saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, anonymous: undefined, active: true })
333
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url }
334
+ }
335
+ // private server: a code is required, full stop
336
+ let code = existing.code ?? ""
337
+ if (!code) {
338
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" })
339
+ if (typed === undefined) return { ok: false, message: "Cancelled - private servers need a code." }
289
340
  code = typed.trim()
290
341
  }
291
342
  const res = await enrollAtServer(url, code)
292
343
  if (!res.ok) return { ok: false, message: `Enrollment refused: ${res.error}` }
293
- // single active server: deactivate the others in the settings home,
294
- // then persist the new membership as active
295
- for (const other of serverEntries()) {
296
- if (other.url !== url) saveServerEntry(other.url, { active: undefined })
297
- }
344
+ deactivateOthers()
298
345
  saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true })
299
- return { ok: true, message: `Connected to ${url} as "${res.label}" — sharing goes there now.`, url }
346
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url }
300
347
  }
301
348
 
302
349
  /** Per-server submenu: identity status + membership actions. Root-token
303
- * holders get "Get a user token" (admin mints their own user identity —
304
- * root stays as adminToken; being admin and being a user are NOT mutually
305
- * exclusive, live-caught). */
350
+ * holders get "Get a user token" (the admin mints their own user identity;
351
+ * root stays as adminToken, since being admin and being a user are not
352
+ * mutually exclusive). */
306
353
  async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
307
354
  while (true) {
308
355
  const entries = serverEntries()
@@ -315,7 +362,9 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
315
362
  ? `token ${mask(mine.entry.token)}`
316
363
  : mine.entry.code
317
364
  ? "no token (code saved, enrolls on demand)"
318
- : "no token (public server, enrolls on demand)"
365
+ : mine.entry.anonymous
366
+ ? "anonymous (declined onboarding)"
367
+ : "no token (public server, shares anonymously)"
319
368
  const options: ShareDialogSelectOption<string>[] = [
320
369
  { title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} · ${cred}${mine.active ? " · ACTIVE" : ""}`, disabled: true },
321
370
  ]
@@ -330,6 +379,7 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
330
379
  }
331
380
  if (!mine.entry.token || holdsRoot) options.push({ title: "Enroll now", value: "enroll", description: holdsRoot ? "join as a user via code/public enrollment" : "get a user token immediately" })
332
381
  if (mine.entry.token && !holdsRoot) options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" })
382
+ if (mine.entry.token && !holdsRoot) options.push({ title: "Your account", value: "account", description: "limits, token expiry, your shares" })
333
383
  if (mine.entry.token && !holdsRoot) options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true })
334
384
  options.push({
335
385
  title: "Admin panel",
@@ -345,11 +395,16 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
345
395
  if (choice === undefined || choice === "__back") return
346
396
 
347
397
  if (choice === "active") {
348
- for (const other of entries) {
349
- if (other.url !== url) saveServerEntry(other.url, { active: undefined })
350
- }
398
+ // make-primary WITHOUT collapsing fan-out: other explicit actives
399
+ // stay active (the engine targets every active entry); silently
400
+ // deactivating them destroyed hand-built multi-server setups
351
401
  saveServerEntry(url, { active: true })
352
- host.ui.toast({ variant: "success", title: "OpenShare", message: `${url} is now the active server.` })
402
+ const stillActive = serverEntries().filter((s) => s.active)
403
+ host.ui.toast({
404
+ variant: "success",
405
+ title: "OpenShare",
406
+ 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.`,
407
+ })
353
408
  return
354
409
  }
355
410
  if (choice === "adopt") {
@@ -383,21 +438,57 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
383
438
  continue
384
439
  }
385
440
  let code = mine.entry.code ?? ""
386
- if (probe.mode === "private" && !code) {
387
- const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_… (from the hoster)" })
441
+ if (!code) {
442
+ // codeless first: only prompt when the server actually demands
443
+ // a code (public + codeless-on boards silently, like connect)
444
+ const free = await enrollAtServer(url, "")
445
+ if (free.ok) {
446
+ saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined })
447
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${free.label}".` })
448
+ continue
449
+ }
450
+ if (!/code required/i.test(free.error ?? "")) {
451
+ await showInfo(host.ui, { title: "Enrollment refused", message: free.error ?? "unknown error" })
452
+ continue
453
+ }
454
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" })
388
455
  if (typed === undefined) continue
389
456
  code = typed.trim()
390
457
  if (code) saveServerEntry(url, { code })
391
458
  }
392
459
  const res = await enrollAtServer(url, code)
393
460
  if (res.ok) {
394
- saveServerEntry(url, { token: res.token, label: res.label })
461
+ saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined })
395
462
  host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` })
396
463
  } else {
397
464
  await showInfo(host.ui, { title: "Enrollment refused", message: res.error ?? "unknown error" })
398
465
  }
399
466
  continue
400
467
  }
468
+ if (choice === "account") {
469
+ const me = meClient(url, mine.entry.token ?? "")
470
+ const [meRes, sharesRes] = await Promise.all([me.me(), me.myShares()])
471
+ if (!meRes.ok) {
472
+ 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.` })
473
+ continue
474
+ }
475
+ const d = meRes.data!
476
+ const lim = d.token?.limits ?? {}
477
+ const fmt = (v: number | undefined) => (v ? String(v) : "unlimited")
478
+ const lines = [
479
+ `User: ${d.label} (${d.userId})`,
480
+ `Token: ${d.token?.label ?? "?"} - expires ${d.token?.expiresAt ? new Date(d.token.expiresAt).toISOString().slice(0, 10) : "never"}`,
481
+ `Limits: live shares ${fmt(lim.maxLiveShares)} - max TTL ${fmt(lim.maxTtlDays)}d - daily creates ${fmt(lim.dailyCreates)}`,
482
+ ]
483
+ if (sharesRes.ok) {
484
+ const shares = sharesRes.data?.shares ?? []
485
+ lines.push(`Shares: ${shares.length} (${shares.filter((s) => s.status === "live").length} live)`)
486
+ for (const s of shares.slice(0, 10)) lines.push(` ${s.id} - ${s.status} - ${new Date(s.createdAt).toISOString().slice(0, 10)}`)
487
+ if (shares.length > 10) lines.push(` ...and ${shares.length - 10} more`)
488
+ }
489
+ await showInfo(host.ui, { title: "Your account", message: lines.join("\n") })
490
+ continue
491
+ }
401
492
  if (choice === "rotate") {
402
493
  const res = await rotateAtServer(url, mine.entry.token ?? "")
403
494
  if (res.ok && res.token) {
@@ -409,7 +500,12 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
409
500
  continue
410
501
  }
411
502
  if (choice === "disconnect") {
412
- 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 })
503
+ const sure = await showConfirm(host.ui, {
504
+ title: "Disconnect",
505
+ 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.`,
506
+ confirmLabel: "Disconnect",
507
+ danger: true,
508
+ })
413
509
  if (!sure) continue
414
510
  const res = await disconnectAtServer(url, mine.entry.token ?? "")
415
511
  saveServerEntry(url, { token: undefined })
@@ -572,9 +668,9 @@ async function withBusy(host: ShareHostApi, title: string, work: Promise<void> |
572
668
  doneBusy?.()
573
669
  }
574
670
 
575
- /** Server info, the readable version (live-caught: the raw JSON policy line
576
- * escaped the dialog border): formatted policy lines, auth explained in
577
- * words, YOUR standing on this server, and follow-up actions. */
671
+ /** Server info, the readable version (the raw JSON policy line escapes
672
+ * the dialog border): formatted policy lines, auth explained in words,
673
+ * YOUR standing on this server, and follow-up actions. */
578
674
  async function serverInfoScreen(host: ShareHostApi, url: string, client: ReturnType<typeof adminClient>): Promise<void> {
579
675
  const h = await client.health()
580
676
  if (!h.ok) {
@@ -817,16 +913,15 @@ async function adminSharesMenu(host: ShareHostApi, client: ReturnType<typeof adm
817
913
  }
818
914
 
819
915
  /** The settings dialog: a compact ROOT of group entries (with live
820
- * summaries) opening focused submenus. Two-level by design — one flat
821
- * ~25-row dump was unreadable (owner-directed regroup 2026-10-19).
822
- * Save semantics unchanged: root Exit saves when dirty; submenu Back
823
- * just returns. */
824
- export async function settingsDialog(host: ShareHostApi, startAtSession?: string): Promise<void> {
916
+ * summaries) opening focused submenus. Two-level by design: one flat ~25-row
917
+ * dump is unreadable. Save semantics unchanged: root Exit saves when dirty;
918
+ * submenu Back just returns. */
919
+ export async function settingsDialog(host: ShareHostApi, startAtSession?: string, startGroup?: "session"): Promise<void> {
825
920
  let cfg = currentConfig()
826
921
  let dirty = false
827
- // Write target (owner rule): global settings home by default; any other
828
- // existing file in the read chain may be chosen explicitly. The TUI
829
- // never creates deploy-time override files.
922
+ // Write target: global settings home by default; any other existing
923
+ // file in the read chain may be chosen explicitly. The TUI never
924
+ // creates deploy-time override files.
830
925
  const candidates = writeCandidates(moduleDir())
831
926
  let writeTarget: WriteCandidate = candidates[0]
832
927
  const sources = configSourceMap(moduleDir())
@@ -981,17 +1076,23 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
981
1076
  }
982
1077
  }
983
1078
  const binding = bindings[0]
1079
+ if (binding?.lastError || binding?.ttlClamped) {
1080
+ const bits: string[] = []
1081
+ if (binding?.ttlClamped) bits.push(`TTL clamped ${binding.ttlClamped.requested}d -> ${binding.ttlClamped.effective}d by server/token policy`)
1082
+ if (binding?.lastError) bits.push(`last share error: ${binding.lastError}`)
1083
+ 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.` })
1084
+ }
984
1085
  rows.push({
985
1086
  title: "Session TTL override (days)",
986
1087
  value: "session:ttl",
987
- description: binding?.opts?.ttlDays !== undefined ? String(binding.opts.ttlDays) : "(worker default)",
988
- 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)"}`,
1088
+ description: binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(worker default)",
1089
+ 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)"}`,
989
1090
  })
990
1091
  rows.push({
991
1092
  title: "Session read auth",
992
1093
  value: "session:auth",
993
1094
  description: binding?.opts?.authLogin ? `${binding.opts.authLogin} / ****` : "(config default)",
994
- 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)"}`,
1095
+ 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)"}`,
995
1096
  })
996
1097
  return rows
997
1098
  }
@@ -1177,9 +1278,11 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1177
1278
  options: [...otherServers.map((s) => ({ title: hostOf(s.url), value: s.url, description: s.entry.label })), { title: "Cancel", value: "__cancel" }],
1178
1279
  })
1179
1280
  if (!pick || pick === "__cancel") return
1180
- for (const s of serverEntries()) if (s.url !== pick) saveServerEntry(s.url, { active: undefined })
1281
+ // fan-out add: activate the picked server WITHOUT deactivating the
1282
+ // current ones - this session (and new sessions) mirror everywhere
1181
1283
  saveServerEntry(pick, { active: true })
1182
- host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated — this session mirrors there within seconds.` })
1284
+ const nActive = serverEntries().filter((s) => s.active).length
1285
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated (${nActive} active) - this session mirrors there within seconds.` })
1183
1286
  return
1184
1287
  }
1185
1288
  if (choice === "session:unshare") {
@@ -1193,54 +1296,89 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1193
1296
  })
1194
1297
  if (how === undefined || how === "__cancel") return
1195
1298
  if (how === "leave") {
1196
- const all = await readBindings(host.client, root)
1299
+ const allB = await readBindings(host.client, root)
1197
1300
  const okPatch = await patchSessionBinding(host.client, root, (servers) => {
1198
- for (const b of all) {
1301
+ for (const b of allB) {
1199
1302
  if (!b.server) continue
1200
- delete servers[b.server]
1201
- servers[b.server] = { optedOut: true }
1303
+ // keep id/secret on the opt-out marker: the snapshot stays
1304
+ // deletable later, and re-sharing reuses the binding instead
1305
+ // of stalling on a secret-less marker that can never unshare
1306
+ // or re-share again.
1307
+ servers[b.server] = { ...(servers[b.server] as Record<string, unknown> | undefined), id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true }
1202
1308
  }
1203
1309
  })
1204
- host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is — snapshots until their TTLs." : "Could not update the binding." })
1310
+ 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." })
1205
1311
  return
1206
1312
  }
1313
+ const sureAll = await showConfirm(host.ui, {
1314
+ title: "Delete every page?",
1315
+ message: `Permanently delete ${live.length} share page(s) and stop sharing?\nEvery link stops working for everyone. This cannot be undone.`,
1316
+ confirmLabel: "Delete every page",
1317
+ danger: true,
1318
+ })
1319
+ if (!sureAll) return
1207
1320
  const result = await runUnshareFlow(host, startAtSession)
1208
1321
  host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message })
1209
1322
  return
1210
1323
  }
1211
1324
  if (choice === "session:ttl") {
1212
- const raw = await showPrompt(host.ui, { title: "Session TTL (days)", placeholder: "days", value: current?.opts?.ttlDays !== undefined ? String(current.opts.ttlDays) : "" })
1325
+ const raw = await showPrompt(host.ui, { title: "Session TTL", placeholder: "hours: 6h, 2d or 48", value: current?.opts?.ttlHours !== undefined ? fmtHours(current.opts.ttlHours) : "" })
1213
1326
  if (raw === undefined) return
1214
1327
  const opts: SessionOpts = { ...(current?.opts ?? {}) }
1215
- if (raw === "") delete opts.ttlDays
1328
+ if (raw === "") delete opts.ttlHours
1216
1329
  else {
1217
- const days = Number(raw)
1218
- if (!Number.isFinite(days) || days <= 0) {
1219
- host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of days." })
1330
+ const hours = parseTtlHours(raw)
1331
+ if (hours === undefined) {
1332
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be 6h / 2d style or a positive number of hours." })
1220
1333
  return
1221
1334
  }
1222
- opts.ttlDays = Math.floor(days)
1335
+ opts.ttlHours = hours
1223
1336
  }
1224
- await setShareFlag(host.client, root, opts)
1225
- 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" })
1337
+ await setShareOpts(host.client, root, opts)
1338
+ const liveNow = live.length > 0
1339
+ if (liveNow) {
1340
+ // REST push (#13): immediate on every server, socket or not
1341
+ 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 })))
1342
+ const failed = results.filter((r) => !r.ok).length
1343
+ if (failed) host.ui.toast({ variant: "error", title: "OpenShare", message: `TTL applied on ${results.length - failed}/${results.length} server(s) - the rest sync on next connect.` })
1344
+ }
1345
+ host.ui.toast({
1346
+ variant: "success",
1347
+ title: "OpenShare",
1348
+ message: liveNow
1349
+ ? `Session TTL: ${opts.ttlHours !== undefined ? `${fmtHours(opts.ttlHours)} - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
1350
+ : `Session TTL: ${opts.ttlHours !== undefined ? fmtHours(opts.ttlHours) : "(default)"} - saved, applies when you share.`,
1351
+ })
1226
1352
  return
1227
1353
  }
1228
1354
  if (choice === "session:auth") {
1229
- const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = use config default", value: current?.opts?.authLogin ?? "" })
1355
+ const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = no read auth", value: current?.opts?.authLogin ?? "" })
1230
1356
  if (login === undefined) return
1231
- const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "empty = no password" })
1357
+ const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: login === "" ? "(unused)" : "password (required with a login)" })
1232
1358
  if (password === undefined) return
1359
+ // both-or-none: the server builds a gate only from a full pair, and
1360
+ // rendering half a pair would show protection that does not exist
1361
+ if ((login === "") !== (password === "")) {
1362
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "Read auth needs both a login and a password - or neither (no gate)." })
1363
+ return
1364
+ }
1233
1365
  const opts: SessionOpts = { ...(current?.opts ?? {}) }
1234
1366
  if (login === "") {
1235
1367
  delete opts.authLogin
1236
1368
  delete opts.authPassword
1237
1369
  } else {
1238
1370
  opts.authLogin = login
1239
- if (password !== "") opts.authPassword = password
1240
- else delete opts.authPassword
1371
+ opts.authPassword = password
1241
1372
  }
1242
- await setShareFlag(host.client, root, opts)
1243
- host.ui.toast({ variant: "success", title: "OpenShare", message: "Session read auth saved (applies at the next attach/create)." })
1373
+ await setShareOpts(host.client, root, opts)
1374
+ const liveNow2 = live.length > 0
1375
+ if (liveNow2) {
1376
+ const pair = login === "" ? { authLogin: "", authPassword: "" } : { authLogin: login, authPassword: password }
1377
+ 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 })))
1378
+ const failed2 = results2.filter((r) => !r.ok).length
1379
+ if (failed2) 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.` })
1380
+ }
1381
+ 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.` })
1244
1382
  return
1245
1383
  }
1246
1384
  }
@@ -1261,6 +1399,10 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1261
1399
 
1262
1400
  // ---- root: compact group list with live summaries --------------------------
1263
1401
 
1402
+ // /share on a shared session lands HERE, straight in the session menu -
1403
+ // one selection sooner (Back still reaches the full root)
1404
+ if (startAtSession && startGroup === "session") await groupMenu("session")
1405
+
1264
1406
  const seconds = (ms: number | undefined, fallback: number) => `${Math.round((ms ?? fallback) / 100) / 10}s`
1265
1407
  while (true) {
1266
1408
  const servers = serverEntries(cfg)
@@ -1288,7 +1430,7 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1288
1430
  })
1289
1431
  groups.push({
1290
1432
  id: "sharing",
1291
- summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${cfg.ttlDays ?? "server cap"}`,
1433
+ summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${fmtHours(cfg.ttlHours ?? 2160)}`,
1292
1434
  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.`,
1293
1435
  })
1294
1436
  groups.push({