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.
- package/README.md +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
- package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
- package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
- package/dist/web/assets/index-Dsca2TwS.js +296 -0
- package/dist/web/index.html +9 -5
- package/hook/hook.js +631 -99
- package/package.json +1 -1
- package/release-notes.json +62 -1
- package/src/server/activity.mjs +42 -4
- package/src/server/args.mjs +30 -4
- package/src/server/browser-history.mjs +164 -38
- package/src/server/browser-presence.mjs +60 -0
- package/src/server/browser-react.mjs +160 -7
- package/src/server/browser-watch-store.mjs +319 -17
- package/src/server/browser-watch.mjs +304 -60
- package/src/server/ccusage.mjs +20 -4
- package/src/server/claude-accounts.mjs +79 -34
- package/src/server/claude-identity.mjs +121 -0
- package/src/server/cswap-admin.mjs +160 -133
- package/src/server/cswap-auto.mjs +86 -7
- package/src/server/cswap-install.mjs +280 -43
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +252 -36
- package/src/server/deck-probe.mjs +1 -1
- package/src/server/exec.mjs +84 -3
- package/src/server/global-install.mjs +13 -0
- package/src/server/index.mjs +1454 -182
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +385 -51
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +495 -22
- package/src/server/log-writer.mjs +575 -2
- package/src/server/login-service.mjs +31 -4
- package/src/server/open-url.mjs +22 -2
- package/src/server/output-watch.mjs +59 -8
- package/src/server/purge-key.mjs +135 -0
- package/src/server/quota.mjs +112 -7
- package/src/server/running-deck.mjs +15 -1
- package/src/server/self-update.mjs +98 -4
- package/src/server/session-recap.mjs +89 -0
- package/src/server/static-cache.mjs +67 -0
- package/src/server/stop-deck.mjs +2 -2
- package/src/server/store-lock.mjs +51 -0
- package/src/server/supervisor.mjs +1 -1
- package/src/server/system-metrics.mjs +143 -17
- package/dist/web/assets/index-BdYsAtN8.js +0 -274
- package/dist/web/assets/index-CgwmcBHv.css +0 -1
package/src/server/index.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1484
|
-
|
|
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(
|
|
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
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
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
|
|
2139
|
-
*
|
|
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
|
|
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
|
-
//
|
|
2269
|
-
//
|
|
2270
|
-
//
|
|
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
|
-
|
|
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
|
|
2673
|
-
//
|
|
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
|
-
|
|
2736
|
-
|
|
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.
|
|
2782
|
-
//
|
|
2783
|
-
//
|
|
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
|
|
2805
|
-
const
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
const
|
|
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
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
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
|
-
*
|
|
3264
|
-
*
|
|
3265
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3389
|
-
//
|
|
3390
|
-
//
|
|
3391
|
-
//
|
|
3392
|
-
//
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
//
|
|
3396
|
-
//
|
|
3397
|
-
//
|
|
3398
|
-
|
|
3399
|
-
|
|
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
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
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
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
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
|
-
&& (
|
|
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
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
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`
|
|
3895
|
-
*
|
|
3896
|
-
*
|
|
3897
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
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
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
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[
|
|
4222
|
-
"Cache-Control":
|
|
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(
|
|
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
|
-
|
|
4242
|
-
|
|
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) {
|
|
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
|
|
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
|
|
4416
|
-
//
|
|
4417
|
-
//
|
|
4418
|
-
//
|
|
4419
|
-
//
|
|
4420
|
-
//
|
|
4421
|
-
//
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
4807
|
-
|
|
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 —
|
|
5183
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|