@mirrowel/opencode-openshare 0.2.0 → 0.3.0-dev.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/src/core.ts CHANGED
@@ -36,18 +36,20 @@
36
36
  // Session metadata shape (metadata.openshare):
37
37
  // - all-mode binding: { id, secret, rev, url }
38
38
  // - manual-mode: { id, secret, rev, url, enabled: true, opts? }
39
- // opts: { ttlDays?, authLogin?, authPassword?, modelLabel? } optional
39
+ // opts: { ttlHours?, authLogin?, authPassword?, modelLabel? } optional
40
40
  // per-session overrides (capture at activation; modelLabel applies
41
41
  // immediately, the rest at create/attach).
42
42
  // - The plugin strips this key from everything it syncs — the write
43
43
  // secret must never reach viewers.
44
44
 
45
45
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
46
+ import { homedir } from "node:os"
46
47
  import { createHash } from "node:crypto"
47
- import { join } from "node:path"
48
- import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken, type ShareConfig, type ShareMode, type ServerEntry } from "./config.js"
48
+ import { isAbsolute, join } from "node:path"
49
+ import { METADATA_KEY, DEFAULTS, configHomePath, configSourceMap, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken, type ShareConfig, type ShareMode, type ServerEntry } from "./config.js"
49
50
  import { CONTRACT_VERSION, cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js"
50
51
  import { queueBindingWrite } from "./binding-queue.js"
52
+ import { activeRules, redactItem, type RedactionHit } from "./redact.js"
51
53
 
52
54
  // --- minimal structural types (hand-written: the standalone bundle must
53
55
  // not depend on the plugin SDK at runtime; shapes verified against
@@ -86,7 +88,7 @@ export type ShareEvent =
86
88
  | { type: "session.deleted"; sessionID: string }
87
89
 
88
90
  export type SessionOpts = {
89
- ttlDays?: number
91
+ ttlHours?: number
90
92
  authLogin?: string
91
93
  authPassword?: string
92
94
  modelLabel?: string
@@ -121,6 +123,7 @@ type RemoteState = {
121
123
  authBlocked: boolean
122
124
  pending: Array<{ items: unknown[]; status: string; seq?: number; wire?: string }> // chunk-frames awaiting ack (spool; wire = prebuilt frame string)
123
125
  frozenRemote?: boolean // server froze the owner mid-session: stop writing
126
+ warnedNoGate?: boolean // B2: one-shot "requested gate missing" warning sent
124
127
  spoolAppendFailed?: boolean // last append errored: memory-only frame exists
125
128
  spoolSaveTimer: ReturnType<typeof setTimeout> | null // debounced ack-path spool rewrite
126
129
  authWaiter?: (() => void) | undefined // frame-auth handshake resolver
@@ -146,7 +149,12 @@ type SessionState = {
146
149
  badSignal: boolean
147
150
  lastSentStatus: string | null
148
151
  closed: boolean
149
- prefsSent: boolean
152
+ /** last create/attach refusal reason surfaced to the TUI (cleared on a
153
+ * successful init-ok); rides the session binding so /share failures can
154
+ * say WHY instead of a generic timeout */
155
+ lastError: string | null
156
+ /** server/token policy clamped our requested TTL: {requested, effective} */
157
+ ttlClamped: { requested: number; effective: number } | null
150
158
  binding: Record<string, unknown> | null // mirrors what was written into session metadata (v2 map)
151
159
  bindingQueried: boolean
152
160
  /** per-session server opt-outs (TUI "stop sharing here" / "leave as-is"):
@@ -222,9 +230,9 @@ export type Engine = {
222
230
  config: () => ShareConfig
223
231
  }
224
232
 
225
- // Stderr policy (owner directive): in manual mode (TUI present) stderr is
226
- // for ACTUAL ERRORS only — informational lines (live:/url file:/mode
227
- // banner, lifecycle traces) are suppressed; the TUI surfaces the URL.
233
+ // Stderr policy: in manual mode (TUI present) stderr is for ACTUAL ERRORS
234
+ // only. Informational lines (live:/url file:/mode banner, lifecycle
235
+ // traces) are suppressed because the TUI surfaces the URL itself.
228
236
  // All-mode (CI/server-only) keeps its stderr contract (announce lines,
229
237
  // silenceable via announceStderr). Error-class lines always reach stderr.
230
238
  let stderrInfoAllowed = true
@@ -279,6 +287,22 @@ function redact<T extends { metadata?: Record<string, unknown> }>(info: T): T {
279
287
  return { ...info, metadata }
280
288
  }
281
289
 
290
+ /** Wave 3 data-inventory split: machine-local facts leave the PUBLIC
291
+ * session item. Viewers recover them from the envelope's private block
292
+ * (gated+authenticated shares only); anonymous shares never store them. */
293
+ function stripLocalFacts<T>(info: T): T {
294
+ if (!info || typeof info !== "object") return info
295
+ const out = { ...(info as Record<string, unknown>) }
296
+ let changed = false
297
+ for (const k of ["directory", "worktree"]) {
298
+ if (out[k] !== undefined) {
299
+ delete out[k]
300
+ changed = true
301
+ }
302
+ }
303
+ return changed ? (out as T) : info
304
+ }
305
+
282
306
  function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: SessionOpts | undefined } {
283
307
  const raw = info?.metadata?.[METADATA_KEY]
284
308
  if (!raw || typeof raw !== "object") return { enabled: false, opts: undefined }
@@ -288,7 +312,9 @@ function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: Ses
288
312
  const opts: SessionOpts = {}
289
313
  if (optsRaw && typeof optsRaw === "object") {
290
314
  const o = optsRaw as Record<string, unknown>
291
- if (typeof o.ttlDays === "number" && o.ttlDays > 0) opts.ttlDays = o.ttlDays
315
+ // legacy days-shaped opts migrate x24 (old bindings)
316
+ if (typeof o.ttlHours === "number" && o.ttlHours > 0) opts.ttlHours = Math.floor(o.ttlHours)
317
+ else if (typeof o.ttlDays === "number" && o.ttlDays > 0) opts.ttlHours = Math.floor(o.ttlDays * 24)
292
318
  if (typeof o.authLogin === "string") opts.authLogin = o.authLogin
293
319
  if (typeof o.authPassword === "string") opts.authPassword = o.authPassword
294
320
  if (typeof o.modelLabel === "string") opts.modelLabel = o.modelLabel
@@ -296,6 +322,32 @@ function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: Ses
296
322
  return { enabled: true, opts: Object.keys(opts).length ? opts : undefined }
297
323
  }
298
324
 
325
+ /** The LIVE engine's resolved target urls (shared module state: the TUI
326
+ * entry reads this so surfaces that gate on "is anything configured" agree
327
+ * with what the engine actually targets - file config alone can miss
328
+ * plugin-options-layer config the TUI cannot see). */
329
+ let liveTargets: string[] = []
330
+ // the live engine registers its counter here (the TUI imports the
331
+ // accessor without holding the engine instance)
332
+ let statsProvider: ((sid: string) => { messages: number; toolCalls: number }) | null = null
333
+
334
+ // Wave 3 preflight ledger: per-session redaction hits (count + first
335
+ // samples, verbatim, so false positives are visible in the consent
336
+ // preview before anything is published)
337
+ const redactionLedger = new Map<string, { count: number; samples: RedactionHit[] }>()
338
+
339
+ /** Preflight stats for the consent dialog: message/tool counts plus the
340
+ * redaction ledger (what would be stripped before publishing). */
341
+ export function sessionStatsFor(sid: string): { messages: number; toolCalls: number; redaction: { count: number; samples: RedactionHit[] } } {
342
+ const led = redactionLedger.get(sid) ?? { count: 0, samples: [] as RedactionHit[] }
343
+ const counts = statsProvider ? statsProvider(sid) : { messages: 0, toolCalls: 0 }
344
+ return { ...counts, redaction: led }
345
+ }
346
+
347
+ export function engineTargetUrls(): string[] {
348
+ return liveTargets
349
+ }
350
+
299
351
  export function createEngine(deps: EngineDeps): Engine {
300
352
  let cfg = loadConfig(deps.pluginDir, deps.options)
301
353
  const mode: ShareMode = resolveShareMode(cfg, deps)
@@ -342,7 +394,15 @@ export function createEngine(deps: EngineDeps): Engine {
342
394
  type ServerTarget = { url: string; entry: ServerEntry }
343
395
  const resolveTargets = (): ServerTarget[] => {
344
396
  const merged: Record<string, ServerEntry> = {}
345
- if (cfg.workerUrl) merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true }
397
+ const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0
398
+ // layer-aware legacy rule: a FILE workerUrl beside a FILE servers group
399
+ // would silently dual-home the session; an ENV workerUrl is the
400
+ // highest-priority layer and always joins.
401
+ const envWorker = !!process.env.OPENSHARE_WORKER_URL
402
+ if (cfg.workerUrl && (!hasServers || envWorker)) merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true }
403
+ if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers![cfg.workerUrl]) {
404
+ log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`)
405
+ }
346
406
  if (cfg.servers) for (const [u, e] of Object.entries(cfg.servers)) merged[u] = { ...merged[u], ...e }
347
407
  const urls = Object.keys(merged)
348
408
  const actives = urls.filter((u) => merged[u].active)
@@ -350,6 +410,7 @@ export function createEngine(deps: EngineDeps): Engine {
350
410
  return chosen.map((url) => ({ url, entry: merged[url] }))
351
411
  }
352
412
  let targets = resolveTargets()
413
+ liveTargets = targets.map((t) => t.url)
353
414
 
354
415
  // ---- per-server credentials (identity) ----------------------------------
355
416
  // Each server enrolls INDEPENDENTLY: a legacy root CREATE_TOKEN works
@@ -381,39 +442,99 @@ export function createEngine(deps: EngineDeps): Engine {
381
442
  }
382
443
  seedCredentials()
383
444
  const ENROLL_RETRY_MS = 60000
445
+ // B4 Class B (server-SUGGESTED modelLabel/shareMode): fetch once per
446
+ // server per boot, adopt ONLY keys with no explicit local source, and
447
+ // persist the adoption to the settings home (owner: no repeated CF
448
+ // fetches). One flag per URL for the whole process lifetime.
449
+ const setsFetched = new Set<string>()
450
+ const adoptServerSuggestions = async (url: string, token: string) => {
451
+ if (setsFetched.has(url)) return
452
+ setsFetched.add(url)
453
+ try {
454
+ const r = await fetch(`${url}/api/token/sets`, {
455
+ headers: { authorization: `Bearer ${token}` },
456
+ signal: AbortSignal.timeout(8000),
457
+ })
458
+ const body = r.ok ? ((await r.json().catch(() => null)) as { sets?: Record<string, { modelLabel?: string; shareMode?: string }> } | null) : null
459
+ const sets = body?.sets ?? {}
460
+ const pick = sets[cfg.configSet ?? ""] ?? Object.values(sets)[0]
461
+ if (!pick) return
462
+ const wantModel = typeof pick.modelLabel === "string" && !pick.modelLabel ? undefined : pick.modelLabel
463
+ const wantMode = pick.shareMode === "all" || pick.shareMode === "manual" ? pick.shareMode : undefined
464
+ // adopt only what is UNSPECIFIED locally: modelLabel sits at the
465
+ // default template, shareMode is unset and env carry no override
466
+ const src = configSourceMap(deps.pluginDir ?? "")
467
+ const modelUnset = cfg.modelLabel === DEFAULTS.modelLabel && !src.modelLabel
468
+ const modeUnset = cfg.shareMode === undefined && !src.shareMode
469
+ const adoptModel = wantModel && modelUnset
470
+ const adoptMode = wantMode && modeUnset
471
+ if (!adoptModel && !adoptMode) return
472
+ // surgical write into the settings home: read, patch just these
473
+ // keys when they are absent, write back (never clobber explicit
474
+ // values that appeared since load)
475
+ const home = configHomePath()
476
+ if (!home) return
477
+ let doc: Record<string, unknown> = {}
478
+ try {
479
+ doc = JSON.parse(readFileSync(home, "utf8"))
480
+ if (!doc || typeof doc !== "object") doc = {}
481
+ } catch {}
482
+ let dirty = false
483
+ if (adoptModel && doc.modelLabel === undefined) { doc.modelLabel = wantModel; dirty = true; log(`${url}: adopted modelLabel from the server config set`) }
484
+ if (adoptMode && doc.shareMode === undefined) { doc.shareMode = wantMode; dirty = true; log(`${url}: adopted shareMode from the server config set`) }
485
+ if (dirty) writeFileSync(home, JSON.stringify(doc, null, 2) + "\n")
486
+ } catch {
487
+ // suggestions are best-effort; a failed fetch never blocks sharing
488
+ }
489
+ }
384
490
 
385
491
  const ensureCredential = async (url: string): Promise<string> => {
386
492
  const c = credOf(url)
387
493
  const target = targets.find((t) => t.url === url)
388
- if (c.credential) return c.credential
494
+ if (c.credential) {
495
+ void adoptServerSuggestions(url, c.credential)
496
+ return c.credential
497
+ }
389
498
  if (Date.now() - c.lastEnrollAt < ENROLL_RETRY_MS) return ""
390
499
  c.lastEnrollAt = Date.now()
391
- try {
392
- const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) })
393
- const hb = h.ok ? ((await h.json().catch(() => null)) as { auth?: { mode?: string } } | null) : null
394
- const authMode2 = hb?.auth?.mode
395
- const code = target?.entry.code ?? ""
396
- if (authMode2 === "public" || code) {
397
- const r = await fetch(`${url}/api/enroll`, {
398
- method: "POST",
399
- headers: { "content-type": "application/json" },
400
- body: JSON.stringify({ code }),
401
- signal: AbortSignal.timeout(10000),
402
- })
403
- const b = r.ok ? ((await r.json().catch(() => null)) as { ok?: boolean; token?: string; userId?: string; label?: string; error?: string } | null) : null
404
- if (b?.ok && typeof b.token === "string") {
405
- c.credential = b.token
406
- c.error = ""
407
- const savedTo = saveServerToken(url, c.credential)
408
- log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`)
409
- return c.credential
500
+ // A configured code IS expressed onboarding intent (headless-safe: CI
501
+ // carries it precisely to enroll). Without one, never auto-enroll:
502
+ // public servers share anonymously (the ask-once TUI flow offers an
503
+ // account), private ones refuse until the user connects via
504
+ // /openshare.
505
+ const code = target?.entry.code ?? ""
506
+ if (!code) {
507
+ try {
508
+ const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) })
509
+ const hb = h.ok ? ((await h.json().catch(() => null)) as { auth?: { mode?: string } } | null) : null
510
+ if (hb?.auth?.mode === "public") {
511
+ c.error = "" // anonymous standing - tokenless create works
512
+ return ""
410
513
  }
411
- c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`
412
- } else if (authMode2 === "private") {
413
- c.error = "private server: add servers.{url}.code (or token) to enroll"
414
- } else {
415
- c.error = `server health unreachable (HTTP ${h.status})`
514
+ c.error = "private server - onboard via /openshare (needs an access code)"
515
+ } catch (e) {
516
+ c.error = `server unreachable: ${(e as Error)?.message || e}`
517
+ }
518
+ logError(`no credential for ${url}: ${c.error}`)
519
+ return ""
520
+ }
521
+ try {
522
+ const r = await fetch(`${url}/api/enroll`, {
523
+ method: "POST",
524
+ headers: { "content-type": "application/json" },
525
+ body: JSON.stringify({ code }),
526
+ signal: AbortSignal.timeout(10000),
527
+ })
528
+ const b = r.ok ? ((await r.json().catch(() => null)) as { ok?: boolean; token?: string; userId?: string; label?: string; error?: string } | null) : null
529
+ if (b?.ok && typeof b.token === "string") {
530
+ c.credential = b.token
531
+ c.error = ""
532
+ const savedTo = saveServerToken(url, c.credential)
533
+ log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`)
534
+ void adoptServerSuggestions(url, c.credential)
535
+ return c.credential
416
536
  }
537
+ c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`
417
538
  } catch (e) {
418
539
  c.error = `enrollment failed: ${(e as Error)?.message || e}`
419
540
  }
@@ -550,7 +671,8 @@ export function createEngine(deps: EngineDeps): Engine {
550
671
  badSignal: false,
551
672
  lastSentStatus: null,
552
673
  closed: false,
553
- prefsSent: false,
674
+ lastError: null,
675
+ ttlClamped: null,
554
676
  binding: null,
555
677
  bindingQueried: false,
556
678
  optOuts: new Set(),
@@ -572,6 +694,7 @@ export function createEngine(deps: EngineDeps): Engine {
572
694
  spoolRoot = spoolDirFor(cfg)
573
695
  const before = new Set(targets.map((t) => t.url))
574
696
  targets = resolveTargets()
697
+ liveTargets = targets.map((t) => t.url)
575
698
  seedCredentials()
576
699
  clampToPolicy()
577
700
  if (debugEvents) {
@@ -775,12 +898,14 @@ export function createEngine(deps: EngineDeps): Engine {
775
898
  // is absent from `servers`, and dropping the marker would let the next
776
899
  // refreshOptOuts resurrect (and re-create) the opted-out server
777
900
  for (const url of st.optOuts) if (!servers[url]) servers[url] = { optedOut: true }
778
- // A binding in session metadata means opted-in — enabled: true in BOTH
779
- // modes (live-caught: all-mode bindings without `enabled` made the TUI
780
- // gate fall through to the consent dialog on an already-shared session;
781
- // live is now uniformly "url && enabled !== false").
901
+ // A binding in session metadata means opted-in, so enabled: true is
902
+ // written in BOTH modes. All-mode bindings without `enabled` made the
903
+ // TUI gate fall through to the consent dialog on an already-shared
904
+ // session; live is uniformly "url && enabled !== false".
782
905
  const value: Record<string, unknown> = { v: 2, servers, enabled: true }
783
906
  if (mode === "manual" && st.opts) value.opts = st.opts
907
+ if (st.lastError) value.lastError = st.lastError
908
+ if (st.ttlClamped) value.ttlClamped = st.ttlClamped
784
909
  st.binding = value
785
910
  st.wroteBinding = true
786
911
  await writeMetadata(st, value)
@@ -829,9 +954,9 @@ export function createEngine(deps: EngineDeps): Engine {
829
954
  try {
830
955
  appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n")
831
956
  } catch (e) {
832
- // the frame lives ONLY in memory now — flag the remote so the next
833
- // spoolLoad does NOT replace in-memory pending with the shorter
834
- // disk queue (silent loss otherwise; live-caught class in review)
957
+ // the frame lives ONLY in memory now, so flag the remote: the next
958
+ // spoolLoad must NOT replace in-memory pending with the shorter disk
959
+ // queue (silent loss otherwise).
835
960
  r.spoolAppendFailed = true
836
961
  logError("spool append failed", (e as Error)?.message || e)
837
962
  }
@@ -852,10 +977,10 @@ export function createEngine(deps: EngineDeps): Engine {
852
977
  }
853
978
  const spoolLoad = (st: SessionState, r: RemoteState) => {
854
979
  // meta first (share binding recovery), in its OWN try: a missing meta
855
- // file is normal (it's only written after the first successful
856
- // handshake) and must NOT touch pending (live-caught class: the old
857
- // blanket catch wiped in-memory pending AND skipped the queue read,
858
- // so everything queued before the first successful connect was lost).
980
+ // file is normal (written only after the first successful handshake)
981
+ // and must NOT touch pending. A blanket catch here wipes in-memory
982
+ // pending AND skips the queue read, so everything queued before the
983
+ // first successful connect is lost.
859
984
  try {
860
985
  const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"))
861
986
  // metadata binding (db) is authoritative when both exist and disagree
@@ -1025,28 +1150,96 @@ export function createEngine(deps: EngineDeps): Engine {
1025
1150
  // Append: CI reads the file after each run; keeping every share
1026
1151
  // URL lets one file serve a whole workflow (the legacy original
1027
1152
  // overwrote with the latest only).
1028
- const file = cfg.urlFile || join(deps.directory ?? process.cwd(), "openshare.urls.txt")
1153
+ const file = cfg.urlFile
1154
+ ? isAbsolute(cfg.urlFile)
1155
+ ? cfg.urlFile
1156
+ : join(deps.directory ?? process.cwd(), cfg.urlFile)
1157
+ : join(deps.directory ?? process.cwd(), "openshare.urls.txt")
1029
1158
  try {
1030
- appendFileSync(file, url + "\n")
1159
+ // "json" format: one machine-readable object per line (CI parses
1160
+ // fields instead of URLs). Status is the engine's current signal
1161
+ // at announce time; the completed flip updates the SHARE, not
1162
+ // this file line.
1163
+ const line =
1164
+ cfg.urlFileFormat === "json"
1165
+ ? JSON.stringify({
1166
+ id: r.shareId,
1167
+ url,
1168
+ status: st.lastSentStatus ?? "waiting",
1169
+ gated: (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "",
1170
+ }) + "\n"
1171
+ : url + "\n"
1172
+ appendFileSync(file, line)
1031
1173
  if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
1032
1174
  urlFileAnnounced = true
1033
1175
  lastUrlFile = file
1034
1176
  log(`url file: ${file}`)
1035
1177
  }
1036
- } catch {}
1178
+ } catch (e) {
1179
+ // a CI pipeline reads this file after the process exits; a silent
1180
+ // failure means the run's share URL is simply gone
1181
+ logError(`url file write failed (${file}):`, (e as Error)?.message || e)
1182
+ }
1037
1183
  }
1038
1184
  }
1039
1185
 
1186
+ let halfAuthWarned = false
1040
1187
  const authFrame = (st: SessionState) => {
1041
1188
  // always send both (empty strings included): attach with empty values
1042
- // REMOVES protection — an intentional owner action. Session opts
1189
+ // REMOVES protection - an intentional owner action. Session opts
1043
1190
  // override the global config when set.
1044
1191
  const login = st.opts?.authLogin ?? cfg.authLogin ?? ""
1045
1192
  const password = st.opts?.authPassword ?? cfg.authPassword ?? ""
1193
+ // both-or-none: the server builds a gate only from a full pair, so a
1194
+ // half pair silently means "no gate" - refuse to send one and say so
1195
+ if ((login === "") !== (password === "")) {
1196
+ if (!halfAuthWarned) {
1197
+ halfAuthWarned = true
1198
+ logError("read auth needs BOTH a login and a password - gate disabled until fixed")
1199
+ }
1200
+ return { authLogin: "", authPassword: "" }
1201
+ }
1046
1202
  return { authLogin: login, authPassword: password }
1047
1203
  }
1048
1204
 
1049
- const ttlFor = (st: SessionState) => st.opts?.ttlDays ?? cfg.ttlDays
1205
+ const ttlFor = (st: SessionState) => st.opts?.ttlHours ?? cfg.ttlHours
1206
+
1207
+ // free-form labels for team directories/admin listings: ${NAME} refs
1208
+ // resolve from the process env at send time; unresolved refs stay
1209
+ // literal (visible beats silently-empty). Cached env lookup, max 20
1210
+ // keys/500 chars per value (config loader caps the same).
1211
+ const metaFor = (): Record<string, string> | undefined => {
1212
+ if (!cfg.meta || typeof cfg.meta !== "object") return undefined
1213
+ const out: Record<string, string> = {}
1214
+ let n = 0
1215
+ for (const [k, v] of Object.entries(cfg.meta)) {
1216
+ if (n >= 20) break
1217
+ out[k] = v.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => process.env[name] ?? m)
1218
+ n++
1219
+ }
1220
+ return Object.keys(out).length ? out : undefined
1221
+ }
1222
+
1223
+ /** Apply a metadata opts update. On a CHANGED opts set for a session with
1224
+ * live sockets, re-send the attach frame over each open socket: the
1225
+ * worker's attach path applies read-auth retroactively (rotating viewer
1226
+ * tokens on change) and now also honors an explicit ttlHours override -
1227
+ * so settings heal immediately instead of waiting for a reconnect.
1228
+ * Re-attach is idempotent (no once-guard on init; earlySyncs empty at
1229
+ * steady state) and revalidates the token. */
1230
+ const applyOpts = (st: SessionState, opts: SessionOpts | undefined) => {
1231
+ if (!opts) return
1232
+ const prev = st.opts ? JSON.stringify(st.opts) : ""
1233
+ st.opts = opts
1234
+ if (prev === JSON.stringify(opts)) return
1235
+ for (const [url, r] of st.remotes) {
1236
+ if (!r.shareId || !r.secret || !r.wsReady) continue
1237
+ const attach: Record<string, unknown> = { id: r.shareId, secret: r.secret, ...authFrame(st) }
1238
+ if (st.opts?.ttlHours) attach.ttlHours = st.opts.ttlHours
1239
+ const sent = wsSend(r, { op: "init", attach })
1240
+ if (sent) log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`)
1241
+ }
1242
+ }
1050
1243
 
