@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/settings.js CHANGED
@@ -4,8 +4,8 @@
4
4
  // run on the v1 TUI and the v2 dialog bridge.
5
5
  //
6
6
  // Sections: Connection, Sharing, Read auth, Advanced, This session.
7
- import { configSourceMap, moduleDir, writeCandidates } from "./config.js";
8
- import { adminClient, adminTokenFor, currentConfig, deleteServerEntry, disconnectAtServer, enrollAtServer, hostOf, isConfigured, normServerUrl, probeAuthMode, probeWorker, patchSessionBinding, readBinding, readBindings, resolveRoot, rotateAtServer, runUnshareFlow, saveConfig, saveServerEntry, meClient, serverEntries, setShareOpts, shareWithConsent, workerDeleteShare, } from "./share-flow.js";
7
+ import { configSourceMap, moduleDir, pluginVersion, writeCandidates } from "./config.js";
8
+ import { adminClient, adminTokenFor, currentConfig, deleteServerEntry, disconnectAtServer, enrollAtServer, fmtHours, parseTtlHours, hostOf, isConfigured, normServerUrl, probeAuthMode, probeWorker, patchSessionBinding, patchShareOpts, readBinding, readBindings, resolveRoot, rotateAtServer, runUnshareFlow, saveConfig, saveServerEntry, meClient, serverEntries, setShareOpts, shareWithConsent, workerDeleteShare, } from "./share-flow.js";
9
9
  import { DIALOG_WIDTH_COLUMNS, kvHeightPercentOf, kvWidthOf, nextDialogWidth, UI_HEIGHT_PERCENT_KV, UI_WIDTH_KV, clampHeightPercent } from "./ui-prefs.js";
10
10
  /** description suffix: `[from <source>]` when a key is overridden by a
11
11
  * file/env higher in the chain than the write target. */
@@ -89,13 +89,13 @@ const LABELS = {
89
89
  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.',
90
90
  def: '"$Provider/$Model"',
91
91
  },
