@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/src/core.ts CHANGED
@@ -44,7 +44,7 @@
44
44
 
45
45
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
46
46
  import { createHash } from "node:crypto"
47
- import { join } from "node:path"
47
+ import { isAbsolute, join } from "node:path"
48
48
  import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken, type ShareConfig, type ShareMode, type ServerEntry } from "./config.js"
49
49
  import { CONTRACT_VERSION, cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js"
50
50
  import { queueBindingWrite } from "./binding-queue.js"
@@ -146,7 +146,12 @@ type SessionState = {
146
146
  badSignal: boolean
147
147
  lastSentStatus: string | null
148
148
  closed: boolean
149
- prefsSent: boolean
149
+ /** last create/attach refusal reason surfaced to the TUI (cleared on a
150
+ * successful init-ok); rides the session binding so /share failures can
151
+ * say WHY instead of a generic timeout */
152
+ lastError: string | null
153
+ /** server/token policy clamped our requested TTL: {requested, effective} */
154
+ ttlClamped: { requested: number; effective: number } | null
150
155
  binding: Record<string, unknown> | null // mirrors what was written into session metadata (v2 map)
151
156
  bindingQueried: boolean
152
157
  /** per-session server opt-outs (TUI "stop sharing here" / "leave as-is"):
@@ -296,6 +301,15 @@ function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: Ses
296
301
  return { enabled: true, opts: Object.keys(opts).length ? opts : undefined }
297
302
  }
298
303
 
304
+ /** The LIVE engine's resolved target urls (shared module state: the TUI
305
+ * entry reads this so surfaces that gate on "is anything configured" agree
306
+ * with what the engine actually targets - file config alone can miss
307
+ * plugin-options-layer config the TUI cannot see). */
308
+ let liveTargets: string[] = []
309
+ export function engineTargetUrls(): string[] {
310
+ return liveTargets
311
+ }
312
+
299
313
  export function createEngine(deps: EngineDeps): Engine {
300
314
  let cfg = loadConfig(deps.pluginDir, deps.options)
301
315
  const mode: ShareMode = resolveShareMode(cfg, deps)
@@ -342,7 +356,15 @@ export function createEngine(deps: EngineDeps): Engine {
342
356
  type ServerTarget = { url: string; entry: ServerEntry }
343
357
  const resolveTargets = (): ServerTarget[] => {
344
358
  const merged: Record<string, ServerEntry> = {}
345
- if (cfg.workerUrl) merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true }
359
+ const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0
360
+ // layer-aware legacy rule: a FILE workerUrl beside a FILE servers
361
+ // group would silently dual-home the session (live-caught surprise);
362
+ // an ENV workerUrl is the highest-priority layer and always joins.
363
+ const envWorker = !!process.env.OPENSHARE_WORKER_URL
364
+ if (cfg.workerUrl && (!hasServers || envWorker)) merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true }
365
+ if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers![cfg.workerUrl]) {
366
+ log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`)
367
+ }
346
368
  if (cfg.servers) for (const [u, e] of Object.entries(cfg.servers)) merged[u] = { ...merged[u], ...e }
347
369
  const urls = Object.keys(merged)
348
370
  const actives = urls.filter((u) => merged[u].active)
@@ -350,6 +372,7 @@ export function createEngine(deps: EngineDeps): Engine {
350
372
  return chosen.map((url) => ({ url, entry: merged[url] }))
351
373
  }
352
374
  let targets = resolveTargets()
375
+ liveTargets = targets.map((t) => t.url)
353
376
 
354
377
  // ---- per-server credentials (identity) ----------------------------------
355
378
  // Each server enrolls INDEPENDENTLY: a legacy root CREATE_TOKEN works
@@ -550,7 +573,8 @@ export function createEngine(deps: EngineDeps): Engine {
550
573
  badSignal: false,
551
574
  lastSentStatus: null,
552
575
  closed: false,
553
- prefsSent: false,
576
+ lastError: null,
577
+ ttlClamped: null,
554
578
  binding: null,
555
579
  bindingQueried: false,
556
580
  optOuts: new Set(),
@@ -572,6 +596,7 @@ export function createEngine(deps: EngineDeps): Engine {
572
596
  spoolRoot = spoolDirFor(cfg)
573
597
  const before = new Set(targets.map((t) => t.url))
574
598
  targets = resolveTargets()
599
+ liveTargets = targets.map((t) => t.url)
575
600
  seedCredentials()
576
601
  clampToPolicy()
577
602
  if (debugEvents) {
@@ -781,6 +806,8 @@ export function createEngine(deps: EngineDeps): Engine {
781
806
  // live is now uniformly "url && enabled !== false").
782
807
  const value: Record<string, unknown> = { v: 2, servers, enabled: true }
783
808
  if (mode === "manual" && st.opts) value.opts = st.opts
809
+ if (st.lastError) value.lastError = st.lastError
810
+ if (st.ttlClamped) value.ttlClamped = st.ttlClamped
784
811
  st.binding = value
785
812
  st.wroteBinding = true
786
813
  await writeMetadata(st, value)
@@ -1025,7 +1052,11 @@ export function createEngine(deps: EngineDeps): Engine {
1025
1052
  // Append: CI reads the file after each run; keeping every share
1026
1053
  // URL lets one file serve a whole workflow (the legacy original
1027
1054
  // overwrote with the latest only).
1028
- const file = cfg.urlFile || join(deps.directory ?? process.cwd(), "openshare.urls.txt")
1055
+ const file = cfg.urlFile
1056
+ ? isAbsolute(cfg.urlFile)
1057
+ ? cfg.urlFile
1058
+ : join(deps.directory ?? process.cwd(), cfg.urlFile)
1059
+ : join(deps.directory ?? process.cwd(), "openshare.urls.txt")
1029
1060
  try {
1030
1061
  appendFileSync(file, url + "\n")
1031
1062
  if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
@@ -1033,21 +1064,56 @@ export function createEngine(deps: EngineDeps): Engine {
1033
1064
  lastUrlFile = file
1034
1065
  log(`url file: ${file}`)
1035
1066
  }
1036
- } catch {}
1067
+ } catch (e) {
1068
+ // a CI pipeline reads this file after the process exits; a silent
1069
+ // failure means the run's share URL is simply gone
1070
+ logError(`url file write failed (${file}):`, (e as Error)?.message || e)
1071
+ }
1037
1072
  }
1038
1073
  }
1039
1074
 
1075
+ let halfAuthWarned = false
1040
1076
  const authFrame = (st: SessionState) => {
1041
1077
  // always send both (empty strings included): attach with empty values
1042
- // REMOVES protection — an intentional owner action. Session opts
1078
+ // REMOVES protection - an intentional owner action. Session opts
1043
1079
  // override the global config when set.
1044
1080
  const login = st.opts?.authLogin ?? cfg.authLogin ?? ""
1045
1081
  const password = st.opts?.authPassword ?? cfg.authPassword ?? ""
1082
+ // both-or-none: the server builds a gate only from a full pair, so a
1083
+ // half pair silently means "no gate" - refuse to send one and say so
1084
+ if ((login === "") !== (password === "")) {
1085
+ if (!halfAuthWarned) {
1086
+ halfAuthWarned = true
1087
+ logError("read auth needs BOTH a login and a password - gate disabled until fixed")
1088
+ }
1089
+ return { authLogin: "", authPassword: "" }
1090
+ }
1046
1091
  return { authLogin: login, authPassword: password }
1047
1092
  }
1048
1093
 
1049
1094
  const ttlFor = (st: SessionState) => st.opts?.ttlDays ?? cfg.ttlDays
1050
1095
 
1096
+ /** Apply a metadata opts update. On a CHANGED opts set for a session with
1097
+ * live sockets, re-send the attach frame over each open socket: the
1098
+ * worker's attach path applies read-auth retroactively (rotating viewer
1099
+ * tokens on change) and now also honors an explicit ttlDays override -
1100
+ * so settings heal immediately instead of waiting for a reconnect.
1101
+ * Re-attach is idempotent (no once-guard on init; earlySyncs empty at
1102
+ * steady state) and revalidates the token. */
1103
+ const applyOpts = (st: SessionState, opts: SessionOpts | undefined) => {
1104
+ if (!opts) return
1105
+ const prev = st.opts ? JSON.stringify(st.opts) : ""
1106
+ st.opts = opts
1107
+ if (prev === JSON.stringify(opts)) return
1108
+ for (const [url, r] of st.remotes) {
1109
+ if (!r.shareId || !r.secret || !r.wsReady) continue
1110
+ const attach: Record<string, unknown> = { id: r.shareId, secret: r.secret, ...authFrame(st) }
1111
+ if (st.opts?.ttlDays) attach.ttlDays = st.opts.ttlDays
1112
+ const sent = wsSend(r, { op: "init", attach })
1113
+ if (sent) log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlDays ? ` (ttl ${st.opts.ttlDays}d)` : ""}`)
1114
+ }
1115
+ }
1116
+
1051
1117
  /** Best-effort cleanup of a share we are about to abandon (refused
1052
1118
  * attach, mismatch recreate). Prevents husk litter; failures are fine —
1053
1119
  * the TTL sweeper collects strays eventually. */
