@mirrowel/opencode-openshare 0.2.1 → 0.3.0-dev.10

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,11 +22,13 @@ var DEFAULTS = {
22
22
  debounceMs: 1e4,
23
23
  completedDelayMs: 5000,
24
24
  modelLabel: "$Provider/$Model",
25
- ttlDays: 90,
25
+ ttlHours: 90 * 24,
26
26
  authLogin: "",
27
27
  authPassword: "",
28
28
  spoolDir: "",
29
29
  urlFile: "",
30
+ urlFileFormat: "url",
31
+ configSet: "",
30
32
  announceStderr: true
31
33
  };
32
34
  var CONFIG_NAME = "openshare.config.json";
@@ -34,6 +36,14 @@ var LOCAL_NAME = "openshare.local.json";
34
36
  function moduleDir() {
35
37
  return dirname(fileURLToPath(import.meta.pathname ?? import.meta.url));
36
38
  }
39
+ function pluginVersion() {
40
+ try {
41
+ const pkg = JSON.parse(readFileSync(join(packageRoot(moduleDir()), "package.json"), "utf8"));
42
+ return pkg.version ?? "?";
43
+ } catch {
44
+ return "?";
45
+ }
46
+ }
37
47
  function packageRoot(start) {
38
48
  let current = start;
39
49
  for (let guard = 0;guard < 16; guard++) {
@@ -97,10 +107,13 @@ function configSourceMap(pluginDir, env = process.env) {
97
107
  debounceMs: env.OPENSHARE_DEBOUNCE_MS,
98
108
  completedDelayMs: env.OPENSHARE_COMPLETED_DELAY_MS,
99
109
  modelLabel: env.OPENSHARE_MODEL_LABEL,
110
+ ttlHours: env.OPENSHARE_TTL_HOURS,
100
111
  ttlDays: env.OPENSHARE_TTL_DAYS,
101
112
  authLogin: env.OPENSHARE_AUTH_LOGIN,
102
113
  authPassword: env.OPENSHARE_AUTH_PASSWORD,
103
114
  urlFile: env.OPENSHARE_URL_FILE,
115
+ urlFileFormat: env.OPENSHARE_URL_FILE_FORMAT,
116
+ configSet: env.OPENSHARE_CONFIG_SET,
104
117
  spoolDir: env.OPENSHARE_SPOOL_DIR,
105
118
  announceStderr: env.OPENSHARE_ANNOUNCE_STDERR,
106
119
  shareMode: env.OPENSHARE_SHARE_MODE
@@ -109,6 +122,24 @@ function configSourceMap(pluginDir, env = process.env) {
109
122
  if (value !== undefined && value !== "")
110
123
  sources[key] = "environment";
111
124
  }
125
+ if (sources.ttlDays && !sources.ttlHours)
126
+ sources.ttlHours = sources.ttlDays;
127
+ delete sources.ttlDays;
128
+ if (env.OPENSHARE_CONFIG_JSON) {
129
+ try {
130
+ const raw = JSON.parse(env.OPENSHARE_CONFIG_JSON);
131
+ if (raw && typeof raw === "object") {
132
+ for (const [key, value] of Object.entries(raw)) {
133
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
134
+ continue;
135
+ if (sources[key] === "environment")
136
+ continue;
137
+ if (key === "servers" || known.has(key))
138
+ sources[key] = "OPENSHARE_CONFIG_JSON";
139
+ }
140
+ }
141
+ } catch {}
142
+ }
112
143
  for (const path of configPaths(pluginDir, env)) {
113
144
  let raw;
114
145
  try {
@@ -162,6 +193,8 @@ function normalizeServers(raw) {
162
193
  entry.adminToken = e.adminToken;
163
194
  if (e.active === true)
164
195
  entry.active = true;
196
+ if (e.anonymous === true)
197
+ entry.anonymous = true;
165
198
  out[url] = entry;
166
199
  }
167
200
  return Object.keys(out).length ? out : undefined;
@@ -231,26 +264,53 @@ function loadConfig(pluginDir, options) {
231
264
  const cfg = { ...DEFAULTS };
232
265
  const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"]);
233
266
  const known = new Set([...Object.keys(DEFAULTS), "shareMode"]);
234
- for (const path of [...configPaths(pluginDir)].reverse()) {
235
- try {
236
- const raw = JSON.parse(readFileSync(path, "utf8"));
237
- if (!raw || typeof raw !== "object")
267
+ const applyObject = (raw) => {
268
+ if (!raw || typeof raw !== "object")
269
+ return;
270
+ for (const [key, value] of Object.entries(raw)) {
271
+ if (key === "servers") {
272
+ const servers = normalizeServers(value);
273
+ if (servers)
274
+ cfg.servers = mergeServerGroups(cfg.servers, servers);
238
275
  continue;
239
- for (const [key, value] of Object.entries(raw)) {
240
- if (key === "servers") {
241
- const servers = normalizeServers(value);
242
- if (servers)
243
- cfg.servers = mergeServerGroups(cfg.servers, servers);
244
- continue;
276
+ }
277
+ if (key === "redact") {
278
+ if (value && typeof value === "object" && Array.isArray(value.patterns)) {
279
+ const pats = value.patterns.filter((x) => typeof x === "string" && x.length > 0 && x.length < 500).slice(0, 50);
280
+ cfg.redact = { patterns: pats };
245
281
  }
246
- if (!known.has(key))
247
- continue;
248
- if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
249
- continue;
250
- if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
251
- continue;
252
- cfg[key] = value;
282
+ continue;
283
+ }
284
+ if (key === "meta") {
285
+ if (value && typeof value === "object" && !Array.isArray(value)) {
286
+ const clean = {};
287
+ for (const [mk, mv] of Object.entries(value)) {
288
+ if (typeof mv === "string" && mv.length <= 500 && mk.length <= 64)
289
+ clean[mk] = mv;
290
+ }
291
+ if (Object.keys(clean).length)
292
+ cfg.meta = { ...cfg.meta, ...clean };
293
+ }
294
+ continue;
295
+ }
296
+ if (key === "ttlDays") {
297
+ const d = Math.floor(Number(value));
298
+ if (Number.isFinite(d) && d > 0)
299
+ cfg.ttlHours = d * 24;
300
+ continue;
253
301
  }
302
+ if (!known.has(key))
303
+ continue;
304
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
305
+ continue;
306
+ if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
307
+ continue;
308
+ cfg[key] = value;
309
+ }
310
+ };
311
+ for (const path of [...configPaths(pluginDir)].reverse()) {
312
+ try {
313
+ applyObject(JSON.parse(readFileSync(path, "utf8")));
254
314
  } catch (e) {
255
315
  if (existsSync(path))
256
316
  console.error(`[openshare] config file ${path} could not be parsed - ignored:`, e?.message ?? e);
@@ -273,6 +333,13 @@ function loadConfig(pluginDir, options) {
273
333
  cfg.shareMode = mode;
274
334
  }
275
335
  const env = process.env;
336
+ if (env.OPENSHARE_CONFIG_JSON) {
337
+ try {
338
+ applyObject(JSON.parse(env.OPENSHARE_CONFIG_JSON));
339
+ } catch (e) {
340
+ console.error("[openshare] OPENSHARE_CONFIG_JSON could not be parsed - ignored:", e?.message ?? e);
341
+ }
342
+ }
276
343
  if (env.OPENSHARE_WORKER_URL)
277
344
  cfg.workerUrl = env.OPENSHARE_WORKER_URL;
278
345
  if (env.OPENSHARE_CREATE_TOKEN)
@@ -283,14 +350,20 @@ function loadConfig(pluginDir, options) {
283
350
  cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs);
284
351
  if (env.OPENSHARE_MODEL_LABEL !== undefined)
285
352
  cfg.modelLabel = env.OPENSHARE_MODEL_LABEL;
353
+ if (env.OPENSHARE_TTL_HOURS)
354
+ cfg.ttlHours = num(env.OPENSHARE_TTL_HOURS, cfg.ttlHours);
286
355
  if (env.OPENSHARE_TTL_DAYS)
287
- cfg.ttlDays = num(env.OPENSHARE_TTL_DAYS, cfg.ttlDays);
356
+ cfg.ttlHours = Math.max(1, Math.floor(num(env.OPENSHARE_TTL_DAYS, 90) * 24));
288
357
  if (env.OPENSHARE_AUTH_LOGIN !== undefined)
289
358
  cfg.authLogin = env.OPENSHARE_AUTH_LOGIN;
290
359
  if (env.OPENSHARE_AUTH_PASSWORD !== undefined)
291
360
  cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD;
292
361
  if (env.OPENSHARE_URL_FILE !== undefined)
293
362
  cfg.urlFile = env.OPENSHARE_URL_FILE;
363
+ if (env.OPENSHARE_URL_FILE_FORMAT === "json" || env.OPENSHARE_URL_FILE_FORMAT === "url")
364
+ cfg.urlFileFormat = env.OPENSHARE_URL_FILE_FORMAT;
365
+ if (env.OPENSHARE_CONFIG_SET)
366
+ cfg.configSet = env.OPENSHARE_CONFIG_SET;
294
367
  if (env.OPENSHARE_SPOOL_DIR)
295
368
  cfg.spoolDir = env.OPENSHARE_SPOOL_DIR;
296
369
  if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined)
@@ -444,8 +517,18 @@ function queueBindingWrite(sid, fn) {
444
517
  return run;
445
518
  }
446
519
 
520
+ // src/redact.ts
521
+ var PATH_KEYS = new Set(["file", "path", "filePath", "cwd", "directory", "root"]);
522
+
447
523
  // src/core.ts
448
524
  var liveTargets = [];
525
+ var statsProvider = null;
526
+ var redactionLedger = new Map;
527
+ function sessionStatsFor(sid) {
528
+ const led = redactionLedger.get(sid) ?? { count: 0, samples: [] };
529
+ const counts = statsProvider ? statsProvider(sid) : { messages: 0, toolCalls: 0 };
530
+ return { ...counts, redaction: led };
531
+ }
449
532
  function engineTargetUrls() {
450
533
  return liveTargets;
451
534
  }
@@ -474,7 +557,7 @@ function saveConfig(cfg, target, baseDir = moduleDir()) {
474
557
  "debounceMs",
475
558
  "completedDelayMs",
476
559
  "modelLabel",
477
- "ttlDays",
560
+ "ttlHours",
478
561
  "authLogin",
479
562
  "authPassword",
480
563
  "spoolDir",
@@ -562,6 +645,23 @@ function bindingsOf(info) {
562
645
  async function readBindings(client, sid) {
563
646
  return bindingsOf(await getSession(client, sid));
564
647
  }
648
+ function fmtHours(hours) {
649
+ if (!Number.isFinite(hours) || hours <= 0)
650
+ return "?";
651
+ if (hours % 24 === 0)
652
+ return `${Math.floor(hours / 24)}d`;
653
+ return `${Math.floor(hours)}h`;
654
+ }
655
+ function parseTtlHours(raw) {
656
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(h|d|hr|hours?|days?)?\s*$/i.exec(raw ?? "");
657
+ if (!m)
658
+ return;
659
+ const v = Number(m[1]);
660
+ if (!Number.isFinite(v) || v <= 0)
661
+ return;
662
+ const unit = (m[2] ?? "h").toLowerCase();
663
+ return Math.max(1, Math.floor(unit.startsWith("d") ? v * 24 : v));
664
+ }
565
665
  function hostOf(url) {
566
666
  try {
567
667
  return new URL(url).host;
@@ -683,6 +783,27 @@ async function workerDeleteShare(workerUrl, id, secret) {
683
783
  return false;
684
784
  }
685
785
  }
786
+ async function patchShareOpts(workerUrl, id, opts, secret) {
787
+ const body = { secret };
788
+ if (opts.authLogin !== undefined && opts.authPassword !== undefined) {
789
+ body.authLogin = opts.authLogin;
790
+ body.authPassword = opts.authPassword;
791
+ }
792
+ if (opts.ttlHours !== undefined)
793
+ body.ttlHours = opts.ttlHours;
794
+ try {
795
+ const res = await fetch(`${workerUrl.replace(/\/$/, "")}/api/share/${id}`, {
796
+ method: "PATCH",
797
+ headers: { "content-type": "application/json" },
798
+ body: JSON.stringify(body),
799
+ signal: AbortSignal.timeout(8000)
800
+ });
801
+ const json = await res.json().catch(() => ({}));
802
+ return res.ok && json.ok ? { ok: true } : { ok: false, error: json.error ?? `HTTP ${res.status}` };
803
+ } catch (e) {
804
+ return { ok: false, error: e?.message || String(e) };
805
+ }
806
+ }
686
807
  async function probeWorker(workerUrl) {
687
808
  if (!workerUrl)
688
809
  return { ok: false, detail: "workerUrl is empty" };
@@ -866,8 +987,10 @@ async function requestShareConsent(host, context) {
866
987
  const bits = [];
867
988
  if (context.server)
868
989
  bits.push(`server: ${context.server}`);
869
- if (context.ttlDays !== undefined)
870
- bits.push(`TTL: ${context.ttlDays}d`);
990
+ if (context.ttlHours !== undefined)
991
+ bits.push(`TTL: ${fmtHours(context.ttlHours)}`);
992
+ if (context.redaction && context.redaction.hits > 0)
993
+ bits.push(`redaction: ${context.redaction.hits} hit(s)`);
871
994
  bits.push(`read auth: ${context.auth ? "on" : "off"}`);
872
995
  lines.push(bits.join(" - "));
873
996
  }
@@ -895,12 +1018,18 @@ async function shareWithConsent(host, sessionID) {
895
1018
  }
896
1019
  const opts = all[0]?.opts;
897
1020
  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
- }))
903
- return { ok: false, message: "Share cancelled." };
1021
+ {
1022
+ const stats = sessionStatsFor(root);
1023
+ const gated = !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword);
1024
+ if (!await requestShareConsent(host, {
1025
+ server: server ? hostOf(server.url) : undefined,
1026
+ ttlHours: opts?.ttlHours ?? cfg.ttlHours,
1027
+ auth: gated,
1028
+ counts: { messages: stats.messages, toolCalls: stats.toolCalls },
1029
+ redaction: gated ? undefined : { hits: stats.redaction.count, samples: stats.redaction.samples.map((h) => `${h.sample} [${h.kind}]`) }
1030
+ }))
1031
+ return { ok: false, message: "Share cancelled." };
1032
+ }
904
1033
  return confirmShare(host, root);
905
1034
  }
906
1035
  async function confirmShare(host, rootSessionID) {
@@ -1070,15 +1199,15 @@ A graceful opencode exit flushes the pending flip, so CI runs land their final s
1070
1199
  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.',
1071
1200
  def: '"$Provider/$Model"'
1072
1201
  },
1073
- ttlDays: {
1074
- label: "TTL (days)",
1075
- help: "Share lifetime; the worker caps it at its MAX_TTL_DAYS.",
1202
+ ttlHours: {
1203
+ label: "TTL (hours)",
1204
+ help: "Share lifetime in hours (2d writes 48); capped server-side.",
1076
1205
  kind: "number",
1077
1206
  section: "Sharing",
1078
- 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.
1207
+ 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.
1079
1208
 
1080
- A per-session override lives in the This-session section; it is sent at create/attach time.`,
1081
- def: "worker cap (90 on the reference deployment)"
1209
+ 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.`,
1210
+ def: "worker cap (2160h = 90d on the reference deployment)"
1082
1211
  },
1083
1212
  authLogin: {
1084
1213
  label: "Read auth: login",
@@ -1203,26 +1332,74 @@ async function connectServerFlow(host) {
1203
1332
  const url = normServerUrl(raw);
1204
1333
  if (!/^https?:\/\//.test(url))
1205
1334
  return { ok: false, message: "URL must start with http:// or https://" };
1206
- host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}\u2026` });
1335
+ host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` });
1207
1336
  const probe = await probeAuthMode(url);
1208
1337
  if (!probe.ok)
1209
1338
  return { ok: false, message: `${url}: ${probe.detail}` };
1210
- let code = "";
1211
- if (probe.mode === "private") {
1212
- const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_\u2026 (from the hoster)" });
1339
+ const existing = serverEntries().find((sv) => sv.url === url)?.entry ?? {};
1340
+ const deactivateOthers = () => {
1341
+ for (const other of serverEntries()) {
1342
+ if (other.url !== url)
1343
+ saveServerEntry(other.url, { active: undefined });
1344
+ }
1345
+ };
1346
+ if (probe.mode === "public") {
1347
+ const picked = await showSelect(host.ui, {
1348
+ title: `${url} - account?`,
1349
+ options: [
1350
+ {
1351
+ value: "onboard",
1352
+ title: "Onboard (get an account)",
1353
+ description: "your own user, tokens and share limits; survives the server's anonymous caps",
1354
+ 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."
1355
+ },
1356
+ {
1357
+ value: "anon",
1358
+ title: "Stay anonymous",
1359
+ description: "share without an account; counts against the server-wide anonymous limits",
1360
+ 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."
1361
+ }
1362
+ ]
1363
+ });
1364
+ if (picked === "anon") {
1365
+ saveServerEntry(url, { ...existing.token ? {} : { anonymous: true }, label: existing.label, active: true });
1366
+ return { ok: true, message: `Connected to ${url} anonymously - sharing works, no account.`, url };
1367
+ }
1368
+ if (picked === undefined || picked === null)
1369
+ return { ok: false, message: "Cancelled." };
1370
+ let code = existing.code ?? "";
1371
+ if (!code) {
1372
+ const free = await enrollAtServer(url, "");
1373
+ if (free.ok) {
1374
+ deactivateOthers();
1375
+ saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined, active: true });
1376
+ return { ok: true, message: `Connected to ${url} as "${free.label}" - sharing goes there now.`, url };
1377
+ }
1378
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
1379
+ if (typed === undefined)
1380
+ return { ok: false, message: "Cancelled - this server needs a code to onboard." };
1381
+ code = typed.trim();
1382
+ }
1383
+ const res = await enrollAtServer(url, code);
1384
+ if (!res.ok)
1385
+ return { ok: false, message: `Enrollment refused: ${res.error}` };
1386
+ deactivateOthers();
1387
+ saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, anonymous: undefined, active: true });
1388
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
1389
+ }
1390
+ let code = existing.code ?? "";
1391
+ if (!code) {
1392
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
1213
1393
  if (typed === undefined)
1214
- return { ok: false, message: "Cancelled \u2014 private servers need a code." };
1394
+ return { ok: false, message: "Cancelled - private servers need a code." };
1215
1395
  code = typed.trim();
1216
1396
  }
1217
1397
  const res = await enrollAtServer(url, code);
1218
1398
  if (!res.ok)
1219
1399
  return { ok: false, message: `Enrollment refused: ${res.error}` };
1220
- for (const other of serverEntries()) {
1221
- if (other.url !== url)
1222
- saveServerEntry(other.url, { active: undefined });
1223
- }
1400
+ deactivateOthers();
1224
1401
  saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true });
1225
- return { ok: true, message: `Connected to ${url} as "${res.label}" \u2014 sharing goes there now.`, url };
1402
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
1226
1403
  }
1227
1404
  async function serverMenu(host, url) {
1228
1405
  while (true) {
@@ -1231,7 +1408,7 @@ async function serverMenu(host, url) {
1231
1408
  if (!mine)
1232
1409
  return;
1233
1410
  const holdsRoot = !!mine.entry.token && mine.entry.token === (mine.entry.adminToken ?? adminTokenFor(url));
1234
- 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)";
1411
+ 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)";
1235
1412
  const options = [
1236
1413
  { title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} \xB7 ${cred}${mine.active ? " \xB7 ACTIVE" : ""}`, disabled: true }
1237
1414
  ];
@@ -1311,8 +1488,18 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
1311
1488
  continue;
1312
1489
  }
1313
1490
  let code = mine.entry.code ?? "";
1314
- if (probe.mode === "private" && !code) {
1315
- const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_\u2026 (from the hoster)" });
1491
+ if (!code) {
1492
+ const free = await enrollAtServer(url, "");
1493
+ if (free.ok) {
1494
+ saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined });
1495
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${free.label}".` });
1496
+ continue;
1497
+ }
1498
+ if (!/code required/i.test(free.error ?? "")) {
1499
+ await showInfo(host.ui, { title: "Enrollment refused", message: free.error ?? "unknown error" });
1500
+ continue;
1501
+ }
1502
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
1316
1503
  if (typed === undefined)
1317
1504
  continue;
1318
1505
  code = typed.trim();
@@ -1321,7 +1508,7 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
1321
1508
  }
1322
1509
  const res = await enrollAtServer(url, code);
1323
1510
  if (res.ok) {
1324
- saveServerEntry(url, { token: res.token, label: res.label });
1511
+ saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined });
1325
1512
  host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` });
1326
1513
  } else {
1327
1514
  await showInfo(host.ui, { title: "Enrollment refused", message: res.error ?? "unknown error" });
@@ -1984,10 +2171,10 @@ The clamp note appears when the server or your token's policy reduced a requeste
1984
2171
  rows.push({
1985
2172
  title: "Session TTL override (days)",
1986
2173
  value: "session:ttl",
1987
- description: binding?.opts?.ttlDays !== undefined ? String(binding.opts.ttlDays) : "(worker default)",
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.
2174
+ description: binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(worker default)",
2175
+ 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.
1989
2176
 
1990
- Current: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days` : "(none \u2014 config default)"}`
2177
+ Current: ${binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(none \u2014 config default)"}`
1991
2178
  });