1051
1244
  /** Best-effort cleanup of a share we are about to abandon (refused
1052
1245
  * attach, mismatch recreate). Prevents husk litter; failures are fine —
@@ -1065,9 +1258,9 @@ export function createEngine(deps: EngineDeps): Engine {
1065
1258
 
1066
1259
  /** Single-flight connect queuing: multiple paths (activation, event
1067
1260
  * reconnect, backfill chain) may want a socket before any connect has
1068
- * STARTED — the connecting flag alone guards execution, not the queue,
1069
- * and double connects used to create duplicate shares + init storms
1070
- * (live-caught in the S11 repro: ws#1 create AND ws#2 create). */
1261
+ * STARTED. The connecting flag alone guards execution, not the queue,
1262
+ * and double connects create duplicate shares and init storms (one
1263
+ * create per socket). */
1071
1264
  const queueConnect = (st: SessionState, r: RemoteState) => {
1072
1265
  if (st.closed || r.connectQueued || r.connecting || r.authBlocked || r.collided) return
1073
1266
  r.connectQueued = true
@@ -1130,7 +1323,7 @@ export function createEngine(deps: EngineDeps): Engine {
1130
1323
  st.remotes.delete(url)
1131
1324
  }
1132
1325
  // collisions resolved by the TUI: the binding entry for the server
1133
- // was removed — re-arm unless the same patch also opted out
1326
+ // was removed - re-arm unless the same patch also opted out
1134
1327
  for (const r of [...st.remotes.values()]) {
1135
1328
  if (!r.collided || st.optOuts.has(r.url)) continue
1136
1329
  if (entryUrls.has(r.url)) continue
@@ -1143,17 +1336,38 @@ export function createEngine(deps: EngineDeps): Engine {
1143
1336
  r.announced = false
1144
1337
  queueConnect(st, r)
1145
1338
  }
1339
+ // re-share after "leave as-is": the entry kept its id/secret and the
1340
+ // opt-out marker is gone - re-arm a remote that ATTACHES the same
1341
+ // page (same URL), then backfill so the frozen gap catches up
1342
+ // (dedupe hashes describe the OLD remote set, not this revival)
1343
+ let rearmed = false
1344
+ for (const [url, e] of Object.entries(servers as Record<string, unknown>)) {
1345
+ if (st.optOuts.has(url) || st.remotes.has(url)) continue
1346
+ const ee = e as Record<string, unknown>
1347
+ if (typeof ee.id !== "string" || typeof ee.secret !== "string" || !ee.id) continue
1348
+ const r = remoteFor(st, url)
1349
+ r.shareId = ee.id
1350
+ r.secret = ee.secret
1351
+ r.ackedRev = typeof ee.rev === "number" ? ee.rev : 0
1352
+ log(`re-share: re-arming ${url.slice(0, 24)} on share ${ee.id}`)
1353
+ queueConnect(st, r)
1354
+ rearmed = true
1355
+ }
1356
+ if (rearmed) {
1357
+ st.sentHash.clear()
1358
+ chain = chain.then(() => backfill(st)).catch((e) => logError("re-share backfill error", (e as Error)?.message || e))
1359
+ }
1146
1360
  } catch {}
1147
1361
  }