@@ -1130,7 +1196,7 @@ export function createEngine(deps: EngineDeps): Engine {
1130
1196
  st.remotes.delete(url)
1131
1197
  }
1132
1198
  // collisions resolved by the TUI: the binding entry for the server
1133
- // was removed — re-arm unless the same patch also opted out
1199
+ // was removed - re-arm unless the same patch also opted out
1134
1200
  for (const r of [...st.remotes.values()]) {
1135
1201
  if (!r.collided || st.optOuts.has(r.url)) continue
1136
1202
  if (entryUrls.has(r.url)) continue
@@ -1143,6 +1209,27 @@ export function createEngine(deps: EngineDeps): Engine {
1143
1209
  r.announced = false
1144
1210
  queueConnect(st, r)
1145
1211
  }
1212
+ // re-share after "leave as-is": the entry kept its id/secret and the
1213
+ // opt-out marker is gone - re-arm a remote that ATTACHES the same
1214
+ // page (same URL), then backfill so the frozen gap catches up
1215
+ // (dedupe hashes describe the OLD remote set, not this revival)
1216
+ let rearmed = false
1217
+ for (const [url, e] of Object.entries(servers as Record<string, unknown>)) {
1218
+ if (st.optOuts.has(url) || st.remotes.has(url)) continue
1219
+ const ee = e as Record<string, unknown>
1220
+ if (typeof ee.id !== "string" || typeof ee.secret !== "string" || !ee.id) continue
1221
+ const r = remoteFor(st, url)
1222
+ r.shareId = ee.id
1223
+ r.secret = ee.secret
1224
+ r.ackedRev = typeof ee.rev === "number" ? ee.rev : 0
1225
+ log(`re-share: re-arming ${url.slice(0, 24)} on share ${ee.id}`)
1226
+ queueConnect(st, r)
1227
+ rearmed = true
1228
+ }
1229
+ if (rearmed) {
1230
+ st.sentHash.clear()
1231
+ chain = chain.then(() => backfill(st)).catch((e) => logError("re-share backfill error", (e as Error)?.message || e))
1232
+ }
1146
1233
  } catch {}
