ccdeck 3.22.15 → 3.23.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-B00oF8_I.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-IkFiv0Vg.js +1 -0
  6. package/dist/web/assets/index-DgrvXP9K.css +1 -0
  7. package/dist/web/assets/index-Q2E4ePHr.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +244 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -184
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +335 -47
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +472 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BYqkKWPC.js +0 -274
  51. package/dist/web/assets/index-s_dF8YYA.css +0 -1
@@ -21,8 +21,11 @@
21
21
  // tab. Only quitting takes anything back.
22
22
  import { run } from "./exec.mjs";
23
23
  // The one table of process names, shared with the presence probe so the reaction
24
- // and the "is it running" answer can never disagree about what to look for.
25
- import { processName } from "./browser-presence.mjs";
24
+ // and the "is it running" answer can never disagree about what to look for —
25
+ // and, with it, the two questions that table cannot answer on its own: which
26
+ // browsers this platform cannot tell apart by name, and where each of those is
27
+ // installed.
28
+ import { installMarker, processName, sharesProcessName } from "./browser-presence.mjs";
26
29
 
27
30
  /** Reactions this platform can actually carry out, in the order the panel
28
31
  * should offer them. Never a list the caller has to filter again. */
@@ -231,14 +234,156 @@ export async function quitBrowser(browserKey, platform = process.platform, deps
231
234
  // the other probe reads them from, so the two cannot drift apart.
232
235
  const proc = processName(browserKey, platform);
233
236
  if (!proc) return { ok: false, reason: "unknown_browser" };
234
- if (platform === "win32") {
235
- const r = await exec("taskkill", ["/IM", `${proc}.exe`, "/F"]).catch(() => null);
236
- return { ok: r?.ok === true, reason: r?.ok ? "quit" : "taskkill_failed" };
237
- }
237
+ if (platform === "win32") return await quitWindows(browserKey, proc, exec);
238
238
  const r = await exec("pkill", ["-x", proc]).catch(() => null);
239
239
  return { ok: r?.ok === true, reason: r?.ok ? "quit" : "pkill_failed" };
240
240
  }
241
241
 