1148
1362
 
1149
1363
  const connect = async (st: SessionState, r: RemoteState) => {
1150
1364
  if (st.closed || r.connecting || r.authBlocked || r.collided) return
1151
- // "connecting" spans the WHOLE handshake: socket open + init ack. It
1152
- // is only cleared on init-ok, socket death or the init timeout — an
1153
- // event arriving mid-handshake must NEVER spawn a second socket (the
1154
- // old connect() returned after merely SENDING init; a second socket
1365
+ // "connecting" spans the WHOLE handshake: socket open plus init ack.
1366
+ // It is cleared only on init-ok, socket death or the init timeout. An
1367
+ // event arriving mid-handshake must NEVER spawn a second socket: the
1368
+ // old connect() returned after merely SENDING init, a second socket
1155
1369
  // raced it, and the loser's late init-ok poisoned the winner's state
1156
- // — live-caught: duplicate shares, mismatch storms, empty pages).
1370
+ // (duplicate shares, mismatch storms, empty pages).
1157
1371
  r.connecting = true
1158
1372
  r.socketSeq += 1
1159
1373
  const seq = r.socketSeq
@@ -1169,9 +1383,9 @@ export function createEngine(deps: EngineDeps): Engine {
1169
1383
  spoolLoad(st, r)
1170
1384
  askPolicy(r.url)
1171
1385
  // was this socket's init an ATTACH (resume) or a CREATE? A create
1172
- // ack legitimately starts at rev 0 — the mismatch guard is for
1173
- // attaches only (live-caught: create-ack rev 0 tripped it and
1174
- // spawned a fresh husk share with the content dropped).
1386
+ // ack legitimately starts at rev 0, so the mismatch guard is for
1387
+ // attaches only. Applying it to a create spawns a fresh husk share
1388
+ // with the content dropped.
1175
1389
  r.initWasAttach = !!(r.shareId && r.secret)
1176
1390
  if (debugEvents) log(`ws ${r.url.slice(0, 24)} #${seq} opening (${r.initWasAttach ? `attach ${r.shareId}` : "create"})`)
1177
1391
  // creates need a credential (enrolls on demand — first contact with
@@ -1259,7 +1473,7 @@ export function createEngine(deps: EngineDeps): Engine {
1259
1473
  if (r.initWasAttach) {
1260
1474
  wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } })
1261
1475
  } else {
1262
- wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } })
1476
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } })
1263
1477
  }
