ccdeck 3.22.14 → 3.23.0

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.
Files changed (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +252 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -182
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +385 -51
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +495 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BdYsAtN8.js +0 -274
  51. package/dist/web/assets/index-CgwmcBHv.css +0 -1
@@ -4,9 +4,9 @@
4
4
  // which asks another deck's port to prove it is the deck its discovery record
5
5
  // describes. Nothing else in this file talks to anything but 127.0.0.1 clients.
6
6
  import { createServer, request as httpRequest } from "node:http";
7
- import { readFile, stat, mkdir, open, truncate, readdir, unlink } from "node:fs/promises";
7
+ import { readFile, stat, mkdir, open, readdir, unlink } from "node:fs/promises";
8
8
  import { existsSync, readFileSync, realpath as realpathCb, realpathSync } from "node:fs";
9
- import { extname, join, resolve, sep, dirname as pdirname } from "node:path";
9
+ import { basename, extname, join, resolve, sep, dirname as pdirname } from "node:path";
10
10
  import { homedir, networkInterfaces } from "node:os";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
12
  import { dirname } from "node:path";
@@ -30,16 +30,18 @@ import { createActivity } from "./activity.mjs";
30
30
  // What a session is producing between its tool calls — the 16.5% of measured
31
31
  // time the hooks cannot see. See output-watch.mjs.
32
32
  import { createOutputWatch } from "./output-watch.mjs";
33
+ // Claude Code's "※ recap:" line — see session-recap.mjs.
34
+ import { RECAP_MARK, foldRecapLine } from "./session-recap.mjs";
33
35
  import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
34
36
  import { createPresence } from "./presence.mjs";
35
- import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
37
+ import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, updatePrefs, writePrefs } from "./deck-prefs.mjs";
36
38
  import { createEngine, defaultName } from "./lan-engine.mjs";
37
39
  import { aboutThisDeck } from "./lan-about.mjs";
38
40
  import { PROBE_PS, localAliases, reachability, readProbe } from "./lan-reach.mjs";
39
41
  import { run } from "./exec.mjs";
40
42
  import { notify as osNotify } from "./browser-react.mjs";
41
43
  import { invokedName, renameNotice } from "./invoked-as.mjs";
