@mirrowel/opencode-openshare 0.2.0 → 0.3.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js CHANGED
@@ -22,12 +22,13 @@ var DEFAULTS = {
22
22
  debounceMs: 1e4,
23
23
  completedDelayMs: 5000,
24
24
  modelLabel: "$Provider/$Model",
25
- ttlDays: 90,
26
- openTasksInNewTab: true,
25
+ ttlHours: 90 * 24,
27
26
  authLogin: "",
28
27
  authPassword: "",
29
28
  spoolDir: "",
30
29
  urlFile: "",
30
+ urlFileFormat: "url",
31
+ configSet: "",
31
32
  announceStderr: true
32
33
  };
33
34
  var CONFIG_NAME = "openshare.config.json";
@@ -98,10 +99,13 @@ function configSourceMap(pluginDir, env = process.env) {
98
99
  debounceMs: env.OPENSHARE_DEBOUNCE_MS,
99
100
  completedDelayMs: env.OPENSHARE_COMPLETED_DELAY_MS,
100
101
  modelLabel: env.OPENSHARE_MODEL_LABEL,
102
+ ttlHours: env.OPENSHARE_TTL_HOURS,
101
103
  ttlDays: env.OPENSHARE_TTL_DAYS,
102
104
  authLogin: env.OPENSHARE_AUTH_LOGIN,
103
105
  authPassword: env.OPENSHARE_AUTH_PASSWORD,
104
106
  urlFile: env.OPENSHARE_URL_FILE,
107
+ urlFileFormat: env.OPENSHARE_URL_FILE_FORMAT,
108
+ configSet: env.OPENSHARE_CONFIG_SET,
105
109
  spoolDir: env.OPENSHARE_SPOOL_DIR,
106
110
  announceStderr: env.OPENSHARE_ANNOUNCE_STDERR,
107
111
  shareMode: env.OPENSHARE_SHARE_MODE
@@ -110,6 +114,24 @@ function configSourceMap(pluginDir, env = process.env) {
110
114
  if (value !== undefined && value !== "")
111
115
  sources[key] = "environment";
112
116
  }
117
+ if (sources.ttlDays && !sources.ttlHours)
118
+ sources.ttlHours = sources.ttlDays;
119
+ delete sources.ttlDays;
120
+ if (env.OPENSHARE_CONFIG_JSON) {
121
+ try {
122
+ const raw = JSON.parse(env.OPENSHARE_CONFIG_JSON);
123
+ if (raw && typeof raw === "object") {
124
+ for (const [key, value] of Object.entries(raw)) {
125
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
126
+ continue;
127
+ if (sources[key] === "environment")
128
+ continue;
129
+ if (key === "servers" || known.has(key))
130
+ sources[key] = "OPENSHARE_CONFIG_JSON";
131
+ }
132
+ }
133
+ } catch {}
134
+ }
113
135
  for (const path of configPaths(pluginDir, env)) {
114
136
  let raw;
115
137
  try {
@@ -163,6 +185,8 @@ function normalizeServers(raw) {
163
185
  entry.adminToken = e.adminToken;
164
186
  if (e.active === true)
165
187
  entry.active = true;
188
+ if (e.anonymous === true)
189
+ entry.anonymous = true;
166
190
  out[url] = entry;
167
191
  }
168
192
  return Object.keys(out).length ? out : undefined;
@@ -232,27 +256,57 @@ function loadConfig(pluginDir, options) {
232
256
  const cfg = { ...DEFAULTS };
233
257
  const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"]);
234
258
  const known = new Set([...Object.keys(DEFAULTS), "shareMode"]);
235
- for (const path of [...configPaths(pluginDir)].reverse()) {
236
- try {
237
- const raw = JSON.parse(readFileSync(path, "utf8"));
238
- if (!raw || typeof raw !== "object")
259
+ const applyObject = (raw) => {
260
+ if (!raw || typeof raw !== "object")
261
+ return;
262
+ for (const [key, value] of Object.entries(raw)) {
263
+ if (key === "servers") {
264
+ const servers = normalizeServers(value);
265
+ if (servers)
266
+ cfg.servers = mergeServerGroups(cfg.servers, servers);
239
267
  continue;
240
- for (const [key, value] of Object.entries(raw)) {
241
- if (key === "servers") {
242
- const servers = normalizeServers(value);
243
- if (servers)
244
- cfg.servers = mergeServerGroups(cfg.servers, servers);
245
- continue;
268
+ }
269
+ if (key === "redact") {
270
+ if (value && typeof value === "object" && Array.isArray(value.patterns)) {
271
+ const pats = value.patterns.filter((x) => typeof x === "string" && x.length > 0 && x.length < 500).slice(0, 50);
272
+ cfg.redact = { patterns: pats };
246
273
  }
247
- if (!known.has(key))
248
- continue;
249
- if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
250
- continue;
251
- if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
252
- continue;
253
- cfg[key] = value;
274
+ continue;
254
275
  }
255
- } catch {}
276
+ if (key === "meta") {
277
+ if (value && typeof value === "object" && !Array.isArray(value)) {
278
+ const clean = {};
279
+ for (const [mk, mv] of Object.entries(value)) {
280
+ if (typeof mv === "string" && mv.length <= 500 && mk.length <= 64)
281
+ clean[mk] = mv;
282
+ }
283
+ if (Object.keys(clean).length)
284
+ cfg.meta = { ...cfg.meta, ...clean };
285
+ }
286
+ continue;
287
+ }
288
+ if (key === "ttlDays") {
289
+ const d = Math.floor(Number(value));
290
+ if (Number.isFinite(d) && d > 0)
291
+ cfg.ttlHours = d * 24;
292
+ continue;
293
+ }
294
+ if (!known.has(key))
295
+ continue;
296
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
297
+ continue;
298
+ if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
299
+ continue;
300
+ cfg[key] = value;
301
+ }
302
+ };
303
+ for (const path of [...configPaths(pluginDir)].reverse()) {
304
+ try {
305
+ applyObject(JSON.parse(readFileSync(path, "utf8")));
306
+ } catch (e) {
307
+ if (existsSync(path))
308
+ console.error(`[openshare] config file ${path} could not be parsed - ignored:`, e?.message ?? e);
309
+ }
256
310
  }
257
311
  if (options && typeof options === "object") {
258
312
  const serversOpt = normalizeServers(options.servers);
@@ -271,6 +325,13 @@ function loadConfig(pluginDir, options) {
271
325
  cfg.shareMode = mode;
272
326
  }
273
327
  const env = process.env;
328
+ if (env.OPENSHARE_CONFIG_JSON) {
329
+ try {
330
+ applyObject(JSON.parse(env.OPENSHARE_CONFIG_JSON));
331
+ } catch (e) {
332
+ console.error("[openshare] OPENSHARE_CONFIG_JSON could not be parsed - ignored:", e?.message ?? e);
333
+ }
334
+ }
274
335
  if (env.OPENSHARE_WORKER_URL)
275
336
  cfg.workerUrl = env.OPENSHARE_WORKER_URL;
276
337
  if (env.OPENSHARE_CREATE_TOKEN)
@@ -281,14 +342,20 @@ function loadConfig(pluginDir, options) {
281
342
  cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs);
282
343
  if (env.OPENSHARE_MODEL_LABEL !== undefined)
283
344
  cfg.modelLabel = env.OPENSHARE_MODEL_LABEL;
345
+ if (env.OPENSHARE_TTL_HOURS)
346
+ cfg.ttlHours = num(env.OPENSHARE_TTL_HOURS, cfg.ttlHours);
284
347
  if (env.OPENSHARE_TTL_DAYS)
285
- cfg.ttlDays = num(env.OPENSHARE_TTL_DAYS, cfg.ttlDays);
348
+ cfg.ttlHours = Math.max(1, Math.floor(num(env.OPENSHARE_TTL_DAYS, 90) * 24));
286
349
  if (env.OPENSHARE_AUTH_LOGIN !== undefined)
287
350
  cfg.authLogin = env.OPENSHARE_AUTH_LOGIN;
288
351
  if (env.OPENSHARE_AUTH_PASSWORD !== undefined)
289
352
  cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD;
290
353
  if (env.OPENSHARE_URL_FILE !== undefined)
291
354
  cfg.urlFile = env.OPENSHARE_URL_FILE;
355
+ if (env.OPENSHARE_URL_FILE_FORMAT === "json" || env.OPENSHARE_URL_FILE_FORMAT === "url")
356
+ cfg.urlFileFormat = env.OPENSHARE_URL_FILE_FORMAT;
357
+ if (env.OPENSHARE_CONFIG_SET)
358
+ cfg.configSet = env.OPENSHARE_CONFIG_SET;
292
359
  if (env.OPENSHARE_SPOOL_DIR)
293
360
  cfg.spoolDir = env.OPENSHARE_SPOOL_DIR;
294
361
  if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined)
@@ -297,6 +364,14 @@ function loadConfig(pluginDir, options) {
297
364
  cfg.shareMode = env.OPENSHARE_SHARE_MODE;
298
365
  if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE)
299
366
  cfg.urlFile = URL_FILE_NONE;
367
+ if (cfg.workerUrl)
368
+ cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "");
369
+ if (cfg.servers) {
370
+ const fixed = {};
371
+ for (const [u, e] of Object.entries(cfg.servers))
372
+ fixed[u.trim().replace(/\/+$/, "")] = e;
373
+ cfg.servers = fixed;
374
+ }
300
375
  return cfg;
301
376
  }
302
377
  function globalConfigDir(env) {
@@ -434,6 +509,22 @@ function queueBindingWrite(sid, fn) {
434
509
  return run;
435
510
  }
436
511
 
512
+ // src/redact.ts
513
+ var PATH_KEYS = new Set(["file", "path", "filePath", "cwd", "directory", "root"]);
514
+
515
+ // src/core.ts
516
+ var liveTargets = [];
517
+ var statsProvider = null;
518
+ var redactionLedger = new Map;
519
+ function sessionStatsFor(sid) {
520
+ const led = redactionLedger.get(sid) ?? { count: 0, samples: [] };
521
+ const counts = statsProvider ? statsProvider(sid) : { messages: 0, toolCalls: 0 };
522
+ return { ...counts, redaction: led };
523
+ }
524
+ function engineTargetUrls() {
525
+ return liveTargets;
526
+ }
527
+
437
528
  // src/share-flow.ts
438
529
  function currentConfig(baseDir = moduleDir()) {
439
530
  return loadConfig(baseDir);
@@ -458,8 +549,7 @@ function saveConfig(cfg, target, baseDir = moduleDir()) {
458
549
  "debounceMs",
459
550
  "completedDelayMs",
460
551
  "modelLabel",
461
- "ttlDays",
462
- "openTasksInNewTab",
552
+ "ttlHours",
463
553
  "authLogin",
464
554
  "authPassword",
465
555
  "spoolDir",
@@ -490,8 +580,8 @@ function serverEntries(cfg = currentConfig()) {
490
580
  for (const [url, entry] of Object.entries(cfg.servers))
491
581
  merged[url] = { ...merged[url], ...entry };
492
582
  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 }));
583
+ const anyActive = urls.some((u) => merged[u].active);
584
+ return urls.map((url) => ({ url, entry: merged[url], active: anyActive ? merged[url].active === true : url === urls[0] }));
495
585
  }
496
586
  function bindingsOf(info) {
497
587
  const raw = info?.metadata?.[METADATA_KEY];
@@ -500,6 +590,8 @@ function bindingsOf(info) {
500
590
  const record = raw;
501
591
  const opts = record.opts ?? undefined;
502
592
  const enabled = record.enabled === true ? true : undefined;
593
+ const lastError = typeof record.lastError === "string" ? record.lastError : undefined;
594
+ const ttlClamped = record.ttlClamped && typeof record.ttlClamped === "object" ? record.ttlClamped : undefined;
503
595
  const out = [];
504
596
  if (record.servers && typeof record.servers === "object") {
505
597
  for (const [server, entry] of Object.entries(record.servers)) {
@@ -517,7 +609,10 @@ function bindingsOf(info) {
517
609
  enabled,
518
610
  opts,
519
611
  server,
520
- collided
612
+ collided,
613
+ optedOut: e.optedOut === true ? true : undefined,
614
+ lastError,
615
+ ttlClamped
521
616
  });
522
617
  }
523
618
  return out;
@@ -532,7 +627,9 @@ function bindingsOf(info) {
532
627
  enabled,
533
628
  opts,
534
629
  server: "",
535
- collided
630
+ collided,
631
+ lastError,
632
+ ttlClamped
536
633
  });
537
634
  }
538
635
  return out;
@@ -540,6 +637,23 @@ function bindingsOf(info) {
540
637
  async function readBindings(client, sid) {
541
638
  return bindingsOf(await getSession(client, sid));
542
639
  }
640
+ function fmtHours(hours) {
641
+ if (!Number.isFinite(hours) || hours <= 0)
642
+ return "?";
643
+ if (hours % 24 === 0)
644
+ return `${Math.floor(hours / 24)}d`;
645
+ return `${Math.floor(hours)}h`;
646
+ }
647
+ function parseTtlHours(raw) {
648
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(h|d|hr|hours?|days?)?\s*$/i.exec(raw ?? "");
649
+ if (!m)
650
+ return;
651
+ const v = Number(m[1]);
652
+ if (!Number.isFinite(v) || v <= 0)
653
+ return;
654
+ const unit = (m[2] ?? "h").toLowerCase();
655
+ return Math.max(1, Math.floor(unit.startsWith("d") ? v * 24 : v));
656
+ }
543
657
  function hostOf(url) {
544
658
  try {
545
659
  return new URL(url).host;
@@ -626,6 +740,20 @@ async function setShareFlag(client, sid, opts) {
626
740
  metadata[METADATA_KEY] = value;
627
741
  return patchMetadata(client, sid, metadata);
628
742
  }
743
+ async function setShareOpts(client, sid, opts) {
744
+ const info = await getSession(client, sid);
745
+ if (!info)
746
+ return false;
747
+ const metadata = { ...info.metadata ?? {} };
748
+ const existing = info.metadata?.[METADATA_KEY];
749
+ const value = existing && typeof existing === "object" ? { ...existing } : { v: 2, servers: {} };
750
+ if (Object.keys(opts).length)
751
+ value.opts = opts;
752
+ else
753
+ delete value.opts;
754
+ metadata[METADATA_KEY] = value;
755
+ return patchMetadata(client, sid, metadata);
756
+ }
629
757
  async function clearShareFlag(client, sid) {
630
758
  const info = await getSession(client, sid);
631
759
  if (!info)
@@ -647,6 +775,27 @@ async function workerDeleteShare(workerUrl, id, secret) {
647
775
  return false;
648
776
  }
649
777
  }
778
+ async function patchShareOpts(workerUrl, id, opts, secret) {
779
+ const body = { secret };
780
+ if (opts.authLogin !== undefined && opts.authPassword !== undefined) {
781
+ body.authLogin = opts.authLogin;
782
+ body.authPassword = opts.authPassword;
783
+ }
784
+ if (opts.ttlHours !== undefined)
785
+ body.ttlHours = opts.ttlHours;
786
+ try {
787
+ const res = await fetch(`${workerUrl.replace(/\/$/, "")}/api/share/${id}`, {
788
+ method: "PATCH",
789
+ headers: { "content-type": "application/json" },
790
+ body: JSON.stringify(body),
791
+ signal: AbortSignal.timeout(8000)
792
+ });
793
+ const json = await res.json().catch(() => ({}));
794
+ return res.ok && json.ok ? { ok: true } : { ok: false, error: json.error ?? `HTTP ${res.status}` };
795
+ } catch (e) {
796
+ return { ok: false, error: e?.message || String(e) };
797
+ }
798
+ }
650
799
  async function probeWorker(workerUrl) {
651
800
  if (!workerUrl)
652
801
  return { ok: false, detail: "workerUrl is empty" };
@@ -786,6 +935,28 @@ function adminClient(workerUrl, token) {
786
935
  health: () => call("GET", "/health")
787
936
  };
788
937
  }
938
+ function meClient(workerUrl, token) {
939
+ const base = normServerUrl(workerUrl);
940
+ const call = async (method, path) => {
941
+ try {
942
+ const res = await fetch(`${base}${path}`, {
943
+ method,
944
+ headers: { authorization: `Bearer ${token}` },
945
+ signal: AbortSignal.timeout(12000)
946
+ });
947
+ const parsed = await res.json().catch(() => ({}));
948
+ if (!res.ok)
949
+ return { ok: false, status: res.status, error: parsed?.error ?? `HTTP ${res.status}` };
950
+ return { ok: parsed?.ok !== false, status: res.status, data: parsed, error: parsed?.error };
951
+ } catch (error) {
952
+ return { ok: false, status: 0, error: error.message };
953
+ }
954
+ };
955
+ return {
956
+ me: () => call("GET", "/api/me"),
957
+ myShares: () => call("GET", "/api/me/shares")
958
+ };
959
+ }
789
960
  function adminTokenFor(url, cfg = currentConfig()) {
790
961
  const base = normServerUrl(url);
791
962
  const entry = serverEntries(cfg).find((s) => s.url === base);
@@ -802,11 +973,24 @@ async function waitForBinding(client, sid, timeoutMs = 25000) {
802
973
  }
803
974
  return;
804
975
  }
805
- async function requestShareConsent(host) {
976
+ async function requestShareConsent(host, context) {
977
+ const lines = ["Publish this session (and its subagent sessions) to your openshare worker?"];
978
+ if (context) {
979
+ const bits = [];
980
+ if (context.server)
981
+ bits.push(`server: ${context.server}`);
982
+ if (context.ttlHours !== undefined)
983
+ bits.push(`TTL: ${fmtHours(context.ttlHours)}`);
984
+ if (context.redaction && context.redaction.hits > 0)
985
+ bits.push(`redaction: ${context.redaction.hits} hit(s)`);
986
+ bits.push(`read auth: ${context.auth ? "on" : "off"}`);
987
+ lines.push(bits.join(" - "));
988
+ }
989
+ lines.push("Anyone with the link can read the conversation (login-gated only when read auth is on).");
806
990
  return host.ui.showConfirm({
807
991
  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.`,
992
+ message: lines.join(`
993
+ `),
810
994
  confirmLabel: "Share"
811
995
  });
812
996
  }
@@ -814,17 +998,30 @@ async function shareWithConsent(host, sessionID) {
814
998
  if (!sessionID)
815
999
  return { ok: false, message: "No active session to share." };
816
1000
  const cfg = currentConfig();
817
- if (!isConfigured(cfg)) {
818
- return { ok: false, message: "No share server connected \u2014 run /openshare to connect one.", unconfigured: true };
1001
+ if (!isConfigured(cfg) && !engineTargetUrls().length) {
1002
+ return { ok: false, message: "No share server connected - run /openshare to connect one.", unconfigured: true };
819
1003
  }
820
1004
  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 };
1005
+ const all = await readBindings(host.client, root);
1006
+ const live = all.find((b) => b.url && b.enabled !== false && !b.optedOut);
1007
+ if (live) {
1008
+ await host.clipboard.write(live.url);
1009
+ return { ok: true, message: `Share URL copied to clipboard: ${live.url}`, url: live.url };
1010
+ }
1011
+ const opts = all[0]?.opts;
1012
+ const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined);
1013
+ {
1014
+ const stats = sessionStatsFor(root);
1015
+ const gated = !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword);
1016
+ if (!await requestShareConsent(host, {
1017
+ server: server ? hostOf(server.url) : undefined,
1018
+ ttlHours: opts?.ttlHours ?? cfg.ttlHours,
1019
+ auth: gated,
1020
+ counts: { messages: stats.messages, toolCalls: stats.toolCalls },
1021
+ redaction: gated ? undefined : { hits: stats.redaction.count, samples: stats.redaction.samples.map((h) => `${h.sample} [${h.kind}]`) }
1022
+ }))
1023
+ return { ok: false, message: "Share cancelled." };
825
1024
  }
826
- if (!await requestShareConsent(host))
827
- return { ok: false, message: "Share cancelled." };
828
1025
  return confirmShare(host, root);
829
1026
  }
830
1027
  async function confirmShare(host, rootSessionID) {
@@ -833,12 +1030,23 @@ async function confirmShare(host, rootSessionID) {
833
1030
  dbgLog(`confirmShare: setShareFlag -> ${set}`);
834
1031
  if (!set)
835
1032
  return { ok: false, message: "Could not write the share flag into session metadata." };
1033
+ await patchSessionBinding(host.client, rootSessionID, (servers) => {
1034
+ for (const e of Object.values(servers)) {
1035
+ if (e && typeof e === "object")
1036
+ delete e.optedOut;
1037
+ }
1038
+ });
836
1039
  const binding = await waitForBinding(host.client, rootSessionID);
837
1040
  dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`);
838
1041
  if (!binding?.url) {
1042
+ await clearShareFlag(host.client, rootSessionID);
1043
+ const fresh = await readBinding(host.client, rootSessionID);
1044
+ const why = fresh?.lastError ? `
1045
+ Reason: ${fresh.lastError}` : "";
839
1046
  return {
840
1047
  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."
1048
+ message: `Share did not come up (timed out waiting for the URL); the share flag was disarmed.${why}
1049
+ Check the [openshare] stderr lines for details.`
842
1050
  };
843
1051
  }
844
1052
  await host.clipboard.write(binding.url);
@@ -944,13 +1152,15 @@ Regular users should NOT hold the root token: connect via an access code instead
944
1152
  },
945
1153
  shareMode: {
946
1154
  label: "Share mode",
947
- help: '"all" shares every session; "manual" needs /share. Default: TUI presence.',
1155
+ help: '"all" shares every session; "manual" needs /share. Default: TUI presence. Takes effect after opencode restarts.',
948
1156
  kind: "text",
949
1157
  options: ["auto", "all", "manual"],
950
1158
  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.
1159
+ 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
1160
 
953
- Default (empty): TUI presence decides \u2014 installing the TUI part means manual, server-only means all.`,
1161
+ Default (empty): TUI presence decides - installing the TUI part means manual, server-only means all.
1162
+
1163
+ 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
1164
  def: "auto (TUI presence)"
955
1165
  },
956
1166
  debounceMs: {
@@ -981,24 +1191,15 @@ A graceful opencode exit flushes the pending flip, so CI runs land their final s
981
1191
  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.',
982
1192
  def: '"$Provider/$Model"'
983
1193
  },
984
- ttlDays: {
985
- label: "TTL (days)",
986
- help: "Share lifetime; the worker caps it at its MAX_TTL_DAYS.",
1194
+ ttlHours: {
1195
+ label: "TTL (hours)",
1196
+ help: "Share lifetime in hours (2d writes 48); capped server-side.",
987
1197
  kind: "number",
988
1198
  section: "Sharing",
989
- 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.
1199
+ 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.
990
1200
 
991
- A per-session override lives in the This-session section; it is sent at create/attach time.`,
992
- def: "worker cap (90 on the reference deployment)"
993
- },
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"
1201
+ A per-session override lives in the This-session section; it is sent at create/attach time. The session prompt takes 6h / 2d style input.`,
1202
+ def: "worker cap (2160h = 90d on the reference deployment)"
1002
1203
  },
1003
1204
  authLogin: {
1004
1205
  label: "Read auth: login",
@@ -1063,10 +1264,8 @@ function helpForKey(key, cfg, sources) {
1063
1264
  function displayValue(key, cfg) {
1064
1265
  if (key === "createToken" || key === "authPassword")
1065
1266
  return mask(String(cfg[key] ?? ""));
1066
- if (key === "openTasksInNewTab")
1067
- return String(cfg.openTasksInNewTab !== false);
1068
1267
  if (key === "announceStderr")
1069
- return String(cfg.announceStderr !== false);
1268
+ return cfg.announceStderr !== false ? "on" : "off";
1070
1269
  const value = cfg[key];
1071
1270
  return value === undefined || value === "" ? "(not set)" : String(value);
1072
1271
  }
@@ -1090,7 +1289,7 @@ async function editKey(host, cfg, key) {
1090
1289
  const next = { ...cfg };
1091
1290
  if (key === "shareMode")
1092
1291
  next[key] = picked === "auto" ? undefined : picked;
1093
- else if (key === "openTasksInNewTab" || key === "announceStderr")
1292
+ else if (key === "announceStderr")
1094
1293
  next[key] = picked === "true";
1095
1294
  else
1096
1295
  next[key] = picked;
@@ -1125,26 +1324,74 @@ async function connectServerFlow(host) {
1125
1324
  const url = normServerUrl(raw);
1126
1325
  if (!/^https?:\/\//.test(url))
1127
1326
  return { ok: false, message: "URL must start with http:// or https://" };
1128
- host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}\u2026` });
1327
+ host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` });
1129
1328
  const probe = await probeAuthMode(url);
1130
1329
  if (!probe.ok)
1131
1330
  return { ok: false, message: `${url}: ${probe.detail}` };
1132
- let code = "";
1133
- if (probe.mode === "private") {
1134
- const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_\u2026 (from the hoster)" });
1331
+ const existing = serverEntries().find((sv) => sv.url === url)?.entry ?? {};
1332
+ const deactivateOthers = () => {
1333
+ for (const other of serverEntries()) {
1334
+ if (other.url !== url)
1335
+ saveServerEntry(other.url, { active: undefined });
1336
+ }
1337
+ };
1338
+ if (probe.mode === "public") {
1339
+ const picked = await showSelect(host.ui, {
1340
+ title: `${url} - account?`,
1341
+ options: [
1342
+ {
1343
+ value: "onboard",
1344
+ title: "Onboard (get an account)",
1345
+ description: "your own user, tokens and share limits; survives the server's anonymous caps",
1346
+ 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."
1347
+ },
1348
+ {
1349
+ value: "anon",
1350
+ title: "Stay anonymous",
1351
+ description: "share without an account; counts against the server-wide anonymous limits",
1352
+ 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."
1353
+ }
1354
+ ]
1355
+ });
1356
+ if (picked === "anon") {
1357
+ saveServerEntry(url, { ...existing.token ? {} : { anonymous: true }, label: existing.label, active: true });
1358
+ return { ok: true, message: `Connected to ${url} anonymously - sharing works, no account.`, url };
1359
+ }
1360
+ if (picked === undefined || picked === null)
1361
+ return { ok: false, message: "Cancelled." };
1362
+ let code = existing.code ?? "";
1363
+ if (!code) {
1364
+ const free = await enrollAtServer(url, "");
1365
+ if (free.ok) {
1366
+ deactivateOthers();
1367
+ saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined, active: true });
1368
+ return { ok: true, message: `Connected to ${url} as "${free.label}" - sharing goes there now.`, url };
1369
+ }
1370
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
1371
+ if (typed === undefined)
1372
+ return { ok: false, message: "Cancelled - this server needs a code to onboard." };
1373
+ code = typed.trim();
1374
+ }
1375
+ const res = await enrollAtServer(url, code);
1376
+ if (!res.ok)
1377
+ return { ok: false, message: `Enrollment refused: ${res.error}` };
1378
+ deactivateOthers();
1379
+ saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, anonymous: undefined, active: true });
1380
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
1381
+ }
1382
+ let code = existing.code ?? "";
1383
+ if (!code) {
1384
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
1135
1385
  if (typed === undefined)
1136
- return { ok: false, message: "Cancelled \u2014 private servers need a code." };
1386
+ return { ok: false, message: "Cancelled - private servers need a code." };
1137
1387
  code = typed.trim();
1138
1388
  }
1139
1389
  const res = await enrollAtServer(url, code);
1140
1390
  if (!res.ok)
1141
1391
  return { ok: false, message: `Enrollment refused: ${res.error}` };
1142
- for (const other of serverEntries()) {
1143
- if (other.url !== url)
1144
- saveServerEntry(other.url, { active: undefined });
1145
- }
1392
+ deactivateOthers();
1146
1393
  saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true });
1147
- return { ok: true, message: `Connected to ${url} as "${res.label}" \u2014 sharing goes there now.`, url };
1394
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
1148
1395
  }
1149
1396
  async function serverMenu(host, url) {
1150
1397
  while (true) {
@@ -1153,7 +1400,7 @@ async function serverMenu(host, url) {
1153
1400
  if (!mine)
1154
1401
  return;
1155
1402
  const holdsRoot = !!mine.entry.token && mine.entry.token === (mine.entry.adminToken ?? adminTokenFor(url));
1156
- const cred = holdsRoot ? `ROOT token ${mask(mine.entry.token)} \u2014 admin, no user identity` : mine.entry.token ? `token ${mask(mine.entry.token)}` : mine.entry.code ? "no token (code saved, enrolls on demand)" : "no token (public server, enrolls on demand)";
1403
+ const cred = holdsRoot ? `ROOT token ${mask(mine.entry.token)} \u2014 admin, no user identity` : mine.entry.token ? `token ${mask(mine.entry.token)}` : mine.entry.code ? "no token (code saved, enrolls on demand)" : mine.entry.anonymous ? "anonymous (declined onboarding)" : "no token (public server, shares anonymously)";
1157
1404
  const options = [
1158
1405
  { title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} \xB7 ${cred}${mine.active ? " \xB7 ACTIVE" : ""}`, disabled: true }
1159
1406
  ];
@@ -1173,6 +1420,8 @@ This creates your own user on ${url} (label prompt, prefilled), mints its token,
1173
1420
  options.push({ title: "Enroll now", value: "enroll", description: holdsRoot ? "join as a user via code/public enrollment" : "get a user token immediately" });
1174
1421
  if (mine.entry.token && !holdsRoot)
1175
1422
  options.push({ title: "Rotate token", value: "rotate", description: "new token, old one revoked" });
1423
+ if (mine.entry.token && !holdsRoot)
1424
+ options.push({ title: "Your account", value: "account", description: "limits, token expiry, your shares" });
1176
1425
  if (mine.entry.token && !holdsRoot)
1177
1426
  options.push({ title: "Disconnect", value: "disconnect", description: "revoke the token server-side and forget it", danger: true });
1178
1427
  options.push({
@@ -1190,12 +1439,13 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
1190
1439
  if (choice === undefined || choice === "__back")
1191
1440
  return;
1192
1441
  if (choice === "active") {
1193
- for (const other of entries) {
1194
- if (other.url !== url)
1195
- saveServerEntry(other.url, { active: undefined });
1196
- }
1197
1442
  saveServerEntry(url, { active: true });
1198
- host.ui.toast({ variant: "success", title: "OpenShare", message: `${url} is now the active server.` });
1443
+ const stillActive = serverEntries().filter((s) => s.active);
1444
+ host.ui.toast({
1445
+ variant: "success",
1446
+ title: "OpenShare",
1447
+ 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.`
1448
+ });
1199
1449
  return;
1200
1450
  }
1201
1451
  if (choice === "adopt") {
@@ -1230,8 +1480,18 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
1230
1480
  continue;
1231
1481
  }
1232
1482
  let code = mine.entry.code ?? "";
1233
- if (probe.mode === "private" && !code) {
1234
- const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_\u2026 (from the hoster)" });
1483
+ if (!code) {
1484
+ const free = await enrollAtServer(url, "");
1485
+ if (free.ok) {
1486
+ saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined });
1487
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${free.label}".` });
1488
+ continue;
1489
+ }
1490
+ if (!/code required/i.test(free.error ?? "")) {
1491
+ await showInfo(host.ui, { title: "Enrollment refused", message: free.error ?? "unknown error" });
1492
+ continue;
1493
+ }
1494
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
1235
1495
  if (typed === undefined)
1236
1496
  continue;
1237
1497
  code = typed.trim();
@@ -1240,13 +1500,41 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
1240
1500
  }
1241
1501
  const res = await enrollAtServer(url, code);
1242
1502
  if (res.ok) {
1243
- saveServerEntry(url, { token: res.token, label: res.label });
1503
+ saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined });
1244
1504
  host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` });
1245
1505
  } else {
1246
1506
  await showInfo(host.ui, { title: "Enrollment refused", message: res.error ?? "unknown error" });
1247
1507
  }
1248
1508
  continue;
1249
1509
  }
1510
+ if (choice === "account") {
1511
+ const me = meClient(url, mine.entry.token ?? "");
1512
+ const [meRes, sharesRes] = await Promise.all([me.me(), me.myShares()]);
1513
+ if (!meRes.ok) {
1514
+ await showInfo(host.ui, { title: "Account", message: `Could not read your account: ${meRes.error ?? "error"}.
1515
+ If the token was revoked server-side, use Enroll now instead.` });
1516
+ continue;
1517
+ }
1518
+ const d = meRes.data;
1519
+ const lim = d.token?.limits ?? {};
1520
+ const fmt = (v) => v ? String(v) : "unlimited";
1521
+ const lines = [
1522
+ `User: ${d.label} (${d.userId})`,
1523
+ `Token: ${d.token?.label ?? "?"} - expires ${d.token?.expiresAt ? new Date(d.token.expiresAt).toISOString().slice(0, 10) : "never"}`,
1524
+ `Limits: live shares ${fmt(lim.maxLiveShares)} - max TTL ${fmt(lim.maxTtlDays)}d - daily creates ${fmt(lim.dailyCreates)}`
1525
+ ];
1526
+ if (sharesRes.ok) {
1527
+ const shares = sharesRes.data?.shares ?? [];
1528
+ lines.push(`Shares: ${shares.length} (${shares.filter((s) => s.status === "live").length} live)`);
1529
+ for (const s of shares.slice(0, 10))
1530
+ lines.push(` ${s.id} - ${s.status} - ${new Date(s.createdAt).toISOString().slice(0, 10)}`);
1531
+ if (shares.length > 10)
1532
+ lines.push(` ...and ${shares.length - 10} more`);
1533
+ }
1534
+ await showInfo(host.ui, { title: "Your account", message: lines.join(`
1535
+ `) });
1536
+ continue;
1537
+ }
1250
1538
  if (choice === "rotate") {
1251
1539
  const res = await rotateAtServer(url, mine.entry.token ?? "");
1252
1540
  if (res.ok && res.token) {
@@ -1258,8 +1546,13 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
1258
1546
  continue;
1259
1547
  }
1260
1548
  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 });
1549
+ const sure = await showConfirm(host.ui, {
1550
+ title: "Disconnect",
1551
+ message: `Revoke the token at ${url} and forget it?
1552
+ 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.`,
1553
+ confirmLabel: "Disconnect",
1554
+ danger: true
1555
+ });
1263
1556
  if (!sure)
1264
1557
  continue;
1265
1558
  const res = await disconnectAtServer(url, mine.entry.token ?? "");
@@ -1672,7 +1965,7 @@ async function adminSharesMenu(host, client, url, shares, users) {
1672
1965
  }
1673
1966
  }
1674
1967
  }
1675
- async function settingsDialog(host, startAtSession) {
1968
+ async function settingsDialog(host, startAtSession, startGroup) {
1676
1969
  let cfg = currentConfig();
1677
1970
  let dirty = false;
1678
1971
  const candidates = writeCandidates(moduleDir());
@@ -1854,19 +2147,32 @@ ${otherServers.map((s) => `\u2022 ${s.url}`).join(`
1854
2147
  }
1855
2148
  }
1856
2149
  const binding = bindings[0];
2150
+ if (binding?.lastError || binding?.ttlClamped) {
2151
+ const bits = [];
2152
+ if (binding?.ttlClamped)
2153
+ bits.push(`TTL clamped ${binding.ttlClamped.requested}d -> ${binding.ttlClamped.effective}d by server/token policy`);
2154
+ if (binding?.lastError)
2155
+ bits.push(`last share error: ${binding.lastError}`);
2156
+ 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):
2157
+
2158
+ ${bits.join(`
2159
+ `)}
2160
+
2161
+ 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.` });
2162
+ }
1857
2163
  rows.push({
1858
2164
  title: "Session TTL override (days)",
1859
2165
  value: "session:ttl",
1860
- 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.
2166
+ description: binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(worker default)",
2167
+ 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.
1862
2168
 
1863
- Current: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days` : "(none \u2014 config default)"}`
2169
+ Current: ${binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(none \u2014 config default)"}`
1864
2170
  });
1865
2171
  rows.push({
1866
2172
  title: "Session read auth",
1867
2173
  value: "session:auth",
1868
2174
  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.
2175
+ 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
2176
 
1871
2177
  Current: ${binding?.opts?.authLogin ? `${binding.opts.authLogin} / (password set)` : "(none \u2014 config default applies)"}`
1872
2178
  });
@@ -2049,11 +2355,9 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
2049
2355
  });
2050
2356
  if (!pick || pick === "__cancel")
2051
2357
  return;
2052
- for (const s of serverEntries())
2053
- if (s.url !== pick)
2054
- saveServerEntry(s.url, { active: undefined });
2055
2358
  saveServerEntry(pick, { active: true });
2056
- host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated \u2014 this session mirrors there within seconds.` });
2359
+ const nActive = serverEntries().filter((s) => s.active).length;
2360
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated (${nActive} active) - this session mirrors there within seconds.` });
2057
2361
  return;
2058
2362
  }
2059
2363
  if (choice === "session:unshare") {
@@ -2068,61 +2372,89 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
2068
2372
  if (how === undefined || how === "__cancel")
2069
2373
  return;
2070
2374
  if (how === "leave") {
2071
- const all = await readBindings(host.client, root);
2375
+ const allB = await readBindings(host.client, root);
2072
2376
  const okPatch = await patchSessionBinding(host.client, root, (servers) => {
2073
- for (const b of all) {
2377
+ for (const b of allB) {
2074
2378
  if (!b.server)
2075
2379
  continue;
2076
- delete servers[b.server];
2077
- servers[b.server] = { optedOut: true };
2380
+ servers[b.server] = { ...servers[b.server], id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true };
2078
2381
  }
2079
2382
  });
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." });
2383
+ 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
2384
  return;
2082
2385
  }
2386
+ const sureAll = await showConfirm(host.ui, {
2387
+ title: "Delete every page?",
2388
+ message: `Permanently delete ${live.length} share page(s) and stop sharing?
2389
+ Every link stops working for everyone. This cannot be undone.`,
2390
+ confirmLabel: "Delete every page",
2391
+ danger: true
2392
+ });
2393
+ if (!sureAll)
2394
+ return;
2083
2395
  const result = await runUnshareFlow(host, startAtSession);
2084
2396
  host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message });
2085
2397
  return;
2086
2398
  }
2087
2399
  if (choice === "session:ttl") {
2088
- const raw = await showPrompt(host.ui, { title: "Session TTL (days)", placeholder: "days", value: current?.opts?.ttlDays !== undefined ? String(current.opts.ttlDays) : "" });
2400
+ const raw = await showPrompt(host.ui, { title: "Session TTL", placeholder: "hours: 6h, 2d or 48", value: current?.opts?.ttlHours !== undefined ? fmtHours(current.opts.ttlHours) : "" });
2089
2401
  if (raw === undefined)
2090
2402
  return;
2091
2403
  const opts = { ...current?.opts ?? {} };
2092
2404
  if (raw === "")
2093
- delete opts.ttlDays;
2405
+ delete opts.ttlHours;
2094
2406
  else {
2095
- const days = Number(raw);
2096
- if (!Number.isFinite(days) || days <= 0) {
2097
- host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of days." });
2407
+ const hours = parseTtlHours(raw);
2408
+ if (hours === undefined) {
2409
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be 6h / 2d style or a positive number of hours." });
2098
2410
  return;
2099
2411
  }
2100
- opts.ttlDays = Math.floor(days);
2412
+ opts.ttlHours = hours;
2101
2413
  }
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" });
2414
+ await setShareOpts(host.client, root, opts);
2415
+ const liveNow = live.length > 0;
2416
+ if (liveNow) {
2417
+ 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 })));
2418
+ const failed = results.filter((r) => !r.ok).length;
2419
+ if (failed)
2420
+ host.ui.toast({ variant: "error", title: "OpenShare", message: `TTL applied on ${results.length - failed}/${results.length} server(s) - the rest sync on next connect.` });
2421
+ }
2422
+ host.ui.toast({
2423
+ variant: "success",
2424
+ title: "OpenShare",
2425
+ message: liveNow ? `Session TTL: ${opts.ttlHours !== undefined ? `${fmtHours(opts.ttlHours)} - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.` : `Session TTL: ${opts.ttlHours !== undefined ? fmtHours(opts.ttlHours) : "(default)"} - saved, applies when you share.`
2426
+ });
2104
2427
  return;
