@mirrowel/opencode-openshare 0.2.1 → 0.3.0-dev.10

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
48
  import { isAbsolute, 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"
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
@@ -227,9 +230,9 @@ export type Engine = {
227
230
  config: () => ShareConfig
228
231
  }
229
232
 
230
- // Stderr policy (owner directive): in manual mode (TUI present) stderr is
231
- // for ACTUAL ERRORS only — informational lines (live:/url file:/mode
232
- // 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.
233
236
  // All-mode (CI/server-only) keeps its stderr contract (announce lines,
234
237
  // silenceable via announceStderr). Error-class lines always reach stderr.
235
238
  let stderrInfoAllowed = true
@@ -284,6 +287,22 @@ function redact<T extends { metadata?: Record<string, unknown> }>(info: T): T {
284
287
  return { ...info, metadata }
285
288
  }
286
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
+
287
306
  function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: SessionOpts | undefined } {
288
307
  const raw = info?.metadata?.[METADATA_KEY]
289
308
  if (!raw || typeof raw !== "object") return { enabled: false, opts: undefined }
@@ -293,7 +312,9 @@ function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: Ses
293
312
  const opts: SessionOpts = {}
294
313
  if (optsRaw && typeof optsRaw === "object") {
295
314
  const o = optsRaw as Record<string, unknown>
296
- 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)
297
318
  if (typeof o.authLogin === "string") opts.authLogin = o.authLogin
298
319
  if (typeof o.authPassword === "string") opts.authPassword = o.authPassword
299
320
  if (typeof o.modelLabel === "string") opts.modelLabel = o.modelLabel
@@ -306,6 +327,23 @@ function shareFlag(info: SessionInfo | undefined): { enabled: boolean; opts: Ses
306
327
  * with what the engine actually targets - file config alone can miss
307
328
  * plugin-options-layer config the TUI cannot see). */
308
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
+
309
347
  export function engineTargetUrls(): string[] {
310
348
  return liveTargets
311
349
  }
@@ -357,9 +395,9 @@ export function createEngine(deps: EngineDeps): Engine {
357
395
  const resolveTargets = (): ServerTarget[] => {
358
396
  const merged: Record<string, ServerEntry> = {}
359
397
  const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0
360
- // layer-aware legacy rule: a FILE workerUrl beside a FILE servers
361
- // group would silently dual-home the session (live-caught surprise);
362
- // an ENV workerUrl is the highest-priority layer and always joins.
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.
363
401
  const envWorker = !!process.env.OPENSHARE_WORKER_URL
364
402
  if (cfg.workerUrl && (!hasServers || envWorker)) merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true }
365
403
  if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers![cfg.workerUrl]) {
@@ -404,39 +442,99 @@ export function createEngine(deps: EngineDeps): Engine {
404
442
  }
405
443
  seedCredentials()
406
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
+ }
407
490
 
408
491
  const ensureCredential = async (url: string): Promise<string> => {
409
492
  const c = credOf(url)
410
493
  const target = targets.find((t) => t.url === url)
411
- if (c.credential) return c.credential
494
+ if (c.credential) {
495
+ void adoptServerSuggestions(url, c.credential)
496
+ return c.credential
497
+ }
412
498
  if (Date.now() - c.lastEnrollAt < ENROLL_RETRY_MS) return ""
413
499
  c.lastEnrollAt = Date.now()
414
- try {
415
- const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) })
416
- const hb = h.ok ? ((await h.json().catch(() => null)) as { auth?: { mode?: string } } | null) : null
417
- const authMode2 = hb?.auth?.mode
418
- const code = target?.entry.code ?? ""
419
- if (authMode2 === "public" || code) {
420
- const r = await fetch(`${url}/api/enroll`, {
421
- method: "POST",
422
- headers: { "content-type": "application/json" },
423
- body: JSON.stringify({ code }),
424
- signal: AbortSignal.timeout(10000),
425
- })
426
- const b = r.ok ? ((await r.json().catch(() => null)) as { ok?: boolean; token?: string; userId?: string; label?: string; error?: string } | null) : null
427
- if (b?.ok && typeof b.token === "string") {
428
- c.credential = b.token
429
- c.error = ""
430
- const savedTo = saveServerToken(url, c.credential)
431
- log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`)
432
- 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 ""
433
513
  }
434
- c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`
435
- } else if (authMode2 === "private") {
436
- c.error = "private server: add servers.{url}.code (or token) to enroll"
437
- } else {
438
- 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}`
439
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
536
+ }
537
+ c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`
440
538
  } catch (e) {
441
539
  c.error = `enrollment failed: ${(e as Error)?.message || e}`
442
540
  }
