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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js CHANGED
@@ -23,7 +23,6 @@ var DEFAULTS = {
23
23
  completedDelayMs: 5000,
24
24
  modelLabel: "$Provider/$Model",
25
25
  ttlDays: 90,
26
- openTasksInNewTab: true,
27
26
  authLogin: "",
28
27
  authPassword: "",
29
28
  spoolDir: "",
@@ -252,7 +251,10 @@ function loadConfig(pluginDir, options) {
252
251
  continue;
253
252
  cfg[key] = value;
254
253
  }
255
- } catch {}
254
+ } catch (e) {
255
+ if (existsSync(path))
256
+ console.error(`[openshare] config file ${path} could not be parsed - ignored:`, e?.message ?? e);
257
+ }
256
258
  }
257
259
  if (options && typeof options === "object") {
258
260
  const serversOpt = normalizeServers(options.servers);
@@ -297,6 +299,14 @@ function loadConfig(pluginDir, options) {
297
299
  cfg.shareMode = env.OPENSHARE_SHARE_MODE;
298
300
  if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE)
299
301
  cfg.urlFile = URL_FILE_NONE;
302
+ if (cfg.workerUrl)
303
+ cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "");
304
+ if (cfg.servers) {
305
+ const fixed = {};
306
+ for (const [u, e] of Object.entries(cfg.servers))
307
+ fixed[u.trim().replace(/\/+$/, "")] = e;
308
+ cfg.servers = fixed;
309
+ }
300
310
  return cfg;
301
311
  }