242
+ /**
243
+ * The windowed processes of one image name, and where each was run from.
244
+ *
245
+ * `MainWindowHandle -ne 0` is the filter that makes the whole thing safe to
246
+ * send a close to. Every renderer, GPU process and utility process a Chromium
247
+ * browser starts carries the SAME image name as the browser itself, has no
248
+ * window, and cannot be asked to close — only forced. Selecting on the window
249
+ * handle leaves exactly the processes a WM_CLOSE means something to, which is
250
+ * the set the user is thinking of when they arm this reaction.
251
+ *
252
+ * `Path` rides along because it is what tells the four Chrome-family channels
253
+ * apart; see installMarker.
254
+ *
255
+ * `-ErrorAction SilentlyContinue` because "no process of that name" is the
256
+ * ordinary answer — the browser may have been closed by hand between the visit
257
+ * and the poll — and it is not a reason to print a stack.
258
+ *
259
+ * SINGLE QUOTES THROUGHOUT AND NOT ONE DOUBLE QUOTE, deliberately. This is a
260
+ * `-Command` string and the whole of it has to survive being one argv entry on
261
+ * a Windows command line; the repo has already paid for the other spelling once
262
+ * (see notify, above: PowerShell documents that a string `-Command` must be the
263
+ * LAST parameter, and everything after it is appended to the command TEXT). A
264
+ * script with no embedded double quote has nothing in it for Node's
265
+ * command-line construction to have to escape.
266
+ *
267
+ * `proc` is interpolated, and it comes from the fixed APP_NAME table in
268
+ * browser-presence.mjs and from nowhere else — the same rule closeTabScript
269
+ * keeps for the application name it interpolates. No value a user or a web page
270
+ * chose reaches this string.
271
+ */
272
+ export const windowedProcessesPs = proc =>
273
+ `Get-Process -Name ${proc} -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object Id,Path | ConvertTo-Json -Compress`;
274
+
275
+ /**
276
+ * The process ids from that listing which belong to this install.
277
+ *
278
+ * `marker` null means the image name IS the install — `msedge`, `brave`,
279
+ * `vivaldi` — so every windowed process of that name is wanted. A marker means
280
+ * the name is shared and only the ones whose executable sits under it are.
281
+ *
282
+ * A row with NO readable path is dropped rather than kept. `Get-Process` cannot
283
+ * read `.Path` for a process this session may not open, and a row the deck
284
+ * cannot attribute is one it must not claim: keeping it would put the
285
+ * unattributable processes back into a set whose entire purpose is to exclude
286
+ * the ones that are not this browser. Erring towards closing nothing is the
287
+ * only direction that cannot take somebody's tabs with it.
288
+ *
289
+ * `ConvertTo-Json` emits a bare object rather than an array when the listing
290
+ * has exactly one row — one browser window is the common case — so a single
291
+ * object is read as readily as a list.
292
+ */
293
+ export function pickInstallPids(json, marker) {
294
+ let rows;
295
+ try { rows = typeof json === "string" ? JSON.parse(json) : json; }
296
+ catch { return []; }
297
+ if (!rows) return [];
298
+ if (!Array.isArray(rows)) rows = [rows];
299
+ const want = marker ? marker.toLowerCase() : null;
300
+ const out = [];
301
+ for (const r of rows) {
302
+ const pid = Number(r?.Id);
303
+ if (!Number.isInteger(pid) || pid <= 0) continue;
304
+ if (want) {
305
+ const path = typeof r?.Path === "string" ? r.Path : "";
306
+ if (!path.toLowerCase().includes(want)) continue;
307
+ }
308
+ if (!out.includes(pid)) out.push(pid);
309
+ }
310
+ return out;
311
+ }
312
+
313
+ /**
314
+ * Quit a browser on Windows — by asking it to close, and asking only the one
315
+ * the finding came from.
316
+ *
317
+ * WHAT THIS USED TO BE, because it is the reason the function exists (#1028):
318
+ *
319
+ * exec("taskkill", ["/IM", `${proc}.exe`, "/F"])
320
+ *
321
+ * Two separate faults, and both of them cost the user work.
322
+ *
323
+ * `/F` is TerminateProcess. It is not a request and nothing gets to refuse it:
324
+ * no beforeunload, no session write, no "restore pages?" on the next launch.
325
+ * The other two legs of this same function are graceful — macOS sends the
326
+ * AppleScript `quit`, Linux sends SIGTERM through `pkill -x` — so Windows was
327
+ * the one platform where arming this reaction meant losing whatever was typed
328
+ * into every open form. Without `/F`, taskkill posts WM_CLOSE, which is the
329
+ * Windows spelling of the same polite request the other two legs make, and
330
+ * which Chrome honours by saving its session first.
331
+ *
332
+ * `/IM chrome.exe` is every Chrome-family process on the machine. Four browser
333
+ * keys share that image name (see sharesProcessName), so a user who armed this
334
+ * for a finding in Canary lost stable Chrome, Beta, Canary and Chromium at
335
+ * once — while the very same reaction on the very same finding quits Canary
336
+ * alone on macOS, where the table distinguishes "Google Chrome" from "Google
337
+ * Chrome Canary". Selecting the pids first and passing them with `/PID` is what
338
+ * makes the Windows reaction as narrow as the macOS one.
339
+ *
340
+ * THE FALLBACK NEVER WIDENS THE DAMAGE, only the reach. If PowerShell cannot be
341
+ * reached at all, this asks the image name to close — the same set the old code
342
+ * touched, and still without `/F`, so the worst case here is strictly gentler
343
+ * than the best case was before. It says which of the two it did, because a
344
+ * reaction that quietly closed three more browsers than it was asked to is
345
+ * something the reader has to be able to see in the log.
346
+ *
347
+ * And a browser with no window open is reported, not worked around. That is the
348
+ * case where the old code would have closed somebody else's browser instead of
349
+ * this one, and "could not" is the honest answer — the same rule react() keeps
350
+ * for every other failure it prints.
351
+ */
352
+ async function quitWindows(browserKey, proc, exec) {
353
+ // The filter is applied only where the name is ambiguous. Narrowing an image
354
+ // name that already names one browser would mean a Brave installed somewhere
355
+ // this table did not predict is a Brave the reaction refuses to close.
356
+ const shared = sharesProcessName(browserKey, "win32").length > 0;
357
+ const marker = shared ? installMarker(browserKey, "win32") : null;
358
+
359
+ // Ambiguous AND unknown to the install table: there is no way to tell this
360
+ // browser's processes from three others', so the only honest answers are the
361
+ // broad graceful close or nothing. Broad-and-graceful, said out loud.
362
+ if (shared && !marker) return await closeByImage(proc, exec, "quit_family");
363
+
364
+ const listed = await exec("powershell.exe", [
365
+ "-NoProfile", "-NonInteractive", "-Command", windowedProcessesPs(proc),
366
+ ]).catch(() => null);
367
+
368
+ if (!listed?.ok) return await closeByImage(proc, exec, "quit_family");
369
+
370
+ const pids = pickInstallPids(String(listed.stdout ?? "").trim(), marker);
371
+ if (!pids.length) return { ok: false, reason: "no_window" };
372
+
373
+ // One taskkill with every pid on it rather than one per pid: a browser with
374
+ // four windows open is four processes only on the rarest Chromium build, but
375
+ // a profile the user runs twice is two, and closing one of them and reporting
376
+ // success would leave the session this reaction exists to take back.
377
+ const r = await exec("taskkill", pids.flatMap(pid => ["/PID", String(pid)])).catch(() => null);
378
+ return { ok: r?.ok === true, reason: r?.ok ? "quit" : "taskkill_failed" };
379
+ }
380
+
381
+ /** Ask every window of an image name to close. Graceful — never `/F`. */
382
+ async function closeByImage(proc, exec, reason) {
383
+ const r = await exec("taskkill", ["/IM", `${proc}.exe`]).catch(() => null);
384
+ return { ok: r?.ok === true, reason: r?.ok ? reason : "taskkill_failed" };
385
+ }
386
+
242
387
  /**
243
388
  * Carry out the armed reaction for one episode.
244
389
  *
@@ -279,6 +424,14 @@ export async function react(reaction, episode, { platform = process.platform, de
279
424
  }
280
425
 
281
426
  const out = await quitBrowser(episode.browser, platform, deps);
282
- done.push(out.ok ? "quit the browser" : `could not quit the browser — ${out.reason}`);
427
+ // `quit_family` is the Windows fallback that could not tell four browsers
428
+ // sharing one image name apart and asked all of them to close. It succeeded,
429
+ // so it is not a `could not` line — but a reaction that closed three more
430
+ // browsers than the reader armed it for is not "quit the browser" either, and
431
+ // the log is the only place they would ever find out.
432
+ done.push(
433
+ out.reason === "quit_family"
434
+ ? "asked every window of that browser's family to close — this platform cannot tell its channels apart by process name"
435
+ : out.ok ? "quit the browser" : `could not quit the browser — ${out.reason}`);
283
436
  return done;
284
437
  }
@@ -17,12 +17,25 @@
17
17
  // `~/.claude/agent-dag`: readLiveDecks() reads every `.json` in that directory
18
18
  // and would have to keep skipping this one forever. A subdirectory is not a
19
19
  // name it can collide with.
20
- import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
20
+ //
21
+ // AND THE READ IS THE OTHER HALF OF THAT SENTENCE, which took #1003 to notice.
22
+ // An archive whose whole premise is that the browser's own copy can be erased
23
+ // has to survive its own file being damaged, and what this module did when it
24
+ // met one was read it as an empty archive — same answer as "no file yet" — and
25
+ // then let the next ten-second poll write that emptiness over it. A file that
26
+ // cannot be parsed is now moved aside and said out loud; only a genuinely
27
+ // ABSENT file starts clean. See loadStore.
28
+ import { appendFile, mkdir, open, readFile, stat, unlink } from "node:fs/promises";
21
29
  // The rename, with the Windows retry ladder installer.mjs wrote for exactly
22
- // this call. See the note over `writeNow` (#786).
23
- import { renameWithRetry } from "./installer.mjs";
30
+ // this call. See the note over `writeNow` (#786). `stripBom` comes from the same
31
+ // module for the reason its export block gives: a rule spelled twice is a rule
32
+ // that drifts, and "a BOM is not damage" has to mean the same thing here as it
33
+ // does on settings.json, or a state.json somebody opened in Notepad gets
34
+ // quarantined for a mark the settings reader has ignored since it was written.
35
+ import { renameWithRetry, stripBom } from "./installer.mjs";
24
36
  import { join } from "node:path";
25
37
  import { claudeConfigDir } from "./claude-dir.mjs";
38
+ import { PRODUCT } from "./brand.mjs";
26
39
 
27
40
  /** Reactions the panel can arm. `close-tab` is macOS-only and the server is the
28
41
  * one that says so — a client cannot be trusted to know what the OS can do,
@@ -54,14 +67,70 @@ const STORE_VERSION = 2;
54
67
  * person could open and read. Trimmed oldest-first. */