@@ -800,10 +898,10 @@ export function createEngine(deps: EngineDeps): Engine {
800
898
  // is absent from `servers`, and dropping the marker would let the next
801
899
  // refreshOptOuts resurrect (and re-create) the opted-out server
802
900
  for (const url of st.optOuts) if (!servers[url]) servers[url] = { optedOut: true }
803
- // A binding in session metadata means opted-in — enabled: true in BOTH
804
- // modes (live-caught: all-mode bindings without `enabled` made the TUI
805
- // gate fall through to the consent dialog on an already-shared session;
806
- // 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".
807
905
  const value: Record<string, unknown> = { v: 2, servers, enabled: true }
808
906
  if (mode === "manual" && st.opts) value.opts = st.opts
809
907
  if (st.lastError) value.lastError = st.lastError
@@ -856,9 +954,9 @@ export function createEngine(deps: EngineDeps): Engine {
856
954
  try {
857
955
  appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n")
858
956
  } catch (e) {
859
- // the frame lives ONLY in memory now — flag the remote so the next
860
- // spoolLoad does NOT replace in-memory pending with the shorter
861
- // 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).
862
960
  r.spoolAppendFailed = true
863
961
  logError("spool append failed", (e as Error)?.message || e)
864
962
  }
@@ -879,10 +977,10 @@ export function createEngine(deps: EngineDeps): Engine {
879
977
  }
880
978
  const spoolLoad = (st: SessionState, r: RemoteState) => {
881
979
  // meta first (share binding recovery), in its OWN try: a missing meta
882
- // file is normal (it's only written after the first successful
883
- // handshake) and must NOT touch pending (live-caught class: the old
884
- // blanket catch wiped in-memory pending AND skipped the queue read,
885
- // 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.
886
984
  try {
887
985
  const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"))
888
986
  // metadata binding (db) is authoritative when both exist and disagree
@@ -1058,7 +1156,20 @@ export function createEngine(deps: EngineDeps): Engine {
1058
1156
  : join(deps.directory ?? process.cwd(), cfg.urlFile)
1059
1157
  : join(deps.directory ?? process.cwd(), "openshare.urls.txt")
1060
1158
  try {
1061
- 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)
1062
1173
  if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
1063
1174
  urlFileAnnounced = true
1064
1175
  lastUrlFile = file
@@ -1091,12 +1202,28 @@ export function createEngine(deps: EngineDeps): Engine {
1091
1202
  return { authLogin: login, authPassword: password }
1092
1203
  }
1093
1204
 
1094
- 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
+ }
1095
1222
 
1096
1223
  /** Apply a metadata opts update. On a CHANGED opts set for a session with
1097
1224
  * live sockets, re-send the attach frame over each open socket: the
1098
1225
  * worker's attach path applies read-auth retroactively (rotating viewer
1099
- * tokens on change) and now also honors an explicit ttlDays override -
1226
+ * tokens on change) and now also honors an explicit ttlHours override -
1100
1227
  * so settings heal immediately instead of waiting for a reconnect.
1101
1228
  * Re-attach is idempotent (no once-guard on init; earlySyncs empty at
1102
1229
  * steady state) and revalidates the token. */
@@ -1108,9 +1235,9 @@ export function createEngine(deps: EngineDeps): Engine {
1108
1235
  for (const [url, r] of st.remotes) {
1109
1236
  if (!r.shareId || !r.secret || !r.wsReady) continue
1110
1237
  const attach: Record<string, unknown> = { id: r.shareId, secret: r.secret, ...authFrame(st) }
1111
- if (st.opts?.ttlDays) attach.ttlDays = st.opts.ttlDays
1238
+ if (st.opts?.ttlHours) attach.ttlHours = st.opts.ttlHours
1112
1239
  const sent = wsSend(r, { op: "init", attach })
1113
- if (sent) log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlDays ? ` (ttl ${st.opts.ttlDays}d)` : ""}`)
1240
+ if (sent) log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`)
1114
1241
  }
1115
1242
  }
1116
1243
 
@@ -1131,9 +1258,9 @@ export function createEngine(deps: EngineDeps): Engine {
1131
1258
 
1132
1259
  /** Single-flight connect queuing: multiple paths (activation, event
1133
1260
  * reconnect, backfill chain) may want a socket before any connect has
1134
- * STARTED — the connecting flag alone guards execution, not the queue,
1135
- * and double connects used to create duplicate shares + init storms
1136
- * (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). */
1137
1264
  const queueConnect = (st: SessionState, r: RemoteState) => {
1138
1265
  if (st.closed || r.connectQueued || r.connecting || r.authBlocked || r.collided) return
1139
1266
  r.connectQueued = true
@@ -1235,12 +1362,12 @@ export function createEngine(deps: EngineDeps): Engine {
1235
1362
 
1236
1363
  const connect = async (st: SessionState, r: RemoteState) => {
1237
1364
  if (st.closed || r.connecting || r.authBlocked || r.collided) return
1238
- // "connecting" spans the WHOLE handshake: socket open + init ack. It
1239
- // is only cleared on init-ok, socket death or the init timeout — an
1240
- // event arriving mid-handshake must NEVER spawn a second socket (the
1241
- // 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
1242
1369
  // raced it, and the loser's late init-ok poisoned the winner's state
1243
- // — live-caught: duplicate shares, mismatch storms, empty pages).
1370
+ // (duplicate shares, mismatch storms, empty pages).
1244
1371
  r.connecting = true
1245
1372
  r.socketSeq += 1
1246
1373
  const seq = r.socketSeq
@@ -1256,9 +1383,9 @@ export function createEngine(deps: EngineDeps): Engine {
1256
1383
  spoolLoad(st, r)
1257
1384
  askPolicy(r.url)
1258
1385
  // was this socket's init an ATTACH (resume) or a CREATE? A create
1259
- // ack legitimately starts at rev 0 — the mismatch guard is for
1260
- // attaches only (live-caught: create-ack rev 0 tripped it and
1261
- // 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.
1262
1389
  r.initWasAttach = !!(r.shareId && r.secret)
1263
1390
  if (debugEvents) log(`ws ${r.url.slice(0, 24)} #${seq} opening (${r.initWasAttach ? `attach ${r.shareId}` : "create"})`)
1264
1391
  // creates need a credential (enrolls on demand — first contact with
@@ -1346,7 +1473,7 @@ export function createEngine(deps: EngineDeps): Engine {
1346
1473
  if (r.initWasAttach) {
1347
1474
  wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } })
1348
1475
  } else {
1349
- 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() } : {}) } })
1350
1477
  }