302
312
  function globalConfigDir(env) {
@@ -434,6 +444,12 @@ function queueBindingWrite(sid, fn) {
434
444
  return run;
435
445
  }
436
446
 
447
+ // src/core.ts
448
+ var liveTargets = [];
449
+ function engineTargetUrls() {
450
+ return liveTargets;
451
+ }
452
+
437
453
  // src/share-flow.ts
438
454
  function currentConfig(baseDir = moduleDir()) {
439
455
  return loadConfig(baseDir);
@@ -459,7 +475,6 @@ function saveConfig(cfg, target, baseDir = moduleDir()) {
459
475
  "completedDelayMs",
460
476
  "modelLabel",
461
477
  "ttlDays",
462
- "openTasksInNewTab",
463
478
  "authLogin",
464
479
  "authPassword",
465
480
  "spoolDir",
@@ -490,8 +505,8 @@ function serverEntries(cfg = currentConfig()) {
490
505
  for (const [url, entry] of Object.entries(cfg.servers))
491
506
  merged[url] = { ...merged[url], ...entry };
492
507
  const urls = Object.keys(merged);
493
- const activeUrl = urls.find((u) => merged[u].active) ?? urls[0];
494
- return urls.map((url) => ({ url, entry: merged[url], active: url === activeUrl }));
508
+ const anyActive = urls.some((u) => merged[u].active);
509
+ return urls.map((url) => ({ url, entry: merged[url], active: anyActive ? merged[url].active === true : url === urls[0] }));
495
510
  }
496
511
  function bindingsOf(info) {
497
512
  const raw = info?.metadata?.[METADATA_KEY];
@@ -500,6 +515,8 @@ function bindingsOf(info) {
500
515
  const record = raw;
501
516
  const opts = record.opts ?? undefined;
502
517
  const enabled = record.enabled === true ? true : undefined;
518
+ const lastError = typeof record.lastError === "string" ? record.lastError : undefined;
519
+ const ttlClamped = record.ttlClamped && typeof record.ttlClamped === "object" ? record.ttlClamped : undefined;
503
520
  const out = [];
504
521
  if (record.servers && typeof record.servers === "object") {
505
522
  for (const [server, entry] of Object.entries(record.servers)) {
@@ -517,7 +534,10 @@ function bindingsOf(info) {
517
534
  enabled,
518
535
  opts,
519
536
  server,
520
- collided
537
+ collided,
538
+ optedOut: e.optedOut === true ? true : undefined,
539
+ lastError,
540
+ ttlClamped
521
541
  });
522
542
  }
523
543
  return out;
@@ -532,7 +552,9 @@ function bindingsOf(info) {
532
552
  enabled,
533
553
  opts,
534
554
  server: "",
535
- collided
555
+ collided,
556
+ lastError,
557
+ ttlClamped
536
558
  });
537
559
  }
538
560
  return out;
@@ -626,6 +648,20 @@ async function setShareFlag(client, sid, opts) {
626
648
  metadata[METADATA_KEY] = value;
627
649
  return patchMetadata(client, sid, metadata);
628
650
  }
651
+ async function setShareOpts(client, sid, opts) {
652
+ const info = await getSession(client, sid);
653
+ if (!info)
654
+ return false;
655
+ const metadata = { ...info.metadata ?? {} };
656
+ const existing = info.metadata?.[METADATA_KEY];
657
+ const value = existing && typeof existing === "object" ? { ...existing } : { v: 2, servers: {} };
658
+ if (Object.keys(opts).length)
659
+ value.opts = opts;
660
+ else
661
+ delete value.opts;
662
+ metadata[METADATA_KEY] = value;
663
+ return patchMetadata(client, sid, metadata);
664
+ }
629
665
  async function clearShareFlag(client, sid) {
630
666
  const info = await getSession(client, sid);
631
667
  if (!info)
@@ -786,6 +822,28 @@ function adminClient(workerUrl, token) {
786
822
  health: () => call("GET", "/health")
787
823
  };
788
824
  }
825
+ function meClient(workerUrl, token) {
826
+ const base = normServerUrl(workerUrl);
827
+ const call = async (method, path) => {
828
+ try {
829
+ const res = await fetch(`${base}${path}`, {
830
+ method,
831
+ headers: { authorization: `Bearer ${token}` },
832
+ signal: AbortSignal.timeout(12000)
833
+ });
834
+ const parsed = await res.json().catch(() => ({}));
835
+ if (!res.ok)
836
+ return { ok: false, status: res.status, error: parsed?.error ?? `HTTP ${res.status}` };
837
+ return { ok: parsed?.ok !== false, status: res.status, data: parsed, error: parsed?.error };
838
+ } catch (error) {
839
+ return { ok: false, status: 0, error: error.message };
840
+ }
841
+ };
842
+ return {
843
+ me: () => call("GET", "/api/me"),
844
+ myShares: () => call("GET", "/api/me/shares")
845
+ };
846
+ }
789
847
  function adminTokenFor(url, cfg = currentConfig()) {
790
848
  const base = normServerUrl(url);
791
849
  const entry = serverEntries(cfg).find((s) => s.url === base);
@@ -802,11 +860,22 @@ async function waitForBinding(client, sid, timeoutMs = 25000) {
802
860
  }
803
861
  return;
804
862
  }
805
- async function requestShareConsent(host) {
863
+ async function requestShareConsent(host, context) {
864
+ const lines = ["Publish this session (and its subagent sessions) to your openshare worker?"];
865
+ if (context) {
866
+ const bits = [];
867
+ if (context.server)
868
+ bits.push(`server: ${context.server}`);
869
+ if (context.ttlDays !== undefined)
870
+ bits.push(`TTL: ${context.ttlDays}d`);
871
+ bits.push(`read auth: ${context.auth ? "on" : "off"}`);
872
+ lines.push(bits.join(" - "));
873
+ }
874
+ lines.push("Anyone with the link can read the conversation (login-gated only when read auth is on).");
806
875
  return host.ui.showConfirm({
807
876
  title: "Share Session",
808
- message: `Publish this session (and its subagent sessions) to your openshare worker?
809
- The link lets anyone with it read the conversation.`,
877
+ message: lines.join(`
878
+ `),
810
879
  confirmLabel: "Share"
811
880
  });
812
881
  }
@@ -814,16 +883,23 @@ async function shareWithConsent(host, sessionID) {
814
883
  if (!sessionID)
815
884
  return { ok: false, message: "No active session to share." };
816
885
  const cfg = currentConfig();
817
- if (!isConfigured(cfg)) {
818
- return { ok: false, message: "No share server connected \u2014 run /openshare to connect one.", unconfigured: true };
886
+ if (!isConfigured(cfg) && !engineTargetUrls().length) {
887
+ return { ok: false, message: "No share server connected - run /openshare to connect one.", unconfigured: true };
819
888
  }
820
889
  const root = await resolveRoot(host.client, sessionID);
821
- const existing = await readBinding(host.client, root);
822
- if (existing?.url && existing.enabled !== false) {
823
- await host.clipboard.write(existing.url);
824
- return { ok: true, message: `Share URL copied to clipboard: ${existing.url}`, url: existing.url };
890
+ const all = await readBindings(host.client, root);
891
+ const live = all.find((b) => b.url && b.enabled !== false && !b.optedOut);
892
+ if (live) {
893
+ await host.clipboard.write(live.url);
894
+ return { ok: true, message: `Share URL copied to clipboard: ${live.url}`, url: live.url };
825
895
  }
826
- if (!await requestShareConsent(host))
896
+ const opts = all[0]?.opts;
897
+ const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined);
898
+ if (!await requestShareConsent(host, {
899
+ server: server ? hostOf(server.url) : undefined,
900
+ ttlDays: opts?.ttlDays ?? cfg.ttlDays,
901
+ auth: !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword)
902
+ }))
827
903
  return { ok: false, message: "Share cancelled." };
828
904
  return confirmShare(host, root);
829
905
  }
@@ -833,12 +909,23 @@ async function confirmShare(host, rootSessionID) {
833
909
  dbgLog(`confirmShare: setShareFlag -> ${set}`);
834
910
  if (!set)
835
911
  return { ok: false, message: "Could not write the share flag into session metadata." };
912
+ await patchSessionBinding(host.client, rootSessionID, (servers) => {
913
+ for (const e of Object.values(servers)) {
914
+ if (e && typeof e === "object")
915
+ delete e.optedOut;
916
+ }
917
+ });
836
918
  const binding = await waitForBinding(host.client, rootSessionID);
837
919
  dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`);
838
920
  if (!binding?.url) {
921
+ await clearShareFlag(host.client, rootSessionID);
922
+ const fresh = await readBinding(host.client, rootSessionID);
923
+ const why = fresh?.lastError ? `
924
+ Reason: ${fresh.lastError}` : "";
839
925
  return {
840
926
  ok: false,
841
- message: "Share did not come up (timeout waiting for the URL). Is the worker reachable? Check the server plugin log for [openshare] lines."
927
+ message: `Share did not come up (timed out waiting for the URL); the share flag was disarmed.${why}
928
+ Check the [openshare] stderr lines for details.`
842
929
  };
843
930
  }
844
931
  await host.clipboard.write(binding.url);
@@ -944,13 +1031,15 @@ Regular users should NOT hold the root token: connect via an access code instead
944
1031
  },
945
1032
  shareMode: {
946
1033
  label: "Share mode",
947
- help: '"all" shares every session; "manual" needs /share. Default: TUI presence.',
1034
+ help: '"all" shares every session; "manual" needs /share. Default: TUI presence. Takes effect after opencode restarts.',
948
1035
  kind: "text",
949
1036
  options: ["auto", "all", "manual"],
950
1037
  section: "Sharing",
951
- info: `all: every NEW session shares automatically (CI posture). Sessions that existed before the plugin started are never auto-shared \u2014 share those explicitly. manual: sessions stay private until you run /share.
1038
+ 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.
952
1039
 
953
- Default (empty): TUI presence decides \u2014 installing the TUI part means manual, server-only means all.`,
1040
+ Default (empty): TUI presence decides - installing the TUI part means manual, server-only means all.
1041
+
1042
+ NOTE: 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.`,
954
1043
  def: "auto (TUI presence)"
955
1044
  },
956
1045
  debounceMs: {
@@ -991,15 +1080,6 @@ A graceful opencode exit flushes the pending flip, so CI runs land their final s
991
1080
  A per-session override lives in the This-session section; it is sent at create/attach time.`,
992
1081
  def: "worker cap (90 on the reference deployment)"
993
1082
  },
994
- openTasksInNewTab: {
995
- label: "Open tasks in new tab",
996
- help: "Viewer: task rows open subagent pages in a new tab.",
997
- kind: "text",
998
- options: ["true", "false"],
999
- section: "Sharing",
1000
- info: "Viewer behavior only: clicking a task (subagent) row opens its sub-page in a new browser tab instead of navigating. true/false.",
1001
- def: "true"
1002
- },
1003
1083
  authLogin: {
1004
1084
  label: "Read auth: login",
1005
1085
  help: "Optional read gate for share pages. Empty removes protection on the next attach.",
@@ -1063,10 +1143,8 @@ function helpForKey(key, cfg, sources) {
1063
1143
  function displayValue(key, cfg) {
1064
1144
  if (key === "createToken" || key === "authPassword")
1065
1145
  return mask(String(cfg[key] ?? ""));
1066
- if (key === "openTasksInNewTab")
1067
- return String(cfg.openTasksInNewTab !== false);
1068
1146
  if (key === "announceStderr")
1069
- return String(cfg.announceStderr !== false);
1147
+ return cfg.announceStderr !== false ? "on" : "off";
1070
1148
  const value = cfg[key];
1071
1149
  return value === undefined || value === "" ? "(not set)" : String(value);
1072
1150
  }
@@ -1090,7 +1168,7 @@ async function editKey(host, cfg, key) {
1090
1168
  const next = { ...cfg };
1091
1169
  if (key === "shareMode")
1092
1170
  next[key] = picked === "auto" ? undefined : picked;
1093
- else if (key === "openTasksInNewTab" || key === "announceStderr")
1171
+ else if (key === "announceStderr")
1094
1172
  next[key] = picked === "true";
1095
1173
  else
1096
1174
  next[key] = picked;
@@ -1173,6 +1251,8 @@ This creates your own user on ${url} (label prompt, prefilled), mints its token,
1173
1251
  options.push({ title: "Enroll now", value: "enroll", description: holdsRoot ? "join as a user via code/public enrollment" : "get a user token immediately" });
1174
1252
  if (mine.entry.token && !holdsRoot)
1175
1253
  options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" });
1254
+ if (mine.entry.token && !holdsRoot)
1255
+ options.push({ title: "Your account", value: "account", description: "limits, token expiry, your shares" });
1176
1256
  if (mine.entry.token && !holdsRoot)
1177
1257
  options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true });
1178
1258
  options.push({
@@ -1190,12 +1270,13 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
1190
1270
  if (choice === undefined || choice === "__back")
1191
1271
  return;
1192
1272
  if (choice === "active") {
1193
- for (const other of entries) {
1194
- if (other.url !== url)
1195
- saveServerEntry(other.url, { active: undefined });
1196
- }
1197
1273
  saveServerEntry(url, { active: true });
1198
- host.ui.toast({ variant: "success", title: "OpenShare", message: `${url} is now the active server.` });
1274
+ const stillActive = serverEntries().filter((s) => s.active);
1275
+ host.ui.toast({
1276
+ variant: "success",
1277
+ title: "OpenShare",
1278
+ 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.`
1279
+ });
1199
1280
  return;
1200
1281
  }
1201
1282
  if (choice === "adopt") {
@@ -1247,6 +1328,34 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
1247
1328
  }
1248
1329
  continue;
1249
1330
  }
1331
+ if (choice === "account") {
1332
+ const me = meClient(url, mine.entry.token ?? "");
1333
+ const [meRes, sharesRes] = await Promise.all([me.me(), me.myShares()]);
1334
+ if (!meRes.ok) {
1335
+ await showInfo(host.ui, { title: "Account", message: `Could not read your account: ${meRes.error ?? "error"}.
1336
+ If the token was revoked server-side, use Enroll now instead.` });
1337
+ continue;
1338
+ }
1339
+ const d = meRes.data;
1340
+ const lim = d.token?.limits ?? {};
1341
+ const fmt = (v) => v ? String(v) : "unlimited";
1342
+ const lines = [
1343
+ `User: ${d.label} (${d.userId})`,
1344
+ `Token: ${d.token?.label ?? "?"} - expires ${d.token?.expiresAt ? new Date(d.token.expiresAt).toISOString().slice(0, 10) : "never"}`,
1345
+ `Limits: live shares ${fmt(lim.maxLiveShares)} - max TTL ${fmt(lim.maxTtlDays)}d - daily creates ${fmt(lim.dailyCreates)}`
1346
+ ];
1347
+ if (sharesRes.ok) {
1348
+ const shares = sharesRes.data?.shares ?? [];
1349
+ lines.push(`Shares: ${shares.length} (${shares.filter((s) => s.status === "live").length} live)`);
1350
+ for (const s of shares.slice(0, 10))
1351
+ lines.push(` ${s.id} - ${s.status} - ${new Date(s.createdAt).toISOString().slice(0, 10)}`);
1352
+ if (shares.length > 10)
1353
+ lines.push(` ...and ${shares.length - 10} more`);
1354
+ }
1355
+ await showInfo(host.ui, { title: "Your account", message: lines.join(`
1356
+ `) });
1357
+ continue;
1358
+ }
1250
1359
  if (choice === "rotate") {
1251
1360
  const res = await rotateAtServer(url, mine.entry.token ?? "");
1252
1361
  if (res.ok && res.token) {
@@ -1258,8 +1367,13 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
1258
1367
  continue;
1259
1368
  }
1260
1369
  if (choice === "disconnect") {
1261
- const sure = await showConfirm(host.ui, { title: "Disconnect", message: `Revoke the token at ${url} and forget it?
1262
- Your shares stay (read-only for you until TTL).`, confirmLabel: "Disconnect", danger: true });
1370
+ const sure = await showConfirm(host.ui, {
1371
+ title: "Disconnect",
1372
+ message: `Revoke the token at ${url} and forget it?
1373
+ Revoking 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.`,
1374
+ confirmLabel: "Disconnect",
1375
+ danger: true
1376
+ });
1263
1377
  if (!sure)
1264
1378
  continue;
1265
1379
  const res = await disconnectAtServer(url, mine.entry.token ?? "");
@@ -1672,7 +1786,7 @@ async function adminSharesMenu(host, client, url, shares, users) {
1672
1786
  }
1673
1787
  }
1674
1788
  }
1675
- async function settingsDialog(host, startAtSession) {
1789
+ async function settingsDialog(host, startAtSession, startGroup) {
1676
1790
  let cfg = currentConfig();
1677
1791
  let dirty = false;
1678
1792
  const candidates = writeCandidates(moduleDir());
@@ -1854,11 +1968,24 @@ ${otherServers.map((s) => `\u2022 ${s.url}`).join(`
1854
1968
  }
1855
1969
  }
1856
1970
  const binding = bindings[0];
1971
+ if (binding?.lastError || binding?.ttlClamped) {
1972
+ const bits = [];
1973
+ if (binding?.ttlClamped)
1974
+ bits.push(`TTL clamped ${binding.ttlClamped.requested}d -> ${binding.ttlClamped.effective}d by server/token policy`);
1975
+ if (binding?.lastError)
1976
+ bits.push(`last share error: ${binding.lastError}`);
1977
+ 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):
1978
+
1979
+ ${bits.join(`
1980
+ `)}
1981
+
1982
+ The 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.` });
1983
+ }
1857
1984
  rows.push({
1858
1985
  title: "Session TTL override (days)",
1859
1986
  value: "session:ttl",
1860
1987
  description: binding?.opts?.ttlDays !== undefined ? String(binding.opts.ttlDays) : "(worker default)",
1861
- 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.
1988
+ 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.
1862
1989
 
1863
1990
  Current: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days` : "(none \u2014 config default)"}`
1864
1991
  });
@@ -1866,7 +1993,7 @@ Current: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days`
1866
1993
  title: "Session read auth",
1867
1994
  value: "session:auth",
1868
1995
  description: binding?.opts?.authLogin ? `${binding.opts.authLogin} / ****` : "(config default)",
1869
- help: `Login gate for THIS session's page only \u2014 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.
1996
+ help: `Login gate for THIS session's page only \u2014 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.
1870
1997
 
1871
1998
  Current: ${binding?.opts?.authLogin ? `${binding.opts.authLogin} / (password set)` : "(none \u2014 config default applies)"}`
1872
1999
  });