1147
1234
  }
1148
1235
 
@@ -1387,8 +1474,10 @@ export function createEngine(deps: EngineDeps): Engine {
1387
1474
  const err = String(frame.error ?? "")
1388
1475
  if (/frozen/i.test(err)) {
1389
1476
  // owner frozen: every control path is severed server-side.
1390
- // Re-enrolling would orphan the shares — surface and stop.
1477
+ // Re-enrolling would orphan the shares - surface and stop.
1391
1478
  r.authBlocked = true
1479
+ st.lastError = `share frozen by the hoster (owner frozen) - contact the server admin`
1480
+ scheduleBindingWrite(st)
1392
1481
  logError(`${r.url}: share frozen by the hoster (owner frozen) - edits refused; contact the server admin`)
1393
1482
  try {
1394
1483
  r.ws?.close(4000, "frozen")
@@ -1438,8 +1527,10 @@ export function createEngine(deps: EngineDeps): Engine {
1438
1527
  if (/limit|too many|rate/i.test(err)) {
1439
1528
  // quota/429 ("max shares reached", "daily create limit", the
1440
1529
  // anonymous caps): an immediate re-init on the same socket is a
1441
- // hot loop against the server — back off through the normal
1530
+ // hot loop against the server - back off through the normal
1442
1531
  // reconnect cadence instead
1532
+ st.lastError = `create refused: ${frame.error ?? "rate limited"}`
1533
+ scheduleBindingWrite(st)
1443
1534
  logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff")
1444
1535
  r.connecting = false
1445
1536
  try {
@@ -1449,6 +1540,8 @@ export function createEngine(deps: EngineDeps): Engine {
1449
1540
  return
1450
1541
  }
1451
1542
  logError(`${r.url}: init refused:`, frame.error, "- creating fresh share")
1543
+ st.lastError = `init refused: ${frame.error ?? "unknown"}`
1544
+ scheduleBindingWrite(st)
1452
1545
  deleteAbandoned(r)
1453
1546
  r.shareId = null
1454
1547
  r.secret = null
@@ -1499,6 +1592,21 @@ export function createEngine(deps: EngineDeps): Engine {
1499
1592
  if (frame.policy) applyPolicy(frame.policy, r.url)
1500
1593
  r.intentionalClose = false
1501
1594
  touch(st)
1595
+ // the ack carries the RESOLVED ttl: surface a server/token clamp to
1596
+ // the binding once, and a successful handshake clears any stale
1597
+ // refusal reason the TUI may be showing
1598
+ if (typeof frame.ttlDays === "number") {
1599
+ const wanted = ttlFor(st)
1600
+ const clamp = wanted != null && frame.ttlDays !== wanted ? { requested: wanted, effective: frame.ttlDays } : null
1601
+ if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
1602
+ st.ttlClamped = clamp
1603
+ scheduleBindingWrite(st)
1604
+ }
1605
+ }
1606
+ if (st.lastError) {
1607
+ st.lastError = null
1608
+ scheduleBindingWrite(st)
1609
+ }
1502
1610
  announceUrl(st, r)
1503
1611
  // first binding write is immediate (the TUI waits on the URL in
1504
1612
  // session metadata); ack-driven rev updates stay debounced
@@ -2400,10 +2508,6 @@ export function createEngine(deps: EngineDeps): Engine {
2400
2508
  // activates anything on its own
2401
2509
  const st = sessions.get(parent)
2402
2510
  if (st?.activated && !st.closed) {
2403
- if (!st.prefsSent) {
2404
- st.prefsSent = true
2405
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
2406
- }
2407
2511
  if (info) enqueue(st, itemFor("session", redact(info), sid))
2408
2512
  }
2409
2513
  return
@@ -2413,21 +2517,12 @@ export function createEngine(deps: EngineDeps): Engine {
2413
2517
  if (flag.enabled) {
2414
2518
  if (st?.activated && !st.closed) {
2415
2519
  // already sharing: opts updates apply, flag removal handled below
2416
- if (flag.opts) st.opts = flag.opts
2417
- if (!st.prefsSent) {
2418
- st.prefsSent = true
2419
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
2420
- }
2520
+ if (flag.opts) applyOpts(st, flag.opts)
2421
2521
  if (info) enqueue(st, itemFor("session", redact(info), sid))
2422
2522
  if (t === "session.updated") chain = chain.then(() => refreshOptOuts(st)).catch(() => {})
2423
2523
  return
2424
2524
  }
2425
2525
  activate(sid, flag.opts, info)
2426
- const fresh = sessions.get(sid)
2427
- if (fresh && !fresh.prefsSent) {
2428
- fresh.prefsSent = true
2429
- enqueue(fresh, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
2430
- }
2431
2526
  return
2432
2527
  }
2433
2528
  if (st?.activated && !st.closed && t === "session.updated") {
@@ -2483,10 +2578,6 @@ export function createEngine(deps: EngineDeps): Engine {
2483
2578
  }
2484
2579
  }
2485
2580
  if (!st) return
2486
- if (!st.prefsSent) {
2487
- st.prefsSent = true
2488
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
2489
- }
2490
2581
  if (info) enqueue(st, itemFor("session", redact(info), sid))
2491
2582
  if (t === "session.updated" && st.activated && !st.closed) {
2492
2583
  chain = chain.then(() => refreshOptOuts(st)).catch(() => {})
@@ -2728,7 +2819,7 @@ export function createEngine(deps: EngineDeps): Engine {
2728
2819
  const flushed = flush(st)
2729
2820
  if (flushed && typeof (flushed as Promise<unknown>).then === "function") await flushed.catch(() => {})
2730
2821
  // dispose with a mid-handshake socket: the deferred pending
2731
- // would die with the process — land it over HTTP instead
2822
+ // would die with the process - land it over HTTP instead
2732
2823
  for (const r of st.remotes.values()) {
2733
2824
  if (!r.wsReady && r.shareId && r.pending.length) {
2734
2825
  await flushViaHttp(st, r).catch(() => {})
@@ -2748,6 +2839,23 @@ export function createEngine(deps: EngineDeps): Engine {
2748
2839
  st.metaTimer = null
2749
2840
  await writeBinding(st).catch(() => {})
2750
2841
  }
2842
+ // URL-at-exit barrier: a very short `opencode run` can exit before
2843
+ // the create handshake finishes, and CI reads the urlFile the
2844
+ // moment the process ends. Bounded 5s wait for every share that is
2845
+ // mid-handshake or not yet announced (a dead worker costs exactly
2846
+ // the cap; a healthy one is instant).
2847
+ const barrierStart = Date.now()
2848
+ while (Date.now() - barrierStart < 5000) {
2849
+ let pendingAnnounce = false
2850
+ for (const st of sessions.values()) {
2851
+ for (const r of st.remotes.values()) {
2852
+ if (r.authBlocked || r.collided) continue
2853
+ if ((r.shareId && !r.announced) || (!r.shareId && (r.ws || r.connecting))) pendingAnnounce = true
2854
+ }
2855
+ }
2856
+ if (!pendingAnnounce) break
2857
+ await new Promise((res) => setTimeout(res, 100))
2858
+ }
2751
2859
  for (const st of sessions.values()) {
2752
2860
  for (const r of st.remotes.values()) {
2753
2861
  try {
package/src/settings.ts CHANGED
@@ -28,8 +28,10 @@ import {
28
28
  runUnshareFlow,
29
29
  saveConfig,
30
30
  saveServerEntry,
31
+ meClient,
31
32
  serverEntries,
32
33
  setShareFlag,
34
+ setShareOpts,
33
35
  shareWithConsent,
34
36
  workerDeleteShare,
35
37
  type AdminShare,
@@ -113,11 +115,11 @@ const LABELS: Record<string, LabelMeta> = {
113
115
  },
114
116
  shareMode: {
115
117
  label: "Share mode",
116
- help: '"all" shares every session; "manual" needs /share. Default: TUI presence.',
118
+ help: '"all" shares every session; "manual" needs /share. Default: TUI presence. Takes effect after opencode restarts.',
117
119
  kind: "text",
118
120
  options: ["auto", "all", "manual"],
119
121
  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.',
122
+ 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
123
  def: "auto (TUI presence)",
122
124
  },
123
125
  debounceMs: {
@@ -152,15 +154,6 @@ const LABELS: Record<string, LabelMeta> = {
152
154
  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
155
  def: "worker cap (90 on the reference deployment)",
154
156
  },
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",
163
- },
164
157
  authLogin: {
165
158
  label: "Read auth: login",
166
159
  help: "Optional read gate for share pages. Empty removes protection on the next attach.",
@@ -218,8 +211,7 @@ function helpForKey(key: string, cfg: ShareConfig, sources: Record<string, strin
218
211
 
219
212
  function displayValue(key: string, cfg: ShareConfig): string {
220
213
  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)
214
+ if (key === "announceStderr") return cfg.announceStderr !== false ? "on" : "off"
223
215
  const value = (cfg as unknown as Record<string, unknown>)[key]
224
216
  return value === undefined || value === "" ? "(not set)" : String(value)
225
217
  }
@@ -243,7 +235,7 @@ async function editKey(host: ShareHostApi, cfg: ShareConfig, key: string): Promi
243
235
  if (picked === undefined) return undefined
244
236
  const next = { ...cfg } as unknown as Record<string, unknown>
245
237
  if (key === "shareMode") next[key] = picked === "auto" ? undefined : picked
246
- else if (key === "openTasksInNewTab" || key === "announceStderr") next[key] = picked === "true"
238
+ else if (key === "announceStderr") next[key] = picked === "true"
247
239
  else next[key] = picked
248
240
  return next as ShareConfig
249
241
  }
@@ -330,6 +322,7 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
330
322
  }
331
323
  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
324
  if (mine.entry.token && !holdsRoot) options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" })
325
+ if (mine.entry.token && !holdsRoot) options.push({ title: "Your account", value: "account", description: "limits, token expiry, your shares" })
333
326
  if (mine.entry.token && !holdsRoot) options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true })
334
327
  options.push({
335
328
  title: "Admin panel",
@@ -345,11 +338,16 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
345
338
  if (choice === undefined || choice === "__back") return
346
339
 
347
340
  if (choice === "active") {
348
- for (const other of entries) {
349
- if (other.url !== url) saveServerEntry(other.url, { active: undefined })
350
- }
341
+ // make-primary WITHOUT collapsing fan-out: other explicit actives
342
+ // stay active (the engine targets every active entry); silently
343
+ // deactivating them destroyed hand-built multi-server setups
351
344
  saveServerEntry(url, { active: true })
352
- host.ui.toast({ variant: "success", title: "OpenShare", message: `${url} is now the active server.` })
345
+ const stillActive = serverEntries().filter((s) => s.active)
346
+ host.ui.toast({
347
+ variant: "success",
348
+ title: "OpenShare",
349
+ 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.`,
350
+ })
353
351
  return
354
352
  }
355
353
  if (choice === "adopt") {
@@ -398,6 +396,30 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
398
396
  }
399
397
  continue
400
398
  }
399
+ if (choice === "account") {
400
+ const me = meClient(url, mine.entry.token ?? "")
401
+ const [meRes, sharesRes] = await Promise.all([me.me(), me.myShares()])
402
+ if (!meRes.ok) {
403
+ 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.` })
404
+ continue
405
+ }
406
+ const d = meRes.data!
407
+ const lim = d.token?.limits ?? {}
408
+ const fmt = (v: number | undefined) => (v ? String(v) : "unlimited")
409
+ const lines = [
410
+ `User: ${d.label} (${d.userId})`,
411
+ `Token: ${d.token?.label ?? "?"} - expires ${d.token?.expiresAt ? new Date(d.token.expiresAt).toISOString().slice(0, 10) : "never"}`,
412
+ `Limits: live shares ${fmt(lim.maxLiveShares)} - max TTL ${fmt(lim.maxTtlDays)}d - daily creates ${fmt(lim.dailyCreates)}`,
413
+ ]
414
+ if (sharesRes.ok) {
415
+ const shares = sharesRes.data?.shares ?? []
416
+ lines.push(`Shares: ${shares.length} (${shares.filter((s) => s.status === "live").length} live)`)
417
+ for (const s of shares.slice(0, 10)) lines.push(` ${s.id} - ${s.status} - ${new Date(s.createdAt).toISOString().slice(0, 10)}`)
418
+ if (shares.length > 10) lines.push(` ...and ${shares.length - 10} more`)
419
+ }
420
+ await showInfo(host.ui, { title: "Your account", message: lines.join("\n") })
421
+ continue
422
+ }
401
423
  if (choice === "rotate") {
402
424
  const res = await rotateAtServer(url, mine.entry.token ?? "")
403
425
  if (res.ok && res.token) {
@@ -409,7 +431,12 @@ async function serverMenu(host: ShareHostApi, url: string): Promise<void> {
409
431
  continue
410
432
  }
411
433
  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 })
434
+ const sure = await showConfirm(host.ui, {
435
+ title: "Disconnect",
436
+ 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.`,
437
+ confirmLabel: "Disconnect",
438
+ danger: true,
439
+ })
413
440
  if (!sure) continue
414
441
  const res = await disconnectAtServer(url, mine.entry.token ?? "")
415
442
  saveServerEntry(url, { token: undefined })
@@ -821,7 +848,7 @@ async function adminSharesMenu(host: ShareHostApi, client: ReturnType<typeof adm
821
848
  * ~25-row dump was unreadable (owner-directed regroup 2026-10-19).
822
849
  * Save semantics unchanged: root Exit saves when dirty; submenu Back
823
850
  * just returns. */
824
- export async function settingsDialog(host: ShareHostApi, startAtSession?: string): Promise<void> {
851
+ export async function settingsDialog(host: ShareHostApi, startAtSession?: string, startGroup?: "session"): Promise<void> {
825
852
  let cfg = currentConfig()
826
853
  let dirty = false
827
854
  // Write target (owner rule): global settings home by default; any other
@@ -981,17 +1008,23 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
981
1008
  }
982
1009
  }
983
1010
  const binding = bindings[0]
1011
+ if (binding?.lastError || binding?.ttlClamped) {
1012
+ const bits: string[] = []
1013
+ if (binding?.ttlClamped) bits.push(`TTL clamped ${binding.ttlClamped.requested}d -> ${binding.ttlClamped.effective}d by server/token policy`)
1014
+ if (binding?.lastError) bits.push(`last share error: ${binding.lastError}`)
1015
+ 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.` })
1016
+ }
984
1017
  rows.push({
985
1018
  title: "Session TTL override (days)",
986
1019
  value: "session:ttl",
987
1020
  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)"}`,
1021
+ 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)"}`,
989
1022
  })
990
1023
  rows.push({
991
1024
  title: "Session read auth",
992
1025
  value: "session:auth",
993
1026
  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)"}`,
1027
+ 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
1028
  })
996
1029
  return rows
997
1030
  }
@@ -1177,9 +1210,11 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1177
1210
  options: [...otherServers.map((s) => ({ title: hostOf(s.url), value: s.url, description: s.entry.label })), { title: "Cancel", value: "__cancel" }],
1178
1211
  })
1179
1212
  if (!pick || pick === "__cancel") return
1180
- for (const s of serverEntries()) if (s.url !== pick) saveServerEntry(s.url, { active: undefined })
1213
+ // fan-out add: activate the picked server WITHOUT deactivating the
1214
+ // current ones - this session (and new sessions) mirror everywhere
1181
1215
  saveServerEntry(pick, { active: true })
1182
- host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated — this session mirrors there within seconds.` })
1216
+ const nActive = serverEntries().filter((s) => s.active).length
1217
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated (${nActive} active) - this session mirrors there within seconds.` })
1183
1218
  return
1184
1219
  }
1185
1220
  if (choice === "session:unshare") {
@@ -1193,17 +1228,27 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1193
1228
  })
1194
1229
  if (how === undefined || how === "__cancel") return
1195
1230
  if (how === "leave") {
1196
- const all = await readBindings(host.client, root)
1231
+ const allB = await readBindings(host.client, root)
1197
1232
  const okPatch = await patchSessionBinding(host.client, root, (servers) => {
1198
- for (const b of all) {
1233
+ for (const b of allB) {
1199
1234
  if (!b.server) continue
1200
- delete servers[b.server]
1201
- servers[b.server] = { optedOut: true }
1235
+ // keep id/secret on the opt-out marker: the snapshot stays
1236
+ // deletable later and re-sharing reuses the binding instead
1237
+ // of stalling on a secret-less marker (live-caught as "can
1238
+ // never unshare or re-share after leave-as-is")
1239
+ 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
1240
  }
1203
1241
  })
1204
- host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is — snapshots until their TTLs." : "Could not update the binding." })
1242
+ 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
1243
  return
1206
1244
  }
1245
+ const sureAll = await showConfirm(host.ui, {
1246
+ title: "Delete every page?",
1247
+ message: `Permanently delete ${live.length} share page(s) and stop sharing?\nEvery link stops working for everyone. This cannot be undone.`,
1248
+ confirmLabel: "Delete every page",
1249
+ danger: true,
1250
+ })
1251
+ if (!sureAll) return
1207
1252
  const result = await runUnshareFlow(host, startAtSession)
1208
1253
  host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message })
1209
1254
  return
@@ -1221,26 +1266,39 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1221
1266
  }
1222
1267
  opts.ttlDays = Math.floor(days)
1223
1268
  }
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" })
1269
+ await setShareOpts(host.client, root, opts)
1270
+ const liveNow = live.length > 0
1271
+ host.ui.toast({
1272
+ variant: "success",
1273
+ title: "OpenShare",
1274
+ message: liveNow
1275
+ ? `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
1276
+ : `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d` : "(default)"} - saved, applies when you share.`,
1277
+ })
1226
1278
  return
1227
1279
  }