55
68
  const KEEP = 500;
56
69
 
57
- const storeDir = (home = claudeConfigDir()) => join(home, "agent-dag", "browser-watch");
70
+ /** Exported so the boot-time temp sweep can be handed this directory by name
71
+ * rather than by a second spelling of the path. `sweepTempFiles` does not
72
+ * recurse — deck-home.mjs:229 walks the directories it is given and nothing
73
+ * under them — so the archive's own `.tmp` files, about 2.5 MB each with a
74
+ * full 500-episode store, were outside every sweep the deck has ever run. */
75
+ export const storeDir = (home = claudeConfigDir()) => join(home, "agent-dag", "browser-watch");
58
76
  export const storePath = (home = claudeConfigDir()) => join(storeDir(home), "state.json");
59
77
 
78
+ /** Where the bytes of a state.json nothing could parse are put.
79
+ *
80
+ * `Date.now()` rather than an ISO timestamp because a colon is not a legal
81
+ * filename character on Windows, and a quarantine that cannot be created on
82
+ * the platform it is protecting is not a quarantine. Beside the file it came
83
+ * from, so nobody has to be told where to look — and it does not end in `.tmp`,
84
+ * so the sweep that now reaches this directory will not carry it off. */
85
+ export const quarantinePath = (home = claudeConfigDir(), at = Date.now()) =>
86
+ `${storePath(home)}.corrupt-${at}`;
87
+
60
88
  /** The plain-text log, which is the one file here a person opens themselves.
61
89
  * state.json is the deck's own record and is JSON because the deck reads it
62
90
  * back; this is the same events in the shape `tail -f` wants. */