1264
1478
 
1265
1479
  // handshake deadline: init sent but no ack (silent worker death)
@@ -1387,8 +1601,10 @@ export function createEngine(deps: EngineDeps): Engine {
1387
1601
  const err = String(frame.error ?? "")
1388
1602
  if (/frozen/i.test(err)) {
1389
1603
  // owner frozen: every control path is severed server-side.
1390
- // Re-enrolling would orphan the shares — surface and stop.
1604
+ // Re-enrolling would orphan the shares - surface and stop.
1391
1605
  r.authBlocked = true
1606
+ st.lastError = `share frozen by the hoster (owner frozen) - contact the server admin`
1607
+ scheduleBindingWrite(st)
1392
1608
  logError(`${r.url}: share frozen by the hoster (owner frozen) - edits refused; contact the server admin`)
1393
1609
  try {
1394
1610
  r.ws?.close(4000, "frozen")
@@ -1432,14 +1648,16 @@ export function createEngine(deps: EngineDeps): Engine {
1432
1648
  return
1433
1649
  }
1434
1650
  // share swept / removed / revoked: start fresh, never merge into a
1435
- // bad base (mismatch protocol). Content REPLAYS to the new share —
1436
- // pending is never dropped on a reset (live-caught: the recreate
1437
- // husk had the title but zero messages).
1651
+ // bad base (mismatch protocol). Content REPLAYS to the new share,
1652
+ // and pending is never dropped on a reset: dropping it leaves the
1653
+ // recreate as a husk with the title and zero messages.
1438
1654
  if (/limit|too many|rate/i.test(err)) {
1439
1655
  // quota/429 ("max shares reached", "daily create limit", the
1440
1656
  // anonymous caps): an immediate re-init on the same socket is a
1441
- // hot loop against the server — back off through the normal
1657
+ // hot loop against the server - back off through the normal
1442
1658
  // reconnect cadence instead
1659
+ st.lastError = `create refused: ${frame.error ?? "rate limited"}`
1660
+ scheduleBindingWrite(st)
1443
1661
  logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff")
1444
1662
  r.connecting = false
1445
1663
  try {
@@ -1449,23 +1667,25 @@ export function createEngine(deps: EngineDeps): Engine {
1449
1667
  return
1450
1668
  }
1451
1669
  logError(`${r.url}: init refused:`, frame.error, "- creating fresh share")
1670
+ st.lastError = `init refused: ${frame.error ?? "unknown"}`
1671
+ scheduleBindingWrite(st)
1452
1672
  deleteAbandoned(r)
1453
1673
  r.shareId = null
1454
1674
  r.secret = null
1455
1675
  r.ackedRev = 0
1456
- wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } })
1676
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } })
1457
1677
  return
