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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { saveServerEntry, deleteServerEntry, type ShareConfig, type ServerEntry } from "./config.js";
2
- import type { ClientApi, SessionInfo, SessionOpts } from "./core.js";
2
+ import { type ClientApi, type SessionInfo, type SessionOpts } from "./core.js";
3
3
  import type { ShareHostApi } from "./tui-host.js";
4
4
  export type SessionBinding = {
5
5
  id: string;
@@ -8,14 +8,22 @@ export type SessionBinding = {
8
8
  url: string;
9
9
  enabled?: boolean;
10
10
  opts?: SessionOpts;
11
+ /** per-server "leave as-is" marker: a snapshot, not a live mirror */
12
+ optedOut?: boolean;
13
+ /** engine-recorded last create/attach refusal (quota, frozen, ...) */
14
+ lastError?: string;
15
+ /** server policy clamped the requested TTL */
16
+ ttlClamped?: {
17
+ requested: number;
18
+ effective: number;
19
+ };
11
20
  };
12
21
  export declare function pluginConfigPaths(baseDir?: string): string[];
13
22
  export declare function currentConfig(baseDir?: string): ShareConfig;
14
- /** Writes the config file. Target rule (owner-directed): the global
15
- * settings home by default, or an explicit existing file chosen in the
16
- * TUI — NEVER "first existing in the read chain" (that wrote TUI edits
17
- * into the tracked adjacent template, live-caught). $comment and foreign
18
- * 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. */
19
27
  export declare function saveConfig(cfg: ShareConfig, target?: string, baseDir?: string): {
20
28
  path: string;
21
29
  } | undefined;
@@ -42,6 +50,11 @@ export declare function readBindings(client: ClientApi | undefined, sid: string)
42
50
  server: string;
43
51
  collided?: string;
44
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;
45
58
  export declare function hostOf(url: string): string;
46
59
  /** Mutate metadata.openshare.servers[url] through a patcher (TUI per-
47
60
  * server actions: opt-out entries, collision resolution). Reads-modifies-
@@ -55,10 +68,29 @@ export declare function resolveRoot(client: ClientApi | undefined, sid: string):
55
68
  export declare function readBinding(client: ClientApi | undefined, sid: string): Promise<SessionBinding | undefined>;
56
69
  /** Sets metadata.openshare = { ...existing(v2 structure preserved),
57
70
  * enabled: true, opts? }. The per-server binding entries are the ENGINE's
58
- * to manage; the flag just opts the session in. */
71
+ * to manage; the flag just opts the session in. CONSENT ONLY: every call
72
+ * here means the user agreed to publish. */
59
73
  export declare function setShareFlag(client: ClientApi | undefined, sid: string, opts?: SessionOpts): Promise<boolean>;
74
+ /** Persists per-session opts WITHOUT touching the enabled state. On an
75
+ * unshared session the opts sit dormant until a consented create consumes
76
+ * them; on a live share the engine picks them up and re-syncs the server
77
+ * (auth/TTL apply immediately). This must never publish anything - the
78
+ * TTL/auth editors use it, and consent is the only path to enabled. */
79
+ export declare function setShareOpts(client: ClientApi | undefined, sid: string, opts: SessionOpts): Promise<boolean>;
60
80
  export declare function clearShareFlag(client: ClientApi | undefined, sid: string): Promise<boolean>;
61
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
+ }>;
62
94
  /** Health probe for the settings dialog's connection test. */
63
95
  export declare function probeWorker(workerUrl: string): Promise<{
64
96
  ok: boolean;
@@ -268,6 +300,40 @@ export declare function adminClient(workerUrl: string, token: string): {
268
300
  error?: string;
269
301
  }>;
270
302
  };
303
+ /** Self-service client for the /api/me surface (the entry's user osk_
304
+ * token - NOT the root token). Answers "what are my limits, how many
305
+ * shares do I have, when does my token expire". */
306
+ export declare function meClient(workerUrl: string, token: string): {
307
+ me: () => Promise<{
308
+ ok: boolean;
309
+ status: number;
310
+ data?: {
311
+ userId: string;
312
+ label: string;
313
+ token: {
314
+ id: string;
315
+ label: string;
316
+ limits: Record<string, number>;
317
+ expiresAt: number;
318
+ };
319
+ settings: Record<string, unknown>;
320
+ } | undefined;
321
+ error?: string;
322
+ }>;
323
+ myShares: () => Promise<{
324
+ ok: boolean;
325
+ status: number;
326
+ data?: {
327
+ shares: Array<{
328
+ id: string;
329
+ sessionID: string;
330
+ createdAt: number;
331
+ status: string;
332
+ }>;
333
+ } | undefined;
334
+ error?: string;
335
+ }>;
336
+ };
271
337
  /** Resolve the admin credential for a server: the entry's adminToken, the
272
338
  * legacy createToken (when it points at this server), or prompt. */
