@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/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
47
  import { isAbsolute, join } from "node:path";
47
- import { METADATA_KEY, loadConfig, resolveShareMode, spoolDirFor, URL_FILE_NONE, resolveServer, saveServerToken } from "./config.js";
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")
@@ -144,6 +165,20 @@ function shareFlag(info) {
144
165
  * with what the engine actually targets - file config alone can miss
145
166
  * plugin-options-layer config the TUI cannot see). */
146
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
+ }
147
182
  export function engineTargetUrls() {
148
183
  return liveTargets;
149
184
  }
@@ -182,9 +217,9 @@ export function createEngine(deps) {
182
217
  const resolveTargets = () => {
183
218
  const merged = {};
184
219
  const hasServers = !!cfg.servers && Object.keys(cfg.servers).length > 0;
185
- // layer-aware legacy rule: a FILE workerUrl beside a FILE servers
186
- // group would silently dual-home the session (live-caught surprise);
187
- // an ENV workerUrl is the highest-priority layer and always joins.
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.
188
223
  const envWorker = !!process.env.OPENSHARE_WORKER_URL;
189
224
  if (cfg.workerUrl && (!hasServers || envWorker))
190
225
  merged[cfg.workerUrl] = { token: cfg.createToken || "", active: true };
@@ -232,42 +267,116 @@ export function createEngine(deps) {
232
267
  };
233
268
  seedCredentials();
234
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
+ };
235
331
  const ensureCredential = async (url) => {
236
332
  const c = credOf(url);
237
333
  const target = targets.find((t) => t.url === url);
238
- if (c.credential)
334
+ if (c.credential) {
335
+ void adoptServerSuggestions(url, c.credential);
239
336
  return c.credential;
337
+ }
240
338
  if (Date.now() - c.lastEnrollAt < ENROLL_RETRY_MS)
241
339
  return "";
242
340
  c.lastEnrollAt = Date.now();
243
- try {
244
- const h = await fetch(`${url}/health`, { signal: AbortSignal.timeout(8000) });
245
- const hb = h.ok ? (await h.json().catch(() => null)) : null;
246
- const authMode2 = hb?.auth?.mode;
247
- const code = target?.entry.code ?? "";
248
- if (authMode2 === "public" || code) {
249
- const r = await fetch(`${url}/api/enroll`, {
250
- method: "POST",
251
- headers: { "content-type": "application/json" },
252
- body: JSON.stringify({ code }),
253
- signal: AbortSignal.timeout(10000),
254
- });
255
- const b = r.ok ? (await r.json().catch(() => null)) : null;
256
- if (b?.ok && typeof b.token === "string") {
257
- c.credential = b.token;
258
- c.error = "";
259
- const savedTo = saveServerToken(url, c.credential);
260
- log(`enrolled at ${url} (user ${b.userId} "${b.label}")${savedTo ? " - token saved" : ""}`);
261
- 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 "";
262
354
  }
263
- c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`;
355
+ c.error = "private server - onboard via /openshare (needs an access code)";
264
356
  }
265
- else if (authMode2 === "private") {
266
- c.error = "private server: add servers.{url}.code (or token) to enroll";
357
+ catch (e) {
358
+ c.error = `server unreachable: ${e?.message || e}`;
267
359
  }
268
- else {
269
- 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;
270
378
  }
379
+ c.error = `enroll refused: ${b?.error ?? `HTTP ${r.status}`}`;
271
380
  }
272
381
  catch (e) {
273
382
  c.error = `enrollment failed: ${e?.message || e}`;
@@ -638,10 +747,10 @@ export function createEngine(deps) {
638
747
  for (const url of st.optOuts)
639
748
  if (!servers[url])
640
749
  servers[url] = { optedOut: true };
641
- // A binding in session metadata means opted-in — enabled: true in BOTH
642
- // modes (live-caught: all-mode bindings without `enabled` made the TUI
643
- // gate fall through to the consent dialog on an already-shared session;
644
- // 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".
645
754
  const value = { v: 2, servers, enabled: true };
646
755
  if (mode === "manual" && st.opts)
647
756
  value.opts = st.opts;
@@ -698,9 +807,9 @@ export function createEngine(deps) {
698
807
  appendFileSync(queuePath(st, r.url), (f.wire ?? JSON.stringify(f)) + "\n");
699
808
  }
700
809
  catch (e) {
701
- // the frame lives ONLY in memory now — flag the remote so the next
702
- // spoolLoad does NOT replace in-memory pending with the shorter
703
- // 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).
704
813
  r.spoolAppendFailed = true;
705
814
  logError("spool append failed", e?.message || e);
706
815
  }
@@ -723,10 +832,10 @@ export function createEngine(deps) {
723
832
  };
724
833
  const spoolLoad = (st, r) => {
725
834
  // meta first (share binding recovery), in its OWN try: a missing meta
726
- // file is normal (it's only written after the first successful
727
- // handshake) and must NOT touch pending (live-caught class: the old
728
- // blanket catch wiped in-memory pending AND skipped the queue read,
729
- // 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.
730
839
  try {
731
840
  const meta = JSON.parse(readFileSync(metaPath(st, r.url), "utf8"));
732
841
  // metadata binding (db) is authoritative when both exist and disagree
@@ -916,7 +1025,19 @@ export function createEngine(deps) {
916
1025
  : join(deps.directory ?? process.cwd(), cfg.urlFile)
917
1026
  : join(deps.directory ?? process.cwd(), "openshare.urls.txt");
918
1027
  try {
919
- 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);
920
1041
  if (cfg.announceStderr && (file !== lastUrlFile || !urlFileAnnounced)) {
921
1042
  urlFileAnnounced = true;
922
1043
  lastUrlFile = file;
@@ -948,11 +1069,28 @@ export function createEngine(deps) {
948
1069
  }
949
1070
  return { authLogin: login, authPassword: password };
950
1071
  };
951
- 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
+ };
952
1090
  /** Apply a metadata opts update. On a CHANGED opts set for a session with
953
1091
  * live sockets, re-send the attach frame over each open socket: the
954
1092
  * worker's attach path applies read-auth retroactively (rotating viewer
955
- * tokens on change) and now also honors an explicit ttlDays override -
1093
+ * tokens on change) and now also honors an explicit ttlHours override -
956
1094
  * so settings heal immediately instead of waiting for a reconnect.
957
1095
  * Re-attach is idempotent (no once-guard on init; earlySyncs empty at
958
1096
  * steady state) and revalidates the token. */
@@ -967,11 +1105,11 @@ export function createEngine(deps) {
967
1105
  if (!r.shareId || !r.secret || !r.wsReady)
968
1106
  continue;
969
1107
  const attach = { id: r.shareId, secret: r.secret, ...authFrame(st) };
970
- if (st.opts?.ttlDays)
971
- attach.ttlDays = st.opts.ttlDays;
1108
+ if (st.opts?.ttlHours)
1109
+ attach.ttlHours = st.opts.ttlHours;
972
1110
  const sent = wsSend(r, { op: "init", attach });
973
1111
  if (sent)
974
- log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlDays ? ` (ttl ${st.opts.ttlDays}d)` : ""}`);
1112
+ log(`opts re-synced to ${url.slice(0, 24)}${st.opts?.ttlHours ? ` (ttl ${st.opts.ttlHours}h)` : ""}`);
975
1113
  }
976
1114
  };
977
1115
  /** Best-effort cleanup of a share we are about to abandon (refused
@@ -991,9 +1129,9 @@ export function createEngine(deps) {
991
1129
  };
992
1130
  /** Single-flight connect queuing: multiple paths (activation, event
993
1131
  * reconnect, backfill chain) may want a socket before any connect has
994
- * STARTED — the connecting flag alone guards execution, not the queue,
995
- * and double connects used to create duplicate shares + init storms
996
- * (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). */
997
1135
  const queueConnect = (st, r) => {
998
1136
  if (st.closed || r.connectQueued || r.connecting || r.authBlocked || r.collided)
999
1137
  return;
@@ -1111,12 +1249,12 @@ export function createEngine(deps) {
1111
1249
  const connect = async (st, r) => {
1112
1250
  if (st.closed || r.connecting || r.authBlocked || r.collided)
1113
1251
  return;
1114
- // "connecting" spans the WHOLE handshake: socket open + init ack. It
1115
- // is only cleared on init-ok, socket death or the init timeout — an
1116
- // event arriving mid-handshake must NEVER spawn a second socket (the
1117
- // 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
1118
1256
  // raced it, and the loser's late init-ok poisoned the winner's state
1119
- // — live-caught: duplicate shares, mismatch storms, empty pages).
1257
+ // (duplicate shares, mismatch storms, empty pages).
1120
1258
  r.connecting = true;
1121
1259
  r.socketSeq += 1;
1122
1260
  const seq = r.socketSeq;
@@ -1133,9 +1271,9 @@ export function createEngine(deps) {
1133
1271
  spoolLoad(st, r);
1134
1272
  askPolicy(r.url);
1135
1273
  // was this socket's init an ATTACH (resume) or a CREATE? A create
1136
- // ack legitimately starts at rev 0 — the mismatch guard is for
1137
- // attaches only (live-caught: create-ack rev 0 tripped it and
1138
- // 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.
1139
1277
  r.initWasAttach = !!(r.shareId && r.secret);
1140
1278
  if (debugEvents)
1141
1279
  log(`ws ${r.url.slice(0, 24)} #${seq} opening (${r.initWasAttach ? `attach ${r.shareId}` : "create"})`);
@@ -1229,7 +1367,7 @@ export function createEngine(deps) {
1229
1367
  wsSend(r, { op: "init", attach: { id: r.shareId, secret: r.secret, ...authFrame(st) } });
1230
1368
  }
1231
1369
  else {
1232
- 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() } : {}) } });
1233
1371
  }
