agent-dag 3.2.1 → 3.4.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 +18 -6
- package/bin/deck.js +9 -1
- package/dist/web/assets/index-3Q9bZKVe.js +171 -0
- package/dist/web/assets/index-D0YH-emg.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +60 -14
- package/package.json +6 -3
- package/release-notes.json +36 -0
- package/src/server/agent-activity.mjs +27 -11
- package/src/server/browser-history.mjs +31 -5
- package/src/server/browser-presence.mjs +57 -12
- package/src/server/browser-react.mjs +31 -9
- package/src/server/browser-watch-store.mjs +56 -2
- package/src/server/browser-watch.mjs +90 -10
- package/src/server/ccusage.mjs +100 -3
- package/src/server/codex-quota.mjs +6 -2
- package/src/server/codex-usage.mjs +24 -1
- package/src/server/cswap-admin.mjs +0 -14
- package/src/server/cswap-auto.mjs +39 -1
- package/src/server/cswap-install.mjs +6 -1
- package/src/server/exec.mjs +44 -0
- package/src/server/index.mjs +127 -11
- package/src/server/installer.mjs +58 -3
- package/src/server/macmon.mjs +17 -6
- package/src/server/npx.mjs +6 -1
- package/src/server/open-url.mjs +25 -4
- package/src/server/quota.mjs +88 -26
- package/src/server/retire-sound-hook.mjs +36 -2
- package/src/server/self-update.mjs +17 -5
- package/src/server/system-metrics.mjs +28 -36
- package/src/server/uv-bootstrap.mjs +39 -3
- package/dist/web/assets/index-Bnn7d7u8.css +0 -1
- package/dist/web/assets/index-COdHVcLu.js +0 -149
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
// ── 3. THERE MAY BE NO SQLITE READER AT ALL ─────────────────────────────────
|
|
47
47
|
//
|
|
48
48
|
// `node:sqlite` exists only from Node 22.5, and this package declares
|
|
49
|
-
// `engines: { node: ">=18" }` — CI
|
|
49
|
+
// `engines: { node: ">=18" }` — CI runs Node 22 on all three OSes and
|
|
50
|
+
// Node 18 on one Linux leg, which is the version the package advertises. A top-level
|
|
50
51
|
// `import "node:sqlite"` is therefore not an option: it throws
|
|
51
52
|
// ERR_UNKNOWN_BUILTIN_MODULE at module load, before any of this file's own error
|
|
52
53
|
// handling exists, and takes the server's import graph with it. It is loaded
|
|
@@ -63,6 +64,9 @@ import { copyFile, mkdir, rm } from "node:fs/promises";
|
|
|
63
64
|
import { createRequire } from "node:module";
|
|
64
65
|
import { tmpdir } from "node:os";
|
|
65
66
|
import { join } from "node:path";
|
|
67
|
+
import { randomUUID } from "node:crypto";
|
|
68
|
+
import { constants as fsConstants } from "node:fs";
|
|
69
|
+
const { COPYFILE_EXCL } = fsConstants;
|
|
66
70
|
import { pathLookup, run } from "./exec.mjs";
|
|
67
71
|
|
|
68
72
|
/**
|
|
@@ -230,7 +234,7 @@ const loadSqlite = async () => {
|
|
|
230
234
|
* CLI is the fallback rather than the default because every call to it costs a
|
|
231
235
|
* process, and this runs on a poll.
|
|
232
236
|
*
|
|
233
|
-
* The dynamic import is inside a try/catch and covers more than "Node
|
|
237
|
+
* The dynamic import is inside a try/catch and covers more than "Node 18 has no
|
|
234
238
|
* such module". Between 22.5 and 22.12 node:sqlite existed but required
|
|
235
239
|
* `--experimental-sqlite`, which the deck is not started with, and the import
|
|
236
240
|
* fails there too — the same catch, the same fallback, no version arithmetic
|
|
@@ -333,9 +337,31 @@ export async function readVisitsSince(historyPath, sinceChromeTime, opts = {}) {
|
|
|
333
337
|
|
|
334
338
|
let copyPath = null;
|
|
335
339
|
try {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
340
|
+
// MODE 0700, AND A NAME NOBODY ELSE CAN PREDICT.
|
|
341
|
+
//
|
|
342
|
+
// `os.tmpdir()` is per-user on macOS (/var/folders/…, 0700) and on Windows,
|
|
343
|
+
// and on Linux it is the shared, world-writable /tmp. A fixed directory
|
|
344
|
+
// name and `history-<pid>-<n>.sqlite` inside it meant three things there,
|
|
345
|
+
// all of them avoidable:
|
|
346
|
+
//
|
|
347
|
+
// * a complete, unencrypted copy of the user's browsing history, mode
|
|
348
|
+
// 0644, under a predictable path, readable by every other account on
|
|
349
|
+
// the machine for the life of the poll;
|
|
350
|
+
// * another UID can create the directory first — `mkdir` with `recursive`
|
|
351
|
+
// swallows EEXIST and keeps THEIR mode — and then read every copy, or
|
|
352
|
+
// plant a symlink at the name and have this overwrite a file the user
|
|
353
|
+
// owns, because `copyFile` was called without COPYFILE_EXCL;
|
|
354
|
+
// * a second user on the same box then fails EACCES on a directory they
|
|
355
|
+
// cannot write, and their deck is degraded for good.
|
|
356
|
+
//
|
|
357
|
+
// The mode is set on creation AND after, because the directory may already
|
|
358
|
+
// exist from an earlier run of this same deck.
|
|
359
|
+
await makeDir(copyDir, { recursive: true, mode: 0o700 });
|
|
360
|
+
copyPath = join(copyDir, `history-${process.pid}-${++copySeq}-${randomUUID().slice(0, 8)}.sqlite`);
|
|
361
|
+
// COPYFILE_EXCL: refuse rather than write through a symlink or over a file
|
|
362
|
+
// that is already there. A refusal is one degraded poll; the alternative is
|
|
363
|
+
// clobbering whatever the name pointed at.
|
|
364
|
+
await copy(historyPath, copyPath, COPYFILE_EXCL);
|
|
339
365
|
} catch (err) {
|
|
340
366
|
// The browser is not installed, the profile moved, the disk is full. All of
|
|
341
367
|
// them are "no rows this poll", none of them is a reason to stop polling.
|
|
@@ -30,20 +30,65 @@ import { basename } from "node:path";
|
|
|
30
30
|
import { browserRoots, hasExtension, profileDirs } from "./browser-profiles.mjs";
|
|
31
31
|
import { run } from "./exec.mjs";
|
|
32
32
|
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
33
|
+
/**
|
|
34
|
+
* The name each browser's processes carry — PER PLATFORM, because they do not
|
|
35
|
+
* agree.
|
|
36
|
+
*
|
|
37
|
+
* This used to be one table of macOS bundle display names, sent to all three
|
|
38
|
+
* probes. `tasklist /FI "IMAGENAME eq Google Chrome.exe"` matches nothing and
|
|
39
|
+
* exits **0** printing `INFO: No tasks are running…`, so the probe read
|
|
40
|
+
* `ok: true` and returned a confident `false`; `pgrep -x "Google Chrome"`
|
|
41
|
+
* matches against `comm`, which on Linux is `chrome`. Every browser on Windows
|
|
42
|
+
* and Linux therefore reported "not running", `relayLink` was never called, and
|
|
43
|
+
* the relay half of this panel was dead on two of the three platforms — while
|
|
44
|
+
* the module's own header forbids exactly that: a state that means "definitely
|
|
45
|
+
* not connected" when nothing established it.
|
|
46
|
+
*
|
|
47
|
+
* Arc is macOS-only and has no entry elsewhere, which is the honest answer: a
|
|
48
|
+
* root with no name here is reported as installed and never as running.
|
|
49
|
+
*/
|
|
36
50
|
const APP_NAME = {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
darwin: {
|
|
52
|
+
chrome: "Google Chrome",
|
|
53
|
+
"chrome-beta": "Google Chrome Beta",
|
|
54
|
+
"chrome-canary": "Google Chrome Canary",
|
|
55
|
+
chromium: "Chromium",
|
|
56
|
+
brave: "Brave Browser",
|
|
57
|
+
edge: "Microsoft Edge",
|
|
58
|
+
vivaldi: "Vivaldi",
|
|
59
|
+
arc: "Arc",
|
|
60
|
+
},
|
|
61
|
+
// Image names, which is what tasklist's IMAGENAME filter compares against.
|
|
62
|
+
// The probe appends `.exe`, so these are spelled without it, exactly as the
|
|
63
|
+
// POSIX ones are.
|
|
64
|
+
win32: {
|
|
65
|
+
chrome: "chrome",
|
|
66
|
+
"chrome-beta": "chrome",
|
|
67
|
+
"chrome-canary": "chrome",
|
|
68
|
+
chromium: "chrome",
|
|
69
|
+
brave: "brave",
|
|
70
|
+
edge: "msedge",
|
|
71
|
+
vivaldi: "vivaldi",
|
|
72
|
+
},
|
|
73
|
+
// `comm`, which is what `pgrep -x` matches and what the packages install as.
|
|
74
|
+
linux: {
|
|
75
|
+
chrome: "chrome",
|
|
76
|
+
"chrome-beta": "chrome",
|
|
77
|
+
"chrome-canary": "chrome",
|
|
78
|
+
chromium: "chromium",
|
|
79
|
+
brave: "brave",
|
|
80
|
+
edge: "msedge",
|
|
81
|
+
vivaldi: "vivaldi-bin",
|
|
82
|
+
},
|
|
45
83
|
};
|
|
46
84
|
|
|
85
|
+
/** The process name for a browser on a platform, or null when this platform
|
|
86
|
+
* has no name for it — which is a different answer from "not running". */
|
|
87
|
+
export function processName(key, platform = process.platform) {
|
|
88
|
+
const table = APP_NAME[platform] ?? APP_NAME.linux;
|
|
89
|
+
return table[key] ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
47
92
|
/** Every address the relay currently resolves to.
|
|
48
93
|
*
|
|
49
94
|
* Empty is not an error — a machine with no `dig`, or one where the name is
|
|
@@ -139,7 +184,7 @@ export async function browserSurvey({
|
|
|
139
184
|
for (const root of roots) {
|
|
140
185
|
const installed = exists(root.root);
|
|
141
186
|
const profiles = installed ? profileDirs(root.root, deps.fs) : [];
|
|
142
|
-
const app =
|
|
187
|
+
const app = processName(root.key, platform);
|
|
143
188
|
const running = installed && app ? await isRunning(app, platform, deps) : false;
|
|
144
189
|
out.push({
|
|
145
190
|
key: root.key,
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
// The session that opened it is still attached and can still read every other
|
|
21
21
|
// tab. Only quitting takes anything back.
|
|
22
22
|
import { run } from "./exec.mjs";
|
|
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";
|
|
23
26
|
|
|
24
27
|
/** Reactions this platform can actually carry out, in the order the panel
|
|
25
28
|
* should offer them. Never a list the caller has to filter again. */
|
|
@@ -107,17 +110,29 @@ export async function notify(title, body, platform = process.platform, deps = {}
|
|
|
107
110
|
return r?.ok === true;
|
|
108
111
|
}
|
|
109
112
|
if (platform === "win32") {
|
|
110
|
-
//
|
|
111
|
-
//
|
|
113
|
+
// THE STRINGS GO THROUGH THE ENVIRONMENT, and the previous spelling could
|
|
114
|
+
// not have worked at all. PowerShell documents that a string `-Command`
|
|
115
|
+
// must be the LAST parameter: everything after it is appended to the
|
|
116
|
+
// command text. So `… -t <title> -b <body>` was not two parameters, it was
|
|
117
|
+
// more script — pasted after `…Show($x)`, where it failed to parse — and
|
|
118
|
+
// `param($t,$b)` cannot receive arguments through `-Command` in any case.
|
|
119
|
+
// The toast therefore never appeared on Windows, for the reaction that is
|
|
120
|
+
// the default.
|
|
121
|
+
//
|
|
122
|
+
// That mistake also put attacker-chosen text into a script. `body` carries
|
|
123
|
+
// `episode.host`, which is `new URL(row.url).host` out of the browser's own
|
|
124
|
+
// history — the whole premise of this feature is that somebody else may
|
|
125
|
+
// have opened that page. `$env:` reads it as data at runtime, which is the
|
|
126
|
+
// same discipline the argv paths above keep.
|
|
112
127
|
const r = await exec("powershell.exe", [
|
|
113
128
|
"-NoProfile", "-NonInteractive", "-Command",
|
|
114
|
-
"
|
|
129
|
+
"[void][Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime];"
|
|
115
130
|
+ "$x = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent(0);"
|
|
116
|
-
+ "$n = $x.GetElementsByTagName('text');
|
|
117
|
-
+ "$n.Item(
|
|
131
|
+
+ "$n = $x.GetElementsByTagName('text');"
|
|
132
|
+
+ "$n.Item(0).AppendChild($x.CreateTextNode($env:CCDECK_TOAST_TITLE)) > $null;"
|
|
133
|
+
+ "$n.Item(1).AppendChild($x.CreateTextNode($env:CCDECK_TOAST_BODY)) > $null;"
|
|
118
134
|
+ "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('ccdeck').Show($x)",
|
|
119
|
-
|
|
120
|
-
]).catch(() => null);
|
|
135
|
+
], { env: { ...process.env, CCDECK_TOAST_TITLE: title, CCDECK_TOAST_BODY: body } }).catch(() => null);
|
|
121
136
|
return r?.ok === true;
|
|
122
137
|
}
|
|
123
138
|
const r = await exec("notify-send", [title, body]).catch(() => null);
|
|
@@ -151,11 +166,18 @@ export async function quitBrowser(browserKey, platform = process.platform, deps
|
|
|
151
166
|
]).catch(() => null);
|
|
152
167
|
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "script_failed" };
|
|
153
168
|
}
|
|
169
|
+
// NOT THE DISPLAY NAME WITH ITS SPACES REMOVED. `"Google Chrome"` became
|
|
170
|
+
// `GoogleChrome.exe` and `google-chrome`, and neither is a process on either
|
|
171
|
+
// platform — so this reaction was offered on Windows and Linux and could
|
|
172
|
+
// never once have worked. The names live in browser-presence, which is where
|
|
173
|
+
// the other probe reads them from, so the two cannot drift apart.
|
|
174
|
+
const proc = processName(browserKey, platform);
|
|
175
|
+
if (!proc) return { ok: false, reason: "unknown_browser" };
|
|
154
176
|
if (platform === "win32") {
|
|
155
|
-
const r = await exec("taskkill", ["/IM", `${
|
|
177
|
+
const r = await exec("taskkill", ["/IM", `${proc}.exe`, "/F"]).catch(() => null);
|
|
156
178
|
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "taskkill_failed" };
|
|
157
179
|
}
|
|
158
|
-
const r = await exec("pkill", ["-x",
|
|
180
|
+
const r = await exec("pkill", ["-x", proc]).catch(() => null);
|
|
159
181
|
return { ok: r?.ok === true, reason: r?.ok ? "quit" : "pkill_failed" };
|
|
160
182
|
}
|
|
161
183
|
|
|
@@ -48,7 +48,7 @@ const STORE_VERSION = 2;
|
|
|
48
48
|
* person could open and read. Trimmed oldest-first. */
|
|
49
49
|
const KEEP = 500;
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
const storeDir = (home = claudeConfigDir()) => join(home, "agent-dag", "browser-watch");
|
|
52
52
|
export const storePath = (home = claudeConfigDir()) => join(storeDir(home), "state.json");
|
|
53
53
|
|
|
54
54
|
/** The plain-text log, which is the one file here a person opens themselves.
|
|
@@ -223,6 +223,22 @@ export async function readStore(home = claudeConfigDir(), deps = {}) {
|
|
|
223
223
|
* driven — the one file whose loss this feature cannot absorb. installer.mjs
|
|
224
224
|
* makes the same argument about settings.json, for the same reason.
|
|
225
225
|
*/
|
|
226
|
+
/**
|
|
227
|
+
* One writer at a time, in this process.
|
|
228
|
+
*
|
|
229
|
+
* Three call sites write this file — the poll's snapshot, the settings route
|
|
230
|
+
* and the dismiss route — and none of them knew about the others. The queue is
|
|
231
|
+
* the same shape `log-writer.mjs` uses for its appends: a promise chain that
|
|
232
|
+
* survives a rejection, so one failed write cannot wedge every later one.
|
|
233
|
+
*/
|
|
234
|
+
let _chain = Promise.resolve();
|
|
235
|
+
let _writeSeq = 0;
|
|
236
|
+
function serialized(job) {
|
|
237
|
+
const started = _chain.then(job, job);
|
|
238
|
+
_chain = started.then(() => {}, () => {});
|
|
239
|
+
return started;
|
|
240
|
+
}
|
|
241
|
+
|
|
226
242
|
/**
|
|
227
243
|
* Write the whole store, atomically.
|
|
228
244
|
*
|
|
@@ -235,6 +251,11 @@ export async function readStore(home = claudeConfigDir(), deps = {}) {
|
|
|
235
251
|
* test that greps this file's callers for the field.
|
|
236
252
|
*/
|
|
237
253
|
export async function writeStore(state, home = claudeConfigDir(), deps = {}) {
|
|
254
|
+
return serialized(() => writeNow(state, home, deps));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The write itself, already inside the queue. */
|
|
258
|
+
async function writeNow(state, home, deps) {
|
|
238
259
|
const mk = deps.mkdir ?? mkdir;
|
|
239
260
|
const write = deps.writeFile ?? writeFile;
|
|
240
261
|
const mv = deps.rename ?? rename;
|
|
@@ -245,11 +266,44 @@ export async function writeStore(state, home = claudeConfigDir(), deps = {}) {
|
|
|
245
266
|
episodes: (state.episodes ?? []).map(archivable),
|
|
246
267
|
dismissed: [...new Set(state.dismissed ?? [])].slice(-DISMISS_KEEP),
|
|
247
268
|
}, null, 2) + "\n";
|
|
248
|
-
|
|
269
|
+
// A NAME NO SECOND WRITE CAN BE USING. The pid distinguishes decks and not
|
|
270
|
+
// the calls inside one, and there are three writers in this process — the
|
|
271
|
+
// poll's snapshot, the settings route and the dismiss route — with nothing
|
|
272
|
+
// between them. Measured with a full 500-episode archive (~2.5 MB, past the
|
|
273
|
+
// 512 KiB writeFile chunk): eight concurrent runs left state.json unparseable
|
|
274
|
+
// in six of them and failed one call with ENOENT, renaming a temp file the
|
|
275
|
+
// other writer had already renamed away. readStore swallows a corrupt file,
|
|
276
|
+
// so the next poll reported an empty archive and no dismissals at all — total
|
|
277
|
+
// loss of the one file this feature exists to keep.
|
|
278
|
+
const tmp = `${storePath(home)}.${process.pid}.${++_writeSeq}.tmp`;
|
|
249
279
|
await write(tmp, body, "utf8");
|
|
250
280
|
await mv(tmp, storePath(home));
|
|
251
281
|
}
|
|
252
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Read, change, write — with nothing else writing in between.
|
|
285
|
+
*
|
|
286
|
+
* `writeStore` writes what it is handed and merges nothing, which is right for
|
|
287
|
+
* a whole-state write and wrong for a caller that owns one field. The snapshot
|
|
288
|
+
* takes about 400ms — a 21 MB History copy plus the sqlite read — and used to
|
|
289
|
+
* write back the `dismissed` and `settings` it had read at the start, so a
|
|
290
|
+
* dismissal made while it ran was reverted by the next poll ten seconds later.
|
|
291
|
+
* The settings route and the dismiss route had the same shape against each
|
|
292
|
+
* other.
|
|
293
|
+
*
|
|
294
|
+
* So a caller that owns one field passes a function instead: it runs inside the
|
|
295
|
+
* same queue the write does, against the state on disk at that moment, and no
|
|
296
|
+
* other writer can slip between the read and the write.
|
|
297
|
+
*/
|
|
298
|
+
export async function updateStore(mutate, home = claudeConfigDir(), deps = {}) {
|
|
299
|
+
return serialized(async () => {
|
|
300
|
+
const current = await readStore(home, deps);
|
|
301
|
+
const next = (await mutate(current)) ?? current;
|
|
302
|
+
await writeNow(next, home, deps);
|
|
303
|
+
return next;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
253
307
|
/**
|
|
254
308
|
* The archive with `seen` folded into it, newest first and capped.
|
|
255
309
|
*
|
|
@@ -27,14 +27,14 @@
|
|
|
27
27
|
// is running, so a deck that was closed all weekend still answers Monday's
|
|
28
28
|
// question completely — which is why there is no process to keep alive and no
|
|
29
29
|
// gap to apologise for.
|
|
30
|
-
import {
|
|
30
|
+
import { statSync } from "node:fs";
|
|
31
31
|
import { readdir, readFile } from "node:fs/promises";
|
|
32
32
|
import { join } from "node:path";
|
|
33
33
|
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
34
34
|
import { discoverProfiles } from "./browser-profiles.mjs";
|
|
35
|
-
import { readVisitsSince } from "./browser-history.mjs";
|
|
35
|
+
import { msToChromeTime, readVisitsSince } from "./browser-history.mjs";
|
|
36
36
|
import { classify, toEpisodes, defaultExclusions, isProgramNavigation } from "./agent-activity.mjs";
|
|
37
|
-
import { appendLog, logPath, mergeEpisodes, readStore, undismissed, writeStore } from "./browser-watch-store.mjs";
|
|
37
|
+
import { appendLog, logPath, mergeEpisodes, readStore, undismissed, updateStore, writeStore } from "./browser-watch-store.mjs";
|
|
38
38
|
import { browserSurvey } from "./browser-presence.mjs";
|
|
39
39
|
import { available, performable, react } from "./browser-react.mjs";
|
|
40
40
|
import { RELAY_HOST } from "./relay-guard.mjs";
|
|
@@ -86,6 +86,22 @@ function mtimeMs(file, deps) {
|
|
|
86
86
|
* schedule. `stale` is reported so the panel can say when it last actually
|
|
87
87
|
* looked rather than implying the answer is a live one.
|
|
88
88
|
*/
|
|
89
|
+
/**
|
|
90
|
+
* Is that pid still running?
|
|
91
|
+
*
|
|
92
|
+
* A bare `catch { continue; }` used to stand where this is called, which threw
|
|
93
|
+
* away the one distinction that matters: a process this account may not signal
|
|
94
|
+
* answers EPERM on POSIX and EACCES on Windows (libuv maps
|
|
95
|
+
* ERROR_ACCESS_DENIED), and both mean ALIVE. Treating them as gone made an
|
|
96
|
+
* elevated deck invisible to the writer election below, which is how a machine
|
|
97
|
+
* ends up with two elected writers — duplicate log lines, duplicate reactions,
|
|
98
|
+
* and two writers racing the same rename.
|
|
99
|
+
*/
|
|
100
|
+
function pidAlive(pid) {
|
|
101
|
+
try { process.kill(pid, 0); return true; }
|
|
102
|
+
catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
|
|
103
|
+
}
|
|
104
|
+
|
|
89
105
|
async function visitsFor(profile, { sinceChromeTime, copyDir, deps = {} }) {
|
|
90
106
|
const stamp = mtimeMs(profile.historyPath, deps);
|
|
91
107
|
if (stamp === null) return { rows: [], degraded: true, reason: "no-history-file", stamp: null };
|
|
@@ -227,7 +243,7 @@ export async function registeredDeckPorts(deps = {}) {
|
|
|
227
243
|
try {
|
|
228
244
|
const d = JSON.parse(await readFile(join(dir, f), "utf8"));
|
|
229
245
|
if (typeof d?.pid !== "number" || typeof d?.port !== "number") continue;
|
|
230
|
-
|
|
246
|
+
if (!pidAlive(d.pid)) continue;
|
|
231
247
|
ports.push(d.port);
|
|
232
248
|
} catch { /* corrupt, or gone between listing and read */ }
|
|
233
249
|
}
|
|
@@ -283,7 +299,7 @@ function note(level, text, atMs = Date.now(), parts = null) {
|
|
|
283
299
|
if (logLines.length > LOG_MAX) logLines.length = LOG_MAX;
|
|
284
300
|
}
|
|
285
301
|
|
|
286
|
-
|
|
302
|
+
function watchLog() {
|
|
287
303
|
return logLines.slice();
|
|
288
304
|
}
|
|
289
305
|
|
|
@@ -341,7 +357,7 @@ async function isReactingDeck(deps = {}) {
|
|
|
341
357
|
// than by a version comparison this would otherwise have to keep.
|
|
342
358
|
if (d.watch !== true) continue;
|
|
343
359
|
// A record whose process is gone is a leftover, not a rival.
|
|
344
|
-
|
|
360
|
+
if (!pidAlive(d.pid)) continue;
|
|
345
361
|
if (!best || d.port < best.port || (d.port === best.port && d.pid < best.pid)) best = d;
|
|
346
362
|
} catch { /* corrupt, or gone between listing and read */ }
|
|
347
363
|
}
|
|
@@ -438,6 +454,24 @@ export async function browserWatchSnapshot({
|
|
|
438
454
|
now = Date.now(),
|
|
439
455
|
platform = process.platform,
|
|
440
456
|
env = process.env,
|
|
457
|
+
// WHETHER TO READ THE BROWSERS AT ALL ON THIS CALL.
|
|
458
|
+
//
|
|
459
|
+
// The panel's badge polls this route every five minutes from the moment the
|
|
460
|
+
// page loads, and the read is not free or invisible: it copies each Chromium
|
|
461
|
+
// profile's whole History database into a temp file, queries it, and deletes
|
|
462
|
+
// the copy. That happened whether or not the watch was switched on, because
|
|
463
|
+
// `enabled` gates only what is KEPT and what is REACTED to.
|
|
464
|
+
//
|
|
465
|
+
// A switch that says off while the deck goes on copying the user's browsing
|
|
466
|
+
// history every five minutes is not a switch. So the background poll asks for
|
|
467
|
+
// `live=0`, and with the watch off that is honoured: the archive answers, and
|
|
468
|
+
// nothing touches a browser. Two things still read live — a watch that is ON,
|
|
469
|
+
// because recording in the background is the whole feature, and the panel
|
|
470
|
+
// itself, because that is the user looking.
|
|
471
|
+
//
|
|
472
|
+
// Named for what it does rather than `live`, which is already the local name
|
|
473
|
+
// for the episodes this poll built.
|
|
474
|
+
readBrowsers = true,
|
|
441
475
|
deps = {},
|
|
442
476
|
} = {}) {
|
|
443
477
|
// The store answers first and the caller's arguments override it, so the
|
|
@@ -458,6 +492,36 @@ export async function browserWatchSnapshot({
|
|
|
458
492
|
if (quietMs === undefined) quietMs = minutes(store.settings.quietMinutes);
|
|
459
493
|
if (gapMs === undefined) gapMs = minutes(store.settings.gapMinutes);
|
|
460
494
|
|
|
495
|
+
// The archive alone, for a poll that has not asked to look and a watch that
|
|
496
|
+
// is not recording. Everything below this line reads browsers.
|
|
497
|
+
if (!readBrowsers && !enabled) {
|
|
498
|
+
const archived = undismissed(store.episodes, store.dismissed);
|
|
499
|
+
return {
|
|
500
|
+
ok: true,
|
|
501
|
+
settings: store.settings,
|
|
502
|
+
reactions: available(platform),
|
|
503
|
+
log: watchLog(),
|
|
504
|
+
profiles: [],
|
|
505
|
+
browsers: [],
|
|
506
|
+
episodes: archived,
|
|
507
|
+
coverage: {
|
|
508
|
+
startedMs: STARTED_MS,
|
|
509
|
+
oldestVisitMs: null,
|
|
510
|
+
lastHumanMs: null,
|
|
511
|
+
quietMs: quietMs ?? 15 * 60_000,
|
|
512
|
+
logPath: logPath(),
|
|
513
|
+
checkedMs: _checkedMs,
|
|
514
|
+
checks: _checks,
|
|
515
|
+
archived: archived.length,
|
|
516
|
+
now,
|
|
517
|
+
// Said rather than implied: a reader who wonders why the profile list
|
|
518
|
+
// is empty gets the reason, in the same word the switch uses.
|
|
519
|
+
why: "the watch is off, so no browser was read on this poll",
|
|
520
|
+
},
|
|
521
|
+
degraded: false,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
461
525
|
const profiles = (deps.discoverProfiles ?? discoverProfiles)(platform, env, undefined, deps.fs);
|
|
462
526
|
// Fixed for the life of the process, which is also what keeps the mtime cache
|
|
463
527
|
// working: a floor computed from `now` moves every millisecond and would land
|
|
@@ -465,9 +529,11 @@ export async function browserWatchSnapshot({
|
|
|
465
529
|
// it re-read and re-copied every database on every request while looking
|
|
466
530
|
// perfectly correct, because only the cost was wrong.
|
|
467
531
|
const sinceMs = STARTED_MS;
|
|
468
|
-
// Chrome counts microseconds from 1601
|
|
469
|
-
//
|
|
470
|
-
|
|
532
|
+
// Chrome counts microseconds from 1601, and `msToChromeTime` is where that
|
|
533
|
+
// conversion lives — its own doc names this caller. The inline copy that used
|
|
534
|
+
// to stand here duplicated both constants and, unlike the helper, had no
|
|
535
|
+
// guard for a non-finite input: it threw where the helper returns "0".
|
|
536
|
+
const sinceChromeTime = msToChromeTime(sinceMs);
|
|
471
537
|
|
|
472
538
|
const exclude = defaultExclusions(deckOrigins);
|
|
473
539
|
const opts = {};
|
|
@@ -632,7 +698,21 @@ export async function browserWatchSnapshot({
|
|
|
632
698
|
if (fresh.length > 0) {
|
|
633
699
|
note("find", `${fresh.length} new episode${fresh.length === 1 ? "" : "s"} · ${kept.length} kept`, now);
|
|
634
700
|
}
|
|
635
|
-
|
|
701
|
+
// THE ARCHIVE IS OURS TO WRITE; THE OTHER TWO FIELDS ARE NOT. This poll
|
|
702
|
+
// takes about 400ms — a 21 MB History copy plus the sqlite read — and it
|
|
703
|
+
// used to write back the `dismissed` and `settings` it had read at the
|
|
704
|
+
// start, so a dismissal or a watch-off toggle made while it ran was
|
|
705
|
+
// reverted ten seconds later by the next poll. Re-merging inside the update
|
|
706
|
+
// keeps this function's own answer and takes the other two from disk as
|
|
707
|
+
// they are at the moment of the write.
|
|
708
|
+
const merge = cur => ({
|
|
709
|
+
settings: cur.settings,
|
|
710
|
+
episodes: mergeEpisodes(cur.episodes, live, now),
|
|
711
|
+
dismissed: cur.dismissed,
|
|
712
|
+
});
|
|
713
|
+
if (deps.updateStore) await deps.updateStore(merge, undefined, deps);
|
|
714
|
+
else if (deps.writeStore) await deps.writeStore(merge(store), undefined, deps);
|
|
715
|
+
else await updateStore(merge, undefined, deps);
|
|
636
716
|
await (deps.appendLog ?? appendLog)(fresh, undefined, deps);
|
|
637
717
|
|
|
638
718
|
// REACT ONLY TO WHAT IS NEW, AND ONLY ONCE. `fresh` is the set that was not
|
package/src/server/ccusage.mjs
CHANGED
|
@@ -19,7 +19,11 @@ import { killTree, pathLookup, shimPath, spawnSpec } from "./exec.mjs";
|
|
|
19
19
|
import { oneLine, termColumns } from "./term.mjs";
|
|
20
20
|
import { PRODUCT } from "./brand.mjs";
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
// 60s, and it is the panel's poll interval rather than a number of its own: the
|
|
23
|
+
// Usage panel asks once a minute and expects a reading that has actually moved,
|
|
24
|
+
// so a longer cache would hand the same figure back and make the interval a
|
|
25
|
+
// lie. The modal is manual-open and unaffected either way.
|
|
26
|
+
const CACHE_MS = 60_000;
|
|
23
27
|
const TIMEOUT_MS = 90_000;
|
|
24
28
|
const INSTALL_TIMEOUT_MS = 120_000; // first-run npm install can be slow
|
|
25
29
|
const UPDATE_CHECK_MS = 24 * 3600_000; // check npm for a newer ccusage once/day
|
|
@@ -823,6 +827,34 @@ let _byAgentUnsupported = false;
|
|
|
823
827
|
*/
|
|
824
828
|
const blamesByAgent = (text) => /by-agent/i.test(String(text ?? ""));
|
|
825
829
|
|
|
830
|
+
/**
|
|
831
|
+
* BOTH REPORTS FROM ONE LOAD.
|
|
832
|
+
*
|
|
833
|
+
* `daily` answers "what did this range cost" and `session` answers "which
|
|
834
|
+
* session spent it". They used to be two commands and therefore two children —
|
|
835
|
+
* two npx resolutions, two Node starts, and two full walks of every transcript
|
|
836
|
+
* on the machine for the same set of files.
|
|
837
|
+
*
|
|
838
|
+
* `--sections` is ccusage's own answer to that: one load, several report
|
|
839
|
+
* sections in one JSON object. Measured against this machine's logs, asking for
|
|
840
|
+
* `daily,session` returns exactly what the two runs returned — `daily` (with
|
|
841
|
+
* its `agents` split when `--by-agent` rides along), `session`, and one
|
|
842
|
+
* `totals` — so the deck reads the same fields off one child.
|
|
843
|
+
*
|
|
844
|
+
* Not on `session` alone: the panel's totals and its per-model split come from
|
|
845
|
+
* `daily`, so `daily` is the command and the sessions are the extra section.
|
|
846
|
+
*/
|
|
847
|
+
const SECTIONS = ["--sections", "daily,session"];
|
|
848
|
+
const blamesSections = (text) => /sections/i.test(String(text ?? ""));
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* A ccusage too old for `--sections`, remembered for the life of the process —
|
|
852
|
+
* the same narrow memory `_byAgentUnsupported` keeps, for the same reason: the
|
|
853
|
+
* retry costs one process on a machine that is already failing, and the memory
|
|
854
|
+
* costs the extra section for as long as the deck runs.
|
|
855
|
+
*/
|
|
856
|
+
let _sectionsUnsupported = false;
|
|
857
|
+
|
|
826
858
|
/**
|
|
827
859
|
* Run `daily` for a range, asking for the per-agent split, and give up the
|
|
828
860
|
* split rather than the whole answer when this ccusage will not produce one.
|
|
@@ -937,6 +969,38 @@ export async function fetchCcusageDaily({ since, until, force = false } = {}) {
|
|
|
937
969
|
* waited its turn. With an empty queue that is the same instant the caller
|
|
938
970
|
* asked, which is what this always did.
|
|
939
971
|
*/
|
|
972
|
+
/**
|
|
973
|
+
* The same range, grouped by session rather than by day.
|
|
974
|
+
*
|
|
975
|
+
* Returns `[]` for every failure, including a ccusage too old to have the
|
|
976
|
+
* subcommand. The panel's totals and its per-model split come from `daily`, and
|
|
977
|
+
* losing the session names must not lose those — an empty list draws one
|
|
978
|
+
* section short, which is the same thing that happens on a machine with no
|
|
979
|
+
* ccusage at all and is already a state the panel knows.
|
|
980
|
+
*
|
|
981
|
+
* WHAT `period` IS HERE, and it is the whole reason this is worth a second
|
|
982
|
+
* child: on a session row ccusage puts the SESSION ID in `period` — the same
|
|
983
|
+
* uuid Claude Code writes into every hook payload, and therefore the same key
|
|
984
|
+
* the canvas already files its agents under. So these rows join to the board by
|
|
985
|
+
* id, which is what lets the panel show ccusage's money against the deck's own
|
|
986
|
+
* project names. Without that join a session row is a uuid and a number.
|
|
987
|
+
*/
|
|
988
|
+
async function readSessions(sinceArg, until) {
|
|
989
|
+
try {
|
|
990
|
+
const args = ["session", "--json", "--since", sinceArg];
|
|
991
|
+
if (until) args.push("--until", until);
|
|
992
|
+
const ran = await runCcusage(args);
|
|
993
|
+
const raw = extractJson(ran.out);
|
|
994
|
+
// `session`, singular — ccusage names the array after the command, not
|
|
995
|
+
// after its contents, and `sessions` reads as the obvious guess and is
|
|
996
|
+
// always undefined.
|
|
997
|
+
return Array.isArray(raw.session) ? raw.session : [];
|
|
998
|
+
} catch (err) {
|
|
999
|
+
note("session read failed", err);
|
|
1000
|
+
return [];
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
|
|
940
1004
|
async function readRange(sinceArg, until, key) {
|
|
941
1005
|
const now = Date.now();
|
|
942
1006
|
let result;
|
|
@@ -944,8 +1008,40 @@ async function readRange(sinceArg, until, key) {
|
|
|
944
1008
|
try {
|
|
945
1009
|
const args = ["daily", "--json", "--since", sinceArg];
|
|
946
1010
|
if (until) args.push("--until", until);
|
|
947
|
-
|
|
948
|
-
|
|
1011
|
+
let raw;
|
|
1012
|
+
if (_sectionsUnsupported) {
|
|
1013
|
+
ran = await runDaily(args);
|
|
1014
|
+
raw = extractJson(ran.out);
|
|
1015
|
+
} else {
|
|
1016
|
+
try {
|
|
1017
|
+
ran = await runDaily([...args, ...SECTIONS]);
|
|
1018
|
+
raw = extractJson(ran.out);
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
// A ccusage that could not run at all fails the same way with or
|
|
1021
|
+
// without the flag, so only a complaint naming the flag is worth a
|
|
1022
|
+
// second child — and `reason` set means the run never started, which is
|
|
1023
|
+
// not something a flag can fix.
|
|
1024
|
+
if (err?.reason !== undefined || !blamesSections(err?.message)) throw err;
|
|
1025
|
+
_sectionsUnsupported = true;
|
|
1026
|
+
ran = await runDaily(args);
|
|
1027
|
+
raw = extractJson(ran.out);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
// Already here on any ccusage that knows `--sections`: one load answered
|
|
1031
|
+
// both questions. The second child is the fallback for the older ones, and
|
|
1032
|
+
// it stays deliberately after the first rather than beside it — a failure
|
|
1033
|
+
// to name the sessions must not cost the totals, which are what the panel
|
|
1034
|
+
// is mostly for, and two ccusage processes at once on a cold machine is the
|
|
1035
|
+
// shape #476 spent a release removing from the boot path.
|
|
1036
|
+
// The second child belongs to the OLD ccusage and to nothing else. A build
|
|
1037
|
+
// that took `--sections` answered with the section — empty for a range with
|
|
1038
|
+
// no sessions in it — so a missing `session` key there means this deck
|
|
1039
|
+
// asked for something that build does not report, and asking again as a
|
|
1040
|
+
// separate command would get the same silence for the price of a second
|
|
1041
|
+
// walk of every transcript on the machine.
|
|
1042
|
+
const sessions = Array.isArray(raw.session)
|
|
1043
|
+
? raw.session
|
|
1044
|
+
: (_sectionsUnsupported ? await readSessions(sinceArg, until) : []);
|
|
949
1045
|
// Passed through whole, `agents` array and all. Every day ccusage returns
|
|
950
1046
|
// under `--by-agent` is a superset of the day it returns without one, so
|
|
951
1047
|
// there is nothing here to reshape: the browser reads the merged totals it
|
|
@@ -957,6 +1053,7 @@ async function readRange(sinceArg, until, key) {
|
|
|
957
1053
|
result = {
|
|
958
1054
|
ok: true,
|
|
959
1055
|
days,
|
|
1056
|
+
sessions,
|
|
960
1057
|
totals: raw.totals ?? null,
|
|
961
1058
|
since: sinceArg,
|
|
962
1059
|
until: until ?? null,
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// optional, unknown values pass through verbatim, and a `partial` flag tells
|
|
9
9
|
// the UI when something was dropped instead of silently showing less.
|
|
10
10
|
import { readFile } from "node:fs/promises";
|
|
11
|
+
// One clamp for both quota readers: see the note on cooldownFromHeader.
|
|
12
|
+
import { cooldownFromHeader } from "./quota.mjs";
|
|
11
13
|
import { join } from "node:path";
|
|
12
14
|
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
13
15
|
import { getCodexAuth, forceCodexRefresh, isCredentialHost } from "./codex-auth.mjs";
|
|
@@ -335,8 +337,10 @@ async function doFetchCodexQuota() {
|
|
|
335
337
|
// than the ordinary floor before asking again. `retry-after` is honoured when
|
|
336
338
|
// the backend sends one, because it knows better than the constant does.
|
|
337
339
|
const cooldown = (res) => {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
+
// Clamped, for the reason quota.mjs states at cooldownFromHeader: a `0`
|
|
341
|
+
// defeats the cooldown a 429 exists to impose, and a day freezes this
|
|
342
|
+
// poller for the life of the process.
|
|
343
|
+
_rateLimitedUntil = Date.now() + cooldownFromHeader(res?.headers?.get?.("retry-after"), COOLDOWN_MS);
|
|
340
344
|
};
|
|
341
345
|
|
|
342
346
|
let auth, base, res;
|