92
- ttlDays: {
93
- label: "TTL (days)",
94
- help: "Share lifetime; the worker caps it at its MAX_TTL_DAYS.",
92
+ ttlHours: {
93
+ label: "TTL (hours)",
94
+ help: "Share lifetime in hours (2d writes 48); capped server-side.",
95
95
  kind: "number",
96
96
  section: "Sharing",
97
- info: "How long pages live before the server's sweeper deletes them. Capped server-side: the deployment's MAX_TTL_DAYS and the token's maxTtlDays limit both apply.\n\nA per-session override lives in the This-session section; it is sent at create/attach time.",
98
- def: "worker cap (90 on the reference deployment)",
97
+ 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.\n\nA per-session override lives in the This-session section; it is sent at create/attach time. The session prompt takes 6h / 2d style input.",
98
+ def: "worker cap (2160h = 90d on the reference deployment)",
99
99
  },
100
100
  authLogin: {
101
101
  label: "Read auth: login",
@@ -165,8 +165,8 @@ async function editKey(host, cfg, key) {
165
165
  const meta = LABELS[key];
166
166
  if (!meta)
167
167
  return undefined;
168
- // selection-backed settings (owner: "setting share mode i need to enter it
169
- // manually? No selection?") — fixed vocabularies become menus
168
+ // selection-backed settings: fixed vocabularies become menus instead of
169
+ // asking for a typed value
170
170
  if (meta.options) {
171
171
  const current = String(cfg[key] ?? "");
172
172
  const picked = await showSelect(host.ui, {
@@ -214,10 +214,14 @@ async function editKey(host, cfg, key) {
214
214
  }
215
215
  return next;
216
216
  }
217
- /** Connect (add + enroll) a server — the /share first-run flow and the
217
+ /** Connect (add + enroll) a server - the /share first-run flow and the
218
218
  * settings "Connect to a server" row share it. Prompts the URL, probes
219
- * the auth mode, prompts a code for private servers, enrolls, persists
220
- * the token and makes it the active server. */
219
+ * the auth mode, then ASKS ONCE: onboard to an account, or stay anonymous
220
+ * (public servers only; anonymous shares sit under the server's global
221
+ * caps). Onboarding detects whether a code is needed and only then prompts
222
+ * for one (a code already in config is used without asking). The outcome is
223
+ * remembered per server: a token means onboarded, an anonymous flag means
224
+ * declined. */
221
225
  export async function connectServerFlow(host) {
222
226
  const raw = await showPrompt(host.ui, { title: "Share server URL", placeholder: "https://your-openshare-host" });
223
227
  if (raw === undefined || raw === "")
@@ -225,33 +229,84 @@ export async function connectServerFlow(host) {
225
229
  const url = normServerUrl(raw);
226
230
  if (!/^https?:\/\//.test(url))
227
231
  return { ok: false, message: "URL must start with http:// or https://" };
228
- host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}…` });
232
+ host.ui.toast({ variant: "info", title: "OpenShare", message: `Probing ${url}:` });
229
233
  const probe = await probeAuthMode(url);
230
234
  if (!probe.ok)
231
235
  return { ok: false, message: `${url}: ${probe.detail}` };
232
- let code = "";
233
- if (probe.mode === "private") {
234
- const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_… (from the hoster)" });
236
+ const existing = serverEntries().find((sv) => sv.url === url)?.entry ?? {};
237
+ const deactivateOthers = () => {
238
+ for (const other of serverEntries()) {
239
+ if (other.url !== url)
240
+ saveServerEntry(other.url, { active: undefined });
241
+ }
242
+ };
243
+ if (probe.mode === "public") {
244
+ // the ask: an account is a real thing (own shares, own limits,
245
+ // revocable credentials) - but anonymous sharing works too
246
+ const picked = await showSelect(host.ui, {
247
+ title: `${url} - account?`,
248
+ options: [
249
+ {
250
+ value: "onboard",
251
+ title: "Onboard (get an account)",
252
+ description: "your own user, tokens and share limits; survives the server's anonymous caps",
253
+ 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.",
254
+ },
255
+ {
256
+ value: "anon",
257
+ title: "Stay anonymous",
258
+ description: "share without an account; counts against the server-wide anonymous limits",
259
+ 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.",
260
+ },
261
+ ],
262
+ });
263
+ if (picked === "anon") {
264
+ saveServerEntry(url, { ...(existing.token ? {} : { anonymous: true }), label: existing.label, active: true });
265
+ return { ok: true, message: `Connected to ${url} anonymously - sharing works, no account.`, url };
266
+ }
267
+ if (picked === undefined || picked === null)
268
+ return { ok: false, message: "Cancelled." };
269
+ // onboard: try without a code (codeless servers), fall through to
270
+ // the code prompt exactly when the server demands one
271
+ let code = existing.code ?? "";
272
+ if (!code) {
273
+ const free = await enrollAtServer(url, "");
274
+ if (free.ok) {
275
+ deactivateOthers();
276
+ saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined, active: true });
277
+ return { ok: true, message: `Connected to ${url} as "${free.label}" - sharing goes there now.`, url };
278
+ }
279
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
280
+ if (typed === undefined)
281
+ return { ok: false, message: "Cancelled - this server needs a code to onboard." };
282
+ code = typed.trim();
283
+ }
284
+ const res = await enrollAtServer(url, code);
285
+ if (!res.ok)
286
+ return { ok: false, message: `Enrollment refused: ${res.error}` };
287
+ deactivateOthers();
288
+ saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, anonymous: undefined, active: true });
289
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
290
+ }
291
+ // private server: a code is required, full stop
292
+ let code = existing.code ?? "";
293
+ if (!code) {
294
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
235
295
  if (typed === undefined)
236
- return { ok: false, message: "Cancelled — private servers need a code." };
296
+ return { ok: false, message: "Cancelled - private servers need a code." };
237
297
  code = typed.trim();
238
298
  }
239
299
  const res = await enrollAtServer(url, code);
240
300
  if (!res.ok)
241
301
  return { ok: false, message: `Enrollment refused: ${res.error}` };
242
- // single active server: deactivate the others in the settings home,
243
- // then persist the new membership as active
244
- for (const other of serverEntries()) {
245
- if (other.url !== url)
246
- saveServerEntry(other.url, { active: undefined });
247
- }
302
+ deactivateOthers();
248
303
  saveServerEntry(url, { token: res.token, label: res.label, code: code || undefined, active: true });
249
- return { ok: true, message: `Connected to ${url} as "${res.label}" — sharing goes there now.`, url };
304
+ return { ok: true, message: `Connected to ${url} as "${res.label}" - sharing goes there now.`, url };
250
305
  }
251
306
  /** Per-server submenu: identity status + membership actions. Root-token
252
- * holders get "Get a user token" (admin mints their own user identity —
253
- * root stays as adminToken; being admin and being a user are NOT mutually
254
- * exclusive, live-caught). */
307
+ * holders get "Get a user token" (the admin mints their own user identity;
308
+ * root stays as adminToken, since being admin and being a user are not
309
+ * mutually exclusive). */
255
310
  async function serverMenu(host, url) {
256
311
  while (true) {
257
312
  const entries = serverEntries();
@@ -265,7 +320,9 @@ async function serverMenu(host, url) {
265
320
  ? `token ${mask(mine.entry.token)}`
266
321
  : mine.entry.code
267
322
  ? "no token (code saved, enrolls on demand)"
268
- : "no token (public server, enrolls on demand)";
323
+ : mine.entry.anonymous
324
+ ? "anonymous (declined onboarding)"
325
+ : "no token (public server, shares anonymously)";
269
326
  const options = [
270
327
  { title: url, value: "info", description: `${mine.entry.label ?? "(no label)"} · ${cred}${mine.active ? " · ACTIVE" : ""}`, disabled: true },
271
328
  ];
@@ -344,8 +401,20 @@ async function serverMenu(host, url) {
344
401
  continue;
345
402
  }
346
403
  let code = mine.entry.code ?? "";
347
- if (probe.mode === "private" && !code) {
348
- const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_… (from the hoster)" });
404
+ if (!code) {
405
+ // codeless first: only prompt when the server actually demands
406
+ // a code (public + codeless-on boards silently, like connect)
407
+ const free = await enrollAtServer(url, "");
408
+ if (free.ok) {
409
+ saveServerEntry(url, { token: free.token, label: free.label, anonymous: undefined });
410
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${free.label}".` });
411
+ continue;
412
+ }
413
+ if (!/code required/i.test(free.error ?? "")) {
414
+ await showInfo(host.ui, { title: "Enrollment refused", message: free.error ?? "unknown error" });
415
+ continue;
416
+ }
417
+ const typed = await showPrompt(host.ui, { title: `${url} needs an access code`, placeholder: "osc_: (from the hoster)" });
349
418
  if (typed === undefined)
350
419
  continue;
351
420
  code = typed.trim();
@@ -354,7 +423,7 @@ async function serverMenu(host, url) {
354
423
  }
355
424
  const res = await enrollAtServer(url, code);
356
425
  if (res.ok) {
357
- saveServerEntry(url, { token: res.token, label: res.label });
426
+ saveServerEntry(url, { token: res.token, label: res.label, anonymous: undefined });
358
427
  host.ui.toast({ variant: "success", title: "OpenShare", message: `Enrolled as "${res.label}".` });
359
428
  }
360
429
  else {
@@ -569,9 +638,9 @@ async function withBusy(host, title, work) {
569
638
  catch { }
570
639
  doneBusy?.();
571
640
  }
572
- /** Server info, the readable version (live-caught: the raw JSON policy line
573
- * escaped the dialog border): formatted policy lines, auth explained in
574
- * words, YOUR standing on this server, and follow-up actions. */
641
+ /** Server info, the readable version (the raw JSON policy line escapes
642
+ * the dialog border): formatted policy lines, auth explained in words,
643
+ * YOUR standing on this server, and follow-up actions. */
575
644
  async function serverInfoScreen(host, url, client) {
576
645
  const h = await client.health();
577
646
  if (!h.ok) {
@@ -835,16 +904,15 @@ async function adminSharesMenu(host, client, url, shares, users) {
835
904
  }
836
905
  }
837
906
  /** The settings dialog: a compact ROOT of group entries (with live
838
- * summaries) opening focused submenus. Two-level by design — one flat
839
- * ~25-row dump was unreadable (owner-directed regroup 2026-10-19).
840
- * Save semantics unchanged: root Exit saves when dirty; submenu Back
841
- * just returns. */
907
+ * summaries) opening focused submenus. Two-level by design: one flat ~25-row
908
+ * dump is unreadable. Save semantics unchanged: root Exit saves when dirty;
909
+ * submenu Back just returns. */
842
910
  export async function settingsDialog(host, startAtSession, startGroup) {
843
911
  let cfg = currentConfig();
844
912
  let dirty = false;
845
- // Write target (owner rule): global settings home by default; any other
846
- // existing file in the read chain may be chosen explicitly. The TUI
847
- // never creates deploy-time override files.
913
+ // Write target: global settings home by default; any other existing
914
+ // file in the read chain may be chosen explicitly. The TUI never
915
+ // creates deploy-time override files.
848
916
  const candidates = writeCandidates(moduleDir());
849
917
  let writeTarget = candidates[0];
850
918
  const sources = configSourceMap(moduleDir());
@@ -1004,8 +1072,8 @@ export async function settingsDialog(host, startAtSession, startGroup) {
1004
1072
  rows.push({
1005
1073
  title: "Session TTL override (days)",
1006
1074
  value: "session:ttl",
1007
- description: binding?.opts?.ttlDays !== undefined ? String(binding.opts.ttlDays) : "(worker default)",
1008
- help: `Per-session share lifetime. Overrides the config ttlDays for THIS session's pages; still capped by the server's MAX_TTL_DAYS and the token's maxTtlDays. On a live share it applies immediately (shortening may end an older page at once); otherwise it is used when you share.\n\nCurrent: ${binding?.opts?.ttlDays !== undefined ? `${binding.opts.ttlDays} days` : "(none — config default)"}`,
1075
+ description: binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(worker default)",
1076
+ 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.\n\nCurrent: ${binding?.opts?.ttlHours !== undefined ? fmtHours(binding.opts.ttlHours) : "(none — config default)"}`,
1009
1077
  });
1010
1078
  rows.push({
1011
1079
  title: "Session read auth",
@@ -1220,9 +1288,9 @@ export async function settingsDialog(host, startAtSession, startGroup) {
1220
1288
  if (!b.server)
1221
1289
  continue;
1222
1290
  // keep id/secret on the opt-out marker: the snapshot stays
1223
- // deletable later and re-sharing reuses the binding instead
1224
- // of stalling on a secret-less marker (live-caught as "can
1225
- // never unshare or re-share after leave-as-is")
1291
+ // deletable later, and re-sharing reuses the binding instead
1292
+ // of stalling on a secret-less marker that can never unshare
1293
+ // or re-share again.
1226
1294
  servers[b.server] = { ...servers[b.server], id: b.id, secret: b.secret, rev: b.rev, url: b.url, optedOut: true };
1227
1295
  }
1228
1296
  });
@@ -1242,28 +1310,35 @@ export async function settingsDialog(host, startAtSession, startGroup) {
1242
1310
  return;
1243
1311
  }
1244
1312
  if (choice === "session:ttl") {
1245
- const raw = await showPrompt(host.ui, { title: "Session TTL (days)", placeholder: "days", value: current?.opts?.ttlDays !== undefined ? String(current.opts.ttlDays) : "" });
1313
+ const raw = await showPrompt(host.ui, { title: "Session TTL", placeholder: "hours: 6h, 2d or 48", value: current?.opts?.ttlHours !== undefined ? fmtHours(current.opts.ttlHours) : "" });
1246
1314
  if (raw === undefined)
1247
1315
  return;
1248
1316
  const opts = { ...(current?.opts ?? {}) };
1249
1317
  if (raw === "")
1250
- delete opts.ttlDays;
1318
+ delete opts.ttlHours;
1251
1319
  else {
1252
- const days = Number(raw);
1253
- if (!Number.isFinite(days) || days <= 0) {
1254
- host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be a positive number of days." });
1320
+ const hours = parseTtlHours(raw);
1321
+ if (hours === undefined) {
1322
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "TTL must be 6h / 2d style or a positive number of hours." });
1255
1323
  return;
1256
1324
  }
1257
- opts.ttlDays = Math.floor(days);
1325
+ opts.ttlHours = hours;
1258
1326
  }
1259
1327
  await setShareOpts(host.client, root, opts);
1260
1328
  const liveNow = live.length > 0;
1329
+ if (liveNow) {
1330
+ // REST push (#13): immediate on every server, socket or not
1331
+ 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 })));
1332
+ const failed = results.filter((r) => !r.ok).length;
1333
+ if (failed)
1334
+ host.ui.toast({ variant: "error", title: "OpenShare", message: `TTL applied on ${results.length - failed}/${results.length} server(s) - the rest sync on next connect.` });
1335
+ }
1261
1336
  host.ui.toast({
1262
1337
  variant: "success",
1263
1338
  title: "OpenShare",
1264
1339
  message: liveNow
1265
- ? `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
1266
- : `Session TTL: ${opts.ttlDays !== undefined ? `${opts.ttlDays}d` : "(default)"} - saved, applies when you share.`,
1340
+ ? `Session TTL: ${opts.ttlHours !== undefined ? `${fmtHours(opts.ttlHours)} - applied now (shortening may end an older page immediately)` : "(default) - applied now"}.`
1341
+ : `Session TTL: ${opts.ttlHours !== undefined ? fmtHours(opts.ttlHours) : "(default)"} - saved, applies when you share.`,
1267
1342
  });
1268
1343
  return;
1269
1344
  }
@@ -1291,6 +1366,13 @@ export async function settingsDialog(host, startAtSession, startGroup) {
1291
1366
  }
1292
1367
  await setShareOpts(host.client, root, opts);
1293
1368
  const liveNow2 = live.length > 0;
1369
+ if (liveNow2) {
1370
+ const pair = login === "" ? { authLogin: "", authPassword: "" } : { authLogin: login, authPassword: password };
1371
+ 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 })));
1372
+ const failed2 = results2.filter((r) => !r.ok).length;
1373
+ if (failed2)
1374
+ 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.` });
1375
+ }
1294
1376
  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.` });
1295
1377
  return;
1296
1378
  }