2105
2428
  }
2106
2429
  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 ?? "" });
2430
+ const login = await showPrompt(host.ui, { title: "Session read auth: login", placeholder: "empty = no read auth", value: current?.opts?.authLogin ?? "" });
2108
2431
  if (login === undefined)
2109
2432
  return;
2110
- const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: "empty = no password" });
2433
+ const password = await showPrompt(host.ui, { title: "Session read auth: password", placeholder: login === "" ? "(unused)" : "password (required with a login)" });
2111
2434
  if (password === undefined)
2112
2435
  return;
2436
+ if (login === "" !== (password === "")) {
2437
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "Read auth needs both a login and a password - or neither (no gate)." });
2438
+ return;
2439
+ }
2113
2440
  const opts = { ...current?.opts ?? {} };
2114
2441
  if (login === "") {
2115
2442
  delete opts.authLogin;
2116
2443
  delete opts.authPassword;
2117
2444
  } else {
2118
2445
  opts.authLogin = login;
2119
- if (password !== "")
2120
- opts.authPassword = password;
2121
- else
2122
- delete opts.authPassword;
2446
+ opts.authPassword = password;
2447
+ }
2448
+ await setShareOpts(host.client, root, opts);
2449
+ const liveNow2 = live.length > 0;
2450
+ if (liveNow2) {
2451
+ const pair = login === "" ? { authLogin: "", authPassword: "" } : { authLogin: login, authPassword: password };
2452
+ 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 })));
2453
+ const failed2 = results2.filter((r) => !r.ok).length;
2454
+ if (failed2)
2455
+ 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.` });
2123
2456
  }
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)." });
2457
+ 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
2458
  return;
2127
2459
  }
2128
2460
  }
@@ -2139,6 +2471,8 @@ Note: no token held \u2014 the plugin enrolls on first contact (public) or needs
2139
2471
  await dispatch(choice);
2140
2472
  }
2141
2473
  };
2474
+ if (startAtSession && startGroup === "session")
2475
+ await groupMenu("session");
2142
2476
  const seconds = (ms, fallback) => `${Math.round((ms ?? fallback) / 100) / 10}s`;
2143
2477
  while (true) {
2144
2478
  const servers = serverEntries(cfg);
@@ -2170,7 +2504,7 @@ Every ACTIVE server receives new sessions (fan-out).`
2170
2504
  });