63
91
  export const logPath = (home = claudeConfigDir()) => join(storeDir(home), "watch.log");
64
92
 
93
+ /** The one generation kept behind it. `.1` rather than a datestamp because this
94
+ * is `tail -f`'s file and `logrotate`'s convention is the one a person reading
95
+ * it already knows — and because a name that changes is a name nothing can
96
+ * overwrite, which is how a rotation that bounds nothing gets written. */
97
+ export const rolledLogPath = (home = claudeConfigDir()) => `${logPath(home)}.1`;
98
+
99
+ /**
100
+ * How large watch.log may grow before it is rolled over (#989).
101
+ *
102
+ * EVERY OTHER STORE IN THIS MODULE HAS A CAP. `KEEP` holds the archive to 500
103
+ * episodes, `DISMISS_KEEP` holds dismissals to 2000, and the panel's feed is
104
+ * held to 200 lines — while this file, the one that writes out EVERY ADDRESS in
105
+ * full, grew for the life of the install and nothing ever trimmed it. With the
106
+ * watch on by default, a machine where something drives a browser in a loop
107
+ * builds a plaintext list of every address it touched, without end.
108
+ *
109
+ * TWO MEBIBYTES. An episode of twenty addresses with query strings is about
110
+ * 2 KB of this file, so the cap is on the order of a thousand episodes: twice
111
+ * the archive beside it, which `KEEP` sizes at about two years of the measured
112
+ * rate. It is only reached where something drives a browser far more often than
113
+ * that, and that machine must not fill its disk reporting it.
114
+ *
115
+ * ONE GENERATION, `watch.log.1`, so the most this costs on disk is a number a
116
+ * reader can state: twice the cap, plus one append that was larger on its own.
117
+ */
118
+ const LOG_MAX_BYTES = 2 * 1024 * 1024;
119
+
120
+ /**
121
+ * What the log holds on disk right now, both generations, in bytes — for the
122
+ * panel, which names the file and now its size. Never throws: 0 is the answer
123
+ * for a log never written, and a coverage field is not where a stat error goes.
124
+ */
125
+ export async function logSize(home = claudeConfigDir(), deps = {}) {
126
+ const st = deps.stat ?? stat;
127
+ let total = 0;
128
+ for (const file of [logPath(home), rolledLogPath(home)]) {
129
+ try { total += (await st(file)).size; } catch { /* not there, or not readable */ }
130
+ }
131
+ return total;
132
+ }
133
+
65
134
  /**
66
135
  * Append one episode, and EVERY ADDRESS IN IT, oldest first.
67
136
  *
@@ -84,6 +153,11 @@ export const logPath = (home = claudeConfigDir()) => join(storeDir(home), "watch
84
153
  * the only part of this feature that outlives the process by design — the panel
85
154
  * shows what this deck has seen, this file is what somebody reads three days
86
155
  * later without opening the panel at all.
156
+ *
157
+ * ROLLED OVER, NOT EDITED, at LOG_MAX_BYTES (#989), which keeps that rule: the
158
+ * whole file moves to `watch.log.1` and the next append starts a new one. No
159
+ * line this function wrote is ever changed. Trimming the oldest lines out in
160
+ * place was the other way to bound it, and it is the edit the rule forbids.
87
161
  */
