@mirrowel/opencode-openshare 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +181 -2
- package/ci/README.md +56 -0
- package/ci/openshare.config.json +10 -0
- package/ci/openshare.js +2270 -0
- package/dist/commands.d.ts +7 -0
- package/dist/commands.js +440 -0
- package/dist/config.d.ts +138 -0
- package/dist/config.js +524 -0
- package/dist/core.d.ts +117 -0
- package/dist/core.js +1944 -0
- package/dist/dialogs.d.ts +94 -0
- package/dist/dialogs.js +725 -0
- package/dist/server.d.ts +64 -0
- package/dist/server.js +30 -0
- package/dist/settings.d.ts +24 -0
- package/dist/settings.js +1226 -0
- package/dist/share-flow.d.ts +287 -0
- package/dist/share-flow.js +516 -0
- package/dist/sidebar.d.ts +19 -0
- package/dist/sidebar.js +144 -0
- package/dist/tui-host.d.ts +158 -0
- package/dist/tui-host.js +6 -0
- package/dist/tui.d.ts +17 -0
- package/dist/tui.js +4719 -0
- package/dist/ui-prefs.d.ts +16 -0
- package/dist/ui-prefs.js +27 -0
- package/dist/v2-server.d.ts +81 -0
- package/dist/v2-server.js +402 -0
- package/dist/v2-tui.d.ts +24 -0
- package/dist/v2-tui.js +313 -0
- package/dist/v2-types.d.ts +134 -0
- package/dist/v2-types.js +8 -0
- package/openshare.config.json +16 -0
- package/package.json +98 -4
- package/src/banner.txt +25 -0
- package/src/commands.ts +452 -0
- package/src/config.ts +524 -0
- package/src/core.ts +1997 -0
- package/src/dialogs.tsx +971 -0
- package/src/server.ts +47 -0
- package/src/settings.ts +1239 -0
- package/src/share-flow.ts +551 -0
- package/src/sidebar.tsx +187 -0
- package/src/tui-host.ts +134 -0
- package/src/tui.tsx +318 -0
- package/src/ui-prefs.ts +36 -0
- package/src/v2-server.ts +438 -0
- package/src/v2-tui.ts +315 -0
- package/src/v2-types.ts +102 -0
- 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
|
+
|