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

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/dialogs.js CHANGED
@@ -562,7 +562,7 @@ function InfoDialog(props) {
562
562
  unregister();
563
563
  popMode();
564
564
  });
565
- return (_jsxs(DialogFrame, { api: props.api, title: props.title, children: [_jsx("scrollbox", { maxHeight: bodyHeight(), ref: (element) => (scroll = element), children: _jsx("box", { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: _jsx(InfoLines, { api: props.api, lines: lines() }) }) }), _jsxs("box", { flexDirection: "row", justifyContent: "space-between", width: "100%", paddingTop: 1, children: [_jsx("box", { flexDirection: "row", gap: 1, children: _jsx(For, { each: actions().filter((action) => action.key && action.key !== "enter"), children: (action) => (_jsx("text", { fg: action.danger ? theme().error : theme().textMuted, children: `${action.key}: ${action.title}` })) }) }), _jsx("box", { flexDirection: "row", gap: 1, children: _jsx(For, { each: actions(), children: (action) => (_jsx("box", { paddingLeft: 2, paddingRight: 2, backgroundColor: action.danger ? theme().error : theme().primary, onMouseUp: () => props.onDone(action.value), children: _jsx("text", { fg: theme().background, children: _jsx("b", { children: action.title }) }) })) }) })] })] }));
565
+ return (_jsxs(DialogFrame, { api: props.api, title: props.title, children: [_jsx("scrollbox", { maxHeight: bodyHeight(), ref: (element) => (scroll = element), children: _jsx("box", { flexDirection: "column", paddingLeft: 1, paddingRight: 1, children: _jsx(InfoLines, { api: props.api, lines: lines() }) }) }), _jsxs("box", { flexDirection: "row", justifyContent: "space-between", width: "100%", paddingTop: 1, children: [_jsx("box", { flexDirection: "row", gap: 1, children: _jsx(For, { each: actions().filter((action) => action.key && action.key !== "enter"), children: (action) => (_jsx("text", { fg: action.danger ? theme().error : theme().textMuted, children: `${action.key}: ${action.title}` })) }) }), _jsx("box", { flexDirection: "row", gap: 1, children: _jsx(For, { each: actions(), children: (action) => (_jsx("box", { paddingLeft: 2, paddingRight: 2, backgroundColor: action.key === "enter" ? (action.danger ? theme().error : theme().primary) : undefined, border: action.key === "enter" ? undefined : true, borderColor: action.danger ? theme().error : theme().textMuted, onMouseUp: () => props.onDone(action.value), children: _jsx("text", { fg: action.key === "enter" ? theme().background : action.danger ? theme().error : theme().textMuted, children: _jsx("b", { children: action.title }) }) })) }) })] })] }));
566
566
  }
567
567
  // --- confirm / alert on the InfoDialog body -------------------------------------