42
- import { appendLogLine, codexCwdInWorkspace, electWriters, foldsCase, writesCodexLog } from "./log-writer.mjs";
44
+ import { appendFailureStats, appendLogLine, appendsLanded, codexCwdInWorkspace, electWriters, emptyLog, flushAppends, foldsCase, writesCodexLog } from "./log-writer.mjs";
43
45
  import { historySnapshot, readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
44
46
  import { linesFromEnd, linesFromStart } from "./log-tail.mjs";
45
47
 
@@ -113,7 +115,51 @@ const MIME = {
113
115
  // Both bounds are now enforced on every push, evicting oldest-first until each
114
116
  // holds. See the eviction in pushEvent for why it is one splice and why the
115
117
  // newest event is never the one evicted.
116
- export const MAX_BUFFER = 2000; // recent events kept for late SSE subscribers
118
+ export const MAX_BUFFER = 2000; // recent HOOK events kept for late SSE subscribers
119
+
120
+ /**
121
+ * The enrichment this deck derives rather than receives, where only the newest
122
+ * one per session has ever been worth anything.
123
+ *
124
+ * These four are last-value-wins STATE, not history: a session's model, its
125
+ * token usage, its context fill and its name. Replaying an older UsageObserved
126
+ * on top of a newer one changes nothing a reader can see, which is precisely
127
+ * why they do not deserve a slot in a window measured in events.
128
+ *
129
+ * MEASURED, at one hook event per session every 3s — an ordinary tool-call
130
+ * cadence, well under every documented cap. Four scanners fire per hook event,
131
+ * throttled at 2.5s, 2.5s and 4s:
132
+ *
133
+ * sessions=10 posted=60 seq_delta=160 amplification=2.67
134
+ * sessions=200 posted=2000 seq_delta=5190 amplification=2.60
135
+ *
136
+ * Flat, near enough: every hook event costs about 2.6 ring slots. At 200
137
+ * sessions the 2000-entry ring held
138
+ * {"PostToolUse":800,"UsageObserved":800,"ContextObserved":400} and spanned
139
+ * 9.5 SECONDS — 60% of the resume window spent on state that is superseded
140
+ * moments later. Close the lid, switch networks, or let a tab sleep for fifteen
141
+ * seconds, and the Last-Event-ID is older than the ring's head: handleSse
142
+ * replays what is left and the client steps lastSeq over the gap, so the tool
143
+ * calls made in those seconds are never drawn and nothing reports the hole.
144
+ *
145
+ * `OutputObserved` is deliberately NOT here. It says something landed at a
146
+ * given moment, which is history — two of them are two events, not one value
147
+ * twice.
148
+ */
149
+ const LAST_VALUE_WINS = new Set(["ModelObserved", "UsageObserved", "ContextObserved", "SessionNamed"]);
150
+
151
+ /**
152
+ * The ring's array-length backstop, distinct from its event budget.
153
+ *
154
+ * MAX_BUFFER now counts hook events only, so the array holds those plus
155
+ * whatever enrichment is interleaved among them — about 2.6x the budget at the
156
+ * amplification measured above. This is the ceiling for a ratio nobody has
157
+ * measured yet: a future scanner, or a cadence that makes enrichment denser
158
+ * still, must not be able to grow the array without bound. It is not the
159
+ * working limit and should never be the binding one; MAX_BUFFER_CHARS remains
160
+ * the memory bound.
161
+ */
162
+ export const MAX_RING_ENTRIES = MAX_BUFFER * 4;
117
163
 
118
164
  // The byte budget, counted in CHARACTERS — the unit this file already measures
119
165
  // payloads in, and the unit MAX_CLIENT_BUFFER_BYTES is really written in
@@ -159,6 +205,21 @@ const events = []; // ring buffer
159
205
  // day only one of them was emptied.
160
206
  let bufferedChars = 0;
161
207
 
208
+ /**
209
+ * How many of the ring's entries are HOOK events — what MAX_BUFFER budgets.
210
+ *
211
+ * Third of the trio that has to move with `events`, for the same reason
212
+ * `bufferedChars` does and with the same consequence if it drifts: a count that
213
+ * names events the array no longer holds evicts against a budget already spent.
214
+ * See LAST_VALUE_WINS for what it excludes and why, and clearEventBuffer for
215
+ * the one place all three are emptied.
216
+ */
217
+ let bufferedHookEvents = 0;
218
+
219
+ /** Set on the envelopes MAX_BUFFER does not count, so eviction can decrement
220
+ * the right total without re-reading a payload it already charged. */
221
+ const ENRICHMENT = Symbol("ring enrichment");
222
+
162
223
  /**
163
224
  * What this payload will cost the ring, charged in characters.
164
225
  *
@@ -266,6 +327,7 @@ const CHARS = Symbol("ring charge");
266
327
  function clearEventBuffer() {
267
328
  events.length = 0;
268
329
  bufferedChars = 0;
330
+ bufferedHookEvents = 0;
269
331
  }
270
332
 
271
333
  /**
@@ -284,6 +346,11 @@ function clearEventBuffer() {
284
346
  export function eventBufferStats() {
285
347
  return {
286
348
  events: events.length,
349
+ // What MAX_BUFFER budgets since #1032, and the only number a resuming
350
+ // client's window is actually measured in. `events` minus this is the
351
+ // enrichment riding along; the ratio between them is the amplification that
352
+ // used to spend the window — 2.6 measured at every session count tried.
353
+ hookEvents: bufferedHookEvents,
287
354
  chars: bufferedChars,
288
355
  oldestSeq: events.length > 0 ? events[0].seq : 0,
289
356
  newestSeq: events.length > 0 ? events[events.length - 1].seq : 0,
@@ -422,19 +489,82 @@ export async function logSharing() {
422
489
  const ROTATE_AT_BYTES = 50 * 1024 * 1024;
423
490
  let lastRotateCheckAt = 0;
424
491
  let rotateInProgress = false;
425
- async function maybeRotatePersistFile() {
492
+ /** Bytes handed to appendLogLine since the last time we looked at the file.
493
+ *
494
+ * THE 30-SECOND CLOCK MADE THE THRESHOLD ADVISORY. The stat was throttled to
495
+ * once per 30s and this function has one caller — the push path — so the
496
+ * overshoot was exactly `30s x the ingest byte rate`, which is unbounded in
497
+ * throughput rather than merely loose. Measured, on a log doing 10.5 MB/s:
498
+ *
499
+ * t=6s log=51393249 <- 50 MB crossed
500
+ * t=30s log=306262048
501
+ * t=31s log=0 log1=316750478 <- rotation fires, 302 MB, 25s late
502
+ *
503
+ * and at higher rates 1,199 MB and 2,579 MB with ZERO rotations — 51x the
504
+ * cap, 604 MB on disk across the two generations for a documented 50 MB.
505
+ *
506
+ * Counting what we hand the appender costs one addition per event and removes
507
+ * the dependency on throughput: the check now happens when enough has been
508
+ * written to be worth a stat, whatever the clock says. The clock stays as a
509
+ * FLOOR for the idle case, where nothing is being written and a periodic look
510
+ * is the only way to notice a file that grew by some other route.
511
+ *
512
+ * Deliberately a lower bound on the real file: it counts bytes queued, not
513
+ * bytes landed, and it is reset on every look rather than on a successful
514
+ * rotation — so a stat that finds the file still under the threshold has
515
+ * already paid for itself and does not need to re-count what it just saw. */
516
+ let bytesSinceRotateCheck = 0;
517
+ /** Enough written to be worth a stat, whatever the clock says. A fifth of the
518
+ * threshold, so the worst overshoot is bounded at ~20% rather than by rate. */
519
+ const ROTATE_CHECK_EVERY_BYTES = Math.floor(ROTATE_AT_BYTES / 5);
520
+ /** And a floor for the idle case, where nothing is being written and a
521
+ * periodic look is the only way to notice a file that grew by some other
522
+ * route — another deck appending to a log they share, say. */
523
+ const ROTATE_CHECK_EVERY_MS = 30_000;
524
+ /**
525
+ * Whether it is worth asking the filesystem how big the log is.
526
+ *
527
+ * Pure, and exported, for the reason mayReadAccounts and maySelfPoll are: this
528
+ * is the rule, and a rule whose only observable failure is a file quietly
529
+ * reaching gigabytes belongs somewhere a test can point at it. Rotation had no
530
+ * test of any kind.
531
+ */
532
+ export function rotateCheckDue({ now, lastCheckAt, bytesSince }) {
533
+ return now - lastCheckAt >= ROTATE_CHECK_EVERY_MS
534
+ || bytesSince >= ROTATE_CHECK_EVERY_BYTES;
535
+ }
536
+
537
+ async function maybeRotatePersistFile(wroteBytes = 0) {
426
538
  if (!persistPath) return;
539
+ bytesSinceRotateCheck += wroteBytes;
427
540
  const now = Date.now();
428
- // Throttle disk-stat checks to once per 30s.
429
- if (now - lastRotateCheckAt < 30_000) return;
541
+ if (!rotateCheckDue({ now, lastCheckAt: lastRotateCheckAt, bytesSince: bytesSinceRotateCheck })) return;
430
542
  lastRotateCheckAt = now;
543
+ bytesSinceRotateCheck = 0;
431
544
  if (rotateInProgress) return;
432
545
  rotateInProgress = true;
433
546
  try {
434
547
  const s = await stat(persistPath).catch(() => null);
435
548
  if (!s || s.size < ROTATE_AT_BYTES) return;
549
+ // ONLY THE DECK THAT WRITES THIS LOG MAY MOVE IT, the same gate `/api/clear`
550
+ // already keeps and for the same reason. Two decks appending to one log is
551
+ // explicitly permitted as the fail-safe — log-writer.mjs says "a line
552
+ // written twice is recoverable" — so both cross 50 MB and both rotate. If
553
+ // B's stat lands before A's rename, B's `unlink` deletes the archive A has
554
+ // just made and B's `rename` moves the new, near-empty live file into its
555
+ // place: one generation of history destroyed by a deck that was only
556
+ // housekeeping. The append path was made multi-writer safe; this one was
557
+ // not. Ownership is electWriters, so nothing new gets to disagree with it.
558
+ const sharing = await logSharing();
559
+ if (!sharing.mine) return;
436
560
  // Roll events.jsonl → events.jsonl.1 (replacing any previous .1).
437
561
  const oldPath = persistPath + ".1";
562
+ // LET THE QUEUE DRAIN FIRST. Appends are ordered behind a promise chain
563
+ // inside log-writer.mjs and a descriptor opened before the rename completes
564
+ // into the RENAMED inode, so the line being appended at the moment of a
565
+ // rotation lands in the archive rather than the live log — measured, a
566
+ // 24 MB line in events.jsonl.1 and a 65-byte live file. See flushAppends.
567
+ await flushAppends(persistPath);
438
568
  try { await unlink(oldPath); } catch {}
439
569
  const { rename } = await import("node:fs/promises");
440
570
  await rename(persistPath, oldPath);
@@ -633,6 +763,7 @@ function newTranscriptState() {
633
763
  subagentModels: {},
634
764
  aiTitle: null, // newest "ai-title" entry, the session's sentence title
635
765
  agentName: null, // newest "agent-name" entry, the session's short name
766
+ recap: null, // newest "away_summary" no later turn retired — session-recap.mjs
636
767
  usage: newUsageTotals(),
637
768
  // The same totals, split by the model that produced them (#686). The flat
638
769
  // bucket above stays the whole-transcript sum and is what every token count
@@ -840,6 +971,9 @@ function foldTranscriptLine(state, line) {
840
971
  // totals and the context counts, so naming costs no read of its own — see the
841
972
  // block above `maybeResolveSessionName` for why that beat a tail read.
842
973
  foldSessionNamingLine(state, line);
974
+ // The recap rides the same pass for the same reason, and costs the ordinary
975
+ // line two substring tests. See session-recap.mjs.
976
+ foldRecapLine(state, line);
843
977
 
844
978
  // Model. Only a line that mentions a model can change it, and parsing the
845
979
  // rest is what made the full rescan expensive.
@@ -1476,12 +1610,18 @@ const pendingNameReads = new Set(); // sid currently being read
1476
1610
  * through a live server" — which stated a test contract no test had (#798).
1477
1611
  * What is worth pinning is the parsing, and that is `foldSessionNamingLine`,
1478
1612
  * which IS exported and which session-name.test.ts drives line at a time the
1479
- * way `foldTranscriptLine` does. This wrapper is a scan and two null checks. */
1613
+ * way `foldTranscriptLine` does. This wrapper is a scan and two null checks.
1614
+ *
1615
+ * The recap comes back beside the naming because it comes off the same scan:
1616
+ * `naming` is null when the transcript has no naming record, `recap` when no
1617
+ * recap is standing. See session-recap.mjs, and `noteRecap` below. */
1480
1618
  async function readSessionNamingFromTranscript(path) {
1481
1619
  const state = await scanTranscript(path);
1482
1620
  if (!state) return null;
1483
- if (!state.agentName && !state.aiTitle) return null;
1484
- return { agentName: state.agentName, aiTitle: state.aiTitle };
1621
+ const naming = (state.agentName || state.aiTitle)
1622
+ ? { agentName: state.agentName, aiTitle: state.aiTitle }
1623
+ : null;
1624
+ return { naming, recap: state.recap ?? null };
1485
1625
  }
1486
1626
 
1487
1627
  function maybeResolveSessionName(payload) {
@@ -1496,7 +1636,10 @@ function maybeResolveSessionName(payload) {
1496
1636
  lastNameReadAt.set(sid, now);
1497
1637
  pendingNameReads.add(sid);
1498
1638
  readSessionNamingFromTranscript(tp)
1499
- .then(naming => {
1639
+ .then(read => {
1640
+ if (!read) return;
1641
+ noteRecap(sid, read.recap);
1642
+ const naming = read.naming;
1500
1643
  if (!naming) return;
1501
1644
  const sig = `${naming.agentName ?? ""}\u0000${naming.aiTitle ?? ""}`;
1502
1645
  if (nameBySession.get(sid) === sig) return;
@@ -1512,6 +1655,43 @@ function maybeResolveSessionName(payload) {
1512
1655
  .finally(() => pendingNameReads.delete(sid));
1513
1656
  }
1514
1657
 
1658
+ // ─── Session recap ───────────────────────────────────────────────────────
1659
+ // Claude Code's "※ recap:" line (see session-recap.mjs). It comes off the
1660
+ // transcript cursor like the naming above, and its emit is gated the same way:
1661
+ // on a CHANGE, keyed by the recap's own timestamp, so a pass that re-reads a
1662
+ // recap already sent says nothing. A session that never had one emits nothing
1663
+ // at all — the first word about a session's recap is always a recap.
1664
+ //
1665
+ // TWO ROADS IN, BECAUSE NO HOOK FIRES WHEN IT LANDS. The cursor runs off hook
1666
+ // events, and a recap is written three minutes into a silence that has none.
1667
+ // So the output watch, which already stats these files between events, hands
1668
+ // every tail it reads to `onRecapTail`, and a tail carrying a recap asks the
1669
+ // cursor to fold the file. The cursor stays the one place the rule lives: a
1670
+ // recap a later turn retired is retired whichever road found it.
1671
+ const recapBySession = new Map(); // sid -> `${at}` last sent, "" once retired
1672
+
1673
+ function noteRecap(sid, recap) {
1674
+ const sig = recap ? String(recap.at) : "";
1675
+ const prev = recapBySession.get(sid);
1676
+ if (prev === sig) return;
1677
+ if (prev === undefined && !recap) return;
1678
+ recapBySession.set(sid, sig);
1679
+ pushEvent({
1680
+ hook_event_name: "SessionRecapped",
1681
+ session_id: sid,
1682
+ recap: recap ? { text: recap.text, at: recap.at } : null,
1683
+ }, "internal");
1684
+ }
1685
+
1686
+ /** The output watch's tap. Only a tail carrying the recap mark costs a scan,
1687
+ * and the scan is the cursor's own: it folds the bytes it has not seen yet. */
1688
+ function onRecapTail(sid, text, path) {
1689
+ if (!path || !text.includes(RECAP_MARK)) return;
1690
+ scanTranscript(path)
1691
+ .then(state => { if (state) noteRecap(sid, state.recap ?? null); })
1692
+ .catch(() => {});
1693
+ }
1694
+
1515
1695
  // ─── Context enrichment ──────────────────────────────────────────────────
1516
1696
  // Approximation of `/context` since CC doesn't expose its breakdown via
1517
1697
  // hooks. We scan the transcript JSONL for message counts (user / assistant
@@ -1844,26 +2024,125 @@ function maybeResolveCodexMemory(sid, cwd, persist) {
1844
2024
  // than a second computation of the same rule, is what makes the printed path and
1845
2025
  // the tailed path unable to disagree.
1846
2026
  export { CODEX_SESSIONS_DIR };
2027
+ // sid -> the rollout's path, or, once a walk of the whole tree came back without
2028
+ // one, the moment that walk began. See findCodexRolloutPath for why a miss is
2029
+ // kept (#992), and why only for CODEX_MISS_TTL_MS (#1134).
1847
2030
  const codexRolloutPathBySid = new Map();
1848
2031
  const lastCodexUsageReadAt = new Map();
1849
2032
  const pendingCodexUsageReads = new Set();
1850
2033
  const CODEX_READ_THROTTLE_MS = 2500;
2034
+ // How many of the newest day directories a lookup reads when it is not owed
2035
+ // the whole tree: the bound listRecentCodexRollouts keeps for the watcher.
2036
+ const CODEX_RECENT_DAY_DIRS = 2;
2037
+ // How long a miss from the whole tree is believed before its id is owed one
2038
+ // more walk of it. See findCodexRolloutPath for why ten minutes.
2039
+ const CODEX_MISS_TTL_MS = 10 * 60 * 1000;
2040
+ // True while one lookup is walking the whole tree. One at a time; see below.
2041
+ let codexWholeTreeWalk = false;
1851
2042
 
2043
+ /**
2044
+ * Where is this session's rollout, if it is anywhere under this tree?
2045
+ *
2046
+ * WHAT WAS WRONG (#992). A hit was kept and a miss was not, and the walk that
2047
+ * answers a miss is the whole tree: one readdir per year, per month and per day
2048
+ * under $CODEX_HOME/sessions, which only a hit ends early. So an id with no
2049
+ * rollout here paid for that walk again on every pass maybeResolveCodex's
2050
+ * 2.5-second throttle let through, for as long as its hooks kept firing. A Codex
2051
+ * hook left over from an older install, for a session whose rollout lives
2052
+ * under another CODEX_HOME, is enough. The id also arrives on `/api/event`,
2053
+ * which takes no credential and throttles per id, so a local process could post
2054
+ * a few hundred fresh ids and have every one of them walk every directory at
2055
+ * once. Both were measured in codex-unresolved-session-walk-992.test.ts, over a
2056
+ * tree holding one year of history. The same unresolvable id, posted twice one
2057
+ * throttle window apart, read all 37 of that year's directories both times, and
2058
+ * six fresh ids posted together had six walks of it in flight at once.
2059
+ *
2060
+ * THE FIRST LOOKUP STILL WALKS THE WHOLE TREE, newest first, as it always did.
2061
+ * A session resumed from last month appends to last month's rollout, and one
2062
+ * that has been running for three days has dropped out of the newest two
2063
+ * directories. Bounding the first walk the way the watcher's listing is bounded
2064
+ * would find neither, and their usage would never show.
2065
+ *
2066
+ * A MISS FROM THE WHOLE TREE IS KEPT, as the moment that walk began, and every
2067
+ * later lookup for that id reads only the newest two day directories. Those are
2068
+ * the only place a rollout written after the walk can land, because Codex names
2069
+ * a file for the moment it creates it, and they are the bound
2070
+ * listRecentCodexRollouts already trusts for live sessions. So a hook that
2071
+ * fires a moment before its rollout is on disk is still answered on a later
2072
+ * pass, and an id nothing will ever carry costs about five readdirs a pass
2073
+ * instead of the whole history. One caveat: walkRolloutDays swallows every
2074
+ * error it meets, so a walk that could not read a directory still counts as
2075
+ * having looked there. That is the one way a wrong miss gets kept.
2076
+ *
2077
+ * BUT NOT FOREVER (#1134). The newest two directories are where a rollout
2078
+ * written after the walk lands, and they do not stay the newest two. A session
2079
+ * whose first hook fired before its rollout existed, and whose next hook came
2080
+ * after two newer day directories had appeared — a `codex resume` two days
2081
+ * later — had its rollout in the third-newest directory, behind a miss that
2082
+ * only forgetSession or a restart would ever clear. Its usage never showed,
2083
+ * where the tree before #1111 found it; codex-kept-miss-expires-1134.test.ts
2084
+ * has the measurement.
2085
+ *
2086
+ * So a kept miss is believed for CODEX_MISS_TTL_MS, and the first lookup after
2087
+ * that is owed one more whole walk, on the same one-at-a-time terms as the
2088
+ * first. Ten minutes closes the hole outright. A rollout drops out of the newest
2089
+ * two directories only once two later calendar days have begun, which is never
2090
+ * less than about a day after it was written (23 hours across a DST change), so
2091
+ * by then every miss that could be hiding it has expired and the next lookup
2092
+ * walks to it. It also keeps #992 fixed: an id nothing carries walks the whole
2093
+ * tree once in ten minutes where it used to walk it every 2.5-second throttle
2094
+ * window, 240 times as often, and reads its five directories in between. And
2095
+ * the caveat above now costs a session ten minutes of usage rather than the
2096
+ * rest of the process's life. It is the same ten minutes CODEX_STATE_TTL_MS
2097
+ * keeps a rollout's cursor for, the other bound on this side that leans on the
2098
+ * two-directory window. A clock that has gone backwards leaves a miss's age
2099
+ * unknown, and such a miss is treated as expired: it costs one walk, where
2100
+ * believing it would reopen the hole for however far the clock went back.
2101
+ *
2102
+ * AND ONE WHOLE-TREE WALK AT A TIME. Keeping the miss handles an id that comes
2103
+ * back; it does nothing for a caller that sends a new one every time. So a
2104
+ * lookup that is owed the whole tree while another lookup is walking it reads
2105
+ * the newest two directories instead, keeps nothing, and gets its full walk on
2106
+ * a later pass. A burst of fresh ids costs one walk of the history plus a few
2107
+ * readdirs each, not one walk each, all at once.
2108
+ *
2109
+ * The map is bounded as it was: forgetSession evicts it with every other
2110
+ * per-session cache, and a number is smaller than the path it stands in for.
2111
+ */
1852
2112
  async function findCodexRolloutPath(sid) {
1853
2113
  const cached = codexRolloutPathBySid.get(sid);
1854
- if (cached) return cached;
2114
+ if (typeof cached === "string") return cached;
2115
+ // Owed the whole tree: never looked for yet, or the whole tree's last miss is
2116
+ // no longer believed — and nobody else is walking it.
2117
+ const startedAt = Date.now();
2118
+ const age = typeof cached === "number" ? startedAt - cached : -1;
2119
+ const missHolds = age >= 0 && age < CODEX_MISS_TTL_MS;
2120
+ const whole = !missHolds && !codexWholeTreeWalk;
2121
+ if (whole) codexWholeTreeWalk = true;
1855
2122
  // Walk year → month → day → files, newest first. Codex includes the sid in the
1856
2123
  // filename (rollout-...-<sid>.jsonl) so a directory-scoped match is enough.
1857
2124
  // The walk itself lives in codex-dir.mjs, shared with the watcher's listing
1858
2125
  // below and with codex-usage.mjs, so all three read one tree the same way.
1859
2126
  let found = null;
1860
- await walkRolloutDays((dayDir, files) => {
1861
- const hit = files.find(f => f.includes(sid) && f.endsWith(".jsonl"));
1862
- if (!hit) return;
1863
- found = join(dayDir, hit);
1864
- return STOP;
1865
- });
2127
+ let dayDirs = 0;
2128
+ try {
2129
+ await walkRolloutDays((dayDir, files) => {
2130
+ const hit = files.find(f => f.includes(sid) && f.endsWith(".jsonl"));
2131
+ if (hit) {
2132
+ found = join(dayDir, hit);
2133
+ return STOP;
2134
+ }
2135
+ if (!whole && ++dayDirs >= CODEX_RECENT_DAY_DIRS) return STOP;
2136
+ });
2137
+ } finally {
2138
+ if (whole) codexWholeTreeWalk = false;
2139
+ }
2140
+ // A hit is kept, as it always was. A miss is kept only when it is the whole
2141
+ // tree's answer — a miss in the newest two directories says nothing about the
2142
+ // rest of them — and it is kept as the moment that walk began, which is when
2143
+ // it read the directories a rollout written since would be in.
1866
2144
  if (found) codexRolloutPathBySid.set(sid, found);
2145
+ else if (whole) codexRolloutPathBySid.set(sid, startedAt);
1867
2146
  return found;
1868
2147
  }
1869
2148
 
@@ -2044,6 +2323,33 @@ const codexSessionApproval = new Map(); // sid -> last approval_policy string
2044
2323
  // listing. The listing covers two day-directories, so anything missing from it
2045
2324
  // is at least a day old and will never be appended to again.
2046
2325
  const CODEX_STATE_TTL_MS = 10 * 60 * 1000;
2326
+ // Every rollout path this PROCESS has ever held a cursor for, which the TTL
2327
+ // sweep deliberately does not clear — that is the whole of its job.
2328
+ //
2329
+ // A cursor dropped and then re-created is indistinguishable, to the code that
2330
+ // creates it, from a rollout nobody has ever read: both are "no entry in
2331
+ // codexFileState". The difference matters enormously, because a new file is
2332
+ // opened at byte 0 and told it has its own beginning, so a re-discovery
2333
+ // replayed the entire rollout as fresh events AND minted a `SessionStart` for a
2334
+ // session this deck joined late. This set is what lets the second case answer
2335
+ // "no, we have seen this one" and park at the end instead (#981).
2336
+ //
2337
+ // Capped, in insertion order, for the reason codexFileState is swept at all: a
2338
+ // long-lived deck on a busy machine would otherwise accumulate a path per
2339
+ // session for as long as it runs. A path is far cheaper than a cursor, so the
2340
+ // cap is generous — but it is a cap, and it is the same LRU shape
2341
+ // sessionTouchedAt uses.
2342
+ const codexSeenEver = new Set();
2343
+ const MAX_CODEX_SEEN_EVER = 512;
2344
+ function rememberCodexPath(path) {
2345
+ // Delete-then-add so the Set's own insertion order IS the LRU order, and
2346
+ // eviction is one key read rather than a scan.
2347
+ codexSeenEver.delete(path);
2348
+ codexSeenEver.add(path);
2349
+ while (codexSeenEver.size > MAX_CODEX_SEEN_EVER) {
2350
+ codexSeenEver.delete(codexSeenEver.values().next().value);
2351
+ }
2352
+ }
2047
2353
  let codexScanRunning = false;
2048
2354
  let codexWatchTimer = null;
2049
2355
  let codexWorkspace = "";
@@ -2135,8 +2441,12 @@ async function provesDeck(d) {
2135
2441
  * which other decks are tailing the same file into the same log. Dead pids are
2136
2442
  * ignored rather than unlinked — sweepStaleDiscovery owns that, and a poll
2137
2443
  * running every 1.5s is no place to be deleting other decks' registrations. A
2138
- * record that fails the challenge is likewise left alone: see the same argument
2139
- * spelled out in proveTargets in hook/hook.js.
2444
+ * record that fails the challenge is likewise left alone HERE. The hook is the one
2445
+ * reader that does delete a record, and only one that stayed silent through both
2446
+ * of its challenge deadlines and has not been restamped for a minute, which a
2447
+ * live deck does every five seconds (proveTargets in hook/hook.js, #1119). One
2448
+ * missed answer on this poll's timer is not that evidence, so nothing here acts
2449
+ * on it.
2140
2450
  *
2141
2451
  * The pid probe stays, in front of the challenge, because it is free and it is
2142
2452
  * the one test that answers instantly when a deck is killed — which is what
@@ -2179,6 +2489,33 @@ async function listRecentCodexRollouts() {
2179
2489
  return out;
2180
2490
  }
2181
2491
 
2492
+ /**
2493
+ * The session id a rollout's own file name carries, or null.
2494
+ *
2495
+ * Codex names every rollout `rollout-<YYYY-MM-DDTHH-MM-SS>-<uuid>.jsonl` —
2496
+ * codex-usage.mjs reads the timestamp half as parseRolloutTime — and the uuid
2497
+ * is the id `session_meta.payload.id` states. findCodexRolloutPath already leans
2498
+ * on that, matching a session id against file names, so the name is the
2499
+ * fallback when the header stops saying it (#996). A compressed `.jsonl.zst`
2500
+ * is not matched: this reader never opens one.
2501
+ */
2502
+ export function sidFromRolloutName(path) {
2503
+ const m = /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i.exec(basename(String(path ?? "")));
2504
+ return m ? m[1] : null;
2505
+ }
2506
+
2507
+ /** Which of the two ways a rollout can fail to say whose it is has been printed
2508
+ * already. Once per kind for the life of the process, not once per file: the
2509
+ * scan retries a rollout it cannot place on every tick, and a change to the
2510
+ * format would hit every rollout at once, so per file would be the same
2511
+ * sentence once for each of them. */
2512
+ const codexHeaderWarned = new Set();
2513
+ function warnUnplacedRollout(kind, path, why) {
2514
+ if (codexHeaderWarned.has(kind)) return;
2515
+ codexHeaderWarned.add(kind);
2516
+ console.warn(`${PRODUCT}: cannot tell which Codex session ${path} belongs to: ${why}. If Codex has changed its rollout format, its sessions will not be drawn until the deck reads the new one.`);
2517
+ }
2518
+
2182
2519
  // Read the first complete JSON line of a rollout (the session_meta header)
2183
2520
  // to learn sid + cwd before we start streaming. The header line can be large
2184
2521
  // (base_instructions text runs tens of KB), so we read in growing chunks until
@@ -2196,11 +2533,31 @@ async function readCodexHeader(path) {
2196
2533
  if (nl >= 0) {
2197
2534
  const obj = JSON.parse(text.slice(0, nl));
2198
2535
  if (obj && obj.type === "session_meta" && obj.payload) {
2536
+ // THE ID, WITH THE FILE NAME BEHIND IT (#996). Every other field this
2537
+ // file takes off a rollout goes through a type guard, and the event
2538
+ // names are read in both spellings because OpenAI has renamed a record
2539
+ // once already; this one went straight into `sid`. A renamed or
2540
+ // dropped `payload.id` made every header answer undefined, and the
2541
+ // scan `continue`s on that — so every rollout was retried on every
2542
+ // tick, its first 64KB re-read each time, not one Codex session was
2543
+ // drawn, and nothing said why. The file name carries the same id.
2544
+ const id = obj.payload.id;
2545
+ const sid = typeof id === "string" && id !== "" ? id : sidFromRolloutName(path);
2546
+ if (!sid) warnUnplacedRollout("no-id", path, "its session_meta has no id, and its file name carries none");
2199
2547
  // Canonicalised here and nowhere else: everything downstream — the
2200
2548
  // workspace test below, the log election, the cwd on every event this
2201
2549
  // rollout produces — reads state.cwd, and this is the one place it is
2202
2550
  // read off disk. See canonicalCwd.
2203
- return { sid: obj.payload.id, cwd: await canonicalCwd(obj.payload.cwd) };
2551
+ return { sid, cwd: await canonicalCwd(obj.payload.cwd) };
2552
+ }
2553
+ // The other rename the scan would otherwise retry in silence: a first
2554
+ // line that is whole but is not a session_meta will never become one.
2555
+ // Said, not guessed around — which record a renamed header would be is
2556
+ // not something to infer from one line. Only for a file named like a
2557
+ // rollout, so some other `.jsonl` left in the tree is not reported as a
2558
+ // format change.
2559
+ if (obj && typeof obj === "object" && sidFromRolloutName(path)) {
2560
+ warnUnplacedRollout("no-header", path, `its first line is a ${JSON.stringify(String(obj.type ?? "record with no type"))}, not a session_meta`);
2204
2561
  }
2205
2562
  return null;
2206
2563
  }
@@ -2265,9 +2622,14 @@ export function codexObjToPayload(obj, sid, cwd) {
2265
2622
  // "waiting" state derived from this plus a pending call would be a guess
2266
2623
  // wearing the clothes of a measurement.
2267
2624
  //
2268
- // Written on the record even when the value is missing or not a string, so
2269
- // a future Codex that renames or drops the field clears the stale answer
2270
- // rather than pinning the session to whatever it last said.
2625
+ // Deleted from the record when the value is missing or not a string, so this
2626
+ // server stops stamping a stale policy on the payloads it builds from here
2627
+ // on. The page does not un-say it, though. `approval_policy: undefined` does
2628
+ // not survive JSON.stringify, so the wire has no way to say "cleared", and
2629
+ // the reducer applies the field only when it is a non-empty string — a card
2630
+ // keeps the last policy it was told for as long as the session is on the
2631
+ // board. This comment used to promise the stale answer was cleared; it is
2632
+ // cleared here and nowhere else (#996).
2271
2633
  if (typeof pl.approval_policy === "string" && pl.approval_policy) {
2272
2634
  codexSessionApproval.set(sid, pl.approval_policy);
2273
2635
  } else {
@@ -2653,8 +3015,19 @@ async function codexScanOnce(firstRun) {
2653
3015
  // Opened at byte 0, so every line this session ever wrote is about to
2654
3016
  // be read: this watcher HAS its beginning, and ensureCodexRoot may say
2655
3017
  // so. The `firstRun` branch below is the one case that takes it away.
2656
- state = { offset: 0, sid: header.sid, cwd: header.cwd, skip: false, sawBeginning: true, rootOpened: false, seenAt: now };
3018
+ //
3019
+ // UNLESS THIS PROCESS HAS SEEN THE PATH BEFORE, in which case there is
3020
+ // no beginning to claim and nothing to replay: the cursor was dropped
3021
+ // by the TTL sweep and this is a RE-discovery. Parking at the current
3022
+ // size costs whatever was appended while the tree was unreachable,
3023
+ // which is the same trade `firstRun` already makes below — against
3024
+ // re-emitting every prompt, every PreToolUse/PostToolUse and every
3025
+ // UsageObserved in the file to every tab, and re-appending the lot to
3026
+ // the shared events.jsonl if this deck is the elected writer (#981).
3027
+ const fresh = !codexSeenEver.has(path);
3028
+ state = { offset: fresh ? 0 : st.size, sid: header.sid, cwd: header.cwd, skip: false, sawBeginning: fresh, rootOpened: false, seenAt: now };
2657
3029
  codexFileState.set(path, state);
3030
+ rememberCodexPath(path);
2658
3031
  if (firstRun) {
2659
3032
  // On startup, skip a pre-existing session's history entirely — no
2660
3033
  // replay. Only future appends (a live session that keeps going) will
@@ -2669,8 +3042,18 @@ async function codexScanOnce(firstRun) {
2669
3042
  // of the old one's history either. If the log it replays at boot
2670
3043
  // already contains a `SessionStart` this deck minted honestly in an
2671
3044
  // earlier life, the root is rebuilt unmarked from that line and stays
2672
- // unmarked — the reducer only honours `synthetic` on the call that
2673
- // CREATES the node, and nothing here contradicts it.
3045
+ // unmarked, because nothing here emits a second one to disturb it.
3046
+ //
3047
+ // NOT because the reducer would refuse to listen. It used to say so
3048
+ // here — "the reducer only honours `synthetic` on the call that
3049
+ // CREATES the node" — and that is false: `reducer.ts` clears
3050
+ // `root.synthetic` on EVERY `SessionStart`, deliberately, so that one
3051
+ // arriving after the event that created the root retracts a marker
3052
+ // the deck no longer deserves to be wearing (#677). Which makes the
3053
+ // marker exactly as honest as the emitter above it, and is why the
3054
+ // rule this branch states — do not emit a `SessionStart` for a
3055
+ // beginning this process did not watch — is the whole guarantee
3056
+ // rather than a belt beside a brace (#981).
2674
3057
  state.offset = st.size;
2675
3058
  state.sawBeginning = false;
2676
3059
  continue;
@@ -2678,6 +3061,14 @@ async function codexScanOnce(firstRun) {
2678
3061
  }
2679
3062
 
2680
3063
  if (state.skip) { state.offset = st.size; continue; }
3064
+ // A cursor sitting PAST the end of the file is a different thing from
3065
+ // "nothing new", and the two used to collapse into one `continue` — where
3066
+ // the Claude scanner beside this one starts the file over (scanTranscript).
3067
+ // Not reachable today: rollouts are append-only and uniquely named, and
3068
+ // readAppendedLines cannot advance a cursor past `size`. It is resynced
3069
+ // FORWARD rather than back to zero because re-reading a rollout from the
3070
+ // beginning is precisely the full replay #981 is about.
3071
+ if (st.size < state.offset) state.offset = st.size;
2681
3072
  if (st.size <= state.offset) continue;
2682
3073
 
2683
3074
  // Same bounded reader the Claude transcripts use. A rollout is not a
@@ -2732,8 +3123,24 @@ async function codexScanOnce(firstRun) {
2732
3123
  // "not seen for a while" rather than "absent from this listing": a single
2733
3124
  // unreadable directory mid-scan would otherwise drop a live file's cursor,
2734
3125
  // and re-adding it at offset 0 replays that entire rollout as fresh events.
2735
- for (const [p, s] of codexFileState) {
2736
- if (now - (s.seenAt ?? 0) > CODEX_STATE_TTL_MS) codexFileState.delete(p);
3126
+ //
3127
+ // AN EMPTY LISTING IS NOT EVIDENCE THAT ANYTHING WENT AWAY, and skipping
3128
+ // the sweep on those ticks is the other half of the same argument. The
3129
+ // "not seen for a while" clock was meant to ride out a directory that is
3130
+ // unreadable for a moment, and it did — but it ran on ticks where `seenAt`
3131
+ // had been refreshed for NOBODY, because walkRolloutDays swallows its
3132
+ // readdir error at every level and answers `[]` rather than throwing. Ten
3133
+ // minutes of an unreachable $CODEX_HOME — a network or removable volume, an
3134
+ // encrypted home not yet unlocked, a permission change — and every cursor
3135
+ // was gone, which is the whole window the clock was sized to survive (#981).
3136
+ //
3137
+ // Nothing is lost by waiting: an empty listing means no file can be read
3138
+ // this tick anyway, and a tree that really is empty stays empty, so the
3139
+ // first tick that lists anything at all sweeps what is genuinely stale.
3140
+ if (files.length) {
3141
+ for (const [p, s] of codexFileState) {
3142
+ if (now - (s.seenAt ?? 0) > CODEX_STATE_TTL_MS) codexFileState.delete(p);
3143
+ }
2737
3144
  }
2738
3145
  } catch {
2739
3146
  /* swallow — watcher must never crash the server */
@@ -2778,12 +3185,47 @@ export function eventsSince(seq) {
2778
3185
  // model of each. SessionEnd is not a usable eviction signal (a killed CLI
2779
3186
  // never sends one, and Codex has no such hook at all), so entries expire by
2780
3187
  // least-recent use against a cap instead — the same shape pruneTranscriptScans
2781
- // already uses for its per-path state. The cap sits far above any plausible
2782
- // number of concurrent sessions, so a live session is never evicted; and an
2783
- // evicted one that speaks again simply re-reads its transcript.
3188
+ // already uses for its per-path state.
3189
+ //
3190
+ // AND A LIVE SESSION IS NEVER EVICTED, which this comment used to assert on the
3191
+ // strength of the cap sitting "far above any plausible number of concurrent
3192
+ // sessions". The cap held; the claim around it did not, and the 257th
3193
+ // concurrent session is where that showed. forgetSession clears the three
3194
+ // read-throttle stamps and pruneTranscriptScans drops the byte cursor, so an
3195
+ // evicted session that speaks again does not "simply re-read its transcript" —
3196
+ // it re-reads it on EVERY event, and those re-reads are themselves pushEvent
3197
+ // calls, so the degradation feeds itself. Measured here, fresh deck per row,
3198
+ // 16 events per session, 21 KB transcripts:
3199
+ //
3200
+ // N=200 posted=3200 synthetic=600 rchar=6 MB rps=5614 p50=17ms
3201
+ // N=256 posted=4096 synthetic=768 rchar=7 MB rps=6341 p50=16ms
3202
+ // N=300 posted=4800 synthetic=9159 rchar=77 MB rps=2308 p50=97ms
3203
+ //
3204
+ // A 17% increase in session count: 11.9x the derived events, 11x the transcript
3205
+ // reads, ingest down 2.7x, p50 up 6x. Three per SESSION became three per EVENT.
3206
+ // A cliff, not a slope, sitting exactly on the constant — and the server's cap
3207
+ // (256) sits above the client's AGENT_CAP (200), so the UI cannot warn about a
3208
+ // number it will not draw.
3209
+ //
3210
+ // So the cap reaps only what it was always described as reaping: sessions
3211
+ // nothing has been heard from. A session with an event inside
3212
+ // OUTPUT_WATCH_WINDOW_MS is live by the same definition outputWatchOnce already
3213
+ // uses, and keeping it costs four numbers and a string.
2784
3214
  const sessionTouchedAt = new Map(); // sid -> ms of the last event seen
2785
3215
  const MAX_TRACKED_SESSIONS = 256;
2786
3216
 
3217
+ /**
3218
+ * The absolute ceiling, above which even a live session is dropped.
3219
+ *
3220
+ * The age rule alone is unbounded in principle — nothing stops a machine from
3221
+ * having ten thousand sessions inside five minutes — and an unbounded map is
3222
+ * the leak this whole mechanism exists to end. This is a backstop, not the
3223
+ * working limit: reaching it means something is wrong in a way no cache policy
3224
+ * fixes, and dropping the least-recently-seen entries is still the least bad
3225
+ * answer. Eight times the cap, which is forty times the client's AGENT_CAP.
3226
+ */
3227
+ export const HARD_TRACKED_SESSIONS = MAX_TRACKED_SESSIONS * 8;
3228
+
2787
3229
  /** The transcript watch, and how far back a session stays worth polling.
2788
3230
  *
2789
3231
  * A session is polled while its last HOOK event is recent — and the window has
@@ -2797,16 +3239,48 @@ const OUTPUT_WATCH_WINDOW_MS = 5 * 60_000;
2797
3239
  const OUTPUT_WATCH_MS = 1_500;
2798
3240
  let outputWatchTimer = null;
2799
3241
 
3242
+ /** How long a quiet session is still worth a `stat` for its recap, and how
3243
+ * often it gets one.
3244
+ *
3245
+ * The five minutes above are sized to a session that is WORKING. A recap is
3246
+ * the opposite case: Claude Code writes it once a finished turn has sat three
3247
+ * minutes AND the terminal has lost focus, so it lands whenever the person
3248
+ * walks away — a median 3.1 minutes after the turn on this machine, and an
3249
+ * hour after it for somebody who stayed at the terminal first. A session that
3250
+ * quiet is producing nothing, so every fourth tick is plenty: a recap reaches
3251
+ * the deck within six seconds of being written, and a resting session costs a
3252
+ * `stat` every six seconds rather than every one and a half. */
3253
+ const RECAP_WATCH_WINDOW_MS = 12 * 60 * 60_000;
3254
+ const RECAP_WATCH_EVERY = 4;
3255
+ let outputWatchTicks = 0;
3256
+
2800
3257
  /** One tick: stat the recent sessions' transcripts, read only what grew, and
2801
3258
  * say what landed. Everything expensive about this is guarded inside the watch
2802
- * — a session that wrote nothing costs one `stat`. */
3259
+ * — a session that wrote nothing costs one `stat`.
3260
+ *
3261
+ * Resting sessions ride along on every RECAP_WATCH_EVERY-th tick for their
3262
+ * recap alone, and their blocks are not reported: this answers for the
3263
+ * sessions it calls live, and a resting one that starts working again fires a
3264
+ * hook first, which makes it live the ordinary way.
3265
+ *
3266
+ * A tick that starts while the last one is still reading gets nothing back:
3267
+ * the watch refuses the overlap itself, where the offset is — see `poll` in
3268
+ * output-watch.mjs for what two overlapping ticks used to report (#1089). */
2803
3269
  async function outputWatchOnce() {
2804
- const cutoff = Date.now() - OUTPUT_WATCH_WINDOW_MS;
2805
- const live = [];
2806
- for (const [sid, at] of sessionTouchedAt) if (at >= cutoff) live.push(sid);
2807
- if (!live.length) return;
2808
- const found = await outputWatch.poll(live);
3270
+ const now = Date.now();
3271
+ const cutoff = now - OUTPUT_WATCH_WINDOW_MS;
3272
+ const restingCutoff = now - RECAP_WATCH_WINDOW_MS;
3273
+ const withResting = outputWatchTicks++ % RECAP_WATCH_EVERY === 0;
3274
+ const live = new Set();
3275
+ const polled = [];
3276
+ for (const [sid, at] of sessionTouchedAt) {
3277
+ if (at >= cutoff) { live.add(sid); polled.push(sid); }
3278
+ else if (withResting && at >= restingCutoff) polled.push(sid);
3279
+ }
3280
+ if (!polled.length) return;
3281
+ const found = await outputWatch.poll(polled, onRecapTail);
2809
3282
  for (const f of found) {
3283
+ if (!live.has(f.sid)) continue;
2810
3284
  pushEvent({
2811
3285
  hook_event_name: "OutputObserved",
2812
3286
  session_id: f.sid,
@@ -2839,6 +3313,8 @@ function forgetSession(sid) {
2839
3313
  // ring has rolled past the original SessionNamed shows that session unnamed
2840
3314
  // for the rest of its life.
2841
3315
  nameBySession.delete(sid);
3316
+ // The recap's gate, for the same reason — see noteRecap.
3317
+ recapBySession.delete(sid);
2842
3318
  lastNameReadAt.delete(sid);
2843
3319
  modelLastReadAt.delete(sid);
2844
3320
  lastUsageReadAt.delete(sid);
@@ -2855,9 +3331,17 @@ function touchSession(sid) {
2855
3331
  // Re-insert so the Map's own insertion order *is* the LRU order and eviction
2856
3332
  // below is one key read rather than a scan of every session ever seen.
2857
3333
  sessionTouchedAt.delete(sid);
2858
- sessionTouchedAt.set(sid, Date.now());
2859
- while (sessionTouchedAt.size > MAX_TRACKED_SESSIONS) {
2860
- const oldest = sessionTouchedAt.keys().next().value;
3334
+ const now = Date.now();
3335
+ sessionTouchedAt.set(sid, now);
3336
+ if (sessionTouchedAt.size <= MAX_TRACKED_SESSIONS) return;
3337
+ // Insertion order IS recency, so the first entry still inside the window
3338
+ // proves every entry after it is too, and the loop stops there rather than
3339
+ // scanning. That is what keeps this O(evicted) and not O(sessions) on the hot
3340
+ // path — which matters most in exactly the case that used to be worst.
3341
+ const idleBefore = now - OUTPUT_WATCH_WINDOW_MS;
3342
+ for (const [oldest, at] of sessionTouchedAt) {
3343
+ if (sessionTouchedAt.size <= MAX_TRACKED_SESSIONS) break;
3344
+ if (at >= idleBefore && sessionTouchedAt.size <= HARD_TRACKED_SESSIONS) break;
2861
3345
  sessionTouchedAt.delete(oldest);
2862
3346
  forgetSession(oldest);
2863
3347
  }
@@ -3234,7 +3718,7 @@ function handlePrefsRead(req, res) {
3234
3718
 
3235
3719
  /** POST a patch. Fields nobody sent keep their value — see writePrefs. */
3236
3720
  async function handlePrefsWrite(req, res) {
3237
- const raw = await readBody(req).catch(() => null);
3721
+ const raw = await readBody(req, res).catch(() => null);
3238
3722
  let body = null;
3239
3723
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3240
3724
  if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
@@ -3260,11 +3744,13 @@ let _awayNothingUntil = 0;
3260
3744
  /**
3261
3745
  * One tick of the away-update. Returns what it did, or null.
3262
3746
  *
3263
- * Exported so a test can drive it without waiting out the boot grace — the one
3264
- * input that is a policy rather than a fact, and so the only one it may pass.
3265
- * Every clock is the real one: presence and activity are stamped with it.
3747
+ * Every clock is the real one: presence and activity are stamped with it, and
3748
+ * the boot grace is AWAY_BOOT_GRACE_MS. This used to be exported and take the
3749
+ * grace as an argument, so a test could drive it without waiting that out; the
3750
+ * tests drive awayGate and awayUpdateStep in auto-update.mjs instead, and the
3751
+ * one caller left is the `_awayTimer` interval.
3266
3752
  */
3267
- export async function awayUpdateTick({ graceMs = AWAY_BOOT_GRACE_MS } = {}) {
3753
+ async function awayUpdateTick() {
3268
3754
  const now = Date.now();
3269
3755
  if (!awayGate({
3270
3756
  enabled: _prefs?.autoUpdate !== false,
@@ -3274,7 +3760,7 @@ export async function awayUpdateTick({ graceMs = AWAY_BOOT_GRACE_MS } = {}) {
3274
3760
  looking: presence.looking(now),
3275
3761
  busy: activity.busy(now),
3276
3762
  quietMs: activity.quietMs(now),
3277
- graceMs,
3763
+ graceMs: AWAY_BOOT_GRACE_MS,
3278
3764
  })) return null;
3279
3765
  // A standing "nothing newer", unless the clock has moved back past it.
3280
3766
  if (now < _awayNothingUntil && _awayNothingUntil - now <= AWAY_RECHECK_MS) return null;
@@ -3295,6 +3781,9 @@ export async function awayUpdateTick({ graceMs = AWAY_BOOT_GRACE_MS } = {}) {
3295
3781
  if (presence.looking(again) || activity.busy(again) || _restarting) return null;
3296
3782
  _awayTry = { target: step.target, at: again };
3297
3783
  if (step.act === "install") {
3784
+ // Pinned before npm is spawned, never after it — see PINNED_MODULES. On a
3785
+ // deck bin/deck.js has marked ready this is a promise that settled long ago.
3786
+ await pinRunningBuild();
3298
3787
  su.startUpgrade({ pkgRoot: PKG_ROOT });
3299
3788
  return step.act;
3300
3789
  }
@@ -3309,7 +3798,7 @@ export async function awayUpdateTick({ graceMs = AWAY_BOOT_GRACE_MS } = {}) {
3309
3798
  /** POST {tab, looking} — a tab saying whether it is being looked at. See
3310
3799
  * presence.mjs, and src/web/presence.ts for the sender. */
3311
3800
  async function handlePresence(req, res) {
3312
- const raw = await readBody(req).catch(() => null);
3801
+ const raw = await readBody(req, res).catch(() => null);
3313
3802
  let body = null;
3314
3803
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3315
3804
  if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
@@ -3337,13 +3826,30 @@ const lanEngine = createEngine({
3337
3826
  return out?.ok ? out.blob : null;
3338
3827
  },
3339
3828
  importAccount: async (blob, step) => {
3340
- const { importAccount, landed } = await import("./cswap-admin.mjs");
3829
+ // `landed` is not needed out here any more: the only call that read it was
3830
+ // the forced import, which fillEmptySlot now owns along with it.
3831
+ const { importAccount, fillEmptySlot } = await import("./cswap-admin.mjs");
3832
+ const [want, wantOrg] = String(step?.key ?? "").split("@@");
3341
3833
  // NO `force`, ever, and this is the line where that promise is kept. A
3342
3834
  // plain import adds an account that is missing and replaces exactly one
3343
3835
  // claude-swap has quarantined; it SKIPS one that is present and healthy.
3344
3836
  // So a peer cannot overwrite a working credential of this deck's even by
3345
3837
  // lying about its own, because the flag that would allow it is not passed.
3346
- const out = await importAccount(blob);
3838
+ //
3839
+ // NARROWED TO THE ONE ACCOUNT ASKED FOR, for the same reason the forced
3840
+ // import below is, and this line did not have it. The seal's AAD is
3841
+ // `${peerFp}->${identity.fp}|${step.key}` — it binds the ENVELOPE to the
3842
+ // key that was requested and says nothing about the contents, and a share
3843
+ // payload is `{ accounts: [...] }`, a bundle rather than one credential.
3844
+ // So a peer asked for A could seal, under A's AAD, a bundle carrying A
3845
+ // plus B, C and D it never listed in its manifest, and every one of them
3846
+ // landed: `syncAction` answers "add" for anything this deck lacks, and an
3847
+ // add does not need the owner's tick. The panel drew A and the store
3848
+ // gained four. `only` narrows without implying `force` — `overwrite` is
3849
+ // `force === true && narrowing` — so the promise above survives word for
3850
+ // word, and a bundle that does not carry what was asked for is refused
3851
+ // rather than unpacked.
3852
+ const out = await importAccount(blob, { only: { email: want, org: wantOrg ?? "" } });
3347
3853
  if (!out?.ok) return { ok: false, why: out?.reason ?? "import refused" };
3348
3854
  // A SKIP IS NOT A HEAL, and reading `ok` alone said it was. `cswap import`
3349
3855
  // exits ZERO when it declines an account it already holds — importAccount's
@@ -3374,29 +3880,30 @@ const lanEngine = createEngine({
3374
3880
  // and the account somebody most needed repaired was the one the feature
3375
3881
  // could not repair.
3376
3882
  //
3377
- // ASKED NOW, NOT READ FROM THE CACHE. The cached verdicts are up to ten
3378
- // minutes old, which is right for a label and wrong for a decision that
3379
- // writes a credential: somebody who signed in two minutes ago still reads
3380
- // as `no_credentials` there, and acting on that would replace the login
3381
- // they had just created.
3382
- const { verdictNow } = await import("./claude-accounts.mjs");
3383
- const [email, org] = String(step?.key ?? "").split("@@");
3384
- const now = await verdictNow(email, org ?? "");
3385
- if (now !== "no_credentials") return { ok: false, why: "claude-swap kept the slot it already has" };
3386
-
3387
3883
  // FILLING AN EMPTY SLOT, WHICH IS NOT OVERWRITING A WORKING ONE — and the
3388
- // promise above survives word for word. A peer cannot reach this: the
3389
- // verdict comes from THIS machine's claude-swap, about THIS machine's
3390
- // store, and nothing a peer sends can make a slot report that it holds
3391
- // nothing. Narrowed to the one account with `only`, so a bundle carrying
3392
- // several cannot ride in behind it.
3393
- const forced = await importAccount(blob, { force: true, only: { email, org: org ?? "" } });
3394
- if (!forced?.ok) return { ok: false, why: forced?.reason ?? "import refused" };
3395
- // `added` counts `imported` alone, and a forced replace is `healed` — see
3396
- // landed, which is the difference between a repair and a repair reported as
3397
- // a failure.
3398
- if (!landed(forced.results)) return { ok: false, why: "claude-swap kept the slot it already has" };
3399
- return { ok: true, filled: true };
3884
+ // promise above survives word for word. A peer cannot reach it: the verdict
3885
+ // comes from THIS machine's claude-swap, about THIS machine's store, and
3886
+ // nothing a peer sends can make a slot report that it holds nothing.
3887
+ //
3888
+ // THE VERDICT AND THE WRITE ARE ONE CRITICAL SECTION, which is why this is
3889
+ // one call and not the pair it used to be (#1040).
3890
+ //
3891
+ // The verdict was already being asked for fresh rather than read from the
3892
+ // ten-minute cache, under a comment naming the exact reason: "somebody who
3893
+ // signed in two minutes ago still reads as `no_credentials` there, and
3894
+ // acting on that would replace the login they had just created". That
3895
+ // sentence is kept, and the read that served it moved rather than went —
3896
+ // see fillEmptySlot, which opens with it.
3897
+ //
3898
+ // Freshness alone only SHORTENED the window. The asking happened out here
3899
+ // while the writing took the store mutex inside importAccount, so the two
3900
+ // were never one critical section: `registerSignedIn` holds that mutex for
3901
+ // `cswap add` (60 s), `cswap list` (60 s) and `restoreActive`'s `cswap
3902
+ // switch` (30 s), and a forced import queued behind one still acted on a
3903
+ // verdict taken before any of it began. fillEmptySlot re-reads the verdict
3904
+ // as its first statement INSIDE the lock, so the promise holds by
3905
+ // construction rather than by how long the queue happened to be.
3906
+ return fillEmptySlot(blob, { email: want, org: wantOrg ?? "" });
3400
3907
  },
3401
3908
  // The deck's own long-term key, kept so a restart is the same deck rather
3402
3909
  // than a stranger to everybody who has paired with it. Written once, on the
@@ -3421,9 +3928,22 @@ const lanEngine = createEngine({
3421
3928
  // is a pairing that goes one-way at the next restart.
3422
3929
  onDial: async entry => {
3423
3930
  try {
3424
- const manual = Array.isArray(_prefs?.lan?.manual) ? _prefs.lan.manual : [];
3425
- if (manual.includes(entry)) return;
3426
- _prefs = await writePrefs({ lan: { manual: [...manual, entry] } });
3931
+ // COMPUTED INSIDE THE JOB, out of what the write is about to read, rather
3932
+ // than out of `_prefs` — which is a module-level copy refreshed only when
3933
+ // a previous write resolves, not "the one on disk". `writePrefs` merges
3934
+ // field by field and cannot merge two writes of one field: the patch here
3935
+ // is the WHOLE array, so a stale one wins. Pressing accept on a heard deck
3936
+ // at the moment an invite round fires this dropped the dialled address out
3937
+ // of `lan.manual`, which is the failure the comment above describes.
3938
+ _prefs = await updatePrefs(prev => {
3939
+ const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
3940
+ return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
3941
+ });
3942
+ // AND RECONCILE THE ENGINE'S DIAL LIST with what was just written. The
3943
+ // disk is now right, but `setPeers` replaces the list wholesale from
3944
+ // `_prefs` on every settings write — so a write that raced this one, and
3945
+ // whose `_prefs` predates it, would still take the address away.
3946
+ lanEngine.setPeers(_prefs.lan.manual);
3427
3947
  } catch { /* dialled this session; the next round re-adds it */ }
3428
3948
  },
3429
3949
  onIdentity: async secret => {
@@ -3434,7 +3954,12 @@ const lanEngine = createEngine({
3434
3954
  // one — so they stayed invisible to each other with a correct file on
3435
3955
  // disk. `apply` restarts only when the id it is holding differs from the
3436
3956
  // one it is given, so this settles after one pass rather than looping.
3437
- await applyLanPrefs();
3957
+ //
3958
+ // The key is handed over HERE rather than read back off `_prefs`, because
3959
+ // `applyLanPrefs` no longer round-trips the three fields the engine
3960
+ // authors — see what it does and does not pass. This is the one caller
3961
+ // that legitimately changes one of them, and it is holding the new value.
3962
+ await applyLanPrefs({ secret });
3438
3963
  } catch { /* the next start picks it up; a shared id is the cost until then */ }
3439
3964
  },
3440
3965
  onError: (what, err) => {
@@ -3445,27 +3970,81 @@ const lanEngine = createEngine({
3445
3970
  },
3446
3971
  });
3447
3972
 
3973
+ /**
3974
+ * What prefs is entitled to tell the LAN engine, and what it is not.
3975
+ *
3976
+ * THREE FIELDS ARE THE ENGINE'S OWN AND ARE LOADED ONCE. `trusted`, `secret`
3977
+ * and `port` are written BY the engine, through `onTrust`, `onIdentity` and
3978
+ * `onPort` — prefs is where they are kept between runs, not where they are
3979
+ * decided. Handing them back on every settings write round-trips the engine's
3980
+ * live state through a module-level copy of a file, and that copy is stale for
3981
+ * as long as one of those writes is queued.
3982
+ *
3983
+ * WHAT THAT COST, reproduced with two real engines and a real handshake: press
3984
+ * accept on deck B, and `lanEngine.accept` adds it to `cfg.trusted`
3985
+ * synchronously and queues `onTrust`. In the same second another tab flips
3986
+ * notifications; that handler's `writePrefs` is queued BEHIND `onTrust`'s, but
3987
+ * its `applyLanPrefs` runs on the `_prefs` it assigned — merged from a disk read
3988
+ * taken before `onTrust` wrote. `apply` does `cfg = { ...cfg, ...next }`, so the
3989
+ * whole array is replaced:
3990
+ *
3991
+ * B trusted after accept : old-deck-fp, 6ef-127-19e-79e
3992
+ * B trusted after a plain settings write: old-deck-fp
3993
+ *
3994
+ * `GET /api/lan` reads `lanEngine.status()`, so the panel then shows B unpaired
3995
+ * while prefs.json says paired, and B's calls are refused. The next `onTrust`
3996
+ * from any source writes `cfg.trusted` back to disk without B, making it
3997
+ * permanent.
3998
+ *
3999
+ * So `load` is true exactly once per boot, where reading them off the file IS
4000
+ * the right answer, and false thereafter. The one caller that legitimately
4001
+ * changes one of them afterwards passes it in directly — see `onIdentity`.
4002
+ *
4003
+ * Exported because it is the rule rather than the plumbing, and the plumbing
4004
+ * around it is a module-level engine no test can reach.
4005
+ */
4006
+ export function lanApplyFields(prefs, { load = false, env = process.env } = {}) {
4007
+ const lan = prefs?.lan ?? {};
4008
+ const page = {
4009
+ // The file's answer unless the machine said no: AGENTS_DECK_NO_LAN=1 is
4010
+ // how a launch script — or this repo's own test suite — keeps a deck off
4011
+ // the network now that the default is on.
4012
+ enabled: lanEnabled(prefs, env),
4013
+ name: lan.name || defaultName(),
4014
+ shared: Array.isArray(lan.shared) ? lan.shared : [],
4015
+ autoAsk: lan.autoAsk !== false,
4016
+ autoAccept: lan.autoAccept !== false,
4017
+ // Whether paired decks are told which shared account this one is on.
4018
+ shareActive: lan.shareActive !== false,
4019
+ // Names somebody here gave other decks. The engine only hands them to
4020
+ // the page, so a change never restarts anything.
4021
+ aliases: lan.aliases && typeof lan.aliases === "object" ? lan.aliases : {},
4022
+ };
4023
+ if (!load) return page;
4024
+ return {
4025
+ ...page,
4026
+ secret: lan.secret || "",
4027
+ trusted: Array.isArray(lan.trusted) ? lan.trusted : [],
4028
+ port: lan.port || 0,
4029
+ };
4030
+ }
4031
+
4032
+ /** Whether the engine has already been handed the three fields it authors.
4033
+ * Reset by `startServer`, because a boot is what reads them off the file. */
4034
+ let _lanLoaded = false;
4035
+
3448
4036
  /** Push whatever is in prefs at the engine. Called at boot and after every
3449
- * write, so there is one source of truth and it is the file. */
3450
- async function applyLanPrefs() {
4037
+ * write, so there is one source of truth and it is the file — for the fields
4038
+ * the file is the source of truth FOR. See `lanApplyFields`.
4039
+ *
4040
+ * `also` is for a caller holding a value the engine itself just produced and
4041
+ * that has to take effect now; nothing else may name one of the three. */
4042
+ async function applyLanPrefs(also = null) {
3451
4043
  const lan = _prefs?.lan ?? {};
4044
+ const load = !_lanLoaded;
4045
+ _lanLoaded = true;
3452
4046
  try {
3453
- await lanEngine.apply({
3454
- // The file's answer unless the machine said no: AGENTS_DECK_NO_LAN=1 is
3455
- // how a launch script — or this repo's own test suite — keeps a deck off
3456
- // the network now that the default is on.
3457
- enabled: lanEnabled(_prefs),
3458
- name: lan.name || defaultName(),
3459
- secret: lan.secret || "",
3460
- shared: Array.isArray(lan.shared) ? lan.shared : [],
3461
- trusted: Array.isArray(lan.trusted) ? lan.trusted : [],
3462
- port: lan.port || 0,
3463
- autoAsk: lan.autoAsk !== false,
3464
- autoAccept: lan.autoAccept !== false,
3465
- // Names somebody here gave other decks. The engine only hands them to
3466
- // the page, so a change never restarts anything.
3467
- aliases: lan.aliases && typeof lan.aliases === "object" ? lan.aliases : {},
3468
- });
4047
+ await lanEngine.apply({ ...lanApplyFields(_prefs, { load }), ...(also ?? {}) });
3469
4048
  // Wholesale, so removing an address in the panel really stops it being
3470
4049
  // dialled rather than only taking the row away.
3471
4050
  lanEngine.setPeers(lan.manual);
@@ -3544,7 +4123,7 @@ function handleLanStatus(req, res) {
3544
4123
  * than offering something its owner has forgotten they sent.
3545
4124
  */
3546
4125
  async function handleLanInvite(req, res) {
3547
- const raw = await readBody(req).catch(() => null);
4126
+ const raw = await readBody(req, res).catch(() => null);
3548
4127
  let body = null;
3549
4128
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3550
4129
  switch (body?.action) {
@@ -3570,7 +4149,7 @@ async function handleLanInvite(req, res) {
3570
4149
  }
3571
4150
 
3572
4151
  async function handleLanPeer(req, res) {
3573
- const raw = await readBody(req).catch(() => null);
4152
+ const raw = await readBody(req, res).catch(() => null);
3574
4153
  let body = null;
3575
4154
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
3576
4155
  const fp = body && typeof body.fp === "string" ? body.fp : null;
@@ -3586,11 +4165,16 @@ async function handleLanPeer(req, res) {
3586
4165
  // saved.
3587
4166
  if (added.dialled) {
3588
4167
  const entry = `${added.addr}:${added.port}`;
3589
- const manual = Array.isArray(_prefs?.lan?.manual) ? _prefs.lan.manual : [];
3590
- if (!manual.includes(entry)) {
3591
- try { _prefs = await writePrefs({ lan: { manual: [...manual, entry] } }); }
3592
- catch { /* it is dialled this session; the next accept re-adds it */ }
4168
+ // Inside the job, like `onDial` — the same whole-array patch computed
4169
+ // from the same stale copy, and the same address lost when two of them
4170
+ // land in one turn.
4171
+ try {
4172
+ _prefs = await updatePrefs(prev => {
4173
+ const manual = Array.isArray(prev?.lan?.manual) ? prev.lan.manual : [];
4174
+ return manual.includes(entry) ? null : { lan: { manual: [...manual, entry] } };
4175
+ });
3593
4176
  }
4177
+ catch { /* it is dialled this session; the next accept re-adds it */ }
3594
4178
  }
3595
4179
  return send(res, 200, { ok: true, added, ...lanEngine.status() });
3596
4180
  }
@@ -3607,13 +4191,19 @@ async function handleLanPeer(req, res) {
3607
4191
  // name of its own choosing. An empty name takes the alias away.
3608
4192
  //
3609
4193
  // The whole map is rebuilt from the one on disk rather than sent by the
3610
- // page, so two tabs renaming two decks cannot undo each other.
4194
+ // page, so two tabs renaming two decks cannot undo each other. It is
4195
+ // rebuilt inside the write's own job for that to be true: `_prefs` is a
4196
+ // module-level copy refreshed only when a previous write resolves, and the
4197
+ // patch is the whole map — so two renames in one turn both read before
4198
+ // either job ran, both answered 200, and the first name was never written.
3611
4199
  case "alias": {
3612
4200
  if (!isAliasKey(fp)) return send(res, 400, { ok: false, reason: "bad_request" });
3613
4201
  const name = cleanAlias(body.name);
3614
- const next = { ...(_prefs?.lan?.aliases ?? {}) };
3615
- if (name) next[fp] = name; else delete next[fp];
3616
- _prefs = await writePrefs({ lan: { aliases: next } });
4202
+ _prefs = await updatePrefs(prev => {
4203
+ const next = { ...(prev?.lan?.aliases ?? {}) };
4204
+ if (name) next[fp] = name; else delete next[fp];
4205
+ return { lan: { aliases: next } };
4206
+ });
3617
4207
  await lanEngine.apply({ aliases: _prefs.lan.aliases });
3618
4208
  return send(res, 200, { ok: true, ...lanEngine.status() });
3619
4209
  }
@@ -3630,7 +4220,7 @@ async function handleLanPeer(req, res) {
3630
4220
  * dialog. A deck that only calls in has no address here, and says so rather
3631
4221
  * than reporting a round that asked nobody. */
3632
4222
  async function handleLanSync(req, res) {
3633
- const raw = await readBody(req).catch(() => null);
4223
+ const raw = await readBody(req, res).catch(() => null);
3634
4224
  let body = null;
3635
4225
  try { body = JSON.parse(raw ?? ""); } catch { /* the whole-list press sends nothing to read */ }
3636
4226
  const fp = body && typeof body.fp === "string" ? body.fp : null;
@@ -3670,9 +4260,14 @@ function pushEvent(raw, source, opts = {}) {
3670
4260
  // Charged AFTER redactDeckToken, like everything else in this function: the
3671
4261
  // payload being measured is the one that will be stored.
3672
4262
  [CHARS]: ENVELOPE_CHARS + payloadChars(raw),
4263
+ // Decided once, here, beside the charge and for the same reason: eviction
4264
+ // must never have to look at a payload again to know what it is giving
4265
+ // back. See LAST_VALUE_WINS.
4266
+ [ENRICHMENT]: raw != null && typeof raw === "object" && LAST_VALUE_WINS.has(raw.hook_event_name),
3673
4267
  };
3674
4268
  events.push(evt);
3675
4269
  bufferedChars += evt[CHARS];
4270
+ if (!evt[ENRICHMENT]) bufferedHookEvents++;
3676
4271
 
3677
4272
  // Evict oldest-first until BOTH bounds hold — the count that has always been
3678
4273
  // here, and the byte budget #625 added. See MAX_BUFFER_CHARS for the numbers.
@@ -3702,16 +4297,33 @@ function pushEvent(raw, source, opts = {}) {
3702
4297
  // The difference worth knowing is that the head can now move in jumps rather
3703
4298
  // than one entry at a time; resumeSse's per-pass snapshot is what makes that
3704
4299
  // safe for a replay already in flight.
4300
+ //
4301
+ // THE COUNT BOUND IS HOOK EVENTS, NOT ENTRIES (#1032). It was entries, and
4302
+ // the deck pushes about 1.6 derived events per hook event — so the window
4303
+ // this ring IS shrank with the session count, to 9.5 measured seconds at 200
4304
+ // sessions. Counting only what a user actually did restores a 2,000-event
4305
+ // window at any number of sessions, and costs the array room for the
4306
+ // enrichment interleaved among them, which MAX_RING_ENTRIES and
4307
+ // MAX_BUFFER_CHARS both still bound.
4308
+ //
4309
+ // Enrichment falls off the head exactly as it always did — no entry is
4310
+ // skipped, held back or reordered, and the ring stays contiguous in seq.
4311
+ // What changed is only which entries the budget is spent on.
3705
4312
  let drop = 0;
3706
4313
  let freed = 0;
4314
+ let freedHookEvents = 0;
3707
4315
  while (drop < events.length - 1
3708
- && (events.length - drop > MAX_BUFFER || bufferedChars - freed > MAX_BUFFER_CHARS)) {
4316
+ && (bufferedHookEvents - freedHookEvents > MAX_BUFFER
4317
+ || events.length - drop > MAX_RING_ENTRIES
4318
+ || bufferedChars - freed > MAX_BUFFER_CHARS)) {
3709
4319
  freed += events[drop][CHARS];
4320
+ if (!events[drop][ENRICHMENT]) freedHookEvents++;
3710
4321
  drop++;
3711
4322
  }
3712
4323
  if (drop > 0) {
3713
4324
  events.splice(0, drop);
3714
4325
  bufferedChars -= freed;
4326
+ bufferedHookEvents -= freedHookEvents;
3715
4327
  }
3716
4328
 
3717
4329
  // Does this event reach the log at all? Not on a replay (it came from
@@ -3807,9 +4419,12 @@ function pushEvent(raw, source, opts = {}) {
3807
4419
  // does: the string being written is the one serialization of the event the
3808
4420
  // SSE frame also used, and the token was taken out of the payload before
3809
4421
  // either existed.
3810
- appendLogLine(persistPath, json + "\n");
3811
- // Cheap throttled check (every 30s) — only rotates if file > 50MB.
3812
- maybeRotatePersistFile();
4422
+ const line = json + "\n";
4423
+ appendLogLine(persistPath, line);
4424
+ // Throttled check — every 30s, or every fifth of the threshold written,
4425
+ // whichever comes first. The byte arm is what keeps the 50 MB cap from
4426
+ // being advisory at any real ingest rate; see maybeRotatePersistFile.
4427
+ maybeRotatePersistFile(Buffer.byteLength(line));
3813
4428
  }
3814
4429
 
3815
4430
  // Note the session so the caches the scanners below fill can expire by
@@ -3891,10 +4506,12 @@ function pushEvent(raw, source, opts = {}) {
3891
4506
  *
3892
4507
  * TWO CASES DECIDED EXPLICITLY:
3893
4508
  *
3894
- * * `__clear` — the control marker `/api/clear` writes after truncating, with
3895
- * `cwd: ""`. It is not a session event; it is the instruction that makes the
3896
- * reducer forget everything before it. Dropping it on a scoped deck would
3897
- * replay the state a user had explicitly cleared, so it is always admitted.
4509
+ * * `__clear` — the control marker `/api/clear` pushes, with `cwd: ""`. It is
4510
+ * not a session event; it is the instruction that makes the reducer forget
4511
+ * everything before it. It has not been written to the log since #698, but
4512
+ * logs the decks before that wrote still carry it, and dropping it on a
4513
+ * scoped deck would replay the state a user had explicitly cleared, so it
4514
+ * is always admitted.
3898
4515
  * * a payload with no cwd and no session the map has seen — refused on a
3899
4516
  * scoped deck, which is exactly what `capturesSession` decides live for a
3900
4517
  * session that never said where it runs.
@@ -3907,8 +4524,38 @@ function pushEvent(raw, source, opts = {}) {
3907
4524
  * log order — it remembers, so the order matters and it is not reusable across
3908
4525
  * two replays.
3909
4526
  */
3910
- export function replayScope(workspace, platform = process.platform) {
3911
- if (!workspace || typeof workspace !== "string") {
4527
+ export function replayScope(workspace, platform = process.platform, providers = null) {
4528
+ // WHICH CLIS THIS DECK IS WATCHING, and the half of `--no-codex` that did not
4529
+ // exist (#1004). Live capture is gated on the provider — `if (codex)
4530
+ // startCodexWatcher(...)`, and the Claude hook is only installed when
4531
+ // `_providers.claude` — and the boot replay was not, so a deck started with
4532
+ // `--no-codex` printed
4533
+ //
4534
+ // Codex sessions skipped — no ~/.codex/, or --no-codex
4535
+ //
4536
+ // and then filled its canvas with Codex sessions out of the machine-wide
4537
+ // events.jsonl, frozen mid-turn, which no live path could ever close because
4538
+ // the watcher that would have is the one that was skipped. Measured on a log
4539
+ // of two Claude lines and two Codex lines replayed with `codex: false`:
4540
+ //
4541
+ // in the ring: [ 'claude:claude-1:SessionStart', 'claude:claude-1:PreToolUse',
4542
+ // 'codex:codex-1:SessionStart', 'codex:codex-1:PreToolUse' ]
4543
+ //
4544
+ // The mirror case is worse: a `--no-claude` deck replays Claude sessions while
4545
+ // `providers.claude` is false, so App.tsx hides the accounts panel and the
4546
+ // sound controls — sessions on screen the deck has deliberately taken the
4547
+ // controls for away.
4548
+ //
4549
+ // It costs one comparison per line. `provider: "codex"` is stamped on every
4550
+ // payload the Codex watcher emits, its cwd-less enrichment included, and the
4551
+ // Claude side is the complement — which is what types.ts already says
4552
+ // `provider` means ("defaults to claude for replay events written before
4553
+ // multi-provider support"). So this needs no per-session memory of its own and
4554
+ // leaves `orderDependent` alone.
4555
+ const claudeOn = providers?.claude !== false;
4556
+ const codexOn = providers?.codex !== false;
4557
+ const scoped = !!workspace && typeof workspace === "string";
4558
+ if (!scoped && claudeOn && codexOn) {
3912
4559
  const all = () => true;
3913
4560
  // Every caller gets the same answer for the same payload, forever. That is
3914
4561
  // what lets replayLog read the log from its end — see `orderDependent` on
@@ -3932,12 +4579,17 @@ export function replayScope(workspace, platform = process.platform) {
3932
4579
  * whenever it is set. Making a scoped replay cheap needs an index of where a
3933
4580
  * workspace's lines are, which is a different change from this one.
3934
4581
  */
3935
- admits.orderDependent = true;
4582
+ admits.orderDependent = scoped;
3936
4583
  return admits;
3937
4584
 
3938
4585
  function admits(payload) {
3939
4586
  if (!payload || typeof payload !== "object") return false;
3940
4587
  if (payload.hook_event_name === "__clear") return true;
4588
+ // The provider gate, ahead of the workspace one and exempting `__clear` for
4589
+ // the same reason it does: the marker carries no provider, and a deck that
4590
+ // dropped it would replay a canvas the user had explicitly cleared.
4591
+ if (!(payload.provider === "codex" ? codexOn : claudeOn)) return false;
4592
+ if (!scoped) return true;
3941
4593
  const sid = typeof payload.session_id === "string" ? payload.session_id : null;
3942
4594
  const cwd = typeof payload.cwd === "string" && payload.cwd !== "" ? payload.cwd : null;
3943
4595
  if (cwd) {
@@ -3950,6 +4602,32 @@ export function replayScope(workspace, platform = process.platform) {
3950
4602
  };
3951
4603
  }
3952
4604
 
4605
+ /**
4606
+ * Can this log fill the ring on its own?
4607
+ *
4608
+ * Read from the END and bounded by the ring, so the answer costs at most one
4609
+ * ring's worth of parsing however large the file is — and on a full log it
4610
+ * stops within the first few hundred lines. Deliberately UNSCOPED: the number
4611
+ * it returns is an upper bound on what any workspace predicate will admit, so
4612
+ * `false` is a certainty that the archive is needed while `true` is only the
4613
+ * absence of evidence that it is. See the order-dependent branch of replayLog
4614
+ * for why that asymmetry is the right way round.
4615
+ */
4616
+ async function fillsRing(filePath, maxEvents, maxChars) {
4617
+ let n = 0;
4618
+ let chars = 0;
4619
+ for await (const line of linesFromEnd(filePath)) {
4620
+ if (!line) continue;
4621
+ let evt;
4622
+ try { evt = JSON.parse(line); } catch { continue; }
4623
+ if (!evt || typeof evt !== "object" || !evt.payload) continue;
4624
+ n++;
4625
+ chars += ENVELOPE_CHARS + payloadChars(evt.payload);
4626
+ if (n >= maxEvents || chars >= maxChars) return true;
4627
+ }
4628
+ return false;
4629
+ }
4630
+
3953
4631
  /**
3954
4632
  * Read the log back into the ring buffer at boot.
3955
4633
  *
@@ -4023,12 +4701,37 @@ export function replayScope(workspace, platform = process.platform) {
4023
4701
  * MiB — which is why the count bound was the only one anything pinned, and why
4024
4702
  * the byte bound was the one that broke. Production passes neither argument. */
4025
4703
  export async function replayLog(filePath, workspace = "", {
4026
- maxEvents = MAX_BUFFER, maxChars = MAX_BUFFER_CHARS,
4704
+ maxEvents = MAX_BUFFER, maxChars = MAX_BUFFER_CHARS, providers = null,
4027
4705
  } = {}) {
4028
- if (!existsSync(filePath)) return 0;
4706
+ // THE ARCHIVE IS PART OF THE HISTORY, and for two years nothing read it.
4707
+ // `maybeRotatePersistFile` renames events.jsonl to events.jsonl.1 at 50 MB;
4708
+ // a grep for `.1` across src/ and bin/ finds the write, the copy deck-home's
4709
+ // migration makes, and two comments. replayLog is called once, with
4710
+ // `persistPath` alone. So the boot immediately after a rotation replays a
4711
+ // file holding a handful of lines while 50 MB of history sits beside it
4712
+ // unread — and the next rotation's `unlink` deletes it outright. Measured:
4713
+ // one rotation, then `replayLog(events.jsonl)` returning 1.
4714
+ //
4715
+ // The reader below already stops the moment EITHER of the ring's bounds is
4716
+ // reached, so on a log that can fill the ring on its own this costs nothing
4717
+ // at all: the archive is never opened. It is read only when the live log
4718
+ // cannot fill the ring, which is exactly the window a rotation opens.
4719
+ //
4720
+ // A Clear opens the same window and means the opposite by it. It empties the
4721
+ // live log, which can then fill nothing, so reading on into the archive put
4722
+ // back precisely what the user had cleared (#1130) — which is why a Clear
4723
+ // removes the archive as well as emptying the live log, rather than this
4724
+ // learning to tell the two kinds of empty apart. See handleClear.
4725
+ const archivePath = filePath + ".1";
4726
+ const liveThere = existsSync(filePath);
4727
+ const archiveThere = existsSync(archivePath);
4728
+ if (!liveThere && !archiveThere) return 0;
4029
4729
  let skipped = 0;
4030
4730
  let skippedBytes = 0;
4031
- const admits = replayScope(workspace);
4731
+ // `providers` is the second scope this deck has, and it goes the same way the
4732
+ // first one does — into the predicate, so the replay rule stays pinned equal
4733
+ // to the live rule rather than being a second notion of it. See replayScope.
4734
+ const admits = replayScope(workspace, process.platform, providers);
4032
4735
  const replay = (evt) =>
4033
4736
  pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
4034
4737
  const parse = (line) => {
@@ -4044,12 +4747,31 @@ export async function replayLog(filePath, workspace = "", {
4044
4747
 
4045
4748
  let count = 0;
4046
4749
  if (admits.orderDependent) {
4047
- for await (const line of linesFromStart(filePath)) {
4048
- if (!line) continue;
4049
- const evt = parse(line);
4050
- if (!usable(evt) || !admits(evt.payload)) continue;
4051
- replay(evt);
4052
- count++;
4750
+ // Oldest first, so the archive comes BEFORE the live log — `admits` is
4751
+ // stateful and answers from what it has already seen, which is the whole
4752
+ // reason this branch reads forwards at all.
4753
+ //
4754
+ // Whether the archive is worth reading is decided by a probe rather than by
4755
+ // this branch's own reader, because this branch has no stopping rule: it
4756
+ // pushes everything and lets the ring evict. The probe reads the LIVE log
4757
+ // backwards, unscoped, and is bounded by the ring, so it costs at most one
4758
+ // ring's worth of parsing from the end of the file and nothing more. An
4759
+ // unscoped count is an upper bound on what scoping will admit, so "this
4760
+ // cannot fill the ring" is certain when the probe says so — and when it
4761
+ // says the opposite a scoped deck may still under-fill, which is precisely
4762
+ // what it does today. Nothing regresses; the case a rotation creates, where
4763
+ // the live log holds a handful of lines, is the one that is fixed.
4764
+ const files = [];
4765
+ if (archiveThere && !(liveThere && await fillsRing(filePath, maxEvents, maxChars))) files.push(archivePath);
4766
+ if (liveThere) files.push(filePath);
4767
+ for (const file of files) {
4768
+ for await (const line of linesFromStart(file)) {
4769
+ if (!line) continue;
4770
+ const evt = parse(line);
4771
+ if (!usable(evt) || !admits(evt.payload)) continue;
4772
+ replay(evt);
4773
+ count++;
4774
+ }
4053
4775
  }
4054
4776
  } else {
4055
4777
  // Newest first, so this is filled back to front and then walked in reverse
@@ -4069,16 +4791,24 @@ export async function replayLog(filePath, workspace = "", {
4069
4791
  // records logs reaching gigabytes.
4070
4792
  const newestFirst = [];
4071
4793
  let stagedChars = 0;
4072
- for await (const line of linesFromEnd(filePath)) {
4073
- if (!line) continue;
4074
- const evt = parse(line);
4075
- if (!usable(evt) || !admits(evt.payload)) continue;
4076
- newestFirst.push(evt);
4077
- stagedChars += ENVELOPE_CHARS + payloadChars(evt.payload);
4078
- // Everything older than this would be evicted by the events already held,
4079
- // so reading further is work whose only result is throwing it away.
4080
- // Either limit reaching its ceiling means exactly that.
4794
+ // Newest generation first. The second pass runs only if the first stopped
4795
+ // because it ran out of FILE rather than because it reached a bound, which
4796
+ // is the "cannot fill the ring" test stated exactly and for free — and it
4797
+ // is also why a full live log never opens the archive at all.
4798
+ for (const file of liveThere ? [filePath, archivePath] : [archivePath]) {
4799
+ if (!existsSync(file)) continue;
4081
4800
  if (newestFirst.length >= maxEvents || stagedChars >= maxChars) break;
4801
+ for await (const line of linesFromEnd(file)) {
4802
+ if (!line) continue;
4803
+ const evt = parse(line);
4804
+ if (!usable(evt) || !admits(evt.payload)) continue;
4805
+ newestFirst.push(evt);
4806
+ stagedChars += ENVELOPE_CHARS + payloadChars(evt.payload);
4807
+ // Everything older than this would be evicted by the events already held,
4808
+ // so reading further is work whose only result is throwing it away.
4809
+ // Either limit reaching its ceiling means exactly that.
4810
+ if (newestFirst.length >= maxEvents || stagedChars >= maxChars) break;
4811
+ }
4082
4812
  }
4083
4813
  for (let i = newestFirst.length - 1; i >= 0; i--) replay(newestFirst[i]);
4084
4814
  count = newestFirst.length;
@@ -4095,6 +4825,13 @@ export async function replayLog(filePath, workspace = "", {
4095
4825
  }
4096
4826
 
4097
4827
  function send(res, status, body, headers = {}) {
4828
+ // ALREADY ANSWERED. A handler that replies after something upstream has
4829
+ // already written a status would otherwise throw ERR_HTTP_HEADERS_SENT out
4830
+ // of the route and become a 500 — or, on a streamed answer, corrupt it. The
4831
+ // oversize path below relies on this: readBody answers 413 itself and then
4832
+ // rejects into a caller whose own `send(res, 400, ...)` must be a no-op
4833
+ // rather than a second reply.
4834
+ if (res.headersSent) return;
4098
4835
  res.writeHead(status, {
4099
4836
  "Content-Type": "application/json; charset=utf-8",
4100
4837
  "Cache-Control": "no-store",
@@ -4206,6 +4943,8 @@ export async function writeJsonArray(res, items) {
4206
4943
  res.end(frame === "[" ? "[]" : "]");
4207
4944
  }
4208
4945
 
4946
+ import { cacheControlFor, encodedBody, pickEncoding } from "./static-cache.mjs";
4947
+
4209
4948
  async function serveStatic(req, res, url) {
4210
4949
  // Strip leading slash, default to index.html
4211
4950
  let rel = url.pathname.replace(/^\/+/, "");
@@ -4217,11 +4956,21 @@ async function serveStatic(req, res, url) {
4217
4956
  const s = await stat(filePath);
4218
4957
  if (s.isDirectory()) return send(res, 404, { error: "not found" });
4219
4958
  const buf = await readFile(filePath);
4959
+ const ext = extname(filePath).toLowerCase();
4960
+ // Compressed when the browser takes it, and cached for good when the name
4961
+ // is a content hash (#883) — see static-cache.mjs. `rel` is the URL's own
4962
+ // spelling, forward slashes on every platform, which is what the hash
4963
+ // pattern reads; `filePath` is only the cache key.
4964
+ const encoding = pickEncoding(req.headers["accept-encoding"], ext);
4965
+ const body = encoding ? encodedBody(filePath, s.mtimeMs, buf, encoding) : buf;
4220
4966
  res.writeHead(200, {
4221
- "Content-Type": MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream",
4222
- "Cache-Control": "no-cache",
4967
+ "Content-Type": MIME[ext] ?? "application/octet-stream",
4968
+ "Cache-Control": cacheControlFor(rel),
4969
+ "Vary": "Accept-Encoding",
4970
+ "Content-Length": body.length,
4971
+ ...(encoding ? { "Content-Encoding": encoding } : {}),
4223
4972
  });
4224
- res.end(buf);
4973
+ res.end(body);
4225
4974
  } catch {
4226
4975
  // SPA fallback to index.html for client-side routes
4227
4976
  try {
@@ -4238,25 +4987,61 @@ async function serveStatic(req, res, url) {
4238
4987
  }
4239
4988
  }
4240
4989
 
4241
- /** Collect a request body as a string, capped so a bad client can't fill memory. */
4242
- function readBody(req, limit = 64_000) {
4990
+ // How long an oversized POST is drained after it has been refused, so the 413
4991
+ // reaches a poster that is still uploading. Generous next to hook.js's own
4992
+ // one-second budget, and finite so nothing can sit on the socket indefinitely.
4993
+ const OVERSIZE_DRAIN_MS = 10_000;
4994
+
4995
+ /**
4996
+ * Collect a request body as a string, capped so a bad client can't fill memory.
4997
+ *
4998
+ * IT ANSWERS BEFORE IT HANGS UP, which it did not. `req.destroy()` ran ahead of
4999
+ * the reject, so the caller's own `send(res, 400, ...)` had no socket left to
5000
+ * write to: every one of the eleven routes through here replied to an oversized
5001
+ * body with a bare connection reset. Measured with a 70 KB body — `curl` exit
5002
+ * 56, "failure receiving network data", no status line, and nothing in the log.
5003
+ * `POST /api/lan/sync` is the one that matters, because its caller is another
5004
+ * deck rather than a person: it saw a reset it could not tell from a deck that
5005
+ * had died, and reported a network error rather than "too big".
5006
+ *
5007
+ * handleEventIngest was fixed for exactly this and its comment asserts this
5008
+ * function already got it right. It did not; this is that fix, in the shape
5009
+ * that one worked out and documents at length:
5010
+ *
5011
+ * • answer 413 here, so there is a status on the wire;
5012
+ * • keep reading and throw it away, because the poster is still mid-upload
5013
+ * and hanging up now lands its next write on a dead socket — it aborts with
5014
+ * EPIPE and discards the answer already sitting in its receive buffer;
5015
+ * • bound the drain, because draining forever is its own denial of service.
5016
+ *
5017
+ * The reject still fires, so the callers' `.catch(() => null)` and their
5018
+ * `send(res, 400, ...)` are unchanged — `send` is a no-op once the headers have
5019
+ * gone out.
5020
+ */
5021
+ function readBody(req, res = null, limit = 64_000) {
4243
5022
  return new Promise((resolve, reject) => {
4244
5023
  let body = "";
5024
+ let refused = false;
4245
5025
  req.setEncoding("utf8");
4246
5026
  req.on("data", c => {
5027
+ if (refused) return;
4247
5028
  body += c;
4248
- if (body.length > limit) { req.destroy(); reject(new Error("body too large")); }
5029
+ if (body.length > limit) {
5030
+ refused = true;
5031
+ body = "";
5032
+ if (res) send(res, 413, { error: "body too large" });
5033
+ req.resume();
5034
+ const grace = setTimeout(() => req.destroy(), OVERSIZE_DRAIN_MS);
5035
+ grace.unref?.();
5036
+ req.on("close", () => clearTimeout(grace));
5037
+ reject(new Error("body too large"));
5038
+ }
4249
5039
  });
4250
- req.on("end", () => resolve(body));
5040
+ req.on("end", () => { if (!refused) resolve(body); });
4251
5041
  req.on("error", reject);
4252
5042
  });
4253
5043
  }
4254
5044
 
4255
- // How long an oversized POST is drained after it has been refused, so the 413
4256
- // reaches a poster that is still uploading. Generous next to hook.js's own
4257
- // one-second budget, and finite so nothing can sit on the socket indefinitely.
4258
- const OVERSIZE_DRAIN_MS = 10_000;
4259
-
4260
5045
  // `persist` is false when the hook posted this event to another deck as well
4261
5046
  // and elected that one to write it to the log they share. The event is still
4262
5047
  // buffered and broadcast here — every matching deck draws it — it is only the
@@ -4279,8 +5064,10 @@ function handleEventIngest(req, res, persist = true) {
4279
5064
  // the connection had gone — indistinguishable from a deck that died or
4280
5065
  // was never there, and nothing in the exchange to tell those apart. `end`
4281
5066
  // never fires on a destroyed request either, so the handler below got no
4282
- // second chance to speak. readBody above gets this right already, by
4283
- // rejecting into a caller that replies.
5067
+ // second chance to speak. readBody above was believed to get this right
5068
+ // by rejecting into a caller that replies; it destroyed the socket first,
5069
+ // so all eleven of its routes answered an oversized body with the same
5070
+ // bare reset. It answers and drains now, in this shape.
4284
5071
  send(res, 413, { error: "event too large" });
4285
5072
  // Then keep reading, and throw it away. Answering is not enough on its
4286
5073
  // own, because the poster is still mid-upload when the answer goes out:
@@ -4412,13 +5199,15 @@ async function resumeSse(req, res, lastId) {
4412
5199
  for (const e of batch) {
4413
5200
  if (e.seq <= sentThrough) continue;
4414
5201
  if (closed || res.destroyed) return;
4415
- // Marked with `replay:true` on the envelope so the client can suppress
4416
- // turn-cleanup side effects (exitAt stamping, autofit churn) until the
4417
- // live stream takes over. Without this the reducer's UserPromptSubmit
4418
- // handler treats replayed events as a real new turn — hiding prior-turn
4419
- // subagents using the event's stale receivedAt, which collides with
4420
- // wall-clock visibility gates and yields the "nodes appear then vanish"
4421
- // symptom on refresh.
5202
+ // Marked with `replay:true` on the envelope for the page, which reads it in
5203
+ // two places: the SSE handler coalesces renders while it is set and draws
5204
+ // once at `replay-end`, and `chimeFor` stays quiet for it, so a reconnect
5205
+ // does not play every Stop in the ring. The reducer never reads it — its
5206
+ // turn cleanup keys on each event's own time, which comes out right for
5207
+ // replayed and live events alike (HookEnvelope.replay in types.ts, and
5208
+ // dead-surface-993 pins it). This used to say the reducer's
5209
+ // UserPromptSubmit handler depended on the tag to tell a replayed prompt
5210
+ // from a new turn; nothing there reads it.
4422
5211
  const tagged = { ...e, replay: true };
4423
5212
  // Through envelopeJson for the reason writeJsonArray is: the ring may be
4424
5213
  // holding an envelope `JSON.stringify` cannot walk. pushEvent takes the
@@ -4471,6 +5260,82 @@ async function resumeSse(req, res, lastId) {
4471
5260
  // Without persistence a restart wipes the canvas irrecoverably — replayLog has
4472
5261
  // no file to read — so the deck must not offer to do it.
4473
5262
  let _canRestart = false;
5263
+ // And a log that cannot be written is the same thing wearing a different hat.
5264
+ // This was a test of CONFIGURATION — `_onRestart != null && persist != null` —
5265
+ // so a deck whose `--history` named a read-only volume, a full one, or a
5266
+ // removable drive that was later unmounted answered `canRestart: true` while
5267
+ // not one event reached disk. Measured: 10 of 10 posts acknowledged, no file on
5268
+ // disk, `canRestart: true`. The press then lands on replayLog's
5269
+ // `if (!existsSync(filePath)) return 0` and takes the whole session history
5270
+ // with it — which is precisely the loss the flag exists to prevent.
5271
+ let _persistWritable = false;
5272
+ // How many lines had landed on `persistPath` when the probe answered. Read by
5273
+ // logWritableNow and nothing else; see appendsLanded.
5274
+ let _landedAtProbe = 0;
5275
+
5276
+ /**
5277
+ * Is the log this deck was told to keep being written, on the newest evidence?
5278
+ *
5279
+ * `_persistWritable` answered this alone, and it is the boot probe's answer:
5280
+ * nothing after the boot could change it. The gate it fed had a closing edge —
5281
+ * a failing append opens an episode, and an episode shut the gate — and no
5282
+ * opening one. MEASURED on a sandboxed deck (#1130), a log whose parent was a
5283
+ * file at boot and a directory a moment later, which is what a mount that
5284
+ * comes up late or a permission that is fixed leaves behind: a line on disk,
5285
+ * the failure count at 0, and `canRestart` false for the life of the process.
5286
+ *
5287
+ * The appender is the evidence, and it already produces it: every line either
5288
+ * lands or fails, and log-writer counts both. So the answer is whichever of the
5289
+ * two sources spoke LAST. An open failure episode means the newest append
5290
+ * failed, whatever came before it. Otherwise a line landed since the probe
5291
+ * means the newest evidence is a write that worked, whatever the probe said;
5292
+ * and with neither, the probe is still the newest thing anyone knows.
5293
+ *
5294
+ * No syscall is added anywhere for it. The probe stays the one at boot, the
5295
+ * episode is the one #1062 keeps, and what an event pays is one counter
5296
+ * increment in log-writer's landing handler, beside the episode lookup that
5297
+ * handler already makes.
5298
+ */
5299
+ function logWritableNow() {
5300
+ if (!persistPath) return false;
5301
+ if (appendFailureStats(persistPath).failing > 0) return false;
5302
+ return _persistWritable || appendsLanded(persistPath) > _landedAtProbe;
5303
+ }
5304
+
5305
+ /**
5306
+ * Can this deck be restarted without losing the canvas?
5307
+ *
5308
+ * Asked at the moment of the press rather than read off a boot-time flag,
5309
+ * because the writable half of it changes while the deck runs, and in both
5310
+ * directions: a drive unmounted or a volume filled mid-session is discovered by
5311
+ * the appender, not by the probe, and so is one that comes back. See
5312
+ * logWritableNow.
5313
+ */
5314
+ function canRestartNow() {
5315
+ return _canRestart && logWritableNow();
5316
+ }
5317
+
5318
+ /**
5319
+ * Is the log this deck was told to keep actually writable?
5320
+ *
5321
+ * One `open(path, "a")` and one close — the same pair every append pays, asked
5322
+ * once at boot so the answer is known before the first event rather than after
5323
+ * the first silent loss. It CREATES the file, which is what an append would do
5324
+ * anyway and what makes the probe honest about the directory as well as the
5325
+ * file: EROFS, EACCES and ENOENT all surface here.
5326
+ */
5327
+ async function probeLogWritable(path) {
5328
+ let handle = null;
5329
+ try {
5330
+ handle = await open(path, "a");
5331
+ return true;
5332
+ } catch (err) {
5333
+ console.error(`${PRODUCT}: the event log ${path} is not writable (${err && err.message ? err.message : err}) — Restart is disabled until an event reaches it, because it would replay a log this deck is not filling`);
5334
+ return false;
5335
+ } finally {
5336
+ await handle?.close().catch(() => {});
5337
+ }
5338
+ }
4474
5339
 
4475
5340
  async function handleVersion(req, res) {
4476
5341
  const { versionReport } = await import(
@@ -4513,7 +5378,7 @@ async function handleVersion(req, res) {
4513
5378
  // default em dash is what a browser should get.
4514
5379
  const rename = renameNotice({ invoked, pkgRoot: PKG_ROOT });
4515
5380
  send(res, 200, {
4516
- ...report, canRestart: _canRestart, invokedAs: invoked, renameFix: rename?.fix ?? null,
5381
+ ...report, canRestart: canRestartNow(), invokedAs: invoked, renameFix: rename?.fix ?? null,
4517
5382
  });
4518
5383
  }
4519
5384
 
@@ -4525,6 +5390,11 @@ async function handleUpgrade(_req, res) {
4525
5390
  const { startUpgrade } = await import(
4526
5391
  pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
4527
5392
  );
5393
+ // Every module this process will ever load, loaded BEFORE npm starts
5394
+ // rewriting the tree they come from — see PINNED_MODULES. On a deck that has
5395
+ // finished booting this settled long ago; it only waits for a press that
5396
+ // lands in the first moments of a boot, or on a server nothing marked ready.
5397
+ await pinRunningBuild();
4528
5398
  const out = startUpgrade({ pkgRoot: PKG_ROOT });
4529
5399
  send(res, out.ok ? 200 : 409, out);
4530
5400
  }
@@ -4542,10 +5412,15 @@ async function handleRestart(req, res) {
4542
5412
  // the whole canvas — replayLog has no file to read — and a destructive act
4543
5413
  // must not be prevented by a hidden button alone.
4544
5414
  if (!_canRestart) return send(res, 409, { ok: false, reason: "no_persist" });
5415
+ // A configured log that cannot be written is the same destruction by another
5416
+ // route, and it deserves its own word: "no_persist" would tell a user who
5417
+ // passed `--history` that they had not, which is the sort of answer that
5418
+ // sends someone looking in the wrong place. See canRestartNow.
5419
+ if (!canRestartNow()) return send(res, 409, { ok: false, reason: "log_unwritable" });
4545
5420
 
4546
5421
  let wantUpgrade = false;
4547
5422
  if (req.method === "POST") {
4548
- const body = await readBody(req).catch(() => null);
5423
+ const body = await readBody(req, res).catch(() => null);
4549
5424
  try { wantUpgrade = JSON.parse(body ?? "")?.upgrade === true; } catch { /* a plain restart */ }
4550
5425
  }
4551
5426
  let mode = null;
@@ -4620,9 +5495,18 @@ export function releaseRestart() {
4620
5495
  *
4621
5496
  * Only /api/restart reads it, and only to answer honestly. Nothing is refused
4622
5497
  * on the strength of it: the restart is still handed to the launcher, which
4623
- * holds it until it can run it (#448). */
5498
+ * holds it until it can run it (#448).
5499
+ *
5500
+ * IT IS ALSO WHERE THE BUILD IS PINNED (#1042) — see PINNED_MODULES. After the
5501
+ * boot, so the laziness still keeps that work off the path to the socket; and
5502
+ * before anything can replace the tree, because `npm i -g` typed in a terminal
5503
+ * rewrites it exactly as startUpgrade's does, and the drift path then leaves
5504
+ * this process serving until an idle moment just the same. The promise is
5505
+ * handed back for the test that has to know when the pin has landed;
5506
+ * bin/deck.js has no reason to wait for it, and it never rejects. */
4624
5507
  export function markDeckReady() {
4625
5508
  _deckReady = true;
5509
+ return pinRunningBuild();
4626
5510
  }
4627
5511
 
4628
5512
  /**
@@ -4669,13 +5553,21 @@ async function handleStop(req, res) {
4669
5553
  send(res, 200, { ok: true, pid: process.pid });
4670
5554
  }
4671
5555
 
5556
+ /**
5557
+ * WITH A DEADLINE OF ITS OWN (#1011). Source 3 is `claude --print /usage`, and
5558
+ * its cost is the sum of three spawns under a 15-second timeout with two
5559
+ * 1.2-second sleeps between them — 47.4 seconds, measured — which nothing here
5560
+ * had ever added up. The budget is quota.mjs's, because the "not yet" answer it
5561
+ * expires into is made of that module's own last-known-good reading; the read
5562
+ * carries on behind it and publishes for the next poll.
5563
+ */
4672
5564
  async function handleQuota(req, res) {
4673
- const { fetchClaudeQuota } = await import(
5565
+ const { fetchClaudeQuota, QUOTA_DEADLINE_MS } = await import(
4674
5566
  pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
4675
5567
  );
4676
5568
  const url = new URL(req.url, "http://localhost");
4677
5569
  const force = url.searchParams.get("refresh") === "1";
4678
- const quota = await fetchClaudeQuota({ force });
5570
+ const quota = await fetchClaudeQuota({ force, deadlineMs: QUOTA_DEADLINE_MS });
4679
5571
  send(res, 200, quota);
4680
5572
  }
4681
5573
 
@@ -4734,7 +5626,7 @@ export const isCliDate = (v) => v === undefined || /^\d{8}$/.test(v);
4734
5626
  * reaches a handler, which is exactly the pair a GET deliberately skips.
4735
5627
  */
4736
5628
  async function handleBrowserWatchSettings(req, res) {
4737
- const raw = await readBody(req).catch(() => null);
5629
+ const raw = await readBody(req, res).catch(() => null);
4738
5630
  let body = null;
4739
5631
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
4740
5632
  if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
@@ -4788,7 +5680,7 @@ async function handleBrowserWatchDismiss(req, res) {
4788
5680
  // isAuthorizedMutation on every non-GET before a handler sees it, which is
4789
5681
  // the pair its GET twin deliberately skips. A second check here would be a
4790
5682
  // second thing to keep correct.
4791
- const raw = await readBody(req).catch(() => null);
5683
+ const raw = await readBody(req, res).catch(() => null);
4792
5684
  let body = null;
4793
5685
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
4794
5686
  const host = typeof body?.host === "string" ? body.host : null;
@@ -4801,10 +5693,18 @@ async function handleBrowserWatchDismiss(req, res) {
4801
5693
  const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
4802
5694
  pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
4803
5695
  );
4804
- const store = await readStore();
5696
+ // COMPUTED INSIDE THE JOB, like the settings route four lines up, whose
5697
+ // comment says why: "updateStore re-reads inside the write queue, so a poll
5698
+ // that landed between the read above and this line cannot have its archive
5699
+ // thrown away." This read the whole `dismissed` array before the queue, so
5700
+ // two Dismiss presses in one turn both read before either job ran and the
5701
+ // second wrote an array without the first key — both rows left the list,
5702
+ // both answered 200, and the first came back on the next ten-second poll.
4805
5703
  const key = episodeKey(host, startMs);
4806
- const dismissed = [...new Set([...(store.dismissed ?? []), key])];
4807
- await updateStore(cur => ({ ...cur, dismissed }));
5704
+ await updateStore(cur => ({
5705
+ ...cur,
5706
+ dismissed: [...new Set([...(cur.dismissed ?? []), key])],
5707
+ }));
4808
5708
  // The reader acting on their own list, which is exactly the kind of line the
4809
5709
  // `act` level exists for.
4810
5710
  noteWatchSetting(`dismissed ${host}`);
@@ -4896,7 +5796,7 @@ async function handleClaudeAccountSwitch(req, res) {
4896
5796
  const { switchClaudeAccount, invalidateClaudeAccountsCache } = await import(
4897
5797
  pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
4898
5798
  );
4899
- const body = await readBody(req).catch(() => null);
5799
+ const body = await readBody(req, res).catch(() => null);
4900
5800
  let parsed = null;
4901
5801
  try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
4902
5802
  if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
@@ -4944,7 +5844,7 @@ async function handleAccountLoginState(_req, res) {
4944
5844
  */
4945
5845
  async function handleClaudeAccountAdmin(req, res) {
4946
5846
  const admin = await cswapAdminModule();
4947
- const body = await readBody(req).catch(() => null);
5847
+ const body = await readBody(req, res).catch(() => null);
4948
5848
  let parsed = null;
4949
5849
  try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
4950
5850
  if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
@@ -4980,6 +5880,84 @@ function cswapAutoModule() {
4980
5880
  return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-auto.mjs")).href);
4981
5881
  }
4982
5882
 
5883
+ /**
5884
+ * Every server module this process reaches only through `import()`, and so
5885
+ * reads off disk the first time it is wanted rather than at boot.
5886
+ *
5887
+ * THAT IS WHAT A SELF-UPDATE BROKE (#1042). Node caches an evaluated module by
5888
+ * its URL for the life of the process, so everything already loaded keeps the
5889
+ * code it booted with — which is the whole of why startUpgrade can leave this
5890
+ * process serving and let the drift path restart it at an idle moment. A module
5891
+ * NOT yet loaded had no such protection: PKG_ROOT is a fixed path, and
5892
+ * `npm i -g` rewrites the tree under it. So the first look at the accounts panel
5893
+ * after an install evaluated the NEW cswap-admin.mjs beside the OLD
5894
+ * claude-accounts.mjs and exec.mjs — a mixed build nobody has ever run — and
5895
+ * while npm was mid-reify the same `import()` found no file at all, which
5896
+ * lan-engine's roundWith catches and files as that PEER's failed round: a
5897
+ * credential sync that did not happen, recorded against the other machine
5898
+ * rather than against the install. The window is not short. The idle
5899
+ * restart waits on presence and activity, and with autoUpdate off it never
5900
+ * comes.
5901
+ *
5902
+ * Reproduced with the tree swap done in a temp copy of the package: after
5903
+ * `POST /api/upgrade`, every one of /api/codex-usage, /api/codex-quota,
5904
+ * /api/browser-watch and /api/claude-accounts/login answered 500, and each one
5905
+ * had evaluated a module out of the tree npm had just written.
5906
+ *
5907
+ * So the build is pinned instead. Each module below is imported once, after
5908
+ * the boot (markDeckReady) and before any install can start (handleUpgrade and
5909
+ * awayUpdateTick both await it), and every lazy import in this file is a cache
5910
+ * hit from then on; their own static imports come with them. Measured, the
5911
+ * whole list costs about 10ms and 8MB of RSS, and none of it does any work at
5912
+ * import — nothing is spawned, read or written — so the laziness still does
5913
+ * what it was for, which is keeping all of this off the path between
5914
+ * `npx ccdeck` and a listening socket.
5915
+ *
5916
+ * The list is every local module that an `import()` in src/server names, and
5917
+ * self-update-pins-build.test.ts reads the directory to keep it that way: a lazy
5918
+ * import added later without a line here is this bug again.
5919
+ */
5920
+ const PINNED_MODULES = [
5921
+ "self-update.mjs",
5922
+ "claude-accounts.mjs",
5923
+ "cswap-admin.mjs",
5924
+ "cswap-auto.mjs",
5925
+ "quota.mjs",
5926
+ "codex-usage.mjs",
5927
+ "codex-quota.mjs",
5928
+ "ccusage.mjs",
5929
+ "browser-watch.mjs",
5930
+ "browser-watch-store.mjs",
5931
+ // system-metrics.mjs's two, on the platforms that have them, and the one
5932
+ // installer.mjs reaches for while it rewrites the hooks.
5933
+ "macmon.mjs",
5934
+ "hwmonitor.mjs",
5935
+ "retire-sound-hook.mjs",
5936
+ ];
5937
+
5938
+ let _pinned = null;
5939
+
5940
+ /**
5941
+ * Load every module in PINNED_MODULES, once per process.
5942
+ *
5943
+ * One at a time rather than all together. It costs nothing measurable, and it
5944
+ * means no two of them are ever evaluating at once — the shape that turned an
5945
+ * import cycle into an empty namespace (see boot-module-graph.test.ts).
5946
+ *
5947
+ * A module that fails to load is passed over rather than allowed to stop the
5948
+ * rest: waiting will not make it load, and one bad file must not leave the
5949
+ * others to be read off whatever npm writes next. The promise never rejects.
5950
+ */
5951
+ export function pinRunningBuild() {
5952
+ _pinned ??= (async () => {
5953
+ for (const name of PINNED_MODULES) {
5954
+ try { await import(pathToFileURL(join(PKG_ROOT, "src/server", name)).href); }
5955
+ catch { /* see above */ }
5956
+ }
5957
+ })();
5958
+ return _pinned;
5959
+ }
5960
+
4983
5961
  async function handleCswapAuto(req, res) {
4984
5962
  const { autoStatus } = await cswapAutoModule();
4985
5963
  send(res, 200, await autoStatus());
@@ -4992,7 +5970,7 @@ async function handleCswapAuto(req, res) {
4992
5970
  */
4993
5971
  async function handleCswapAutoAction(req, res) {
4994
5972
  const mod = await cswapAutoModule();
4995
- const body = await readBody(req).catch(() => null);
5973
+ const body = await readBody(req, res).catch(() => null);
4996
5974
  let parsed = null;
4997
5975
  try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
4998
5976
  if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
@@ -5179,8 +6157,9 @@ export async function canonicalCwd(raw) {
5179
6157
  }
5180
6158
 
5181
6159
  /**
5182
- * The deck's one irreversible action: empty the ring, and empty the log — but
5183
- * only the log this deck is the one writing.
6160
+ * The deck's one irreversible action: empty the ring, and empty the log — both
6161
+ * of its generations, the live file and the archive rotation leaves beside it —
6162
+ * but only the log this deck is the one writing.
5184
6163
  *
5185
6164
  * The gate is the whole of #698. `truncate(persistPath, 0)` ran from whichever
5186
6165
  * deck was asked, and `persistPath` is one file several decks share by default,
@@ -5206,11 +6185,72 @@ export async function canonicalCwd(raw) {
5206
6185
  */
5207
6186
  async function handleClear(res) {
5208
6187
  const sharing = await logSharing();
6188
+ const mineToEmpty = Boolean(sharing.path && sharing.mine);
6189
+ // THE PRESS IS ONE SYNCHRONOUS MOMENT, from the ring being emptied to the
6190
+ // marker being pushed, and the log's turn on the append queue is taken
6191
+ // inside it. Nothing from here awaits until the marker is out, so no event
6192
+ // can be pushed in the middle: every event is on one side of the press, and
6193
+ // it is the same side for the board, the ring and the file.
6194
+ //
6195
+ // * Pushed before it: out of the ring by clearEventBuffer, numbered below
6196
+ // the marker so the reducer forgets it, and queued before the truncate —
6197
+ // written, then erased.
6198
+ // * Pushed after it: numbered above the marker so the board keeps it, and
6199
+ // queued AFTER the truncate, so its write cannot begin until the file has
6200
+ // been emptied. That is why a Clear cannot take an event posted after it
6201
+ // along with it: the chain in log-writer.mjs is the only way this process
6202
+ // writes the log, and it starts each step only when the one before it has
6203
+ // settled.
6204
+ //
6205
+ // Both halves were broken in turn. #1005: the truncate consulted the queue in
6206
+ // neither direction, so the queue drained PRE-CLEAR lines into the freshly
6207
+ // emptied file — 564 bytes and three events of a cleared session, 2.5s after
6208
+ // a Clear that answered `{"log":"cleared"}`. Its fix waited for the queue and
6209
+ // then truncated beside it, but the wait was for the queue as it stood when
6210
+ // the wait began, and the session went on posting through it: MEASURED
6211
+ // (#1130), five runs, 4, 5, 3, 3 and 2 events the Clear had taken off the
6212
+ // board were in the file a restart replays. See emptyLog.
6213
+ //
6214
+ // This is #698's residue by a different route, and it survives the ownership
6215
+ // gate that fixed #698 precisely because it happens on the deck that DOES own
6216
+ // the file.
6217
+ //
5209
6218
  // Not `events.length = 0`: the ring is measured by a running total now, and
5210
6219
  // emptying the array without the total leaves a debt that never clears. See
5211
6220
  // clearEventBuffer.
5212
6221
  clearEventBuffer();
5213
- if (sharing.path && sharing.mine) truncate(sharing.path, 0).catch(() => {});
6222
+ // THE ARCHIVE GOES WITH IT. Since #1062 the replay reads events.jsonl.1
6223
+ // whenever the live log cannot fill the ring, and a live log a Clear has just
6224
+ // emptied never can — so a Clear on a log that had rotated once came back,
6225
+ // whole, at the next boot. MEASURED (#1130): three events in the archive and
6226
+ // one in the live log, `/api/clear` answering `log: "cleared"` over a live
6227
+ // file of 0 bytes, and replayLog on the same path returning the three
6228
+ // archived sessions.
6229
+ //
6230
+ // Removed, rather than fenced off by a `__clear` line the replay stops at,
6231
+ // and the confirmation is why: it tells the user the history is gone and
6232
+ // cannot be undone, and a marker would leave a whole rotated generation of it
6233
+ // on disk behind a line asking readers not to look — readable by anything
6234
+ // else that opens the file. It would also put back on disk the line #698
6235
+ // took off it on purpose, and it would need a stopping rule the forwards
6236
+ // replay does not have: that branch reads the archive FIRST, so it would push
6237
+ // the cleared generation into the ring before it ever met the line saying to
6238
+ // discard it. The ownership gate is the truncate's, and it covers the archive
6239
+ // exactly: only the deck that writes a log may rotate it (#1062), so only
6240
+ // that deck ever made its archive.
6241
+ //
6242
+ // Nothing else of the log's bookkeeping is reset, deliberately.
6243
+ // `failedLines` / `failedChars` count what the DISK refused since this deck
6244
+ // started, which a Clear does not change — `/api/health` documents them that
6245
+ // way, and zeroing them would hide a volume that is still failing — and an
6246
+ // open failure episode ends only on a line that lands, the one evidence that
6247
+ // the condition is over, which a Clear is not. The rotation's byte count is
6248
+ // a trigger for a `stat`, not a size: over-counting after a truncate costs at
6249
+ // most one early look, which finds a small file and resets it. And a
6250
+ // rotation running at the same moment needs nothing from here; emptyLog says
6251
+ // why the order inside the turn is enough.
6252
+ const emptyOutcome = {};
6253
+ const emptied = mineToEmpty ? emptyLog(sharing.path, [sharing.path + ".1"], undefined, emptyOutcome) : null;
5214
6254
  // Drop the caches that gate an emit on "has this changed", because the
5215
6255
  // client is about to forget what they are comparing against: __clear makes
5216
6256
  // the reducer return a fresh state, so every session's name and every
@@ -5229,6 +6269,7 @@ async function handleClear(res) {
5229
6269
  // longer has it — here, and in forgetSession.
5230
6270
  outputWatch.clear();
5231
6271
  nameBySession.clear();
6272
+ recapBySession.clear();
5232
6273
  modelBySession.clear();
5233
6274
  // The read stamps go with them. Clearing only the signatures would leave
5234
6275
  // the next hook event inside MODEL_READ_THROTTLE_MS, so the transcript
@@ -5238,9 +6279,37 @@ async function handleClear(res) {
5238
6279
  lastNameReadAt.clear();
5239
6280
  modelLastReadAt.clear();
5240
6281
  pushEvent({ hook_event_name: "__clear", cwd: "" }, "internal", { persist: false });
6282
+ // The end of the press. Awaited, where the truncate used to be fired and
6283
+ // forgotten, so the answer does not go out before the file has actually
6284
+ // reached zero — and bounded by emptyLog, so a disk that does not come back
6285
+ // in time still gets an answer, with the turn left on the chain in order.
6286
+ if (emptied) await emptied;
6287
+ // WHAT THE FILE ACTUALLY DID (#1140). This answered "cleared" for any log this
6288
+ // deck owns, whatever the truncate had done: emptyLog keeps the queue moving
6289
+ // by swallowing its error, and nothing read it back, so on a read-only volume
6290
+ // the page was told the history was gone and the next boot replayed all of
6291
+ // it. The canvas IS clear either way — the ring was emptied above — so this
6292
+ // stays `ok`, and says which half did not happen, here and in the terminal.
6293
+ // A turn still on the chain at the deadline has no error yet and keeps the
6294
+ // answer it always had.
6295
+ //
6296
+ // AND THE ARCHIVE, for the same reason: one that survives a Clear is the whole
6297
+ // history again at the next boot, because the replay falls back to it once the
6298
+ // live log is empty (#1130). Either half failing makes the answer "failed", and
6299
+ // each says which it was.
6300
+ const truncateFailed = mineToEmpty && emptyOutcome.error ? emptyOutcome.error : null;
6301
+ const archiveFailed = mineToEmpty && emptyOutcome.archiveError ? emptyOutcome.archiveError : null;
6302
+ const failed = truncateFailed ?? archiveFailed;
6303
+ if (truncateFailed) {
6304
+ console.error(`${PRODUCT}: Clear could not empty the event log ${sharing.path} (${truncateFailed.code ?? truncateFailed.message}) — the canvas is clear, but that history will come back at the next boot`);
6305
+ }
6306
+ if (archiveFailed) {
6307
+ console.error(`${PRODUCT}: Clear could not remove the event log's archive ${sharing.path}.1 (${archiveFailed.code ?? archiveFailed.message}) — the canvas is clear, but that history will come back at the next boot`);
6308
+ }
5241
6309
  return send(res, 200, {
5242
6310
  ok: true,
5243
- log: !sharing.path ? "none" : sharing.mine ? "cleared" : "kept",
6311
+ log: !sharing.path ? "none" : sharing.mine ? (failed ? "failed" : "cleared") : "kept",
6312
+ ...(failed ? { error: failed.code ?? "EIO" } : {}),
5244
6313
  path: sharing.path,
5245
6314
  decks: sharing.decks,
5246
6315
  mine: sharing.mine,
@@ -5248,6 +6317,73 @@ async function handleClear(res) {
5248
6317
  });
5249
6318
  }
5250
6319
 
6320
+ /** The most session ids one press of this may name. A board holding more than
6321
+ * the server tracks at all cannot be describing anything the server still
6322
+ * remembers, and a body is not a reason to walk an unbounded list. */
6323
+ const MAX_FORGET_IDS = MAX_TRACKED_SESSIONS;
6324
+
6325
+ /**
6326
+ * POST /api/forget — the client's own pruners, saying what left the board.
6327
+ *
6328
+ * THE THIRD PLACE THE RULE HAD TO REACH (#1024). `handleClear`'s comment states
6329
+ * it: "anything answering 'has this changed' has to appear in BOTH places that
6330
+ * mean the client no longer has it — here, and in forgetSession." There was a
6331
+ * third, and the server never heard about it — `pruneDoneSessions` and
6332
+ * `pruneOldAgents` in the page, which run every 250ms at cap 6 / grace 2
6333
+ * minutes, and #445's own measurement says 7 of 20 evicted sessions went on to
6334
+ * emit more events.
6335
+ *
6336
+ * Usage, context and the ROOT model all come back on their own: the first two
6337
+ * are not change-gated, and `pushEvent` stamps `raw.model` on every payload.
6338
+ * `sessionName`/`sessionTitle` and the per-subagent models do not, because
6339
+ * `nameBySession` and `modelBySession` still hold the signature that gates the
6340
+ * emit. Measured against a real deck over a real transcript, sandboxed HOME:
6341
+ *
6342
+ * SessionNamed while the session was on the board: ["reducer-audit"]
6343
+ * --- the client prunes S5, and tells nobody ---
6344
+ * events for S5 after the resume: UserPromptSubmit, UsageObserved,
6345
+ * UserPromptSubmit, UsageObserved, ContextObserved, …
6346
+ * SessionNamed among them: false
6347
+ *
6348
+ * A session evicted while idle and then resumed showed as unnamed in the sidebar
6349
+ * and on the card for the rest of the day, with no way to recover but reloading
6350
+ * the tab.
6351
+ *
6352
+ * `forgetSession` is the whole of the answer and it already exists — this route
6353
+ * is the client's half of a call the server has been making to itself since the
6354
+ * LRU cap was added. It drops the read stamps along with the signatures, for the
6355
+ * reason `handleClear` lists them: clearing only the signatures would leave the
6356
+ * next hook event inside MODEL_READ_THROTTLE_MS, so nothing would be re-read and
6357
+ * the name would still be missing.
6358
+ *
6359
+ * NOT DROPPING THE CHANGE GATE INSTEAD, which was the other option in the
6360
+ * report. The gate is what keeps a per-pass emit from becoming ~683 events
6361
+ * saying nothing out of 685 records, and the reducer absorbing repeats correctly
6362
+ * is not a reason to send them.
6363
+ *
6364
+ * Costs nothing when it is wrong. A session named here that the deck is still
6365
+ * hearing from re-reads its transcript once and re-emits what it finds, which
6366
+ * the reducer's assign-don't-append handlers apply idempotently.
6367
+ */
6368
+ async function handleForget(req, res) {
6369
+ const raw = await readBody(req).catch(() => null);
6370
+ let body = null;
6371
+ try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
6372
+ const ids = Array.isArray(body?.ids) ? body.ids : null;
6373
+ if (!ids) return send(res, 400, { ok: false, reason: "bad_request" });
6374
+ let forgotten = 0;
6375
+ for (const sid of ids.slice(0, MAX_FORGET_IDS)) {
6376
+ if (typeof sid !== "string" || sid === "") continue;
6377
+ // Out of the LRU as well, so the cap is not spent on a session nothing is
6378
+ // tracking any more. A session that speaks again is re-inserted by
6379
+ // `touchSession` as what it now is: one the deck has just heard from.
6380
+ sessionTouchedAt.delete(sid);
6381
+ forgetSession(sid);
6382
+ forgotten++;
6383
+ }
6384
+ return send(res, 200, { ok: true, forgotten });
6385
+ }
6386
+
5251
6387
  function handleHealth(_req, res) {
5252
6388
  send(res, 200, {
5253
6389
  ok: true,
@@ -5257,6 +6393,20 @@ function handleHealth(_req, res) {
5257
6393
  uptimeMs: Math.round(process.uptime() * 1000),
5258
6394
  workspace: _workspace,
5259
6395
  providers: _providers,
6396
+ // WHETHER THE EVENTS BEING DRAWN ARE BEING KEPT. `seq` above counts what
6397
+ // the deck accepted, and it counted a deck whose every append was failing
6398
+ // exactly the same as one whose every append landed. `log` is the other
6399
+ // half of that sentence: `writable` is the newest evidence about the log —
6400
+ // the boot probe's answer until a line lands after it and the appender's
6401
+ // from then on, the same answer the Restart gate reads (#1130) — `failing`
6402
+ // says the appender is inside a failure episode right now, and
6403
+ // `failedLines` / `failedChars` are what has been attempted and lost since
6404
+ // this deck started.
6405
+ //
6406
+ // No path. The health probe is a deliberately open route and this is the
6407
+ // smallest set of facts that answers the question; the path is already in
6408
+ // the banner for anyone standing at the terminal.
6409
+ log: persistPath ? { writable: logWritableNow(), ...appendFailureStats(persistPath) } : null,
5260
6410
  });
5261
6411
  }
5262
6412
 
@@ -5504,6 +6654,17 @@ function originMatchesHost(origin, host) {
5504
6654
  // pointed at; MAX_SCAN_BYTES_PER_PASS means no single POST walks more than 256
5505
6655
  // MiB of a file.
5506
6656
  //
6657
+ // #992 is the same question asked of the Codex half of this route, which the
6658
+ // paragraph above never covered. A `provider: "codex"` event carries a
6659
+ // `session_id` and no path, and the deck looks that id up by walking
6660
+ // $CODEX_HOME/sessions. A miss was never kept, so every id no rollout carries
6661
+ // walked the whole history again on every throttled pass, and a burst of fresh
6662
+ // ids walked it once each, all at once. What bounds it now is
6663
+ // findCodexRolloutPath: at most one walk of the whole tree per id in any
6664
+ // CODEX_MISS_TTL_MS, never two in flight, and every other lookup reads the
6665
+ // newest two day directories. A caller with an endless supply of fresh ids can
6666
+ // keep that one walk busy; it cannot make it two.
6667
+ //
5507
6668
  // So the claim above holds again, with its scope written out: the worst a
5508
6669
  // caller does with this route is draw a session on the canvas that is not
5509
6670
  // there. It cannot make the deck open a file of its choosing, and it cannot
@@ -5666,13 +6827,40 @@ function isAuthorizedDataRead(req) {
5666
6827
  return originMatchesHost(headers.referer, headers.host);
5667
6828
  }
5668
6829
 
5669
- /** The reads that carry the user's own work, rather than the machine's. */
6830
+ /** The reads that carry the user's own work, rather than the machine's.
6831
+ *
6832
+ * `/api/lan` and `/api/prefs` were missing, and they are the same class of
6833
+ * secret as the four that were here. Measured against a running deck with a
6834
+ * request carrying no headers at all — the sandboxed subprocess with loopback
6835
+ * egress that isAuthorizedDataRead names above, or another UID on a shared
6836
+ * box — `/api/claude-accounts` answered 401 and `/api/lan` answered 200 with:
6837
+ *
6838
+ * • `shared`, one `<email>@@<organization uuid>` per account this deck
6839
+ * offers — the address of every Claude login on the machine;
6840
+ * • `peers[].offers.accounts[]`, the same for every paired deck, plus each
6841
+ * one's LAN address, port, hostname, OS and ccdeck version;
6842
+ * • `invite`, whenever the owner has one open — and that one is a bearer
6843
+ * credential, not a description. readInvite -> connectToPeer({code}) ->
6844
+ * onInviteUsed pins the caller as trusted with nobody pressing anything,
6845
+ * and a trusted deck may then send `manifest` and `want` and receive the
6846
+ * sealed OAuth credentials for every shared account.
6847
+ *
6848
+ * `/api/prefs` carries the narrower half: publicPrefs already strips
6849
+ * `lan.secret` and every `trusted[].pub`, which was the part that had to be
6850
+ * right, but it keeps `lan.shared` — the same addresses — and every trusted
6851
+ * peer's name and fingerprint.
6852
+ *
6853
+ * Neither route has a caller outside the deck's own page, so guarding them
6854
+ * costs nothing: every fetch of both is in src/web, and a page's own GET
6855
+ * carries Sec-Fetch-Site: same-origin. */
5670
6856
  const GUARDED_READS = new Set([
5671
6857
  "/events",
5672
6858
  "/api/events",
5673
6859
  "/api/claude-accounts",
5674
6860
  "/api/claude-accounts/login",
5675
6861
  "/api/browser-watch",
6862
+ "/api/lan",
6863
+ "/api/prefs",
5676
6864
  ]);
5677
6865
 
5678
6866
  function isAuthorizedMutation(req) {
@@ -5823,7 +7011,7 @@ let _restarting = false;
5823
7011
  // is reachable in and cannot answer for on its own.
5824
7012
  let _deckReady = false;
5825
7013
 
5826
- export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null, onStop = null } = {}) {
7014
+ export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null, onStop = null, cswapQuiet = null } = {}) {
5827
7015
  _onRestart = typeof onRestart === "function" ? onRestart : null;
5828
7016
  _onStop = typeof onStop === "function" ? onStop : null;
5829
7017
  _stopping = false;
@@ -5836,6 +7024,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5836
7024
  // `!== false` rather than a cast: a caller that omits the field means "yes",
5837
7025
  // which is how every embedder that predates this option keeps working.
5838
7026
  _providers = { claude: claude !== false, codex: codex !== false };
7027
+ // A boot is the one moment the file, and not the engine, is the authority on
7028
+ // the key, the pairings and the port. See lanApplyFields.
7029
+ _lanLoaded = false;
5839
7030
  // The away-update's clock. The boot grace counts from here, and the timer is
5840
7031
  // unref'd so it never keeps a process alive on its own. It does nothing until
5841
7032
  // the launcher has handed down a restart — see awayGate's `supervised`.
@@ -5847,21 +7038,68 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
5847
7038
  // handed to the roster read here, by the server that is actually running,
5848
7039
  // rather than wired at import — see repairStaleCopyWith.
5849
7040
  if (_providers.claude) {
5850
- void Promise.all([
5851
- import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href),
5852
- cswapAdminModule(),
5853
- ]).then(([accounts, admin]) => accounts.repairStaleCopyWith(admin.autoRecapture), () => {});
7041
+ // THIS WIRING IS WHERE AN IMPORT CYCLE SHOWED UP AS A SILENT NO-OP, and it
7042
+ // is the cycle that has been fixed rather than this line — see
7043
+ // claude-identity.mjs.
7044
+ //
7045
+ // claude-accounts.mjs used to take `currentIdentity` from cswap-admin.mjs
7046
+ // while cswap-admin.mjs took `backupRoot`, `invalidateClaudeAccountsCache`
7047
+ // and `verdictNow` back, which was the only static import cycle in
7048
+ // src/server. Two dynamic imports entering a cycle concurrently are each
7049
+ // handed the other module's HALF-BUILT namespace rather than waiting for
7050
+ // it, and a half-built namespace has no exports on it at all: measured on
7051
+ // CI, both came back with zero keys, so `accounts.repairStaleCopyWith` was
7052
+ // a TypeError in a promise nothing awaits. The repair was never wired and
7053
+ // nothing said so — every test passed, and the run exited 1 on an unhandled
7054
+ // rejection alone.
7055
+ //
7056
+ // Several importers reach this pair within a few ticks at boot —
7057
+ // `cswapAutoModule()` below imports claude-accounts.mjs too, and
7058
+ // `startServer` can be called again before this has settled — so it was a
7059
+ // timing defect that any change to those import lists could trip, and
7060
+ // sequencing one call site was never going to be enough.
7061
+ //
7062
+ // Asked for one at a time anyway, which is now belt as well as braces: with
7063
+ // no cycle left, a concurrent pair would simply wait for each other.
7064
+ // boot-module-graph.test.ts asserts the braces — that src/server has no
7065
+ // import cycles at all — rather than trying to police call sites.
7066
+ void (async () => {
7067
+ let accounts, admin;
7068
+ // An import that genuinely fails stays tolerated, exactly as the
7069
+ // `() => {}` this replaced tolerated it: the wiring is best-effort. A
7070
+ // namespace that arrives WITHOUT the function is a different thing and
7071
+ // must stay loud, because that is the failure described above.
7072
+ try {
7073
+ accounts = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
7074
+ admin = await cswapAdminModule();
7075
+ } catch { return; }
7076
+ accounts.repairStaleCopyWith(admin.autoRecapture);
7077
+ })();
5854
7078
  }
5855
7079
  const removed = await sweepStaleDiscovery();
5856
7080
  if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
7081
+ _persistWritable = false;
5857
7082
  if (persist) {
5858
7083
  persistPath = resolve(persist);
5859
7084
  try { await mkdir(pdirname(persistPath), { recursive: true }); } catch {}
7085
+ // Asked before the first event, so a log that cannot be written is a fact
7086
+ // the deck states rather than one the user discovers by pressing Restart.
7087
+ // The mkdir above is inside a bare try/catch, so its failure is already
7088
+ // swallowed once by the time we get here — see _persistWritable.
7089
+ _persistWritable = await probeLogWritable(persistPath);
7090
+ // Taken the moment the probe answers, so that a line landing from here on
7091
+ // is evidence newer than it. See logWritableNow.
7092
+ _landedAtProbe = appendsLanded(persistPath);
5860
7093
  // `_workspace`, not `workspace`: the field has just been normalised on the
5861
7094
  // line above, and the replay has to answer the same question the live paths
5862
7095
  // answer with the same string. Passed rather than read off the module scope
5863
7096
  // so replayLog states what it depends on. See replayScope.
5864
- const replayed = await replayLog(persistPath, _workspace);
7097
+ //
7098
+ // `_providers` goes with it, and for the same reason: it gates the two live
7099
+ // capture paths a few lines down (`if (codex) startCodexWatcher`, and the
7100
+ // hook install above), and until #1004 it reached neither the replay nor
7101
+ // anything else but the health payload.
7102
+ const replayed = await replayLog(persistPath, _workspace, { providers: _providers });
5865
7103
  if (replayed > 0) {
5866
7104
  // Don't broadcast replays as live; SSE clients catch up via Last-Event-ID
5867
7105
  // already. Just keep the buffer + seq counter primed.
@@ -6007,6 +7245,37 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
6007
7245
  // POST /api/clear — wipe in-memory buffer + persistence file (UI reset)
6008
7246
  if (req.method === "POST" && url.pathname === "/api/clear") return guard(handleClear(res), res);
6009
7247
 
7248
+ // POST /api/forget — the sessions the page's own pruners dropped. The same
7249
+ // sentence as `__clear`, about part of the board rather than all of it.
7250
+ // Above the 404 below, which is what every real route has to be.
7251
+ if (req.method === "POST" && url.pathname === "/api/forget") return guard(handleForget(req, res), res);
7252
+
7253
+ // AN UNMATCHED /api/ PATH IS A 404, not the SPA shell.
7254
+ //
7255
+ // serveStatic falls back to index.html for a path it cannot find, which is
7256
+ // right for a client-side route and wrong for this namespace: nothing under
7257
+ // dist/web is served from /api/, so the fallback could only ever answer a
7258
+ // route that does not exist — with 200 and text/html.
7259
+ //
7260
+ // That made every status-code probe against this server vacuous. The Node
7261
+ // 18 floor job asks four endpoints for a 200, and one of them (/api/state)
7262
+ // has never been a route: the string appears exactly once in the repo, in
7263
+ // that loop. It reported 200 anyway, and so would the other three if the
7264
+ // whole route table were deleted, as long as index.html was in the tarball.
7265
+ // Measured before this line existed:
7266
+ //
7267
+ // /api/state 200 text/html; charset=utf-8
7268
+ // /api/definitely-not-a-route 200 text/html; charset=utf-8
7269
+ // /api/health/bogus 200 text/html; charset=utf-8
7270
+ //
7271
+ // The repo already builds machinery against checks that cannot fail —
7272
+ // skip-gate-inventory.test.ts exists because "a probe can start answering
7273
+ // the other way and take its case off ALL THREE legs at once". A probe that
7274
+ // cannot answer the other way is the limiting case of the same thing.
7275
+ if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
7276
+ return send(res, 404, { error: "not found" });
7277
+ }
7278
+
6010
7279
  if (req.method === "GET") return serveStatic(req, res, url);
6011
7280
  send(res, 405, { error: "method not allowed" });
6012
7281
  };
@@ -6059,8 +7328,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
6059
7328
  // asked the registry never binds a port it is about to walk away from.
6060
7329
  _prefsRead.then(() => applyLanPrefs()).catch(() => {});
6061
7330
  // Auto-switch resumes only if the user previously turned it on; the
6062
- // module reads its own persisted flag and does nothing otherwise.
6063
- cswapAutoModule().then(m => m.initCswapAuto()).catch(() => {});
7331
+ // module reads its own persisted flag and does nothing otherwise. Its
7332
+ // ticks wait for `cswapQuiet`, the launcher's word that claude-swap is not
7333
+ // being installed or upgraded underneath them (#1043) — null from every
7334
+ // caller with nothing to wait for.
7335
+ cswapAutoModule().then(m => m.initCswapAuto({ after: cswapQuiet })).catch(() => {});
6064
7336
  return server;
6065
7337
  } catch (err) {
6066
7338
  lastErr = err;