@mirrowel/opencode-openshare 0.0.0-stage → 0.1.0

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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +175 -2
  3. package/ci/README.md +56 -0
  4. package/ci/openshare.config.json +5 -0
  5. package/ci/openshare.js +2265 -0
  6. package/dist/commands.d.ts +7 -0
  7. package/dist/commands.js +440 -0
  8. package/dist/config.d.ts +134 -0
  9. package/dist/config.js +517 -0
  10. package/dist/core.d.ts +117 -0
  11. package/dist/core.js +1944 -0
  12. package/dist/dialogs.d.ts +94 -0
  13. package/dist/dialogs.js +725 -0
  14. package/dist/server.d.ts +64 -0
  15. package/dist/server.js +30 -0
  16. package/dist/settings.d.ts +24 -0
  17. package/dist/settings.js +1226 -0
  18. package/dist/share-flow.d.ts +287 -0
  19. package/dist/share-flow.js +516 -0
  20. package/dist/sidebar.d.ts +19 -0
  21. package/dist/sidebar.js +144 -0
  22. package/dist/tui-host.d.ts +158 -0
  23. package/dist/tui-host.js +6 -0
  24. package/dist/tui.d.ts +17 -0
  25. package/dist/tui.js +4762 -0
  26. package/dist/ui-prefs.d.ts +16 -0
  27. package/dist/ui-prefs.js +27 -0
  28. package/dist/v2-server.d.ts +81 -0
  29. package/dist/v2-server.js +402 -0
  30. package/dist/v2-tui.d.ts +24 -0
  31. package/dist/v2-tui.js +313 -0
  32. package/dist/v2-types.d.ts +134 -0
  33. package/dist/v2-types.js +8 -0
  34. package/openshare.config.json +16 -0
  35. package/package.json +98 -4
  36. package/src/banner.txt +22 -0
  37. package/src/commands.ts +452 -0
  38. package/src/config.ts +518 -0
  39. package/src/core.ts +1997 -0
  40. package/src/dialogs.tsx +971 -0
  41. package/src/server.ts +47 -0
  42. package/src/settings.ts +1239 -0
  43. package/src/share-flow.ts +551 -0
  44. package/src/sidebar.tsx +187 -0
  45. package/src/tui-host.ts +134 -0
  46. package/src/tui.tsx +318 -0
  47. package/src/ui-prefs.ts +36 -0
  48. package/src/v2-server.ts +438 -0
  49. package/src/v2-tui.ts +315 -0
  50. package/src/v2-types.ts +102 -0
  51. package/tsconfig.json +18 -0