1992
2179
  rows.push({
1993
2180
  title: "Session read auth",
@@ -2218,26 +2405,32 @@ Every link stops working for everyone. This cannot be undone.`,
2218
2405
  return;
2219
2406
  }
2220
2407
  if (choice === "session:ttl") {
2221
- const raw = await showPrompt(host.ui, { title: "Session TTL (days)", placeholder: "days", value: current?.opts?.ttlDays !== undefined ? String(current.opts.ttlDays) : "" });
2408
+ const raw = await showPrompt(host.ui, { title: "Session TTL", placeholder: "hours: 6h, 2d or 48", value: current?.opts?.ttlHours !== undefined ? fmtHours(current.opts.ttlHours) : "" });
2222
2409
  if (raw === undefined)
2223
2410
  return;
2224
2411
  const opts = { ...current?.opts ?? {} };
2225
2412
  if (raw === "")
2226
- delete opts.ttlDays;
2413
+ delete opts.ttlHours;
2227
2414
  else {
2228
- const days = Number(raw);
2229
- if (!Number.isFinite(days) || days <= 0) {
2230
- host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of days." });
2415
+ const hours = parseTtlHours(raw);
2416
+ if (hours === undefined) {
2417
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be 6h / 2d style or a positive number of hours." });
2231
2418
  return;
2232
2419
  }
2233
- opts.ttlDays = Math.floor(days);
2420
+ opts.ttlHours = hours;
2234
2421
  }
2235
2422
  await setShareOpts(host.client, root, opts);
2236
2423
  const liveNow = live.length > 0;
2424
+ if (liveNow) {
2425
+ 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 })));
2426
+ const failed = results.filter((r) => !r.ok).length;
2427
+ if (failed)
2428
+ host.ui.toast({ variant: "error", title: "OpenShare", message: `TTL applied on ${results.length - failed}/${results.length} server(s) - the rest sync on next connect.` });
2429
+ }
2237
2430
  host.ui.toast({
2238
2431
  variant: "success",
2239
2432
  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.`
2433
+ 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.`
2241
2434
  });
2242
2435
  return;
2243
2436
  }
@@ -2262,6 +2455,13 @@ Every link stops working for everyone. This cannot be undone.`,
2262
2455
  }
2263
2456
  await setShareOpts(host.client, root, opts);
2264
2457
  const liveNow2 = live.length > 0;
2458
+ if (liveNow2) {
2459
+ const pair = login === "" ? { authLogin: "", authPassword: "" } : { authLogin: login, authPassword: password };
2460
+ 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 })));
2461
+ const failed2 = results2.filter((r) => !r.ok).length;
2462
+ if (failed2)
2463
+ 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.` });
2464
+ }
2265
2465
  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.` });