1458
1678
  }
1459
1679
  if (r.initWasAttach && typeof frame.rev === "number" && frame.rev < r.ackedRev) {
1460
- // worker lost data behind our back (ATTACH only — a create ack
1461
- // legitimately starts at rev 0; the old guard misread that as
1462
- // data loss and spawned a fresh husk, live-caught)
1680
+ // worker lost data behind our back (ATTACH only: a create ack
1681
+ // legitimately starts at rev 0, and misreading that as data loss
1682
+ // spawns a fresh husk)
1463
1683
  logError(`${r.url}: mismatch: worker rev ${frame.rev} < acked ${r.ackedRev} - recreating`)
1464
1684
  deleteAbandoned(r)
1465
1685
  r.shareId = null
1466
1686
  r.secret = null
1467
1687
  r.ackedRev = 0
1468
- wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } })
1688
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } })
1469
1689
  return
1470
1690
  }
1471
1691
  const wasFanoutCreate = !r.initWasAttach && st.everConnected
@@ -1485,11 +1705,10 @@ export function createEngine(deps: EngineDeps): Engine {
1485
1705
  clearTimeout(r.initTimer)
1486
1706
  r.initTimer = null
1487
1707
  }
1488
- // backoff reset is DEFERRED until the socket proves stable: a
1489
- // server that accepts init then immediately drops would otherwise
1490
- // reconnect once per second forever (~1 cycle/s per session-server
1491
- // pair, live-caught in the optimization audit). 30s alive (socket
1492
- // still ready, no reconnect pending) or a second acked sync.
1708
+ // backoff reset is DEFERRED until the socket proves stable: a server
1709
+ // that accepts init then immediately drops would otherwise reconnect
1710
+ // once per second per session-server pair. Stability means 30s alive
1711
+ // (socket still ready, no reconnect pending) or a second acked sync.
1493
1712
  if (r.backoffResetTimer) clearTimeout(r.backoffResetTimer)