@@ -0,0 +1,7 @@
1
+ import type { ShareHostApi, ShareHostLayerCommand } from "./tui-host.js";
2
+ export declare const PALETTE_CATEGORY = "OpenShare";
3
+ export declare function buildShareCommand(host: ShareHostApi, sessionID: () => string | undefined): ShareHostLayerCommand;
4
+ export declare function buildSettingsCommand(host: ShareHostApi, sessionID: () => string | undefined): ShareHostLayerCommand;
5
+ /** Registers the /share toggle + /openshare settings on one keymap layer.
6
+ * Returns the unregister function. */
7
+ export declare function registerCommands(host: ShareHostApi, sessionID: () => string | undefined): () => void;
@@ -0,0 +1,440 @@
1
+ // OpenShare command registration. ONE user-facing /share command shadows
2
+ // the deprecated built-in (opncd.ai) by registering the same command name
3
+ // on a plugin keymap layer; running it on a shared session opens the
4
+ // share manager (copy link / stop sharing) instead — you can't share
5
+ // twice, so /share is a toggle. /openshare opens settings.
6
+ //
7
+ // LAYER SHAPE (live-caught 2026-10-19): the v1 host keymap gates layers
8
+ // that declare `mode` on the mode stack (resting mode is "base"), so a
9
+ // plugin layer MUST NOT pass mode ("global" made the whole layer
10
+ // unreachable — no palette entries, no slash commands, no shadow) and
11
+ // must not carry undefined-valued command fields. The registration shape
12
+ // is exactly what the proven family plugins use: {commands, bindings},
13
+ // commands with only defined fields, category "OpenShare" (own group,
14
+ // deliberately NOT joined into the shared family palette category).
15
+ import { CONSENT_KV_KEY, clearGoneBindingEntries, confirmShare, currentConfig, isConfigured, hostOf, patchSessionBinding, readBindings, resolveRoot, runShareFlow, runUnshareFlow, serverEntries, verifyShare, workerDeleteShare, } from "./share-flow.js";
16
+ import { onboardingDialog, settingsDialog } from "./settings.js";
17
+ import { dbgLog } from "./sidebar.js";
18
+ export const PALETTE_CATEGORY = "OpenShare";
19
+ function finish(host, result) {
20
+ // terminal point of a flow: the dialog chain is over — clear the host
21
+ // stack explicitly (done() no longer clears; replace-without-clear was
22
+ // the flicker fix, so chain ENDS own the clear)
23
+ try {
24
+ host.ui.dialog.clear();
25
+ }
26
+ catch { }
27
+ host.ui.toast({
28
+ variant: result.ok ? "success" : result.unconfigured ? "warning" : "error",
29
+ title: "OpenShare",
30
+ message: result.message,
31
+ duration: result.ok ? 8000 : 15000,
32
+ });
33
+ }
34
+ /** The already-shared state: per-server management for the fan-out era.
35
+ * Single binding keeps the compact classic menu; multiple bindings get a
36
+ * per-server submenu (copy / stop here / leave as-is) plus all-stop.
37
+ * Opens with a liveness sweep: bindings whose page died server-side
38
+ * (purge/TTL/delete) are auto-cleared and surfaced in a gone modal. */
39
+ async function showShareManager(host, sessionID) {
40
+ const sid = sessionID();
41
+ if (!sid)
42
+ return;
43
+ const root = await resolveRoot(host.client, sid);
44
+ let all = await readBindings(host.client, root);
45
+ // collision first: the engine marked a server's credentials as rejected
46
+ const collided = all.find((b) => b.collided);
47
+ if (collided)
48
+ return showCollisionDialog(host, root, collided.server, collided.id, collided.collided ?? "");
49
+ // liveness sweep: probe every live-bound server (credential-free /data
50
+ // check). GONE entries are removed from the binding automatically; the
51
+ // modal says what happened and offers to re-share. Network errors are
52
+ // "unknown" and never treated as gone.
53
+ const live = all.filter((b) => b.url && b.enabled !== false && b.id);
54
+ if (live.length) {
55
+ const verdicts = await Promise.all(live.map((b) => verifyShare(b.server, b.id)));
56
+ const gone = live.filter((b, i) => verdicts[i] === "gone");
57
+ if (gone.length) {
58
+ await clearGoneBindingEntries(host, root, gone.map((b) => b.server));
59
+ all = all.filter((b) => !gone.some((g) => g.server === b.server));
60
+ const list = gone.map((b) => `• ${hostOf(b.url || b.server)} — ${b.url}`).join("\n");
61
+ const choice = await host.ui.showMenu({
62
+ title: "Share is gone",
63
+ hint: "The page was removed, expired, or purged on the server — sharing disabled for it.",
64
+ options: [
65
+ { title: "Share again", value: "again", description: "create a fresh page with the full history", help: `The stale binding was already cleared. Sharing again creates a brand-new page on the ACTIVE server with this session's full history backfilled.\n\nGone:\n${list}` },
66
+ { title: "Close", value: "close", description: "leave the session unshared", help: `The stale binding entries were cleared automatically; the session is not shared anymore. Run /share any time to share it fresh.\n\nGone:\n${list}` },
67
+ ],
68
+ });
69
+ if (choice === "again") {
70
+ finish(host, await runShareFlow(host, sid));
71
+ return;
72
+ }
73
+ if (all.length === 0)
74
+ return;
75
+ }
76
+ }
77
+ if (all.length === 0)
78
+ return;
79
+ const otherServers = serverEntries().filter((s) => !all.some((b) => b.server === s.url));
80
+ if (all.length === 1 && otherServers.length === 0) {
81
+ // the classic single-server menu
82
+ const b = all[0];
83
+ void host.ui
84
+ .showMenu({
85
+ title: "Session is shared",
86
+ hint: b.url,
87
+ options: [
88
+ {
89
+ title: "Copy share link",
90
+ value: "copy",
91
+ description: "copy the share URL to the clipboard",
92
+ help: `Copies the share URL to the clipboard (navigator + OSC52 fallback).\n\nURL: ${b.url}\nServer: ${b.server}\nShare id: ${b.id}\n\nAnyone with the link can read the page (plus the login, if read auth is set).`,
93
+ },
94
+ {
95
+ title: "Stop sharing",
96
+ value: "stop",
97
+ description: "Delete the share page — the link stops working for everyone",
98
+ help: `Deletes the page on ${hostOf(b.url || b.server)} server-side and clears this session's share flag. Viewers with the link see a final "gone" state. This cannot be undone — sharing again creates a NEW page with a new URL.`,
99
+ },
100
+ { title: "Cancel", value: "cancel" },
101
+ ],
102
+ })
103
+ .then(async (choice) => {
104
+ if (choice === "copy") {
105
+ const ok = await host.clipboard.write(b.url);
106
+ finish(host, ok ? { ok: true, message: `Share URL copied to clipboard: ${b.url}`, url: b.url } : { ok: false, message: `Could not write the clipboard — link: ${b.url}`, url: b.url });
107
+ return;
108
+ }
109
+ if (choice === "stop") {
110
+ const how = await host.ui.showMenu({
111
+ title: "Stop sharing",
112
+ hint: b.url,
113
+ options: [
114
+ {
115
+ title: "Delete the page",
116
+ value: "delete",
117
+ description: "the link stops working for everyone",
118
+ danger: true,
119
+ help: `Deletes the page on ${hostOf(b.url || b.server)} and clears this session's share flag. Viewers with the link see a final "gone" state. Sharing again later creates a fresh page with a new URL.`,
120
+ },
121
+ {
122
+ title: "Leave as-is (snapshot)",
123
+ value: "leave",
124
+ description: "stop updating; the page stays until its TTL",
125
+ help: `Stops mirroring WITHOUT deleting anything — viewers keep reading the snapshot until the share's TTL expires. Useful for handing someone a frozen copy while you keep working.`,
126
+ },
127
+ { title: "Cancel", value: "cancel" },
128
+ ],
129
+ });
130
+ if (!how || how === "cancel")
131
+ return;
132
+ if (how === "leave") {
133
+ await patchSessionBinding(host.client, root, (servers) => {
134
+ delete servers[b.server];
135
+ servers[b.server] = { optedOut: true };
136
+ });
137
+ finish(host, { ok: true, message: `${hostOf(b.url || b.server)} left as-is — a snapshot until its TTL.` });
138
+ return;
139
+ }
140
+ const yes = await host.ui.showConfirm({
141
+ title: "Delete the share page",
142
+ message: `Delete the page on ${hostOf(b.url || b.server)}?\nThe link stops working for everyone.`,
143
+ confirmLabel: "Delete",
144
+ danger: true,
145
+ });
146
+ if (!yes) {
147
+ host.ui.dialog.clear();
148
+ return;
149
+ }
150
+ finish(host, await runUnshareFlow(host, sessionID()));
151
+ }
152
+ });
153
+ return;
154
+ }
155
+ // fan-out menu
156
+ const options = all.map((b) => ({
157
+ title: `Copy link — ${hostOf(b.url || b.server)}`,
158
+ value: `copy:${b.server}`,
159
+ description: b.url,
160
+ help: `Copies this server's share URL to the clipboard.\n\nURL: ${b.url}\nServer: ${b.server}\nShare id: ${b.id}\n\nEach server carries an independent page of the same session.`,
161
+ }));
162
+ options.push({
163
+ title: "Manage per server…",
164
+ value: "manage",
165
+ description: `${all.length} servers — stop one, leave a snapshot`,
166
+ help: `Per-server actions: copy the link, stop sharing there (page deleted), or leave it as a read-only snapshot (updates stop, the page lives out its TTL). Other servers are unaffected.\n\nBound servers:\n${all.map((b) => `• ${b.server} — ${b.url}`).join("\n")}`,
167
+ });
168
+ if (otherServers.length) {
169
+ options.push({
170
+ title: "Share to another server…",
171
+ value: "more",
172
+ description: otherServers.map((s) => hostOf(s.url)).join(", "),
173
+ 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")}`,
174
+ });
175
+ }
176
+ options.push({
177
+ title: "Stop sharing (all)",
178
+ value: "stop-all",
179
+ description: "delete every server's page, or leave snapshots",
180
+ danger: true,
181
+ help: `Deletes the page on all ${all.length} servers (or leaves them as read-only snapshots — your choice on the next screen) and clears the share flag. Sharing again creates fresh pages.`,
182
+ });
183
+ options.push({ title: "Cancel", value: "cancel" });
184
+ const choice = await host.ui.showMenu({ title: `Session is shared · ${all.length} servers`, options });
185
+ if (!choice || choice === "cancel")
186
+ return;
187
+ if (choice.startsWith("copy:")) {
188
+ const b = all.find((x) => `copy:${x.server}` === choice);
189
+ if (b) {
190
+ const ok = await host.clipboard.write(b.url);
191
+ finish(host, ok ? { ok: true, message: `Share URL copied: ${b.url}`, url: b.url } : { ok: false, message: `Clipboard failed — ${b.url}`, url: b.url });
192
+ }
193
+ return;
194
+ }
195
+ if (choice === "stop-all") {
196
+ const how = await host.ui.showMenu({
197
+ title: `Stop sharing · ${all.length} servers`,
198
+ options: [
199
+ { title: "Delete every page", value: "delete", description: "all links stop working", danger: true, help: `Deletes the page on every bound server (${all.map((b) => hostOf(b.url || b.server)).join(", ")}) and clears the share flag.` },
200
+ { title: "Leave all as-is (snapshots)", value: "leave", description: "stop updating; pages stay until their TTLs", help: `Stops mirroring everywhere without deleting anything — every page stays readable as a frozen snapshot until its TTL expires.` },
201
+ { title: "Cancel", value: "cancel" },
202
+ ],
203
+ });
204
+ if (!how || how === "cancel")
205
+ return;
206
+ if (how === "leave") {
207
+ await patchSessionBinding(host.client, root, (servers) => {
208
+ for (const b of all) {
209
+ delete servers[b.server];
210
+ servers[b.server] = { optedOut: true };
211
+ }
212
+ });
213
+ finish(host, { ok: true, message: `${all.length} server(s) left as-is — snapshots until their TTLs.` });
214
+ return;
215
+ }
216
+ const yes = await host.ui.showConfirm({
217
+ title: "Stop sharing",
218
+ message: `Delete the share pages on all ${all.length} servers? The links stop working for everyone.`,
219
+ confirmLabel: "Stop all",
220
+ danger: true,
221
+ });
222
+ if (!yes)
223
+ return;
224
+ finish(host, await runUnshareFlow(host, sessionID()));
225
+ return;
226
+ }
227
+ if (choice === "more") {
228
+ const pick = await host.ui.showMenu({
229
+ title: "Share to another server",
230
+ options: [...otherServers.map((s) => ({ title: hostOf(s.url), value: s.url, description: s.entry.label })), { title: "Cancel", value: "__cancel" }],
231
+ });
232
+ if (!pick || pick === "__cancel")
233
+ return;
234
+ const { saveServerEntry } = await import("./share-flow.js");
235
+ for (const s of serverEntries())
236
+ if (s.url !== pick)
237
+ saveServerEntry(s.url, { active: undefined });
238
+ saveServerEntry(pick, { active: true });
239
+ finish(host, { ok: true, message: `${hostOf(pick)} activated — this session mirrors there within seconds (new sessions share to it too).` });
240
+ return;
241
+ }
242
+ if (choice === "manage") {
243
+ const pick = await host.ui.showMenu({
244
+ title: "Manage server",
245
+ options: [...all.map((b) => ({ title: hostOf(b.url || b.server), value: b.server, description: b.url })), { title: "Cancel", value: "__cancel" }],
246
+ });
247
+ if (!pick || pick === "__cancel")
248
+ return;
249
+ const b = all.find((x) => x.server === pick);
250
+ if (!b)
251
+ return;
252
+ const action = await host.ui.showMenu({
253
+ title: `${hostOf(b.url || b.server)}`,
254
+ hint: b.url,
255
+ options: [
256
+ {
257
+ title: "Copy link",
258
+ value: "copy",
259
+ description: "copy the share URL",
260
+ help: `Copies this server's share URL to the clipboard.\n\nURL: ${b.url}\nShare id: ${b.id}`,
261
+ },
262
+ {
263
+ title: "Stop sharing here",
264
+ value: "stop",
265
+ description: "delete THIS server's page; other servers unaffected",
266
+ help: `Deletes the page on ${hostOf(b.url || b.server)} and retires this server for the session (the engine stops mirroring here). Other servers keep streaming. Sharing here again later creates a new page.`,
267
+ },
268
+ {
269
+ title: "Leave as-is (snapshot)",
270
+ value: "leave",
271
+ description: "stop updating it; the page stays until its TTL",
272
+ help: `Stops mirroring to ${hostOf(b.url || b.server)} WITHOUT deleting anything — viewers keep reading the snapshot until the share's TTL expires. Useful for handing someone a frozen copy while you keep working.`,
273
+ },
274
+ { title: "Cancel", value: "cancel" },
275
+ ],
276
+ });
277
+ if (!action || action === "cancel")
278
+ return;
279
+ if (action === "copy") {
280
+ const ok = await host.clipboard.write(b.url);
281
+ finish(host, ok ? { ok: true, message: `Share URL copied: ${b.url}`, url: b.url } : { ok: false, message: `Clipboard failed — ${b.url}`, url: b.url });
282
+ return;
283
+ }
284
+ if (action === "stop") {
285
+ const deleted = await workerDeleteShare(b.server || b.url.slice(0, b.url.indexOf("/s/")), b.id, b.secret);
286
+ await patchSessionBinding(host.client, root, (servers) => {
287
+ delete servers[b.server];
288
+ servers[b.server] = { optedOut: true };
289
+ });
290
+ finish(host, { ok: true, message: deleted ? `Stopped sharing on ${hostOf(b.url || b.server)} — page deleted.` : `Stopped sharing on ${hostOf(b.url || b.server)} (page deletion follows via the sweeper).` });
291
+ return;
292
+ }
293
+ if (action === "leave") {
294
+ await patchSessionBinding(host.client, root, (servers) => {
295
+ delete servers[b.server];
296
+ servers[b.server] = { optedOut: true };
297
+ });
298
+ finish(host, { ok: true, message: `${hostOf(b.url || b.server)} left as-is — a snapshot until its TTL.` });
299
+ return;
300
+ }
301
+ }
302
+ }
303
+ /** Credential collision: the engine refused to auto-recreate. The user
304
+ * chooses: copy debug info (for the hoster/issue), share on a new share,
305
+ * or don't share. */
306
+ async function showCollisionDialog(host, root, server, shareId, error) {
307
+ const debugInfo = JSON.stringify({ server, shareId, error, sessionID: root, plugin: "openshare", time: new Date().toISOString() }, null, 2);
308
+ const choice = await host.ui.showMenu({
309
+ title: `Share conflict — ${hostOf(server)}`,
310
+ hint: `saved credentials for ${shareId.slice(0, 12)}… were rejected (${error})`,
311
+ options: [
312
+ {
313
+ title: "Copy debug info",
314
+ value: "debug",
315
+ description: "server, share id, error — for the hoster or an issue report",
316
+ help: `Copies a JSON blob with the server, share id, exact error, and session id — attach it when reporting to the hoster or filing an issue.\n\n${debugInfo}`,
317
+ },
318
+ {
319
+ title: "Share on a new share",
320
+ value: "new",
321
+ description: "abandon the old binding; create a fresh share with full history",
322
+ help: `Drops the rejected binding and lets the engine create a brand-new share on ${hostOf(server)} with the session's full history. The old page (whoever owns it now) is left untouched.`,
323
+ },
324
+ {
325
+ title: "Don't share here",
326
+ value: "no",
327
+ description: "leave the old page; this server stops receiving this session",
328
+ help: `Opts this session out on ${hostOf(server)}: the engine stops trying to attach. The rejected page stays as-is. Re-share here anytime via "Share to another server".`,
329
+ },
330
+ { title: "Cancel", value: "cancel" },
331
+ ],
332
+ });
333
+ if (!choice || choice === "cancel")
334
+ return;
335
+ if (choice === "debug") {
336
+ const ok = await host.clipboard.write(debugInfo);
337
+ finish(host, ok ? { ok: true, message: "Debug info copied to clipboard." } : { ok: false, message: `Clipboard failed:\n${debugInfo}` });
338
+ return;
339
+ }
340
+ if (choice === "new") {
341
+ await patchSessionBinding(host.client, root, (servers) => {
342
+ delete servers[server];
343
+ });
344
+ finish(host, { ok: true, message: `Re-sharing on ${hostOf(server)} — a new share appears within seconds.` });
345
+ return;
346
+ }
347
+ if (choice === "no") {
348
+ await patchSessionBinding(host.client, root, (servers) => {
349
+ delete servers[server];
350
+ servers[server] = { optedOut: true };
351
+ });
352
+ finish(host, { ok: true, message: `${hostOf(server)} opted out for this session.` });
353
+ }
354
+ }
355
+ export function buildShareCommand(host, sessionID) {
356
+ return {
357
+ name: "session.share",
358
+ title: "Share session (re:share)",
359
+ desc: "Share this session via openshare; on a shared session: copy the link or stop sharing",
360
+ slashName: "share",
361
+ slashAliases: ["reshare"],
362
+ category: PALETTE_CATEGORY,
363
+ group: PALETTE_CATEGORY,
364
+ run: async () => {
365
+ dbgLog(`/share run: entered (sid=${sessionID() ?? "none"})`);
366
+ host.ui.dialog.clear();
367
+ const cfg = currentConfig();
368
+ if (!isConfigured(cfg)) {
369
+ void onboardingDialog(host);
370
+ return;
371
+ }
372
+ const sid = sessionID();
373
+ if (!sid) {
374
+ finish(host, { ok: false, message: "No active session to share." });
375
+ return;
376
+ }
377
+ const root = await resolveRoot(host.client, sid);
378
+ const bindings = await readBindings(host.client, root);
379
+ const binding = bindings[0];
380
+ dbgLog(`/share gate: sid=${sid} root=${root} bindings=${bindings.length}`);
381
+ // live ⇔ url && enabled !== false (all-mode bindings carry
382
+ // enabled:true since the fix; legacy all-mode bindings without the
383
+ // field are live too — only an explicit false is dormant)
384
+ if (bindings.some((b) => b.url && b.enabled !== false)) {
385
+ await showShareManager(host, sessionID);
386
+ host.ui.dialog.clear();
387
+ return;
388
+ }
389
+ // not shared (or dormant): the normal flow — consent once, then share
390
+ const result = await runShareFlow(host, sid);
391
+ if (result.message === "consent required") {
392
+ const yes = await host.ui.showConfirm({
393
+ title: "Share Session",
394
+ message: "Publish this session (and its subagent sessions) to your openshare worker?\nThe link lets anyone with it read the conversation.",
395
+ confirmLabel: "Share",
396
+ });
397
+ if (!yes)
398
+ return;
399
+ try {
400
+ host.kv.set(CONSENT_KV_KEY, true);
401
+ dbgLog("consent kv.set ok");
402
+ }
403
+ catch (e) {
404
+ dbgLog(`consent kv.set THREW: ${e?.message ?? e}`);
405
+ }
406
+ try {
407
+ finish(host, await confirmShare(host, root));
408
+ }
409
+ catch (e) {
410
+ dbgLog(`confirmShare rejected: ${e?.stack ?? e}`);
411
+ finish(host, { ok: false, message: "Share failed (see debug log)." });
412
+ }
413
+ return;
414
+ }
415
+ finish(host, result);
416
+ },
417
+ };
418
+ }
419
+ export function buildSettingsCommand(host, sessionID) {
420
+ return {
421
+ name: "openshare.settings",
422
+ title: "OpenShare: Settings",
423
+ desc: "Worker connection, sharing defaults, read auth, this session",
424
+ slashName: "openshare",
425
+ category: PALETTE_CATEGORY,
426
+ group: PALETTE_CATEGORY,
427
+ run: async () => {
428
+ host.ui.dialog.clear();
429
+ await settingsDialog(host, sessionID());
430
+ },
431
+ };
432
+ }
433
+ /** Registers the /share toggle + /openshare settings on one keymap layer.
434
+ * Returns the unregister function. */
435
+ export function registerCommands(host, sessionID) {
436
+ return host.keymap.registerLayer({
437
+ commands: [buildShareCommand(host, sessionID), buildSettingsCommand(host, sessionID)],
438
+ bindings: [],
439
+ });
440
+ }
@@ -0,0 +1,134 @@
1
+ export type ShareMode = "all" | "manual";
2
+ /** One share-server membership. Keyed by server URL in the `servers`
3
+ * config group; the entry with `active: true` (or the first) is where
4
+ * new sessions share. `token` is the enrolled user token (osk_…) or the
5
+ * legacy root CREATE_TOKEN; `code` is an enrollment access code for
6
+ * private servers (used once when no token is held); `adminToken` is the
7
+ * server's root CREATE_TOKEN for the /openshare admin console (optional
8
+ * convenience — the hoster's own machines only). */
9
+ export type ServerEntry = {
10
+ token?: string;
11
+ code?: string;
12
+ label?: string;
13
+ active?: boolean;
14
+ adminToken?: string;
15
+ };
16
+ export type ShareConfig = {
17
+ workerUrl: string;
18
+ createToken: string;
19
+ /** Multi-server memberships (URL -> entry). Legacy workerUrl/createToken
20
+ * synthesize into this group at load; the group is the canonical form. */
21
+ servers?: Record<string, ServerEntry>;
22
+ debounceMs: number;
23
+ completedDelayMs: number;
24
+ modelLabel: string;
25
+ ttlDays: number;
26
+ openTasksInNewTab: boolean;
27
+ authLogin: string;
28
+ authPassword: string;
29
+ spoolDir: string;
30
+ urlFile: string;
31
+ /** false silences the `[openshare] live:` (and `url file:`) stderr
32
+ * lines. The share itself is unaffected. */
33
+ announceStderr: boolean;
34
+ shareMode?: ShareMode;
35
+ };
36
+ export declare const METADATA_KEY = "openshare";
37
+ /** Sentinel urlFile value that disables the URL file entirely
38
+ * (`""` stays "unset = default path"; "none" is an explicit opt-out). */
39
+ export declare const URL_FILE_NONE = "none";
40
+ export declare const DEFAULTS: ShareConfig;
41
+ /** Directory of this module file (bundled standalone: the single file's
42
+ * directory; package: dist/). */
43
+ export declare function moduleDir(): string;
44
+ /** Nearest ancestor (starting at `start`) containing package.json. */
45
+ export declare function packageRoot(start: string): string;
46
+ /** Config file search paths for a plugin living at `pluginDir`.
47
+ *
48
+ * Order (first FOUND wins; env vars still override everything):
49
+ * 1. `openshare.config.json` adjacent to the plugin file (CI bundles
50
+ * ship it alongside; the package ships a commented template).
51
+ * 2. `openshare.local.json` walking up from the plugin dir (repo-local
52
+ * overrides for dev/testing; gitignored by convention).
53
+ * 3. `openshare.config.json` in the opencode config folder
54
+ * (`~/.config/opencode/`) — the interactive settings home, same
55
+ * sidecar pattern the family plugins use.
56
+ */
57
+ export declare function configPaths(pluginDir: string, env?: NodeJS.ProcessEnv): string[];
58
+ /** Canonical settings target (what the TUI dialog writes when no
59
+ * adjacent config exists): the opencode config folder. */
60
+ export declare function configHomePath(env?: NodeJS.ProcessEnv): string;
61
+ export type WriteCandidate = {
62
+ path: string;
63
+ label: string;
64
+ always?: boolean;
65
+ };
66
+ /** Write-target candidates for the TUI picker: the global settings home
67
+ * (always writable) plus every OTHER file in the read chain that already
68
+ * exists on disk (deploy-time overrides are never created from the TUI —
69
+ * owner rule). Order: global first (default), then the rest by read
70
+ * precedence (highest first). */
71
+ export declare function writeCandidates(pluginDir: string, env?: NodeJS.ProcessEnv): WriteCandidate[];
72
+ /** Per-key source attribution mirroring loadConfig precedence exactly
73
+ * (env > adjacent > package root > local walks > global > default).
74
+ * Values of "" count as unset for the credential/path keys. */
75
+ export declare function configSourceMap(pluginDir: string, env?: NodeJS.ProcessEnv): Record<string, string>;
76
+ /** Sanitized `servers` group from raw config JSON (invalid entries
77
+ * dropped; url keys kept verbatim). */
78
+ export declare function normalizeServers(raw: unknown): Record<string, ServerEntry> | undefined;
79
+ /** Field-level merge of servers groups: entries only carry present fields
80
+ * (normalizeServers), so a static `code` in plugin options and a persisted
81
+ * `token` in the settings home combine instead of shadowing — per-URL
82
+ * wholesale replacement would re-enroll on every boot (live-caught in
83
+ * suite design review). */
84
+ export declare function mergeServerGroups(base: Record<string, ServerEntry> | undefined, add: Record<string, ServerEntry>): Record<string, ServerEntry>;
85
+ /** The server new sessions share to: the `active` entry (or the first).
86
+ * Legacy workerUrl/createToken merge in as the implicit first entry — with
87
+ * the root token ALSO filed as adminToken (the createToken IS root; filing
88
+ * it only as `token` made the TUI offer user-actions like rotate to a root
89
+ * holder — live-caught "can either be admin or user but not both"). */
90
+ export declare function resolveServer(cfg: ShareConfig): {
91
+ url: string;
92
+ token: string;
93
+ entry: ServerEntry;
94
+ } | undefined;
95
+ /** Persist a field patch for a server entry into the settings-home
96
+ * config's `servers` group — surgical merge (never rewrites other keys,
97
+ * never touches deploy-time files). */
98
+ export declare function saveServerEntry(url: string, patch: Partial<ServerEntry>, target?: string): string | undefined;
99
+ /** Token persistence (engine enrollment) — entry-patch special case. */
100
+ export declare function saveServerToken(url: string, token: string, target?: string): string | undefined;
101
+ /** Remove a server membership from the settings home (per-field merge
102
+ * means entries born in plugin options/env survive — that's the override
103
+ * owner's business, shown by the sources view). */
104
+ export declare function deleteServerEntry(url: string, target?: string): string | undefined;
105
+ /** Load the effective config. `options` is the plugin-array options tuple
106
+ * object (env still wins over it). Files MERGE lowest→highest priority
107
+ * (later files override per key); empty strings in credential/path keys
108
+ * mean "unset" — a shipped template with blank workerUrl/createToken
109
+ * never shadows the real config elsewhere. */
110
+ export declare function loadConfig(pluginDir: string, options?: Record<string, unknown>): ShareConfig;
111
+ export declare function spoolDirFor(cfg: ShareConfig): string;
112
+ /** Does this plugin spec (tui.json entry) point at the openshare package? */
113
+ export declare function isOpenShareSpec(spec: unknown): spec is string;
114
+ /** tui.json layers the TUI reads (mirrors the family selfwire path rules,
115
+ * read-only). The walk runs from the session directory up to the worktree
116
+ * root when the host reports a sane worktree; hosts have been observed to
117
+ * report "/" for non-git dirs — in that case walk to the filesystem root. */
118
+ export declare function tuiLayerPaths(input: {
119
+ directory?: string;
120
+ worktree?: string;
121
+ env?: NodeJS.ProcessEnv;
122
+ }): string[];
123
+ /** True when an openshare TUI part is registered in any tui.json layer. */
124
+ export declare function detectTuiPresence(input: {
125
+ directory?: string;
126
+ worktree?: string;
127
+ env?: NodeJS.ProcessEnv;
128
+ }): boolean;
129
+ /** Resolve the effective share mode: explicit config wins, else presence. */
130
+ export declare function resolveShareMode(cfg: ShareConfig, input: {
131
+ directory?: string;
132
+ worktree?: string;
133
+ env?: NodeJS.ProcessEnv;
134
+ }): ShareMode;