@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
package/dist/core.js ADDED
@@ -0,0 +1,1944 @@
1
+ // Open(code)Share — the forwarding engine.
2
+ //
3
+ // Design contract (unchanged from the original single file):
4
+ // - Dumb forwarder: it forwards events, never diffs. Delivery reliability
5
+ // = ack + retain-until-acked disk spool (temporary buffer, replayed on
6
+ // reconnect) + the share binding persisted INSIDE opencode (session
7
+ // metadata) so recovery never depends on the spool.
8
+ // - One share per MAIN session; child sessions (session info.parentID)
9
+ // route into the parent's share. (message info.parentID is the
10
+ // replied-to message — NOT a subagent marker.)
11
+ // - Status = last signal: busy -> live; idle -> completed, or error if
12
+ // any part error / retry was seen. Always resumable.
13
+ // - Model label: config template ($Provider/$Model placeholders,
14
+ // literals as-is, "" hides) resolved from the user message's actual
15
+ // model; rides every user message (robust, free).
16
+ // - Optional read auth: authLogin/authPassword are sent on create AND
17
+ // attach (resume retroactively applies/updates credentials; empty on
18
+ // attach REMOVES protection — an intentional owner action).
19
+ // - Mismatch protocol: attach refused / share gone / worker rev below
20
+ // our acked rev -> fresh create (never merge into a bad base); nacked
21
+ // or dropped frames -> spool replay; stale extras -> left for the
22
+ // next prompt's keyed merge to correct. All logged loudly.
23
+ // - Undo needs nothing here: opencode publishes info.revert on the
24
+ // session item; the viewer hides the reverted range while it is set.
25
+ //
26
+ // Share modes:
27
+ // - "all": every session shares from its first event (the CI shape —
28
+ // behavior identical to the original single-file plugin).
29
+ // - "manual": sessions stay dormant until metadata.openshare.enabled is
30
+ // set on the MAIN session (the TUI /share flow). Activation runs a
31
+ // full backfill (session tree, messages, parts, models, diff) through
32
+ // the same keyed queue, so a share started mid-session is complete.
33
+ // Clearing the flag (or deleting the key) deletes the share — the
34
+ // same path as session.deleted.
35
+ //
36
+ // Session metadata shape (metadata.openshare):
37
+ // - all-mode binding: { id, secret, rev, url }
38
+ // - manual-mode: { id, secret, rev, url, enabled: true, opts? }
39
+ // opts: { ttlDays?, authLogin?, authPassword?, modelLabel? } optional
40
+ // per-session overrides (capture at activation; modelLabel applies
41
+ // immediately, the rest at create/attach).
42
+ // - The plugin strips this key from everything it syncs — the write
43
+ // secret must never reach viewers.
44
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
45
+ import { join } from "node:path";
46
+ import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
47
+ const DEFAULT_POLICY = {
48
+ mode: "persistent",
49
+ turnGraceMs: 120000,
50
+ idleDropMs: 600000,
51
+ finalizeAfterMs: 300000,
52
+ debounceMinMs: 0,
53
+ debounceMaxMs: Infinity,
54
+ completedDelayMaxMs: Infinity,
55
+ };
56
+ // Stderr policy (owner directive): in manual mode (TUI present) stderr is
57
+ // for ACTUAL ERRORS only — informational lines (live:/url file:/mode
58
+ // banner, lifecycle traces) are suppressed; the TUI surfaces the URL.
59
+ // All-mode (CI/server-only) keeps its stderr contract (announce lines,
60
+ // silenceable via announceStderr). Error-class lines always reach stderr.
61
+ let stderrInfoAllowed = true;
62
+ const log = (...a) => {
63
+ if (!stderrInfoAllowed)
64
+ return;
65
+ try {
66
+ console.error(`[${METADATA_KEY}]`, ...a);
67
+ }
68
+ catch { }
69
+ };
70
+ /** Error-class log: reaches stderr in BOTH modes (worker down, refused
71
+ * init, mismatch recreate, timeouts — things an owner must see even with
72
+ * the TUI installed). */
73
+ const logError = (...a) => {
74
+ try {
75
+ console.error(`[${METADATA_KEY}]`, ...a);
76
+ }
77
+ catch { }
78
+ };
79
+ function formatLabel(template, model) {
80
+ if (template === "")
81
+ return "";
82
+ if (!model)
83
+ return template ?? "";
84
+ return (template ?? "")
85
+ .replace(/\$Provider/g, model.providerID ?? "")
86
+ .replace(/\$provider/g, model.providerID ?? "")
87
+ .replace(/\$Model/g, model.modelID ?? "")
88
+ .replace(/\$model/g, model.modelID ?? "");
89
+ }
90
+ function itemFor(type, data, sessionID) {
91
+ if (type === "model")
92
+ return { key: `model/${sessionID}`, item: { type, sessionID, data } };
93
+ if (type === "prefs")
94
+ return { key: "prefs", item: { type, data } };
95
+ if (type === "session")
96
+ return { key: `session/${data?.id ?? ""}`, item: { type, data } };
97
+ if (type === "message")
98
+ return { key: `message/${data?.id ?? ""}`, item: { type, data } };
99
+ if (type === "part")
100
+ return { key: `part/${data?.messageID ?? ""}/${data?.id ?? ""}`, item: { type, data } };
101
+ if (type === "session_diff")
102
+ return { key: `session_diff/${sessionID}`, item: { type, sessionID, data } };
103
+ return undefined;
104
+ }
105
+ // session items must never carry our binding (the secret would reach
106
+ // viewers) — strip our metadata key from anything we enqueue
107
+ function redact(info) {
108
+ if (!info?.metadata || typeof info.metadata !== "object")
109
+ return info;
110
+ if (!(METADATA_KEY in info.metadata))
111
+ return info;
112
+ const metadata = { ...info.metadata };
113
+ delete metadata[METADATA_KEY];
114
+ return { ...info, metadata };
115
+ }
116
+ function shareFlag(info) {
117
+ const raw = info?.metadata?.[METADATA_KEY];
118
+ if (!raw || typeof raw !== "object")
119
+ return { enabled: false, opts: undefined };
120
+ const record = raw;
121
+ if (record.enabled !== true)
122
+ return { enabled: false, opts: undefined };
123
+ const optsRaw = record.opts;
124
+ const opts = {};
125
+ if (optsRaw && typeof optsRaw === "object") {
126
+ const o = optsRaw;
127
+ if (typeof o.ttlDays === "number" && o.ttlDays > 0)
128
+ opts.ttlDays = o.ttlDays;
129
+ if (typeof o.authLogin === "string")
130
+ opts.authLogin = o.authLogin;
131
+ if (typeof o.authPassword === "string")
132
+ opts.authPassword = o.authPassword;
133
+ if (typeof o.modelLabel === "string")
134
+ opts.modelLabel = o.modelLabel;
135
+ }
136
+ return { enabled: true, opts: Object.keys(opts).length ? opts : undefined };
137
+ }
138
+ export function createEngine(deps) {
139
+ let cfg = loadConfig(deps.pluginDir, deps.options);
140
+ const mode = resolveShareMode(cfg, deps);
141
+ // stderr = errors only when the TUI part exists (the TUI surfaces URLs)
142
+ if (mode === "manual")
143
+ stderrInfoAllowed = false;
144
+ if (process.env.OPENSHARE_DEBUG_FILE) {
145
+ // diagnostic ground truth for the suites (never set in production)
146
+ try {
147
+ writeFileSync(process.env.OPENSHARE_DEBUG_FILE, JSON.stringify({
148
+ mode,
149
+ directory: deps.directory,
150
+ worktree: deps.worktree,
151
+ pluginDir: deps.pluginDir,
152
+ shareModeCfg: cfg.shareMode ?? null,
153
+ }, null, 2));
154
+ }
155
+ catch { }
156
+ }
157
+ const server = resolveServer(cfg);
158
+ if (!server || !server.url) {
159
+ log("disabled: no share server configured (workerUrl or a servers entry)");
160
+ return {
161
+ mode,
162
+ config: () => cfg,
163
+ hooks: {
164
+ event: async () => { },
165
+ dispose: async () => { },
166
+ },
167
+ };
168
+ }
169
+ log(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`);
170
+ const resolveTargets = () => {
171
+ const merged = {};
172
+ if (cfg.workerUrl)
173
+ merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
174
+ if (cfg.servers)
175
+ for (const [u, e] of Object.entries(cfg.servers))
176
+ merged[u] = { ...merged[u], ...e };
177
+ const urls = Object.keys(merged);
178
+ const actives = urls.filter((u) => merged[u].active);
179
+ const chosen = actives.length ? actives : urls.slice(0, 1);
180
+ return chosen.map((url) => ({ url, entry: merged[url] }));
181
+ };
182
+ let targets = resolveTargets();
183
+ // ---- per-server credentials (identity) ----------------------------------
184
+ // Each server enrolls INDEPENDENTLY: a legacy root CREATE_TOKEN works
185
+ // forever (server-side bootstrap); an osk_ user token is the normal
186
+ // shape. With no token the plugin ENROLLS: public servers take anyone,
187
+ // private ones need servers.{url}.code. Tokens persist to the settings
188
+ // home so enrollment happens once per server.
189
+ const credState = new Map();
190
+ const credOf = (url) => {
191
+ let s = credState.get(url);
192
+ if (!s) {
193
+ s = { credential: "", error: "", lastEnrollAt: 0 };
194
+ credState.set(url, s);
195
+ }
196
+ return s;
197
+ };
198
+ const seedCredentials = () => {
199
+ for (const t of targets) {
200
+ const c = credOf(t.url);
201
+ if (t.entry.token) {
202
+ if (c.credential !== t.entry.token) {
203
+ c.credential = t.entry.token;
204
+ c.error = "";
205
+ }
206
+ }
207
+ else if (!c.credential && !c.error) {
208
+ c.credential = ""; // enrolled token may arrive via config refresh
209
+ }
210
+ }
211
+ };
212
+ seedCredentials();
213
+ const ENROLL_RETRY_MS = 60000;
214
+ const ensureCredential = async (url) => {
215
+ const c = credOf(url);
216
+ const target = targets.find((t) => t.url === url);
217
+ if (c.credential)
218
+ return c.credential;
219
+ if (Date.now() - c.lastEnrollAt < ENROLL_RETRY_MS)
220
+ return "";
221
+ c.lastEnrollAt = Date.now();
222
+ try {
223
+ const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) });
224
+ const hb = h.ok ? (await h.json().catch(() => null)) : null;
225
+ const authMode2 = hb?.auth?.mode;
226
+ const code = target?.entry.code ?? "";
227
+ if (authMode2 === "public" || code) {
228
+ const r = await fetch(`${url}/api/enroll`, {
229
+ method: "POST",
230
+ headers: { "content-type": "application/json" },
231
+ body: JSON.stringify({ code }),
232
+ signal: AbortSignal.timeout(10000),
233
+ });
234
+ const b = r.ok ? (await r.json().catch(() => null)) : null;
235
+ if (b?.ok && typeof b.token === "string") {
236
+ c.credential = b.token;
237
+ c.error = "";
238
+ const savedTo = saveServerToken(url, c.credential);
239
+ log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
240
+ return c.credential;
241
+ }
242
+ c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`;
243
+ }
244
+ else if (authMode2 === "private") {
245
+ c.error = "private server: add servers.{url}.code (or token) to enroll";
246
+ }
247
+ else {
248
+ c.error = `server health unreachable (HTTP ${h.status})`;
249
+ }
250
+ }
251
+ catch (e) {
252
+ c.error = `enrollment failed: ${e?.message || e}`;
253
+ }
254
+ logError(`no credential for ${url}: ${c.error}`);
255
+ return "";
256
+ };
257
+ const remoteFor = (st, url) => {
258
+ let r = st.remotes.get(url);
259
+ if (!r) {
260
+ r = {
261
+ url,
262
+ shareId: null,
263
+ secret: null,
264
+ ackedRev: 0,
265
+ ws: null,
266
+ wsReady: false,
267
+ connecting: false,
268
+ socketSeq: 0,
269
+ initTimer: null,
270
+ initWasAttach: false,
271
+ backoff: 1000,
272
+ reconnectTimer: null,
273
+ intentionalClose: false,
274
+ connectQueued: false,
275
+ authBlocked: false,
276
+ pending: [],
277
+ announced: false,
278
+ settleTimer: null,
279
+ idleDropTimer: null,
280
+ collided: null,
281
+ };
282
+ st.remotes.set(url, r);
283
+ }
284
+ return r;
285
+ };
286
+ // ---- server policy (worker-owned, per server) ---------------------------
287
+ // The worker defines the socket policy and the allowed sync-cadence
288
+ // window; the plugin clamps its own preferences into the INTERSECTION
289
+ // of every connected server's window (cfg is shared across remotes).
290
+ // Received in the WS init ack (authoritative) and from /health (covers
291
+ // the pure HTTP fallback). Defaults apply until a server answers.
292
+ const policyMap = new Map();
293
+ const policyAsked = new Set();
294
+ const policyOf = (url) => policyMap.get(url) ?? DEFAULT_POLICY;
295
+ const clampToPolicy = () => {
296
+ const policies = [...policyMap.values()];
297
+ if (!policies.length)
298
+ return;
299
+ const min = Math.max(0, ...policies.map((p) => p.debounceMinMs));
300
+ const max = Math.min(...policies.map((p) => p.debounceMaxMs));
301
+ cfg.debounceMs = Math.min(Math.max(cfg.debounceMs, min), Math.max(min, max));
302
+ const cd = Math.min(...policies.map((p) => p.completedDelayMaxMs));
303
+ cfg.completedDelayMs = Math.min(Math.max(cfg.completedDelayMs, 0), cd);
304
+ };
305
+ const applyPolicy = (raw, url) => {
306
+ if (!raw || typeof raw !== "object")
307
+ return;
308
+ const p = raw;
309
+ const num = (v, d) => {
310
+ const n = Number(v);
311
+ return Number.isFinite(n) && n >= 0 ? n : d;
312
+ };
313
+ const prev = policyOf(url);
314
+ policyMap.set(url, {
315
+ mode: p.mode === "turn" ? "turn" : "persistent",
316
+ turnGraceMs: num(p.turnGraceMs, prev.turnGraceMs),
317
+ idleDropMs: num(p.idleDropMs, prev.idleDropMs),
318
+ finalizeAfterMs: num(p.finalizeAfterMs, prev.finalizeAfterMs),
319
+ debounceMinMs: num(p.debounceMinMs, 0),
320
+ debounceMaxMs: Math.max(num(p.debounceMaxMs, Infinity), num(p.debounceMinMs, 0)),
321
+ completedDelayMaxMs: num(p.completedDelayMaxMs, Infinity),
322
+ });
323
+ clampToPolicy();
324
+ if (debugEvents)
325
+ log(`policy ${url}: ${JSON.stringify(policyOf(url))}`);
326
+ };
327
+ const askPolicy = (url) => {
328
+ if (policyAsked.has(url))
329
+ return;
330
+ policyAsked.add(url);
331
+ void fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) })
332
+ .then((r) => (r.ok ? r.json() : null))
333
+ .then((b) => {
334
+ if (b && typeof b === "object" && b.policy) {
335
+ applyPolicy(b.policy, url);
336
+ }
337
+ })
338
+ .catch(() => {
339
+ // unreachable worker: defaults stay; the init ack will correct us
340
+ policyAsked.delete(url);
341
+ });
342
+ };
343
+ const wsUrl = (url) => url.replace(/^http/, "ws");
344
+ // ---- spool root ---------------------------------------------------------
345
+ let spoolRoot = spoolDirFor(cfg);
346
+ try {
347
+ mkdirSync(spoolRoot, { recursive: true });
348
+ }
349
+ catch { }
350
+ // ---- per-main-session state -------------------------------------------
351
+ const sessions = new Map(); // mainSid -> st
352
+ const childToParent = new Map(); // childSid -> mainSid
353
+ // all-mode opt-in ledger: sessions that predate plugin start and are
354
+ // neither flagged nor already bound stay unshared (log once each)
355
+ const bootTime = Date.now();
356
+ const preExistingRetired = new Set();
357
+ const verifying = new Set();
358
+ let chain = Promise.resolve();
359
+ let lastUrlFile = null;
360
+ let urlFileAnnounced = false;
361
+ const newSt = (sid) => ({
362
+ sid,
363
+ client: deps.client,
364
+ queue: new Map(),
365
+ timer: null,
366
+ metaTimer: null,
367
+ completedTimer: null,
368
+ lastFlushAt: 0,
369
+ statusEpoch: 0,
370
+ remotes: new Map(),
371
+ status: "live",
372
+ badSignal: false,
373
+ lastSentStatus: null,
374
+ closed: false,
375
+ prefsSent: false,
376
+ binding: null,
377
+ bindingQueried: false,
378
+ optOuts: new Set(),
379
+ opts: undefined,
380
+ activated: false,
381
+ wroteBinding: false,
382
+ everConnected: false,
383
+ });
384
+ const refreshConfig = () => {
385
+ // settings may change between activations (the TUI writes the config
386
+ // file); each activation re-reads. env always wins, so CI is stable.
387
+ const next = loadConfig(deps.pluginDir, deps.options);
388
+ const resolved = resolveServer(next);
389
+ if (resolved?.url) {
390
+ cfg = next;
391
+ spoolRoot = spoolDirFor(cfg);
392
+ const before = new Set(targets.map((t) => t.url));
393
+ targets = resolveTargets();
394
+ seedCredentials();
395
+ clampToPolicy();
396
+ if (debugEvents) {
397
+ const after = targets.map((t) => t.url);
398
+ const added = after.filter((u) => !before.has(u));
399
+ if (added.length)
400
+ log(`fan-out set grew: ${added.join(", ")}`);
401
+ }
402
+ }
403
+ };
404
+ /** Activate a main session: create state, connect, backfill when gated
405
+ * (manual mode always; all-mode flag activations need the history too —
406
+ * an opted-in old session deserves a complete page, not just the events
407
+ * after the flag). */
408
+ const activate = (sid, opts, info, opts2) => {
409
+ refreshConfig();
410
+ let st = sessions.get(sid);
411
+ if (!st) {
412
+ st = newSt(sid);
413
+ sessions.set(sid, st);
414
+ }
415
+ else if (st.activated) {
416
+ if (opts)
417
+ st.opts = opts;
418
+ return st;
419
+ }
420
+ st.activated = true;
421
+ st.opts = opts;
422
+ const gated = mode === "manual" || opts2?.gated === true;
423
+ if (gated) {
424
+ // seed the share status from the session's CURRENT state before
425
+ // anything flushes: activating an idle session must not ship a
426
+ // phantom "live" badge. backfill follows, then the connection —
427
+ // through the same single-flight queue as everyone else.
428
+ chain = chain
429
+ .then(() => seedManualStatus(st))
430
+ .then(() => backfill(st, info))
431
+ .then(() => queueConnectAll(st))
432
+ .catch((e) => logError("activation error", e?.message || e));
433
+ }
434
+ else {
435
+ queueConnectAll(st);
436
+ }
437
+ return st;
438
+ };
439
+ const seedManualStatus = async (st) => {
440
+ if (!st.client || typeof st.client.session.status !== "function")
441
+ return;
442
+ try {
443
+ const map = unwrap(await st.client.session.status({
444
+ ...(deps.directory ? { query: { directory: deps.directory } } : {}),
445
+ }));
446
+ const type = map && typeof map === "object" ? map[st.sid]?.type : undefined;
447
+ if (type === "busy" || type === "retry") {
448
+ st.status = "live";
449
+ if (type === "retry")
450
+ st.badSignal = true;
451
+ }
452
+ else {
453
+ // idle (or absent from the map = not running): completed unless
454
+ // the backfilled history shows errors (resolved after backfill)
455
+ st.status = "completed";
456
+ }
457
+ }
458
+ catch {
459
+ // status unknown: keep "live" (a prompt may be running)
460
+ }
461
+ };
462
+ /** Existing state for an event sid, or null (manual mode never creates
463
+ * state on sight — activation is flag-driven). */
464
+ const stFor = (sid) => {
465
+ const main = childToParent.get(sid) ?? sid;
466
+ return sessions.get(main) ?? null;
467
+ };
468
+ // ---- durable binding: session metadata (inside opencode's db) ---------
469
+ // NOTE: the plugin SDK client can hit a foreign server when something
470
+ // else listens on Server.url's port — error envelopes don't throw, so
471
+ // every call is checked and degradation (spool covers reconnects) is
472
+ // logged loudly instead of failing silently.
473
+ const unwrap = (res) => {
474
+ if (res && typeof res === "object" && "error" in res && res.error) {
475
+ throw new Error(`client error: ${JSON.stringify(res.error).slice(0, 120)}`);
476
+ }
477
+ return res?.data ?? res;
478
+ };
479
+ async function sessionInfo(client, sid) {
480
+ if (!client)
481
+ return undefined;
482
+ try {
483
+ const info = unwrap(await client.session.get({
484
+ path: { id: sid },
485
+ ...(deps.directory ? { query: { directory: deps.directory } } : {}),
486
+ }));
487
+ return info;
488
+ }
489
+ catch {
490
+ return undefined;
491
+ }
492
+ }
493
+ async function writeMetadata(st, value) {
494
+ if (!st.client)
495
+ return;
496
+ try {
497
+ const info = unwrap(await st.client.session.get({
498
+ path: { id: st.sid },
499
+ ...(deps.directory ? { query: { directory: deps.directory } } : {}),
500
+ }));
501
+ const metadata = { ...(info?.metadata ?? {}) };
502
+ if (value === null)
503
+ delete metadata[METADATA_KEY];
504
+ else
505
+ metadata[METADATA_KEY] = value;
506
+ unwrap(await st.client.session.update({
507
+ path: { id: st.sid },
508
+ body: { metadata },
509
+ ...(deps.directory ? { query: { directory: deps.directory } } : {}),
510
+ }));
511
+ }
512
+ catch (e) {
513
+ log("metadata write failed (spool still covers reconnects):", e?.message || e);
514
+ }
515
+ }
516
+ /** Parse the metadata binding: v2 map ({v:2, servers:{url:{id,secret,
517
+ * rev,url,enabled?}}}) or legacy flat ({id,secret,rev,url}) which maps
518
+ * onto the first target. Returns per-server live entries. */
519
+ const parseBinding = (record) => {
520
+ const out = new Map();
521
+ if (record.servers && typeof record.servers === "object") {
522
+ for (const [url, raw] of Object.entries(record.servers)) {
523
+ if (!raw || typeof raw !== "object")
524
+ continue;
525
+ const e = raw;
526
+ if (typeof e.id === "string" && typeof e.secret === "string" && e.id && e.secret) {
527
+ out.set(url, { id: e.id, secret: e.secret, rev: typeof e.rev === "number" ? e.rev : 0 });
528
+ }
529
+ else if (e.optedOut === true) {
530
+ out.set(url, { id: "", secret: "", rev: 0, optedOut: true });
531
+ }
532
+ }
533
+ return out;
534
+ }
535
+ if (typeof record.id === "string" && typeof record.secret === "string" && record.id) {
536
+ const url = targets[0]?.url ?? "";
537
+ if (url)
538
+ out.set(url, { id: record.id, secret: record.secret, rev: typeof record.rev === "number" ? record.rev : 0 });
539
+ }
540
+ return out;
541
+ };
542
+ async function readBinding(st) {
543
+ if (!st.client || st.bindingQueried)
544
+ return;
545
+ st.bindingQueried = true;
546
+ try {
547
+ const info = await sessionInfo(st.client, st.sid);
548
+ const b = info?.metadata?.[METADATA_KEY];
549
+ if (b && typeof b === "object") {
550
+ const record = b;
551
+ if (mode === "manual" && record.enabled !== true) {
552
+ // flagless binding (unshared, or legacy): stay dormant
553
+ return;
554
+ }
555
+ const found = parseBinding(record);
556
+ if (found.size) {
557
+ for (const [url, e] of found) {
558
+ if (e.optedOut)
559
+ continue;
560
+ const r = remoteFor(st, url);
561
+ if (!r.shareId) {
562
+ r.shareId = e.id;
563
+ r.secret = e.secret;
564
+ r.ackedRev = e.rev;
565
+ }
566
+ }
567
+ if (mode === "manual")
568
+ st.opts = shareFlag(info).opts ?? st.opts;
569
+ log(`binding recovered from session metadata: ${[...found.keys()].join(", ")}`);
570
+ }
571
+ }
572
+ }
573
+ catch (e) {
574
+ log("metadata read failed (degrading to spool):", e?.message || e);
575
+ }
576
+ }
577
+ async function writeBinding(st) {
578
+ if (!st.client)
579
+ return;
580
+ const servers = {};
581
+ let any = false;
582
+ for (const [url, r] of st.remotes) {
583
+ if (!r.shareId || !r.secret)
584
+ continue;
585
+ any = true;
586
+ const entry = { id: r.shareId, secret: r.secret, rev: r.ackedRev, url: `${url}/s/${r.shareId}` };
587
+ if (r.collided)
588
+ entry.collided = r.collided;
589
+ servers[url] = entry;
590
+ }
591
+ if (!any)
592
+ return;
593
+ // preserve per-session opt-outs in the written map — a retired remote
594
+ // is absent from `servers`, and dropping the marker would let the next
595
+ // refreshOptOuts resurrect (and re-create) the opted-out server
596
+ for (const url of st.optOuts)
597
+ if (!servers[url])
598
+ servers[url] = { optedOut: true };
599
+ // A binding in session metadata means opted-in — enabled: true in BOTH
600
+ // modes (live-caught: all-mode bindings without `enabled` made the TUI
601
+ // gate fall through to the consent dialog on an already-shared session;
602
+ // live is now uniformly "url && enabled !== false").
603
+ const value = { v: 2, servers, enabled: true };
604
+ if (mode === "manual" && st.opts)
605
+ value.opts = st.opts;
606
+ st.binding = value;
607
+ st.wroteBinding = true;
608
+ await writeMetadata(st, value);
609
+ }
610
+ const scheduleBindingWrite = (st) => {
611
+ if (st.metaTimer)
612
+ return;
613
+ st.metaTimer = setTimeout(() => {
614
+ st.metaTimer = null;
615
+ chain = chain.then(() => writeBinding(st)).catch(() => { });
616
+ }, 30000);
617
+ };
618
+ // ---- spool (temporary buffer for unacked frames) ----------------------
619
+ // One spool per session per server: `<sid>.<h8(url)>.meta/queue`. The
620
+ // legacy suffix-less files (single-server era) migrate onto the first
621
+ // target at load.
622
+ const h8 = (url) => {
623
+ let h = 5381;
624
+ for (let i = 0; i < url.length; i++)
625
+ h = ((h << 5) + h + url.charCodeAt(i)) >>> 0;
626
+ return h.toString(16).padStart(8, "0");
627
+ };
628
+ const metaPath = (st, url) => join(spoolRoot, `${st.sid}.${h8(url)}.meta.json`);
629
+ const queuePath = (st, url) => join(spoolRoot, `${st.sid}.${h8(url)}.queue.jsonl`);
630
+ const legacyMetaPath = (st) => join(spoolRoot, `${st.sid}.meta.json`);
631
+ const legacyQueuePath = (st) => join(spoolRoot, `${st.sid}.queue.jsonl`);
632
+ const spoolSave = (st, r) => {
633
+ try {
634
+ writeFileSync(metaPath(st, r.url), JSON.stringify({ shareId: r.shareId, secret: r.secret, ackedRev: r.ackedRev, sid: st.sid, url: r.url }));
635
+ writeFileSync(queuePath(st, r.url), r.pending.map((f) => JSON.stringify(f)).join("\n") + (r.pending.length ? "\n" : ""));
636
+ }
637
+ catch (e) {
638
+ logError("spool save failed", e?.message || e);
639
+ }
640
+ };
641
+ const spoolLoad = (st, r) => {
642
+ try {
643
+ const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"));
644
+ // metadata binding (db) is authoritative when both exist and disagree
645
+ if (!r.shareId && meta.shareId) {
646
+ r.shareId = meta.shareId;
647
+ r.secret = meta.secret;
648
+ r.ackedRev = meta.ackedRev ?? 0;
649
+ }
650
+ if (existsSync(queuePath(st, r.url))) {
651
+ r.pending = readFileSync(queuePath(st, r.url), "utf8")
652
+ .split("\n")
653
+ .filter((l) => l.trim())
654
+ .map((l) => JSON.parse(l));
655
+ }
656
+ }
657
+ catch {
658
+ r.pending = [];
659
+ }
660
+ // single-server-era spool (no url suffix): belongs to the first target
661
+ if (!r.shareId && targets[0]?.url === r.url) {
662
+ try {
663
+ const meta = JSON.parse(readFileSync(legacyMetaPath(st), "utf8"));
664
+ if (meta.shareId && !r.shareId) {
665
+ r.shareId = meta.shareId;
666
+ r.secret = meta.secret;
667
+ r.ackedRev = meta.ackedRev ?? 0;
668
+ if (existsSync(legacyQueuePath(st))) {
669
+ r.pending = readFileSync(legacyQueuePath(st), "utf8")
670
+ .split("\n")
671
+ .filter((l) => l.trim())
672
+ .map((l) => JSON.parse(l));
673
+ }
674
+ log(`migrated legacy spool for ${st.sid.slice(0, 18)} onto ${r.url}`);
675
+ }
676
+ }
677
+ catch { }
678
+ }
679
+ };
680
+ const spoolClear = (st, r) => {
681
+ try {
682
+ rmSync(metaPath(st, r.url), { force: true });
683
+ rmSync(queuePath(st, r.url), { force: true });
684
+ }
685
+ catch { }
686
+ };
687
+ // ---- websocket transport (per remote) ----------------------------------
688
+ const wsSend = (r, obj) => {
689
+ if (debugEvents)
690
+ log(`ws ${r.url.slice(0, 24)} #${r.socketSeq} send ${obj?.op} (ready=${r.wsReady})`);
691
+ try {
692
+ r.ws?.send(JSON.stringify(obj));
693
+ }
694
+ catch {
695
+ return false;
696
+ }
697
+ return true;
698
+ };
699
+ /** Sync frames are chunked (~1MB WS cap on CF; a 5000-message backfill
700
+ * must ride several frames — worker apply is keyed-idempotent, chunks
701
+ * just bump rev). Status rides each chunk; last write wins. */
702
+ const SYNC_CHUNK = 200;
703
+ const sendSync = (r, f) => {
704
+ if (!f.items.length)
705
+ return wsSend(r, { op: "sync", items: [], status: f.status });
706
+ for (let i = 0; i < f.items.length; i += SYNC_CHUNK) {
707
+ wsSend(r, { op: "sync", items: f.items.slice(i, i + SYNC_CHUNK), status: f.status });
708
+ }
709
+ return true;
710
+ };
711
+ const announceUrl = (st, r) => {
712
+ if (r.announced)
713
+ return;
714
+ r.announced = true;
715
+ const url = `${r.url}/s/${r.shareId}`;
716
+ if (cfg.announceStderr)
717
+ log(`live: ${url}`);
718
+ if (mode === "all" && cfg.urlFile !== URL_FILE_NONE) {
719
+ // default: the file lives where the session lives (its directory,
720
+ // falling back to the process cwd) — never a fixed global path.
721
+ // Explicit urlFile config/env wins ("none" disables the file).
722
+ // Append: CI reads the file after each run; keeping every share
723
+ // URL lets one file serve a whole workflow (the legacy original
724
+ // overwrote with the latest only).
725
+ const file = cfg.urlFile || join(deps.directory ?? process.cwd(), "openshare.urls.txt");
726
+ try {
727
+ appendFileSync(file, url + "\n");
728
+ if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
729
+ urlFileAnnounced = true;
730
+ lastUrlFile = file;
731
+ log(`url file: ${file}`);
732
+ }
733
+ }
734
+ catch { }
735
+ }
736
+ };
737
+ const authFrame = (st) => {
738
+ // always send both (empty strings included): attach with empty values
739
+ // REMOVES protection — an intentional owner action. Session opts
740
+ // override the global config when set.
741
+ const login = st.opts?.authLogin ?? cfg.authLogin ?? "";
742
+ const password = st.opts?.authPassword ?? cfg.authPassword ?? "";
743
+ return { authLogin: login, authPassword: password };
744
+ };
745
+ const ttlFor = (st) => st.opts?.ttlDays ?? cfg.ttlDays;
746
+ /** Best-effort cleanup of a share we are about to abandon (refused
747
+ * attach, mismatch recreate). Prevents husk litter; failures are fine —
748
+ * the TTL sweeper collects strays eventually. */
749
+ const deleteAbandoned = (r) => {
750
+ const id = r.shareId;
751
+ const secret = r.secret;
752
+ if (!id || !secret)
753
+ return;
754
+ void fetch(`${r.url}/api/share/${id}`, {
755
+ method: "DELETE",
756
+ headers: { "content-type": "application/json" },
757
+ body: JSON.stringify({ secret }),
758
+ signal: AbortSignal.timeout(8000),
759
+ }).catch(() => { });
760
+ };
761
+ /** Single-flight connect queuing: multiple paths (activation, event
762
+ * reconnect, backfill chain) may want a socket before any connect has
763
+ * STARTED — the connecting flag alone guards execution, not the queue,
764
+ * and double connects used to create duplicate shares + init storms
765
+ * (live-caught in the S11 repro: ws#1 create AND ws#2 create). */
766
+ const queueConnect = (st, r) => {
767
+ if (st.closed || r.connectQueued || r.connecting || r.authBlocked || r.collided)
768
+ return;
769
+ r.connectQueued = true;
770
+ chain = chain
771
+ .then(() => {
772
+ r.connectQueued = false;
773
+ return connect(st, r);
774
+ })
775
+ .catch((e) => logError("connect error", e?.message || e));
776
+ };
777
+ /** Connect every target (activation, resume) — remotes materialize from
778
+ * targets ∪ binding (a share bound on a no-longer-active server keeps
779
+ * receiving updates until explicitly unshared there). Opted-out servers
780
+ * (this session) are skipped entirely. */
781
+ const queueConnectAll = (st) => {
782
+ if (st.closed)
783
+ return;
784
+ for (const t of targets) {
785
+ if (st.optOuts.has(t.url))
786
+ continue;
787
+ queueConnect(st, remoteFor(st, t.url));
788
+ }
789
+ for (const r of st.remotes.values()) {
790
+ if (st.optOuts.has(r.url))
791
+ continue;
792
+ queueConnect(st, r);
793
+ }
794
+ };
795
+ /** Re-read the session's binding map for per-server opt-outs (written by
796
+ * the TUI: "stop sharing here" deletes the share then opts out; "leave
797
+ * as-is" just opts out). Live remotes for newly opted-out servers are
798
+ * retired — the share keeps living server-side until its TTL.
799
+ * Also resolves collisions the TUI answered: entry removed → re-arm the
800
+ * remote (fresh create + backfill) unless opted out. */
801
+ const refreshOptOuts = async (st) => {
802
+ if (!st.client)
803
+ return;
804
+ try {
805
+ const info = await sessionInfo(st.client, st.sid);
806
+ const raw = info?.metadata?.[METADATA_KEY];
807
+ if (!raw || typeof raw !== "object")
808
+ return;
809
+ const servers = raw.servers;
810
+ if (!servers || typeof servers !== "object")
811
+ return;
812
+ const entryUrls = new Set();
813
+ for (const [url, e] of Object.entries(servers)) {
814
+ entryUrls.add(url);
815
+ const out = !!(e && typeof e === "object" && e.optedOut === true);
816
+ if (out)
817
+ st.optOuts.add(url);
818
+ else
819
+ st.optOuts.delete(url);
820
+ }
821
+ // retire remotes that just got opted out (socket closed; spool
822
+ // dropped — the share is out of our hands by the TUI's choice)
823
+ for (const url of [...st.optOuts]) {
824
+ const r = st.remotes.get(url);
825
+ if (!r)
826
+ continue;
827
+ if (debugEvents)
828
+ log(`remote ${url.slice(0, 24)} opted out for ${st.sid.slice(0, 18)} - retiring`);
829
+ r.intentionalClose = true;
830
+ try {
831
+ r.ws?.close(1000, "opted out");
832
+ }
833
+ catch { }
834
+ spoolClear(st, r);
835
+ st.remotes.delete(url);
836
+ }
837
+ // collisions resolved by the TUI: the binding entry for the server
838
+ // was removed — re-arm unless the same patch also opted out
839
+ for (const r of [...st.remotes.values()]) {
840
+ if (!r.collided || st.optOuts.has(r.url))
841
+ continue;
842
+ if (entryUrls.has(r.url))
843
+ continue;
844
+ if (debugEvents)
845
+ log(`collision on ${r.url.slice(0, 24)} resolved - re-arming`);
846
+ r.collided = null;
847
+ r.shareId = null;
848
+ r.secret = null;
849
+ r.ackedRev = 0;
850
+ r.pending = [];
851
+ r.announced = false;
852
+ queueConnect(st, r);
853
+ }
854
+ }
855
+ catch { }
856
+ };
857
+ const connect = async (st, r) => {
858
+ if (st.closed || r.connecting || r.authBlocked || r.collided)
859
+ return;
860
+ // "connecting" spans the WHOLE handshake: socket open + init ack. It
861
+ // is only cleared on init-ok, socket death or the init timeout — an
862
+ // event arriving mid-handshake must NEVER spawn a second socket (the
863
+ // old connect() returned after merely SENDING init; a second socket
864
+ // raced it, and the loser's late init-ok poisoned the winner's state
865
+ // — live-caught: duplicate shares, mismatch storms, empty pages).
866
+ r.connecting = true;
867
+ r.socketSeq += 1;
868
+ const seq = r.socketSeq;
869
+ // supersede any previous socket: its frames are dead to us now
870
+ if (r.ws) {
871
+ const old = r.ws;
872
+ try {
873
+ old.close(4000, "superseded");
874
+ }
875
+ catch { }
876
+ }
877
+ try {
878
+ await readBinding(st);
879
+ spoolLoad(st, r);
880
+ askPolicy(r.url);
881
+ // was this socket's init an ATTACH (resume) or a CREATE? A create
882
+ // ack legitimately starts at rev 0 — the mismatch guard is for
883
+ // attaches only (live-caught: create-ack rev 0 tripped it and
884
+ // spawned a fresh husk share with the content dropped).
885
+ r.initWasAttach = !!(r.shareId && r.secret);
886
+ if (debugEvents)
887
+ log(`ws ${r.url.slice(0, 24)} #${seq} opening (${r.initWasAttach ? `attach ${r.shareId}` : "create"})`);
888
+ // creates need a credential (enrolls on demand — first contact with
889
+ // a server); attaches ride the share secret alone when tokenless
890
+ const cred = credOf(r.url);
891
+ const token = r.initWasAttach ? cred.credential : cred.credential || (await ensureCredential(r.url));
892
+ if (!r.initWasAttach && !token && cred.error) {
893
+ // the server is reachable but enrollment is impossible (private,
894
+ // no valid code / enroll refused). A tokenless create would just
895
+ // be refused in a loop — block this remote, surface once.
896
+ r.authBlocked = true;
897
+ r.connecting = false;
898
+ r.ws = null;
899
+ logError(`sharing to ${r.url} disabled (no credential): ${cred.error}`);
900
+ return;
901
+ }
902
+ const ws = new WebSocket(`${wsUrl(r.url)}/api/ws?token=${encodeURIComponent(token ?? "")}`);
903
+ r.ws = ws;
904
+ await new Promise((resolve, reject) => {
905
+ const t = setTimeout(() => reject(new Error("ws open timeout")), 15000);
906
+ ws.addEventListener("open", () => {
907
+ clearTimeout(t);
908
+ resolve(undefined);
909
+ }, { once: true });
910
+ ws.addEventListener("error", () => {
911
+ clearTimeout(t);
912
+ reject(new Error("ws open error"));
913
+ }, { once: true });
914
+ });
915
+ ws.addEventListener("message", (ev) => {
916
+ // stale sockets never touch live state (defense in depth: even a
917
+ // superseded socket that still delivers frames is dropped here)
918
+ if (seq !== r.socketSeq)
919
+ return;
920
+ let frame;
921
+ try {
922
+ frame = JSON.parse(ev.data);
923
+ }
924
+ catch {
925
+ return;
926
+ }
927
+ handleFrame(st, r, frame).catch((e) => logError("frame error", e?.message || e));
928
+ });
929
+ ws.addEventListener("close", () => {
930
+ if (seq !== r.socketSeq)
931
+ return;
932
+ r.wsReady = false;
933
+ r.connecting = false;
934
+ // a deliberate turn-mode drop must not trigger the reconnect
935
+ // backoff loop — the next event reconnects on demand
936
+ if (r.intentionalClose) {
937
+ r.intentionalClose = false;
938
+ return;
939
+ }
940
+ if (!st.closed)
941
+ scheduleReconnect(st, r);
942
+ });
943
+ if (r.initWasAttach) {
944
+ wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } });
945
+ }
946
+ else {
947
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } });
948
+ }
949
+ // handshake deadline: init sent but no ack (silent worker death)
950
+ // — close, let the close listener reconnect us
951
+ r.initTimer = setTimeout(() => {
952
+ if (seq !== r.socketSeq)
953
+ return;
954
+ logError(`ws ${r.url.slice(0, 24)} #${seq} init ack timeout`);
955
+ r.connecting = false;
956
+ try {
957
+ ws.close(4000, "init timeout");
958
+ }
959
+ catch { }
960
+ if (!st.closed)
961
+ scheduleReconnect(st, r);
962
+ }, 20000);
963
+ }
964
+ catch (e) {
965
+ r.connecting = false;
966
+ logError("socket failed:", e?.message || e, "- falling back to HTTP, will retry");
967
+ r.wsReady = false;
968
+ void flushViaHttp(st, r).catch(() => { });
969
+ scheduleReconnect(st, r);
970
+ return;
971
+ }
972
+ };
973
+ const scheduleReconnect = (st, r) => {
974
+ if (st.closed || r.reconnectTimer)
975
+ return;
976
+ const wait = r.backoff;
977
+ r.backoff = Math.min(r.backoff * 2, 60000);
978
+ r.reconnectTimer = setTimeout(() => {
979
+ r.reconnectTimer = null;
980
+ queueConnect(st, r);
981
+ }, wait);
982
+ };
983
+ // ---- turn-scoped socket lifecycle (server policy "turn") ---------------
984
+ const dropSocket = (st, r, why) => {
985
+ if (debugEvents)
986
+ log(`turn: drop socket (${why}) ${r.url.slice(0, 24)} sid=${st.sid.slice(0, 18)}`);
987
+ r.wsReady = false;
988
+ r.intentionalClose = true;
989
+ try {
990
+ r.ws?.close(1000, why);
991
+ }
992
+ catch { }
993
+ r.ws = null;
994
+ };
995
+ const cancelSettleDrop = (r) => {
996
+ if (r.settleTimer) {
997
+ clearTimeout(r.settleTimer);
998
+ r.settleTimer = null;
999
+ }
1000
+ };
1001
+ const armSettleDrop = (st) => {
1002
+ for (const r of st.remotes.values()) {
1003
+ const p = policyOf(r.url);
1004
+ if (p.mode !== "turn" || p.turnGraceMs <= 0)
1005
+ continue;
1006
+ cancelSettleDrop(r);
1007
+ r.settleTimer = setTimeout(() => {
1008
+ r.settleTimer = null;
1009
+ if (st.closed || !r.ws)
1010
+ return;
1011
+ dropSocket(st, r, "turn end");
1012
+ }, p.turnGraceMs);
1013
+ }
1014
+ };
1015
+ const armIdleDrop = (st) => {
1016
+ for (const r of st.remotes.values()) {
1017
+ const p = policyOf(r.url);
1018
+ if (p.mode !== "turn" || p.idleDropMs <= 0)
1019
+ continue;
1020
+ if (r.idleDropTimer)
1021
+ clearTimeout(r.idleDropTimer);
1022
+ r.idleDropTimer = setTimeout(() => {
1023
+ r.idleDropTimer = null;
1024
+ if (st.closed || !r.ws)
1025
+ return;
1026
+ // no signal for idleDropMs (incl. hosts that never emit idle):
1027
+ // drop; the next event reconnects and the spool replays the gap
1028
+ dropSocket(st, r, "idle timeout");
1029
+ }, p.idleDropMs);
1030
+ }
1031
+ };
1032
+ /** Any activity: the turn is alive — cancel the settle drop, reset the
1033
+ * no-signal fallback timer (per server). */
1034
+ const touch = (st) => {
1035
+ for (const r of st.remotes.values())
1036
+ cancelSettleDrop(r);
1037
+ armIdleDrop(st);
1038
+ };
1039
+ /** An event arrived with no live socket (post-drop or post-fallback):
1040
+ * reconnect now — the queue rides the normal flush path afterwards.
1041
+ * Target-set refresh is throttled (config files on disk per event would
1042
+ * be heavy); 5s catches TUI server switches quickly enough. */
1043
+ let lastTargetsCheck = 0;
1044
+ const ensureConnected = (st) => {
1045
+ if (st.closed)
1046
+ return;
1047
+ if (Date.now() - lastTargetsCheck > 5000) {
1048
+ lastTargetsCheck = Date.now();
1049
+ refreshConfig();
1050
+ }
1051
+ for (const t of targets) {
1052
+ if (st.optOuts.has(t.url))
1053
+ continue;
1054
+ const r = remoteFor(st, t.url);
1055
+ if (r.connecting || r.reconnectTimer || r.wsReady || r.authBlocked || r.collided)
1056
+ continue;
1057
+ queueConnect(st, r);
1058
+ }
1059
+ for (const r of st.remotes.values()) {
1060
+ if (st.optOuts.has(r.url))
1061
+ continue;
1062
+ if (r.connecting || r.reconnectTimer || r.wsReady || r.authBlocked || r.collided)
1063
+ continue;
1064
+ queueConnect(st, r);
1065
+ }
1066
+ };
1067
+ const handleFrame = async (st, r, frame) => {
1068
+ if (frame.op === "init") {
1069
+ if (!frame.ok) {
1070
+ const err = String(frame.error ?? "");
1071
+ if (/frozen/i.test(err)) {
1072
+ // owner frozen: every control path is severed server-side.
1073
+ // Re-enrolling would orphan the shares — surface and stop.
1074
+ r.authBlocked = true;
1075
+ logError(`${r.url}: share frozen by the hoster (owner frozen) - edits refused; contact the server admin`);
1076
+ try {
1077
+ r.ws?.close(4000, "frozen");
1078
+ }
1079
+ catch { }
1080
+ return;
1081
+ }
1082
+ if (/forbidden|bad secret/i.test(err) && r.initWasAttach && r.secret) {
1083
+ // COLLISION: the binding's secret no longer opens this share and
1084
+ // the socket token doesn't own it either (share recreated by a
1085
+ // later run? host-side restore?). Auto-recreating could dupe a
1086
+ // share someone else now owns — stop, mark, let the TUI offer
1087
+ // copy-debug / share-on-new / don't-share (headless: share-new).
1088
+ r.collided = err;
1089
+ r.connecting = false;
1090
+ logError(`${r.url}: attach refused (${err}) - credential collision on share ${r.shareId}; not auto-recreating`);
1091
+ try {
1092
+ r.ws?.close(4000, "collision");
1093
+ }
1094
+ catch { }
1095
+ // persist the collision into the binding so the TUI can offer
1096
+ // the choice; writeBinding marks collided remotes
1097
+ st.wroteBinding = false;
1098
+ chain = chain.then(() => writeBinding(st)).catch(() => { });
1099
+ // headless (all-mode): resolve as "share on a new share"
1100
+ if (mode === "all")
1101
+ resolveCollision(st, r);
1102
+ return;
1103
+ }
1104
+ if (/token|credential/i.test(err) && !r.initWasAttach) {
1105
+ // our token was rejected (revoked/expired): drop it, re-enroll
1106
+ // (60s-gated; public server or a fresh code), reconnect. A
1107
+ // legacy root token never lands here.
1108
+ const cred = credOf(r.url);
1109
+ if (cred.credential) {
1110
+ cred.credential = "";
1111
+ logError(`${r.url}: server rejected our token - re-enrolling`);
1112
+ }
1113
+ try {
1114
+ r.ws?.close(4000, "auth");
1115
+ }
1116
+ catch { }
1117
+ r.connecting = false;
1118
+ if (!st.closed)
1119
+ queueConnect(st, r);
1120
+ return;
1121
+ }
1122
+ // share swept / removed / revoked: start fresh, never merge into a
1123
+ // bad base (mismatch protocol). Content REPLAYS to the new share —
1124
+ // pending is never dropped on a reset (live-caught: the recreate
1125
+ // husk had the title but zero messages).
1126
+ logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
1127
+ deleteAbandoned(r);
1128
+ r.shareId = null;
1129
+ r.secret = null;
1130
+ r.ackedRev = 0;
1131
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } });
1132
+ return;
1133
+ }
1134
+ if (r.initWasAttach && typeof frame.rev === "number" && frame.rev < r.ackedRev) {
1135
+ // worker lost data behind our back (ATTACH only — a create ack
1136
+ // legitimately starts at rev 0; the old guard misread that as
1137
+ // data loss and spawned a fresh husk, live-caught)
1138
+ logError(`${r.url}: mismatch: worker rev ${frame.rev} < acked ${r.ackedRev} - recreating`);
1139
+ deleteAbandoned(r);
1140
+ r.shareId = null;
1141
+ r.secret = null;
1142
+ r.ackedRev = 0;
1143
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } });
1144
+ return;
1145
+ }
1146
+ const wasFanoutCreate = !r.initWasAttach && st.everConnected;
1147
+ const shareChanged = !!frame.id && frame.id !== r.shareId;
1148
+ if (shareChanged)
1149
+ r.announced = false;
1150
+ r.shareId = frame.id ?? r.shareId;
1151
+ r.secret = frame.secret ?? r.secret;
1152
+ r.ackedRev = frame.rev ?? 0;
1153
+ r.wsReady = true;
1154
+ r.connecting = false;
1155
+ r.initWasAttach = false;
1156
+ r.collided = null;
1157
+ if (r.initTimer) {
1158
+ clearTimeout(r.initTimer);
1159
+ r.initTimer = null;
1160
+ }
1161
+ r.backoff = 1000;
1162
+ st.everConnected = true;
1163
+ if (frame.policy)
1164
+ applyPolicy(frame.policy, r.url);
1165
+ r.intentionalClose = false;
1166
+ touch(st);
1167
+ announceUrl(st, r);
1168
+ // first binding write is immediate (the TUI waits on the URL in
1169
+ // session metadata); ack-driven rev updates stay debounced
1170
+ if (!st.wroteBinding) {
1171
+ chain = chain.then(() => writeBinding(st)).catch(() => { });
1172
+ }
1173
+ else {
1174
+ scheduleBindingWrite(st);
1175
+ }
1176
+ spoolSave(st, r);
1177
+ if (r.pending.length) {
1178
+ for (const f of r.pending)
1179
+ sendSync(r, f);
1180
+ }
1181
+ else if (st.queue.size) {
1182
+ flush(st);
1183
+ }
1184
+ if (wasFanoutCreate) {
1185
+ // a server joined an ALREADY-live session (fan-out add, or a
1186
+ // post-mismatch recreate): its fresh share needs the full history —
1187
+ // backfill is keyed-idempotent, other servers just re-upsert
1188
+ chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", e?.message || e));
1189
+ }
1190
+ return;
1191
+ }
1192
+ if (frame.op === "ack") {
1193
+ r.ackedRev = frame.rev ?? r.ackedRev;
1194
+ r.pending = [];
1195
+ spoolSave(st, r);
1196
+ scheduleBindingWrite(st);
1197
+ return;
1198
+ }
1199
+ if (frame.op === "nack" || frame.op === "error") {
1200
+ logError(`${r.url}: worker refused frame:`, JSON.stringify(frame).slice(0, 200));
1201
+ // the control socket lost its attach state (worker-side eviction of
1202
+ // the control DO): force a reconnect — init attach replays pending
1203
+ if (frame.op === "error" && r.wsReady && !st.closed) {
1204
+ r.wsReady = false;
1205
+ try {
1206
+ r.ws?.close();
1207
+ }
1208
+ catch { }
1209
+ scheduleReconnect(st, r);
1210
+ }
1211
+ }
1212
+ };
1213
+ /** Collision resolution "share on a new share": forget the dead binding
1214
+ * entry for this server (debug info logged first), delete the husk if
1215
+ * we still can, and reconnect — the create path makes a fresh share and
1216
+ * the fan-out backfill fills it. */
1217
+ const resolveCollision = (st, r) => {
1218
+ logError(`collision debug: server=${r.url} share=${r.shareId} rev=${r.ackedRev} sid=${st.sid}`);
1219
+ deleteAbandoned(r);
1220
+ r.collided = null;
1221
+ r.shareId = null;
1222
+ r.secret = null;
1223
+ r.ackedRev = 0;
1224
+ r.pending = [];
1225
+ r.announced = false;
1226
+ queueConnect(st, r);
1227
+ };
1228
+ // ---- flush -------------------------------------------------------------
1229
+ const frameFrom = (st) => {
1230
+ const items = Array.from(st.queue.values());
1231
+ st.queue.clear();
1232
+ const statusChanged = st.status !== st.lastSentStatus;
1233
+ if (!items.length && !statusChanged) {
1234
+ if (debugEvents)
1235
+ log(`flush skip (no items, status ${st.status}==${st.lastSentStatus})`);
1236
+ return null;
1237
+ }
1238
+ st.lastSentStatus = st.status;
1239
+ if (debugEvents)
1240
+ log(`frame: ${items.length} items, status ${st.status}`);
1241
+ return { items, status: st.status };
1242
+ };
1243
+ // returns a promise when the frame goes out via HTTP (the dispose path
1244
+ // awaits it - an unawaited fetch dies with the exiting process and the
1245
+ // final status never lands, live-caught as shares stuck on "live")
1246
+ const flush = (st) => {
1247
+ if (st.closed)
1248
+ return;
1249
+ const f = frameFrom(st);
1250
+ if (!f)
1251
+ return;
1252
+ // fan out to every remote; each carries its own pending copy and acks
1253
+ // independently. Remotes mid-handshake or auth-blocked just buffer.
1254
+ let httpWork;
1255
+ for (const r of st.remotes.values()) {
1256
+ // never send syncs before the init ack: the worker's per-socket
1257
+ // ControlDO has no attach until OUR init completes, and a racing
1258
+ // sync dies as "unknown op" (live-caught: post-drop reconnects
1259
+ // lost whole turns). The init-ok handler flushes pending itself.
1260
+ r.pending.push(f);
1261
+ spoolSave(st, r);
1262
+ if (r.ws && !r.wsReady)
1263
+ continue;
1264
+ if (r.wsReady) {
1265
+ sendSync(r, f);
1266
+ continue;
1267
+ }
1268
+ const viaHttp = flushViaHttp(st, r);
1269
+ if (viaHttp)
1270
+ httpWork = (httpWork ?? Promise.resolve()).then(() => viaHttp);
1271
+ }
1272
+ if (st.remotes.size)
1273
+ st.lastFlushAt = Date.now();
1274
+ return httpWork;
1275
+ };
1276
+ const flushViaHttp = async (st, r) => {
1277
+ if (!r.shareId || !r.pending.length)
1278
+ return;
1279
+ // ALL pending frames, merged (keyed upserts — later wins). Sending
1280
+ // only the last frame dropped every earlier batch's items whenever
1281
+ // the HTTP fallback carried more than one (live-caught class:
1282
+ // content loss on reconnect paths).
1283
+ const items = [];
1284
+ let status;
1285
+ for (const f of r.pending) {
1286
+ for (const it of f.items)
1287
+ items.push(it);
1288
+ if (f.status)
1289
+ status = f.status;
1290
+ }
1291
+ const cred = credOf(r.url);
1292
+ const res = await fetch(`${r.url}/api/share/${r.shareId}/sync`, {
1293
+ method: "POST",
1294
+ headers: { "content-type": "application/json", ...(cred.credential ? { authorization: `Bearer ${cred.credential}` } : {}) },
1295
+ body: JSON.stringify({ secret: r.secret ?? "", data: items, status }),
1296
+ signal: AbortSignal.timeout(15000),
1297
+ }).catch(() => null);
1298
+ if (res && res.ok) {
1299
+ const body = (await res.json().catch(() => ({})));
1300
+ r.ackedRev = body.rev ?? r.ackedRev;
1301
+ r.pending = [];
1302
+ spoolSave(st, r);
1303
+ }
1304
+ };
1305
+ const scheduleFlush = (st) => {
1306
+ if (st.timer)
1307
+ return;
1308
+ // sync interval is a rate limit anchored at the PREVIOUS flush: an
1309
+ // event arriving after the interval already elapsed flushes
1310
+ // immediately; one arriving right after a flush waits the remainder.
1311
+ // The very first batch flushes as soon as it exists (a viewer that
1312
+ // just opened the link sees the opening prompt fast).
1313
+ const elapsed = Date.now() - st.lastFlushAt;
1314
+ const wait = Math.max(0, cfg.debounceMs - elapsed);
1315
+ st.timer = setTimeout(() => {
1316
+ st.timer = null;
1317
+ if (debugEvents)
1318
+ log(`flush timer fired sid=${st.sid.slice(0, 18)} queue=${st.queue.size} remotes=${st.remotes.size}`);
1319
+ chain = chain.then(() => flush(st)).catch((e) => logError("flush error", e?.message || e));
1320
+ }, wait);
1321
+ };
1322
+ const enqueue = (st, entry) => {
1323
+ if (!st || !entry)
1324
+ return;
1325
+ st.queue.set(entry.key, entry.item);
1326
+ if (debugEvents)
1327
+ log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`);
1328
+ touch(st);
1329
+ ensureConnected(st);
1330
+ scheduleFlush(st);
1331
+ };
1332
+ const remove = (st) => {
1333
+ st.closed = true;
1334
+ st.activated = false;
1335
+ if (st.timer)
1336
+ clearTimeout(st.timer);
1337
+ if (st.metaTimer)
1338
+ clearTimeout(st.metaTimer);
1339
+ if (st.completedTimer) {
1340
+ clearTimeout(st.completedTimer);
1341
+ st.completedTimer = null;
1342
+ }
1343
+ for (const r of st.remotes.values()) {
1344
+ r.connecting = false;
1345
+ r.connectQueued = false;
1346
+ if (r.initTimer) {
1347
+ clearTimeout(r.initTimer);
1348
+ r.initTimer = null;
1349
+ }
1350
+ if (r.reconnectTimer) {
1351
+ clearTimeout(r.reconnectTimer);
1352
+ r.reconnectTimer = null;
1353
+ }
1354
+ cancelSettleDrop(r);
1355
+ if (r.idleDropTimer) {
1356
+ clearTimeout(r.idleDropTimer);
1357
+ r.idleDropTimer = null;
1358
+ }
1359
+ if (r.wsReady)
1360
+ wsSend(r, { op: "delete" });
1361
+ else if (r.shareId && r.secret) {
1362
+ // turn mode may have dropped the socket after the last settle; the
1363
+ // goodbye still must land — say it over HTTP
1364
+ void fetch(`${r.url}/api/share/${r.shareId}`, {
1365
+ method: "DELETE",
1366
+ headers: { "content-type": "application/json" },
1367
+ body: JSON.stringify({ secret: r.secret }),
1368
+ signal: AbortSignal.timeout(8000),
1369
+ }).catch(() => { });
1370
+ }
1371
+ }
1372
+ setTimeout(() => {
1373
+ for (const r of st.remotes.values()) {
1374
+ try {
1375
+ r.ws?.close();
1376
+ }
1377
+ catch { }
1378
+ }
1379
+ }, 1000);
1380
+ for (const r of st.remotes.values())
1381
+ spoolClear(st, r);
1382
+ sessions.delete(st.sid);
1383
+ try {
1384
+ chain = chain.then(async () => {
1385
+ if (!st.client)
1386
+ return;
1387
+ const res = await st.client.session
1388
+ .get({
1389
+ path: { id: st.sid },
1390
+ ...(deps.directory ? { query: { directory: deps.directory } } : {}),
1391
+ })
1392
+ .catch(() => null);
1393
+ const info = res?.data ?? res;
1394
+ if (info?.metadata?.[METADATA_KEY]) {
1395
+ const metadata = { ...info.metadata };
1396
+ delete metadata[METADATA_KEY];
1397
+ await st.client.session
1398
+ .update({
1399
+ path: { id: st.sid },
1400
+ body: { metadata },
1401
+ ...(deps.directory ? { query: { directory: deps.directory } } : {}),
1402
+ })
1403
+ .catch(() => { });
1404
+ }
1405
+ });
1406
+ }
1407
+ catch { }
1408
+ };
1409
+ // ---- manual-mode backfill ----------------------------------------------
1410
+ // Activation may happen mid-session: fetch the whole session tree
1411
+ // through the client and enqueue everything through the same keyed
1412
+ // queue (idempotent; live events keep overwriting the same keys).
1413
+ const PAGE_SIZE = 100;
1414
+ const MAX_PAGES = 50;
1415
+ const query = () => (deps.directory ? { directory: deps.directory } : {});
1416
+ async function fetchMessages(client, sid) {
1417
+ if (typeof client.session.messages !== "function")
1418
+ return [];
1419
+ const out = [];
1420
+ let before;
1421
+ for (let page = 0; page < MAX_PAGES; page++) {
1422
+ const q = { ...query(), limit: PAGE_SIZE };
1423
+ if (before !== undefined)
1424
+ q.before = before;
1425
+ const result = unwrap(await client.session.messages({
1426
+ path: { id: sid },
1427
+ query: q,
1428
+ }));
1429
+ if (!Array.isArray(result) || result.length === 0)
1430
+ break;
1431
+ out.push(...result);
1432
+ if (result.length < PAGE_SIZE)
1433
+ break;
1434
+ const last = result[result.length - 1];
1435
+ const nextBefore = typeof last?.info?.id === "string" ? last.info.id : typeof last?.id === "string" ? last.id : undefined;
1436
+ if (!nextBefore || nextBefore === before)
1437
+ break;
1438
+ before = nextBefore;
1439
+ }
1440
+ return out;
1441
+ }
1442
+ const backfillSession = async (st, client, sid, info) => {
1443
+ const sessionInfoValue = info ?? (await sessionInfo(client, sid));
1444
+ if (sessionInfoValue)
1445
+ enqueue(st, itemFor("session", redact(sessionInfoValue), sid));
1446
+ const messages = await fetchMessages(client, sid);
1447
+ let modelSeen = false;
1448
+ for (const message of messages) {
1449
+ const m = (message.info ?? message);
1450
+ if (!m || typeof m.id !== "string")
1451
+ continue;
1452
+ enqueue(st, itemFor("message", m, sid));
1453
+ if (m.role === "user" && m.model) {
1454
+ modelSeen = true;
1455
+ const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, m.model);
1456
+ enqueue(st, itemFor("model", [{ id: m.model.modelID, providerID: m.model.providerID, name: label }], sid));
1457
+ }
1458
+ for (const part of message.parts ?? []) {
1459
+ const p = part;
1460
+ if (!p || typeof p.id !== "string" || typeof p.messageID !== "string")
1461
+ continue;
1462
+ if (p.state?.status === "error")
1463
+ st.badSignal = true;
1464
+ enqueue(st, itemFor("part", p, sid));
1465
+ }
1466
+ }
1467
+ if (!modelSeen && sessionInfoValue?.model) {
1468
+ // hosts whose user messages carry no model (v2): label from the
1469
+ // session's own model
1470
+ const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, sessionInfoValue.model);
1471
+ enqueue(st, itemFor("model", [{ id: sessionInfoValue.model.modelID, providerID: sessionInfoValue.model.providerID, name: label }], sid));
1472
+ }
1473
+ return messages;
1474
+ };
1475
+ const backfill = async (st, info) => {
1476
+ const client = st.client;
1477
+ if (!client)
1478
+ return;
1479
+ const messages = await backfillSession(st, client, st.sid, info);
1480
+ if (messages.length === 0 && !info) {
1481
+ // nothing synced yet and no info in hand: the session item is the
1482
+ // one thing a viewer always needs — fetch it explicitly
1483
+ const fetched = await sessionInfo(client, st.sid);
1484
+ if (fetched)
1485
+ enqueue(st, itemFor("session", redact(fetched), st.sid));
1486
+ }
1487
+ // children (subagent sessions) share the parent's page
1488
+ if (typeof client.session.list === "function") {
1489
+ try {
1490
+ const result = unwrap(await client.session.list({ query: query() }));
1491
+ if (Array.isArray(result)) {
1492
+ for (const raw of result) {
1493
+ if (!raw || typeof raw.id !== "string")
1494
+ continue;
1495
+ if (raw.parentID !== st.sid)
1496
+ continue;
1497
+ childToParent.set(raw.id, st.sid);
1498
+ await backfillSession(st, client, raw.id, raw);
1499
+ }
1500
+ }
1501
+ }
1502
+ catch (e) {
1503
+ log("backfill child list failed:", e?.message || e);
1504
+ }
1505
+ }
1506
+ if (typeof client.session.diff === "function") {
1507
+ try {
1508
+ const diff = unwrap(await client.session.diff({
1509
+ path: { id: st.sid },
1510
+ query: query(),
1511
+ }));
1512
+ if (diff !== undefined)
1513
+ enqueue(st, itemFor("session_diff", diff, st.sid));
1514
+ }
1515
+ catch {
1516
+ // diff is best-effort
1517
+ }
1518
+ }
1519
+ // seeded "completed" but history contains errors -> "error"
1520
+ if (st.status === "completed" && st.badSignal)
1521
+ st.status = "error";
1522
+ };
1523
+ // ---- events ------------------------------------------------------------
1524
+ const debugEvents = !!process.env.OPENSHARE_DEBUG_EVENTS;
1525
+ const trackChild = (sid, info) => {
1526
+ if (info?.parentID) {
1527
+ const parent = childToParent.get(sid) ?? info.parentID;
1528
+ childToParent.set(sid, parent);
1529
+ return parent;
1530
+ }
1531
+ return null;
1532
+ };
1533
+ /** A child session whose events arrived BEFORE its session.created got
1534
+ * created as a bogus MAIN st (own share). When the parent relationship
1535
+ * is finally known, merge: move queued items into the parent's share
1536
+ * and retire the bogus one (worker delete + metadata cleanup). */
1537
+ const adoptStrayChild = (childSid, parentSid) => {
1538
+ const stray = sessions.get(childSid);
1539
+ const parent = sessions.get(parentSid);
1540
+ if (!stray || stray.closed)
1541
+ return;
1542
+ if (parent && parent !== stray) {
1543
+ for (const [key, item] of stray.queue)
1544
+ parent.queue.set(key, item);
1545
+ stray.queue.clear();
1546
+ // a stray's remotes may hold shares/pending the parent should adopt:
1547
+ // merge per-server state so the parent's fan-out covers them
1548
+ for (const [url, sr] of stray.remotes) {
1549
+ const pr = remoteFor(parent, url);
1550
+ if (!pr.shareId && sr.shareId) {
1551
+ pr.shareId = sr.shareId;
1552
+ pr.secret = sr.secret;
1553
+ pr.ackedRev = sr.ackedRev;
1554
+ }
1555
+ for (const frame of sr.pending)
1556
+ pr.pending.push(frame);
1557
+ sr.pending = [];
1558
+ sr.shareId = null;
1559
+ sr.secret = null;
1560
+ }
1561
+ scheduleFlush(parent);
1562
+ }
1563
+ for (const sr of stray.remotes.values()) {
1564
+ if (sr.shareId)
1565
+ log(`stray child share ${sr.shareId} (${sr.url}) -> merging into ${parentSid}`);
1566
+ }
1567
+ remove(stray);
1568
+ };
1569
+ const handleSessionEvent = (t, sid, info) => {
1570
+ const parent = trackChild(sid, info);
1571
+ if (mode === "manual") {
1572
+ if (parent) {
1573
+ // children follow their parent's share; a child event never
1574
+ // activates anything on its own
1575
+ const st = sessions.get(parent);
1576
+ if (st?.activated && !st.closed) {
1577
+ if (!st.prefsSent) {
1578
+ st.prefsSent = true;
1579
+ enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
1580
+ }
1581
+ if (info)
1582
+ enqueue(st, itemFor("session", redact(info), sid));
1583
+ }
1584
+ return;
1585
+ }
1586
+ const flag = shareFlag(info);
1587
+ const st = sessions.get(sid);
1588
+ if (flag.enabled) {
1589
+ if (st?.activated && !st.closed) {
1590
+ // already sharing: opts updates apply, flag removal handled below
1591
+ if (flag.opts)
1592
+ st.opts = flag.opts;
1593
+ if (!st.prefsSent) {
1594
+ st.prefsSent = true;
1595
+ enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
1596
+ }
1597
+ if (info)
1598
+ enqueue(st, itemFor("session", redact(info), sid));
1599
+ if (t === "session.updated")
1600
+ chain = chain.then(() => refreshOptOuts(st)).catch(() => { });
1601
+ return;
1602
+ }
1603
+ activate(sid, flag.opts, info);
1604
+ const fresh = sessions.get(sid);
1605
+ if (fresh && !fresh.prefsSent) {
1606
+ fresh.prefsSent = true;
1607
+ enqueue(fresh, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
1608
+ }
1609
+ return;
1610
+ }
1611
+ if (st?.activated && !st.closed && t === "session.updated") {
1612
+ // flag removed -> unshare (same path as session.deleted)
1613
+ log(`share disabled for ${sid} - removing`);
1614
+ chain = chain.then(() => remove(st)).catch((e) => logError("remove error", e?.message || e));
1615
+ return;
1616
+ }
1617
+ return;
1618
+ }
1619
+ // all-mode: NEW sessions share automatically; sessions that predate
1620
+ // plugin start share only when explicitly opted in (metadata flag) or
1621
+ // already carrying a binding (resume keeps the same share). This is
1622
+ // critical: attaching the plugin to a long-lived server must never
1623
+ // retro-share the user's entire session history on first prompt.
1624
+ const main = parent ?? sid;
1625
+ if (parent && parent !== sid && sessions.has(sid)) {
1626
+ // stray child discovered: its events created a bogus main share
1627
+ // before the parentID was known — fold it into the parent's
1628
+ if (!sessions.has(main)) {
1629
+ const created = newSt(main);
1630
+ created.activated = true;
1631
+ sessions.set(main, created);
1632
+ queueConnectAll(created);
1633
+ }
1634
+ adoptStrayChild(sid, main);
1635
+ }
1636
+ let st = sessions.get(main);
1637
+ if (!st && !parent) {
1638
+ const flag = shareFlag(info);
1639
+ const hasBinding = !!info?.metadata?.[METADATA_KEY];
1640
+ const createdMs = info?.time?.created;
1641
+ const isPreExisting = typeof createdMs === "number" && createdMs < bootTime;
1642
+ if (isPreExisting && !flag.enabled && !hasBinding) {
1643
+ if (!preExistingRetired.has(main)) {
1644
+ preExistingRetired.add(main);
1645
+ log(`session ${main} predates plugin start - not shared (set the share flag to share it)`);
1646
+ }
1647
+ return;
1648
+ }
1649
+ if (flag.enabled) {
1650
+ // explicit opt-in on a pre-existing session: full gated activation
1651
+ // (status seed + backfill + connect) so the page carries history
1652
+ activate(main, flag.opts, info, { gated: true });
1653
+ st = sessions.get(main);
1654
+ }
1655
+ else {
1656
+ const created = newSt(main);
1657
+ created.activated = true;
1658
+ sessions.set(main, created);
1659
+ st = created;
1660
+ queueConnectAll(created);
1661
+ }
1662
+ }
1663
+ if (!st)
1664
+ return;
1665
+ if (!st.prefsSent) {
1666
+ st.prefsSent = true;
1667
+ enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
1668
+ }
1669
+ if (info)
1670
+ enqueue(st, itemFor("session", redact(info), sid));
1671
+ if (t === "session.updated" && st.activated && !st.closed) {
1672
+ chain = chain.then(() => refreshOptOuts(st)).catch(() => { });
1673
+ }
1674
+ };
1675
+ return {
1676
+ mode,
1677
+ config: () => cfg,
1678
+ hooks: {
1679
+ event: async ({ event }) => {
1680
+ const t = event?.type;
1681
+ const p = event?.properties || {};
1682
+ const sid = p.sessionID;
1683
+ if (!sid)
1684
+ return;
1685
+ if (debugEvents) {
1686
+ const par = p.info?.parentID;
1687
+ log(`event: ${t} ${sid}${par ? ` (child of ${par})` : ""}`);
1688
+ }
1689
+ if (t === "session.created" || t === "session.updated") {
1690
+ handleSessionEvent(t, sid, p.info);
1691
+ }
1692
+ else if (t === "session.status") {
1693
+ const st = stFor(sid);
1694
+ if (!st)
1695
+ return;
1696
+ const type = p.status?.type;
1697
+ touch(st);
1698
+ if (type === "busy") {
1699
+ // work resumed: a pending completed flip is cancelled (opencode
1700
+ // idles between sequential automation prompts — that is NOT
1701
+ // "done"), a recovered retry is forgiven, share is live again.
1702
+ // The busy epoch is the completed-flip guard: only WORK
1703
+ // invalidates a pending settle (v1.18 republishes the
1704
+ // status/idle pair on failure paths — a repeated idle must
1705
+ // NOT void it, live-caught as a share stuck on live forever)
1706
+ st.statusEpoch++;
1707
+ if (st.completedTimer) {
1708
+ clearTimeout(st.completedTimer);
1709
+ st.completedTimer = null;
1710
+ }
1711
+ st.badSignal = false;
1712
+ st.status = "live";
1713
+ ensureConnected(st);
1714
+ scheduleFlush(st);
1715
+ }
1716
+ else if (type === "idle") {
1717
+ // opencode's idle is authoritative turn end (v1 holds busy
1718
+ // through steps, tool runs and permission waits). CONTENT
1719
+ // flushes now — turn-end batches must not ride the sync
1720
+ // interval (live-caught: subagent finals lagged one interval).
1721
+ // Only the completed flip waits out completedDelayMs, guarded
1722
+ // by the busy epoch: work resuming voids it, repeated idle
1723
+ // publications do not. Dispose flushes a pending flip, so CI
1724
+ // runs still land their final status.
1725
+ if (st.timer)
1726
+ clearTimeout(st.timer);
1727
+ st.timer = null;
1728
+ chain = chain
1729
+ .then(() => flush(st))
1730
+ .catch((e) => logError("idle flush error", e?.message || e));
1731
+ const epoch = st.statusEpoch;
1732
+ const settle = () => {
1733
+ st.status = st.badSignal ? "error" : "completed";
1734
+ st.badSignal = false;
1735
+ if (st.timer)
1736
+ clearTimeout(st.timer);
1737
+ st.timer = null;
1738
+ chain = chain
1739
+ .then(() => flush(st))
1740
+ .then(() => {
1741
+ armSettleDrop(st);
1742
+ })
1743
+ .catch((e) => logError("settle flush error", e?.message || e));
1744
+ };
1745
+ if (cfg.completedDelayMs <= 0) {
1746
+ settle();
1747
+ }
1748
+ else if (!st.completedTimer && !st.closed) {
1749
+ st.completedTimer = setTimeout(() => {
1750
+ st.completedTimer = null;
1751
+ if (st.closed || st.statusEpoch !== epoch)
1752
+ return;
1753
+ settle();
1754
+ }, cfg.completedDelayMs);
1755
+ }
1756
+ }
1757
+ else if (type === "retry") {
1758
+ // scheduled backoff retry (attempt/next): NOT terminal — busy
1759
+ // forgiveness above clears it when work resumes and completes
1760
+ st.badSignal = true;
1761
+ }
1762
+ }
1763
+ else if (t === "session.error") {
1764
+ // terminal turn error (aborted run, context overflow): the share
1765
+ // says error at the following idle; recovered retries never set it
1766
+ const st = stFor(sid);
1767
+ if (st) {
1768
+ st.badSignal = true;
1769
+ touch(st);
1770
+ }
1771
+ }
1772
+ else if (t === "message.removed" || t === "message.part.removed") {
1773
+ // opencode undo: revert() only marks the session (the viewer
1774
+ // hides the range); the NEXT prompt hard-deletes the reverted
1775
+ // messages/parts via cleanup() and publishes these events.
1776
+ // The worker's store is upsert-only, so without an explicit
1777
+ // tombstone the deleted items live forever and reappear the
1778
+ // moment the revert marker clears (live-caught in production).
1779
+ const st = stFor(sid);
1780
+ if (!st || !st.activated || st.closed)
1781
+ return;
1782
+ const messageID = p.messageID;
1783
+ if (!messageID)
1784
+ return;
1785
+ if (t === "message.removed") {
1786
+ enqueue(st, { key: `message/${messageID}`, item: { type: "message", del: true, data: { id: messageID } } });
1787
+ }
1788
+ else {
1789
+ const partID = p.partID;
1790
+ if (!partID)
1791
+ return;
1792
+ enqueue(st, { key: `part/${messageID}/${partID}`, item: { type: "part", del: true, data: { id: partID, messageID } } });
1793
+ }
1794
+ }
1795
+ else if (t === "message.updated") {
1796
+ // resumed sessions may emit the user message BEFORE any session.*
1797
+ // event — in all-mode an unknown sid is optimistically treated as
1798
+ // main and VERIFIED against the session record (children announce
1799
+ // via session.created+parentID first); in manual-mode unknown
1800
+ // sids are dropped (the flag-bearing session.updated activates,
1801
+ // and backfill fills the past)
1802
+ const main = childToParent.get(sid) ?? sid;
1803
+ let st = sessions.get(main);
1804
+ if (!st && mode === "all" && !preExistingRetired.has(main)) {
1805
+ const created = newSt(main);
1806
+ created.activated = true;
1807
+ sessions.set(main, created);
1808
+ st = created;
1809
+ // pre-existing-session gate for the no-info path: verify
1810
+ // against the session record BEFORE connecting (a retired
1811
+ // session never reaches the worker, never announces, never
1812
+ // writes the url file); a pass connects, and queued events
1813
+ // ride the normal pending replay
1814
+ if (!verifying.has(main)) {
1815
+ verifying.add(main);
1816
+ chain = chain
1817
+ .then(async () => {
1818
+ if (created.closed)
1819
+ return;
1820
+ const info = await sessionInfo(created.client, main);
1821
+ verifying.delete(main);
1822
+ if (created.closed)
1823
+ return;
1824
+ const flag = shareFlag(info);
1825
+ const hasBinding = !!info?.metadata?.[METADATA_KEY];
1826
+ const createdMs = info?.time?.created;
1827
+ const isPreExisting = typeof createdMs === "number" && createdMs < bootTime;
1828
+ if (debugEvents) {
1829
+ log(`verify ${main}: created=${createdMs} boot=${bootTime} pre=${isPreExisting} flag=${flag.enabled} binding=${hasBinding} infoMissing=${!info}`);
1830
+ }
1831
+ if (isPreExisting && !flag.enabled && !hasBinding) {
1832
+ preExistingRetired.add(main);
1833
+ log(`session ${main} predates plugin start - not shared (set the share flag to share it)`);
1834
+ remove(created);
1835
+ return;
1836
+ }
1837
+ queueConnectAll(created);
1838
+ })
1839
+ .catch((e) => {
1840
+ verifying.delete(main);
1841
+ logError("verify error", e?.message || e);
1842
+ });
1843
+ }
1844
+ }
1845
+ if (!st || !st.activated || st.closed)
1846
+ return;
1847
+ enqueue(st, itemFor("message", p.info, sid));
1848
+ const info = p.info;
1849
+ if (info?.role === "user" && info?.model) {
1850
+ const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model);
1851
+ enqueue(st, itemFor("model", [{ id: info.model.modelID, providerID: info.model.providerID, name: label }], sid));
1852
+ }
1853
+ // an assistant message that DIED (finish:"error") — e.g. all
1854
+ // retries exhausted — surfaces as a 200 response with an errored
1855
+ // message, NOT as session.error (live-caught in the suite); the
1856
+ // share must still say error
1857
+ if (info?.role === "assistant" && info.finish === "error") {
1858
+ st.badSignal = true;
1859
+ }
1860
+ }
1861
+ else if (t === "message.part.updated") {
1862
+ const st = stFor(sid);
1863
+ if (!st || !st.activated || st.closed)
1864
+ return;
1865
+ const part = p.part;
1866
+ if (part?.state?.status === "error")
1867
+ st.badSignal = true;
1868
+ enqueue(st, itemFor("part", part, sid));
1869
+ }
1870
+ else if (t === "session.diff") {
1871
+ const st = stFor(sid);
1872
+ if (!st || !st.activated || st.closed)
1873
+ return;
1874
+ enqueue(st, itemFor("session_diff", p.diff, sid));
1875
+ }
1876
+ else if (t === "session.deleted") {
1877
+ // a CHILD session ending (task done, gc) must NEVER take the
1878
+ // parent's share down with it — the page keeps the subagent's
1879
+ // output; only the MAIN session's deletion unshares
1880
+ const isChild = childToParent.has(sid);
1881
+ if (isChild) {
1882
+ if (debugEvents)
1883
+ log("event: child session.deleted (ignored)", sid);
1884
+ return;
1885
+ }
1886
+ const st = sessions.get(sid);
1887
+ if (!st)
1888
+ return;
1889
+ chain = chain.then(() => remove(st)).catch((e) => logError("remove error", e?.message || e));
1890
+ }
1891
+ },
1892
+ dispose: async () => {
1893
+ for (const st of sessions.values()) {
1894
+ if (st.completedTimer) {
1895
+ // a pending idle never got its delay: resolve now so the
1896
+ // final status lands even though the process is exiting
1897
+ // (the timer existing implies idle was the last signal; the
1898
+ // epoch guard is satisfied by definition — nothing newer ran)
1899
+ clearTimeout(st.completedTimer);
1900
+ st.completedTimer = null;
1901
+ st.status = st.badSignal ? "error" : "completed";
1902
+ st.badSignal = false;
1903
+ }
1904
+ if (st.timer)
1905
+ clearTimeout(st.timer);
1906
+ st.timer = null;
1907
+ for (const r of st.remotes.values()) {
1908
+ if (r.initTimer) {
1909
+ clearTimeout(r.initTimer);
1910
+ r.initTimer = null;
1911
+ }
1912
+ cancelSettleDrop(r);
1913
+ if (r.idleDropTimer) {
1914
+ clearTimeout(r.idleDropTimer);
1915
+ r.idleDropTimer = null;
1916
+ }
1917
+ }
1918
+ const flushed = flush(st);
1919
+ if (flushed && typeof flushed.then === "function")
1920
+ await flushed.catch(() => { });
1921
+ // dispose with a mid-handshake socket: the deferred pending
1922
+ // would die with the process — land it over HTTP instead
1923
+ for (const r of st.remotes.values()) {
1924
+ if (!r.wsReady && r.shareId && r.pending.length) {
1925
+ await flushViaHttp(st, r).catch(() => { });
1926
+ }
1927
+ }
1928
+ if (st.metaTimer)
1929
+ clearTimeout(st.metaTimer);
1930
+ st.metaTimer = null;
1931
+ await writeBinding(st).catch(() => { });
1932
+ }
1933
+ for (const st of sessions.values()) {
1934
+ for (const r of st.remotes.values()) {
1935
+ try {
1936
+ r.ws?.close();
1937
+ }
1938
+ catch { }
1939
+ }
1940
+ }
1941
+ },
1942
+ },
1943
+ };
1944
+ }