@@ -2049,11 +2176,9 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
2049
2176
  });
2050
2177
  if (!pick || pick === "__cancel")
2051
2178
  return;
2052
- for (const s of serverEntries())
2053
- if (s.url !== pick)
2054
- saveServerEntry(s.url, { active: undefined });
2055
2179
  saveServerEntry(pick, { active: true });
2056
- host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated \u2014 this session mirrors there within seconds.` });
2180
+ const nActive = serverEntries().filter((s) => s.active).length;
2181
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated (${nActive} active) - this session mirrors there within seconds.` });
2057
2182
  return;
2058
2183
  }
2059
2184
  if (choice === "session:unshare") {
@@ -2068,18 +2193,26 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
2068
2193
  if (how === undefined || how === "__cancel")
2069
2194
  return;
2070
2195
  if (how === "leave") {
2071
- const all = await readBindings(host.client, root);
2196
+ const allB = await readBindings(host.client, root);
2072
2197
  const okPatch = await patchSessionBinding(host.client, root, (servers) => {
2073
- for (const b of all) {
2198
+ for (const b of allB) {
2074
2199
  if (!b.server)
2075
2200
  continue;
2076
- delete servers[b.server];
2077
- servers[b.server] = { optedOut: true };
2201
+ servers[b.server] = { ...servers[b.server], id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true };
2078
2202
  }
2079
2203
  });
2080
- host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is \u2014 snapshots until their TTLs." : "Could not update the binding." });
2204
+ host.ui.toast({ variant: okPatch ? "success" : "error", title: "OpenShare", message: okPatch ? "Left as-is \u2014 snapshots until their TTLs (delete stays available here)." : "Could not update the binding." });
2081
2205
  return;
2082
2206
  }
2207
+ const sureAll = await showConfirm(host.ui, {
2208
+ title: "Delete every page?",
2209
+ message: `Permanently delete ${live.length} share page(s) and stop sharing?
2210
+ Every link stops working for everyone. This cannot be undone.`,
2211
+ confirmLabel: "Delete every page",
2212
+ danger: true
2213
+ });
2214
+ if (!sureAll)
2215
+ return;
2083
2216
  const result = await runUnshareFlow(host, startAtSession);
2084
2217
  host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message });
