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