@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,2265 @@
1
+ // Open(code)Share — re:share, session sharing for opencode. Self-hosted
2
+ // replacement for the discontinued opencode share. Streams session events
3
+ // over one WebSocket per main session to an openshare-worker; viewers
4
+ // watch via hibernating sockets.
5
+ //
6
+ // This is the SINGLE-FILE build of the server plugin (generated by
7
+ // scripts/build-standalone.mjs — do not edit; edit src/ and rebuild).
8
+ // It behaves exactly like the package's dist/server.js.
9
+ //
10
+ // Install:
11
+ // - CI / share-everything: reference this file in the `plugin` array of
12
+ // your opencode config (no TUI part -> every session shares, the URL
13
+ // lands in the log and optionally in `urlFile`).
14
+ // - Interactive opt-in: install the package's TUI part (tui.json) too —
15
+ // the server detects its presence and switches to manual mode
16
+ // (`/share`, `/unshare` in the TUI).
17
+ //
18
+ // Config: env (OPENSHARE_*) > openshare.config.json (next to this file)
19
+ // > code defaults. Keys: workerUrl, createToken, debounceMs, modelLabel,
20
+ // ttlDays, openTasksInNewTab, authLogin, authPassword, spoolDir, urlFile,
21
+ // shareMode ("all" | "manual"; default = TUI presence).
22
+ //
23
+ // src/config.ts
24
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
25
+ import { basename, dirname, join, relative } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ var METADATA_KEY = "openshare";
28
+ var URL_FILE_NONE = "none";
29
+ var DEFAULTS = {
30
+ workerUrl: "",
31
+ createToken: "",
32
+ debounceMs: 1e4,
33
+ completedDelayMs: 5000,
34
+ modelLabel: "$Provider/$Model",
35
+ ttlDays: 90,
36
+ openTasksInNewTab: true,
37
+ authLogin: "",
38
+ authPassword: "",
39
+ spoolDir: "",
40
+ urlFile: "",
41
+ announceStderr: true
42
+ };
43
+ var CONFIG_NAME = "openshare.config.json";
44
+ var LOCAL_NAME = "openshare.local.json";
45
+ function moduleDir() {
46
+ return dirname(fileURLToPath(import.meta.pathname ?? import.meta.url));
47
+ }
48
+ function packageRoot(start) {
49
+ let current = start;
50
+ for (let guard = 0;guard < 16; guard++) {
51
+ if (existsSync(join(current, "package.json")))
52
+ return current;
53
+ const parent = dirname(current);
54
+ if (parent === current)
55
+ break;
56
+ current = parent;
57
+ }
58
+ return start;
59
+ }
60
+ function configPaths(pluginDir, env = process.env) {
61
+ const paths = [join(pluginDir, CONFIG_NAME)];
62
+ const root = packageRoot(pluginDir);
63
+ if (root !== pluginDir)
64
+ paths.push(join(root, CONFIG_NAME));
65
+ let current = pluginDir;
66
+ for (let i = 0;i < 5; i++) {
67
+ paths.push(join(current, LOCAL_NAME));
68
+ const parent = dirname(current);
69
+ if (parent === current)
70
+ break;
71
+ current = parent;
72
+ }
73
+ paths.push(join(globalConfigDir(env), CONFIG_NAME));
74
+ return paths;
75
+ }
76
+ function configHomePath(env = process.env) {
77
+ return join(globalConfigDir(env), CONFIG_NAME);
78
+ }
79
+ function num(value, fallback) {
80
+ const parsed = Number(value);
81
+ return Number.isFinite(parsed) ? parsed : fallback;
82
+ }
83
+ function str(value) {
84
+ return typeof value === "string" ? value : undefined;
85
+ }
86
+ function normalizeServers(raw) {
87
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
88
+ return;
89
+ const out = {};
90
+ for (const [url, value] of Object.entries(raw)) {
91
+ if (!url.startsWith("http") || !value || typeof value !== "object")
92
+ continue;
93
+ const e = value;
94
+ const entry = {};
95
+ if (typeof e.token === "string" && e.token)
96
+ entry.token = e.token;
97
+ if (typeof e.code === "string" && e.code)
98
+ entry.code = e.code;
99
+ if (typeof e.label === "string" && e.label)
100
+ entry.label = e.label;
101
+ if (typeof e.adminToken === "string" && e.adminToken)
102
+ entry.adminToken = e.adminToken;
103
+ if (e.active === true)
104
+ entry.active = true;
105
+ out[url] = entry;
106
+ }
107
+ return Object.keys(out).length ? out : undefined;
108
+ }
109
+ function mergeServerGroups(base, add) {
110
+ const merged = { ...base ?? {} };
111
+ for (const [url, entry] of Object.entries(add))
112
+ merged[url] = { ...merged[url] ?? {}, ...entry };
113
+ return merged;
114
+ }
115
+ function resolveServer(cfg) {
116
+ const servers = {};
117
+ if (cfg.workerUrl)
118
+ servers[cfg.workerUrl] = { token: cfg.createToken || "", adminToken: cfg.createToken || "", active: true };
119
+ if (cfg.servers)
120
+ for (const [url2, entry] of Object.entries(cfg.servers))
121
+ servers[url2] = entry;
122
+ const urls = Object.keys(servers);
123
+ if (!urls.length)
124
+ return;
125
+ const url = urls.find((u) => servers[u].active) ?? urls[0];
126
+ return { url, token: servers[url].token ?? "", entry: servers[url] };
127
+ }
128
+ function saveServerEntry(url, patch, target) {
129
+ const path = target ?? configHomePath();
130
+ if (!path || !url.startsWith("http"))
131
+ return;
132
+ try {
133
+ let previous = {};
134
+ try {
135
+ previous = JSON.parse(readFileSync(path, "utf8"));
136
+ if (!previous || typeof previous !== "object")
137
+ previous = {};
138
+ } catch {}
139
+ const servers = normalizeServers(previous.servers) ?? {};
140
+ servers[url] = { ...servers[url], ...patch };
141
+ previous.servers = servers;
142
+ writeFileSync(path, JSON.stringify(previous, null, 2) + `
143
+ `);
144
+ return path;
145
+ } catch {
146
+ return;
147
+ }
148
+ }
149
+ function saveServerToken(url, token, target) {
150
+ return saveServerEntry(url, { token }, target);
151
+ }
152
+ function loadConfig(pluginDir, options) {
153
+ const cfg = { ...DEFAULTS };
154
+ const emptyMeansUnset = new Set(["workerUrl", "createToken", "urlFile", "spoolDir"]);
155
+ const known = new Set([...Object.keys(DEFAULTS), "shareMode"]);
156
+ for (const path of [...configPaths(pluginDir)].reverse()) {
157
+ try {
158
+ const raw = JSON.parse(readFileSync(path, "utf8"));
159
+ if (!raw || typeof raw !== "object")
160
+ continue;
161
+ for (const [key, value] of Object.entries(raw)) {
162
+ if (key === "servers") {
163
+ const servers = normalizeServers(value);
164
+ if (servers)
165
+ cfg.servers = mergeServerGroups(cfg.servers, servers);
166
+ continue;
167
+ }
168
+ if (!known.has(key))
169
+ continue;
170
+ if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean")
171
+ continue;
172
+ if (typeof value === "string" && value === "" && emptyMeansUnset.has(key))
173
+ continue;
174
+ cfg[key] = value;
175
+ }
176
+ } catch {}
177
+ }
178
+ if (options && typeof options === "object") {
179
+ const serversOpt = normalizeServers(options.servers);
180
+ if (serversOpt)
181
+ cfg.servers = mergeServerGroups(cfg.servers, serversOpt);
182
+ for (const key of Object.keys(DEFAULTS)) {
183
+ const value = options[key];
184
+ if (value === undefined || value === null)
185
+ continue;
186
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "string") {
187
+ cfg[key] = value;
188
+ }
189
+ }
190
+ const mode = str(options.shareMode);
191
+ if (mode === "all" || mode === "manual")
192
+ cfg.shareMode = mode;
193
+ }
194
+ const env = process.env;
195
+ if (env.OPENSHARE_WORKER_URL)
196
+ cfg.workerUrl = env.OPENSHARE_WORKER_URL;
197
+ if (env.OPENSHARE_CREATE_TOKEN)
198
+ cfg.createToken = env.OPENSHARE_CREATE_TOKEN;
199
+ if (env.OPENSHARE_DEBOUNCE_MS)
200
+ cfg.debounceMs = num(env.OPENSHARE_DEBOUNCE_MS, cfg.debounceMs);
201
+ if (env.OPENSHARE_COMPLETED_DELAY_MS !== undefined)
202
+ cfg.completedDelayMs = num(env.OPENSHARE_COMPLETED_DELAY_MS, cfg.completedDelayMs);
203
+ if (env.OPENSHARE_MODEL_LABEL !== undefined)
204
+ cfg.modelLabel = env.OPENSHARE_MODEL_LABEL;
205
+ if (env.OPENSHARE_TTL_DAYS)
206
+ cfg.ttlDays = num(env.OPENSHARE_TTL_DAYS, cfg.ttlDays);
207
+ if (env.OPENSHARE_AUTH_LOGIN !== undefined)
208
+ cfg.authLogin = env.OPENSHARE_AUTH_LOGIN;
209
+ if (env.OPENSHARE_AUTH_PASSWORD !== undefined)
210
+ cfg.authPassword = env.OPENSHARE_AUTH_PASSWORD;
211
+ if (env.OPENSHARE_URL_FILE !== undefined)
212
+ cfg.urlFile = env.OPENSHARE_URL_FILE;
213
+ if (env.OPENSHARE_SPOOL_DIR)
214
+ cfg.spoolDir = env.OPENSHARE_SPOOL_DIR;
215
+ if (env.OPENSHARE_ANNOUNCE_STDERR !== undefined)
216
+ cfg.announceStderr = !/^(0|false|no|off)$/i.test(env.OPENSHARE_ANNOUNCE_STDERR.trim());
217
+ if (env.OPENSHARE_SHARE_MODE === "all" || env.OPENSHARE_SHARE_MODE === "manual")
218
+ cfg.shareMode = env.OPENSHARE_SHARE_MODE;
219
+ if (cfg.urlFile.trim().toLowerCase() === URL_FILE_NONE)
220
+ cfg.urlFile = URL_FILE_NONE;
221
+ return cfg;
222
+ }
223
+ function spoolDirFor(cfg) {
224
+ if (cfg.spoolDir)
225
+ return cfg.spoolDir;
226
+ const base = process.env.XDG_DATA_HOME || (process.env.HOME ? join(process.env.HOME, ".local", "share") : "/tmp");
227
+ return join(base, "opencode", "openshare");
228
+ }
229
+ var NPM_NAME = "@mirrowel/opencode-openshare";
230
+ function normalizePath(value) {
231
+ return value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
232
+ }
233
+ function specPath(spec) {
234
+ if (!spec.startsWith("file:"))
235
+ return;
236
+ try {
237
+ let url = spec;
238
+ if (!url.startsWith("file:///") && url.startsWith("file://"))
239
+ url = `file:///${url.slice("file://".length)}`;
240
+ return normalizePath(new URL(url).pathname);
241
+ } catch {
242
+ return;
243
+ }
244
+ }
245
+ function isOpenShareSpec(spec) {
246
+ if (typeof spec !== "string" || spec.length === 0)
247
+ return false;
248
+ if (spec === NPM_NAME || spec.startsWith(`${NPM_NAME}@`))
249
+ return true;
250
+ const path = specPath(spec);
251
+ if (path) {
252
+ if (path.endsWith("/opencode-openshare") || path.endsWith("/share-plugin"))
253
+ return true;
254
+ return path.includes("/opencode-openshare/") || path.includes("/share-plugin/");
255
+ }
256
+ const normalized = normalizePath(spec);
257
+ if (/^[a-z]:\//.test(normalized) || normalized.startsWith("/")) {
258
+ if (normalized.endsWith("/opencode-openshare") || normalized.endsWith("/share-plugin"))
259
+ return true;
260
+ return normalized.includes("/opencode-openshare/") || normalized.includes("/share-plugin/");
261
+ }
262
+ return false;
263
+ }
264
+ function readJson(path) {
265
+ try {
266
+ if (!existsSync(path))
267
+ return {};
268
+ const data = JSON.parse(readFileSync(path, "utf8"));
269
+ return data && typeof data === "object" ? data : {};
270
+ } catch {
271
+ return {};
272
+ }
273
+ }
274
+ function specStrings(data) {
275
+ const result = [];
276
+ for (const key of ["plugin", "plugins"]) {
277
+ const array = data[key];
278
+ if (!Array.isArray(array))
279
+ continue;
280
+ for (const entry of array) {
281
+ const spec = typeof entry === "string" ? entry : Array.isArray(entry) && typeof entry[0] === "string" ? entry[0] : entry && typeof entry === "object" && typeof entry.package === "string" ? entry.package : undefined;
282
+ if (spec !== undefined)
283
+ result.push(spec);
284
+ }
285
+ }
286
+ return result;
287
+ }
288
+ function globalConfigDir(env) {
289
+ if (env["OPENCODE_CONFIG_DIR"])
290
+ return env["OPENCODE_CONFIG_DIR"];
291
+ if (env["XDG_CONFIG_HOME"])
292
+ return join(env["XDG_CONFIG_HOME"], "opencode");
293
+ const home = process.env.USERPROFILE ?? process.env.HOME ?? "";
294
+ return join(home, ".config", "opencode");
295
+ }
296
+ function tuiLayerPaths(input) {
297
+ const env = input.env ?? process.env;
298
+ const paths = [];
299
+ const globalTui = join(globalConfigDir(env), "tui.json");
300
+ if (existsSync(globalTui))
301
+ paths.push(globalTui);
302
+ const envTui = env["OPENCODE_TUI_CONFIG"];
303
+ if (envTui && existsSync(envTui))
304
+ paths.push(envTui);
305
+ if (input.directory) {
306
+ const dir = normalizePath(input.directory);
307
+ const worktree = input.worktree ? normalizePath(input.worktree) : undefined;
308
+ const sane = worktree !== undefined && worktree !== "/" && !/^[a-z]:\/$/.test(worktree) && (dir === worktree || dir.startsWith(`${worktree}/`));
309
+ let current = dir;
310
+ for (let guard = 0;guard < 64 && current; guard++) {
311
+ for (const name of ["tui.json", ".opencode/tui.json"]) {
312
+ const candidate = `${current}/${name}`;
313
+ if (existsSync(candidate) && !paths.includes(candidate))
314
+ paths.push(candidate);
315
+ }
316
+ if (sane && current === worktree)
317
+ break;
318
+ const parent = dirname(current);
319
+ if (parent === current)
320
+ break;
321
+ current = parent;
322
+ }
323
+ }
324
+ return paths;
325
+ }
326
+ function detectTuiPresence(input) {
327
+ try {
328
+ for (const layer of tuiLayerPaths(input)) {
329
+ if (specStrings(readJson(layer)).some((spec) => isOpenShareSpec(spec)))
330
+ return true;
331
+ }
332
+ } catch {}
333
+ return false;
334
+ }
335
+ function resolveShareMode(cfg, input) {
336
+ if (cfg.shareMode === "all" || cfg.shareMode === "manual")
337
+ return cfg.shareMode;
338
+ return detectTuiPresence(input) ? "manual" : "all";
339
+ }
340
+
341
+ // src/core.ts
342
+ import { appendFileSync, existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
343
+ import { join as join2 } from "node:path";
344
+ var DEFAULT_POLICY = {
345
+ mode: "persistent",
346
+ turnGraceMs: 120000,
347
+ idleDropMs: 600000,
348
+ finalizeAfterMs: 300000,
349
+ debounceMinMs: 0,
350
+ debounceMaxMs: Infinity,
351
+ completedDelayMaxMs: Infinity
352
+ };
353
+ var stderrInfoAllowed = true;
354
+ var log = (...a) => {
355
+ if (!stderrInfoAllowed)
356
+ return;
357
+ try {
358
+ console.error(`[${METADATA_KEY}]`, ...a);
359
+ } catch {}
360
+ };
361
+ var logError = (...a) => {
362
+ try {
363
+ console.error(`[${METADATA_KEY}]`, ...a);
364
+ } catch {}
365
+ };
366
+ function formatLabel(template, model) {
367
+ if (template === "")
368
+ return "";
369
+ if (!model)
370
+ return template ?? "";
371
+ return (template ?? "").replace(/\$Provider/g, model.providerID ?? "").replace(/\$provider/g, model.providerID ?? "").replace(/\$Model/g, model.modelID ?? "").replace(/\$model/g, model.modelID ?? "");
372
+ }
373
+ function itemFor(type, data, sessionID) {
374
+ if (type === "model")
375
+ return { key: `model/${sessionID}`, item: { type, sessionID, data } };
376
+ if (type === "prefs")
377
+ return { key: "prefs", item: { type, data } };
378
+ if (type === "session")
379
+ return { key: `session/${data?.id ?? ""}`, item: { type, data } };
380
+ if (type === "message")
381
+ return { key: `message/${data?.id ?? ""}`, item: { type, data } };
382
+ if (type === "part")
383
+ return { key: `part/${data?.messageID ?? ""}/${data?.id ?? ""}`, item: { type, data } };
384
+ if (type === "session_diff")
385
+ return { key: `session_diff/${sessionID}`, item: { type, sessionID, data } };
386
+ return;
387
+ }
388
+ function redact(info) {
389
+ if (!info?.metadata || typeof info.metadata !== "object")
390
+ return info;
391
+ if (!(METADATA_KEY in info.metadata))
392
+ return info;
393
+ const metadata = { ...info.metadata };
394
+ delete metadata[METADATA_KEY];
395
+ return { ...info, metadata };
396
+ }
397
+ function shareFlag(info) {
398
+ const raw = info?.metadata?.[METADATA_KEY];
399
+ if (!raw || typeof raw !== "object")
400
+ return { enabled: false, opts: undefined };
401
+ const record = raw;
402
+ if (record.enabled !== true)
403
+ return { enabled: false, opts: undefined };
404
+ const optsRaw = record.opts;
405
+ const opts = {};
406
+ if (optsRaw && typeof optsRaw === "object") {
407
+ const o = optsRaw;
408
+ if (typeof o.ttlDays === "number" && o.ttlDays > 0)
409
+ opts.ttlDays = o.ttlDays;
410
+ if (typeof o.authLogin === "string")
411
+ opts.authLogin = o.authLogin;
412
+ if (typeof o.authPassword === "string")
413
+ opts.authPassword = o.authPassword;
414
+ if (typeof o.modelLabel === "string")
415
+ opts.modelLabel = o.modelLabel;
416
+ }
417
+ return { enabled: true, opts: Object.keys(opts).length ? opts : undefined };
418
+ }
419
+ function createEngine(deps) {
420
+ let cfg = loadConfig(deps.pluginDir, deps.options);
421
+ const mode = resolveShareMode(cfg, deps);
422
+ if (mode === "manual")
423
+ stderrInfoAllowed = false;
424
+ if (process.env.OPENSHARE_DEBUG_FILE) {
425
+ try {
426
+ writeFileSync2(process.env.OPENSHARE_DEBUG_FILE, JSON.stringify({
427
+ mode,
428
+ directory: deps.directory,
429
+ worktree: deps.worktree,
430
+ pluginDir: deps.pluginDir,
431
+ shareModeCfg: cfg.shareMode ?? null
432
+ }, null, 2));
433
+ } catch {}
434
+ }
435
+ const server = resolveServer(cfg);
436
+ if (!server || !server.url) {
437
+ log("disabled: no share server configured (workerUrl or a servers entry)");
438
+ return {
439
+ mode,
440
+ config: () => cfg,
441
+ hooks: {
442
+ event: async () => {},
443
+ dispose: async () => {}
444
+ }
445
+ };
446
+ }
447
+ log(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`);
448
+ const resolveTargets = () => {
449
+ const merged = {};
450
+ if (cfg.workerUrl)
451
+ merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
452
+ if (cfg.servers)
453
+ for (const [u, e] of Object.entries(cfg.servers))
454
+ merged[u] = { ...merged[u], ...e };
455
+ const urls = Object.keys(merged);
456
+ const actives = urls.filter((u) => merged[u].active);
457
+ const chosen = actives.length ? actives : urls.slice(0, 1);
458
+ return chosen.map((url) => ({ url, entry: merged[url] }));
459
+ };
460
+ let targets = resolveTargets();
461
+ const credState = new Map;
462
+ const credOf = (url) => {
463
+ let s = credState.get(url);
464
+ if (!s) {
465
+ s = { credential: "", error: "", lastEnrollAt: 0 };
466
+ credState.set(url, s);
467
+ }
468
+ return s;
469
+ };
470
+ const seedCredentials = () => {
471
+ for (const t of targets) {
472
+ const c = credOf(t.url);
473
+ if (t.entry.token) {
474
+ if (c.credential !== t.entry.token) {
475
+ c.credential = t.entry.token;
476
+ c.error = "";
477
+ }
478
+ } else if (!c.credential && !c.error) {
479
+ c.credential = "";
480
+ }
481
+ }
482
+ };
483
+ seedCredentials();
484
+ const ENROLL_RETRY_MS = 60000;
485
+ const ensureCredential = async (url) => {
486
+ const c = credOf(url);
487
+ const target = targets.find((t) => t.url === url);
488
+ if (c.credential)
489
+ return c.credential;
490
+ if (Date.now() - c.lastEnrollAt < ENROLL_RETRY_MS)
491
+ return "";
492
+ c.lastEnrollAt = Date.now();
493
+ try {
494
+ const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) });
495
+ const hb = h.ok ? await h.json().catch(() => null) : null;
496
+ const authMode2 = hb?.auth?.mode;
497
+ const code = target?.entry.code ?? "";
498
+ if (authMode2 === "public" || code) {
499
+ const r = await fetch(`${url}/api/enroll`, {
500
+ method: "POST",
501
+ headers: { "content-type": "application/json" },
502
+ body: JSON.stringify({ code }),
503
+ signal: AbortSignal.timeout(1e4)
504
+ });
505
+ const b = r.ok ? await r.json().catch(() => null) : null;
506
+ if (b?.ok && typeof b.token === "string") {
507
+ c.credential = b.token;
508
+ c.error = "";
509
+ const savedTo = saveServerToken(url, c.credential);
510
+ log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
511
+ return c.credential;
512
+ }
513
+ c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`;
514
+ } else if (authMode2 === "private") {
515
+ c.error = "private server: add servers.{url}.code (or token) to enroll";
516
+ } else {
517
+ c.error = `server health unreachable (HTTP ${h.status})`;
518
+ }
519
+ } catch (e) {
520
+ c.error = `enrollment failed: ${e?.message || e}`;
521
+ }
522
+ logError(`no credential for ${url}: ${c.error}`);
523
+ return "";
524
+ };
525
+ const remoteFor = (st, url) => {
526
+ let r = st.remotes.get(url);
527
+ if (!r) {
528
+ r = {
529
+ url,
530
+ shareId: null,
531
+ secret: null,
532
+ ackedRev: 0,
533
+ ws: null,
534
+ wsReady: false,
535
+ connecting: false,
536
+ socketSeq: 0,
537
+ initTimer: null,
538
+ initWasAttach: false,
539
+ backoff: 1000,
540
+ reconnectTimer: null,
541
+ intentionalClose: false,
542
+ connectQueued: false,
543
+ authBlocked: false,
544
+ pending: [],
545
+ announced: false,
546
+ settleTimer: null,
547
+ idleDropTimer: null,
548
+ collided: null
549
+ };
550
+ st.remotes.set(url, r);
551
+ }
552
+ return r;
553
+ };
554
+ const policyMap = new Map;
555
+ const policyAsked = new Set;
556
+ const policyOf = (url) => policyMap.get(url) ?? DEFAULT_POLICY;
557
+ const clampToPolicy = () => {
558
+ const policies = [...policyMap.values()];
559
+ if (!policies.length)
560
+ return;
561
+ const min = Math.max(0, ...policies.map((p) => p.debounceMinMs));
562
+ const max = Math.min(...policies.map((p) => p.debounceMaxMs));
563
+ cfg.debounceMs = Math.min(Math.max(cfg.debounceMs, min), Math.max(min, max));
564
+ const cd = Math.min(...policies.map((p) => p.completedDelayMaxMs));
565
+ cfg.completedDelayMs = Math.min(Math.max(cfg.completedDelayMs, 0), cd);
566
+ };
567
+ const applyPolicy = (raw, url) => {
568
+ if (!raw || typeof raw !== "object")
569
+ return;
570
+ const p = raw;
571
+ const num2 = (v, d) => {
572
+ const n = Number(v);
573
+ return Number.isFinite(n) && n >= 0 ? n : d;
574
+ };
575
+ const prev = policyOf(url);
576
+ policyMap.set(url, {
577
+ mode: p.mode === "turn" ? "turn" : "persistent",
578
+ turnGraceMs: num2(p.turnGraceMs, prev.turnGraceMs),
579
+ idleDropMs: num2(p.idleDropMs, prev.idleDropMs),
580
+ finalizeAfterMs: num2(p.finalizeAfterMs, prev.finalizeAfterMs),
581
+ debounceMinMs: num2(p.debounceMinMs, 0),
582
+ debounceMaxMs: Math.max(num2(p.debounceMaxMs, Infinity), num2(p.debounceMinMs, 0)),
583
+ completedDelayMaxMs: num2(p.completedDelayMaxMs, Infinity)
584
+ });
585
+ clampToPolicy();
586
+ if (debugEvents)
587
+ log(`policy ${url}: ${JSON.stringify(policyOf(url))}`);
588
+ };
589
+ const askPolicy = (url) => {
590
+ if (policyAsked.has(url))
591
+ return;
592
+ policyAsked.add(url);
593
+ fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) }).then((r) => r.ok ? r.json() : null).then((b) => {
594
+ if (b && typeof b === "object" && b.policy) {
595
+ applyPolicy(b.policy, url);
596
+ }
597
+ }).catch(() => {
598
+ policyAsked.delete(url);
599
+ });
600
+ };
601
+ const wsUrl = (url) => url.replace(/^http/, "ws");
602
+ let spoolRoot = spoolDirFor(cfg);
603
+ try {
604
+ mkdirSync(spoolRoot, { recursive: true });
605
+ } catch {}
606
+ const sessions = new Map;
607
+ const childToParent = new Map;
608
+ const bootTime = Date.now();
609
+ const preExistingRetired = new Set;
610
+ const verifying = new Set;
611
+ let chain = Promise.resolve();
612
+ let lastUrlFile = null;
613
+ let urlFileAnnounced = false;
614
+ const newSt = (sid) => ({
615
+ sid,
616
+ client: deps.client,
617
+ queue: new Map,
618
+ timer: null,
619
+ metaTimer: null,
620
+ completedTimer: null,
621
+ lastFlushAt: 0,
622
+ statusEpoch: 0,
623
+ remotes: new Map,
624
+ status: "live",
625
+ badSignal: false,
626
+ lastSentStatus: null,
627
+ closed: false,
628
+ prefsSent: false,
629
+ binding: null,
630
+ bindingQueried: false,
631
+ optOuts: new Set,
632
+ opts: undefined,
633
+ activated: false,
634
+ wroteBinding: false,
635
+ everConnected: false
636
+ });
637
+ const refreshConfig = () => {
638
+ const next = loadConfig(deps.pluginDir, deps.options);
639
+ const resolved = resolveServer(next);
640
+ if (resolved?.url) {
641
+ cfg = next;
642
+ spoolRoot = spoolDirFor(cfg);
643
+ const before = new Set(targets.map((t) => t.url));
644
+ targets = resolveTargets();
645
+ seedCredentials();
646
+ clampToPolicy();
647
+ if (debugEvents) {
648
+ const after = targets.map((t) => t.url);
649
+ const added = after.filter((u) => !before.has(u));
650
+ if (added.length)
651
+ log(`fan-out set grew: ${added.join(", ")}`);
652
+ }
653
+ }
654
+ };
655
+ const activate = (sid, opts, info, opts2) => {
656
+ refreshConfig();
657
+ let st = sessions.get(sid);
658
+ if (!st) {
659
+ st = newSt(sid);
660
+ sessions.set(sid, st);
661
+ } else if (st.activated) {
662
+ if (opts)
663
+ st.opts = opts;
664
+ return st;
665
+ }
666
+ st.activated = true;
667
+ st.opts = opts;
668
+ const gated = mode === "manual" || opts2?.gated === true;
669
+ if (gated) {
670
+ chain = chain.then(() => seedManualStatus(st)).then(() => backfill(st, info)).then(() => queueConnectAll(st)).catch((e) => logError("activation error", e?.message || e));
671
+ } else {
672
+ queueConnectAll(st);
673
+ }
674
+ return st;
675
+ };
676
+ const seedManualStatus = async (st) => {
677
+ if (!st.client || typeof st.client.session.status !== "function")
678
+ return;
679
+ try {
680
+ const map = unwrap(await st.client.session.status({
681
+ ...deps.directory ? { query: { directory: deps.directory } } : {}
682
+ }));
683
+ const type = map && typeof map === "object" ? map[st.sid]?.type : undefined;
684
+ if (type === "busy" || type === "retry") {
685
+ st.status = "live";
686
+ if (type === "retry")
687
+ st.badSignal = true;
688
+ } else {
689
+ st.status = "completed";
690
+ }
691
+ } catch {}
692
+ };
693
+ const stFor = (sid) => {
694
+ const main = childToParent.get(sid) ?? sid;
695
+ return sessions.get(main) ?? null;
696
+ };
697
+ const unwrap = (res) => {
698
+ if (res && typeof res === "object" && "error" in res && res.error) {
699
+ throw new Error(`client error: ${JSON.stringify(res.error).slice(0, 120)}`);
700
+ }
701
+ return res?.data ?? res;
702
+ };
703
+ async function sessionInfo(client, sid) {
704
+ if (!client)
705
+ return;
706
+ try {
707
+ const info = unwrap(await client.session.get({
708
+ path: { id: sid },
709
+ ...deps.directory ? { query: { directory: deps.directory } } : {}
710
+ }));
711
+ return info;
712
+ } catch {
713
+ return;
714
+ }
715
+ }
716
+ async function writeMetadata(st, value) {
717
+ if (!st.client)
718
+ return;
719
+ try {
720
+ const info = unwrap(await st.client.session.get({
721
+ path: { id: st.sid },
722
+ ...deps.directory ? { query: { directory: deps.directory } } : {}
723
+ }));
724
+ const metadata = { ...info?.metadata ?? {} };
725
+ if (value === null)
726
+ delete metadata[METADATA_KEY];
727
+ else
728
+ metadata[METADATA_KEY] = value;
729
+ unwrap(await st.client.session.update({
730
+ path: { id: st.sid },
731
+ body: { metadata },
732
+ ...deps.directory ? { query: { directory: deps.directory } } : {}
733
+ }));
734
+ } catch (e) {
735
+ log("metadata write failed (spool still covers reconnects):", e?.message || e);
736
+ }
737
+ }
738
+ const parseBinding = (record) => {
739
+ const out = new Map;
740
+ if (record.servers && typeof record.servers === "object") {
741
+ for (const [url, raw] of Object.entries(record.servers)) {
742
+ if (!raw || typeof raw !== "object")
743
+ continue;
744
+ const e = raw;
745
+ if (typeof e.id === "string" && typeof e.secret === "string" && e.id && e.secret) {
746
+ out.set(url, { id: e.id, secret: e.secret, rev: typeof e.rev === "number" ? e.rev : 0 });
747
+ } else if (e.optedOut === true) {
748
+ out.set(url, { id: "", secret: "", rev: 0, optedOut: true });
749
+ }
750
+ }
751
+ return out;
752
+ }
753
+ if (typeof record.id === "string" && typeof record.secret === "string" && record.id) {
754
+ const url = targets[0]?.url ?? "";
755
+ if (url)
756
+ out.set(url, { id: record.id, secret: record.secret, rev: typeof record.rev === "number" ? record.rev : 0 });
757
+ }
758
+ return out;
759
+ };
760
+ async function readBinding(st) {
761
+ if (!st.client || st.bindingQueried)
762
+ return;
763
+ st.bindingQueried = true;
764
+ try {
765
+ const info = await sessionInfo(st.client, st.sid);
766
+ const b = info?.metadata?.[METADATA_KEY];
767
+ if (b && typeof b === "object") {
768
+ const record = b;
769
+ if (mode === "manual" && record.enabled !== true) {
770
+ return;
771
+ }
772
+ const found = parseBinding(record);
773
+ if (found.size) {
774
+ for (const [url, e] of found) {
775
+ if (e.optedOut)
776
+ continue;
777
+ const r = remoteFor(st, url);
778
+ if (!r.shareId) {
779
+ r.shareId = e.id;
780
+ r.secret = e.secret;
781
+ r.ackedRev = e.rev;
782
+ }
783
+ }
784
+ if (mode === "manual")
785
+ st.opts = shareFlag(info).opts ?? st.opts;
786
+ log(`binding recovered from session metadata: ${[...found.keys()].join(", ")}`);
787
+ }
788
+ }
789
+ } catch (e) {
790
+ log("metadata read failed (degrading to spool):", e?.message || e);
791
+ }
792
+ }
793
+ async function writeBinding(st) {
794
+ if (!st.client)
795
+ return;
796
+ const servers = {};
797
+ let any = false;
798
+ for (const [url, r] of st.remotes) {
799
+ if (!r.shareId || !r.secret)
800
+ continue;
801
+ any = true;
802
+ const entry = { id: r.shareId, secret: r.secret, rev: r.ackedRev, url: `${url}/s/${r.shareId}` };
803
+ if (r.collided)
804
+ entry.collided = r.collided;
805
+ servers[url] = entry;
806
+ }
807
+ if (!any)
808
+ return;
809
+ for (const url of st.optOuts)
810
+ if (!servers[url])
811
+ servers[url] = { optedOut: true };
812
+ const value = { v: 2, servers, enabled: true };
813
+ if (mode === "manual" && st.opts)
814
+ value.opts = st.opts;
815
+ st.binding = value;
816
+ st.wroteBinding = true;
817
+ await writeMetadata(st, value);
818
+ }
819
+ const scheduleBindingWrite = (st) => {
820
+ if (st.metaTimer)
821
+ return;
822
+ st.metaTimer = setTimeout(() => {
823
+ st.metaTimer = null;
824
+ chain = chain.then(() => writeBinding(st)).catch(() => {});
825
+ }, 30000);
826
+ };
827
+ const h8 = (url) => {
828
+ let h = 5381;
829
+ for (let i = 0;i < url.length; i++)
830
+ h = (h << 5) + h + url.charCodeAt(i) >>> 0;
831
+ return h.toString(16).padStart(8, "0");
832
+ };
833
+ const metaPath = (st, url) => join2(spoolRoot, `${st.sid}.${h8(url)}.meta.json`);
834
+ const queuePath = (st, url) => join2(spoolRoot, `${st.sid}.${h8(url)}.queue.jsonl`);
835
+ const legacyMetaPath = (st) => join2(spoolRoot, `${st.sid}.meta.json`);
836
+ const legacyQueuePath = (st) => join2(spoolRoot, `${st.sid}.queue.jsonl`);
837
+ const spoolSave = (st, r) => {
838
+ try {
839
+ writeFileSync2(metaPath(st, r.url), JSON.stringify({ shareId: r.shareId, secret: r.secret, ackedRev: r.ackedRev, sid: st.sid, url: r.url }));
840
+ writeFileSync2(queuePath(st, r.url), r.pending.map((f) => JSON.stringify(f)).join(`
841
+ `) + (r.pending.length ? `
842
+ ` : ""));
843
+ } catch (e) {
844
+ logError("spool save failed", e?.message || e);
845
+ }
846
+ };
847
+ const spoolLoad = (st, r) => {
848
+ try {
849
+ const meta = JSON.parse(readFileSync2(metaPath(st, r.url), "utf8"));
850
+ if (!r.shareId && meta.shareId) {
851
+ r.shareId = meta.shareId;
852
+ r.secret = meta.secret;
853
+ r.ackedRev = meta.ackedRev ?? 0;
854
+ }
855
+ if (existsSync2(queuePath(st, r.url))) {
856
+ r.pending = readFileSync2(queuePath(st, r.url), "utf8").split(`
857
+ `).filter((l) => l.trim()).map((l) => JSON.parse(l));
858
+ }
859
+ } catch {
860
+ r.pending = [];
861
+ }
862
+ if (!r.shareId && targets[0]?.url === r.url) {
863
+ try {
864
+ const meta = JSON.parse(readFileSync2(legacyMetaPath(st), "utf8"));
865
+ if (meta.shareId && !r.shareId) {
866
+ r.shareId = meta.shareId;
867
+ r.secret = meta.secret;
868
+ r.ackedRev = meta.ackedRev ?? 0;
869
+ if (existsSync2(legacyQueuePath(st))) {
870
+ r.pending = readFileSync2(legacyQueuePath(st), "utf8").split(`
871
+ `).filter((l) => l.trim()).map((l) => JSON.parse(l));
872
+ }
873
+ log(`migrated legacy spool for ${st.sid.slice(0, 18)} onto ${r.url}`);
874
+ }
875
+ } catch {}
876
+ }
877
+ };
878
+ const spoolClear = (st, r) => {
879
+ try {
880
+ rmSync(metaPath(st, r.url), { force: true });
881
+ rmSync(queuePath(st, r.url), { force: true });
882
+ } catch {}
883
+ };
884
+ const wsSend = (r, obj) => {
885
+ if (debugEvents)
886
+ log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send ${obj?.op} (ready=${r.wsReady})`);
887
+ try {
888
+ r.ws?.send(JSON.stringify(obj));
889
+ } catch {
890
+ return false;
891
+ }
892
+ return true;
893
+ };
894
+ const SYNC_CHUNK = 200;
895
+ const sendSync = (r, f) => {
896
+ if (!f.items.length)
897
+ return wsSend(r, { op: "sync", items: [], status: f.status });
898
+ for (let i = 0;i < f.items.length; i += SYNC_CHUNK) {
899
+ wsSend(r, { op: "sync", items: f.items.slice(i, i + SYNC_CHUNK), status: f.status });
900
+ }
901
+ return true;
902
+ };
903
+ const announceUrl = (st, r) => {
904
+ if (r.announced)
905
+ return;
906
+ r.announced = true;
907
+ const url = `${r.url}/s/${r.shareId}`;
908
+ if (cfg.announceStderr)
909
+ log(`live: ${url}`);
910
+ if (mode === "all" && cfg.urlFile !== URL_FILE_NONE) {
911
+ const file = cfg.urlFile || join2(deps.directory ?? process.cwd(), "openshare.urls.txt");
912
+ try {
913
+ appendFileSync(file, url + `
914
+ `);
915
+ if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
916
+ urlFileAnnounced = true;
917
+ lastUrlFile = file;
918
+ log(`url file: ${file}`);
919
+ }
920
+ } catch {}
921
+ }
922
+ };
923
+ const authFrame = (st) => {
924
+ const login = st.opts?.authLogin ?? cfg.authLogin ?? "";
925
+ const password = st.opts?.authPassword ?? cfg.authPassword ?? "";
926
+ return { authLogin: login, authPassword: password };
927
+ };
928
+ const ttlFor = (st) => st.opts?.ttlDays ?? cfg.ttlDays;
929
+ const deleteAbandoned = (r) => {
930
+ const id = r.shareId;
931
+ const secret = r.secret;
932
+ if (!id || !secret)
933
+ return;
934
+ fetch(`${r.url}/api/share/${id}`, {
935
+ method: "DELETE",
936
+ headers: { "content-type": "application/json" },
937
+ body: JSON.stringify({ secret }),
938
+ signal: AbortSignal.timeout(8000)
939
+ }).catch(() => {});
940
+ };
941
+ const queueConnect = (st, r) => {
942
+ if (st.closed || r.connectQueued || r.connecting || r.authBlocked || r.collided)
943
+ return;
944
+ r.connectQueued = true;
945
+ chain = chain.then(() => {
946
+ r.connectQueued = false;
947
+ return connect(st, r);
948
+ }).catch((e) => logError("connect error", e?.message || e));
949
+ };
950
+ const queueConnectAll = (st) => {
951
+ if (st.closed)
952
+ return;
953
+ for (const t of targets) {
954
+ if (st.optOuts.has(t.url))
955
+ continue;
956
+ queueConnect(st, remoteFor(st, t.url));
957
+ }
958
+ for (const r of st.remotes.values()) {
959
+ if (st.optOuts.has(r.url))
960
+ continue;
961
+ queueConnect(st, r);
962
+ }
963
+ };
964
+ const refreshOptOuts = async (st) => {
965
+ if (!st.client)
966
+ return;
967
+ try {
968
+ const info = await sessionInfo(st.client, st.sid);
969
+ const raw = info?.metadata?.[METADATA_KEY];
970
+ if (!raw || typeof raw !== "object")
971
+ return;
972
+ const servers = raw.servers;
973
+ if (!servers || typeof servers !== "object")
974
+ return;
975
+ const entryUrls = new Set;
976
+ for (const [url, e] of Object.entries(servers)) {
977
+ entryUrls.add(url);
978
+ const out = !!(e && typeof e === "object" && e.optedOut === true);
979
+ if (out)
980
+ st.optOuts.add(url);
981
+ else
982
+ st.optOuts.delete(url);
983
+ }
984
+ for (const url of [...st.optOuts]) {
985
+ const r = st.remotes.get(url);
986
+ if (!r)
987
+ continue;
988
+ if (debugEvents)
989
+ log(`remote ${url.slice(0, 24)} opted out for ${st.sid.slice(0, 18)} - retiring`);
990
+ r.intentionalClose = true;
991
+ try {
992
+ r.ws?.close(1000, "opted out");
993
+ } catch {}
994
+ spoolClear(st, r);
995
+ st.remotes.delete(url);
996
+ }
997
+ for (const r of [...st.remotes.values()]) {
998
+ if (!r.collided || st.optOuts.has(r.url))
999
+ continue;
1000
+ if (entryUrls.has(r.url))
1001
+ continue;
1002
+ if (debugEvents)
1003
+ log(`collision on ${r.url.slice(0, 24)} resolved - re-arming`);
1004
+ r.collided = null;
1005
+ r.shareId = null;
1006
+ r.secret = null;
1007
+ r.ackedRev = 0;
1008
+ r.pending = [];
1009
+ r.announced = false;
1010
+ queueConnect(st, r);
1011
+ }
1012
+ } catch {}
1013
+ };
1014
+ const connect = async (st, r) => {
1015
+ if (st.closed || r.connecting || r.authBlocked || r.collided)
1016
+ return;
1017
+ r.connecting = true;
1018
+ r.socketSeq += 1;
1019
+ const seq = r.socketSeq;
1020
+ if (r.ws) {
1021
+ const old = r.ws;
1022
+ try {
1023
+ old.close(4000, "superseded");
1024
+ } catch {}
1025
+ }
1026
+ try {
1027
+ await readBinding(st);
1028
+ spoolLoad(st, r);
1029
+ askPolicy(r.url);
1030
+ r.initWasAttach = !!(r.shareId && r.secret);
1031
+ if (debugEvents)
1032
+ log(`ws ${r.url.slice(0, 24)} #${seq} opening (${r.initWasAttach ? `attach ${r.shareId}` : "create"})`);
1033
+ const cred = credOf(r.url);
1034
+ const token = r.initWasAttach ? cred.credential : cred.credential || await ensureCredential(r.url);
1035
+ if (!r.initWasAttach && !token && cred.error) {
1036
+ r.authBlocked = true;
1037
+ r.connecting = false;
1038
+ r.ws = null;
1039
+ logError(`sharing to ${r.url} disabled (no credential): ${cred.error}`);
1040
+ return;
1041
+ }
1042
+ const ws = new WebSocket(`${wsUrl(r.url)}/api/ws?token=${encodeURIComponent(token ?? "")}`);
1043
+ r.ws = ws;
1044
+ await new Promise((resolve, reject) => {
1045
+ const t = setTimeout(() => reject(new Error("ws open timeout")), 15000);
1046
+ ws.addEventListener("open", () => {
1047
+ clearTimeout(t);
1048
+ resolve(undefined);
1049
+ }, { once: true });
1050
+ ws.addEventListener("error", () => {
1051
+ clearTimeout(t);
1052
+ reject(new Error("ws open error"));
1053
+ }, { once: true });
1054
+ });
1055
+ ws.addEventListener("message", (ev) => {
1056
+ if (seq !== r.socketSeq)
1057
+ return;
1058
+ let frame;
1059
+ try {
1060
+ frame = JSON.parse(ev.data);
1061
+ } catch {
1062
+ return;
1063
+ }
1064
+ handleFrame(st, r, frame).catch((e) => logError("frame error", e?.message || e));
1065
+ });
1066
+ ws.addEventListener("close", () => {
1067
+ if (seq !== r.socketSeq)
1068
+ return;
1069
+ r.wsReady = false;
1070
+ r.connecting = false;
1071
+ if (r.intentionalClose) {
1072
+ r.intentionalClose = false;
1073
+ return;
1074
+ }
1075
+ if (!st.closed)
1076
+ scheduleReconnect(st, r);
1077
+ });
1078
+ if (r.initWasAttach) {
1079
+ wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } });
1080
+ } else {
1081
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } });
1082
+ }
1083
+ r.initTimer = setTimeout(() => {
1084
+ if (seq !== r.socketSeq)
1085
+ return;
1086
+ logError(`ws ${r.url.slice(0, 24)} #${seq} init ack timeout`);
1087
+ r.connecting = false;
1088
+ try {
1089
+ ws.close(4000, "init timeout");
1090
+ } catch {}
1091
+ if (!st.closed)
1092
+ scheduleReconnect(st, r);
1093
+ }, 20000);
1094
+ } catch (e) {
1095
+ r.connecting = false;
1096
+ logError("socket failed:", e?.message || e, "- falling back to HTTP, will retry");
1097
+ r.wsReady = false;
1098
+ flushViaHttp(st, r).catch(() => {});
1099
+ scheduleReconnect(st, r);
1100
+ return;
1101
+ }
1102
+ };
1103
+ const scheduleReconnect = (st, r) => {
1104
+ if (st.closed || r.reconnectTimer)
1105
+ return;
1106
+ const wait = r.backoff;
1107
+ r.backoff = Math.min(r.backoff * 2, 60000);
1108
+ r.reconnectTimer = setTimeout(() => {
1109
+ r.reconnectTimer = null;
1110
+ queueConnect(st, r);
1111
+ }, wait);
1112
+ };
1113
+ const dropSocket = (st, r, why) => {
1114
+ if (debugEvents)
1115
+ log(`turn: drop socket (${why}) ${r.url.slice(0, 24)} sid=${st.sid.slice(0, 18)}`);
1116
+ r.wsReady = false;
1117
+ r.intentionalClose = true;
1118
+ try {
1119
+ r.ws?.close(1000, why);
1120
+ } catch {}
1121
+ r.ws = null;
1122
+ };
1123
+ const cancelSettleDrop = (r) => {
1124
+ if (r.settleTimer) {
1125
+ clearTimeout(r.settleTimer);
1126
+ r.settleTimer = null;
1127
+ }
1128
+ };
1129
+ const armSettleDrop = (st) => {
1130
+ for (const r of st.remotes.values()) {
1131
+ const p = policyOf(r.url);
1132
+ if (p.mode !== "turn" || p.turnGraceMs <= 0)
1133
+ continue;
1134
+ cancelSettleDrop(r);
1135
+ r.settleTimer = setTimeout(() => {
1136
+ r.settleTimer = null;
1137
+ if (st.closed || !r.ws)
1138
+ return;
1139
+ dropSocket(st, r, "turn end");
1140
+ }, p.turnGraceMs);
1141
+ }
1142
+ };
1143
+ const armIdleDrop = (st) => {
1144
+ for (const r of st.remotes.values()) {
1145
+ const p = policyOf(r.url);
1146
+ if (p.mode !== "turn" || p.idleDropMs <= 0)
1147
+ continue;
1148
+ if (r.idleDropTimer)
1149
+ clearTimeout(r.idleDropTimer);
1150
+ r.idleDropTimer = setTimeout(() => {
1151
+ r.idleDropTimer = null;
1152
+ if (st.closed || !r.ws)
1153
+ return;
1154
+ dropSocket(st, r, "idle timeout");
1155
+ }, p.idleDropMs);
1156
+ }
1157
+ };
1158
+ const touch = (st) => {
1159
+ for (const r of st.remotes.values())
1160
+ cancelSettleDrop(r);
1161
+ armIdleDrop(st);
1162
+ };
1163
+ let lastTargetsCheck = 0;
1164
+ const ensureConnected = (st) => {
1165
+ if (st.closed)
1166
+ return;
1167
+ if (Date.now() - lastTargetsCheck > 5000) {
1168
+ lastTargetsCheck = Date.now();
1169
+ refreshConfig();
1170
+ }
1171
+ for (const t of targets) {
1172
+ if (st.optOuts.has(t.url))
1173
+ continue;
1174
+ const r = remoteFor(st, t.url);
1175
+ if (r.connecting || r.reconnectTimer || r.wsReady || r.authBlocked || r.collided)
1176
+ continue;
1177
+ queueConnect(st, r);
1178
+ }
1179
+ for (const r of st.remotes.values()) {
1180
+ if (st.optOuts.has(r.url))
1181
+ continue;
1182
+ if (r.connecting || r.reconnectTimer || r.wsReady || r.authBlocked || r.collided)
1183
+ continue;
1184
+ queueConnect(st, r);
1185
+ }
1186
+ };
1187
+ const handleFrame = async (st, r, frame) => {
1188
+ if (frame.op === "init") {
1189
+ if (!frame.ok) {
1190
+ const err = String(frame.error ?? "");
1191
+ if (/frozen/i.test(err)) {
1192
+ r.authBlocked = true;
1193
+ logError(`${r.url}: share frozen by the hoster (owner frozen) - edits refused; contact the server admin`);
1194
+ try {
1195
+ r.ws?.close(4000, "frozen");
1196
+ } catch {}
1197
+ return;
1198
+ }
1199
+ if (/forbidden|bad secret/i.test(err) && r.initWasAttach && r.secret) {
1200
+ r.collided = err;
1201
+ r.connecting = false;
1202
+ logError(`${r.url}: attach refused (${err}) - credential collision on share ${r.shareId}; not auto-recreating`);
1203
+ try {
1204
+ r.ws?.close(4000, "collision");
1205
+ } catch {}
1206
+ st.wroteBinding = false;
1207
+ chain = chain.then(() => writeBinding(st)).catch(() => {});
1208
+ if (mode === "all")
1209
+ resolveCollision(st, r);
1210
+ return;
1211
+ }
1212
+ if (/token|credential/i.test(err) && !r.initWasAttach) {
1213
+ const cred = credOf(r.url);
1214
+ if (cred.credential) {
1215
+ cred.credential = "";
1216
+ logError(`${r.url}: server rejected our token - re-enrolling`);
1217
+ }
1218
+ try {
1219
+ r.ws?.close(4000, "auth");
1220
+ } catch {}
1221
+ r.connecting = false;
1222
+ if (!st.closed)
1223
+ queueConnect(st, r);
1224
+ return;
1225
+ }
1226
+ logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
1227
+ deleteAbandoned(r);
1228
+ r.shareId = null;
1229
+ r.secret = null;
1230
+ r.ackedRev = 0;
1231
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } });
1232
+ return;
1233
+ }
1234
+ if (r.initWasAttach && typeof frame.rev === "number" && frame.rev < r.ackedRev) {
1235
+ logError(`${r.url}: mismatch: worker rev ${frame.rev} < acked ${r.ackedRev} - recreating`);
1236
+ deleteAbandoned(r);
1237
+ r.shareId = null;
1238
+ r.secret = null;
1239
+ r.ackedRev = 0;
1240
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } });
1241
+ return;
1242
+ }
1243
+ const wasFanoutCreate = !r.initWasAttach && st.everConnected;
1244
+ const shareChanged = !!frame.id && frame.id !== r.shareId;
1245
+ if (shareChanged)
1246
+ r.announced = false;
1247
+ r.shareId = frame.id ?? r.shareId;
1248
+ r.secret = frame.secret ?? r.secret;
1249
+ r.ackedRev = frame.rev ?? 0;
1250
+ r.wsReady = true;
1251
+ r.connecting = false;
1252
+ r.initWasAttach = false;
1253
+ r.collided = null;
1254
+ if (r.initTimer) {
1255
+ clearTimeout(r.initTimer);
1256
+ r.initTimer = null;
1257
+ }
1258
+ r.backoff = 1000;
1259
+ st.everConnected = true;
1260
+ if (frame.policy)
1261
+ applyPolicy(frame.policy, r.url);
1262
+ r.intentionalClose = false;
1263
+ touch(st);
1264
+ announceUrl(st, r);
1265
+ if (!st.wroteBinding) {
1266
+ chain = chain.then(() => writeBinding(st)).catch(() => {});
1267
+ } else {
1268
+ scheduleBindingWrite(st);
1269
+ }
1270
+ spoolSave(st, r);
1271
+ if (r.pending.length) {
1272
+ for (const f of r.pending)
1273
+ sendSync(r, f);
1274
+ } else if (st.queue.size) {
1275
+ flush(st);
1276
+ }
1277
+ if (wasFanoutCreate) {
1278
+ chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", e?.message || e));
1279
+ }
1280
+ return;
1281
+ }
1282
+ if (frame.op === "ack") {
1283
+ r.ackedRev = frame.rev ?? r.ackedRev;
1284
+ r.pending = [];
1285
+ spoolSave(st, r);
1286
+ scheduleBindingWrite(st);
1287
+ return;
1288
+ }
1289
+ if (frame.op === "nack" || frame.op === "error") {
1290
+ logError(`${r.url}: worker refused frame:`, JSON.stringify(frame).slice(0, 200));
1291
+ if (frame.op === "error" && r.wsReady && !st.closed) {
1292
+ r.wsReady = false;
1293
+ try {
1294
+ r.ws?.close();
1295
+ } catch {}
1296
+ scheduleReconnect(st, r);
1297
+ }
1298
+ }
1299
+ };
1300
+ const resolveCollision = (st, r) => {
1301
+ logError(`collision debug: server=${r.url} share=${r.shareId} rev=${r.ackedRev} sid=${st.sid}`);
1302
+ deleteAbandoned(r);
1303
+ r.collided = null;
1304
+ r.shareId = null;
1305
+ r.secret = null;
1306
+ r.ackedRev = 0;
1307
+ r.pending = [];
1308
+ r.announced = false;
1309
+ queueConnect(st, r);
1310
+ };
1311
+ const frameFrom = (st) => {
1312
+ const items = Array.from(st.queue.values());
1313
+ st.queue.clear();
1314
+ const statusChanged = st.status !== st.lastSentStatus;
1315
+ if (!items.length && !statusChanged) {
1316
+ if (debugEvents)
1317
+ log(`flush skip (no items, status ${st.status}==${st.lastSentStatus})`);
1318
+ return null;
1319
+ }
1320
+ st.lastSentStatus = st.status;
1321
+ if (debugEvents)
1322
+ log(`frame: ${items.length} items, status ${st.status}`);
1323
+ return { items, status: st.status };
1324
+ };
1325
+ const flush = (st) => {
1326
+ if (st.closed)
1327
+ return;
1328
+ const f = frameFrom(st);
1329
+ if (!f)
1330
+ return;
1331
+ let httpWork;
1332
+ for (const r of st.remotes.values()) {
1333
+ r.pending.push(f);
1334
+ spoolSave(st, r);
1335
+ if (r.ws && !r.wsReady)
1336
+ continue;
1337
+ if (r.wsReady) {
1338
+ sendSync(r, f);
1339
+ continue;
1340
+ }
1341
+ const viaHttp = flushViaHttp(st, r);
1342
+ if (viaHttp)
1343
+ httpWork = (httpWork ?? Promise.resolve()).then(() => viaHttp);
1344
+ }
1345
+ if (st.remotes.size)
1346
+ st.lastFlushAt = Date.now();
1347
+ return httpWork;
1348
+ };
1349
+ const flushViaHttp = async (st, r) => {
1350
+ if (!r.shareId || !r.pending.length)
1351
+ return;
1352
+ const items = [];
1353
+ let status;
1354
+ for (const f of r.pending) {
1355
+ for (const it of f.items)
1356
+ items.push(it);
1357
+ if (f.status)
1358
+ status = f.status;
1359
+ }
1360
+ const cred = credOf(r.url);
1361
+ const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
1362
+ method: "POST",
1363
+ headers: { "content-type": "application/json", ...cred.credential ? { authorization: `Bearer ${cred.credential}` } : {} },
1364
+ body: JSON.stringify({ secret: r.secret ?? "", data: items, status }),
1365
+ signal: AbortSignal.timeout(15000)
1366
+ }).catch(() => null);
1367
+ if (res && res.ok) {
1368
+ const body = await res.json().catch(() => ({}));
1369
+ r.ackedRev = body.rev ?? r.ackedRev;
1370
+ r.pending = [];
1371
+ spoolSave(st, r);
1372
+ }
1373
+ };
1374
+ const scheduleFlush = (st) => {
1375
+ if (st.timer)
1376
+ return;
1377
+ const elapsed = Date.now() - st.lastFlushAt;
1378
+ const wait = Math.max(0, cfg.debounceMs - elapsed);
1379
+ st.timer = setTimeout(() => {
1380
+ st.timer = null;
1381
+ if (debugEvents)
1382
+ log(`flush timer fired sid=${st.sid.slice(0, 18)} queue=${st.queue.size} remotes=${st.remotes.size}`);
1383
+ chain = chain.then(() => flush(st)).catch((e) => logError("flush error", e?.message || e));
1384
+ }, wait);
1385
+ };
1386
+ const enqueue = (st, entry) => {
1387
+ if (!st || !entry)
1388
+ return;
1389
+ st.queue.set(entry.key, entry.item);
1390
+ if (debugEvents)
1391
+ log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`);
1392
+ touch(st);
1393
+ ensureConnected(st);
1394
+ scheduleFlush(st);
1395
+ };
1396
+ const remove = (st) => {
1397
+ st.closed = true;
1398
+ st.activated = false;
1399
+ if (st.timer)
1400
+ clearTimeout(st.timer);
1401
+ if (st.metaTimer)
1402
+ clearTimeout(st.metaTimer);
1403
+ if (st.completedTimer) {
1404
+ clearTimeout(st.completedTimer);
1405
+ st.completedTimer = null;
1406
+ }
1407
+ for (const r of st.remotes.values()) {
1408
+ r.connecting = false;
1409
+ r.connectQueued = false;
1410
+ if (r.initTimer) {
1411
+ clearTimeout(r.initTimer);
1412
+ r.initTimer = null;
1413
+ }
1414
+ if (r.reconnectTimer) {
1415
+ clearTimeout(r.reconnectTimer);
1416
+ r.reconnectTimer = null;
1417
+ }
1418
+ cancelSettleDrop(r);
1419
+ if (r.idleDropTimer) {
1420
+ clearTimeout(r.idleDropTimer);
1421
+ r.idleDropTimer = null;
1422
+ }
1423
+ if (r.wsReady)
1424
+ wsSend(r, { op: "delete" });
1425
+ else if (r.shareId && r.secret) {
1426
+ fetch(`${r.url}/api/share/${r.shareId}`, {
1427
+ method: "DELETE",
1428
+ headers: { "content-type": "application/json" },
1429
+ body: JSON.stringify({ secret: r.secret }),
1430
+ signal: AbortSignal.timeout(8000)
1431
+ }).catch(() => {});
1432
+ }
1433
+ }
1434
+ setTimeout(() => {
1435
+ for (const r of st.remotes.values()) {
1436
+ try {
1437
+ r.ws?.close();
1438
+ } catch {}
1439
+ }
1440
+ }, 1000);
1441
+ for (const r of st.remotes.values())
1442
+ spoolClear(st, r);
1443
+ sessions.delete(st.sid);
1444
+ try {
1445
+ chain = chain.then(async () => {
1446
+ if (!st.client)
1447
+ return;
1448
+ const res = await st.client.session.get({
1449
+ path: { id: st.sid },
1450
+ ...deps.directory ? { query: { directory: deps.directory } } : {}
1451
+ }).catch(() => null);
1452
+ const info = res?.data ?? res;
1453
+ if (info?.metadata?.[METADATA_KEY]) {
1454
+ const metadata = { ...info.metadata };
1455
+ delete metadata[METADATA_KEY];
1456
+ await st.client.session.update({
1457
+ path: { id: st.sid },
1458
+ body: { metadata },
1459
+ ...deps.directory ? { query: { directory: deps.directory } } : {}
1460
+ }).catch(() => {});
1461
+ }
1462
+ });
1463
+ } catch {}
1464
+ };
1465
+ const PAGE_SIZE = 100;
1466
+ const MAX_PAGES = 50;
1467
+ const query = () => deps.directory ? { directory: deps.directory } : {};
1468
+ async function fetchMessages(client, sid) {
1469
+ if (typeof client.session.messages !== "function")
1470
+ return [];
1471
+ const out = [];
1472
+ let before;
1473
+ for (let page = 0;page < MAX_PAGES; page++) {
1474
+ const q = { ...query(), limit: PAGE_SIZE };
1475
+ if (before !== undefined)
1476
+ q.before = before;
1477
+ const result = unwrap(await client.session.messages({
1478
+ path: { id: sid },
1479
+ query: q
1480
+ }));
1481
+ if (!Array.isArray(result) || result.length === 0)
1482
+ break;
1483
+ out.push(...result);
1484
+ if (result.length < PAGE_SIZE)
1485
+ break;
1486
+ const last = result[result.length - 1];
1487
+ const nextBefore = typeof last?.info?.id === "string" ? last.info.id : typeof last?.id === "string" ? last.id : undefined;
1488
+ if (!nextBefore || nextBefore === before)
1489
+ break;
1490
+ before = nextBefore;
1491
+ }
1492
+ return out;
1493
+ }
1494
+ const backfillSession = async (st, client, sid, info) => {
1495
+ const sessionInfoValue = info ?? await sessionInfo(client, sid);
1496
+ if (sessionInfoValue)
1497
+ enqueue(st, itemFor("session", redact(sessionInfoValue), sid));
1498
+ const messages = await fetchMessages(client, sid);
1499
+ let modelSeen = false;
1500
+ for (const message of messages) {
1501
+ const m = message.info ?? message;
1502
+ if (!m || typeof m.id !== "string")
1503
+ continue;
1504
+ enqueue(st, itemFor("message", m, sid));
1505
+ if (m.role === "user" && m.model) {
1506
+ modelSeen = true;
1507
+ const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model);
1508
+ enqueue(st, itemFor("model", [{ id: m.model.modelID, providerID: m.model.providerID, name: label }], sid));
1509
+ }
1510
+ for (const part of message.parts ?? []) {
1511
+ const p = part;
1512
+ if (!p || typeof p.id !== "string" || typeof p.messageID !== "string")
1513
+ continue;
1514
+ if (p.state?.status === "error")
1515
+ st.badSignal = true;
1516
+ enqueue(st, itemFor("part", p, sid));
1517
+ }
1518
+ }
1519
+ if (!modelSeen && sessionInfoValue?.model) {
1520
+ const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, sessionInfoValue.model);
1521
+ enqueue(st, itemFor("model", [{ id: sessionInfoValue.model.modelID, providerID: sessionInfoValue.model.providerID, name: label }], sid));
1522
+ }
1523
+ return messages;
1524
+ };
1525
+ const backfill = async (st, info) => {
1526
+ const client = st.client;
1527
+ if (!client)
1528
+ return;
1529
+ const messages = await backfillSession(st, client, st.sid, info);
1530
+ if (messages.length === 0 && !info) {
1531
+ const fetched = await sessionInfo(client, st.sid);
1532
+ if (fetched)
1533
+ enqueue(st, itemFor("session", redact(fetched), st.sid));
1534
+ }
1535
+ if (typeof client.session.list === "function") {
1536
+ try {
1537
+ const result = unwrap(await client.session.list({ query: query() }));
1538
+ if (Array.isArray(result)) {
1539
+ for (const raw of result) {
1540
+ if (!raw || typeof raw.id !== "string")
1541
+ continue;
1542
+ if (raw.parentID !== st.sid)
1543
+ continue;
1544
+ childToParent.set(raw.id, st.sid);
1545
+ await backfillSession(st, client, raw.id, raw);
1546
+ }
1547
+ }
1548
+ } catch (e) {
1549
+ log("backfill child list failed:", e?.message || e);
1550
+ }
1551
+ }
1552
+ if (typeof client.session.diff === "function") {
1553
+ try {
1554
+ const diff = unwrap(await client.session.diff({
1555
+ path: { id: st.sid },
1556
+ query: query()
1557
+ }));
1558
+ if (diff !== undefined)
1559
+ enqueue(st, itemFor("session_diff", diff, st.sid));
1560
+ } catch {}
1561
+ }
1562
+ if (st.status === "completed" && st.badSignal)
1563
+ st.status = "error";
1564
+ };
1565
+ const debugEvents = !!process.env.OPENSHARE_DEBUG_EVENTS;
1566
+ const trackChild = (sid, info) => {
1567
+ if (info?.parentID) {
1568
+ const parent = childToParent.get(sid) ?? info.parentID;
1569
+ childToParent.set(sid, parent);
1570
+ return parent;
1571
+ }
1572
+ return null;
1573
+ };
1574
+ const adoptStrayChild = (childSid, parentSid) => {
1575
+ const stray = sessions.get(childSid);
1576
+ const parent = sessions.get(parentSid);
1577
+ if (!stray || stray.closed)
1578
+ return;
1579
+ if (parent && parent !== stray) {
1580
+ for (const [key, item] of stray.queue)
1581
+ parent.queue.set(key, item);
1582
+ stray.queue.clear();
1583
+ for (const [url, sr] of stray.remotes) {
1584
+ const pr = remoteFor(parent, url);
1585
+ if (!pr.shareId && sr.shareId) {
1586
+ pr.shareId = sr.shareId;
1587
+ pr.secret = sr.secret;
1588
+ pr.ackedRev = sr.ackedRev;
1589
+ }
1590
+ for (const frame of sr.pending)
1591
+ pr.pending.push(frame);
1592
+ sr.pending = [];
1593
+ sr.shareId = null;
1594
+ sr.secret = null;
1595
+ }
1596
+ scheduleFlush(parent);
1597
+ }
1598
+ for (const sr of stray.remotes.values()) {
1599
+ if (sr.shareId)
1600
+ log(`stray child share ${sr.shareId} (${sr.url}) -> merging into ${parentSid}`);
1601
+ }
1602
+ remove(stray);
1603
+ };
1604
+ const handleSessionEvent = (t, sid, info) => {
1605
+ const parent = trackChild(sid, info);
1606
+ if (mode === "manual") {
1607
+ if (parent) {
1608
+ const st3 = sessions.get(parent);
1609
+ if (st3?.activated && !st3.closed) {
1610
+ if (!st3.prefsSent) {
1611
+ st3.prefsSent = true;
1612
+ enqueue(st3, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
1613
+ }
1614
+ if (info)
1615
+ enqueue(st3, itemFor("session", redact(info), sid));
1616
+ }
1617
+ return;
1618
+ }
1619
+ const flag = shareFlag(info);
1620
+ const st2 = sessions.get(sid);
1621
+ if (flag.enabled) {
1622
+ if (st2?.activated && !st2.closed) {
1623
+ if (flag.opts)
1624
+ st2.opts = flag.opts;
1625
+ if (!st2.prefsSent) {
1626
+ st2.prefsSent = true;
1627
+ enqueue(st2, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
1628
+ }
1629
+ if (info)
1630
+ enqueue(st2, itemFor("session", redact(info), sid));
1631
+ if (t === "session.updated")
1632
+ chain = chain.then(() => refreshOptOuts(st2)).catch(() => {});
1633
+ return;
1634
+ }
1635
+ activate(sid, flag.opts, info);
1636
+ const fresh = sessions.get(sid);
1637
+ if (fresh && !fresh.prefsSent) {
1638
+ fresh.prefsSent = true;
1639
+ enqueue(fresh, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
1640
+ }
1641
+ return;
1642
+ }
1643
+ if (st2?.activated && !st2.closed && t === "session.updated") {
1644
+ log(`share disabled for ${sid} - removing`);
1645
+ chain = chain.then(() => remove(st2)).catch((e) => logError("remove error", e?.message || e));
1646
+ return;
1647
+ }
1648
+ return;
1649
+ }
1650
+ const main = parent ?? sid;
1651
+ if (parent && parent !== sid && sessions.has(sid)) {
1652
+ if (!sessions.has(main)) {
1653
+ const created = newSt(main);
1654
+ created.activated = true;
1655
+ sessions.set(main, created);
1656
+ queueConnectAll(created);
1657
+ }
1658
+ adoptStrayChild(sid, main);
1659
+ }
1660
+ let st = sessions.get(main);
1661
+ if (!st && !parent) {
1662
+ const flag = shareFlag(info);
1663
+ const hasBinding = !!info?.metadata?.[METADATA_KEY];
1664
+ const createdMs = info?.time?.created;
1665
+ const isPreExisting = typeof createdMs === "number" && createdMs < bootTime;
1666
+ if (isPreExisting && !flag.enabled && !hasBinding) {
1667
+ if (!preExistingRetired.has(main)) {
1668
+ preExistingRetired.add(main);
1669
+ log(`session ${main} predates plugin start - not shared (set the share flag to share it)`);
1670
+ }
1671
+ return;
1672
+ }
1673
+ if (flag.enabled) {
1674
+ activate(main, flag.opts, info, { gated: true });
1675
+ st = sessions.get(main);
1676
+ } else {
1677
+ const created = newSt(main);
1678
+ created.activated = true;
1679
+ sessions.set(main, created);
1680
+ st = created;
1681
+ queueConnectAll(created);
1682
+ }
1683
+ }
1684
+ if (!st)
1685
+ return;
1686
+ if (!st.prefsSent) {
1687
+ st.prefsSent = true;
1688
+ enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
1689
+ }
1690
+ if (info)
1691
+ enqueue(st, itemFor("session", redact(info), sid));
1692
+ if (t === "session.updated" && st.activated && !st.closed) {
1693
+ chain = chain.then(() => refreshOptOuts(st)).catch(() => {});
1694
+ }
1695
+ };
1696
+ return {
1697
+ mode,
1698
+ config: () => cfg,
1699
+ hooks: {
1700
+ event: async ({ event }) => {
1701
+ const t = event?.type;
1702
+ const p = event?.properties || {};
1703
+ const sid = p.sessionID;
1704
+ if (!sid)
1705
+ return;
1706
+ if (debugEvents) {
1707
+ const par = p.info?.parentID;
1708
+ log(`event: ${t} ${sid}${par ? ` (child of ${par})` : ""}`);
1709
+ }
1710
+ if (t === "session.created" || t === "session.updated") {
1711
+ handleSessionEvent(t, sid, p.info);
1712
+ } else if (t === "session.status") {
1713
+ const st = stFor(sid);
1714
+ if (!st)
1715
+ return;
1716
+ const type = p.status?.type;
1717
+ touch(st);
1718
+ if (type === "busy") {
1719
+ st.statusEpoch++;
1720
+ if (st.completedTimer) {
1721
+ clearTimeout(st.completedTimer);
1722
+ st.completedTimer = null;
1723
+ }
1724
+ st.badSignal = false;
1725
+ st.status = "live";
1726
+ ensureConnected(st);
1727
+ scheduleFlush(st);
1728
+ } else if (type === "idle") {
1729
+ if (st.timer)
1730
+ clearTimeout(st.timer);
1731
+ st.timer = null;
1732
+ chain = chain.then(() => flush(st)).catch((e) => logError("idle flush error", e?.message || e));
1733
+ const epoch = st.statusEpoch;
1734
+ const settle = () => {
1735
+ st.status = st.badSignal ? "error" : "completed";
1736
+ st.badSignal = false;
1737
+ if (st.timer)
1738
+ clearTimeout(st.timer);
1739
+ st.timer = null;
1740
+ chain = chain.then(() => flush(st)).then(() => {
1741
+ armSettleDrop(st);
1742
+ }).catch((e) => logError("settle flush error", e?.message || e));
1743
+ };
1744
+ if (cfg.completedDelayMs <= 0) {
1745
+ settle();
1746
+ } else if (!st.completedTimer && !st.closed) {
1747
+ st.completedTimer = setTimeout(() => {
1748
+ st.completedTimer = null;
1749
+ if (st.closed || st.statusEpoch !== epoch)
1750
+ return;
1751
+ settle();
1752
+ }, cfg.completedDelayMs);
1753
+ }
1754
+ } else if (type === "retry") {
1755
+ st.badSignal = true;
1756
+ }
1757
+ } else if (t === "session.error") {
1758
+ const st = stFor(sid);
1759
+ if (st) {
1760
+ st.badSignal = true;
1761
+ touch(st);
1762
+ }
1763
+ } else if (t === "message.removed" || t === "message.part.removed") {
1764
+ const st = stFor(sid);
1765
+ if (!st || !st.activated || st.closed)
1766
+ return;
1767
+ const messageID = p.messageID;
1768
+ if (!messageID)
1769
+ return;
1770
+ if (t === "message.removed") {
1771
+ enqueue(st, { key: `message/${messageID}`, item: { type: "message", del: true, data: { id: messageID } } });
1772
+ } else {
1773
+ const partID = p.partID;
1774
+ if (!partID)
1775
+ return;
1776
+ enqueue(st, { key: `part/${messageID}/${partID}`, item: { type: "part", del: true, data: { id: partID, messageID } } });
1777
+ }
1778
+ } else if (t === "message.updated") {
1779
+ const main = childToParent.get(sid) ?? sid;
1780
+ let st = sessions.get(main);
1781
+ if (!st && mode === "all" && !preExistingRetired.has(main)) {
1782
+ const created = newSt(main);
1783
+ created.activated = true;
1784
+ sessions.set(main, created);
1785
+ st = created;
1786
+ if (!verifying.has(main)) {
1787
+ verifying.add(main);
1788
+ chain = chain.then(async () => {
1789
+ if (created.closed)
1790
+ return;
1791
+ const info2 = await sessionInfo(created.client, main);
1792
+ verifying.delete(main);
1793
+ if (created.closed)
1794
+ return;
1795
+ const flag = shareFlag(info2);
1796
+ const hasBinding = !!info2?.metadata?.[METADATA_KEY];
1797
+ const createdMs = info2?.time?.created;
1798
+ const isPreExisting = typeof createdMs === "number" && createdMs < bootTime;
1799
+ if (debugEvents) {
1800
+ log(`verify ${main}: created=${createdMs} boot=${bootTime} pre=${isPreExisting} flag=${flag.enabled} binding=${hasBinding} infoMissing=${!info2}`);
1801
+ }
1802
+ if (isPreExisting && !flag.enabled && !hasBinding) {
1803
+ preExistingRetired.add(main);
1804
+ log(`session ${main} predates plugin start - not shared (set the share flag to share it)`);
1805
+ remove(created);
1806
+ return;
1807
+ }
1808
+ queueConnectAll(created);
1809
+ }).catch((e) => {
1810
+ verifying.delete(main);
1811
+ logError("verify error", e?.message || e);
1812
+ });
1813
+ }
1814
+ }
1815
+ if (!st || !st.activated || st.closed)
1816
+ return;
1817
+ enqueue(st, itemFor("message", p.info, sid));
1818
+ const info = p.info;
1819
+ if (info?.role === "user" && info?.model) {
1820
+ const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model);
1821
+ enqueue(st, itemFor("model", [{ id: info.model.modelID, providerID: info.model.providerID, name: label }], sid));
1822
+ }
1823
+ if (info?.role === "assistant" && info.finish === "error") {
1824
+ st.badSignal = true;
1825
+ }
1826
+ } else if (t === "message.part.updated") {
1827
+ const st = stFor(sid);
1828
+ if (!st || !st.activated || st.closed)
1829
+ return;
1830
+ const part = p.part;
1831
+ if (part?.state?.status === "error")
1832
+ st.badSignal = true;
1833
+ enqueue(st, itemFor("part", part, sid));
1834
+ } else if (t === "session.diff") {
1835
+ const st = stFor(sid);
1836
+ if (!st || !st.activated || st.closed)
1837
+ return;
1838
+ enqueue(st, itemFor("session_diff", p.diff, sid));
1839
+ } else if (t === "session.deleted") {
1840
+ const isChild = childToParent.has(sid);
1841
+ if (isChild) {
1842
+ if (debugEvents)
1843
+ log("event: child session.deleted (ignored)", sid);
1844
+ return;
1845
+ }
1846
+ const st = sessions.get(sid);
1847
+ if (!st)
1848
+ return;
1849
+ chain = chain.then(() => remove(st)).catch((e) => logError("remove error", e?.message || e));
1850
+ }
1851
+ },
1852
+ dispose: async () => {
1853
+ for (const st of sessions.values()) {
1854
+ if (st.completedTimer) {
1855
+ clearTimeout(st.completedTimer);
1856
+ st.completedTimer = null;
1857
+ st.status = st.badSignal ? "error" : "completed";
1858
+ st.badSignal = false;
1859
+ }
1860
+ if (st.timer)
1861
+ clearTimeout(st.timer);
1862
+ st.timer = null;
1863
+ for (const r of st.remotes.values()) {
1864
+ if (r.initTimer) {
1865
+ clearTimeout(r.initTimer);
1866
+ r.initTimer = null;
1867
+ }
1868
+ cancelSettleDrop(r);
1869
+ if (r.idleDropTimer) {
1870
+ clearTimeout(r.idleDropTimer);
1871
+ r.idleDropTimer = null;
1872
+ }
1873
+ }
1874
+ const flushed = flush(st);
1875
+ if (flushed && typeof flushed.then === "function")
1876
+ await flushed.catch(() => {});
1877
+ for (const r of st.remotes.values()) {
1878
+ if (!r.wsReady && r.shareId && r.pending.length) {
1879
+ await flushViaHttp(st, r).catch(() => {});
1880
+ }
1881
+ }
1882
+ if (st.metaTimer)
1883
+ clearTimeout(st.metaTimer);
1884
+ st.metaTimer = null;
1885
+ await writeBinding(st).catch(() => {});
1886
+ }
1887
+ for (const st of sessions.values()) {
1888
+ for (const r of st.remotes.values()) {
1889
+ try {
1890
+ r.ws?.close();
1891
+ } catch {}
1892
+ }
1893
+ }
1894
+ }
1895
+ }
1896
+ };
1897
+ }
1898
+
1899
+ // src/v2-server.ts
1900
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
1901
+ import { join as join3 } from "node:path";
1902
+ function toolContentText(content) {
1903
+ if (typeof content === "string")
1904
+ return content;
1905
+ if (Array.isArray(content)) {
1906
+ return content.map((item) => {
1907
+ if (typeof item === "string")
1908
+ return item;
1909
+ if (item && typeof item === "object") {
1910
+ const record = item;
1911
+ if (typeof record.text === "string")
1912
+ return record.text;
1913
+ }
1914
+ return "";
1915
+ }).join("");
1916
+ }
1917
+ return "";
1918
+ }
1919
+ function convertToolState(state) {
1920
+ if (!state || typeof state !== "object")
1921
+ return { status: "pending" };
1922
+ const record = state;
1923
+ const status = String(record.status ?? "pending");
1924
+ const mapped = {};
1925
+ if (status === "streaming" || status === "running")
1926
+ mapped.status = "running";
1927
+ else if (status === "completed")
1928
+ mapped.status = "completed";
1929
+ else if (status === "failed" || status === "error")
1930
+ mapped.status = "error";
1931
+ else
1932
+ mapped.status = "pending";
1933
+ if (record.input !== undefined)
1934
+ mapped.input = record.input;
1935
+ if (record.output !== undefined)
1936
+ mapped.output = toolContentText(record.output);
1937
+ if (record.error !== undefined)
1938
+ mapped.error = record.error;
1939
+ if (record.time !== undefined)
1940
+ mapped.time = record.time;
1941
+ return mapped;
1942
+ }
1943
+ function convertV2Message(message, sessionID) {
1944
+ if (!message || typeof message.id !== "string")
1945
+ return;
1946
+ const base = {
1947
+ id: message.id,
1948
+ sessionID
1949
+ };
1950
+ if (message.type === "user") {
1951
+ return {
1952
+ info: {
1953
+ ...base,
1954
+ role: "user",
1955
+ time: { created: message.time?.created ?? 0 },
1956
+ text: message.text ?? ""
1957
+ },
1958
+ parts: []
1959
+ };
1960
+ }
1961
+ if (message.type === "assistant") {
1962
+ const parts = [];
1963
+ const content = Array.isArray(message.content) ? message.content : [];
1964
+ content.forEach((block, index) => {
1965
+ const record = block;
1966
+ const kind = String(record.type ?? "");
1967
+ if (kind === "text") {
1968
+ parts.push({
1969
+ id: `${message.id}_t${index}`,
1970
+ messageID: message.id,
1971
+ sessionID,
1972
+ type: "text",
1973
+ text: String(record.text ?? ""),
1974
+ synthetic: false,
1975
+ time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 }
1976
+ });
1977
+ } else if (kind === "reasoning") {
1978
+ parts.push({
1979
+ id: `${message.id}_r${index}`,
1980
+ messageID: message.id,
1981
+ sessionID,
1982
+ type: "reasoning",
1983
+ text: String(record.text ?? ""),
1984
+ synthetic: false,
1985
+ time: { start: message.time?.created ?? 0, end: message.time?.completed ?? 0 }
1986
+ });
1987
+ } else if (kind === "tool") {
1988
+ parts.push({
1989
+ id: String(record.id ?? `${message.id}_tool${index}`),
1990
+ messageID: message.id,
1991
+ sessionID,
1992
+ type: "tool",
1993
+ tool: String(record.name ?? "unknown"),
1994
+ state: convertToolState(record.state),
1995
+ synthetic: false
1996
+ });
1997
+ }
1998
+ });
1999
+ return {
2000
+ info: {
2001
+ ...base,
2002
+ role: "assistant",
2003
+ agent: message.agent,
2004
+ model: message.model ? { providerID: message.model.providerID, modelID: message.model.modelID ?? message.model.id } : undefined,
2005
+ time: { created: message.time?.created ?? 0, completed: message.time?.completed ?? 0 }
2006
+ },
2007
+ parts
2008
+ };
2009
+ }
2010
+ return;
2011
+ }
2012
+ function adaptSessionClient(session, message, directory) {
2013
+ if (!session)
2014
+ return;
2015
+ const loc = () => directory ? { directory } : undefined;
2016
+ const listAll = async () => {
2017
+ const out = [];
2018
+ let cursor;
2019
+ for (let page = 0;page < 50; page++) {
2020
+ const res = await session.list({ ...loc(), cursor, limit: 100 });
2021
+ const data = res?.data;
2022
+ if (Array.isArray(data))
2023
+ out.push(...data);
2024
+ const next = res?.cursor?.next ?? undefined;
2025
+ if (!next || !Array.isArray(data) || data.length === 0)
2026
+ break;
2027
+ cursor = next;
2028
+ }
2029
+ return out;
2030
+ };
2031
+ const listMessages = async (sessionID) => {
2032
+ if (!message)
2033
+ return [];
2034
+ const out = [];
2035
+ let cursor;
2036
+ for (let page = 0;page < 50; page++) {
2037
+ const res = await message.list({ sessionID, limit: 100, cursor });
2038
+ const data = res?.data;
2039
+ if (!Array.isArray(data))
2040
+ break;
2041
+ for (const raw of data) {
2042
+ const converted = convertV2Message(raw, sessionID);
2043
+ if (converted)
2044
+ out.push(converted);
2045
+ }
2046
+ const next = res?.cursor?.next ?? undefined;
2047
+ if (!next || data.length === 0)
2048
+ break;
2049
+ cursor = next;
2050
+ }
2051
+ return out;
2052
+ };
2053
+ return {
2054
+ session: {
2055
+ async get(input) {
2056
+ const res = await session.get({ sessionID: input.path.id });
2057
+ return { data: res };
2058
+ },
2059
+ async update(input) {
2060
+ const body = input.body;
2061
+ const res = await session.update({ sessionID: input.path.id, title: body.title, metadata: body.metadata });
2062
+ return { data: res };
2063
+ },
2064
+ async list() {
2065
+ return { data: await listAll() };
2066
+ },
2067
+ async messages(input) {
2068
+ return { data: await listMessages(input.path.id) };
2069
+ },
2070
+ async diff(input) {
2071
+ const res = await session.diff({ sessionID: input.path.id });
2072
+ return { data: res };
2073
+ }
2074
+ }
2075
+ };
2076
+ }
2077
+ var SESSION_ITEM_EVENTS = new Set([
2078
+ "session.created",
2079
+ "session.renamed",
2080
+ "session.metadata.updated",
2081
+ "session.agent.selected",
2082
+ "session.model.selected",
2083
+ "session.moved",
2084
+ "session.permissions",
2085
+ "session.compaction.started",
2086
+ "session.compaction.ended"
2087
+ ]);
2088
+ var CONTENT_EVENTS_PREFIXES = [
2089
+ "session.text.",
2090
+ "session.reasoning.",
2091
+ "session.tool.",
2092
+ "session.message.",
2093
+ "session.step.streamed"
2094
+ ];
2095
+ var BUSY_EVENTS = new Set(["session.step.started", "session.execution.started"]);
2096
+ var IDLE_EVENTS = new Set(["session.step.ended", "session.execution.succeeded", "session.execution.interrupted"]);
2097
+ var ERROR_EVENTS = new Set(["session.step.failed", "session.tool.failed", "session.retry.scheduled"]);
2098
+ function eventSessionID(event) {
2099
+ const data = event.data;
2100
+ if (data && typeof data === "object" && typeof data.sessionID === "string") {
2101
+ return data.sessionID;
2102
+ }
2103
+ return;
2104
+ }
2105
+ function resolveV2ShareMode(options) {
2106
+ const explicit = options?.["shareMode"] ?? process.env.OPENSHARE_SHARE_MODE;
2107
+ if (explicit === "all" || explicit === "manual")
2108
+ return explicit;
2109
+ try {
2110
+ const root = packageRoot(moduleDir());
2111
+ const pkgPath = join3(root, "package.json");
2112
+ if (existsSync3(pkgPath)) {
2113
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
2114
+ const declared = pkg["oc-plugin"];
2115
+ if (Array.isArray(declared) && declared.includes("tui"))
2116
+ return "manual";
2117
+ }
2118
+ } catch {}
2119
+ return;
2120
+ }
2121
+ function createV2ServerSetup() {
2122
+ return async (context) => {
2123
+ if (!context?.event?.subscribe || !context.client?.session) {
2124
+ if (process.env.OPENSHARE_DEBUG_FILE) {
2125
+ try {
2126
+ console.error(`[${METADATA_KEY}] v2 setup not applicable on this host — sharing handled by the server entry`);
2127
+ } catch {}
2128
+ }
2129
+ return () => {};
2130
+ }
2131
+ const directory = context.directory;
2132
+ const client = adaptSessionClient(context.client.session, context.client.message, directory);
2133
+ const modeOverride = resolveV2ShareMode(context.options);
2134
+ const engine = createEngine({
2135
+ client,
2136
+ pluginDir: moduleDir(),
2137
+ directory,
2138
+ options: modeOverride ? { ...context.options, shareMode: modeOverride } : context.options
2139
+ });
2140
+ if (!engine.hooks.event) {
2141
+ return () => {};
2142
+ }
2143
+ const pendingRefresh = new Map;
2144
+ const refresh = (sessionID) => {
2145
+ let timer = pendingRefresh.get(sessionID);
2146
+ if (timer)
2147
+ clearTimeout(timer);
2148
+ timer = setTimeout(() => {
2149
+ pendingRefresh.delete(sessionID);
2150
+ (async () => {
2151
+ try {
2152
+ const info = await client.session.get({ path: { id: sessionID } });
2153
+ await engine.hooks.event({
2154
+ event: { type: "session.updated", properties: { sessionID, info: info.data ?? info } }
2155
+ });
2156
+ const messages = await client.session.messages?.({ path: { id: sessionID } });
2157
+ const list = messages?.data ?? [];
2158
+ for (const message of list) {
2159
+ await engine.hooks.event({
2160
+ event: { type: "message.updated", properties: { sessionID, info: message.info } }
2161
+ });
2162
+ for (const part of message.parts ?? []) {
2163
+ await engine.hooks.event({
2164
+ event: { type: "message.part.updated", properties: { sessionID, part } }
2165
+ });
2166
+ }
2167
+ }
2168
+ } catch {}
2169
+ })();
2170
+ }, 400);
2171
+ pendingRefresh.set(sessionID, timer);
2172
+ };
2173
+ const unsubscribe = (() => {
2174
+ let stopped = false;
2175
+ const run = async () => {
2176
+ try {
2177
+ const stream = context.event.subscribe({ signal: context.signal });
2178
+ for await (const event of stream) {
2179
+ if (stopped)
2180
+ break;
2181
+ if (!event || typeof event.type !== "string")
2182
+ continue;
2183
+ const sid = eventSessionID(event);
2184
+ if (!sid)
2185
+ continue;
2186
+ const type = event.type;
2187
+ if (type === "session.deleted") {
2188
+ await engine.hooks.event({ event: { type, properties: { sessionID: sid } } });
2189
+ continue;
2190
+ }
2191
+ if (type === "message.removed" || type === "message.part.removed") {
2192
+ const data = event.data ?? {};
2193
+ await engine.hooks.event({
2194
+ event: { type, properties: { sessionID: sid, messageID: data.messageID, partID: data.partID } }
2195
+ });
2196
+ continue;
2197
+ }
2198
+ if (SESSION_ITEM_EVENTS.has(type)) {
2199
+ refresh(sid);
2200
+ continue;
2201
+ }
2202
+ if (BUSY_EVENTS.has(type)) {
2203
+ await engine.hooks.event({ event: { type: "session.status", properties: { sessionID: sid, status: { type: "busy" } } } });
2204
+ refresh(sid);
2205
+ continue;
2206
+ }
2207
+ if (IDLE_EVENTS.has(type)) {
2208
+ await engine.hooks.event({ event: { type: "session.status", properties: { sessionID: sid, status: { type: "idle" } } } });
2209
+ refresh(sid);
2210
+ continue;
2211
+ }
2212
+ if (type === "session.error" || type === "session.execution.failed") {
2213
+ await engine.hooks.event({ event: { type: "session.error", properties: { sessionID: sid } } });
2214
+ continue;
2215
+ }
2216
+ if (ERROR_EVENTS.has(type)) {
2217
+ await engine.hooks.event({ event: { type: "session.status", properties: { sessionID: sid, status: { type: "retry" } } } });
2218
+ continue;
2219
+ }
2220
+ if (CONTENT_EVENTS_PREFIXES.some((prefix) => type.startsWith(prefix))) {
2221
+ refresh(sid);
2222
+ continue;
2223
+ }
2224
+ }
2225
+ } catch {}
2226
+ };
2227
+ run();
2228
+ return () => {
2229
+ stopped = true;
2230
+ };
2231
+ })();
2232
+ return () => {
2233
+ unsubscribe();
2234
+ for (const timer of pendingRefresh.values())
2235
+ clearTimeout(timer);
2236
+ pendingRefresh.clear();
2237
+ engine.hooks.dispose();
2238
+ };
2239
+ };
2240
+ }
2241
+
2242
+ // src/server.ts
2243
+ var PLUGIN_ID = "openshare";
2244
+ var OpenSharePlugin = async (input, options) => {
2245
+ const deps = {
2246
+ client: input?.client,
2247
+ directory: typeof input?.directory === "string" ? input.directory : undefined,
2248
+ worktree: typeof input?.worktree === "string" ? input.worktree : undefined,
2249
+ pluginDir: moduleDir(),
2250
+ options
2251
+ };
2252
+ const engine = createEngine(deps);
2253
+ return engine.hooks;
2254
+ };
2255
+ var server = OpenSharePlugin;
2256
+ var server_default = {
2257
+ id: PLUGIN_ID,
2258
+ server,
2259
+ setup: createV2ServerSetup()
2260
+ };
2261
+ export {
2262
+ server_default as default,
2263
+ PLUGIN_ID,
2264
+ OpenSharePlugin
2265
+ };