2171
2505
  groups.push({
2172
2506
  id: "sharing",
2173
- summary: `sync ${seconds(cfg.debounceMs, 1e4)} \xB7 completed ${seconds(cfg.completedDelayMs, 5000)} \xB7 TTL ${cfg.ttlDays ?? "server cap"}`,
2507
+ summary: `sync ${seconds(cfg.debounceMs, 1e4)} \xB7 completed ${seconds(cfg.completedDelayMs, 5000)} \xB7 TTL ${fmtHours(cfg.ttlHours ?? 2160)}`,
2174
2508
  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.
2175
2509
 
2176
2510
  Cadence values are clamped by the server's policy window.`
@@ -2363,7 +2697,7 @@ function buildShareCommand(host, sessionID) {
2363
2697
  dbgLog(`/share run: entered (sid=${sessionID() ?? "none"})`);
2364
2698
  host.ui.dialog.clear();
2365
2699
  const cfg = currentConfig();
2366
- if (!isConfigured(cfg)) {
2700
+ if (!isConfigured(cfg) && !engineTargetUrls().length) {
2367
2701
  onboardingDialog(host);
2368
2702
  return;
2369
2703
  }
@@ -2378,7 +2712,7 @@ function buildShareCommand(host, sessionID) {
2378
2712
  return;
2379
2713
  const { all, goneShown } = swept;
2380
2714
  if (all.some((b) => b.url && b.enabled !== false)) {
2381
- await settingsDialog(host, sid);
2715
+ await settingsDialog(host, sid, "session");
2382
2716
  return;
2383
2717
  }
2384
2718
  if (goneShown && all.length === 0)