2085
2218
  return;
@@ -2099,30 +2232,37 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
2099
2232
  }
2100
2233
  opts.ttlDays = Math.floor(days);
2101
2234
  }
2102
- await setShareFlag(host.client, root, opts);
2103
- 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" });
2235
+ await setShareOpts(host.client, root, opts);
2236
+ const liveNow = live.length > 0;
2237
+ host.ui.toast({
2238
+ variant: "success",
2239
+ title: "OpenShare",
2240
+ message: liveNow ? `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.` : `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d` : "(default)"} - saved, applies when you share.`
2241
+ });
2104
2242
  return;
2105
2243
  }
2106
2244
  if (choice === "session:auth") {
2107
- const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = use config default", value: current?.opts?.authLogin ?? "" });
2245
+ const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = no read auth", value: current?.opts?.authLogin ?? "" });
2108
2246
  if (login === undefined)
2109
2247
  return;
2110
- const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "empty = no password" });
2248
+ const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: login === "" ? "(unused)" : "password (required with a login)" });
2111
2249
  if (password === undefined)
2112
2250
  return;
2251
+ if (login === "" !== (password === "")) {
2252
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "Read auth needs both a login and a password - or neither (no gate)." });
2253
+ return;
2254
+ }
2113
2255
  const opts = { ...current?.opts ?? {} };
2114
2256
  if (login === "") {
2115
2257
  delete opts.authLogin;
2116
2258
  delete opts.authPassword;
2117
2259
  } else {
2118
2260
  opts.authLogin = login;
2119
- if (password !== "")
2120
- opts.authPassword = password;
2121
- else
2122
- delete opts.authPassword;
2261
+ opts.authPassword = password;
2123
2262
  }
2124
- await setShareFlag(host.client, root, opts);
2125
- host.ui.toast({ variant: "success", title: "OpenShare", message: "Session read auth saved (applies at the next attach/create)." });
2263
+ await setShareOpts(host.client, root, opts);
2264
+ const liveNow2 = live.length > 0;
2265
+ 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.` });
2126
2266
  return;