273
339
  export declare function adminTokenFor(url: string, cfg?: ShareConfig): string;
@@ -281,8 +347,22 @@ export type FlowResult = {
281
347
  unconfigured?: boolean;
282
348
  };
283
349
  /** Consent gate: EVERY share creation asks explicitly (per owner
284
- * directive — no remembered consent, no silent creates). */
285
- export declare function requestShareConsent(host: ShareHostApi): Promise<boolean>;
350
+ * directive — no remembered consent, no silent creates). The dialog
351
+ * carries the live context (server, TTL, gate state) so the decision is
352
+ * informed, not ceremonial. */
353
+ export declare function requestShareConsent(host: ShareHostApi, context?: {
354
+ server?: string;
355
+ ttlHours?: number;
356
+ auth: boolean;
357
+ counts?: {
358
+ messages: number;
359
+ toolCalls: number;
360
+ };
361
+ redaction?: {
362
+ hits: number;
363
+ samples: string[];
364
+ };
365
+ }): Promise<boolean>;
286
366
  /** runShareFlow with the consent gate applied: share copies the link,
287
367
  * unshared asks consent once, then creates. Every create path routes
288
368
  * through here (/share, settings, gone-again). */
@@ -16,6 +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, sessionStatsFor } from "./core.js";
19
20
  import { queueBindingWrite } from "./binding-queue.js";
