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.
- package/README.md +53 -10
- package/THIRD_PARTY_NOTICES.md +123 -15
- package/bin/deck.js +264 -21
- package/dist/web/assets/BrowserWatchModal-B00oF8_I.js +1 -0
- package/dist/web/assets/UsageHistoryModal-IkFiv0Vg.js +1 -0
- package/dist/web/assets/index-DgrvXP9K.css +1 -0
- package/dist/web/assets/index-Q2E4ePHr.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 +244 -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 -184
- package/src/server/installer.mjs +136 -8
- package/src/server/lan-about.mjs +8 -3
- package/src/server/lan-engine.mjs +335 -47
- package/src/server/lan-socket.mjs +384 -57
- package/src/server/lan-sync.mjs +472 -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-BYqkKWPC.js +0 -274
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
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 ??
|
|
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
|
|
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
|
|
287
|
-
// so the next poll reported an empty archive and no dismissals at all —
|
|
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
|
-
|
|
291
|
-
|
|
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
|
|
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;
|