@mirrowel/opencode-openshare 0.2.1 → 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,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";
@@ -97,10 +99,13 @@ function configSourceMap(pluginDir, env = process.env) {
97
99
  debounceMs: env.OPENSHARE_DEBOUNCE_MS,
98
100
  completedDelayMs: env.OPENSHARE_COMPLETED_DELAY_MS,
99
101
  modelLabel: env.OPENSHARE_MODEL_LABEL,
102
+ ttlHours: env.OPENSHARE_TTL_HOURS,
100
103
  ttlDays: env.OPENSHARE_TTL_DAYS,
101
104
  authLogin: env.OPENSHARE_AUTH_LOGIN,
102
105
  authPassword: env.OPENSHARE_AUTH_PASSWORD,
103
106
  urlFile: env.OPENSHARE_URL_FILE,
107
+ urlFileFormat: env.OPENSHARE_URL_FILE_FORMAT,
108
+ configSet: env.OPENSHARE_CONFIG_SET,
104
109
  spoolDir: env.OPENSHARE_SPOOL_DIR,
105
110
  announceStderr: env.OPENSHARE_ANNOUNCE_STDERR,
106
111
  shareMode: env.OPENSHARE_SHARE_MODE
@@ -109,6 +114,24 @@ function configSourceMap(pluginDir, env = process.env) {
109
114
  if (value !== undefined && value !== "")
110
115
  sources[key] = "environment";
111
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
+ }
112
135
  for (const path of configPaths(pluginDir, env)) {
113
136
  let raw;
114
137
  try {
@@ -162,6 +185,8 @@ function normalizeServers(raw) {
162
185
  entry.adminToken = e.adminToken;
163
186
  if (e.active === true)
164
187
  entry.active = true;
188
+ if (e.anonymous === true)
189
+ entry.anonymous = true;
165
190
  out[url] = entry;
166
191
  }
167
192
  return Object.keys(out).length ? out : undefined;
@@ -231,26 +256,53 @@ function loadConfig(pluginDir, options) {
231
256
  const cfg = { ...DEFAULTS };
232
257
  const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"]);
233
258
  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")
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);
238
267
  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;
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 };
245
273
  }
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;
274
+ continue;
253
275
  }
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")));
254
306
  } catch (e) {
255
307
  if (existsSync(path))
256
308
  console.error(`[openshare] config file ${path} could not be parsed - ignored:`, e?.message ?? e);
@@ -273,6 +325,13 @@ function loadConfig(pluginDir, options) {
273
325
  cfg.shareMode = mode;
274
326
  }
275
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
+ }
276
335
  if (env.OPENSHARE_WORKER_URL)
277
336
  cfg.workerUrl = env.OPENSHARE_WORKER_URL;
278
337
  if (env.OPENSHARE_CREATE_TOKEN)
@@ -283,14 +342,20 @@ function loadConfig(pluginDir, options) {
283
342
  cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs);
284
343
  if (env.OPENSHARE_MODEL_LABEL !== undefined)
285
344
  cfg.modelLabel = env.OPENSHARE_MODEL_LABEL;
345
+ if (env.OPENSHARE_TTL_HOURS)
346
+ cfg.ttlHours = num(env.OPENSHARE_TTL_HOURS, cfg.ttlHours);
286
347
  if (env.OPENSHARE_TTL_DAYS)
287
- cfg.ttlDays = num(env.OPENSHARE_TTL_DAYS, cfg.ttlDays);
348
+ cfg.ttlHours = Math.max(1, Math.floor(num(env.OPENSHARE_TTL_DAYS, 90) * 24));
288
349
  if (env.OPENSHARE_AUTH_LOGIN !== undefined)
289
350
  cfg.authLogin = env.OPENSHARE_AUTH_LOGIN;
290
351
  if (env.OPENSHARE_AUTH_PASSWORD !== undefined)
291
352
  cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD;
292
353
  if (env.OPENSHARE_URL_FILE !== undefined)
293
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;
294
359
  if (env.OPENSHARE_SPOOL_DIR)
295
360
  cfg.spoolDir = env.OPENSHARE_SPOOL_DIR;
296
361
  if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined)
@@ -444,8 +509,18 @@ function queueBindingWrite(sid, fn) {
444
509
  return run;
445
510
  }
446
511
 
512
+ // src/redact.ts
513
+ var PATH_KEYS = new Set(["file", "path", "filePath", "cwd", "directory", "root"]);
514
+
447
515
  // src/core.ts
448
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
+ }
449
524
  function engineTargetUrls() {
450
525
  return liveTargets;
451
526
  }