1351
1478
 
1352
1479
  // handshake deadline: init sent but no ack (silent worker death)
@@ -1521,9 +1648,9 @@ export function createEngine(deps: EngineDeps): Engine {
1521
1648
  return
1522
1649
  }
1523
1650
  // share swept / removed / revoked: start fresh, never merge into a
1524
- // bad base (mismatch protocol). Content REPLAYS to the new share —
1525
- // pending is never dropped on a reset (live-caught: the recreate
1526
- // 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.
1527
1654
  if (/limit|too many|rate/i.test(err)) {
1528
1655
  // quota/429 ("max shares reached", "daily create limit", the
1529
1656
  // anonymous caps): an immediate re-init on the same socket is a
@@ -1546,19 +1673,19 @@ export function createEngine(deps: EngineDeps): Engine {
1546
1673
  r.shareId = null
1547
1674
  r.secret = null
1548
1675
  r.ackedRev = 0
1549
- 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() } : {}) } })
1550
1677
  return
1551
1678
  }
1552
1679
  if (r.initWasAttach && typeof frame.rev === "number" && frame.rev < r.ackedRev) {
1553
- // worker lost data behind our back (ATTACH only — a create ack
1554
- // legitimately starts at rev 0; the old guard misread that as
1555
- // 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)
1556
1683
  logError(`${r.url}: mismatch: worker rev ${frame.rev} < acked ${r.ackedRev} - recreating`)
1557
1684
  deleteAbandoned(r)
1558
1685
  r.shareId = null
1559
1686
  r.secret = null
1560
1687
  r.ackedRev = 0
1561
- 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() } : {}) } })
1562
1689
  return