1228
1280
  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 ?? "" })
1281
+ const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = no read auth", value: current?.opts?.authLogin ?? "" })
1230
1282
  if (login === undefined) return
1231
- const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "empty = no password" })
1283
+ const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: login === "" ? "(unused)" : "password (required with a login)" })
1232
1284
  if (password === undefined) return
1285
+ // both-or-none: the server builds a gate only from a full pair, and
1286
+ // rendering half a pair would show protection that does not exist
1287
+ if ((login === "") !== (password === "")) {
1288
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "Read auth needs both a login and a password - or neither (no gate)." })
1289
+ return
1290
+ }
1233
1291
  const opts: SessionOpts = { ...(current?.opts ?? {}) }
1234
1292
  if (login === "") {
1235
1293
  delete opts.authLogin
1236
1294
  delete opts.authPassword
1237
1295
  } else {
1238
1296
  opts.authLogin = login
1239
- if (password !== "") opts.authPassword = password
1240
- else delete opts.authPassword
1297
+ opts.authPassword = password
1241
1298
  }
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)." })
1299
+ await setShareOpts(host.client, root, opts)
1300
+ const liveNow2 = live.length > 0
1301
+ 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
1302
  return
1245
1303
  }
1246
1304
  }
@@ -1261,6 +1319,10 @@ export async function settingsDialog(host: ShareHostApi, startAtSession?: string
1261
1319
 
1262
1320
  // ---- root: compact group list with live summaries --------------------------
1263
1321
 
1322
+ // /share on a shared session lands HERE, straight in the session menu -
1323
+ // one selection sooner (Back still reaches the full root)
1324
+ if (startAtSession && startGroup === "session") await groupMenu("session")
1325
+
1264
1326
  const seconds = (ms: number | undefined, fallback: number) => `${Math.round((ms ?? fallback) / 100) / 10}s`
1265
1327
  while (true) {
1266
1328
  const servers = serverEntries(cfg)