@@ -474,7 +549,7 @@ function saveConfig(cfg, target, baseDir = moduleDir()) {
474
549
  "debounceMs",
475
550
  "completedDelayMs",
476
551
  "modelLabel",
477
- "ttlDays",
552
+ "ttlHours",
478
553
  "authLogin",
479
554
  "authPassword",
480
555
  "spoolDir",
@@ -562,6 +637,23 @@ function bindingsOf(info) {
562
637
  async function readBindings(client, sid) {
563
638
  return bindingsOf(await getSession(client, sid));
564
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
+ }
565
657
  function hostOf(url) {
566
658
  try {
567
659
  return new URL(url).host;
@@ -683,6 +775,27 @@ async function workerDeleteShare(workerUrl, id, secret) {
683
775
  return false;
684
776
  }
685
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
+ }
686
799
  async function probeWorker(workerUrl) {
687
800
  if (!workerUrl)
688
801
  return { ok: false, detail: "workerUrl is empty" };
@@ -866,8 +979,10 @@ async function requestShareConsent(host, context) {
866
979
  const bits = [];
867
980
  if (context.server)
868
981
  bits.push(`server: ${context.server}`);
869
- if (context.ttlDays !== undefined)
870
- bits.push(`TTL: ${context.ttlDays}d`);
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)`);
871
986
  bits.push(`read auth: ${context.auth ? "on" : "off"}`);
872
987
  lines.push(bits.join(" - "));
873
988
  }
@@ -895,12 +1010,18 @@ async function shareWithConsent(host, sessionID) {
895
1010
  }
896
1011
  const opts = all[0]?.opts;
897
1012
  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." };
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." };
1024
+ }
904
1025
  return confirmShare(host, root);
905
1026
  }
906
1027
  async function confirmShare(host, rootSessionID) {
@@ -1070,15 +1191,15 @@ A graceful opencode exit flushes the pending flip, so CI runs land their final s
1070
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.',
1071
1192
  def: '"$Provider/$Model"'
1072
1193
  },
1073
- ttlDays: {
1074
- label: "TTL (days)",
1075
- 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.",
1076
1197
  kind: "number",
1077
1198
  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.
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.
1079
1200
 
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)"
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)"
1082
1203
  },
1083
1204
  authLogin: {
1084
1205
  label: "Read auth: login",
@@ -1203,26 +1324,74 @@ async function connectServerFlow(host) {
1203
1324
  const url = normServerUrl(raw);
1204
1325
  if (!/^https?:\/\//.test(url))
1205
1326
  return { ok: false, message: "URL must start with http:// or https://" };
1206
- host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}\u2026` });
1327
+ host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` });
1207
1328
  const probe = await probeAuthMode(url);
1208
1329
  if (!probe.ok)
1209
1330
  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)" });
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)" });
1213
1385
  if (typed === undefined)
1214
- return { ok: false, message: "Cancelled \u2014 private servers need a code." };
1386
+ return { ok: false, message: "Cancelled - private servers need a code." };
1215
1387
  code = typed.trim();
1216
1388
  }
1217
1389
  const res = await enrollAtServer(url, code);
1218
1390
  if (!res.ok)
1219
1391
  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
- }
1392
+ deactivateOthers();
1224
1393
  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 };
1394
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
1226
1395
  }
1227
1396
  async function serverMenu(host, url) {
1228
1397
  while (true) {
@@ -1231,7 +1400,7 @@ async function serverMenu(host, url) {
1231
1400
  if (!mine)
1232
1401
  return;
1233
1402
  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)";
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)";
1235
1404
  const options = [
1236
1405
  { title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} \xB7 ${cred}${mine.active ? " \xB7 ACTIVE" : ""}`, disabled: true }
1237
1406
  ];
@@ -1311,8 +1480,18 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
1311
1480
  continue;
1312
1481
  }
1313
1482
  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)" });
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)" });
1316
1495
  if (typed === undefined)
1317
1496
  continue;
1318
1497
  code = typed.trim();
@@ -1321,7 +1500,7 @@ Needs the server's root CREATE_TOKEN (saved admin token used first; prompted oth
1321
1500
  }
1322
1501
  const res = await enrollAtServer(url, code);
1323
1502
  if (res.ok) {
1324
- saveServerEntry(url, { token: res.token, label: res.label });
1503
+ saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined });
1325
1504
  host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` });
1326
1505
  } else {
1327
1506
  await showInfo(host.ui, { title: "Enrollment refused", message: res.error ?? "unknown error" });
@@ -1984,10 +2163,10 @@ The clamp note appears when the server or your token's policy reduced a requeste
1984
2163
  rows.push({
1985
2164
  title: "Session TTL override (days)",
1986
2165
  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.
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.
1989
2168
 
1990
- 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)"}`
1991
2170
  });
1992
2171
  rows.push({
1993
2172
  title: "Session read auth",
@@ -2218,26 +2397,32 @@ Every link stops working for everyone. This cannot be undone.`,
2218
2397
  return;
2219
2398
  }
2220
2399
  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) : "" });
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) : "" });
2222
2401
  if (raw === undefined)
2223
2402
  return;
2224
2403
  const opts = { ...current?.opts ?? {} };
2225
2404
  if (raw === "")
2226
- delete opts.ttlDays;
2405
+ delete opts.ttlHours;
2227
2406
  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." });
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." });
2231
2410
  return;
2232
2411
  }
2233
- opts.ttlDays = Math.floor(days);
2412
+ opts.ttlHours = hours;
2234
2413
  }
2235
2414
  await setShareOpts(host.client, root, opts);
2236
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
+ }
2237
2422
  host.ui.toast({
2238
2423
  variant: "success",
2239
2424
  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.`
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.`
2241
2426
  });
2242
2427
  return;
2243
2428
  }
@@ -2262,6 +2447,13 @@ Every link stops working for everyone. This cannot be undone.`,
2262
2447
  }
2263
2448
  await setShareOpts(host.client, root, opts);
2264
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.` });
2456
+ }
2265
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.` });
2266
2458
  return;
2267
2459
  }
@@ -2312,7 +2504,7 @@ Every ACTIVE server receives new sessions (fan-out).`
2312
2504
  });
2313
2505
  groups.push({
2314
2506
  id: "sharing",
2315
- 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)}`,
2316
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.
2317
2509
 
2318
2510
  Cadence values are clamped by the server's policy window.`
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.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",
@@ -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,