2127
2267
  }
2128
2268
  }
@@ -2139,6 +2279,8 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
2139
2279
  await dispatch(choice);
2140
2280
  }
2141
2281
  };
2282
+ if (startAtSession && startGroup === "session")
2283
+ await groupMenu("session");
2142
2284
  const seconds = (ms, fallback) => `${Math.round((ms ?? fallback) / 100) / 10}s`;
2143
2285
  while (true) {
2144
2286
  const servers = serverEntries(cfg);
@@ -2363,7 +2505,7 @@ function buildShareCommand(host, sessionID) {
2363
2505
  dbgLog(`/share run: entered (sid=${sessionID() ?? "none"})`);
2364
2506
  host.ui.dialog.clear();
2365
2507
  const cfg = currentConfig();
2366
- if (!isConfigured(cfg)) {
2508
+ if (!isConfigured(cfg) && !engineTargetUrls().length) {
2367
2509
  onboardingDialog(host);
2368
2510
  return;
2369
2511
  }
@@ -2378,7 +2520,7 @@ function buildShareCommand(host, sessionID) {
2378
2520
  return;
2379
2521
  const { all, goneShown } = swept;
2380
2522
  if (all.some((b) => b.url && b.enabled !== false)) {
2381
- await settingsDialog(host, sid);
2523
+ await settingsDialog(host, sid, "session");
2382
2524
  return;
2383
2525
  }
2384
2526
  if (goneShown && all.length === 0)
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "Open(code)Share plugin config. Priority: OPENSHARE_* env vars > plugin options > this file > code defaults. Sync cadence keys are clamped into the worker's policy window (see worker SOCKET_POLICY/DEBOUNCE_MIN_MS/DEBOUNCE_MAX_MS/COMPLETED_DELAY_MAX_MS). Keys: workerUrl (your openshare-worker deployment; empty = plugin stays disabled), createToken (legacy: the worker's CREATE_TOKEN — keep OUT of git), servers (multi-server memberships: url -> {token, code, label, active, adminToken (root token for the TUI admin console)}; the active entry (or the first) receives new sessions; with no token the plugin ENROLLS itself — public servers take anyone, private ones need a code from the hoster; enrolled tokens persist to ~/.config/opencode/openshare.config.json), debounceMs (sync interval: max time between syncs while streaming; default 10000), completedDelayMs (idle this long before the share says completed; default 5000, 0 = immediate), modelLabel ($Provider/$Model placeholders; literal text sends as-is; empty string hides the model), ttlDays (per-share TTL, capped by the worker), openTasksInNewTab (task rows open subagent pages in a new tab), authLogin/authPassword (optional read gate on the share pages; sent on create AND resume; empty on resume removes protection), spoolDir (temporary unacked-frame buffer; default XDG data home), urlFile (write each share URL to this file; empty = default openshare.urls.txt in the session's directory; \"none\" = no URL file), announceStderr (false silences the [openshare] live: / url file: log lines; the share itself is unaffected).",
2
+ "$comment": "Open(code)Share plugin config. Priority: OPENSHARE_* env vars > plugin options > this file > code defaults. Sync cadence keys are clamped into the worker's policy window (see worker SOCKET_POLICY/DEBOUNCE_MIN_MS/DEBOUNCE_MAX_MS/COMPLETED_DELAY_MAX_MS). Keys: workerUrl (your openshare-worker deployment; empty = plugin stays disabled), createToken (legacy: the worker's CREATE_TOKEN — keep OUT of git), servers (multi-server memberships: url -> {token, code, label, active, adminToken (root token for the TUI admin console)}; the active entry (or the first) receives new sessions; with no token the plugin ENROLLS itself — public servers take anyone, private ones need a code from the hoster; enrolled tokens persist to ~/.config/opencode/openshare.config.json), debounceMs (sync interval: max time between syncs while streaming; default 10000), completedDelayMs (idle this long before the share says completed; default 5000, 0 = immediate), modelLabel ($Provider/$Model placeholders; literal text sends as-is; empty string hides the model), ttlDays (per-share TTL, capped by the worker), authLogin/authPassword (optional read gate on the share pages; sent on create AND resume; empty on resume removes protection), spoolDir (temporary unacked-frame buffer; default XDG data home), urlFile (write each share URL to this file; empty = default openshare.urls.txt in the session's directory; \"none\" = no URL file), announceStderr (false silences the [openshare] live: / url file: log lines; the share itself is unaffected).",
3
3
  "workerUrl": "",
4
4
  "createToken": "",
5
5
  "servers": {},
@@ -7,7 +7,6 @@
7
7
  "completedDelayMs": 5000,
8
8
  "modelLabel": "$Provider/$Model",
9
9
  "ttlDays": 90,
10
- "openTasksInNewTab": true,
11
10
  "authLogin": "",
12
11
  "authPassword": "",
13
12
  "spoolDir": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mirrowel/opencode-openshare",
3
- "version": "0.2.0-dev.2",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Open(code)Share — re:share: self-hosted session sharing for opencode. Server plugin (single file or package), optional TUI for interactive opt-in sharing.",
6
6
  "license": "MIT",
package/src/banner.txt CHANGED
@@ -19,7 +19,7 @@
19
19
  // > code defaults. Keys: servers (multi-server: url -> {token, code,
20
20
  // label, active, adminToken}), workerUrl, createToken (legacy pair),
21
21
  // shareMode ("all" | "manual"; default = TUI presence), debounceMs,
22
- // completedDelayMs, modelLabel, ttlDays, openTasksInNewTab,
22
+ // completedDelayMs, modelLabel, ttlDays,
23
23
  // authLogin, authPassword, spoolDir, urlFile ("none" = off),
24
24
  // announceStderr.
25
25
  //
package/src/commands.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  verifyShare,
28
28
  } from "./share-flow.js"
29
29
  import { onboardingDialog, settingsDialog } from "./settings.js"
30
+ import { engineTargetUrls } from "./core.js"
30
31
  import { dbgLog } from "./sidebar.js"
31
32
  import type { ShareHostApi, ShareHostLayerCommand } from "./tui-host.js"
32
33
 
@@ -152,7 +153,9 @@ export function buildShareCommand(host: ShareHostApi, sessionID: () => string |
152
153
  dbgLog(`/share run: entered (sid=${sessionID() ?? "none"})`)
153
154
  host.ui.dialog.clear()
154
155
  const cfg = currentConfig()
155
- if (!isConfigured(cfg)) {
156
+ // engine-aware gate: project-level plugin options can configure
157
+ // targets the TUI's file chain cannot see - trust the live engine
158
+ if (!isConfigured(cfg) && !engineTargetUrls().length) {
156
159
  void onboardingDialog(host)
157
160
  return
158
161
  }
@@ -171,12 +174,12 @@ export function buildShareCommand(host: ShareHostApi, sessionID: () => string |
171
174
  // enabled:true; legacy all-mode bindings without the field are live
172
175
  // too — only an explicit false is dormant)
173
176
  if (all.some((b) => b.url && b.enabled !== false)) {
174
- // THE session menu — identical to /openshare → This session by
175
- // construction (one surface, no duplicated manager). No
176
- // dialog.clear() after: settings owns its chain end-to-end (the
177
- // old fire-and-forget menu + immediate clear wiped it instantly —
178
- // live-caught as "/share did nothing" on a shared session).
179
- await settingsDialog(host, sid)
177
+ // THE session menu - identical to /openshare This session by
178
+ // construction (one surface, no duplicated manager), opened
179
+ // DIRECTLY (owner: "/share is supposed to be the same menu, not
180
+ // the settings root one hop away"). No dialog.clear() after:
181
+ // settings owns its chain end-to-end.
182
+ await settingsDialog(host, sid, "session")
180
183
  return
181
184
  }
182
185
  // everything was just cleared by the gone modal and the user chose
package/src/config.ts CHANGED
@@ -49,7 +49,6 @@ export type ShareConfig = {
49
49
  completedDelayMs: number
50
50
  modelLabel: string
51
51
  ttlDays: number
52
- openTasksInNewTab: boolean
53
52
  authLogin: string
54
53
  authPassword: string
55
54
  spoolDir: string
@@ -78,7 +77,6 @@ export const DEFAULTS: ShareConfig = {
78
77
  completedDelayMs: 5000,
79
78
  modelLabel: "$Provider/$Model",
80
79
  ttlDays: 90,
81
- openTasksInNewTab: true,
82
80
  authLogin: "",
83
81
  authPassword: "",
84
82
  spoolDir: "",
@@ -348,7 +346,13 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
348
346
  if (typeof value === "string" && value === "" && emptyMeansUnset.has(key)) continue
349
347
  ;(cfg as Record<string, unknown>)[key] = value
350
348
  }
351
- } catch {}
349
+ } catch (e) {
350
+ // a malformed config file is a real setup error the user must hear
351
+ // about - silence here looks like "no share server configured".
352
+ // (Absent files are normal chain candidates - only broken content
353
+ // is worth a line.)
354
+ if (existsSync(path)) console.error(`[openshare] config file ${path} could not be parsed - ignored:`, (e as Error)?.message ?? e)
355
+ }
352
356
  }
353
357
 
354
358
  if (options && typeof options === "object") {
@@ -382,6 +386,16 @@ export function loadConfig(pluginDir: string, options?: Record<string, unknown>)
382
386
  // normalize the disable sentinel (case-insensitive from any source)
383
387
  if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE) cfg.urlFile = URL_FILE_NONE
384
388
 
389
+ // URL hygiene: a trailing slash on a server url becomes
390
+ // wss://host//api/ws and https://host//s/<id> downstream - strip once,
391
+ // at the source, so every consumer agrees
392
+ if (cfg.workerUrl) cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "")
393
+ if (cfg.servers) {
394
+ const fixed: Record<string, ServerEntry> = {}
395
+ for (const [u, e] of Object.entries(cfg.servers)) fixed[u.trim().replace(/\/+$/, "")] = e
396
+ cfg.servers = fixed
397
+ }
398
+
385
399
  return cfg
386
400
  }
387
401