88
162
  export async function appendLog(episodes, home = claudeConfigDir(), deps = {}) {
89
163
  if (!episodes.length) return;
@@ -108,7 +182,42 @@ export async function appendLog(episodes, home = claudeConfigDir(), deps = {}) {
108
182
  return [head, ...rows].join("\n");
109
183
  };
110
184
  await mk(storeDir(home), { recursive: true });
111
- await add(logPath(home), episodes.map(block).join("\n") + "\n", "utf8");
185
+ const text = episodes.map(block).join("\n") + "\n";
186
+ await rollIfFull(home, Buffer.byteLength(text, "utf8"), deps);
187
+ await add(logPath(home), text, "utf8");
188
+ }
189
+
190
+ /**
191
+ * Move the log aside when this append would carry it past the cap.
192
+ *
193
+ * MEASURED BEFORE THE WRITE, so the cap is a ceiling rather than a line the file
194
+ * sits above until the next append. The one overshoot left is a single append
195
+ * larger than the whole cap, written whole on purpose: an episode split across
196
+ * two files would leave addresses under no summary line, and that line and its
197
+ * indented addresses are what `grep -v '^ '` and `grep '^ '` separate.
198
+ *
199
+ * A ROTATION THAT FAILS MUST NOT COST THE APPEND. This is the record of what a
200
+ * program did in somebody's browser. A full disk, a Windows handle still open on
201
+ * `watch.log.1`, a permission on the directory: each is a reason to write a
202
+ * larger file than intended, and none is a reason to write nothing. The next
203
+ * append tries again.
204
+ *
205
+ * `renameWithRetry` for the reason #786 gives over `writeNow`: on Windows a
206
+ * rename loses to anything still holding a handle, and this is a file people
207
+ * open to read.
208
+ */
209
+ async function rollIfFull(home, adding, deps) {
210
+ const st = deps.stat ?? stat;
211
+ const mv = deps.rename ?? renameWithRetry;
212
+ try {
213
+ const { size } = await st(logPath(home));
214
+ if (size === 0 || size + adding <= LOG_MAX_BYTES) return;
215
+ await mv(logPath(home), rolledLogPath(home));
216
+ } catch {
217
+ // No log yet (the ordinary first call), or the filesystem refused. Either
218
+ // way the append is the thing that matters and it is not this function's to
219
+ // cancel.
220
+ }
112
221
  }
113
222
 
114
223
  /**
@@ -179,10 +288,132 @@ export const episodeKey = (host, startMs) => `${host}\u0000${startMs}`;
179
288
  * it cannot be. */
180
289
  const DISMISS_KEEP = 2000;
181
290
 