2266
2466
  return;
2267
2467
  }
@@ -2312,7 +2512,7 @@ Every ACTIVE server receives new sessions (fan-out).`
2312
2512
  });
2313
2513
  groups.push({
2314
2514
  id: "sharing",
2315
- summary: `sync ${seconds(cfg.debounceMs, 1e4)} \xB7 completed ${seconds(cfg.completedDelayMs, 5000)} \xB7 TTL ${cfg.ttlDays ?? "server cap"}`,
2515
+ summary: `sync ${seconds(cfg.debounceMs, 1e4)} \xB7 completed ${seconds(cfg.completedDelayMs, 5000)} \xB7 TTL ${fmtHours(cfg.ttlHours ?? 2160)}`,
2316
2516
  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.
2317
2517
 
2318
2518
  Cadence values are clamped by the server's policy window.`
@@ -2348,7 +2548,7 @@ Stored in the host's preference store \u2014 per machine, not the config file.`
2348
2548
  help: group.help
2349
2549
  }));
2350
2550
  options.push({ title: dirty ? "Save & exit" : "Exit", value: "action:exit", description: dirty ? `Write ${writeTarget.path}` : "No changes" });
2351
- const choice = await showSelect(host.ui, { title: "OpenShare settings", options });
2551
+ const choice = await showSelect(host.ui, { title: `OpenShare v${pluginVersion()} \xB7 settings`, options });
2352
2552
  if (choice === undefined || choice === "action:exit") {
2353
2553
  if (dirty) {
2354
2554
  const saved = saveConfig(cfg, writeTarget.path);
package/dist/ui-prefs.js CHANGED
@@ -1,9 +1,9 @@
1
- // Host-agnostic dialog size preferences (owner decision 2026-10-19: host
2
- // kv, not the config file — UI settings are per-host and synced by
3
- // opencode itself). dialog width maps to the host's fixed dialog widths;
4
- // height is a percent of terminal rows. v1 dialogs apply these directly
5
- // (dialogs.tsx); the settings dialog edits them through this module so the
6
- // flows stay host-agnostic.
1
+ // Host-agnostic dialog size preferences, stored in host kv rather than the
2
+ // config file because UI settings are per-host and synced by opencode
3
+ // itself. Dialog width maps to the host's fixed dialog widths; height is a
4
+ // percent of terminal rows. v1 dialogs apply these directly (dialogs.tsx);
5
+ // the settings dialog edits them through this module so the flows stay
6
+ // host-agnostic.
7
7
  export const UI_WIDTH_KV = "openshare.ui-width";
8
8
  export const UI_HEIGHT_PERCENT_KV = "openshare.ui-height-percent";
9
9
  export const HEIGHT_PERCENT_MIN = 25;
package/dist/v2-server.js CHANGED
@@ -177,12 +177,11 @@ export function createV2ServerSetup() {
177
177
  // the client adapter and feed them into the engine as the v1 events it
178
178
  // understands (session.updated / message.updated / message.part.updated).
179
179
  // The engine's keyed queue makes repeated re-syncs idempotent.
180
- // NOTE: no content-hash dedupe here — a refresh that runs while the
181
- // session is still dormant (pre-flag) would poison the cache without
182
- // emitting anything, and the flag-time refresh would then SKIP the
183
- // unchanged messages (live-caught in the v2 mock suite: the share's
184
- // first assistant content arrived only as the next change). The dedupe
185
- // belongs engine-side, where dormancy is known, if it's ever needed.
180
+ // NOTE: no content-hash dedupe here. A refresh running while the session
181
+ // is still dormant (pre-flag) would poison the cache without emitting
182
+ // anything, and the flag-time refresh would then SKIP the unchanged
183
+ // messages, so the share's first assistant content would only arrive on
184
+ // the next change. Dedupe belongs engine-side, where dormancy is known.
186
185
  const pendingRefresh = new Map();
187
186
  // queued inbox inputs per session: v2 persists the prompt in the inbox
188
187
  // (delivery "queue") while the current turn still runs — the message
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mirrowel/opencode-openshare",
3
- "version": "0.2.1",
3
+ "version": "0.3.0-dev.10",
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",
@@ -64,10 +64,8 @@
64
64
  "build:tui": "bun scripts/build-tui.mjs",
65
65
  "build:standalone": "bun scripts/build-standalone.mjs",
66
66
  "typecheck": "tsc --noEmit",
67
- "ci": "npm run release:intent:check && npm run ci:package",
67
+ "ci": "npm run ci:package",
68
68
  "ci:package": "npm run typecheck && npm run build && npm run pack:dry-run",
69
- "release:intent": "node scripts/set-release-intent.mjs",
70
- "release:intent:check": "node scripts/check-release-intent.mjs",
71
69
  "pack:dry-run": "node scripts/pack-dry-run.mjs",
72
70
  "test:regression": "node scripts/regression-tests.mjs",
73
71
  "prepublishOnly": "npm run typecheck && npm run build",
@@ -5,8 +5,8 @@
5
5
  * the servers map) and the TUI (`patchSessionBinding` in share-flow.ts —
6
6
  * opt-outs, collision resolution). Their PATCHes interleave at the host:
7
7
  * writer A reads at T0, writer B reads at T1, A PATCHes at T2, B PATCHes
8
- * at T3 — A's change is silently lost (live-caught class: a TUI opt-out
9
- * erased by the engine's binding refresh).
8
+ * at T3, and A's change is silently lost (a TUI opt-out erased by the
9
+ * engine's binding refresh).
10
10
  *
11
11
  * Both route their whole read-modify-write through this per-session FIFO.
12
12
  * The queue never breaks on errors; entries drop when the process exits.
package/src/commands.ts CHANGED
@@ -5,14 +5,14 @@
5
5
  // auth — one surface, no duplicated manager); on an unshared session it
6
6
  // asks consent and creates the share. /openshare opens settings.
7
7
  //
8
- // LAYER SHAPE (live-caught 2026-10-19): the v1 host keymap gates layers
9
- // that declare `mode` on the mode stack (resting mode is "base"), so a
10
- // plugin layer MUST NOT pass mode ("global" made the whole layer
11
- // unreachable — no palette entries, no slash commands, no shadow) and
12
- // must not carry undefined-valued command fields. The registration shape
13
- // is exactly what the proven family plugins use: {commands, bindings},
14
- // commands with only defined fields, category "OpenShare" (own group,
15
- // deliberately NOT joined into the shared family palette category).
8
+ // LAYER SHAPE: the v1 host keymap gates layers that declare `mode` on the
9
+ // mode stack (resting mode is "base"), so a plugin layer MUST NOT pass mode
10
+ // ("global" made the whole layer unreachable: no palette entries, no slash
11
+ // commands, no shadow) and must not carry undefined-valued command fields.
12
+ // The registration shape is exactly what the proven family plugins use:
13
+ // {commands, bindings}, commands with only defined fields, category
14
+ // "OpenShare" (own group, deliberately NOT joined into the shared family
15
+ // palette category).
16
16
 
17
17
  import {
18
18
  clearGoneBindingEntries,