1234
1372
  // handshake deadline: init sent but no ack (silent worker death)
1235
1373
  // — close, let the close listener reconnect us
@@ -1418,9 +1556,9 @@ export function createEngine(deps) {
1418
1556
  return;
1419
1557
  }
1420
1558
  // share swept / removed / revoked: start fresh, never merge into a
1421
- // bad base (mismatch protocol). Content REPLAYS to the new share —
1422
- // pending is never dropped on a reset (live-caught: the recreate
1423
- // 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.
1424
1562
  if (/limit|too many|rate/i.test(err)) {
1425
1563
  // quota/429 ("max shares reached", "daily create limit", the
1426
1564
  // anonymous caps): an immediate re-init on the same socket is a
@@ -1445,19 +1583,19 @@ export function createEngine(deps) {
1445
1583
  r.shareId = null;
1446
1584
  r.secret = null;
1447
1585
  r.ackedRev = 0;
1448
- 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() } : {}) } });
1449
1587
  return;
1450
1588
  }
1451
1589
  if (r.initWasAttach && typeof frame.rev === "number" && frame.rev < r.ackedRev) {
1452
- // worker lost data behind our back (ATTACH only — a create ack
1453
- // legitimately starts at rev 0; the old guard misread that as
1454
- // 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)
1455
1593
  logError(`${r.url}: mismatch: worker rev ${frame.rev} < acked ${r.ackedRev} - recreating`);
1456
1594
  deleteAbandoned(r);
1457
1595
  r.shareId = null;
1458
1596
  r.secret = null;
1459
1597
  r.ackedRev = 0;
1460
- 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() } : {}) } });
1461
1599
  return;