@@ -1340,7 +1422,7 @@ export async function settingsDialog(host, startAtSession, startGroup) {
1340
1422
  });
1341
1423
  groups.push({
1342
1424
  id: "sharing",
1343
- summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${cfg.ttlDays ?? "server cap"}`,
1425
+ summary: `sync ${seconds(cfg.debounceMs, 10000)} · completed ${seconds(cfg.completedDelayMs, 5000)} · TTL ${fmtHours(cfg.ttlHours ?? 2160)}`,
1344
1426
  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.\n\nCadence values are clamped by the server's policy window.`,
1345
1427
  });
1346
1428
  groups.push({
@@ -1370,7 +1452,7 @@ export async function settingsDialog(host, startAtSession, startGroup) {
1370
1452
  help: group.help,
1371
1453
  }));
1372
1454
  options.push({ title: dirty ? "Save & exit" : "Exit", value: "action:exit", description: dirty ? `Write ${writeTarget.path}` : "No changes" });
1373
- const choice = await showSelect(host.ui, { title: "OpenShare settings", options });
1455
+ const choice = await showSelect(host.ui, { title: `OpenShare v${pluginVersion()} · settings`, options });
1374
1456
  if (choice === undefined || choice === "action:exit") {
1375
1457
  if (dirty) {
1376
1458
  const saved = saveConfig(cfg, writeTarget.path);
@@ -20,11 +20,10 @@ export type SessionBinding = {
20
20
  };
21
21
  export declare function pluginConfigPaths(baseDir?: string): string[];
22
22
  export declare function currentConfig(baseDir?: string): ShareConfig;
23
- /** Writes the config file. Target rule (owner-directed): the global
24
- * settings home by default, or an explicit existing file chosen in the
25
- * TUI — NEVER "first existing in the read chain" (that wrote TUI edits
26
- * into the tracked adjacent template, live-caught). $comment and foreign
27
- * keys preserved. */
23
+ /** Writes the config file. Target rule: the global settings home by
24
+ * default, or an explicit existing file chosen in the TUI, never "first
25
+ * existing in the read chain" (that writes TUI edits into the tracked
26
+ * adjacent template). $comment and foreign keys preserved. */
28
27
  export declare function saveConfig(cfg: ShareConfig, target?: string, baseDir?: string): {
29
28
  path: string;
30
29
  } | undefined;
@@ -51,6 +50,11 @@ export declare function readBindings(client: ClientApi | undefined, sid: string)
51
50
  server: string;
52
51
  collided?: string;
53
52
  }>>;
53
+ /** Human TTL from hours: "6h", "2d", "90d" (the TUI prompt accepts
54
+ * these same shapes back). */
55
+ export declare function fmtHours(hours: number): string;
56
+ /** Parse "6h" / "2d" / "48" into hours (bare number = hours). */
57
+ export declare function parseTtlHours(raw: string): number | undefined;
54
58
  export declare function hostOf(url: string): string;
55
59
  /** Mutate metadata.openshare.servers[url] through a patcher (TUI per-
56
60
  * server actions: opt-out entries, collision resolution). Reads-modifies-
@@ -75,6 +79,18 @@ export declare function setShareFlag(client: ClientApi | undefined, sid: string,
75
79
  export declare function setShareOpts(client: ClientApi | undefined, sid: string, opts: SessionOpts): Promise<boolean>;
76
80
  export declare function clearShareFlag(client: ClientApi | undefined, sid: string): Promise<boolean>;
77
81
  export declare function workerDeleteShare(workerUrl: string, id: string, secret: string): Promise<boolean>;
82
+ /** PATCH a live share's settings over REST (#13): immediate effect with
83
+ * no socket required (the engine's attach-sync remains the structural
84
+ * path - this is the same edit pushed now, per server). Auth sends as
85
+ * the both-or-none pair or not at all; ttlHours only when explicit. */
86
+ export declare function patchShareOpts(workerUrl: string, id: string, opts: {
87
+ authLogin?: string;
88
+ authPassword?: string;
89
+ ttlHours?: number;
90
+ }, secret: string): Promise<{
91
+ ok: boolean;
92
+ error?: string;
93
+ }>;
78
94
  /** Health probe for the settings dialog's connection test. */
79
95
  export declare function probeWorker(workerUrl: string): Promise<{
80
96
  ok: boolean;
@@ -336,8 +352,16 @@ export type FlowResult = {
336
352
  * informed, not ceremonial. */
337
353
  export declare function requestShareConsent(host: ShareHostApi, context?: {
338
354
  server?: string;
339
- ttlDays?: number;
355
+ ttlHours?: number;
340
356
  auth: boolean;
357
+ counts?: {
358
+ messages: number;
359
+ toolCalls: number;
360
+ };
361
+ redaction?: {
362
+ hits: number;
363
+ samples: string[];
364
+ };
341
365
  }): Promise<boolean>;
342
366
  /** runShareFlow with the consent gate applied: share copies the link,
343
367
  * unshared asks consent once, then creates. Every create path routes
@@ -16,7 +16,7 @@
16
16
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { loadConfig, moduleDir, configPaths, configHomePath, resolveServer, saveServerEntry, deleteServerEntry, METADATA_KEY } from "./config.js";
18
18
  import { dbg, dbgLog } from "./sidebar.js";
19
- import { engineTargetUrls } from "./core.js";
19
+ import { engineTargetUrls, sessionStatsFor } from "./core.js";
20
20
  import { queueBindingWrite } from "./binding-queue.js";
21
21
  export function pluginConfigPaths(baseDir = moduleDir()) {
22
22
  return configPaths(baseDir);
@@ -24,11 +24,10 @@ export function pluginConfigPaths(baseDir = moduleDir()) {
24
24
  export function currentConfig(baseDir = moduleDir()) {
25
25
  return loadConfig(baseDir);
26
26
  }
27
- /** Writes the config file. Target rule (owner-directed): the global
28
- * settings home by default, or an explicit existing file chosen in the
29
- * TUI — NEVER "first existing in the read chain" (that wrote TUI edits
30
- * into the tracked adjacent template, live-caught). $comment and foreign
31
- * keys preserved. */
27
+ /** Writes the config file. Target rule: the global settings home by
28
+ * default, or an explicit existing file chosen in the TUI, never "first
29
+ * existing in the read chain" (that writes TUI edits into the tracked
30
+ * adjacent template). $comment and foreign keys preserved. */
32
31
  export function saveConfig(cfg, target, baseDir = moduleDir()) {
33
32
  const path = target ?? configHomePath();
34
33
  if (!path)
@@ -53,7 +52,7 @@ export function saveConfig(cfg, target, baseDir = moduleDir()) {
53
52
  "debounceMs",
54
53
  "completedDelayMs",
55
54
  "modelLabel",
56
- "ttlDays",
55
+ "ttlHours",
57
56
  "authLogin",
58
57
  "authPassword",
59
58
  "spoolDir",
@@ -152,6 +151,26 @@ export function bindingsOf(info) {
152
151
  export async function readBindings(client, sid) {
153
152
  return bindingsOf(await getSession(client, sid));
154
153
  }
154
+ /** Human TTL from hours: "6h", "2d", "90d" (the TUI prompt accepts
155
+ * these same shapes back). */
156
+ export function fmtHours(hours) {
157
+ if (!Number.isFinite(hours) || hours <= 0)
158
+ return "?";
159
+ if (hours % 24 === 0)
160
+ return `${Math.floor(hours / 24)}d`;
161
+ return `${Math.floor(hours)}h`;
162
+ }
163
+ /** Parse "6h" / "2d" / "48" into hours (bare number = hours). */
164
+ export function parseTtlHours(raw) {
165
+ const m = /^\s*(\d+(?:\.\d+)?)\s*(h|d|hr|hours?|days?)?\s*$/i.exec(raw ?? "");
166
+ if (!m)
167
+ return undefined;
168
+ const v = Number(m[1]);
169
+ if (!Number.isFinite(v) || v <= 0)
170
+ return undefined;
171
+ const unit = (m[2] ?? "h").toLowerCase();
172
+ return Math.max(1, Math.floor(unit.startsWith("d") ? v * 24 : v));
173
+ }
155
174
  export function hostOf(url) {
156
175
  try {
157
176
  return new URL(url).host;
@@ -300,6 +319,32 @@ export async function workerDeleteShare(workerUrl, id, secret) {
300
319
  return false;
301
320
  }
302
321
  }
322
+ /** PATCH a live share's settings over REST (#13): immediate effect with
323
+ * no socket required (the engine's attach-sync remains the structural
324
+ * path - this is the same edit pushed now, per server). Auth sends as
325
+ * the both-or-none pair or not at all; ttlHours only when explicit. */
326
+ export async function patchShareOpts(workerUrl, id, opts, secret) {
327
+ const body = { secret };
328
+ if (opts.authLogin !== undefined && opts.authPassword !== undefined) {
329
+ body.authLogin = opts.authLogin;
330
+ body.authPassword = opts.authPassword;
331
+ }
332
+ if (opts.ttlHours !== undefined)
333
+ body.ttlHours = opts.ttlHours;
334
+ try {
335
+ const res = await fetch(`${workerUrl.replace(/\/$/, "")}/api/share/${id}`, {
336
+ method: "PATCH",
337
+ headers: { "content-type": "application/json" },
338
+ body: JSON.stringify(body),
339
+ signal: AbortSignal.timeout(8000),
340
+ });
341
+ const json = (await res.json().catch(() => ({})));
342
+ return res.ok && json.ok ? { ok: true } : { ok: false, error: json.error ?? `HTTP ${res.status}` };
343
+ }
344
+ catch (e) {
345
+ return { ok: false, error: e?.message || String(e) };
346
+ }
347
+ }
303
348
  /** Health probe for the settings dialog's connection test. */
304
349
  export async function probeWorker(workerUrl) {
305
350
  if (!workerUrl)
@@ -518,8 +563,10 @@ export async function requestShareConsent(host, context) {
518
563
  const bits = [];
519
564
  if (context.server)
520
565
  bits.push(`server: ${context.server}`);
521
- if (context.ttlDays !== undefined)
522
- bits.push(`TTL: ${context.ttlDays}d`);
566
+ if (context.ttlHours !== undefined)
567
+ bits.push(`TTL: ${fmtHours(context.ttlHours)}`);
568
+ if (context.redaction && context.redaction.hits > 0)
569
+ bits.push(`redaction: ${context.redaction.hits} hit(s)`);
523
570
  bits.push(`read auth: ${context.auth ? "on" : "off"}`);
524
571
  lines.push(bits.join(" - "));
525
572
  }
@@ -554,12 +601,20 @@ export async function shareWithConsent(host, sessionID) {
554
601
  }
555
602
  const opts = all[0]?.opts;
556
603
  const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined);
557
- if (!(await requestShareConsent(host, {
558
- server: server ? hostOf(server.url) : undefined,
559
- ttlDays: opts?.ttlDays ?? cfg.ttlDays,
560
- auth: !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword),
561
- })))
562
- return { ok: false, message: "Share cancelled." };
604
+ {
605
+ const stats = sessionStatsFor(root);
606
+ const gated = !!(opts?.authLogin ?? cfg.authLogin) && !!(opts?.authPassword ?? cfg.authPassword);
607
+ if (!(await requestShareConsent(host, {
608
+ server: server ? hostOf(server.url) : undefined,
609
+ ttlHours: opts?.ttlHours ?? cfg.ttlHours,
610
+ auth: gated,
611
+ counts: { messages: stats.messages, toolCalls: stats.toolCalls },
612
+ // redaction only runs on ungated shares (a gated share is
613
+ // private from casual observers; the ledger reflects that)
614
+ redaction: gated ? undefined : { hits: stats.redaction.count, samples: stats.redaction.samples.map((h) => `${h.sample} [${h.kind}]`) },
615
+ })))
616
+ return { ok: false, message: "Share cancelled." };
617
+ }
563
618
  return confirmShare(host, root);
564
619
  }
565
620
  /** Post-consent share: set the flag, wait for the URL, copy it. */
@@ -580,9 +635,9 @@ export async function confirmShare(host, rootSessionID) {
580
635
  const binding = await waitForBinding(host.client, rootSessionID);
581
636
  dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`);
582
637
  if (!binding?.url) {
583
- // a failed create must NOT leave the flag armed - the engine would
584
- // publish LATER with no further consent (live-caught). Disarm now;
585
- // re-running /share re-consents.
638
+ // a failed create must NOT leave the flag armed: the engine would
639
+ // publish LATER with no further consent. Disarm now; re-running
640
+ // /share re-consents.
586
641
  await clearShareFlag(host.client, rootSessionID);
587
642
  const fresh = await readBinding(host.client, rootSessionID);
588
643
  const why = fresh?.lastError ? `\nReason: ${fresh.lastError}` : "";