@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/dist/core.js CHANGED
@@ -36,17 +36,19 @@
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
  import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
45
+ import { homedir } from "node:os";
45
46
  import { createHash } from "node:crypto";
46
- import { join } from "node:path";
47
- import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
47
+ import { isAbsolute, join } from "node:path";
48
+ import { METADATA_KEY, DEFAULTS, configHomePath, configSourceMap, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
48
49
  import { cursorFor, isV2NativeMessage, messageTombstone, normalizeV1Diff, normalizeV1Message } from "./contract.js";
49
50
  import { queueBindingWrite } from "./binding-queue.js";
51
+ import { activeRules, redactItem } from "./redact.js";
50
52
  const DEFAULT_POLICY = {
51
53
  mode: "persistent",
52
54
  turnGraceMs: 120000,
@@ -56,9 +58,9 @@ const DEFAULT_POLICY = {
56
58
  debounceMaxMs: Infinity,
57
59
  completedDelayMaxMs: Infinity,
58
60
  };
59
- // Stderr policy (owner directive): in manual mode (TUI present) stderr is
60
- // for ACTUAL ERRORS only — informational lines (live:/url file:/mode
61
- // banner, lifecycle traces) are suppressed; the TUI surfaces the URL.
61
+ // Stderr policy: in manual mode (TUI present) stderr is for ACTUAL ERRORS
62
+ // only. Informational lines (live:/url file:/mode banner, lifecycle
63
+ // traces) are suppressed because the TUI surfaces the URL itself.
62
64
  // All-mode (CI/server-only) keeps its stderr contract (announce lines,
63
65
  // silenceable via announceStderr). Error-class lines always reach stderr.
64
66
  let stderrInfoAllowed = true;
@@ -117,6 +119,22 @@ function redact(info) {
117
119
  delete metadata[METADATA_KEY];
118
120
  return { ...info, metadata };
119
121
  }
122
+ /** Wave 3 data-inventory split: machine-local facts leave the PUBLIC
123
+ * session item. Viewers recover them from the envelope's private block
124
+ * (gated+authenticated shares only); anonymous shares never store them. */
125
+ function stripLocalFacts(info) {
126
+ if (!info || typeof info !== "object")
127
+ return info;
128
+ const out = { ...info };
129
+ let changed = false;
130
+ for (const k of ["directory", "worktree"]) {
131
+ if (out[k] !== undefined) {
132
+ delete out[k];
133
+ changed = true;
134
+ }
135
+ }
136
+ return changed ? out : info;
137
+ }
120
138
  function shareFlag(info) {
121
139
  const raw = info?.metadata?.[METADATA_KEY];
122
140
  if (!raw || typeof raw !== "object")
@@ -128,8 +146,11 @@ function shareFlag(info) {
128
146
  const opts = {};
129
147
  if (optsRaw && typeof optsRaw === "object") {
130
148
  const o = optsRaw;
131
- if (typeof o.ttlDays === "number" && o.ttlDays > 0)
132
- opts.ttlDays = o.ttlDays;
149
+ // legacy days-shaped opts migrate x24 (old bindings)
150
+ if (typeof o.ttlHours === "number" && o.ttlHours > 0)
151
+ opts.ttlHours = Math.floor(o.ttlHours);
152
+ else if (typeof o.ttlDays === "number" && o.ttlDays > 0)
153
+ opts.ttlHours = Math.floor(o.ttlDays * 24);
133
154
  if (typeof o.authLogin === "string")
134
155
  opts.authLogin = o.authLogin;
135
156
  if (typeof o.authPassword === "string")
@@ -139,6 +160,28 @@ function shareFlag(info) {
139
160
  }
140
161
  return { enabled: true, opts: Object.keys(opts).length ? opts : undefined };
141
162
  }
163
+ /** The LIVE engine's resolved target urls (shared module state: the TUI
164
+ * entry reads this so surfaces that gate on "is anything configured" agree
165
+ * with what the engine actually targets - file config alone can miss
166
+ * plugin-options-layer config the TUI cannot see). */
167
+ let liveTargets = [];
168
+ // the live engine registers its counter here (the TUI imports the
169
+ // accessor without holding the engine instance)
170
+ let statsProvider = null;
171
+ // Wave 3 preflight ledger: per-session redaction hits (count + first
172
+ // samples, verbatim, so false positives are visible in the consent
173
+ // preview before anything is published)
174
+ const redactionLedger = new Map();
175
+ /** Preflight stats for the consent dialog: message/tool counts plus the
176
+ * redaction ledger (what would be stripped before publishing). */
177
+ export function sessionStatsFor(sid) {
178
+ const led = redactionLedger.get(sid) ?? { count: 0, samples: [] };
179
+ const counts = statsProvider ? statsProvider(sid) : { messages: 0, toolCalls: 0 };
180
+ return { ...counts, redaction: led };
181
+ }
182
+ export function engineTargetUrls() {
183
+ return liveTargets;
184
+ }
142
185
  export function createEngine(deps) {
143
186
  let cfg = loadConfig(deps.pluginDir, deps.options);
144
187
  const mode = resolveShareMode(cfg, deps);
@@ -173,8 +216,16 @@ export function createEngine(deps) {
173
216
  log(`share mode: ${mode} (directory: ${deps.directory ?? "?"} worktree: ${deps.worktree ?? "?"})`);
174
217
  const resolveTargets = () => {
175
218
  const merged = {};
176
- if (cfg.workerUrl)
219
+ const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0;
220
+ // layer-aware legacy rule: a FILE workerUrl beside a FILE servers group
221
+ // would silently dual-home the session; an ENV workerUrl is the
222
+ // highest-priority layer and always joins.
223
+ const envWorker = !!process.env.OPENSHARE_WORKER_URL;
224
+ if (cfg.workerUrl && (!hasServers || envWorker))
177
225
  merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
226
+ if (cfg.workerUrl && hasServers && !envWorker && !cfg.servers[cfg.workerUrl]) {
227
+ log(`legacy workerUrl ${cfg.workerUrl} ignored: a servers group is configured - remove one of the two`);
228
+ }
178
229
  if (cfg.servers)
179
230
  for (const [u, e] of Object.entries(cfg.servers))
180
231
  merged[u] = { ...merged[u], ...e };
@@ -184,6 +235,7 @@ export function createEngine(deps) {
184
235
  return chosen.map((url) => ({ url, entry: merged[url] }));
185
236
  };
186
237
  let targets = resolveTargets();
238
+ liveTargets = targets.map((t) => t.url);
187
239
  // ---- per-server credentials (identity) ----------------------------------
188
240
  // Each server enrolls INDEPENDENTLY: a legacy root CREATE_TOKEN works
189
241
  // forever (server-side bootstrap); an osk_ user token is the normal
@@ -215,42 +267,116 @@ export function createEngine(deps) {
215
267
  };
216
268
  seedCredentials();
217
269
  const ENROLL_RETRY_MS = 60000;
270
+ // B4 Class B (server-SUGGESTED modelLabel/shareMode): fetch once per
271
+ // server per boot, adopt ONLY keys with no explicit local source, and
272
+ // persist the adoption to the settings home (owner: no repeated CF
273
+ // fetches). One flag per URL for the whole process lifetime.
274
+ const setsFetched = new Set();
275
+ const adoptServerSuggestions = async (url, token) => {
276
+ if (setsFetched.has(url))
277
+ return;
278
+ setsFetched.add(url);
279
+ try {
280
+ const r = await fetch(`${url}/api/token/sets`, {
281
+ headers: { authorization: `Bearer ${token}` },
282
+ signal: AbortSignal.timeout(8000),
283
+ });
284
+ const body = r.ok ? (await r.json().catch(() => null)) : null;
285
+ const sets = body?.sets ?? {};
286
+ const pick = sets[cfg.configSet ?? ""] ?? Object.values(sets)[0];
287
+ if (!pick)
288
+ return;
289
+ const wantModel = typeof pick.modelLabel === "string" && !pick.modelLabel ? undefined : pick.modelLabel;
290
+ const wantMode = pick.shareMode === "all" || pick.shareMode === "manual" ? pick.shareMode : undefined;
291
+ // adopt only what is UNSPECIFIED locally: modelLabel sits at the
292
+ // default template, shareMode is unset and env carry no override
293
+ const src = configSourceMap(deps.pluginDir ?? "");
294
+ const modelUnset = cfg.modelLabel === DEFAULTS.modelLabel && !src.modelLabel;
295
+ const modeUnset = cfg.shareMode === undefined && !src.shareMode;
296
+ const adoptModel = wantModel && modelUnset;
297
+ const adoptMode = wantMode && modeUnset;
298
+ if (!adoptModel && !adoptMode)
299
+ return;
300
+ // surgical write into the settings home: read, patch just these
301
+ // keys when they are absent, write back (never clobber explicit
302
+ // values that appeared since load)
303
+ const home = configHomePath();
304
+ if (!home)
305
+ return;
306
+ let doc = {};
307
+ try {
308
+ doc = JSON.parse(readFileSync(home, "utf8"));
309
+ if (!doc || typeof doc !== "object")
310
+ doc = {};
311
+ }
312
+ catch { }
313
+ let dirty = false;
314
+ if (adoptModel && doc.modelLabel === undefined) {
315
+ doc.modelLabel = wantModel;
316
+ dirty = true;
317
+ log(`${url}: adopted modelLabel from the server config set`);
318
+ }
319
+ if (adoptMode && doc.shareMode === undefined) {
320
+ doc.shareMode = wantMode;
321
+ dirty = true;
322
+ log(`${url}: adopted shareMode from the server config set`);
323
+ }
324
+ if (dirty)
325
+ writeFileSync(home, JSON.stringify(doc, null, 2) + "\n");
326
+ }
327
+ catch {
328
+ // suggestions are best-effort; a failed fetch never blocks sharing
329
+ }
330
+ };
218
331
  const ensureCredential = async (url) => {
219
332
  const c = credOf(url);
220
333
  const target = targets.find((t) => t.url === url);
221
- if (c.credential)
334
+ if (c.credential) {
335
+ void adoptServerSuggestions(url, c.credential);
222
336
  return c.credential;
337
+ }
223
338
  if (Date.now() - c.lastEnrollAt < ENROLL_RETRY_MS)
224
339
  return "";
225
340
  c.lastEnrollAt = Date.now();
226
- try {
227
- const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) });
228
- const hb = h.ok ? (await h.json().catch(() => null)) : null;
229
- const authMode2 = hb?.auth?.mode;
230
- const code = target?.entry.code ?? "";
231
- if (authMode2 === "public" || code) {
232
- const r = await fetch(`${url}/api/enroll`, {
233
- method: "POST",
234
- headers: { "content-type": "application/json" },
235
- body: JSON.stringify({ code }),
236
- signal: AbortSignal.timeout(10000),
237
- });
238
- const b = r.ok ? (await r.json().catch(() => null)) : null;
239
- if (b?.ok && typeof b.token === "string") {
240
- c.credential = b.token;
241
- c.error = "";
242
- const savedTo = saveServerToken(url, c.credential);
243
- log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
244
- return c.credential;
341
+ // A configured code IS expressed onboarding intent (headless-safe: CI
342
+ // carries it precisely to enroll). Without one, never auto-enroll:
343
+ // public servers share anonymously (the ask-once TUI flow offers an
344
+ // account), private ones refuse until the user connects via
345
+ // /openshare.
346
+ const code = target?.entry.code ?? "";
347
+ if (!code) {
348
+ try {
349
+ const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) });
350
+ const hb = h.ok ? (await h.json().catch(() => null)) : null;
351
+ if (hb?.auth?.mode === "public") {
352
+ c.error = ""; // anonymous standing - tokenless create works
353
+ return "";
245
354
  }
246
- c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`;
355
+ c.error = "private server - onboard via /openshare (needs an access code)";
247
356
  }
248
- else if (authMode2 === "private") {
249
- c.error = "private server: add servers.{url}.code (or token) to enroll";
357
+ catch (e) {
358
+ c.error = `server unreachable: ${e?.message || e}`;
250
359
  }
251
- else {
252
- c.error = `server health unreachable (HTTP ${h.status})`;
360
+ logError(`no credential for ${url}: ${c.error}`);
361
+ return "";
362
+ }
363
+ try {
364
+ const r = await fetch(`${url}/api/enroll`, {
365
+ method: "POST",
366
+ headers: { "content-type": "application/json" },
367
+ body: JSON.stringify({ code }),
368
+ signal: AbortSignal.timeout(10000),
369
+ });
370
+ const b = r.ok ? (await r.json().catch(() => null)) : null;
371
+ if (b?.ok && typeof b.token === "string") {
372
+ c.credential = b.token;
373
+ c.error = "";
374
+ const savedTo = saveServerToken(url, c.credential);
375
+ log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
376
+ void adoptServerSuggestions(url, c.credential);
377
+ return c.credential;
253
378
  }
379
+ c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`;
254
380
  }
255
381
  catch (e) {
256
382
  c.error = `enrollment failed: ${e?.message || e}`;
@@ -385,7 +511,8 @@ export function createEngine(deps) {
385
511
  badSignal: false,
386
512
  lastSentStatus: null,
387
513
  closed: false,
388
- prefsSent: false,
514
+ lastError: null,
515
+ ttlClamped: null,
389
516
  binding: null,
390
517
  bindingQueried: false,
391
518
  optOuts: new Set(),
@@ -406,6 +533,7 @@ export function createEngine(deps) {
406
533
  spoolRoot = spoolDirFor(cfg);
407
534
  const before = new Set(targets.map((t) => t.url));
408
535
  targets = resolveTargets();
536
+ liveTargets = targets.map((t) => t.url);
409
537
  seedCredentials();
410
538
  clampToPolicy();
411
539
  if (debugEvents) {
@@ -619,13 +747,17 @@ export function createEngine(deps) {
619
747
  for (const url of st.optOuts)
620
748
  if (!servers[url])
621
749
  servers[url] = { optedOut: true };
622
- // A binding in session metadata means opted-in — enabled: true in BOTH
623
- // modes (live-caught: all-mode bindings without `enabled` made the TUI
624
- // gate fall through to the consent dialog on an already-shared session;
625
- // live is now uniformly "url && enabled !== false").
750
+ // A binding in session metadata means opted-in, so enabled: true is
751
+ // written in BOTH modes. All-mode bindings without `enabled` made the
752
+ // TUI gate fall through to the consent dialog on an already-shared
753
+ // session; live is uniformly "url && enabled !== false".
626
754
  const value = { v: 2, servers, enabled: true };
627
755
  if (mode === "manual" && st.opts)
628
756
  value.opts = st.opts;
757
+ if (st.lastError)
758
+ value.lastError = st.lastError;
759
+ if (st.ttlClamped)
760
+ value.ttlClamped = st.ttlClamped;
629
761
  st.binding = value;
630
762
  st.wroteBinding = true;
631
763
  await writeMetadata(st, value);
@@ -675,9 +807,9 @@ export function createEngine(deps) {
675
807
  appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n");
676
808
  }
677
809
  catch (e) {
678
- // the frame lives ONLY in memory now — flag the remote so the next
679
- // spoolLoad does NOT replace in-memory pending with the shorter
680
- // disk queue (silent loss otherwise; live-caught class in review)
810
+ // the frame lives ONLY in memory now, so flag the remote: the next
811
+ // spoolLoad must NOT replace in-memory pending with the shorter disk
812
+ // queue (silent loss otherwise).
681
813
  r.spoolAppendFailed = true;
682
814
  logError("spool append failed", e?.message || e);
683
815
  }
@@ -700,10 +832,10 @@ export function createEngine(deps) {
700
832
  };
701
833
  const spoolLoad = (st, r) => {
702
834
  // meta first (share binding recovery), in its OWN try: a missing meta
703
- // file is normal (it's only written after the first successful
704
- // handshake) and must NOT touch pending (live-caught class: the old
705
- // blanket catch wiped in-memory pending AND skipped the queue read,
706
- // so everything queued before the first successful connect was lost).
835
+ // file is normal (written only after the first successful handshake)
836
+ // and must NOT touch pending. A blanket catch here wipes in-memory
837
+ // pending AND skips the queue read, so everything queued before the
838
+ // first successful connect is lost.
707
839
  try {
708
840
  const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"));
709
841
  // metadata binding (db) is authoritative when both exist and disagree
@@ -887,27 +1019,99 @@ export function createEngine(deps) {
887
1019
  // Append: CI reads the file after each run; keeping every share
888
1020
  // URL lets one file serve a whole workflow (the legacy original
889
1021
  // overwrote with the latest only).
890
- const file = cfg.urlFile || join(deps.directory ?? process.cwd(), "openshare.urls.txt");
1022
+ const file = cfg.urlFile
1023
+ ? isAbsolute(cfg.urlFile)
1024
+ ? cfg.urlFile
1025
+ : join(deps.directory ?? process.cwd(), cfg.urlFile)
1026
+ : join(deps.directory ?? process.cwd(), "openshare.urls.txt");
891
1027
  try {
892
- appendFileSync(file, url + "\n");
1028
+ // "json" format: one machine-readable object per line (CI parses
1029
+ // fields instead of URLs). Status is the engine's current signal
1030
+ // at announce time; the completed flip updates the SHARE, not
1031
+ // this file line.
1032
+ const line = cfg.urlFileFormat === "json"
1033
+ ? JSON.stringify({
1034
+ id: r.shareId,
1035
+ url,
1036
+ status: st.lastSentStatus ?? "waiting",
1037
+ gated: (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "",
1038
+ }) + "\n"
1039
+ : url + "\n";
1040
+ appendFileSync(file, line);
893
1041
  if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
894
1042
  urlFileAnnounced = true;
895
1043
  lastUrlFile = file;
896
1044
  log(`url file: ${file}`);
897
1045
  }
898
1046
  }
899
- catch { }
1047
+ catch (e) {
1048
+ // a CI pipeline reads this file after the process exits; a silent
1049
+ // failure means the run's share URL is simply gone
1050
+ logError(`url file write failed (${file}):`, e?.message || e);
1051
+ }
900
1052
  }
901
1053
  };
1054
+ let halfAuthWarned = false;
902
1055
  const authFrame = (st) => {
903
1056
  // always send both (empty strings included): attach with empty values
904
- // REMOVES protection — an intentional owner action. Session opts
1057
+ // REMOVES protection - an intentional owner action. Session opts
905
1058
  // override the global config when set.
906
1059
  const login = st.opts?.authLogin ?? cfg.authLogin ?? "";
907
1060
  const password = st.opts?.authPassword ?? cfg.authPassword ?? "";
1061
+ // both-or-none: the server builds a gate only from a full pair, so a
1062
+ // half pair silently means "no gate" - refuse to send one and say so
1063
+ if ((login === "") !== (password === "")) {
1064
+ if (!halfAuthWarned) {
1065
+ halfAuthWarned = true;
1066
+ logError("read auth needs BOTH a login and a password - gate disabled until fixed");
1067
+ }
1068
+ return { authLogin: "", authPassword: "" };
1069
+ }
908
1070
  return { authLogin: login, authPassword: password };
909
1071
  };
910
- const ttlFor = (st) => st.opts?.ttlDays ?? cfg.ttlDays;
1072
+ const ttlFor = (st) => st.opts?.ttlHours ?? cfg.ttlHours;
1073
+ // free-form labels for team directories/admin listings: ${NAME} refs
1074
+ // resolve from the process env at send time; unresolved refs stay
1075
+ // literal (visible beats silently-empty). Cached env lookup, max 20
1076
+ // keys/500 chars per value (config loader caps the same).
1077
+ const metaFor = () => {
1078
+ if (!cfg.meta || typeof cfg.meta !== "object")
1079
+ return undefined;
1080
+ const out = {};
1081
+ let n = 0;
1082
+ for (const [k, v] of Object.entries(cfg.meta)) {
1083
+ if (n >= 20)
1084
+ break;
1085
+ out[k] = v.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name) => process.env[name] ?? m);
1086
+ n++;
1087
+ }
1088
+ return Object.keys(out).length ? out : undefined;
1089
+ };
1090
+ /** Apply a metadata opts update. On a CHANGED opts set for a session with
1091
+ * live sockets, re-send the attach frame over each open socket: the
1092
+ * worker's attach path applies read-auth retroactively (rotating viewer
1093
+ * tokens on change) and now also honors an explicit ttlHours override -
1094
+ * so settings heal immediately instead of waiting for a reconnect.
1095
+ * Re-attach is idempotent (no once-guard on init; earlySyncs empty at
1096
+ * steady state) and revalidates the token. */
1097
+ const applyOpts = (st, opts) => {
1098
+ if (!opts)
1099
+ return;
1100
+ const prev = st.opts ? JSON.stringify(st.opts) : "";
1101
+ st.opts = opts;
1102
+ if (prev === JSON.stringify(opts))
1103
+ return;
1104
+ for (const [url, r] of st.remotes) {
1105
+ if (!r.shareId || !r.secret || !r.wsReady)
1106
+ continue;
1107
+ const attach = { id: r.shareId, secret: r.secret, ...authFrame(st) };
1108
+ if (st.opts?.ttlHours)
1109
+ attach.ttlHours = st.opts.ttlHours;
1110
+ const sent = wsSend(r, { op: "init", attach });
1111
+ if (sent)
1112
+ log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`);
1113
+ }
1114
+ };
911
1115
  /** Best-effort cleanup of a share we are about to abandon (refused
912
1116
  * attach, mismatch recreate). Prevents husk litter; failures are fine —
913
1117
  * the TTL sweeper collects strays eventually. */
@@ -925,9 +1129,9 @@ export function createEngine(deps) {
925
1129
  };
926
1130
  /** Single-flight connect queuing: multiple paths (activation, event
927
1131
  * reconnect, backfill chain) may want a socket before any connect has
928
- * STARTED — the connecting flag alone guards execution, not the queue,
929
- * and double connects used to create duplicate shares + init storms
930
- * (live-caught in the S11 repro: ws#1 create AND ws#2 create). */
1132
+ * STARTED. The connecting flag alone guards execution, not the queue,
1133
+ * and double connects create duplicate shares and init storms (one
1134
+ * create per socket). */
931
1135
  const queueConnect = (st, r) => {
932
1136
  if (st.closed || r.connectQueued || r.connecting || r.authBlocked || r.collided)
933
1137
  return;
@@ -1000,7 +1204,7 @@ export function createEngine(deps) {
1000
1204
  st.remotes.delete(url);
1001
1205
  }
1002
1206
  // collisions resolved by the TUI: the binding entry for the server
1003
- // was removed — re-arm unless the same patch also opted out
1207
+ // was removed - re-arm unless the same patch also opted out
1004
1208
  for (const r of [...st.remotes.values()]) {
1005
1209
  if (!r.collided || st.optOuts.has(r.url))
1006
1210
  continue;
@@ -1016,18 +1220,41 @@ export function createEngine(deps) {
1016
1220
  r.announced = false;
1017
1221
  queueConnect(st, r);
1018
1222
  }
1223
+ // re-share after "leave as-is": the entry kept its id/secret and the
1224
+ // opt-out marker is gone - re-arm a remote that ATTACHES the same
1225
+ // page (same URL), then backfill so the frozen gap catches up
1226
+ // (dedupe hashes describe the OLD remote set, not this revival)
1227
+ let rearmed = false;
1228
+ for (const [url, e] of Object.entries(servers)) {
1229
+ if (st.optOuts.has(url) || st.remotes.has(url))
1230
+ continue;
1231
+ const ee = e;
1232
+ if (typeof ee.id !== "string" || typeof ee.secret !== "string" || !ee.id)
1233
+ continue;
1234
+ const r = remoteFor(st, url);
1235
+ r.shareId = ee.id;
1236
+ r.secret = ee.secret;
1237
+ r.ackedRev = typeof ee.rev === "number" ? ee.rev : 0;
1238
+ log(`re-share: re-arming ${url.slice(0, 24)} on share ${ee.id}`);
1239
+ queueConnect(st, r);
1240
+ rearmed = true;
1241
+ }
1242
+ if (rearmed) {
1243
+ st.sentHash.clear();
1244
+ chain = chain.then(() => backfill(st)).catch((e) => logError("re-share backfill error", e?.message || e));
1245
+ }
1019
1246
  }
1020
1247
  catch { }
1021
1248
  };
1022
1249
  const connect = async (st, r) => {
1023
1250
  if (st.closed || r.connecting || r.authBlocked || r.collided)
1024
1251
  return;
1025
- // "connecting" spans the WHOLE handshake: socket open + init ack. It
1026
- // is only cleared on init-ok, socket death or the init timeout — an
1027
- // event arriving mid-handshake must NEVER spawn a second socket (the
1028
- // old connect() returned after merely SENDING init; a second socket
1252
+ // "connecting" spans the WHOLE handshake: socket open plus init ack.
1253
+ // It is cleared only on init-ok, socket death or the init timeout. An
1254
+ // event arriving mid-handshake must NEVER spawn a second socket: the
1255
+ // old connect() returned after merely SENDING init, a second socket
1029
1256
  // raced it, and the loser's late init-ok poisoned the winner's state
1030
- // — live-caught: duplicate shares, mismatch storms, empty pages).
1257
+ // (duplicate shares, mismatch storms, empty pages).
1031
1258
  r.connecting = true;
1032
1259
  r.socketSeq += 1;
1033
1260
  const seq = r.socketSeq;
@@ -1044,9 +1271,9 @@ export function createEngine(deps) {
1044
1271
  spoolLoad(st, r);
1045
1272
  askPolicy(r.url);
1046
1273
  // was this socket's init an ATTACH (resume) or a CREATE? A create
1047
- // ack legitimately starts at rev 0 — the mismatch guard is for
1048
- // attaches only (live-caught: create-ack rev 0 tripped it and
1049
- // spawned a fresh husk share with the content dropped).
1274
+ // ack legitimately starts at rev 0, so the mismatch guard is for
1275
+ // attaches only. Applying it to a create spawns a fresh husk share
1276
+ // with the content dropped.
1050
1277
  r.initWasAttach = !!(r.shareId && r.secret);
1051
1278
  if (debugEvents)
1052
1279
  log(`ws ${r.url.slice(0, 24)} #${seq} opening (${r.initWasAttach ? `attach ${r.shareId}` : "create"})`);
@@ -1140,7 +1367,7 @@ export function createEngine(deps) {
1140
1367
  wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } });
1141
1368
  }
1142
1369
  else {
1143
- wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } });
1370
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } });
1144
1371
  }
1145
1372
  // handshake deadline: init sent but no ack (silent worker death)
1146
1373
  // — close, let the close listener reconnect us
@@ -1277,8 +1504,10 @@ export function createEngine(deps) {
1277
1504
  const err = String(frame.error ?? "");
1278
1505
  if (/frozen/i.test(err)) {
1279
1506
  // owner frozen: every control path is severed server-side.
1280
- // Re-enrolling would orphan the shares — surface and stop.
1507
+ // Re-enrolling would orphan the shares - surface and stop.
1281
1508
  r.authBlocked = true;
1509
+ st.lastError = `share frozen by the hoster (owner frozen) - contact the server admin`;
1510
+ scheduleBindingWrite(st);
1282
1511
  logError(`${r.url}: share frozen by the hoster (owner frozen) - edits refused; contact the server admin`);
1283
1512
  try {
1284
1513
  r.ws?.close(4000, "frozen");
@@ -1327,14 +1556,16 @@ export function createEngine(deps) {
1327
1556
  return;
1328
1557
  }
1329
1558
  // share swept / removed / revoked: start fresh, never merge into a
1330
- // bad base (mismatch protocol). Content REPLAYS to the new share —
1331
- // pending is never dropped on a reset (live-caught: the recreate
1332
- // husk had the title but zero messages).
1559
+ // bad base (mismatch protocol). Content REPLAYS to the new share,
1560
+ // and pending is never dropped on a reset: dropping it leaves the
1561
+ // recreate as a husk with the title and zero messages.
1333
1562
  if (/limit|too many|rate/i.test(err)) {
1334
1563
  // quota/429 ("max shares reached", "daily create limit", the
1335
1564
  // anonymous caps): an immediate re-init on the same socket is a
1336
- // hot loop against the server — back off through the normal
1565
+ // hot loop against the server - back off through the normal
1337
1566
  // reconnect cadence instead
1567
+ st.lastError = `create refused: ${frame.error ?? "rate limited"}`;
1568
+ scheduleBindingWrite(st);
1338
1569
  logError(`${r.url}: create refused:`, frame.error, "- retrying with backoff");
1339
1570
  r.connecting = false;
1340
1571
  try {
@@ -1346,23 +1577,25 @@ export function createEngine(deps) {
1346
1577
  return;
1347
1578
  }
1348
1579
  logError(`${r.url}: init refused:`, frame.error, "- creating fresh share");
1580
+ st.lastError = `init refused: ${frame.error ?? "unknown"}`;
1581
+ scheduleBindingWrite(st);
1349
1582
  deleteAbandoned(r);
1350
1583
  r.shareId = null;
1351
1584
  r.secret = null;
1352
1585
  r.ackedRev = 0;
1353
- wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } });
1586
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } });
1354
1587
  return;
1355
1588
  }
1356
1589
  if (r.initWasAttach && typeof frame.rev === "number" && frame.rev < r.ackedRev) {
1357
- // worker lost data behind our back (ATTACH only — a create ack
1358
- // legitimately starts at rev 0; the old guard misread that as
1359
- // data loss and spawned a fresh husk, live-caught)
1590
+ // worker lost data behind our back (ATTACH only: a create ack
1591
+ // legitimately starts at rev 0, and misreading that as data loss
1592
+ // spawns a fresh husk)
1360
1593
  logError(`${r.url}: mismatch: worker rev ${frame.rev} < acked ${r.ackedRev} - recreating`);
1361
1594
  deleteAbandoned(r);
1362
1595
  r.shareId = null;
1363
1596
  r.secret = null;
1364
1597
  r.ackedRev = 0;
1365
- wsSend(r, { op: "init", create: { sessionID: st.sid, ttlDays: ttlFor(st), ...authFrame(st) } });
1598
+ wsSend(r, { op: "init", create: { sessionID: st.sid, ttlHours: ttlFor(st), ...authFrame(st), ...(cfg.configSet ? { set: cfg.configSet } : {}), ...(metaFor() ? { meta: metaFor() } : {}) } });
1366
1599
  return;
1367
1600
  }
1368
1601
  const wasFanoutCreate = !r.initWasAttach && st.everConnected;
@@ -1384,11 +1617,10 @@ export function createEngine(deps) {
1384
1617
  clearTimeout(r.initTimer);
1385
1618
  r.initTimer = null;
1386
1619
  }
1387
- // backoff reset is DEFERRED until the socket proves stable: a
1388
- // server that accepts init then immediately drops would otherwise
1389
- // reconnect once per second forever (~1 cycle/s per session-server
1390
- // pair, live-caught in the optimization audit). 30s alive (socket
1391
- // still ready, no reconnect pending) or a second acked sync.
1620
+ // backoff reset is DEFERRED until the socket proves stable: a server
1621
+ // that accepts init then immediately drops would otherwise reconnect
1622
+ // once per second per session-server pair. Stability means 30s alive
1623
+ // (socket still ready, no reconnect pending) or a second acked sync.
1392
1624
  if (r.backoffResetTimer)
1393
1625
  clearTimeout(r.backoffResetTimer);
1394
1626
  r.backoffResetTimer = setTimeout(() => {
@@ -1401,6 +1633,56 @@ export function createEngine(deps) {
1401
1633
  applyPolicy(frame.policy, r.url);
1402
1634
  r.intentionalClose = false;
1403
1635
  touch(st);
1636
+ // the ack carries the RESOLVED ttl: surface a server/token clamp to
1637
+ // the binding once, and a successful handshake clears any stale
1638
+ // refusal reason the TUI may be showing
1639
+ const ackTtl = typeof frame.ttlHours === "number" ? frame.ttlHours : (typeof frame.ttlDays === "number" ? frame.ttlDays * 24 : undefined);
1640
+ if (typeof ackTtl === "number") {
1641
+ const wanted = ttlFor(st);
1642
+ const clamp = wanted != null && ackTtl !== wanted ? { requested: wanted, effective: ackTtl } : null;
1643
+ if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
1644
+ st.ttlClamped = clamp;
1645
+ scheduleBindingWrite(st);
1646
+ }
1647
+ }
1648
+ if (st.lastError) {
1649
+ st.lastError = null;
1650
+ scheduleBindingWrite(st);
1651
+ }
1652
+ // ack-level gate assurance (B2): we asked for a gate (both fields
1653
+ // set) but the server reports none - the page is actually public.
1654
+ // Once per remote; the next init-ok re-checks after a fix.
1655
+ const wantedGate = (st.opts?.authLogin ?? cfg.authLogin ?? "") !== "" && (st.opts?.authPassword ?? cfg.authPassword ?? "") !== "";
1656
+ if (wantedGate && frame.auth === false && !r.warnedNoGate) {
1657
+ r.warnedNoGate = true;
1658
+ logError(`${r.url}: read auth requested but the page has NO gate - check the server's auth handling`);
1659
+ }
1660
+ else if (frame.auth === true) {
1661
+ r.warnedNoGate = false;
1662
+ }
1663
+ // Wave 3 pmeta: machine-local facts (directory/worktree) ride their
1664
+ // OWN tiny sync once per init-ok, only to remotes we hold a
1665
+ // credential for - anonymous (ownerless) shares never store them.
1666
+ // Server-side they land in the private block, readable with share
1667
+ // auth. Idempotent upsert; rides no shared frame so fan-out
1668
+ // remotes with mixed identity each get exactly their due.
1669
+ if (credOf(r.url).credential) {
1670
+ const priv = {};
1671
+ const dir = (deps.directory ?? "");
1672
+ if (dir)
1673
+ priv.directory = dir;
1674
+ if (deps.worktree)
1675
+ priv.worktree = String(deps.worktree);
1676
+ // the sharer's home lets the server mask absolute paths as ~/...
1677
+ try {
1678
+ priv.home = homedir();
1679
+ }
1680
+ catch { }
1681
+ if (Object.keys(priv).length) {
1682
+ const seq = ++nextSeq;
1683
+ wsSend(r, { op: "sync", items: [{ type: "pmeta", sessionID: st.sid, data: priv }], status: undefined, seq });
1684
+ }
1685
+ }
1404
1686
  announceUrl(st, r);
1405
1687
  // first binding write is immediate (the TUI waits on the URL in
1406
1688
  // session metadata); ack-driven rev updates stay debounced
@@ -1420,12 +1702,12 @@ export function createEngine(deps) {
1420
1702
  }
1421
1703
  if (wasFanoutCreate) {
1422
1704
  // a server joined an ALREADY-live session (fan-out add, or a
1423
- // post-mismatch recreate): its fresh share needs the full history —
1424
- // backfill is keyed-idempotent, other servers just re-upsert.
1425
- // The enqueue dedupe must step aside: those hashes say "already
1426
- // entered the machinery", but the NEW remote never received any
1427
- // of it (live-caught by F3: the join's backfill was fully
1428
- // deduped away and the joined share stayed empty).
1705
+ // post-mismatch recreate): its fresh share needs the full history,
1706
+ // and backfill is keyed-idempotent while other servers simply
1707
+ // re-upsert. The enqueue dedupe must step aside here: those hashes
1708
+ // say "already entered the machinery", but the NEW remote received
1709
+ // none of it, so a deduped join backfills nothing and the joined
1710
+ // share stays empty.
1429
1711
  st.sentHash.clear();
1430
1712
  chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", e?.message || e));
1431
1713
  }
@@ -1533,8 +1815,8 @@ export function createEngine(deps) {
1533
1815
  return { items, status: st.status };
1534
1816
  };
1535
1817
  // returns a promise when the frame goes out via HTTP (the dispose path
1536
- // awaits it - an unawaited fetch dies with the exiting process and the
1537
- // final status never lands, live-caught as shares stuck on "live")
1818
+ // awaits it: an unawaited fetch dies with the exiting process and the
1819
+ // final status never lands, leaving the share stuck on "live")
1538
1820
  const flush = (st) => {
1539
1821
  if (st.closed)
1540
1822
  return;
@@ -1544,8 +1826,8 @@ export function createEngine(deps) {
1544
1826
  // fan out to every remote; each carries its own pending copy and acks
1545
1827
  // independently. Remotes mid-handshake or auth-blocked just buffer.
1546
1828
  // PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
1547
- // chunk-frames so a partial-ack can never erase unsent chunks
1548
- // (live-caught class: chunk-1 ack + socket death erased chunks 2..N).
1829
+ // chunk-frames, so a partial ack can never erase unsent chunks (a
1830
+ // chunk-1 ack followed by socket death used to drop chunks 2..N).
1549
1831
  const chunks = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : [];
1550
1832
  let httpWork;
1551
1833
  for (const r of st.remotes.values()) {
@@ -1571,8 +1853,8 @@ export function createEngine(deps) {
1571
1853
  }
1572
1854
  // never send syncs before the init ack: the worker's per-socket
1573
1855
  // ControlDO has no attach until OUR init completes, and a racing
1574
- // sync dies as "unknown op" (live-caught: post-drop reconnects
1575
- // lost whole turns). The init-ok handler flushes pending itself.
1856
+ // sync dies as "unknown op", losing whole turns on post-drop
1857
+ // reconnects. The init-ok handler flushes pending itself.
1576
1858
  if (r.ws && !r.wsReady)
1577
1859
  continue;
1578
1860
  if (r.wsReady) {
@@ -1612,13 +1894,12 @@ export function createEngine(deps) {
1612
1894
  const flushViaHttp = async (st, r) => {
1613
1895
  if (!r.shareId || !r.pending.length)
1614
1896
  return;
1615
- // ALL pending frames, merged (keyed upserts — later wins). Sending
1616
- // only the last frame dropped every earlier batch's items whenever
1617
- // the HTTP fallback carried more than one (live-caught class:
1618
- // content loss on reconnect paths). DEDUPE BY KEY before chunking:
1619
- // a streaming message appearing in k pending frames shipped k
1620
- // copies (up to 25x on a replay burst) — every duplicate was also a
1621
- // server row-write + a rebroadcast frame.
1897
+ // ALL pending frames, merged (keyed upserts, later wins). Sending only
1898
+ // the last frame dropped every earlier batch's items whenever the HTTP
1899
+ // fallback carried more than one, losing content on reconnect paths.
1900
+ // DEDUPE BY KEY before chunking: a streaming message appearing in k
1901
+ // pending frames shipped k copies (up to 25x on a replay burst), and
1902
+ // every duplicate cost a server row-write plus a rebroadcast frame.
1622
1903
  const byKey = new Map();
1623
1904
  const unkeyed = [];
1624
1905
  let status;
@@ -1690,16 +1971,24 @@ export function createEngine(deps) {
1690
1971
  .catch((e) => logError("flush error", e?.message || e));
1691
1972
  }, wait);
1692
1973
  };
1974
+ const homeDir = (() => { try {
1975
+ return homedir();
1976
+ }
1977
+ catch {
1978
+ return "";
1979
+ } })();
1980
+ const userPatterns = cfg.redact?.patterns;
1981
+ const redactionRules = activeRules(userPatterns);
1693
1982
  const enqueue = (st, entry) => {
1694
1983
  if (!st || !entry)
1695
1984
  return;
1696
1985
  // content dedupe at the machinery boundary: an identical re-feed has
1697
- // either entered the queue already or been flushed into every
1698
- // remote's pending/spool (the per-remote delivery machinery owns it
1699
- // from there) — re-sending it bumps rev, rewrites storage and
1700
- // re-folds every viewer for nothing. This is the engine-side home
1701
- // the v2-adapter dedupe lacked (an adapter-side cache poisoned while
1702
- // dormant and skipped the flag-time content — live-caught).
1986
+ // either entered the queue already or been flushed into every remote's
1987
+ // pending/spool, and the per-remote delivery machinery owns it from
1988
+ // there. Re-sending it bumps rev, rewrites storage and re-folds every
1989
+ // viewer for nothing. This is the engine-side home dedupe belongs in:
1990
+ // an adapter-side cache can be poisoned while the session is still
1991
+ // dormant, and then the flag-time content is skipped.
1703
1992
  let h;
1704
1993
  try {
1705
1994
  h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex");
@@ -1714,6 +2003,26 @@ export function createEngine(deps) {
1714
2003
  }
1715
2004
  if (h)
1716
2005
  st.sentHash.set(entry.key, h);
2006
+ // Wave 3 trust pass: redaction on UNGATED shares only (a gated share
2007
+ // is private from casual observers already; the server owner sees
2008
+ // content either way until encryption lands). The light path
2009
+ // transform applies regardless. Zero-copy when nothing matches.
2010
+ {
2011
+ const gate = authFrame(st);
2012
+ const gated = gate.authLogin !== "" && gate.authPassword !== "";
2013
+ const pass = redactItem(entry.item, { rules: redactionRules, home: homeDir, enabled: !gated });
2014
+ if (pass.item !== entry.item)
2015
+ entry = { ...entry, item: pass.item };
2016
+ if (pass.hits.length) {
2017
+ const led = redactionLedger.get(st.sid) ?? { count: 0, samples: [] };
2018
+ led.count += pass.hits.length;
2019
+ for (const hit of pass.hits) {
2020
+ if (led.samples.length < 5 && !led.samples.some((x) => x.kind === hit.kind))
2021
+ led.samples.push(hit);
2022
+ }
2023
+ redactionLedger.set(st.sid, led);
2024
+ }
2025
+ }
1717
2026
  st.queue.set(entry.key, entry.item);
1718
2027
  indexMessage(entry);
1719
2028
  if (debugEvents)
@@ -1722,12 +2031,30 @@ export function createEngine(deps) {
1722
2031
  ensureConnected(st);
1723
2032
  scheduleFlush(st);
1724
2033
  };
2034
+ // Wave 3 preflight counts: the module-level accessor delegates here
2035
+ statsProvider = (sid) => {
2036
+ const st = sessions.get(sid);
2037
+ let messages = 0;
2038
+ let toolCalls = 0;
2039
+ if (st) {
2040
+ for (const [, merged] of st.merged) {
2041
+ messages++;
2042
+ const content = merged.content;
2043
+ if (Array.isArray(content))
2044
+ toolCalls += content.filter((c) => !!c && c.type === "tool").length;
2045
+ }
2046
+ for (const it of st.queue.values()) {
2047
+ if (it?.type === "message")
2048
+ messages++;
2049
+ }
2050
+ }
2051
+ return { messages, toolCalls };
2052
+ };
1725
2053
  const msgIndex = new Map(); // `${sid}:${messageID}`
1726
2054
  /** message ids tombstoned this run: stampsPass must never re-upsert an
1727
- * undone message (live-caught class: dropMergedMessage removed the
1728
- * merged entry but msgIndex kept the corpse; a neighboring-segment
1729
- * change recomputed its stamps and reEnqueueMessage resurrected the
1730
- * deleted turn server-side). */
2055
+ * undone message. dropMergedMessage removes the merged entry but msgIndex
2056
+ * keeps the corpse, so a neighboring-segment change recomputes its
2057
+ * stamps and reEnqueueMessage resurrects the deleted turn server-side. */
1731
2058
  const tombstoned = new Set();
1732
2059
  const indexMessage = (entry) => {
1733
2060
  if (entry.item.type !== "message")
@@ -1791,11 +2118,11 @@ export function createEngine(deps) {
1791
2118
  const agent = segmentAgent(list, users[k]);
1792
2119
  const d = list[users[k]].data;
1793
2120
  const want = k > 0 && agent && prev && agent !== prev ? agent : undefined;
1794
- // only touch metadata when the stamp actually CHANGES — a blanket
1795
- // `d.metadata ??= {}` polluted every user message (including the
2121
+ // only touch metadata when the stamp actually CHANGES: a blanket
2122
+ // `d.metadata ??= {}` pollutes every user message (including the
1796
2123
  // host's live objects on v2 pass-through) with an empty metadata
1797
- // object, which re-emitted the message once per refresh (rev churn
1798
- // per feed — live-caught by the enqueue-dedupe rev check)
2124
+ // object, which re-emits the message once per refresh and churns
2125
+ // rev per feed.
1799
2126
  if (want !== d.metadata?.agentSwitch) {
1800
2127
  if (want) {
1801
2128
  const meta = (d.metadata ??= {});
@@ -1869,8 +2196,8 @@ export function createEngine(deps) {
1869
2196
  }
1870
2197
  };
1871
2198
  /** file tools get metadata.language (shiki lang id, from the path the
1872
- * call touched) - the viewer highlights from the stamp and never guesses
1873
- * (owner directive; the viewer's extension map is an old-share fallback) */
2199
+ * call touched). The viewer highlights from this stamp and never
2200
+ * guesses; its extension map only covers old shares without one. */
1874
2201
  const LANG_BY_EXT = {
1875
2202
  ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
1876
2203
  js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
@@ -1899,6 +2226,10 @@ export function createEngine(deps) {
1899
2226
  const meta = state?.metadata;
1900
2227
  if (!meta)
1901
2228
  continue;
2229
+ // paths stay absolute here: the server rewrites them against the
2230
+ // session's working folder on read (wireItems), so old shares get
2231
+ // the same treatment and the host's message objects are never
2232
+ // mutated
1902
2233
  const raw = state.input?.path ?? state.input?.filePath;
1903
2234
  // language stamp (shiki id from the path)
1904
2235
  if (typeof meta.language !== "string" && typeof raw === "string") {
@@ -2063,9 +2394,9 @@ export function createEngine(deps) {
2063
2394
  r.idleDropTimer = null;
2064
2395
  }
2065
2396
  // wsReady delete only when THIS remote still owns a share: a stray
2066
- // child teardown clears shareId after adopting into the parent —
2067
- // the ControlDO's server-side attach would otherwise delete the
2068
- // very share the parent just inherited (live-caught adoption churn)
2397
+ // child teardown clears shareId after adopting into the parent, and
2398
+ // the ControlDO's server-side attach would then delete the very
2399
+ // share the parent just inherited.
2069
2400
  if (r.wsReady && r.shareId) {
2070
2401
  wsSend(r, { op: "delete" });
2071
2402
  // backstop: the WS delete is fire-and-forget on the wire — a
@@ -2185,9 +2516,9 @@ export function createEngine(deps) {
2185
2516
  const lastInfo = (last?.info ?? last);
2186
2517
  const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined;
2187
2518
  // `before` is an OPAQUE cursor: base64url({id, time}) of the page
2188
- // tail (message-v2.ts codec). A bare message id 400s on hosts with
2189
- // cursor pagination — every v1 backfill silently truncated to one
2190
- // 100-message page (live-caught).
2519
+ // tail (message-v2.ts codec). Hosts with cursor pagination 400 on a
2520
+ // bare message id, which truncates a v1 backfill to its first
2521
+ // 100-message page.
2191
2522
  const lastTime = lastInfo?.time?.created;
2192
2523
  if (!lastID || typeof lastTime !== "number")
2193
2524
  break;
@@ -2206,7 +2537,7 @@ export function createEngine(deps) {
2206
2537
  const backfillSession = async (st, client, sid, info) => {
2207
2538
  const sessionInfoValue = info ?? (await sessionInfo(client, sid));
2208
2539
  if (sessionInfoValue)
2209
- enqueue(st, itemFor("session", redact(sessionInfoValue), sid));
2540
+ enqueue(st, itemFor("session", stripLocalFacts(redact(sessionInfoValue)), sid));
2210
2541
  let modelSeen = false;
2211
2542
  const consume = (messages) => {
2212
2543
  for (const message of messages) {
@@ -2251,7 +2582,7 @@ export function createEngine(deps) {
2251
2582
  // one thing a viewer always needs — fetch it explicitly
2252
2583
  const fetched = await sessionInfo(client, st.sid);
2253
2584
  if (fetched)
2254
- enqueue(st, itemFor("session", redact(fetched), st.sid));
2585
+ enqueue(st, itemFor("session", stripLocalFacts(redact(fetched)), st.sid));
2255
2586
  }
2256
2587
  // children (subagent sessions) share the parent's page
2257
2588
  if (typeof client.session.list === "function") {
@@ -2343,12 +2674,8 @@ export function createEngine(deps) {
2343
2674
  // activates anything on its own
2344
2675
  const st = sessions.get(parent);
2345
2676
  if (st?.activated && !st.closed) {
2346
- if (!st.prefsSent) {
2347
- st.prefsSent = true;
2348
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2349
- }
2350
2677
  if (info)
2351
- enqueue(st, itemFor("session", redact(info), sid));
2678
+ enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
2352
2679
  }
2353
2680
  return;
2354
2681
  }
@@ -2358,23 +2685,14 @@ export function createEngine(deps) {
2358
2685
  if (st?.activated && !st.closed) {
2359
2686
  // already sharing: opts updates apply, flag removal handled below
2360
2687
  if (flag.opts)
2361
- st.opts = flag.opts;
2362
- if (!st.prefsSent) {
2363
- st.prefsSent = true;
2364
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2365
- }
2688
+ applyOpts(st, flag.opts);
2366
2689
  if (info)
2367
- enqueue(st, itemFor("session", redact(info), sid));
2690
+ enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
2368
2691
  if (t === "session.updated")
2369
2692
  chain = chain.then(() => refreshOptOuts(st)).catch(() => { });
2370
2693
  return;
2371
2694
  }
2372
2695
  activate(sid, flag.opts, info);
2373
- const fresh = sessions.get(sid);
2374
- if (fresh && !fresh.prefsSent) {
2375
- fresh.prefsSent = true;
2376
- enqueue(fresh, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2377
- }
2378
2696
  return;
2379
2697
  }
2380
2698
  if (st?.activated && !st.closed && t === "session.updated") {
@@ -2431,12 +2749,8 @@ export function createEngine(deps) {
2431
2749
  }
2432
2750
  if (!st)
2433
2751
  return;
2434
- if (!st.prefsSent) {
2435
- st.prefsSent = true;
2436
- enqueue(st, itemFor("prefs", { openTasksInNewTab: cfg.openTasksInNewTab !== false }, sid));
2437
- }
2438
2752
  if (info)
2439
- enqueue(st, itemFor("session", redact(info), sid));
2753
+ enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
2440
2754
  if (t === "session.updated" && st.activated && !st.closed) {
2441
2755
  chain = chain.then(() => refreshOptOuts(st)).catch(() => { });
2442
2756
  }
@@ -2466,12 +2780,12 @@ export function createEngine(deps) {
2466
2780
  touch(st);
2467
2781
  if (type === "busy") {
2468
2782
  // work resumed: a pending completed flip is cancelled (opencode
2469
- // idles between sequential automation prompts — that is NOT
2470
- // "done"), a recovered retry is forgiven, share is live again.
2471
- // The busy epoch is the completed-flip guard: only WORK
2472
- // invalidates a pending settle (v1.18 republishes the
2473
- // status/idle pair on failure paths — a repeated idle must
2474
- // NOT void it, live-caught as a share stuck on live forever)
2783
+ // idles between sequential automation prompts, which is NOT
2784
+ // "done"), a recovered retry is forgiven, and the share is live
2785
+ // again. The busy epoch is the completed-flip guard: only WORK
2786
+ // invalidates a pending settle. v1.18 republishes the status/idle
2787
+ // pair on failure paths, and a repeated idle must NOT void it or
2788
+ // the share stays on live forever.
2475
2789
  st.statusEpoch++;
2476
2790
  if (st.completedTimer) {
2477
2791
  clearTimeout(st.completedTimer);
@@ -2485,8 +2799,8 @@ export function createEngine(deps) {
2485
2799
  else if (type === "idle") {
2486
2800
  // opencode's idle is authoritative turn end (v1 holds busy
2487
2801
  // through steps, tool runs and permission waits). CONTENT
2488
- // flushes now — turn-end batches must not ride the sync
2489
- // interval (live-caught: subagent finals lagged one interval).
2802
+ // flushes now, because turn-end batches must not ride the sync
2803
+ // interval (subagent finals lagged one interval otherwise).
2490
2804
  // Only the completed flip waits out completedDelayMs, guarded
2491
2805
  // by the busy epoch: work resuming voids it, repeated idle
2492
2806
  // publications do not. Dispose flushes a pending flip, so CI
@@ -2631,11 +2945,11 @@ export function createEngine(deps) {
2631
2945
  const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model);
2632
2946
  enqueue(st, itemFor("model", [{ id: info.model.modelID, providerID: info.model.providerID, name: label }], sid));
2633
2947
  }
2634
- // an assistant message that DIED (finish:"error") — e.g. all
2635
- // retries exhausted — surfaces as a 200 response with an errored
2636
- // message, NOT as session.error (live-caught in the suite); the
2637
- // share must still say error. Works for v2-native messages too
2638
- // (finish lives on the message itself).
2948
+ // an assistant message that DIED (finish:"error"), e.g. all
2949
+ // retries exhausted, surfaces as a 200 response with an errored
2950
+ // message rather than session.error. The share must still say
2951
+ // error. Works for v2-native messages too (finish lives on the
2952
+ // message itself).
2639
2953
  if (info?.finish === "error") {
2640
2954
  st.badSignal = true;
2641
2955
  }
@@ -2701,7 +3015,7 @@ export function createEngine(deps) {
2701
3015
  if (flushed && typeof flushed.then === "function")
2702
3016
  await flushed.catch(() => { });
2703
3017
  // dispose with a mid-handshake socket: the deferred pending
2704
- // would die with the process — land it over HTTP instead
3018
+ // would die with the process - land it over HTTP instead
2705
3019
  for (const r of st.remotes.values()) {
2706
3020
  if (!r.wsReady && r.shareId && r.pending.length) {
2707
3021
  await flushViaHttp(st, r).catch(() => { });
@@ -2722,6 +3036,26 @@ export function createEngine(deps) {
2722
3036
  st.metaTimer = null;
2723
3037
  await writeBinding(st).catch(() => { });
2724
3038
  }
3039
+ // URL-at-exit barrier: a very short `opencode run` can exit before
3040
+ // the create handshake finishes, and CI reads the urlFile the
3041
+ // moment the process ends. Bounded 5s wait for every share that is
3042
+ // mid-handshake or not yet announced (a dead worker costs exactly
3043
+ // the cap; a healthy one is instant).
3044
+ const barrierStart = Date.now();
3045
+ while (Date.now() - barrierStart < 5000) {
3046
+ let pendingAnnounce = false;
3047
+ for (const st of sessions.values()) {
3048
+ for (const r of st.remotes.values()) {
3049
+ if (r.authBlocked || r.collided)
3050
+ continue;
3051
+ if ((r.shareId && !r.announced) || (!r.shareId && (r.ws || r.connecting)))
3052
+ pendingAnnounce = true;
3053
+ }
3054
+ }
3055
+ if (!pendingAnnounce)
3056
+ break;
3057
+ await new Promise((res) => setTimeout(res, 100));
3058
+ }
2725
3059
  for (const st of sessions.values()) {
2726
3060
  for (const r of st.remotes.values()) {
2727
3061
  try {