1563
1690
  }
1564
1691
  const wasFanoutCreate = !r.initWasAttach && st.everConnected
@@ -1578,11 +1705,10 @@ export function createEngine(deps: EngineDeps): Engine {
1578
1705
  clearTimeout(r.initTimer)
1579
1706
  r.initTimer = null
1580
1707
  }
1581
- // backoff reset is DEFERRED until the socket proves stable: a
1582
- // server that accepts init then immediately drops would otherwise
1583
- // reconnect once per second forever (~1 cycle/s per session-server
1584
- // pair, live-caught in the optimization audit). 30s alive (socket
1585
- // 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.
1586
1712
  if (r.backoffResetTimer) clearTimeout(r.backoffResetTimer)
1587
1713
  r.backoffResetTimer = setTimeout(() => {
1588
1714
  r.backoffResetTimer = null
@@ -1595,9 +1721,10 @@ export function createEngine(deps: EngineDeps): Engine {
1595
1721
  // the ack carries the RESOLVED ttl: surface a server/token clamp to
1596
1722
  // the binding once, and a successful handshake clears any stale
1597
1723
  // refusal reason the TUI may be showing
1598
- if (typeof frame.ttlDays === "number") {
1724
+ const ackTtl = typeof frame.ttlHours === "number" ? frame.ttlHours : (typeof frame.ttlDays === "number" ? frame.ttlDays * 24 : undefined)
1725
+ if (typeof ackTtl === "number") {
1599
1726
  const wanted = ttlFor(st)
1600
- const clamp = wanted != null && frame.ttlDays !== wanted ? { requested: wanted, effective: frame.ttlDays } : null
1727
+ const clamp = wanted != null && ackTtl !== wanted ? { requested: wanted, effective: ackTtl } : null
1601
1728
  if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
1602
1729
  st.ttlClamped = clamp
1603
1730
  scheduleBindingWrite(st)
@@ -1607,6 +1734,36 @@ export function createEngine(deps: EngineDeps): Engine {
1607
1734
  st.lastError = null
1608
1735
  scheduleBindingWrite(st)
1609
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
+ }
1610
1767
  announceUrl(st, r)
1611
1768
  // first binding write is immediate (the TUI waits on the URL in
1612
1769
  // session metadata); ack-driven rev updates stay debounced
@@ -1623,12 +1780,12 @@ export function createEngine(deps: EngineDeps): Engine {
1623
1780
  }
1624
1781
  if (wasFanoutCreate) {
1625
1782
  // a server joined an ALREADY-live session (fan-out add, or a
1626
- // post-mismatch recreate): its fresh share needs the full history —
1627
- // backfill is keyed-idempotent, other servers just re-upsert.
1628
- // The enqueue dedupe must step aside: those hashes say "already
1629
- // entered the machinery", but the NEW remote never received any
1630
- // of it (live-caught by F3: the join's backfill was fully
1631
- // 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.
1632
1789
  st.sentHash.clear()
1633
1790
  chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", (e as Error)?.message || e))
1634
1791
  }
@@ -1733,8 +1890,8 @@ export function createEngine(deps: EngineDeps): Engine {
1733
1890
  }
1734
1891
 
1735
1892
  // returns a promise when the frame goes out via HTTP (the dispose path
1736
- // awaits it - an unawaited fetch dies with the exiting process and the
1737
- // 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")
1738
1895
  const flush = (st: SessionState): Promise<unknown> | undefined => {
1739
1896
  if (st.closed) return
1740
1897
  const f = frameFrom(st)
@@ -1742,8 +1899,8 @@ export function createEngine(deps: EngineDeps): Engine {
1742
1899
  // fan out to every remote; each carries its own pending copy and acks
1743
1900
  // independently. Remotes mid-handshake or auth-blocked just buffer.
1744
1901
  // PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
1745
- // chunk-frames so a partial-ack can never erase unsent chunks
1746
- // (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).
1747
1904
  const chunks: Array<{ items: unknown[]; json: string }> = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : []
1748
1905
  let httpWork: Promise<unknown> | undefined
1749
1906
  for (const r of st.remotes.values()) {
@@ -1767,8 +1924,8 @@ export function createEngine(deps: EngineDeps): Engine {
1767
1924
  }
1768
1925
  // never send syncs before the init ack: the worker's per-socket
1769
1926
  // ControlDO has no attach until OUR init completes, and a racing
1770
- // sync dies as "unknown op" (live-caught: post-drop reconnects
1771
- // 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.
1772
1929
  if (r.ws && !r.wsReady) continue
1773
1930
  if (r.wsReady) {
1774
1931
  for (const cf of fresh) sendSync(r, cf)
@@ -1803,13 +1960,12 @@ export function createEngine(deps: EngineDeps): Engine {
1803
1960
 
1804
1961
  const flushViaHttp = async (st: SessionState, r: RemoteState) => {
1805
1962
  if (!r.shareId || !r.pending.length) return
1806
- // ALL pending frames, merged (keyed upserts — later wins). Sending
1807
- // only the last frame dropped every earlier batch's items whenever
1808
- // the HTTP fallback carried more than one (live-caught class:
1809
- // content loss on reconnect paths). DEDUPE BY KEY before chunking:
1810
- // a streaming message appearing in k pending frames shipped k
1811
- // copies (up to 25x on a replay burst) — every duplicate was also a
1812
- // 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.
1813
1969
  const byKey = new Map<string, unknown>()
1814
1970
  const unkeyed: unknown[] = []
1815
1971
  let status: string | undefined
@@ -1876,15 +2032,19 @@ export function createEngine(deps: EngineDeps): Engine {
1876
2032
  }, wait)
1877
2033
  }
1878
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
+
1879
2039
  const enqueue = (st: SessionState, entry: ItemEntry | undefined) => {
1880
2040
  if (!st || !entry) return
1881
2041
  // content dedupe at the machinery boundary: an identical re-feed has
1882
- // either entered the queue already or been flushed into every
1883
- // remote's pending/spool (the per-remote delivery machinery owns it
1884
- // from there) — re-sending it bumps rev, rewrites storage and
1885
- // re-folds every viewer for nothing. This is the engine-side home
1886
- // the v2-adapter dedupe lacked (an adapter-side cache poisoned while
1887
- // 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.
1888
2048
  let h: string
1889
2049
  try {
1890
2050
  h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex")
@@ -1896,6 +2056,24 @@ export function createEngine(deps: EngineDeps): Engine {
1896
2056
  return
1897
2057
  }
1898
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
+ }
1899
2077
  st.queue.set(entry.key, entry.item)
1900
2078
  indexMessage(entry)
1901
2079
  if (debugEvents) log(`enqueue sid=${st.sid.slice(0, 18)} ${entry.item.type} queue=${st.queue.size}`)
@@ -1904,6 +2082,24 @@ export function createEngine(deps: EngineDeps): Engine {
1904
2082
  scheduleFlush(st)
1905
2083
  }
1906
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
+
1907
2103
  // ---- authoritative stamps ----------------------------------------------
1908
2104
  // The viewer renders stamped facts and never re-derives them (owner
1909
2105
  // directive). Both stamps are computed at frame time across the whole
@@ -1919,10 +2115,9 @@ export function createEngine(deps: EngineDeps): Engine {
1919
2115
  }
1920
2116
  const msgIndex = new Map<string, IndexedMessage>() // `${sid}:${messageID}`
1921
2117
  /** message ids tombstoned this run: stampsPass must never re-upsert an
1922
- * undone message (live-caught class: dropMergedMessage removed the
1923
- * merged entry but msgIndex kept the corpse; a neighboring-segment
1924
- * change recomputed its stamps and reEnqueueMessage resurrected the
1925
- * 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. */
1926
2121
  const tombstoned = new Set<string>()
1927
2122
 
1928
2123
  const indexMessage = (entry: ItemEntry) => {
@@ -1985,11 +2180,11 @@ export function createEngine(deps: EngineDeps): Engine {
1985
2180
  const agent = segmentAgent(list, users[k])
1986
2181
  const d = list[users[k]].data as Record<string, any>
1987
2182
  const want = k > 0 && agent && prev && agent !== prev ? agent : undefined
1988
- // only touch metadata when the stamp actually CHANGES — a blanket
1989
- // `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
1990
2185
  // host's live objects on v2 pass-through) with an empty metadata
1991
- // object, which re-emitted the message once per refresh (rev churn
1992
- // 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.
1993
2188
  if (want !== (d.metadata?.agentSwitch as string | undefined)) {
1994
2189
  if (want) {
1995
2190
  const meta = (d.metadata ??= {}) as Record<string, unknown>
@@ -2053,8 +2248,8 @@ export function createEngine(deps: EngineDeps): Engine {
2053
2248
  }
2054
2249
 
2055
2250
  /** file tools get metadata.language (shiki lang id, from the path the
2056
- * call touched) - the viewer highlights from the stamp and never guesses
2057
- * (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. */
2058
2253
  const LANG_BY_EXT: Record<string, string> = {
2059
2254
  ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
2060
2255
  js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
@@ -2065,7 +2260,44 @@ export function createEngine(deps: EngineDeps): Engine {
2065
2260
  sql: "sql", c: "c", h: "c", cpp: "cpp", hpp: "cpp", cs: "csharp",
2066
2261
  swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
2067
2262
  }
2263
+
2068
2264
  const FILE_TOOLS = new Set(["read", "write", "edit", "patch"])
2265
+
2266
+ /** A read that returns lines from inside an html file's <script>/<style>
2267
+ * block gets tokenized as html by the viewer, and a snippet with no tag
2268
+ * context tokenizes as untyped soup (one default span per line). The
2269
+ * viewer only sees the snippet, so the stamp carries the language that is
2270
+ * true for the RETURNED RANGE: no tags plus code shape means the range sat
2271
+ * inside an embedded script or style block. */
2272
+ const refineEmbeddedLanguage = (meta: { language?: unknown }, text: string): boolean => {
2273
+ if (meta.language !== "html") return false
2274
+ const head = text.slice(0, 4000)
2275
+ if (/<[a-zA-Z][^>\n]*>/.test(head)) return false
2276
+ let refined: string | undefined
2277
+ if (/^[ \t]*[.#][A-Za-z][\w-]*[^\n{]*\{/m.test(head) && !/\b(function|=>|return)\b/.test(head)) {
2278
+ refined = "css"
2279
+ } else if (/\b(function|const|let|var|return|if|for|while|=>|addEventListener|document\.|window\.)\b/.test(head) || /[;{}]\s*$/m.test(head)) {
2280
+ refined = "javascript"
2281
+ }
2282
+ if (!refined || meta.language === refined) return false
2283
+ meta.language = refined
2284
+ return true
2285
+ }
2286
+
2287
+ /** Sniff the CODE CONTENT of a unified patch: drop everything before the
2288
+ * first hunk header and strip each line's diff marker, so the same
2289
+ * tag/shape checks as reads run over clean source lines. */
2290
+ const refinePatchLanguage = (meta: { language?: unknown }, patchText: string): boolean => {
2291
+ const at = patchText.indexOf("@@")
2292
+ if (at < 0) return false
2293
+ const body = patchText
2294
+ .slice(at)
2295
+ .split("\n")
2296
+ .map((l) => (/^[ +\\]/.test(l) ? l.slice(1) : l.startsWith("-") ? l.slice(1) : ""))
2297
+ .join("\n")
2298
+ return refineEmbeddedLanguage(meta, body)
2299
+ }
2300
+
2069
2301
  const stampFileLanguages = () => {
2070
2302
  for (const m of msgIndex.values()) {
2071
2303
  if (m.role !== "assistant") continue
@@ -2078,6 +2310,10 @@ export function createEngine(deps: EngineDeps): Engine {
2078
2310
  const state = part.state
2079
2311
  const meta = state?.metadata
2080
2312
  if (!meta) continue
2313
+ // paths stay absolute here: the server rewrites them against the
2314
+ // session's working folder on read (wireItems), so old shares get
2315
+ // the same treatment and the host's message objects are never
2316
+ // mutated
2081
2317
  const raw = state.input?.path ?? state.input?.filePath
2082
2318
  // language stamp (shiki id from the path)
2083
2319
  if (typeof meta.language !== "string" && typeof raw === "string") {
@@ -2088,6 +2324,16 @@ export function createEngine(deps: EngineDeps): Engine {
2088
2324
  mutated = true
2089
2325
  }
2090
2326
  }
2327
+ // range refinement: html-stamped reads inside script/style blocks
2328
+ if (part.name === "read" && typeof state.output === "string" && state.output.length > 0) {
2329
+ if (refineEmbeddedLanguage(meta, state.output)) mutated = true
2330
+ }
2331
+ // same refinement for edit hunks: a mid-script html diff tokenizes
2332
+ // untyped without the range language
2333
+ if ((part.name === "edit" || part.name === "patch") && typeof meta.language === "string") {
2334
+ const pt = typeof (meta as any).diff === "string" ? (meta as any).diff : typeof (meta as any).filediff?.patch === "string" ? (meta as any).filediff.patch : undefined
2335
+ if (pt && refinePatchLanguage(meta, pt)) mutated = true
2336
+ }
2091
2337
  // lineStart stamp: reads sliced from an offset keep REAL file line
2092
2338
  // numbers in the viewer's unified file card (pierre numbers from 1;
2093
2339
  // the viewer shifts by this stamp, authoritatively)
@@ -2238,9 +2484,9 @@ export function createEngine(deps: EngineDeps): Engine {
2238
2484
  r.idleDropTimer = null
2239
2485
  }
2240
2486
  // wsReady delete only when THIS remote still owns a share: a stray
2241
- // child teardown clears shareId after adopting into the parent —
2242
- // the ControlDO's server-side attach would otherwise delete the
2243
- // very share the parent just inherited (live-caught adoption churn)
2487
+ // child teardown clears shareId after adopting into the parent, and
2488
+ // the ControlDO's server-side attach would then delete the very
2489
+ // share the parent just inherited.
2244
2490
  if (r.wsReady && r.shareId) {
2245
2491
  wsSend(r, { op: "delete" })
2246
2492
  // backstop: the WS delete is fire-and-forget on the wire — a
@@ -2349,9 +2595,9 @@ export function createEngine(deps: EngineDeps): Engine {
2349
2595
  const lastInfo = (last?.info ?? last) as Record<string, unknown> | undefined
2350
2596
  const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined
2351
2597
  // `before` is an OPAQUE cursor: base64url({id, time}) of the page
2352
- // tail (message-v2.ts codec). A bare message id 400s on hosts with
2353
- // cursor pagination — every v1 backfill silently truncated to one
2354
- // 100-message page (live-caught).
2598
+ // tail (message-v2.ts codec). Hosts with cursor pagination 400 on a
2599
+ // bare message id, which truncates a v1 backfill to its first
2600
+ // 100-message page.
2355
2601
  const lastTime = (lastInfo?.time as Record<string, unknown> | undefined)?.created
2356
2602
  if (!lastID || typeof lastTime !== "number") break
2357
2603
  const nextBefore = cursorFor(lastID, lastTime)
@@ -2368,7 +2614,7 @@ export function createEngine(deps: EngineDeps): Engine {
2368
2614
 
2369
2615
  const backfillSession = async (st: SessionState, client: ClientApi, sid: string, info?: SessionInfo) => {
2370
2616
  const sessionInfoValue = info ?? (await sessionInfo(client, sid))
2371
- if (sessionInfoValue) enqueue(st, itemFor("session", redact(sessionInfoValue), sid))
2617
+ if (sessionInfoValue) enqueue(st, itemFor("session", stripLocalFacts(redact(sessionInfoValue)), sid))
2372
2618
 
2373
2619
  let modelSeen = false
2374
2620
  const consume = (messages: Message[]) => {
@@ -2416,7 +2662,7 @@ export function createEngine(deps: EngineDeps): Engine {
2416
2662
  // nothing synced yet and no info in hand: the session item is the
2417
2663
  // one thing a viewer always needs — fetch it explicitly
2418
2664
  const fetched = await sessionInfo(client, st.sid)
2419
- if (fetched) enqueue(st, itemFor("session", redact(fetched), st.sid))
2665
+ if (fetched) enqueue(st, itemFor("session", stripLocalFacts(redact(fetched)), st.sid))
2420
2666
  }
2421
2667
 
2422
2668
  // children (subagent sessions) share the parent's page
@@ -2508,7 +2754,7 @@ export function createEngine(deps: EngineDeps): Engine {
2508
2754
  // activates anything on its own
2509
2755
  const st = sessions.get(parent)
2510
2756
  if (st?.activated && !st.closed) {
2511
- if (info) enqueue(st, itemFor("session", redact(info), sid))
2757
+ if (info) enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid))
2512
2758
  }
2513
2759
  return
2514
2760
  }
@@ -2518,7 +2764,7 @@ export function createEngine(deps: EngineDeps): Engine {
2518
2764
  if (st?.activated && !st.closed) {
2519
2765
  // already sharing: opts updates apply, flag removal handled below
2520
2766
  if (flag.opts) applyOpts(st, flag.opts)
2521
- if (info) enqueue(st, itemFor("session", redact(info), sid))
2767
+ if (info) enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid))
2522
2768
  if (t === "session.updated") chain = chain.then(() => refreshOptOuts(st)).catch(() => {})
2523
2769
  return
2524
2770
  }
@@ -2578,13 +2824,14 @@ export function createEngine(deps: EngineDeps): Engine {
2578
2824
  }
2579
2825
  }
2580
2826
  if (!st) return
2581
- if (info) enqueue(st, itemFor("session", redact(info), sid))
2827
+ if (info) enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid))
2582
2828
  if (t === "session.updated" && st.activated && !st.closed) {
2583
2829
  chain = chain.then(() => refreshOptOuts(st)).catch(() => {})
2584
2830
  }
2585
2831
  }
2586
2832
 
2587
2833
  return {
2834
+
2588
2835
  mode,
2589
2836
  config: () => cfg,
2590
2837
  hooks: {
@@ -2607,12 +2854,12 @@ export function createEngine(deps: EngineDeps): Engine {
2607
2854
  touch(st)
2608
2855
  if (type === "busy") {
2609
2856
  // work resumed: a pending completed flip is cancelled (opencode
2610
- // idles between sequential automation prompts — that is NOT
2611
- // "done"), a recovered retry is forgiven, share is live again.
2612
- // The busy epoch is the completed-flip guard: only WORK
2613
- // invalidates a pending settle (v1.18 republishes the
2614
- // status/idle pair on failure paths — a repeated idle must
2615
- // NOT void it, live-caught as a share stuck on live forever)
2857
+ // idles between sequential automation prompts, which is NOT
2858
+ // "done"), a recovered retry is forgiven, and the share is live
2859
+ // again. The busy epoch is the completed-flip guard: only WORK
2860
+ // invalidates a pending settle. v1.18 republishes the status/idle
2861
+ // pair on failure paths, and a repeated idle must NOT void it or
2862
+ // the share stays on live forever.
2616
2863
  st.statusEpoch++
2617
2864
  if (st.completedTimer) {
2618
2865
  clearTimeout(st.completedTimer)
@@ -2625,8 +2872,8 @@ export function createEngine(deps: EngineDeps): Engine {
2625
2872
  } else if (type === "idle") {
2626
2873
  // opencode's idle is authoritative turn end (v1 holds busy
2627
2874
  // through steps, tool runs and permission waits). CONTENT
2628
- // flushes now — turn-end batches must not ride the sync
2629
- // interval (live-caught: subagent finals lagged one interval).
2875
+ // flushes now, because turn-end batches must not ride the sync
2876
+ // interval (subagent finals lagged one interval otherwise).
2630
2877
  // Only the completed flip waits out completedDelayMs, guarded
2631
2878
  // by the busy epoch: work resuming voids it, repeated idle
2632
2879
  // publications do not. Dispose flushes a pending flip, so CI
@@ -2758,11 +3005,11 @@ export function createEngine(deps: EngineDeps): Engine {
2758
3005
  const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model)
2759
3006
  enqueue(st, itemFor("model", [{ id: info.model.modelID, providerID: info.model.providerID, name: label }], sid))
2760
3007
  }
2761
- // an assistant message that DIED (finish:"error") — e.g. all
2762
- // retries exhausted — surfaces as a 200 response with an errored
2763
- // message, NOT as session.error (live-caught in the suite); the
2764
- // share must still say error. Works for v2-native messages too
2765
- // (finish lives on the message itself).
3008
+ // an assistant message that DIED (finish:"error"), e.g. all
3009
+ // retries exhausted, surfaces as a 200 response with an errored
3010
+ // message rather than session.error. The share must still say
3011
+ // error. Works for v2-native messages too (finish lives on the
3012
+ // message itself).
2766
3013
  if ((info as { finish?: string; role?: string })?.finish === "error") {
2767
3014
  st.badSignal = true
2768
3015
  }