182
- export async function readStore(home = claudeConfigDir(), deps = {}) {
291
+ /** The refusal `updateStore` throws rather than merge onto a base it knows is
292
+ * not what is on disk. Shaped like installer.mjs's SETTINGS_UNREADABLE and
293
+ * deck-prefs' PREFS_UNREADABLE, which is this same policy on the other two
294
+ * files the deck rewrites in place: a file we cannot reproduce is never
295
+ * treated as an empty one. */
296
+ function unreadableStore(path, why) {
297
+ const err = new Error(
298
+ `${path} could not be read (${why}). Refusing to overwrite it — the episodes ` +
299
+ `in there are this deck's own record of what a program did in the browser, and ` +
300
+ `nothing can re-derive them. Fix the file or move it aside, then restart ${PRODUCT}.`,
301
+ );
302
+ err.code = "WATCH_STORE_UNREADABLE";
303
+ err.storePath = path;
304
+ err.why = why;
305
+ return err;
306
+ }
307
+
308
+ /**
309
+ * Complaints already made, so a poll does not repeat one six times a minute.
310
+ *
311
+ * THE DIVERGENCE FROM deck-prefs, and the reason for it. prefs.json is read at
312
+ * boot and when somebody presses something; this file is read by the panel's
313
+ * ten-second poll and by the badge's background one, for as long as the deck is
314
+ * up. A quarantine that succeeded says its piece once by construction — the
315
+ * damaged file is gone from that name afterwards — but the two failures that
316
+ * CANNOT clear themselves, an unreadable file and a quarantine the filesystem
317
+ * refused, would otherwise put the same line on stderr every ten seconds
318
+ * forever, which is how a warning becomes something people filter out.
319
+ *
320
+ * Keyed on the path and the reason, not on a bare flag: two different things
321
+ * going wrong with two different files are two things somebody needs told.
322
+ */
323
+ const _said = new Set();
324
+ function sayOnce(warn, key, line) {
325
+ if (_said.has(key)) return;
326
+ _said.add(key);
327
+ warn(line);
328
+ }
329
+
330
+ /**
331
+ * Read the store, and say WHICH of four things happened — because three of them
332
+ * hand back the same empty archive and only one of them means it.
333
+ *
334
+ * WHY THE SOURCE IS PART OF THE ANSWER (#1003). The old read caught every
335
+ * failure the same way — one bare `catch` marked "absent or corrupt" — which is
336
+ * a defensible answer to a question about VALUES and a catastrophic one as the
337
+ * merge base of a write, because `updateStore` asked it that question on every
338
+ * single write. Measured end to end against a state.json truncated to half its
339
+ * length, the way a machine that died between the write and the rename leaves
340
+ * one: the archive read as empty, and the next poll wrote an archive holding
341
+ * only what Chrome itself still remembered — which is precisely the copy this
342
+ * feature exists because an intruder can erase. The dismissals went with it and
343
+ * the settings went back to defaults in the same write, and nothing anywhere
344
+ * said a word.
345
+ *
346
+ * "file" parsed. This is the deck's own record.
347
+ * "missing" ENOENT, and only ENOENT. Nothing has been written yet;
348
+ * an empty archive, silently, which is what a first run is.
349
+ * "corrupt" bytes that are not JSON. Moved aside to `quarantined` BEFORE
350
+ * this returns, so nothing can write over them.
351
+ * "unreadable" the read itself failed for some reason other than absence — a
352
+ * permission, a directory in the way. The file is still there
353
+ * and still unread, which is exactly when a write must not land.
354
+ */
355
+ export async function loadStore(home = claudeConfigDir(), deps = {}) {
183
356
  const read = deps.readFile ?? readFile;
184
- let parsed = null;
185
- try { parsed = JSON.parse(await read(storePath(home), "utf8")); } catch { /* absent or corrupt */ }
357
+ const warn = deps.warn ?? console.error;
358
+ const path = storePath(home);
359
+ const empty = () => ({ settings: normalise(null), episodes: [], dismissed: [], migrated: false });
360
+
361
+ let raw;
362
+ try {
363
+ raw = await read(path, "utf8");
364
+ } catch (err) {
365
+ if (err?.code === "ENOENT") return { store: empty(), source: "missing", quarantined: "" };
366
+ sayOnce(warn, `${path} read`,
367
+ `${PRODUCT}: could not read ${path}: ${err?.message ?? err}. Leaving it alone — ` +
368
+ `the episode archive will not be written until it can be read.`);
369
+ return { store: empty(), source: "unreadable", quarantined: "" };
370
+ }
371
+
372
+ let parsed;
373
+ try {
374
+ parsed = JSON.parse(stripBom(raw));
375
+ } catch (err) {
376
+ const why = err?.message ?? String(err);
377
+ const mv = deps.rename ?? renameWithRetry;
378
+ const to = quarantinePath(home);
379
+ try {
380
+ await mv(path, to);
381
+ } catch (moveErr) {
382
+ // ENOENT means somebody else moved it between the read and the rename —
383
+ // a second deck on the same home, or this deck's own poll racing its own
384
+ // write queue. The bytes are safe, just not under a name this read chose,
385
+ // and what is at `path` now is nothing.
386
+ if (moveErr?.code === "ENOENT") return { store: empty(), source: "missing", quarantined: "" };
387
+ // Anything else and the damaged file is STILL THERE, unread and
388
+ // unprotected. Saying so is the whole point: `updateStore` refuses on it.
389
+ sayOnce(warn, `${path} quarantine`,
390
+ `${PRODUCT}: ${path} could not be read as JSON (${why}), and could not be moved ` +
391
+ `aside either: ${moveErr?.message ?? moveErr}. The episode archive will not be ` +
392
+ `written until it is fixed or moved.`);
393
+ return { store: empty(), source: "corrupt", quarantined: "" };
394
+ }
395
+ warn(
396
+ `${PRODUCT}: ${path} could not be read as JSON (${why}). It has been kept as ${to} ` +
397
+ `and Browser Watch is starting a fresh archive — the episodes this deck recorded ` +
398
+ `are in that file, so do not delete it if you want them back.`,
399
+ );
400
+ return { store: empty(), source: "corrupt", quarantined: to };
401
+ }
402
+ return { store: shapeStore(parsed), source: "file", quarantined: "" };
403
+ }
404
+
405
+ /** What is on disk, or an empty archive — the VALUES alone, for the callers
406
+ * that want nothing else. `loadStore` is the same read with the answer to "and
407
+ * was that really the deck's own file?" still attached; anything about to
408
+ * write must ask that question, and does. */
409
+ export async function readStore(home = claudeConfigDir(), deps = {}) {
410
+ return (await loadStore(home, deps)).store;
411
+ }
412
+
413
+ /** The parsed document as the rest of the deck reads it. Unchanged from what
414
+ * `readStore` always did; it is a function of its own now only because the
415
+ * read above has three other answers to give. */
416
+ function shapeStore(parsed) {
186
417
  const settings = normalise(parsed?.settings);
187
418
 
188
419
  // A VERSION BUMP DROPS THE EPISODES AND KEEPS THE SETTINGS, because the two
@@ -207,8 +438,24 @@ export async function readStore(home = claudeConfigDir(), deps = {}) {
207
438
  // caller — so it says so and the snapshot does it.
208
439
  if (parsed && parsed.v !== STORE_VERSION) return { settings, episodes: [], dismissed: [], migrated: true };
209
440
 
441
+ // FILTERED BEFORE `archivable`, NOT AFTER. archivable opens with
442
+ // `String(e.host ?? "")`, which is a property access — so a `null` element
443
+ // threw before the `.filter` below could refuse it, and the throw escaped
444
+ // readStore (the try up there wraps only JSON.parse) through
445
+ // browserWatchSnapshot and fetchBrowserWatch into guard(): a 500 on
446
+ // GET /api/browser-watch, and on the settings and dismiss POSTs through
447
+ // updateStore. The panel stayed dead until the file was edited by hand.
448
+ //
449
+ // `{"v":2,"episodes":[null]}` is all it took. A string element was already
450
+ // handled correctly — it is specifically a non-object that reached the
451
+ // property access — and normalise a few lines up is defensive for exactly
452
+ // this reason: "a hand edit, a half-written save and an older version all
453
+ // arrive from" here.
210
454
  const episodes = Array.isArray(parsed?.episodes)
211
- ? parsed.episodes.map(archivable).filter(e => Number.isFinite(e.startMs))
455
+ ? parsed.episodes
456
+ .filter(e => e && typeof e === "object")
457
+ .map(archivable)
458
+ .filter(e => Number.isFinite(e.startMs))
212
459
  : [];
213
460
  // WHAT THE READER HAS ALREADY LOOKED AT. It has to be its own list rather
214
461
  // than a deletion from `episodes`, because the panel reads the browser's
@@ -260,10 +507,37 @@ export async function writeStore(state, home = claudeConfigDir(), deps = {}) {
260
507
  return serialized(() => writeNow(state, home, deps));
261
508
  }
262
509
 
510
+ /**
511
+ * The temp file, on disk and FLUSHED, before anything renames it.
512
+ *
513
+ * A rename orders the DIRECTORY ENTRY; it does not order the bytes. A machine
514
+ * that loses power just after one can come up with the new entry pointing at
515
+ * blocks that were never written — the classic file of zero bytes, and the
516
+ * ordinary outcome of a crash for a writer that does not sync. installer.mjs's
517
+ * writeFileAtomic has fsync'd for exactly this reason since it was written, and
518
+ * this is the file where the argument is strongest: a truncated state.json is a
519
+ * lost archive, and the archive exists because the browser's own copy can be
520
+ * erased by whoever is driving it.
521
+ *
522
+ * Signature-compatible with `writeFile` on purpose. `deps.writeFile` is the seam
523
+ * the suite drives a fake filesystem through, and a staging step that changed
524
+ * its shape would be a second thing for every caller of that seam to know.
525
+ */
526
+ async function writeAndSync(path, body, encoding) {
527
+ const fh = await open(path, "w");
528
+ try {
529
+ await fh.writeFile(body, encoding);
530
+ await fh.sync();
531
+ } finally {
532
+ await fh.close();
533
+ }
534
+ }
535
+
263
536
  /** The write itself, already inside the queue. */
264
537
  async function writeNow(state, home, deps) {
265
538
  const mk = deps.mkdir ?? mkdir;
266
- const write = deps.writeFile ?? writeFile;
539
+ const write = deps.writeFile ?? writeAndSync;
540
+ const drop = deps.unlink ?? unlink;
267
541
  // `renameWithRetry`, not `rename` (#786). Same Windows rule as deck-prefs,
268
542
  // and the stakes are higher here: none of the three writers catches the
269
543
  // throw, so a refused rename 500s `GET /api/browser-watch` and the panel goes
@@ -281,14 +555,29 @@ async function writeNow(state, home, deps) {
281
555
  // the calls inside one, and there are three writers in this process — the
282
556
  // poll's snapshot, the settings route and the dismiss route — with nothing
283
557
  // between them. Measured with a full 500-episode archive (~2.5 MB, past the
284
- // 512 KiB writeFile chunk): eight concurrent runs left state.json unparseable
558
+ // 512 KiB write chunk): eight concurrent runs left state.json unparseable
285
559
  // in six of them and failed one call with ENOENT, renaming a temp file the
286
- // other writer had already renamed away. readStore swallows a corrupt file,
287
- // so the next poll reported an empty archive and no dismissals at all — total
288
- // loss of the one file this feature exists to keep.
560
+ // other writer had already renamed away. readStore swallowed a corrupt file
561
+ // then, so the next poll reported an empty archive and no dismissals at all —
562
+ // total loss of the one file this feature exists to keep. That second half is
563
+ // no longer true of any cause, which is #1003 and `loadStore` above; this
564
+ // counter is what stops this particular cause from arising at all.
289
565
  const tmp = `${storePath(home)}.${process.pid}.${++_writeSeq}.tmp`;
290
- await write(tmp, body, "utf8");
291
- await mv(tmp, storePath(home));
566
+ let landed = false;
567
+ try {
568
+ await write(tmp, body, "utf8");
569
+ await mv(tmp, storePath(home));
570
+ landed = true;
571
+ } finally {
572
+ // A REFUSED WRITE MUST NOT LEAVE ITS STAGING FILE. Each of these is about
573
+ // 2.5 MB with a full archive, and nothing ever unlinked one: a rename the
574
+ // Windows ladder could not outlast, a full disk, a permission — every one
575
+ // of them left a copy of the whole archive lying beside it. That is only
576
+ // half the litter, because a deck KILLED between the two never reaches this
577
+ // line at all; the other half is the boot sweep, which now gets handed this
578
+ // directory (see `storeDir`, and bin/deck.js's sweepTempFiles call).
579
+ if (!landed) await drop(tmp).catch(() => {});
580
+ }
292
581
  }
293
582
 
294
583
  /**
@@ -305,10 +594,23 @@ async function writeNow(state, home, deps) {
305
594
  * So a caller that owns one field passes a function instead: it runs inside the
306
595
  * same queue the write does, against the state on disk at that moment, and no
307
596
  * other writer can slip between the read and the write.
597
+ *
598
+ * AND IT REFUSES RATHER THAN MUTATE A BASE THAT IS NOT WHAT IS ON DISK (#1003).
599
+ * `current` exists to PRESERVE what the caller does not own; handed an empty
600
+ * archive because `loadStore` could not read the file, it preserves nothing and
601
+ * this becomes the call that destroys the episodes. So the two failures that
602
+ * read as empty are separated: a file already moved aside is safe to start
603
+ * clean over, and one still sitting there unread is not. installer.mjs's
604
+ * readSettingsForWrite is the same policy on settings.json and deck-prefs'
605
+ * writePrefs is the same policy on prefs.json.
308
606
  */
309
607
  export async function updateStore(mutate, home = claudeConfigDir(), deps = {}) {
310
608
  return serialized(async () => {
311
- const current = await readStore(home, deps);
609
+ const { store: current, source, quarantined } = await loadStore(home, deps);
610
+ if (source === "unreadable") throw unreadableStore(storePath(home), "the read failed");
611
+ if (source === "corrupt" && !quarantined) {
612
+ throw unreadableStore(storePath(home), "it is not JSON and could not be moved aside");
613
+ }
312
614
  const next = (await mutate(current)) ?? current;
313
615
  await writeNow(next, home, deps);
314
616
  return next;