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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  The opencode side of [Open(code)Share](../README.md) — session sharing for
4
4
  OpenCode v1 and v2, self-hosted on your own worker.
5
5
 
6
+ Every plugin config key, its file chain, and the cross-package config surface are inventoried in [../CONFIGURATION.md](../CONFIGURATION.md).
7
+
6
8
  ## Artifacts
7
9
 
8
10
  | Artifact | What it is | Share mode |
package/ci/README.md CHANGED
@@ -46,12 +46,23 @@ OPENSHARE_URL_FILE=share-urls.txt # each share URL appended + stderr line
46
46
 
47
47
  With no token held, the bundle **enrolls itself** on first contact
48
48
  (public servers take anyone; private ones consume the one-time code) and
49
- persists the token to `~/.config/opencode/openshare.config.json` —
49
+ persists the token to `~/.config/opencode/openshare.config.json` -
50
50
  enroll once, then every run just shares. A revoked token re-enrolls the
51
51
  same way; if enrollment is impossible the error lands on stderr and
52
- sharing stops (no retry storms). The bundle also supports **fan-out**:
53
- multiple `active` servers in the `servers` group each get the session
54
- (one urlFile line per server).
52
+ sharing stops (no retry storms).
53
+
54
+ **Ephemeral runners** (GitHub-hosted, containers): do NOT enroll through
55
+ a code - each fresh machine would burn one activation. Ask the hoster
56
+ for a pre-minted registered token (`osk_…`) and pass it via
57
+ `OPENSHARE_CREATE_TOKEN` (or the config's token field) - registered
58
+ tokens skip enrollment entirely and never expire on their own. Point
59
+ `OPENSHARE_CONFIG_DIR` (or mount) the settings home at a cached path
60
+ only if you want the token persisted; env alone is enough. Note:
61
+ revoking that token (when it is the user's last) freezes control of all
62
+ its shares server-side, even ones holding the share secret.
63
+
64
+ The bundle also supports **fan-out**: multiple `active` servers in the
65
+ `servers` group each get the session (one urlFile line per server).
55
66
 
56
67
  On OpenCode v2, plugin targets should be directories/packages — wrap this
57
68
  file in a folder with a `package.json` exporting it (`{"exports": {".": "./openshare.js"}}`)
package/ci/openshare.js CHANGED
@@ -19,7 +19,7 @@
19
19
  // > code defaults. Keys: servers (multi-server: url -> {token, code,
20
20
  // label, active, adminToken}), workerUrl, createToken (legacy pair),
21
21
  // shareMode ("all" | "manual"; default = TUI presence), debounceMs,
22
- // completedDelayMs, modelLabel, ttlDays, openTasksInNewTab,
22
+ // completedDelayMs, modelLabel, ttlDays,
23
23
  // authLogin, authPassword, spoolDir, urlFile ("none" = off),
24
24
  // announceStderr.
25
25
  //
@@ -36,7 +36,6 @@ var DEFAULTS = {
36
36
  completedDelayMs: 5000,
37
37
  modelLabel: "$Provider/$Model",
38
38
  ttlDays: 90,
39
- openTasksInNewTab: true,
40
39
  authLogin: "",
41
40
  authPassword: "",
42
41
  spoolDir: "",
@@ -176,7 +175,10 @@ function loadConfig(pluginDir, options) {
176
175
  continue;
177
176
  cfg[key] = value;
178
177
  }
179
- } catch {}
178
+ } catch (e) {
179
+ if (existsSync(path))
180
+ console.error(`[openshare] config file ${path} could not be parsed - ignored:`, e?.message ?? e);
181
+ }
180
182
  }
181
183
  if (options && typeof options === "object") {
182
184
  const serversOpt = normalizeServers(options.servers);
@@ -221,6 +223,14 @@ function loadConfig(pluginDir, options) {
221
223
  cfg.shareMode = env.OPENSHARE_SHARE_MODE;
222
224
  if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE)
223
225
  cfg.urlFile = URL_FILE_NONE;
226
+ if (cfg.workerUrl)
227
+ cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "");
228
+ if (cfg.servers) {
229
+ const fixed = {};
230
+ for (const [u, e] of Object.entries(cfg.servers))
231
+ fixed[u.trim().replace(/\/+$/, "")] = e;
232
+ cfg.servers = fixed;
233
+ }
224
234
  return cfg;
225
235
  }