20
21
  export function pluginConfigPaths(baseDir = moduleDir()) {
21
22
  return configPaths(baseDir);
@@ -23,11 +24,10 @@ export function pluginConfigPaths(baseDir = moduleDir()) {
23
24
  export function currentConfig(baseDir = moduleDir()) {
24
25
  return loadConfig(baseDir);
25
26
  }
26
- /** Writes the config file. Target rule (owner-directed): the global
27
- * settings home by default, or an explicit existing file chosen in the
28
- * TUI — NEVER "first existing in the read chain" (that wrote TUI edits
29
- * into the tracked adjacent template, live-caught). $comment and foreign
30
- * 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. */
31
31
  export function saveConfig(cfg, target, baseDir = moduleDir()) {
32
32
  const path = target ?? configHomePath();
33
33
  if (!path)
@@ -52,8 +52,7 @@ export function saveConfig(cfg, target, baseDir = moduleDir()) {
52
52
  "debounceMs",
53
53
  "completedDelayMs",
54
54
  "modelLabel",
55
- "ttlDays",
56
- "openTasksInNewTab",
55
+ "ttlHours",
57
56
  "authLogin",
58
57
  "authPassword",
59
58
  "spoolDir",
@@ -88,8 +87,10 @@ export function serverEntries(cfg = currentConfig()) {
88
87
  for (const [url, entry] of Object.entries(cfg.servers))
89
88
  merged[url] = { ...merged[url], ...entry };
90
89
  const urls = Object.keys(merged);
91
- const activeUrl = urls.find((u) => merged[u].active) ?? urls[0];
92
- return urls.map((url) => ({ url, entry: merged[url], active: url === activeUrl }));
90
+ // EVERY explicit active is active (the engine fans out to all of them);
91
+ // only when none is marked does the first entry act as the default
92
+ const anyActive = urls.some((u) => merged[u].active);
93
+ return urls.map((url) => ({ url, entry: merged[url], active: anyActive ? merged[url].active === true : url === urls[0] }));
93
94
  }
94
95
  export { saveServerEntry, deleteServerEntry };
95
96
  // --- session metadata helpers -------------------------------------------------
@@ -101,6 +102,8 @@ export function bindingsOf(info) {
101
102
  const record = raw;
102
103
  const opts = record.opts ?? undefined;
103
104
  const enabled = record.enabled === true ? true : undefined;
105
+ const lastError = typeof record.lastError === "string" ? record.lastError : undefined;
106
+ const ttlClamped = record.ttlClamped && typeof record.ttlClamped === "object" ? record.ttlClamped : undefined;
104
107
  const out = [];
105
108
  if (record.servers && typeof record.servers === "object") {
106
109
  for (const [server, entry] of Object.entries(record.servers)) {
@@ -119,6 +122,9 @@ export function bindingsOf(info) {
119
122
  opts,
120
123
  server,
121
124
  collided,
125
+ optedOut: e.optedOut === true ? true : undefined,
126
+ lastError,
127
+ ttlClamped,
122
128
  });
123
129
  }
124
130
  return out;
@@ -135,6 +141,8 @@ export function bindingsOf(info) {
135
141
  opts,
136
142
  server: "",
137
143
  collided,
144
+ lastError,
145
+ ttlClamped,
138
146
  });
139
147
  }
140
148
  return out;
@@ -143,6 +151,26 @@ export function bindingsOf(info) {
143
151
  export async function readBindings(client, sid) {
144
152
  return bindingsOf(await getSession(client, sid));
145
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
+ }
146
174
  export function hostOf(url) {
147
175
  try {
148
176
  return new URL(url).host;
@@ -230,7 +258,8 @@ export async function readBinding(client, sid) {
230
258
  }
231
259
  /** Sets metadata.openshare = { ...existing(v2 structure preserved),
232
260
  * enabled: true, opts? }. The per-server binding entries are the ENGINE's
233
- * to manage; the flag just opts the session in. */
261
+ * to manage; the flag just opts the session in. CONSENT ONLY: every call
262
+ * here means the user agreed to publish. */
234
263
  export async function setShareFlag(client, sid, opts) {
235
264
  const info = await getSession(client, sid);
236
265
  if (!info)
@@ -246,6 +275,27 @@ export async function setShareFlag(client, sid, opts) {
246
275
  metadata[METADATA_KEY] = value;
247
276
  return patchMetadata(client, sid, metadata);
248
277
  }
278
+ /** Persists per-session opts WITHOUT touching the enabled state. On an
279
+ * unshared session the opts sit dormant until a consented create consumes
280
+ * them; on a live share the engine picks them up and re-syncs the server
281
+ * (auth/TTL apply immediately). This must never publish anything - the
282
+ * TTL/auth editors use it, and consent is the only path to enabled. */
283
+ export async function setShareOpts(client, sid, opts) {
284
+ const info = await getSession(client, sid);
285
+ if (!info)
286
+ return false;
287
+ const metadata = { ...(info.metadata ?? {}) };
288
+ const existing = info.metadata?.[METADATA_KEY];
289
+ const value = existing && typeof existing === "object"
290
+ ? { ...existing }
291
+ : { v: 2, servers: {} };
292
+ if (Object.keys(opts).length)
293
+ value.opts = opts;
294
+ else
295
+ delete value.opts;
296
+ metadata[METADATA_KEY] = value;
297
+ return patchMetadata(client, sid, metadata);
298
+ }
249
299
  export async function clearShareFlag(client, sid) {
250
300
  const info = await getSession(client, sid);
251
301
  if (!info)
@@ -269,6 +319,32 @@ export async function workerDeleteShare(workerUrl, id, secret) {
269
319
  return false;
270
320
  }
271
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
+ }
272
348
  /** Health probe for the settings dialog's connection test. */
273
349
  export async function probeWorker(workerUrl) {
274
350
  if (!workerUrl)
@@ -430,6 +506,32 @@ export function adminClient(workerUrl, token) {
430
506
  health: () => call("GET", "/health"),
431
507
  };
432
508
  }
509
+ /** Self-service client for the /api/me surface (the entry's user osk_
510
+ * token - NOT the root token). Answers "what are my limits, how many
511
+ * shares do I have, when does my token expire". */
512
+ export function meClient(workerUrl, token) {
513
+ const base = normServerUrl(workerUrl);
514
+ const call = async (method, path) => {
515
+ try {
516
+ const res = await fetch(`${base}${path}`, {
517
+ method,
518
+ headers: { authorization: `Bearer ${token}` },
519
+ signal: AbortSignal.timeout(12000),
520
+ });
521
+ const parsed = (await res.json().catch(() => ({})));
522
+ if (!res.ok)
523
+ return { ok: false, status: res.status, error: parsed?.error ?? `HTTP ${res.status}` };
524
+ return { ok: parsed?.ok !== false, status: res.status, data: parsed, error: parsed?.error };
525
+ }
526
+ catch (error) {
527
+ return { ok: false, status: 0, error: error.message };
528
+ }
529
+ };
530
+ return {
531
+ me: () => call("GET", "/api/me"),
532
+ myShares: () => call("GET", "/api/me/shares"),
533
+ };
534
+ }
433
535
  /** Resolve the admin credential for a server: the entry's adminToken, the
434
536
  * legacy createToken (when it points at this server), or prompt. */
435
537
  export function adminTokenFor(url, cfg = currentConfig()) {
@@ -452,11 +554,26 @@ export async function waitForBinding(client, sid, timeoutMs = 25000) {
452
554
  return undefined;
453
555
  }
454
556
  /** Consent gate: EVERY share creation asks explicitly (per owner
455
- * directive — no remembered consent, no silent creates). */
456
- export async function requestShareConsent(host) {
557
+ * directive — no remembered consent, no silent creates). The dialog
558
+ * carries the live context (server, TTL, gate state) so the decision is
559
+ * informed, not ceremonial. */
560
+ export async function requestShareConsent(host, context) {
561
+ const lines = ["Publish this session (and its subagent sessions) to your openshare worker?"];
562
+ if (context) {
563
+ const bits = [];
564
+ if (context.server)
565
+ bits.push(`server: ${context.server}`);
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)`);
570
+ bits.push(`read auth: ${context.auth ? "on" : "off"}`);
571
+ lines.push(bits.join(" - "));
572
+ }
573
+ lines.push("Anyone with the link can read the conversation (login-gated only when read auth is on).");
457
574
  return host.ui.showConfirm({
458
575
  title: "Share Session",
459
- message: "Publish this session (and its subagent sessions) to your openshare worker?\nThe link lets anyone with it read the conversation.",
576
+ message: lines.join("\n"),
460
577
  confirmLabel: "Share",
461
578
  });
462
579
  }
@@ -467,18 +584,37 @@ export async function shareWithConsent(host, sessionID) {
467
584
  if (!sessionID)
468
585
  return { ok: false, message: "No active session to share." };
469
586
  const cfg = currentConfig();
470
- if (!isConfigured(cfg)) {
471
- return { ok: false, message: "No share server connected — run /openshare to connect one.", unconfigured: true };
587
+ // engine-aware: plugin-options-layer config can target servers the file
588
+ // chain cannot see (TUI blindness would push onboarding at a configured user)
589
+ if (!isConfigured(cfg) && !engineTargetUrls().length) {
590
+ return { ok: false, message: "No share server connected - run /openshare to connect one.", unconfigured: true };
472
591
  }
473
592
  const root = await resolveRoot(host.client, sessionID);
474
- const existing = await readBinding(host.client, root);
475
- if (existing?.url && existing.enabled !== false) {
593
+ const all = await readBindings(host.client, root);
594
+ // fully opted-out (leave-as-is everywhere) is NOT live: consent again
595
+ // instead of silently copying a snapshot link
596
+ const live = all.find((b) => b.url && b.enabled !== false && !b.optedOut);
597
+ if (live) {
476
598
  // already sharing: copy the link
477
- await host.clipboard.write(existing.url);
478
- return { ok: true, message: `Share URL copied to clipboard: ${existing.url}`, url: existing.url };
599
+ await host.clipboard.write(live.url);
600
+ return { ok: true, message: `Share URL copied to clipboard: ${live.url}`, url: live.url };
601
+ }
602
+ const opts = all[0]?.opts;
603
+ const server = serverEntries(cfg).find((s) => s.active) ?? serverEntries(cfg)[0] ?? (engineTargetUrls()[0] ? { url: engineTargetUrls()[0] } : undefined);
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." };
479
617
  }
480
- if (!(await requestShareConsent(host)))
481
- return { ok: false, message: "Share cancelled." };
482
618
  return confirmShare(host, root);
483
619
  }
484
620
  /** Post-consent share: set the flag, wait for the URL, copy it. */
@@ -488,12 +624,26 @@ export async function confirmShare(host, rootSessionID) {
488
624
  dbgLog(`confirmShare: setShareFlag -> ${set}`);
489
625
  if (!set)
490
626
  return { ok: false, message: "Could not write the share flag into session metadata." };
627
+ // fresh consent: clear any leave-as-is markers so the engine re-arms
628
+ // (re-attaching the same pages wherever the secrets still exist)
629
+ await patchSessionBinding(host.client, rootSessionID, (servers) => {
630
+ for (const e of Object.values(servers)) {
631
+ if (e && typeof e === "object")
632
+ delete e.optedOut;
633
+ }
634
+ });
491
635
  const binding = await waitForBinding(host.client, rootSessionID);
492
636
  dbgLog(`confirmShare: waitForBinding -> ${binding?.url ? binding.url : "timeout"}`);
493
637
  if (!binding?.url) {
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.
641
+ await clearShareFlag(host.client, rootSessionID);
642
+ const fresh = await readBinding(host.client, rootSessionID);
643
+ const why = fresh?.lastError ? `\nReason: ${fresh.lastError}` : "";
494
644
  return {
495
645
  ok: false,
496
- message: "Share did not come up (timeout waiting for the URL). Is the worker reachable? Check the server plugin log for [openshare] lines.",
646
+ message: `Share did not come up (timed out waiting for the URL); the share flag was disarmed.${why}\nCheck the [openshare] stderr lines for details.`,
497
647
  };
498
648
  }
499
649
  await host.clipboard.write(binding.url);