1462
1600
  }
1463
1601
  const wasFanoutCreate = !r.initWasAttach && st.everConnected;
@@ -1479,11 +1617,10 @@ export function createEngine(deps) {
1479
1617
  clearTimeout(r.initTimer);
1480
1618
  r.initTimer = null;
1481
1619
  }
1482
- // backoff reset is DEFERRED until the socket proves stable: a
1483
- // server that accepts init then immediately drops would otherwise
1484
- // reconnect once per second forever (~1 cycle/s per session-server
1485
- // pair, live-caught in the optimization audit). 30s alive (socket
1486
- // 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.
1487
1624
  if (r.backoffResetTimer)
1488
1625
  clearTimeout(r.backoffResetTimer);
1489
1626
  r.backoffResetTimer = setTimeout(() => {
@@ -1499,9 +1636,10 @@ export function createEngine(deps) {
1499
1636
  // the ack carries the RESOLVED ttl: surface a server/token clamp to
1500
1637
  // the binding once, and a successful handshake clears any stale
1501
1638
  // refusal reason the TUI may be showing
1502
- if (typeof frame.ttlDays === "number") {
1639
+ const ackTtl = typeof frame.ttlHours === "number" ? frame.ttlHours : (typeof frame.ttlDays === "number" ? frame.ttlDays * 24 : undefined);
1640
+ if (typeof ackTtl === "number") {
1503
1641
  const wanted = ttlFor(st);
1504
- const clamp = wanted != null && frame.ttlDays !== wanted ? { requested: wanted, effective: frame.ttlDays } : null;
1642
+ const clamp = wanted != null && ackTtl !== wanted ? { requested: wanted, effective: ackTtl } : null;
1505
1643
  if (JSON.stringify(clamp) !== JSON.stringify(st.ttlClamped)) {
1506
1644
  st.ttlClamped = clamp;
1507
1645
  scheduleBindingWrite(st);
@@ -1511,6 +1649,40 @@ export function createEngine(deps) {
1511
1649
  st.lastError = null;
1512
1650
  scheduleBindingWrite(st);
1513
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
+ }
1514
1686
  announceUrl(st, r);
1515
1687
  // first binding write is immediate (the TUI waits on the URL in
1516
1688
  // session metadata); ack-driven rev updates stay debounced
@@ -1530,12 +1702,12 @@ export function createEngine(deps) {
1530
1702
  }
1531
1703
  if (wasFanoutCreate) {
1532
1704
  // a server joined an ALREADY-live session (fan-out add, or a
1533
- // post-mismatch recreate): its fresh share needs the full history —
1534
- // backfill is keyed-idempotent, other servers just re-upsert.
1535
- // The enqueue dedupe must step aside: those hashes say "already
1536
- // entered the machinery", but the NEW remote never received any
1537
- // of it (live-caught by F3: the join's backfill was fully
1538
- // 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.
1539
1711
  st.sentHash.clear();
1540
1712
  chain = chain.then(() => backfill(st)).catch((e) => logError("fan-out backfill error", e?.message || e));
1541
1713
  }
@@ -1643,8 +1815,8 @@ export function createEngine(deps) {
1643
1815
  return { items, status: st.status };
1644
1816
  };
1645
1817
  // returns a promise when the frame goes out via HTTP (the dispose path
1646
- // awaits it - an unawaited fetch dies with the exiting process and the
1647
- // 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")
1648
1820
  const flush = (st) => {
1649
1821
  if (st.closed)
1650
1822
  return;
@@ -1654,8 +1826,8 @@ export function createEngine(deps) {
1654
1826
  // fan out to every remote; each carries its own pending copy and acks
1655
1827
  // independently. Remotes mid-handshake or auth-blocked just buffer.
1656
1828
  // PENDING IS CHUNK-SHAPED (seq-tagged): one logical frame becomes N
1657
- // chunk-frames so a partial-ack can never erase unsent chunks
1658
- // (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).
1659
1831
  const chunks = f.items.length ? chunkedFrames(f.items, 200, 512 * 1024) : [];
1660
1832
  let httpWork;
1661
1833
  for (const r of st.remotes.values()) {
@@ -1681,8 +1853,8 @@ export function createEngine(deps) {
1681
1853
  }
1682
1854
  // never send syncs before the init ack: the worker's per-socket
1683
1855
  // ControlDO has no attach until OUR init completes, and a racing
1684
- // sync dies as "unknown op" (live-caught: post-drop reconnects
1685
- // 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.
1686
1858
  if (r.ws && !r.wsReady)
1687
1859
  continue;
1688
1860
  if (r.wsReady) {
@@ -1722,13 +1894,12 @@ export function createEngine(deps) {
1722
1894
  const flushViaHttp = async (st, r) => {
1723
1895
  if (!r.shareId || !r.pending.length)
1724
1896
  return;
1725
- // ALL pending frames, merged (keyed upserts — later wins). Sending
1726
- // only the last frame dropped every earlier batch's items whenever
1727
- // the HTTP fallback carried more than one (live-caught class:
1728
- // content loss on reconnect paths). DEDUPE BY KEY before chunking:
1729
- // a streaming message appearing in k pending frames shipped k
1730
- // copies (up to 25x on a replay burst) — every duplicate was also a
1731
- // 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.
1732
1903
  const byKey = new Map();
1733
1904
  const unkeyed = [];
1734
1905
  let status;
@@ -1800,16 +1971,24 @@ export function createEngine(deps) {
1800
1971
  .catch((e) => logError("flush error", e?.message || e));
1801
1972
  }, wait);
1802
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);
1803
1982
  const enqueue = (st, entry) => {
1804
1983
  if (!st || !entry)
1805
1984
  return;
1806
1985
  // content dedupe at the machinery boundary: an identical re-feed has
1807
- // either entered the queue already or been flushed into every
1808
- // remote's pending/spool (the per-remote delivery machinery owns it
1809
- // from there) — re-sending it bumps rev, rewrites storage and
1810
- // re-folds every viewer for nothing. This is the engine-side home
1811
- // the v2-adapter dedupe lacked (an adapter-side cache poisoned while
1812
- // 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.
1813
1992
  let h;
1814
1993
  try {
1815
1994
  h = createHash("sha256").update(JSON.stringify(entry.item)).digest("hex");
@@ -1824,6 +2003,26 @@ export function createEngine(deps) {
1824
2003
  }
1825
2004
  if (h)
1826
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
+ }
1827
2026
  st.queue.set(entry.key, entry.item);
1828
2027
  indexMessage(entry);
1829
2028
  if (debugEvents)
@@ -1832,12 +2031,30 @@ export function createEngine(deps) {
1832
2031
  ensureConnected(st);
1833
2032
  scheduleFlush(st);
1834
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
+ };
1835
2053
  const msgIndex = new Map(); // `${sid}:${messageID}`
1836
2054
  /** message ids tombstoned this run: stampsPass must never re-upsert an
1837
- * undone message (live-caught class: dropMergedMessage removed the
1838
- * merged entry but msgIndex kept the corpse; a neighboring-segment
1839
- * change recomputed its stamps and reEnqueueMessage resurrected the
1840
- * 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. */
1841
2058
  const tombstoned = new Set();
1842
2059
  const indexMessage = (entry) => {
1843
2060
  if (entry.item.type !== "message")
@@ -1901,11 +2118,11 @@ export function createEngine(deps) {
1901
2118
  const agent = segmentAgent(list, users[k]);
1902
2119
  const d = list[users[k]].data;
1903
2120
  const want = k > 0 && agent && prev && agent !== prev ? agent : undefined;
1904
- // only touch metadata when the stamp actually CHANGES — a blanket
1905
- // `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
1906
2123
  // host's live objects on v2 pass-through) with an empty metadata
1907
- // object, which re-emitted the message once per refresh (rev churn
1908
- // 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.
1909
2126
  if (want !== d.metadata?.agentSwitch) {
1910
2127
  if (want) {
1911
2128
  const meta = (d.metadata ??= {});
@@ -1979,8 +2196,8 @@ export function createEngine(deps) {
1979
2196
  }
1980
2197
  };
1981
2198
  /** file tools get metadata.language (shiki lang id, from the path the
1982
- * call touched) - the viewer highlights from the stamp and never guesses
1983
- * (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. */
1984
2201
  const LANG_BY_EXT = {
1985
2202
  ts: "typescript", tsx: "tsx", mts: "typescript", cts: "typescript",
1986
2203
  js: "javascript", jsx: "jsx", mjs: "javascript", cjs: "javascript",
@@ -1992,6 +2209,44 @@ export function createEngine(deps) {
1992
2209
  swift: "swift", kt: "kotlin", php: "php", lua: "lua", vue: "vue", svelte: "svelte",
1993
2210
  };
1994
2211
  const FILE_TOOLS = new Set(["read", "write", "edit", "patch"]);
2212
+ /** A read that returns lines from inside an html file's <script>/<style>
2213
+ * block gets tokenized as html by the viewer, and a snippet with no tag
2214
+ * context tokenizes as untyped soup (one default span per line). The
2215
+ * viewer only sees the snippet, so the stamp carries the language that is
2216
+ * true for the RETURNED RANGE: no tags plus code shape means the range sat
2217
+ * inside an embedded script or style block. */
2218
+ const refineEmbeddedLanguage = (meta, text) => {
2219
+ if (meta.language !== "html")
2220
+ return false;
2221
+ const head = text.slice(0, 4000);
2222
+ if (/<[a-zA-Z][^>\n]*>/.test(head))
2223
+ return false;
2224
+ let refined;
2225
+ if (/^[ \t]*[.#][A-Za-z][\w-]*[^\n{]*\{/m.test(head) && !/\b(function|=>|return)\b/.test(head)) {
2226
+ refined = "css";
2227
+ }
2228
+ else if (/\b(function|const|let|var|return|if|for|while|=>|addEventListener|document\.|window\.)\b/.test(head) || /[;{}]\s*$/m.test(head)) {
2229
+ refined = "javascript";
2230
+ }
2231
+ if (!refined || meta.language === refined)
2232
+ return false;
2233
+ meta.language = refined;
2234
+ return true;
2235
+ };
2236
+ /** Sniff the CODE CONTENT of a unified patch: drop everything before the
2237
+ * first hunk header and strip each line's diff marker, so the same
2238
+ * tag/shape checks as reads run over clean source lines. */
2239
+ const refinePatchLanguage = (meta, patchText) => {
2240
+ const at = patchText.indexOf("@@");
2241
+ if (at < 0)
2242
+ return false;
2243
+ const body = patchText
2244
+ .slice(at)
2245
+ .split("\n")
2246
+ .map((l) => (/^[ +\\]/.test(l) ? l.slice(1) : l.startsWith("-") ? l.slice(1) : ""))
2247
+ .join("\n");
2248
+ return refineEmbeddedLanguage(meta, body);
2249
+ };
1995
2250
  const stampFileLanguages = () => {
1996
2251
  for (const m of msgIndex.values()) {
1997
2252
  if (m.role !== "assistant")
@@ -2009,6 +2264,10 @@ export function createEngine(deps) {
2009
2264
  const meta = state?.metadata;
2010
2265
  if (!meta)
2011
2266
  continue;
2267
+ // paths stay absolute here: the server rewrites them against the
2268
+ // session's working folder on read (wireItems), so old shares get
2269
+ // the same treatment and the host's message objects are never
2270
+ // mutated
2012
2271
  const raw = state.input?.path ?? state.input?.filePath;
2013
2272
  // language stamp (shiki id from the path)
2014
2273
  if (typeof meta.language !== "string" && typeof raw === "string") {
@@ -2019,6 +2278,18 @@ export function createEngine(deps) {
2019
2278
  mutated = true;
2020
2279
  }
2021
2280
  }
2281
+ // range refinement: html-stamped reads inside script/style blocks
2282
+ if (part.name === "read" && typeof state.output === "string" && state.output.length > 0) {
2283
+ if (refineEmbeddedLanguage(meta, state.output))
2284
+ mutated = true;
2285
+ }
2286
+ // same refinement for edit hunks: a mid-script html diff tokenizes
2287
+ // untyped without the range language
2288
+ if ((part.name === "edit" || part.name === "patch") && typeof meta.language === "string") {
2289
+ const pt = typeof meta.diff === "string" ? meta.diff : typeof meta.filediff?.patch === "string" ? meta.filediff.patch : undefined;
2290
+ if (pt && refinePatchLanguage(meta, pt))
2291
+ mutated = true;
2292
+ }
2022
2293
  // lineStart stamp: reads sliced from an offset keep REAL file line
2023
2294
  // numbers in the viewer's unified file card (pierre numbers from 1;
2024
2295
  // the viewer shifts by this stamp, authoritatively)
@@ -2173,9 +2444,9 @@ export function createEngine(deps) {
2173
2444
  r.idleDropTimer = null;
2174
2445
  }
2175
2446
  // wsReady delete only when THIS remote still owns a share: a stray
2176
- // child teardown clears shareId after adopting into the parent —
2177
- // the ControlDO's server-side attach would otherwise delete the
2178
- // very share the parent just inherited (live-caught adoption churn)
2447
+ // child teardown clears shareId after adopting into the parent, and
2448
+ // the ControlDO's server-side attach would then delete the very
2449
+ // share the parent just inherited.
2179
2450
  if (r.wsReady && r.shareId) {
2180
2451
  wsSend(r, { op: "delete" });
2181
2452
  // backstop: the WS delete is fire-and-forget on the wire — a
@@ -2295,9 +2566,9 @@ export function createEngine(deps) {
2295
2566
  const lastInfo = (last?.info ?? last);
2296
2567
  const lastID = typeof lastInfo?.id === "string" ? lastInfo.id : undefined;
2297
2568
  // `before` is an OPAQUE cursor: base64url({id, time}) of the page
2298
- // tail (message-v2.ts codec). A bare message id 400s on hosts with
2299
- // cursor pagination — every v1 backfill silently truncated to one
2300
- // 100-message page (live-caught).
2569
+ // tail (message-v2.ts codec). Hosts with cursor pagination 400 on a
2570
+ // bare message id, which truncates a v1 backfill to its first
2571
+ // 100-message page.
2301
2572
  const lastTime = lastInfo?.time?.created;
2302
2573
  if (!lastID || typeof lastTime !== "number")
2303
2574
  break;
@@ -2316,7 +2587,7 @@ export function createEngine(deps) {
2316
2587
  const backfillSession = async (st, client, sid, info) => {
2317
2588
  const sessionInfoValue = info ?? (await sessionInfo(client, sid));
2318
2589
  if (sessionInfoValue)
2319
- enqueue(st, itemFor("session", redact(sessionInfoValue), sid));
2590
+ enqueue(st, itemFor("session", stripLocalFacts(redact(sessionInfoValue)), sid));
2320
2591
  let modelSeen = false;
2321
2592
  const consume = (messages) => {
2322
2593
  for (const message of messages) {
@@ -2361,7 +2632,7 @@ export function createEngine(deps) {
2361
2632
  // one thing a viewer always needs — fetch it explicitly
2362
2633
  const fetched = await sessionInfo(client, st.sid);
2363
2634
  if (fetched)
2364
- enqueue(st, itemFor("session", redact(fetched), st.sid));
2635
+ enqueue(st, itemFor("session", stripLocalFacts(redact(fetched)), st.sid));
2365
2636
  }
2366
2637
  // children (subagent sessions) share the parent's page
2367
2638
  if (typeof client.session.list === "function") {
@@ -2454,7 +2725,7 @@ export function createEngine(deps) {
2454
2725
  const st = sessions.get(parent);
2455
2726
  if (st?.activated && !st.closed) {
2456
2727
  if (info)
2457
- enqueue(st, itemFor("session", redact(info), sid));
2728
+ enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
2458
2729
  }
2459
2730
  return;
2460
2731
  }
@@ -2466,7 +2737,7 @@ export function createEngine(deps) {
2466
2737
  if (flag.opts)
2467
2738
  applyOpts(st, flag.opts);
2468
2739
  if (info)
2469
- enqueue(st, itemFor("session", redact(info), sid));
2740
+ enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
2470
2741
  if (t === "session.updated")
2471
2742
  chain = chain.then(() => refreshOptOuts(st)).catch(() => { });
2472
2743
  return;
@@ -2529,7 +2800,7 @@ export function createEngine(deps) {
2529
2800
  if (!st)
2530
2801
  return;
2531
2802
  if (info)
2532
- enqueue(st, itemFor("session", redact(info), sid));
2803
+ enqueue(st, itemFor("session", stripLocalFacts(redact(info)), sid));
2533
2804
  if (t === "session.updated" && st.activated && !st.closed) {
2534
2805
  chain = chain.then(() => refreshOptOuts(st)).catch(() => { });
2535
2806
  }
@@ -2559,12 +2830,12 @@ export function createEngine(deps) {
2559
2830
  touch(st);
2560
2831
  if (type === "busy") {
2561
2832
  // work resumed: a pending completed flip is cancelled (opencode
2562
- // idles between sequential automation prompts — that is NOT
2563
- // "done"), a recovered retry is forgiven, share is live again.
2564
- // The busy epoch is the completed-flip guard: only WORK
2565
- // invalidates a pending settle (v1.18 republishes the
2566
- // status/idle pair on failure paths — a repeated idle must
2567
- // NOT void it, live-caught as a share stuck on live forever)
2833
+ // idles between sequential automation prompts, which is NOT
2834
+ // "done"), a recovered retry is forgiven, and the share is live
2835
+ // again. The busy epoch is the completed-flip guard: only WORK
2836
+ // invalidates a pending settle. v1.18 republishes the status/idle
2837
+ // pair on failure paths, and a repeated idle must NOT void it or
2838
+ // the share stays on live forever.
2568
2839
  st.statusEpoch++;
2569
2840
  if (st.completedTimer) {
2570
2841
  clearTimeout(st.completedTimer);
@@ -2578,8 +2849,8 @@ export function createEngine(deps) {
2578
2849
  else if (type === "idle") {
2579
2850
  // opencode's idle is authoritative turn end (v1 holds busy
2580
2851
  // through steps, tool runs and permission waits). CONTENT
2581
- // flushes now — turn-end batches must not ride the sync
2582
- // interval (live-caught: subagent finals lagged one interval).
2852
+ // flushes now, because turn-end batches must not ride the sync
2853
+ // interval (subagent finals lagged one interval otherwise).
2583
2854
  // Only the completed flip waits out completedDelayMs, guarded
2584
2855
  // by the busy epoch: work resuming voids it, repeated idle
2585
2856
  // publications do not. Dispose flushes a pending flip, so CI
@@ -2724,11 +2995,11 @@ export function createEngine(deps) {
2724
2995
  const label = formatLabel(st.opts?.modelLabel ?? cfg.modelLabel, info.model);
2725
2996
  enqueue(st, itemFor("model", [{ id: info.model.modelID, providerID: info.model.providerID, name: label }], sid));
2726
2997
  }
2727
- // an assistant message that DIED (finish:"error") — e.g. all
2728
- // retries exhausted — surfaces as a 200 response with an errored
2729
- // message, NOT as session.error (live-caught in the suite); the
2730
- // share must still say error. Works for v2-native messages too
2731
- // (finish lives on the message itself).
2998
+ // an assistant message that DIED (finish:"error"), e.g. all
2999
+ // retries exhausted, surfaces as a 200 response with an errored
3000
+ // message rather than session.error. The share must still say
3001
+ // error. Works for v2-native messages too (finish lives on the
3002
+ // message itself).
2732
3003
  if (info?.finish === "error") {
2733
3004
  st.badSignal = true;
2734
3005
  }