568
568
  export function showConfirmMenu(api, props) {
package/dist/settings.js CHANGED
@@ -5,7 +5,7 @@
5
5
  //
6
6
  // Sections: Connection, Sharing, Read auth, Advanced, This session.
7
7
  import { configSourceMap, moduleDir, writeCandidates } from "./config.js";
8
- import { adminClient, adminTokenFor, confirmShare, currentConfig, deleteServerEntry, disconnectAtServer, enrollAtServer, hostOf, isConfigured, normServerUrl, probeAuthMode, probeWorker, patchSessionBinding, readBinding, readBindings, resolveRoot, rotateAtServer, runUnshareFlow, saveConfig, saveServerEntry, serverEntries, setShareFlag, } from "./share-flow.js";
8
+ import { adminClient, adminTokenFor, currentConfig, deleteServerEntry, disconnectAtServer, enrollAtServer, hostOf, isConfigured, normServerUrl, probeAuthMode, probeWorker, patchSessionBinding, readBinding, readBindings, resolveRoot, rotateAtServer, runUnshareFlow, saveConfig, saveServerEntry, serverEntries, setShareFlag, 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. */
@@ -903,26 +903,70 @@ export async function settingsDialog(host, startAtSession) {
903
903
  const sessionRows = async () => {
904
904
  if (!startAtSession)
905
905
  return [];
906
- const binding = await readBinding(host.client, await resolveRoot(host.client, startAtSession));
907
- const rows = [
908
- {
909
- title: binding?.url ? "Copy share link" : "Share this session",
906
+ const root = await resolveRoot(host.client, startAtSession);
907
+ const bindings = await readBindings(host.client, root);
908
+ const live = bindings.filter((b) => b.url && b.enabled !== false);
909
+ const rows = [];
910
+ if (live.length === 0) {
911
+ rows.push({
912
+ title: "Share this session",
910
913
  value: "session:share",
911
- description: binding?.url ? "copy the share URL to the clipboard" : "Set the share flag; the server plugin creates the page",
912
- help: binding?.url
913
- ? `Copies the share URL to the clipboard.\n\nURL: ${binding.url}\nShare id: ${binding.id ?? "?"}`
914
- : "Publishes this session (and its subagent sessions) to the active server. The full history lands in the page — sharing mid-session is first-class, nothing is missed.",
915
- },
916
- ];
917
- if (binding?.url) {
914
+ description: "set the share flag; the server plugin creates the page",
915
+ help: "Publishes this session (and its subagent sessions) to the active server. The full history lands in the page — sharing mid-session is first-class, nothing is missed. You will be asked to confirm before anything is published.",
916
+ });
917
+ }
918
+ else if (live.length === 1) {
919
+ const b = live[0];
920
+ rows.push({
921
+ title: "Copy share link",
922
+ value: "session:copy",
923
+ description: "copy the share URL to the clipboard",
924
+ help: `Copies the share URL to the clipboard.\n\nURL: ${b.url}\nShare id: ${b.id ?? "?"}`,
925
+ });
926
+ }
927
+ else {
928
+ for (const b of live) {
929
+ rows.push({
930
+ title: `Copy link — ${hostOf(b.url || b.server)}`,
931
+ value: `session:copy:${b.server}`,
932
+ description: b.url,
933
+ help: `Copies this server's share URL to the clipboard.\n\nURL: ${b.url}\nShare id: ${b.id}\n\nEach server carries an independent page of the same session.`,
934
+ });
935
+ }
936
+ rows.push({
937
+ title: "Copy all links",
938
+ value: "session:copyall",
939
+ description: `${live.length} servers`,
940
+ help: `Copies every bound server's share URL.\n\n${live.map((b) => `• ${b.url}`).join("\n")}`,
941
+ });
942
+ }
943
+ if (live.length) {
918
944
  rows.push({
919
945
  title: "Stop sharing",
920
946
  value: "session:unshare",
921
- description: "delete the page(s), or leave a snapshot",
947
+ description: live.length > 1 ? `delete every page (${live.length} servers), or leave snapshots` : "delete the page, or leave a snapshot",
922
948
  danger: true,
923
- help: `Choose per run: DELETE every page server-side (links stop working — final "gone" state), or LEAVE AS-IS (stop updating; pages stay readable until their TTL — a frozen snapshot).\n\nURLs:\n${[binding.url].join("\n")}\n\nSharing again later always creates fresh page(s) with new URLs.`,
949
+ help: `Choose per run: DELETE every page server-side (links stop working — final "gone" state), or LEAVE AS-IS (stop updating; pages stay readable until their TTL — a frozen snapshot).\n\nURLs:\n${live.map((b) => b.url).join("\n")}\n\nSharing again later always creates fresh page(s) with new URLs.`,
924
950
  });
951
+ if (live.length > 1) {
952
+ rows.push({
953
+ title: "Stop on one server…",
954
+ value: "session:unshare:one",
955
+ description: "delete or snapshot a single server's page",
956
+ help: `Per-server stop: pick the server, then delete its page or leave it as a read-only snapshot. Other servers are unaffected.\n\nBound servers:\n${live.map((b) => `• ${b.server} — ${b.url}`).join("\n")}`,
957
+ });
958
+ const otherServers = serverEntries().filter((s) => !live.some((b) => b.server === s.url));
959
+ if (otherServers.length) {
960
+ rows.push({
961
+ title: "Share to another server…",
962
+ value: "session:more",
963
+ description: otherServers.map((s) => hostOf(s.url)).join(", "),
964
+ help: `Adds one of your other configured servers as a destination for THIS session: it becomes the active server and the full history is mirrored there within seconds.\n\nAvailable:\n${otherServers.map((s) => `• ${s.url}`).join("\n")}`,
965
+ });
966
+ }
967
+ }
925
968
  }
969
+ const binding = bindings[0];
926
970
  rows.push({
927
971
  title: "Session TTL override (days)",
928
972
  value: "session:ttl",
@@ -1033,16 +1077,97 @@ export async function settingsDialog(host, startAtSession) {
1033
1077
  }
1034
1078
  if (choice.startsWith("session:") && startAtSession) {
1035
1079
  const root = await resolveRoot(host.client, startAtSession);
1036
- const current = await readBinding(host.client, root);
1080
+ const all = await readBindings(host.client, root);
1081
+ const live = all.filter((b) => b.url && b.enabled !== false);
1082
+ const current = all[0];
1037
1083
  if (choice === "session:share") {
1038
1084
  if (!isConfigured(cfg)) {
1039
1085
  await showInfo(host.ui, { title: "Not configured", message: "Connect a server (Servers group) first, then share." });
1040
1086
  return;
1041
1087
  }
1042
- const result = await confirmShare(host, root);
1088
+ const result = await shareWithConsent(host, startAtSession);
1043
1089
  host.ui.toast({ variant: result.ok ? "success" : "error", title: "OpenShare", message: result.message, duration: result.ok ? 8000 : 15000 });
1044
1090
  return;
1045
1091
  }
1092
+ if (choice === "session:copy" && live[0]) {
1093
+ const okw = await host.clipboard.write(live[0].url);
1094
+ host.ui.toast({ variant: okw ? "success" : "error", title: "OpenShare", message: okw ? `Share URL copied: ${live[0].url}` : `Clipboard failed — ${live[0].url}` });
1095
+ return;
1096
+ }
1097
+ if (choice.startsWith("session:copy:")) {
1098
+ const b = live.find((x) => `session:copy:${x.server}` === choice);
1099
+ if (b) {
1100
+ const okw = await host.clipboard.write(b.url);
1101
+ host.ui.toast({ variant: okw ? "success" : "error", title: "OpenShare", message: okw ? `Share URL copied: ${b.url}` : `Clipboard failed — ${b.url}` });
1102
+ }
1103
+ return;
1104
+ }
1105
+ if (choice === "session:copyall") {
1106
+ const joined = live.map((b) => b.url).filter(Boolean).join("\n");
1107
+ const okw = await host.clipboard.write(joined);
1108
+ host.ui.toast({ variant: okw ? "success" : "error", title: "OpenShare", message: okw ? `${live.length} share URL(s) copied` : "Clipboard failed" });
1109
+ return;
1110
+ }
1111
+ if (choice === "session:unshare:one") {
1112
+ const pick = await showSelect(host.ui, {
1113
+ title: "Stop on which server",
1114
+ options: [...live.map((b) => ({ title: hostOf(b.url || b.server), value: b.server, description: b.url })), { title: "Cancel", value: "__cancel" }],
1115
+ });
1116
+ if (!pick || pick === "__cancel")
1117
+ return;
1118
+ const target = live.find((b) => b.server === pick);
1119
+ if (!target)
1120
+ return;
1121
+ const how = await showSelect(host.ui, {
1122
+ title: `Stop sharing — ${hostOf(target.url || target.server)}`,
1123
+ options: [
1124
+ { title: "Delete the page", value: "delete", description: "this link stops working for everyone", danger: true },
1125
+ { title: "Leave as-is (snapshot)", value: "leave", description: "stop updating; the page stays until its TTL" },
1126
+ { title: "Cancel", value: "__cancel" },
1127
+ ],
1128
+ });
1129
+ if (!how || how === "__cancel")
1130
+ return;
1131
+ if (how === "leave") {
1132
+ await patchSessionBinding(host.client, root, (servers) => {
1133
+ delete servers[target.server];
1134
+ servers[target.server] = { optedOut: true };
1135
+ });
1136
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(target.url || target.server)} left as-is — a snapshot until its TTL.` });
1137
+ return;
1138
+ }
1139
+ const targetEntry = all.find((b) => b.server === target.server);
1140
+ if (!targetEntry?.secret) {
1141
+ host.ui.toast({ variant: "error", title: "OpenShare", message: "No secret for this page — cannot delete it server-side; binding cleared." });
1142
+ await patchSessionBinding(host.client, root, (servers) => {
1143
+ delete servers[target.server];
1144
+ });
1145
+ return;
1146
+ }
1147
+ const gone = await workerDeleteShare(target.server, target.id, targetEntry.secret);
1148
+ await patchSessionBinding(host.client, root, (servers) => {
1149
+ delete servers[target.server];
1150
+ });
1151
+ host.ui.toast({ variant: gone ? "success" : "warning", title: "OpenShare", message: gone ? `Page deleted on ${hostOf(target.url || target.server)}.` : `Could not delete the page (it may already be gone) — binding cleared.` });
1152
+ return;
1153
+ }
1154
+ if (choice === "session:more") {
1155
+ const otherServers = serverEntries().filter((s) => !live.some((b) => b.server === s.url));
1156
+ if (!otherServers.length)
1157
+ return;
1158
+ const pick = await showSelect(host.ui, {
1159
+ title: "Share to another server",
1160
+ options: [...otherServers.map((s) => ({ title: hostOf(s.url), value: s.url, description: s.entry.label })), { title: "Cancel", value: "__cancel" }],
1161
+ });
1162
+ if (!pick || pick === "__cancel")
1163
+ return;
1164
+ for (const s of serverEntries())
1165
+ if (s.url !== pick)
1166
+ saveServerEntry(s.url, { active: undefined });
1167
+ saveServerEntry(pick, { active: true });
1168
+ host.ui.toast({ variant: "success", title: "OpenShare", message: `${hostOf(pick)} activated — this session mirrors there within seconds.` });
1169
+ return;
1170
+ }
1046
1171
  if (choice === "session:unshare") {
1047
1172
  const how = await showSelect(host.ui, {
1048
1173
  title: "Stop sharing",
@@ -9,7 +9,6 @@ export type SessionBinding = {
9
9
  enabled?: boolean;
10
10
  opts?: SessionOpts;
11
11
  };
12
- export declare const CONSENT_KV_KEY = "openshare_consent";
13
12
  export declare function pluginConfigPaths(baseDir?: string): string[];
14
13
  export declare function currentConfig(baseDir?: string): ShareConfig;
15
14
  /** Writes the config file. Target rule (owner-directed): the global
@@ -281,7 +280,13 @@ export type FlowResult = {
281
280
  url?: string;
282
281
  unconfigured?: boolean;
283
282
  };
284
- export declare function runShareFlow(host: ShareHostApi, sessionID: string | undefined): Promise<FlowResult>;
283
+ /** 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>;
286
+ /** runShareFlow with the consent gate applied: share copies the link,
287
+ * unshared asks consent once, then creates. Every create path routes
288
+ * through here (/share, settings, gone-again). */
289
+ export declare function shareWithConsent(host: ShareHostApi, sessionID: string | undefined): Promise<FlowResult>;
285
290
  /** Post-consent share: set the flag, wait for the URL, copy it. */
286
291
  export declare function confirmShare(host: ShareHostApi, rootSessionID: string): Promise<FlowResult>;
287
292
  export declare function runUnshareFlow(host: ShareHostApi, sessionID: string | undefined): Promise<FlowResult>;
@@ -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
- export const CONSENT_KV_KEY = "openshare_consent";
19
+ import { queueBindingWrite } from "./binding-queue.js";
20
20
  export function pluginConfigPaths(baseDir = moduleDir()) {
21
21
  return configPaths(baseDir);
22
22
  }
@@ -155,17 +155,22 @@ export function hostOf(url) {
155
155
  * server actions: opt-out entries, collision resolution). Reads-modifies-
156
156
  * writes the whole metadata to keep foreign keys intact. */
157
157
  export async function patchSessionBinding(client, sid, mutator) {
158
- const info = await getSession(client, sid);
159
- if (!info)
160
- return false;
161
- const metadata = { ...(info.metadata ?? {}) };
162
- const raw = info.metadata?.[METADATA_KEY];
163
- const binding = raw && typeof raw === "object" ? { ...raw } : { v: 2, servers: {} };
164
- const servers = binding.servers && typeof binding.servers === "object" ? { ...binding.servers } : {};
165
- mutator(servers, binding);
166
- binding.servers = servers;
167
- metadata[METADATA_KEY] = binding;
168
- return patchMetadata(client, sid, metadata);
158
+ // the whole read-modify-write runs inside the shared per-session queue:
159
+ // the engine patches the same metadata (binding refresh) and interleaved
160
+ // RMWs silently lose one side's update (see binding-queue.ts)
161
+ return queueBindingWrite(sid, async () => {
162
+ const info = await getSession(client, sid);
163
+ if (!info)
164
+ return false;
165
+ const metadata = { ...(info.metadata ?? {}) };
166
+ const raw = info.metadata?.[METADATA_KEY];
167
+ const binding = raw && typeof raw === "object" ? { ...raw } : { v: 2, servers: {} };
168
+ const servers = binding.servers && typeof binding.servers === "object" ? { ...binding.servers } : {};
169
+ mutator(servers, binding);
170
+ binding.servers = servers;
171
+ metadata[METADATA_KEY] = binding;
172
+ return patchMetadata(client, sid, metadata);
173
+ });
169
174
  }
170
175
  /** The display/primary binding: the active server's entry when bound
171
176
  * there, else the first live entry (single-server configs are unchanged). */
@@ -446,7 +451,19 @@ export async function waitForBinding(client, sid, timeoutMs = 25000) {
446
451
  }
447
452
  return undefined;
448
453
  }
449
- export async function runShareFlow(host, sessionID) {
454
+ /** Consent gate: EVERY share creation asks explicitly (per owner
455
+ * directive — no remembered consent, no silent creates). */
456
+ export async function requestShareConsent(host) {
457
+ return host.ui.showConfirm({
458
+ 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.",
460
+ confirmLabel: "Share",
461
+ });
462
+ }
463
+ /** runShareFlow with the consent gate applied: share copies the link,
464
+ * unshared asks consent once, then creates. Every create path routes
465
+ * through here (/share, settings, gone-again). */
466
+ export async function shareWithConsent(host, sessionID) {
450
467
  if (!sessionID)
451
468
  return { ok: false, message: "No active session to share." };
452
469
  const cfg = currentConfig();
@@ -460,10 +477,8 @@ export async function runShareFlow(host, sessionID) {
460
477
  await host.clipboard.write(existing.url);
461
478
  return { ok: true, message: `Share URL copied to clipboard: ${existing.url}`, url: existing.url };
462
479
  }
463
- if (!host.kv.get(CONSENT_KV_KEY, false)) {
464
- // caller shows the consent dialog before invoking confirmShare()
465
- return { ok: false, message: "consent required" };
466
- }
480
+ if (!(await requestShareConsent(host)))
481
+ return { ok: false, message: "Share cancelled." };
467
482
  return confirmShare(host, root);
468
483
  }
469
484
  /** Post-consent share: set the flag, wait for the URL, copy it. */
package/dist/sidebar.js CHANGED
@@ -25,27 +25,6 @@ export function dbg(v, depth = 0) {
25
25
  return "obj:?}";
26
26
  }
27
27
  }
28
- /** shallow structural dump of a rendered element tree (type names, children kinds) */
29
- function dbgEl(el, depth = 0) {
30
- if (el === null || el === undefined || typeof el === "string" || typeof el === "number" || typeof el === "boolean")
31
- return dbg(el);
32
- if (Array.isArray(el))
33
- return `[${el.map((x) => dbgEl(x, depth)).join(",")}]`;
34
- if (typeof el === "function")
35
- return "fn-node";
36
- if (depth >= 4)
37
- return "…";
38
- try {
39
- const rec = el;
40
- const t = dbg(rec.t ?? rec.type, 2);
41
- const kids = rec.children;
42
- const props = Object.keys(rec).filter((k) => !["t", "type", "children"].includes(k));
43
- return `<${t} ${props.join(" ")}>${kids === undefined ? "" : dbgEl(kids, depth + 1)}</${t}>`;
44
- }
45
- catch (e) {
46
- return `el?-${dbg(e)}`;
47
- }
48
- }
49
28
  /** no-op: the TUI debug log file was a crash-hunt aid and must not write
50
29
  * to temp any more (owner request 2026-10-21) */
51
30
  export function dbgLog(_line) { }