1494
1713
  r.backoffResetTimer = setTimeout(() => {
1495
1714
  r.backoffResetTimer = null
@@ -1499,6 +1718,52 @@ export function createEngine(deps: EngineDeps): Engine {
1499
1718
  if (frame.policy) applyPolicy(frame.policy, r.url)
1500
1719
  r.intentionalClose = false
1501
1720
  touch(st)
1721
+ // the ack carries the RESOLVED ttl: surface a server/token clamp to
1722
+ // the binding once, and a successful handshake clears any stale
1723
+ // refusal reason the TUI may be showing
1724
+ const ackTtl = typeof frame.ttlHours === "number" ? frame.ttlHours : (typeof frame.ttlDays === "number" ? frame.ttlDays * 24 : undefined)
1725
+ if (typeof ackTtl === "number") {
1726
+ const wanted = ttlFor(st)
1727
+ const clamp = wanted != null && ackTtl !== wanted ? { requested: wanted, effective: ackTtl } : null
1728
+ if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
1729
+ st.ttlClamped = clamp
1730
+ scheduleBindingWrite(st)
1731
+ }
1732
+ }
1733
+ if (st.lastError) {
1734
+ st.lastError = null
1735
+ scheduleBindingWrite(st)
1736
+ }
1737
+ // ack-level gate assurance (B2): we asked for a gate (both fields
1738
+ // set) but the server reports none - the page is actually public.
1739
+ // Once per remote; the next init-ok re-checks after a fix.
1740
+ const wantedGate = (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "" && (st.opts?.authPassword ?? cfg.authPassword ?? "") !== ""
1741
+ if (wantedGate && frame.auth === false && !r.warnedNoGate) {
1742
+ r.warnedNoGate = true
1743
+ logError(`${r.url}: read auth requested but the page has NO gate - check the server's auth handling`)
1744
+ } else if (frame.auth === true) {
1745
+ r.warnedNoGate = false
1746
+ }
1747
+ // Wave 3 pmeta: machine-local facts (directory/worktree) ride their
1748
+ // OWN tiny sync once per init-ok, only to remotes we hold a
1749
+ // credential for - anonymous (ownerless) shares never store them.
1750
+ // Server-side they land in the private block, readable with share
1751
+ // auth. Idempotent upsert; rides no shared frame so fan-out
1752
+ // remotes with mixed identity each get exactly their due.
1753
+ if (credOf(r.url).credential) {
1754
+ const priv: Record<string, string> = {}
1755
+ const dir = (deps.directory ?? "") as string
1756
+ if (dir) priv.directory = dir
1757
+ if (deps.worktree) priv.worktree = String(deps.worktree)
1758
+ // the sharer's home lets the server mask absolute paths as ~/...
1759
+ try {
1760
+ priv.home = homedir()
1761
+ } catch {}
1762
+ if (Object.keys(priv).length) {
1763
+ const seq = ++nextSeq
1764
+ wsSend(r, { op: "sync", items: [{ type: "pmeta", sessionID: st.sid, data: priv }], status: undefined, seq })
1765
+ }
1766
+ }
1502
1767
  announceUrl(st, r)
1503
1768
  // first binding write is immediate (the TUI waits on the URL in
1504
1769
  // session metadata); ack-driven rev updates stay debounced
@@ -1515,12 +1780,12 @@ export function createEngine(deps: EngineDeps): Engine {
1515
1780
  }
1516
1781
  if (wasFanoutCreate) {
1517
1782
  // a server joined an ALREADY-live session (fan-out add, or a
1518
- // post-mismatch recreate): its fresh share needs the full history —
1519
- // backfill is keyed-idempotent, other servers just re-upsert.
1520
- // The enqueue dedupe must step aside: those hashes say "already
1521
- // entered the machinery", but the NEW remote never received any
1522
- // of it (live-caught by F3: the join's backfill was fully
1523
- // deduped away and the joined share stayed empty).
1783
+ // post-mismatch recreate): its fresh share needs the full history,
1784
+ // and backfill is keyed-idempotent while other servers simply
1785
+ // re-upsert. The enqueue dedupe must step aside here: those hashes
1786
+ // say "already entered the machinery", but the NEW remote received
1787
+ // none of it, so a deduped join backfills nothing and the joined
1788
+ // share stays empty.
1524
1789
  st.sentHash.clear()
1525
1790
  chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", (e as Error)?.message || e))
1526
1791
  }
@@ -1625,8 +1890,8 @@ export function createEngine(deps: EngineDeps): Engine {
1625
1890
  }
1626
1891
 
1627
1892
  // returns a promise when the frame goes out via HTTP (the dispose path
1628
- // awaits it - an unawaited fetch dies with the exiting process and the
1629
- // final status never lands, live-caught as shares stuck on "live")
1893
+ // awaits it: an unawaited fetch dies with the exiting process and the
1894
+ // final status never lands, leaving the share stuck on "live")
1630
1895
  const flush = (st: SessionState): Promise<unknown> | undefined => {
1631
1896
  if (st.closed) return
1632
1897
  const f = frameFrom(st)
@@ -1634,8 +1899,8 @@ export function createEngine(deps: EngineDeps): Engine {
1634
1899
  // fan out to every remote; each carries its own pending copy and acks
1635
1900
  // independently. Remotes mid-handshake or auth-blocked just buffer.
1636
1901
  // PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
1637
- // chunk-frames so a partial-ack can never erase unsent chunks
1638
- // (live-caught class: chunk-1 ack + socket death erased chunks 2..N).
1902
+ // chunk-frames, so a partial ack can never erase unsent chunks (a
1903
+ // chunk-1 ack followed by socket death used to drop chunks 2..N).
1639
1904
  const chunks: Array<{ items: unknown[]; json: string }> = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : []
1640
1905
  let httpWork: Promise<unknown> | undefined
1641
1906
  for (const r of st.remotes.values()) {
@@ -1659,8 +1924,8 @@ export function createEngine(deps: EngineDeps): Engine {
1659
1924
  }
1660
1925
  // never send syncs before the init ack: the worker's per-socket
1661
1926
  // ControlDO has no attach until OUR init completes, and a racing
1662
- // sync dies as "unknown op" (live-caught: post-drop reconnects
1663
- // lost whole turns). The init-ok handler flushes pending itself.
1927
+ // sync dies as "unknown op", losing whole turns on post-drop
1928
+ // reconnects. The init-ok handler flushes pending itself.
1664
1929
  if (r.ws && !r.wsReady) continue
1665
1930
  if (r.wsReady) {
1666
1931
  for (const cf of fresh) sendSync(r, cf)
@@ -1695,13 +1960,12 @@ export function createEngine(deps: EngineDeps): Engine {
1695
1960
 
1696
1961
  const flushViaHttp = async (st: SessionState, r: RemoteState) => {
1697
1962
  if (!r.shareId || !r.pending.length) return
1698
- // ALL pending frames, merged (keyed upserts — later wins). Sending
1699
- // only the last frame dropped every earlier batch's items whenever
1700
- // the HTTP fallback carried more than one (live-caught class:
1701
- // content loss on reconnect paths). DEDUPE BY KEY before chunking:
1702
- // a streaming message appearing in k pending frames shipped k
1703
- // copies (up to 25x on a replay burst) — every duplicate was also a
1704
- // server row-write + a rebroadcast frame.
1963
+ // ALL pending frames, merged (keyed upserts, later wins). Sending only
1964
+ // the last frame dropped every earlier batch's items whenever the HTTP
1965
+ // fallback carried more than one, losing content on reconnect paths.
1966
+ // DEDUPE BY KEY before chunking: a streaming message appearing in k
1967
+ // pending frames shipped k copies (up to 25x on a replay burst), and
1968
+ // every duplicate cost a server row-write plus a rebroadcast frame.
1705
1969
  const byKey = new Map<string, unknown>()
1706
1970
  const unkeyed: unknown[] = []
1707
1971
  let status: string | undefined
@@ -1768,15 +2032,19 @@ export function createEngine(deps: EngineDeps): Engine {
1768
2032
  }, wait)
1769
2033
  }
1770
2034
 
2035
+ const homeDir = (() => { try { return homedir() } catch { return "" } })()
2036
+ const userPatterns = (cfg as { redact?: { patterns?: unknown } }).redact?.patterns
2037
+ const redactionRules = activeRules(userPatterns)
2038
+
1771
2039
  const enqueue = (st: SessionState, entry: ItemEntry | undefined) => {
1772
2040
  if (!st || !entry) return
1773
2041
  // content dedupe at the machinery boundary: an identical re-feed has
1774
- // either entered the queue already or been flushed into every
1775
- // remote's pending/spool (the per-remote delivery machinery owns it
1776
- // from there) — re-sending it bumps rev, rewrites storage and
1777
- // re-folds every viewer for nothing. This is the engine-side home
1778
- // the v2-adapter dedupe lacked (an adapter-side cache poisoned while
1779
- // dormant and skipped the flag-time content — live-caught).
2042
+ // either entered the queue already or been flushed into every remote's
2043
+ // pending/spool, and the per-remote delivery machinery owns it from
2044
+ // there. Re-sending it bumps rev, rewrites storage and re-folds every
2045
+ // viewer for nothing. This is the engine-side home dedupe belongs in:
2046
+ // an adapter-side cache can be poisoned while the session is still
2047
+ // dormant, and then the flag-time content is skipped.
1780
2048
  let h: string
1781
2049
  try {
1782
2050
  h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex")
@@ -1788,6 +2056,24 @@ export function createEngine(deps: EngineDeps): Engine {
1788
2056
  return
1789
2057
  }
1790
2058
  if (h) st.sentHash.set(entry.key, h)
2059
+ // Wave 3 trust pass: redaction on UNGATED shares only (a gated share
2060
+ // is private from casual observers already; the server owner sees
2061
+ // content either way until encryption lands). The light path
2062
+ // transform applies regardless. Zero-copy when nothing matches.
2063
+ {
2064
+ const gate = authFrame(st)
2065
+ const gated = gate.authLogin !== "" && gate.authPassword !== ""
2066
+ const pass = redactItem(entry.item, { rules: redactionRules, home: homeDir, enabled: !gated })
2067
+ if (pass.item !== entry.item) entry = { ...entry, item: pass.item as typeof entry.item }
2068
+ if (pass.hits.length) {
2069
+ const led = redactionLedger.get(st.sid) ?? { count: 0, samples: [] as RedactionHit[] }
2070
+ led.count += pass.hits.length
2071
+ for (const hit of pass.hits) {
2072
+ if (led.samples.length < 5 && !led.samples.some((x) => x.kind === hit.kind)) led.samples.push(hit)
2073
+ }
2074
+ redactionLedger.set(st.sid, led)
2075
+ }
2076
+ }
1791
2077
  st.queue.set(entry.key, entry.item)
1792
2078
  indexMessage(entry)
1793
2079
  if (debugEvents) log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`)
@@ -1796,6 +2082,24 @@ export function createEngine(deps: EngineDeps): Engine {
1796
2082
  scheduleFlush(st)
1797
2083
  }
1798
2084
 
2085
+ // Wave 3 preflight counts: the module-level accessor delegates here
2086
+ statsProvider = (sid: string) => {
2087
+ const st = sessions.get(sid)
2088
+ let messages = 0
2089
+ let toolCalls = 0
2090
+ if (st) {
2091
+ for (const [, merged] of st.merged) {
2092
+ messages++
2093
+ const content = (merged as { content?: unknown }).content
2094
+ if (Array.isArray(content)) toolCalls += content.filter((c: unknown) => !!c && (c as { type?: string }).type === "tool").length
2095
+ }
2096
+ for (const it of st.queue.values()) {
2097
+ if ((it as { type?: string })?.type === "message") messages++
2098
+ }
2099
+ }
2100
+ return { messages, toolCalls }
2101
+ }
2102
+
1799
2103
  // ---- authoritative stamps ----------------------------------------------
1800
2104
  // The viewer renders stamped facts and never re-derives them (owner
1801
2105
  // directive). Both stamps are computed at frame time across the whole
@@ -1811,10 +2115,9 @@ export function createEngine(deps: EngineDeps): Engine {
1811
2115
  }
1812
2116
  const msgIndex = new Map<string, IndexedMessage>() // `${sid}:${messageID}`
1813
2117
  /** message ids tombstoned this run: stampsPass must never re-upsert an
1814
- * undone message (live-caught class: dropMergedMessage removed the
1815
- * merged entry but msgIndex kept the corpse; a neighboring-segment
1816
- * change recomputed its stamps and reEnqueueMessage resurrected the
1817
- * deleted turn server-side). */
2118
+ * undone message. dropMergedMessage removes the merged entry but msgIndex
2119
+ * keeps the corpse, so a neighboring-segment change recomputes its
2120
+ * stamps and reEnqueueMessage resurrects the deleted turn server-side. */
1818
2121
  const tombstoned = new Set<string>()
1819
2122
 
1820
2123
  const indexMessage = (entry: ItemEntry) => {
@@ -1877,11 +2180,11 @@ export function createEngine(deps: EngineDeps): Engine {
1877
2180
  const agent = segmentAgent(list, users[k])
1878
2181
  const d = list[users[k]].data as Record<string, any>
1879
2182
  const want = k > 0 && agent && prev && agent !== prev ? agent : undefined
1880
- // only touch metadata when the stamp actually CHANGES — a blanket
1881
- // `d.metadata ??= {}` polluted every user message (including the
2183
+ // only touch metadata when the stamp actually CHANGES: a blanket
2184
+ // `d.metadata ??= {}` pollutes every user message (including the
1882
2185
  // host's live objects on v2 pass-through) with an empty metadata
1883
- // object, which re-emitted the message once per refresh (rev churn
1884
- // per feed — live-caught by the enqueue-dedupe rev check)
2186
+ // object, which re-emits the message once per refresh and churns
2187
+ // rev per feed.
1885
2188
  if (want !== (d.metadata?.agentSwitch as string | undefined)) {
1886
2189
  if (want) {
1887
2190
  const meta = (d.metadata ??= {}) as Record<string, unknown>
@@ -1945,8 +2248,8 @@ export function createEngine(deps: EngineDeps): Engine {
1945
2248
  }
1946
2249
 
1947
2250
  /** file tools get metadata.language (shiki lang id, from the path the
1948
- * call touched) - the viewer highlights from the stamp and never guesses
1949
- * (owner directive; the viewer's extension map is an old-share fallback) */
2251
+ * call touched). The viewer highlights from this stamp and never
2252
+ * guesses; its extension map only covers old shares without one. */
1950
2253
  const LANG_BY_EXT: Record<string, string> = {
1951
2254
  ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
1952
2255
  js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
@@ -1957,7 +2260,9 @@ export function createEngine(deps: EngineDeps): Engine {
1957
2260
  sql: "sql", c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp",
1958
2261
  swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
1959
2262
  }
2263
+
1960
2264
  const FILE_TOOLS = new Set(["read", "write", "edit", "patch"])
2265
+
1961
2266
  const stampFileLanguages = () => {
1962
2267
  for (const m of msgIndex.values()) {
1963
2268
  if (m.role !== "assistant") continue
@@ -1970,6 +2275,10 @@ export function createEngine(deps: EngineDeps): Engine {
1970
2275
  const state = part.state
1971
2276
  const meta = state?.metadata
1972
2277
  if (!meta) continue
2278
+ // paths stay absolute here: the server rewrites them against the
2279
+ // session's working folder on read (wireItems), so old shares get
2280
+ // the same treatment and the host's message objects are never
2281
+ // mutated
1973
2282
  const raw = state.input?.path ?? state.input?.filePath
1974
2283
  // language stamp (shiki id from the path)
1975
2284
  if (typeof meta.language !== "string" && typeof raw === "string") {
@@ -2130,9 +2439,9 @@ export function createEngine(deps: EngineDeps): Engine {
2130
2439
  r.idleDropTimer = null
2131
2440
  }
2132
2441
  // wsReady delete only when THIS remote still owns a share: a stray
2133
- // child teardown clears shareId after adopting into the parent —
2134
- // the ControlDO's server-side attach would otherwise delete the
2135
- // very share the parent just inherited (live-caught adoption churn)
2442
+ // child teardown clears shareId after adopting into the parent, and
2443
+ // the ControlDO's server-side attach would then delete the very
2444
+ // share the parent just inherited.
2136
2445
  if (r.wsReady && r.shareId) {
2137
2446
  wsSend(r, { op: "delete" })
2138
2447
  // backstop: the WS delete is fire-and-forget on the wire — a
@@ -2241,9 +2550,9 @@ export function createEngine(deps: EngineDeps): Engine {
2241
2550
  const lastInfo = (last?.info ?? last) as Record<string, unknown> | undefined
2242
2551
  const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined
2243
2552
  // `before` is an OPAQUE cursor: base64url({id, time}) of the page
2244
- // tail (message-v2.ts codec). A bare message id 400s on hosts with
2245
- // cursor pagination — every v1 backfill silently truncated to one
2246
- // 100-message page (live-caught).
2553
+ // tail (message-v2.ts codec). Hosts with cursor pagination 400 on a
2554
+ // bare message id, which truncates a v1 backfill to its first
2555
+ // 100-message page.
2247
2556
  const lastTime = (lastInfo?.time as Record<string, unknown> | undefined)?.created
2248
2557
  if (!lastID || typeof lastTime !== "number") break
2249
2558
  const nextBefore = cursorFor(lastID, lastTime)
@@ -2260,7 +2569,7 @@ export function createEngine(deps: EngineDeps): Engine {
2260
2569
 
2261
2570
  const backfillSession = async (st: SessionState, client: ClientApi, sid: string, info?: SessionInfo) => {
2262
2571
  const sessionInfoValue = info ?? (await sessionInfo(client, sid))
2263
- if (sessionInfoValue) enqueue(st, itemFor("session", redact(sessionInfoValue), sid))
2572
+ if (sessionInfoValue) enqueue(st, itemFor("session", stripLocalFacts(redact(sessionInfoValue)), sid))
2264
2573
 
2265
2574
  let modelSeen = false
2266
2575
  const consume = (messages: Message[]) => {
@@ -2308,7 +2617,7 @@ export function createEngine(deps: EngineDeps): Engine {
2308
2617
  // nothing synced yet and no info in hand: the session item is the
2309
2618
  // one thing a viewer always needs — fetch it explicitly
2310
2619
  const fetched = await sessionInfo(client, st.sid)
2311
- if (fetched) enqueue(st, itemFor("session", redact(fetched), st.sid))
2620
+ if (fetched) enqueue(st, itemFor("session", stripLocalFacts(redact(fetched)), st.sid))
2312
2621
  }
2313
2622
 
2314
2623
  // children (subagent sessions) share the parent's page
@@ -2400,11 +2709,7 @@ export function createEngine(deps: EngineDeps): Engine {
2400
2709
  // activates anything on its own
2401
2710
  const st = sessions.get(parent)
2402
2711
  if (st?.activated && !st.closed) {
2403
- if (!st.prefsSent) {
2404
- st.prefsSent = true
2405
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
2406
- }
2407
- if (info) enqueue(st, itemFor("session", redact(info), sid))
2712
+ if (info) enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid))
2408
2713
  }
2409
2714
  return
2410
2715
  }
@@ -2413,21 +2718,12 @@ export function createEngine(deps: EngineDeps): Engine {
2413
2718
  if (flag.enabled) {
2414
2719
  if (st?.activated && !st.closed) {
2415
2720
  // already sharing: opts updates apply, flag removal handled below
2416
- if (flag.opts) st.opts = flag.opts
2417
- if (!st.prefsSent) {
2418
- st.prefsSent = true
2419
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
2420
- }
2421
- if (info) enqueue(st, itemFor("session", redact(info), sid))
2721
+ if (flag.opts) applyOpts(st, flag.opts)
2722
+ if (info) enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid))
2422
2723
  if (t === "session.updated") chain = chain.then(() => refreshOptOuts(st)).catch(() => {})
2423
2724
  return
2424
2725
  }
2425
2726
  activate(sid, flag.opts, info)
2426
- const fresh = sessions.get(sid)
2427
- if (fresh && !fresh.prefsSent) {
2428
- fresh.prefsSent = true
2429
- enqueue(fresh, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
2430
- }
2431
2727
  return
2432
2728
  }
2433
2729
  if (st?.activated && !st.closed && t === "session.updated") {
@@ -2483,17 +2779,14 @@ export function createEngine(deps: EngineDeps): Engine {
2483
2779
  }
2484
2780
  }
2485
2781
  if (!st) return
2486
- if (!st.prefsSent) {
2487
- st.prefsSent = true
2488
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid))
2489
- }
2490
- if (info) enqueue(st, itemFor("session", redact(info), sid))
2782
+ if (info) enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid))
2491
2783
  if (t === "session.updated" && st.activated && !st.closed) {
2492
2784
  chain = chain.then(() => refreshOptOuts(st)).catch(() => {})
2493
2785
  }
2494
2786
  }
2495
2787
 
2496
2788
  return {
2789
+
2497
2790
  mode,
2498
2791
  config: () => cfg,
2499
2792
  hooks: {
@@ -2516,12 +2809,12 @@ export function createEngine(deps: EngineDeps): Engine {
2516
2809
  touch(st)
2517
2810
  if (type === "busy") {
2518
2811
  // work resumed: a pending completed flip is cancelled (opencode
2519
- // idles between sequential automation prompts — that is NOT
2520
- // "done"), a recovered retry is forgiven, share is live again.
2521
- // The busy epoch is the completed-flip guard: only WORK
2522
- // invalidates a pending settle (v1.18 republishes the
2523
- // status/idle pair on failure paths — a repeated idle must
2524
- // NOT void it, live-caught as a share stuck on live forever)
2812
+ // idles between sequential automation prompts, which is NOT
2813
+ // "done"), a recovered retry is forgiven, and the share is live
2814
+ // again. The busy epoch is the completed-flip guard: only WORK
2815
+ // invalidates a pending settle. v1.18 republishes the status/idle
2816
+ // pair on failure paths, and a repeated idle must NOT void it or
2817
+ // the share stays on live forever.
2525
2818
  st.statusEpoch++
2526
2819
  if (st.completedTimer) {
2527
2820
  clearTimeout(st.completedTimer)
@@ -2534,8 +2827,8 @@ export function createEngine(deps: EngineDeps): Engine {
2534
2827
  } else if (type === "idle") {
2535
2828
  // opencode's idle is authoritative turn end (v1 holds busy
2536
2829
  // through steps, tool runs and permission waits). CONTENT
2537
- // flushes now — turn-end batches must not ride the sync
2538
- // interval (live-caught: subagent finals lagged one interval).
2830
+ // flushes now, because turn-end batches must not ride the sync
2831
+ // interval (subagent finals lagged one interval otherwise).
2539
2832
  // Only the completed flip waits out completedDelayMs, guarded
2540
2833
  // by the busy epoch: work resuming voids it, repeated idle
2541
2834
  // publications do not. Dispose flushes a pending flip, so CI
@@ -2667,11 +2960,11 @@ export function createEngine(deps: EngineDeps): Engine {
2667
2960
  const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model)
2668
2961
  enqueue(st, itemFor("model", [{ id: info.model.modelID, providerID: info.model.providerID, name: label }], sid))
2669
2962
  }
2670
- // an assistant message that DIED (finish:"error") — e.g. all
2671
- // retries exhausted — surfaces as a 200 response with an errored
2672
- // message, NOT as session.error (live-caught in the suite); the
2673
- // share must still say error. Works for v2-native messages too
2674
- // (finish lives on the message itself).
2963
+ // an assistant message that DIED (finish:"error"), e.g. all
2964
+ // retries exhausted, surfaces as a 200 response with an errored
2965
+ // message rather than session.error. The share must still say
2966
+ // error. Works for v2-native messages too (finish lives on the
2967
+ // message itself).
2675
2968
  if ((info as { finish?: string; role?: string })?.finish === "error") {
2676
2969
  st.badSignal = true
2677
2970
  }
@@ -2728,7 +3021,7 @@ export function createEngine(deps: EngineDeps): Engine {
2728
3021
  const flushed = flush(st)
2729
3022
  if (flushed && typeof (flushed as Promise<unknown>).then === "function") await flushed.catch(() => {})
2730
3023
  // dispose with a mid-handshake socket: the deferred pending
2731
- // would die with the process — land it over HTTP instead
3024
+ // would die with the process - land it over HTTP instead
2732
3025
  for (const r of st.remotes.values()) {
2733
3026
  if (!r.wsReady && r.shareId && r.pending.length) {
2734
3027
  await flushViaHttp(st, r).catch(() => {})
@@ -2748,6 +3041,23 @@ export function createEngine(deps: EngineDeps): Engine {
2748
3041
  st.metaTimer = null
2749
3042
  await writeBinding(st).catch(() => {})
2750
3043
  }
3044
+ // URL-at-exit barrier: a very short `opencode run` can exit before
3045
+ // the create handshake finishes, and CI reads the urlFile the
3046
+ // moment the process ends. Bounded 5s wait for every share that is
3047
+ // mid-handshake or not yet announced (a dead worker costs exactly
3048
+ // the cap; a healthy one is instant).
3049
+ const barrierStart = Date.now()
3050
+ while (Date.now() - barrierStart < 5000) {
3051
+ let pendingAnnounce = false
3052
+ for (const st of sessions.values()) {
3053
+ for (const r of st.remotes.values()) {
3054
+ if (r.authBlocked || r.collided) continue
3055
+ if ((r.shareId && !r.announced) || (!r.shareId && (r.ws || r.connecting))) pendingAnnounce = true
3056
+ }
3057
+ }
3058
+ if (!pendingAnnounce) break
3059
+ await new Promise((res) => setTimeout(res, 100))
3060
+ }
2751
3061
  for (const st of sessions.values()) {
2752
3062
  for (const r of st.remotes.values()) {
2753
3063
  try {