226
236
  function spoolDirFor(cfg) {
@@ -346,7 +356,7 @@ function resolveShareMode(cfg, input) {
346
356
  // src/core.ts
347
357
  import { appendFileSync, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
348
358
  import { createHash } from "node:crypto";
349
- import { join as join2 } from "node:path";
359
+ import { isAbsolute, join as join2 } from "node:path";
350
360
 
351
361
  // src/contract.ts
352
362
  function messageTombstone(sessionID, messageID) {
@@ -638,6 +648,7 @@ function shareFlag(info) {
638
648
  }
639
649
  return { enabled: true, opts: Object.keys(opts).length ? opts : undefined };
640
650
  }
651
+ var liveTargets = [];
641
652
  function createEngine(deps) {
642
653
  let cfg = loadConfig(deps.pluginDir, deps.options);
643
654
  const mode = resolveShareMode(cfg, deps);
@@ -669,8 +680,13 @@ function createEngine(deps) {
669
680
  log(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`);
670
681
  const resolveTargets = () => {
671
682
  const merged = {};
672
- if (cfg.workerUrl)
683
+ const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0;
684
+ const envWorker = !!process.env.OPENSHARE_WORKER_URL;
685
+ if (cfg.workerUrl && (!hasServers || envWorker))
673
686
  merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
687
+ if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers[cfg.workerUrl]) {
688
+ log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`);
689
+ }
674
690
  if (cfg.servers)
675
691
  for (const [u, e] of Object.entries(cfg.servers))
676
692
  merged[u] = { ...merged[u], ...e };
@@ -680,6 +696,7 @@ function createEngine(deps) {
680
696
  return chosen.map((url) => ({ url, entry: merged[url] }));
681
697
  };
682
698
  let targets = resolveTargets();
699
+ liveTargets = targets.map((t) => t.url);
683
700
  const credState = new Map;
684
701
  const credOf = (url) => {
685
702
  let s = credState.get(url);
@@ -853,7 +870,8 @@ function createEngine(deps) {
853
870
  badSignal: false,
854
871
  lastSentStatus: null,
855
872
  closed: false,
856
- prefsSent: false,
873
+ lastError: null,
874
+ ttlClamped: null,
857
875
  binding: null,
858
876
  bindingQueried: false,
859
877
  optOuts: new Set,
@@ -872,6 +890,7 @@ function createEngine(deps) {
872
890
  spoolRoot = spoolDirFor(cfg);
873
891
  const before = new Set(targets.map((t) => t.url));
874
892
  targets = resolveTargets();
893
+ liveTargets = targets.map((t) => t.url);
875
894
  seedCredentials();
876
895
  clampToPolicy();
877
896
  if (debugEvents) {
@@ -1045,6 +1064,10 @@ function createEngine(deps) {
1045
1064
  const value = { v: 2, servers, enabled: true };
1046
1065
  if (mode === "manual" && st.opts)
1047
1066
  value.opts = st.opts;
1067
+ if (st.lastError)
1068
+ value.lastError = st.lastError;
1069
+ if (st.ttlClamped)
1070
+ value.ttlClamped = st.ttlClamped;
1048
1071
  st.binding = value;
1049
1072
  st.wroteBinding = true;
1050
1073
  await writeMetadata(st, value);
@@ -1225,7 +1248,7 @@ function createEngine(deps) {
1225
1248
  if (cfg.announceStderr)
1226
1249
  log(`live: ${url}`);
1227
1250
  if (mode === "all" && cfg.urlFile !== URL_FILE_NONE) {
1228
- const file = cfg.urlFile || join2(deps.directory ?? process.cwd(), "openshare.urls.txt");
1251
+ const file = cfg.urlFile ? isAbsolute(cfg.urlFile) ? cfg.urlFile : join2(deps.directory ?? process.cwd(), cfg.urlFile) : join2(deps.directory ?? process.cwd(), "openshare.urls.txt");
1229
1252
  try {
1230
1253
  appendFileSync(file, url + `
1231
1254
  `);
@@ -1234,15 +1257,43 @@ function createEngine(deps) {
1234
1257
  lastUrlFile = file;
1235
1258
  log(`url file: ${file}`);
1236
1259
  }
1237
- } catch {}
1260
+ } catch (e) {
1261
+ logError(`url file write failed (${file}):`, e?.message || e);
1262
+ }
1238
1263
  }
1239
1264
  };
1265
+ let halfAuthWarned = false;
1240
1266
  const authFrame = (st) => {
1241
1267
  const login = st.opts?.authLogin ?? cfg.authLogin ?? "";
1242
1268
  const password = st.opts?.authPassword ?? cfg.authPassword ?? "";
1269
+ if (login === "" !== (password === "")) {
1270
+ if (!halfAuthWarned) {
1271
+ halfAuthWarned = true;
1272
+ logError("read auth needs BOTH a login and a password - gate disabled until fixed");
1273
+ }
1274
+ return { authLogin: "", authPassword: "" };
1275
+ }
1243
1276
  return { authLogin: login, authPassword: password };
1244
1277
  };
1245
1278
  const ttlFor = (st) => st.opts?.ttlDays ?? cfg.ttlDays;
1279
+ const applyOpts = (st, opts) => {
1280
+ if (!opts)
1281
+ return;
1282
+ const prev = st.opts ? JSON.stringify(st.opts) : "";
1283
+ st.opts = opts;
1284
+ if (prev === JSON.stringify(opts))
1285
+ return;
1286
+ for (const [url, r] of st.remotes) {
1287
+ if (!r.shareId || !r.secret || !r.wsReady)
1288
+ continue;
1289
+ const attach = { id: r.shareId, secret: r.secret, ...authFrame(st) };
1290
+ if (st.opts?.ttlDays)
1291
+ attach.ttlDays = st.opts.ttlDays;
1292
+ const sent = wsSend(r, { op: "init", attach });
1293
+ if (sent)
1294
+ log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlDays ? ` (ttl ${st.opts.ttlDays}d)` : ""}`);
1295
+ }
1296
+ };
1246
1297
  const deleteAbandoned = (r) => {
1247
1298
  const id = r.shareId;
1248
1299
  const secret = r.secret;
@@ -1326,6 +1377,25 @@ function createEngine(deps) {
1326
1377
  r.announced = false;
1327
1378
  queueConnect(st, r);
1328
1379
  }
1380
+ let rearmed = false;
1381
+ for (const [url, e] of Object.entries(servers)) {
1382
+ if (st.optOuts.has(url) || st.remotes.has(url))
1383
+ continue;
1384
+ const ee = e;
1385
+ if (typeof ee.id !== "string" || typeof ee.secret !== "string" || !ee.id)
1386
+ continue;
1387
+ const r = remoteFor(st, url);
1388
+ r.shareId = ee.id;
1389
+ r.secret = ee.secret;
1390
+ r.ackedRev = typeof ee.rev === "number" ? ee.rev : 0;
1391
+ log(`re-share: re-arming ${url.slice(0, 24)} on share ${ee.id}`);
1392
+ queueConnect(st, r);
1393
+ rearmed = true;
1394
+ }
1395
+ if (rearmed) {
1396
+ st.sentHash.clear();
1397
+ chain = chain.then(() => backfill(st)).catch((e) => logError("re-share backfill error", e?.message || e));
1398
+ }
1329
1399
  } catch {}
1330
1400
  };
1331
1401
  const connect = async (st, r) => {
@@ -1534,6 +1604,8 @@ function createEngine(deps) {
1534
1604
  const err = String(frame.error ?? "");
1535
1605
  if (/frozen/i.test(err)) {
1536
1606
  r.authBlocked = true;
1607
+ st.lastError = `share frozen by the hoster (owner frozen) - contact the server admin`;
1608
+ scheduleBindingWrite(st);
1537
1609
  logError(`${r.url}: share frozen by the hoster (owner frozen) - edits refused; contact the server admin`);
1538
1610
  try {
1539
1611
  r.ws?.close(4000, "frozen");
@@ -1568,6 +1640,8 @@ function createEngine(deps) {
1568
1640
  return;
1569
1641
  }
1570
1642
  if (/limit|too many|rate/i.test(err)) {
1643
+ st.lastError = `create refused: ${frame.error ?? "rate limited"}`;
1644
+ scheduleBindingWrite(st);
1571
1645
  logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff");
1572
1646
  r.connecting = false;
1573
1647
  try {
@@ -1578,6 +1652,8 @@ function createEngine(deps) {
1578
1652
  return;
1579
1653
  }
1580
1654
  logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
1655
+ st.lastError = `init refused: ${frame.error ?? "unknown"}`;
1656
+ scheduleBindingWrite(st);
1581
1657
  deleteAbandoned(r);
1582
1658
  r.shareId = null;
1583
1659
  r.secret = null;
@@ -1623,6 +1699,18 @@ function createEngine(deps) {
1623
1699
  applyPolicy(frame.policy, r.url);
1624
1700
  r.intentionalClose = false;
1625
1701
  touch(st);
1702
+ if (typeof frame.ttlDays === "number") {
1703
+ const wanted = ttlFor(st);
1704
+ const clamp = wanted != null && frame.ttlDays !== wanted ? { requested: wanted, effective: frame.ttlDays } : null;
1705
+ if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
1706
+ st.ttlClamped = clamp;
1707
+ scheduleBindingWrite(st);
1708
+ }
1709
+ }
1710
+ if (st.lastError) {
1711
+ st.lastError = null;
1712
+ scheduleBindingWrite(st);
1713
+ }
1626
1714
  announceUrl(st, r);
1627
1715
  if (!st.wroteBinding) {
1628
1716
  chain = chain.then(() => writeBinding(st)).catch(() => {});
@@ -2396,10 +2484,6 @@ function createEngine(deps) {
2396
2484
  if (parent) {
2397
2485
  const st = sessions.get(parent);
2398
2486
  if (st?.activated && !st.closed) {
2399
- if (!st.prefsSent) {
2400
- st.prefsSent = true;
2401
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2402
- }
2403
2487
  if (info)
2404
2488
  enqueue(st, itemFor("session", redact(info), sid));
2405
2489
  }
@@ -2410,11 +2494,7 @@ function createEngine(deps) {
2410
2494
  if (flag.enabled) {
2411
2495
  if (st?.activated && !st.closed) {
2412
2496
  if (flag.opts)
2413
- st.opts = flag.opts;
2414
- if (!st.prefsSent) {
2415
- st.prefsSent = true;
2416
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2417
- }
2497
+ applyOpts(st, flag.opts);
2418
2498
  if (info)
2419
2499
  enqueue(st, itemFor("session", redact(info), sid));
2420
2500
  if (t === "session.updated")
@@ -2422,11 +2502,6 @@ function createEngine(deps) {
2422
2502
  return;
2423
2503
  }
2424
2504
  activate(sid, flag.opts, info);
2425
- const fresh = sessions.get(sid);
2426
- if (fresh && !fresh.prefsSent) {
2427
- fresh.prefsSent = true;
2428
- enqueue(fresh, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2429
- }
2430
2505
  return;
2431
2506
  }
2432
2507
  if (st?.activated && !st.closed && t === "session.updated") {
@@ -2472,10 +2547,6 @@ function createEngine(deps) {
2472
2547
  }
2473
2548
  if (!st)
2474
2549
  return;
2475
- if (!st.prefsSent) {
2476
- st.prefsSent = true;
2477
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2478
- }
2479
2550
  if (info)
2480
2551
  enqueue(st, itemFor("session", redact(info), sid));
2481
2552
  if (t === "session.updated" && st.activated && !st.closed) {
@@ -2685,6 +2756,21 @@ function createEngine(deps) {
2685
2756
  st.metaTimer = null;
2686
2757
  await writeBinding(st).catch(() => {});
2687
2758
  }
2759
+ const barrierStart = Date.now();
2760
+ while (Date.now() - barrierStart < 5000) {
2761
+ let pendingAnnounce = false;
2762
+ for (const st of sessions.values()) {
2763
+ for (const r of st.remotes.values()) {
2764
+ if (r.authBlocked || r.collided)
2765
+ continue;
2766
+ if (r.shareId && !r.announced || !r.shareId && (r.ws || r.connecting))
2767
+ pendingAnnounce = true;
2768
+ }
2769
+ }
2770
+ if (!pendingAnnounce)
2771
+ break;
2772
+ await new Promise((res) => setTimeout(res, 100));
2773
+ }
2688
2774
  for (const st of sessions.values()) {
2689
2775
  for (const r of st.remotes.values()) {
2690
2776
  try {
package/dist/commands.js CHANGED
@@ -15,6 +15,7 @@
15
15
  // deliberately NOT joined into the shared family palette category).
16
16
  import { clearGoneBindingEntries, currentConfig, hostOf, isConfigured, patchSessionBinding, readBindings, resolveRoot, shareWithConsent, verifyShare, } from "./share-flow.js";
17
17
  import { onboardingDialog, settingsDialog } from "./settings.js";
18
+ import { engineTargetUrls } from "./core.js";
18
19
  import { dbgLog } from "./sidebar.js";
19
20
  export const PALETTE_CATEGORY = "OpenShare";
20
21
  function finish(host, result) {
@@ -135,7 +136,9 @@ export function buildShareCommand(host, sessionID) {
135
136
  dbgLog(`/share run: entered (sid=${sessionID() ?? "none"})`);
136
137
  host.ui.dialog.clear();
137
138
  const cfg = currentConfig();
138
- if (!isConfigured(cfg)) {
139
+ // engine-aware gate: project-level plugin options can configure
140
+ // targets the TUI's file chain cannot see - trust the live engine
141
+ if (!isConfigured(cfg) && !engineTargetUrls().length) {
139
142
  void onboardingDialog(host);
140
143
  return;
141
144
  }
@@ -155,12 +158,12 @@ export function buildShareCommand(host, sessionID) {
155
158
  // enabled:true; legacy all-mode bindings without the field are live
156
159
  // too — only an explicit false is dormant)
157
160
  if (all.some((b) => b.url && b.enabled !== false)) {
158
- // THE session menu — identical to /openshare → This session by
159
- // construction (one surface, no duplicated manager). No
160
- // dialog.clear() after: settings owns its chain end-to-end (the
161
- // old fire-and-forget menu + immediate clear wiped it instantly —
162
- // live-caught as "/share did nothing" on a shared session).
163
- await settingsDialog(host, sid);
161
+ // THE session menu - identical to /openshare This session by
162
+ // construction (one surface, no duplicated manager), opened
163
+ // DIRECTLY (owner: "/share is supposed to be the same menu, not
164
+ // the settings root one hop away"). No dialog.clear() after:
165
+ // settings owns its chain end-to-end.
166
+ await settingsDialog(host, sid, "session");
164
167
  return;
165
168
  }
166
169
  // everything was just cleared by the gone modal and the user chose
package/dist/config.d.ts CHANGED
@@ -23,7 +23,6 @@ export type ShareConfig = {
23
23
  completedDelayMs: number;
24
24
  modelLabel: string;
25
25
  ttlDays: number;
26
- openTasksInNewTab: boolean;
27
26
  authLogin: string;
28
27
  authPassword: string;
29
28
  spoolDir: string;
package/dist/config.js CHANGED
@@ -36,7 +36,6 @@ export const DEFAULTS = {
36
36
  completedDelayMs: 5000,
37
37
  modelLabel: "$Provider/$Model",
38
38
  ttlDays: 90,
39
- openTasksInNewTab: true,
40
39
  authLogin: "",
41
40
  authPassword: "",
42
41
  spoolDir: "",
@@ -327,7 +326,14 @@ export function loadConfig(pluginDir, options) {
327
326
  cfg[key] = value;
328
327
  }
329
328
  }
330
- catch { }
329
+ catch (e) {
330
+ // a malformed config file is a real setup error the user must hear
331
+ // about - silence here looks like "no share server configured".
332
+ // (Absent files are normal chain candidates - only broken content
333
+ // is worth a line.)
334
+ if (existsSync(path))
335
+ console.error(`[openshare] config file ${path} could not be parsed - ignored:`, e?.message ?? e);
336
+ }
331
337
  }
332
338
  if (options && typeof options === "object") {
333
339
  const serversOpt = normalizeServers(options.servers);
@@ -374,6 +380,17 @@ export function loadConfig(pluginDir, options) {
374
380
  // normalize the disable sentinel (case-insensitive from any source)
375
381
  if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE)
376
382
  cfg.urlFile = URL_FILE_NONE;
383
+ // URL hygiene: a trailing slash on a server url becomes
384
+ // wss://host//api/ws and https://host//s/<id> downstream - strip once,
385
+ // at the source, so every consumer agrees
386
+ if (cfg.workerUrl)
387
+ cfg.workerUrl = cfg.workerUrl.trim().replace(/\/+$/, "");
388
+ if (cfg.servers) {
389
+ const fixed = {};
390
+ for (const [u, e] of Object.entries(cfg.servers))
391
+ fixed[u.trim().replace(/\/+$/, "")] = e;
392
+ cfg.servers = fixed;
393
+ }
377
394
  return cfg;
378
395
  }
379
396
  export function spoolDirFor(cfg) {
package/dist/core.d.ts CHANGED
@@ -114,4 +114,5 @@ export type Engine = {
114
114
  mode: ShareMode;
115
115
  config: () => ShareConfig;
116
116
  };
117
+ export declare function engineTargetUrls(): string[];
117
118
  export declare function createEngine(deps: EngineDeps): Engine;