agent-dag 3.2.1 → 3.3.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-GLqupad3.css +1 -0
- package/dist/web/assets/index-Z0vOy1A6.js +165 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +60 -14
- package/package.json +6 -3
- package/release-notes.json +26 -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 +95 -2
- 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
package/src/server/index.mjs
CHANGED
|
@@ -662,7 +662,7 @@ export const MAX_SCAN_CHUNK = 8 * 1024 * 1024;
|
|
|
662
662
|
// pointing one POST at something arbitrarily large: the read still terminates,
|
|
663
663
|
// the cursor keeps whatever it reached, and the next throttled pass continues
|
|
664
664
|
// from there.
|
|
665
|
-
|
|
665
|
+
const MAX_SCAN_BYTES_PER_PASS = 256 * 1024 * 1024;
|
|
666
666
|
|
|
667
667
|
const NEWLINE = 0x0a;
|
|
668
668
|
|
|
@@ -1458,7 +1458,7 @@ const pendingNameReads = new Set(); // sid currently being read
|
|
|
1458
1458
|
/** The naming the cursor has folded so far, or null when the scan has nothing.
|
|
1459
1459
|
* Exported beside readContextFromTranscript for the same reason: the rule is
|
|
1460
1460
|
* worth pinning directly rather than through a live server. */
|
|
1461
|
-
|
|
1461
|
+
async function readSessionNamingFromTranscript(path) {
|
|
1462
1462
|
const state = await scanTranscript(path);
|
|
1463
1463
|
if (!state) return null;
|
|
1464
1464
|
if (!state.agentName && !state.aiTitle) return null;
|
|
@@ -3470,7 +3470,13 @@ export function replayScope(workspace, platform = process.platform) {
|
|
|
3470
3470
|
* that case cheap needs an index of where a workspace's lines are, which is a
|
|
3471
3471
|
* different change from this one.
|
|
3472
3472
|
*/
|
|
3473
|
-
|
|
3473
|
+
/* Exported for the suite, with both ceilings as parameters. The byte budget is
|
|
3474
|
+
* 128 MiB, so a test that wanted to reach it honestly would have to write 128
|
|
3475
|
+
* MiB — which is why the count bound was the only one anything pinned, and why
|
|
3476
|
+
* the byte bound was the one that broke. Production passes neither argument. */
|
|
3477
|
+
export async function replayLog(filePath, workspace = "", {
|
|
3478
|
+
maxEvents = MAX_BUFFER, maxChars = MAX_BUFFER_CHARS,
|
|
3479
|
+
} = {}) {
|
|
3474
3480
|
if (!existsSync(filePath)) return 0;
|
|
3475
3481
|
let skipped = 0;
|
|
3476
3482
|
let skippedBytes = 0;
|
|
@@ -3499,17 +3505,32 @@ async function replayLog(filePath, workspace = "") {
|
|
|
3499
3505
|
}
|
|
3500
3506
|
} else {
|
|
3501
3507
|
// Newest first, so this is filled back to front and then walked in reverse
|
|
3502
|
-
// to push. Bounded by
|
|
3503
|
-
// property of the ring rather than of the file.
|
|
3508
|
+
// to push. Bounded by BOTH of the ring's limits, which is what makes the
|
|
3509
|
+
// memory here a property of the ring rather than of the file.
|
|
3510
|
+
//
|
|
3511
|
+
// The count alone was not enough, and the comment that used to say it was
|
|
3512
|
+
// predates #625. Eviction is the only thing that applies MAX_BUFFER_CHARS,
|
|
3513
|
+
// and eviction happens inside pushEvent — which does not run until this
|
|
3514
|
+
// array is already full. Measured on a 187 MB log of 40 events of 4.9M
|
|
3515
|
+
// characters each, every one of them under the ingest cap: RSS went from
|
|
3516
|
+
// 215 MB to a peak of 505 MB, and the ring that survived held 27 events and
|
|
3517
|
+
// 126 MiB. About 290 MB staged for a ring capped at 128.
|
|
3518
|
+
//
|
|
3519
|
+
// Rotation at 50 MB normally keeps logs well under this, but rotation is
|
|
3520
|
+
// best-effort and its failure is only logged, and this file's own header
|
|
3521
|
+
// records logs reaching gigabytes.
|
|
3504
3522
|
const newestFirst = [];
|
|
3523
|
+
let stagedChars = 0;
|
|
3505
3524
|
for await (const line of linesFromEnd(filePath)) {
|
|
3506
3525
|
if (!line) continue;
|
|
3507
3526
|
const evt = parse(line);
|
|
3508
3527
|
if (!usable(evt) || !admits(evt.payload)) continue;
|
|
3509
3528
|
newestFirst.push(evt);
|
|
3529
|
+
stagedChars += ENVELOPE_CHARS + payloadChars(evt.payload);
|
|
3510
3530
|
// Everything older than this would be evicted by the events already held,
|
|
3511
3531
|
// so reading further is work whose only result is throwing it away.
|
|
3512
|
-
|
|
3532
|
+
// Either limit reaching its ceiling means exactly that.
|
|
3533
|
+
if (newestFirst.length >= maxEvents || stagedChars >= maxChars) break;
|
|
3513
3534
|
}
|
|
3514
3535
|
for (let i = newestFirst.length - 1; i >= 0; i--) replay(newestFirst[i]);
|
|
3515
3536
|
count = newestFirst.length;
|
|
@@ -4116,7 +4137,7 @@ async function handleBrowserWatchSettings(req, res) {
|
|
|
4116
4137
|
try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
|
|
4117
4138
|
if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
|
|
4118
4139
|
|
|
4119
|
-
const { readStore,
|
|
4140
|
+
const { readStore, updateStore, normalise } = await import(
|
|
4120
4141
|
pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
|
|
4121
4142
|
);
|
|
4122
4143
|
const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
|
|
@@ -4132,7 +4153,11 @@ async function handleBrowserWatchSettings(req, res) {
|
|
|
4132
4153
|
// this field ERASES it. Measured — changing the reaction wiped every
|
|
4133
4154
|
// dismissal, so every episode the reader had reviewed came straight back on
|
|
4134
4155
|
// the next poll, from a settings change that had nothing to do with them.
|
|
4135
|
-
|
|
4156
|
+
// Only the settings are this route's to change. `updateStore` re-reads inside
|
|
4157
|
+
// the write queue, so a poll that landed between the read above and this line
|
|
4158
|
+
// cannot have its archive thrown away by a settings change — which is what
|
|
4159
|
+
// writing a whole state read seconds earlier used to do.
|
|
4160
|
+
await updateStore(cur => ({ ...cur, settings }));
|
|
4136
4161
|
// The one line in the log that is somebody acting rather than the deck
|
|
4137
4162
|
// reading, which is exactly why it is worth its own entry.
|
|
4138
4163
|
if (settings.enabled !== store.settings.enabled) {
|
|
@@ -4168,7 +4193,7 @@ async function handleBrowserWatchDismiss(req, res) {
|
|
|
4168
4193
|
const startMs = typeof body?.startMs === "number" && Number.isFinite(body.startMs) ? body.startMs : null;
|
|
4169
4194
|
if (host === null || startMs === null) return send(res, 400, { ok: false, reason: "bad_request" });
|
|
4170
4195
|
|
|
4171
|
-
const { readStore,
|
|
4196
|
+
const { readStore, updateStore, episodeKey } = await import(
|
|
4172
4197
|
pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
|
|
4173
4198
|
);
|
|
4174
4199
|
const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
|
|
@@ -4177,7 +4202,7 @@ async function handleBrowserWatchDismiss(req, res) {
|
|
|
4177
4202
|
const store = await readStore();
|
|
4178
4203
|
const key = episodeKey(host, startMs);
|
|
4179
4204
|
const dismissed = [...new Set([...(store.dismissed ?? []), key])];
|
|
4180
|
-
await
|
|
4205
|
+
await updateStore(cur => ({ ...cur, dismissed }));
|
|
4181
4206
|
// The reader acting on their own list, which is exactly the kind of line the
|
|
4182
4207
|
// `act` level exists for.
|
|
4183
4208
|
noteWatchSetting(`dismissed ${host}`);
|
|
@@ -4188,6 +4213,12 @@ async function handleBrowserWatchDismiss(req, res) {
|
|
|
4188
4213
|
async function handleBrowserWatch(req, res) {
|
|
4189
4214
|
const url = new URL(req.url, "http://localhost");
|
|
4190
4215
|
const force = url.searchParams.get("refresh") === "1";
|
|
4216
|
+
// `live=0` is the badge's five-minute poll saying "the archive is enough".
|
|
4217
|
+
// With the watch OFF that is honoured and no browser is read at all — see
|
|
4218
|
+
// browserWatchSnapshot: the switch used to gate only what was KEPT, so a deck
|
|
4219
|
+
// nobody had switched on still copied every History database every five
|
|
4220
|
+
// minutes. A forced read overrides it, because that is the user pressing ↻.
|
|
4221
|
+
const readBrowsers = force || url.searchParams.get("live") !== "0";
|
|
4191
4222
|
|
|
4192
4223
|
// Numbers from a query string are refused rather than coerced: NaN would
|
|
4193
4224
|
// silently widen the quiet gate to "everything counts", which is the failure
|
|
@@ -4217,6 +4248,7 @@ async function handleBrowserWatch(req, res) {
|
|
|
4217
4248
|
const ports = await registeredDeckPorts();
|
|
4218
4249
|
return send(res, 200, await fetchBrowserWatch({
|
|
4219
4250
|
force,
|
|
4251
|
+
readBrowsers,
|
|
4220
4252
|
deckOrigins: deckOwnOrigins(undefined, ports),
|
|
4221
4253
|
quietMs: minutes("quiet"),
|
|
4222
4254
|
gapMs: minutes("gap"),
|
|
@@ -4676,9 +4708,19 @@ function handleHookChallenge(_req, res, url) {
|
|
|
4676
4708
|
send(res, 200, { proof: challengeProof(HOOK_TOKEN, nonce) });
|
|
4677
4709
|
}
|
|
4678
4710
|
|
|
4711
|
+
// Signal 0 delivers nothing; it asks whether the pid could be signalled.
|
|
4712
|
+
//
|
|
4713
|
+
// BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
|
|
4714
|
+
// answers EPERM for a process this account may not signal. On Windows
|
|
4715
|
+
// `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
|
|
4716
|
+
// maps that to EACCES — so a deck started from an elevated terminal, or under
|
|
4717
|
+
// another account, read as DEAD to every probe in this repo. What followed was
|
|
4718
|
+
// silent: the live deck's discovery file was unlinked on the next hook fire,
|
|
4719
|
+
// rewritten five seconds later by keepDiscovery, and its banner went on
|
|
4720
|
+
// claiming it was receiving events it had stopped receiving.
|
|
4679
4721
|
function isProcessAlive(pid) {
|
|
4680
4722
|
try { process.kill(pid, 0); return true; }
|
|
4681
|
-
catch (e) { return e && e.code === "EPERM"; }
|
|
4723
|
+
catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
|
|
4682
4724
|
}
|
|
4683
4725
|
|
|
4684
4726
|
async function sweepStaleDiscovery() {
|
|
@@ -4990,6 +5032,72 @@ function isDeckUiRequest({ origin, host, secFetchSite } = {}) {
|
|
|
4990
5032
|
// presents nothing at all no longer changes anything — and what is opened is
|
|
4991
5033
|
// the honest door, so a script of the user's own authenticates by reading the
|
|
4992
5034
|
// token instead of impersonating a page.
|
|
5035
|
+
/**
|
|
5036
|
+
* May this request read the deck's OWN data — the events, the accounts, the
|
|
5037
|
+
* browsing episodes?
|
|
5038
|
+
*
|
|
5039
|
+
* The mutation gate exists because `curl -XPOST localhost:4317/…/admin` handed
|
|
5040
|
+
* a live OAuth refresh token to a sandboxed subprocess with loopback egress.
|
|
5041
|
+
* The same caller could still `curl localhost:4317/api/events` and read the
|
|
5042
|
+
* whole ring — prompt text, the Bash command lines the agent ran, the paths and
|
|
5043
|
+
* contents it wrote, the contents of every file it read back — plus the account
|
|
5044
|
+
* roster and the browsing episodes. The threat model had been applied to half
|
|
5045
|
+
* the surface.
|
|
5046
|
+
*
|
|
5047
|
+
* Same shape as isAuthorizedMutation, with one difference forced by the
|
|
5048
|
+
* browser: a same-origin GET carries no `Origin` header at all, so the UI
|
|
5049
|
+
* cannot be recognised the way a POST is. `Sec-Fetch-Site: same-origin` is what
|
|
5050
|
+
* a page's own fetch and its EventSource both send, on every browser new enough
|
|
5051
|
+
* to run this bundle, and it is a header no non-browser client sends by
|
|
5052
|
+
* accident. A caller that sends neither it nor the token is not a page.
|
|
5053
|
+
*
|
|
5054
|
+
* WHAT STAYS OPEN, deliberately: /api/health (the hook's readiness probe),
|
|
5055
|
+
* /api/hook-challenge (the handshake itself), the static files, and every
|
|
5056
|
+
* measurement route — the machine panel's numbers are about the machine, not
|
|
5057
|
+
* about what the user is doing on it.
|
|
5058
|
+
*/
|
|
5059
|
+
function isAuthorizedDataRead(req) {
|
|
5060
|
+
const headers = req?.headers ?? {};
|
|
5061
|
+
if (presentsDeckToken(headers)) return true;
|
|
5062
|
+
// Addressed to this machine by a name that can only be this machine — so a
|
|
5063
|
+
// rebound page, which also reports same-origin, does not qualify.
|
|
5064
|
+
if (!isLoopbackHost(headers.host)) return false;
|
|
5065
|
+
|
|
5066
|
+
const site = typeof headers["sec-fetch-site"] === "string"
|
|
5067
|
+
? headers["sec-fetch-site"].trim().toLowerCase() : "";
|
|
5068
|
+
if (site === "same-origin") return true;
|
|
5069
|
+
// ANY FETCH METADATA AT ALL, AND IT SAID SOMETHING ELSE. `cross-site`,
|
|
5070
|
+
// `same-site` and `none` are all a page that is not this one — or a top-level
|
|
5071
|
+
// navigation typed into the address bar, which has no business reading the
|
|
5072
|
+
// ring.
|
|
5073
|
+
if (site !== "") return false;
|
|
5074
|
+
|
|
5075
|
+
// THE BROWSER THAT SENDS NO FETCH METADATA, and this is the whole reason this
|
|
5076
|
+
// branch exists. Sec-Fetch-Site is Safari 16.4 and newer; Safari 16.0-16.3
|
|
5077
|
+
// runs this bundle perfectly well (Vite's default target is Safari 16) and
|
|
5078
|
+
// sends none of it. Without a fallback those users get an empty canvas and a
|
|
5079
|
+
// 401 they cannot act on — an impediment for a browser that is otherwise
|
|
5080
|
+
// fine.
|
|
5081
|
+
//
|
|
5082
|
+
// Referer is what they do send, on a page's own fetches and on its
|
|
5083
|
+
// EventSource, and it must name THIS origin. A cross-site page's Referer
|
|
5084
|
+
// names its own; a rebound page's names the attacker's host, which is not a
|
|
5085
|
+
// loopback identity. It is forgeable by a non-browser client — and so is
|
|
5086
|
+
// Sec-Fetch-Site, which curl sets as easily; neither is the control that
|
|
5087
|
+
// stops a deliberate local caller. That control is the token, and this only
|
|
5088
|
+
// decides which BROWSERS are recognised as the deck's own page.
|
|
5089
|
+
return originMatchesHost(headers.referer, headers.host);
|
|
5090
|
+
}
|
|
5091
|
+
|
|
5092
|
+
/** The reads that carry the user's own work, rather than the machine's. */
|
|
5093
|
+
const GUARDED_READS = new Set([
|
|
5094
|
+
"/events",
|
|
5095
|
+
"/api/events",
|
|
5096
|
+
"/api/claude-accounts",
|
|
5097
|
+
"/api/claude-accounts/login",
|
|
5098
|
+
"/api/browser-watch",
|
|
5099
|
+
]);
|
|
5100
|
+
|
|
4993
5101
|
function isAuthorizedMutation(req) {
|
|
4994
5102
|
const headers = req?.headers ?? {};
|
|
4995
5103
|
if (presentsDeckToken(headers)) return true;
|
|
@@ -5200,6 +5308,14 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5200
5308
|
return send(res, 401, { error: "unauthenticated" });
|
|
5201
5309
|
}
|
|
5202
5310
|
|
|
5311
|
+
// And the reads that carry the same secrets. See isAuthorizedDataRead: the
|
|
5312
|
+
// gate above was written for a sandboxed subprocess with loopback egress,
|
|
5313
|
+
// and that caller was reading the ring through a GET the whole time.
|
|
5314
|
+
if ((req.method === "GET" || req.method === "HEAD")
|
|
5315
|
+
&& GUARDED_READS.has(url.pathname) && !isAuthorizedDataRead(req)) {
|
|
5316
|
+
return send(res, 401, { error: "unauthenticated" });
|
|
5317
|
+
}
|
|
5318
|
+
|
|
5203
5319
|
// `?persist=0` — another deck was elected to write this event to the log
|
|
5204
5320
|
// the two of them share. Absent, this deck writes it.
|
|
5205
5321
|
if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res, url.searchParams.get("persist") !== "0"), res);
|
package/src/server/installer.mjs
CHANGED
|
@@ -353,7 +353,31 @@ async function writeFileAtomic(rawTarget, text) {
|
|
|
353
353
|
// to stay 600. No-op on Windows, where chmod only toggles the read-only bit.
|
|
354
354
|
const mode = await stat(target).then(s => s.mode, () => null);
|
|
355
355
|
if (mode !== null) await chmod(tmp, mode).catch(() => {});
|
|
356
|
+
// A READ-ONLY TARGET IS A DEAD END ON WINDOWS, and only there. libuv's
|
|
357
|
+
// rename is one MoveFileExW(MOVEFILE_REPLACE_EXISTING), which refuses to
|
|
358
|
+
// replace a destination carrying FILE_ATTRIBUTE_READONLY; POSIX rename(2)
|
|
359
|
+
// over a 0444 file succeeds, because only the parent directory's write bit
|
|
360
|
+
// decides. A settings.json picks that attribute up from a OneDrive restore,
|
|
361
|
+
// a copy off a network share, or read-only media — and EACCES is in the
|
|
362
|
+
// retry ladder, so the whole ~1.4s was spent before throwing, on every
|
|
363
|
+
// boot, forever. Every settings writer goes through here, so hooks never
|
|
364
|
+
// installed and the sound-hook retirement could never repair a stale entry
|
|
365
|
+
// either.
|
|
366
|
+
//
|
|
367
|
+
// chmod on Windows toggles exactly that attribute and nothing else, which
|
|
368
|
+
// is why this is safe to do unconditionally there: the mode carried above
|
|
369
|
+
// is re-applied to the new file after the rename, so a file the user marked
|
|
370
|
+
// read-only stays read-only.
|
|
371
|
+
// ONLY WHEN THE TARGET IS ACTUALLY READ-ONLY. Two extra syscalls on the
|
|
372
|
+
// path between the temp write and the rename are not free on Windows:
|
|
373
|
+
// discovery-live.test.ts hammers writeDiscovery while a reader holds the
|
|
374
|
+
// destination open, and the wider window turned a rename the retry ladder
|
|
375
|
+
// used to win into an EPERM it gave up on. The attribute is what this
|
|
376
|
+
// clears, so a file that does not carry it has nothing to clear.
|
|
377
|
+
const readOnly = process.platform === "win32" && mode !== null && (mode & 0o200) === 0;
|
|
378
|
+
if (readOnly) await chmod(target, 0o666).catch(() => {});
|
|
356
379
|
await renameWithRetry(tmp, target);
|
|
380
|
+
if (readOnly) await chmod(target, mode).catch(() => {});
|
|
357
381
|
} catch (err) {
|
|
358
382
|
// Cleanup covers the write and the fsync as well as the rename: a full disk
|
|
359
383
|
// used to leave the half-written temp file sitting beside the target.
|
|
@@ -405,7 +429,7 @@ function dedupeOurEntries(group) {
|
|
|
405
429
|
}
|
|
406
430
|
|
|
407
431
|
/** Install hooks for a single provider. Returns {settingsPath, hookPath, events, changed}. */
|
|
408
|
-
export async function installHooks({ provider = "claude" } = {}) {
|
|
432
|
+
export async function installHooks({ provider = "claude", beforeWrite = null } = {}) {
|
|
409
433
|
const cfg = PROVIDERS[provider];
|
|
410
434
|
if (!cfg) throw new Error(`unknown provider: ${provider}`);
|
|
411
435
|
// An uninstall-only provider has no event list. Saying so beats the
|
|
@@ -481,7 +505,38 @@ export async function installHooks({ provider = "claude" } = {}) {
|
|
|
481
505
|
// compare against the exact bytes we read and, when they match, do nothing.
|
|
482
506
|
const next = JSON.stringify(current, null, 2) + "\n";
|
|
483
507
|
const changed = next !== before;
|
|
484
|
-
if (changed)
|
|
508
|
+
if (changed) {
|
|
509
|
+
// COMPARE AGAINST THE FILE, NOT AGAINST THE SNAPSHOT, at the last moment.
|
|
510
|
+
//
|
|
511
|
+
// Everything above was computed from bytes read at the top of this
|
|
512
|
+
// function, and two decks booting together — the ordinary case on a machine
|
|
513
|
+
// where one was already running — interleave inside that window. The one
|
|
514
|
+
// that loses is unrecoverable rather than merely stale: deck A restores the
|
|
515
|
+
// user's own sound hooks from the parked file and deletes the park, and
|
|
516
|
+
// deck B then writes a settings object computed before that restore, with
|
|
517
|
+
// an empty park behind it. The user's hook is gone from settings.json and
|
|
518
|
+
// from the only other copy of it.
|
|
519
|
+
//
|
|
520
|
+
// So the file is re-read immediately before the write and, if another
|
|
521
|
+
// writer has touched it, this pass declines. Declining is safe by
|
|
522
|
+
// construction: every boot reinstalls, so the next one recomputes against
|
|
523
|
+
// the new bytes and converges — and the entries this function adds are
|
|
524
|
+
// identical on both decks, which is why the loser has nothing of its own to
|
|
525
|
+
// lose.
|
|
526
|
+
// The seam the suite needs, and the only way to test this deterministically:
|
|
527
|
+
// the window between the read at the top and the write below is filled with
|
|
528
|
+
// real fs work, so a test that raced it by wall clock would pass or fail by
|
|
529
|
+
// how fast the machine is. Production passes nothing.
|
|
530
|
+
if (beforeWrite) await beforeWrite();
|
|
531
|
+
const { raw: onDisk } = await readSettingsForWrite(cfg.settingsPath).catch(() => ({ raw: before }));
|
|
532
|
+
if (onDisk !== before) {
|
|
533
|
+
return {
|
|
534
|
+
settingsPath: cfg.settingsPath, hookPath, events: cfg.events, provider,
|
|
535
|
+
changed: false, raced: true, retire: { ...retire, pending: false },
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
await writeFileAtomic(cfg.settingsPath, next);
|
|
539
|
+
}
|
|
485
540
|
// After the write, never before it: the notify script an older deck installed
|
|
486
541
|
// is what a live session's cached command still names until the new entry is
|
|
487
542
|
// on disk, and deleting it early turns a stale sound into a missing module.
|
|
@@ -773,4 +828,4 @@ export { AGENT_DAG_DIR, CLAUDE_DIR, CODEX_DIR };
|
|
|
773
828
|
// repo for the same reasons settings.json is, and "never rename onto a link" is
|
|
774
829
|
// one rule: codex-auth.mjs called a realpath of its own before this existed, and
|
|
775
830
|
// two spellings of a rule are two things that can drift.
|
|
776
|
-
export { readSettingsForWrite, writeFileAtomic,
|
|
831
|
+
export { readSettingsForWrite, writeFileAtomic, renameWithRetry, createTemp, resolveWriteTarget };
|
package/src/server/macmon.mjs
CHANGED
|
@@ -193,11 +193,6 @@ export function macmonAsset(release) {
|
|
|
193
193
|
return { version: typeof tag === "string" ? tag : "unknown", url: a.browser_download_url, sha256: m[1] };
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
/** A macmon this function installed earlier, or null. */
|
|
197
|
-
export function existingBootstrappedMacmon() {
|
|
198
|
-
const bin = join(MACMON_DIR, "macmon");
|
|
199
|
-
return existsSync(bin) ? bin : null;
|
|
200
|
-
}
|
|
201
196
|
|
|
202
197
|
/**
|
|
203
198
|
* Download macmon into ~/.agents-deck/tools/macmon.
|
|
@@ -210,9 +205,25 @@ export function existingBootstrappedMacmon() {
|
|
|
210
205
|
* ioreg and never reaches this file at all.
|
|
211
206
|
*/
|
|
212
207
|
export async function bootstrapMacmon({
|
|
213
|
-
platform = process.platform,
|
|
208
|
+
platform = process.platform, arch = process.arch, env = process.env,
|
|
209
|
+
fetchFn = fetch, dir = MACMON_DIR, findBin = macmonBin,
|
|
214
210
|
} = {}) {
|
|
215
211
|
if (platform !== "darwin") return { ok: false, reason: "unsupported_platform" };
|
|
212
|
+
// ARM64 ONLY, checked rather than merely documented. The release publishes
|
|
213
|
+
// one asset and it is an arm64 Mach-O, so an Intel Mac downloaded 746 KB it
|
|
214
|
+
// could not execute and failed its own --version check afterwards. That is
|
|
215
|
+
// the small half. The larger half is the promise: the README says an Intel
|
|
216
|
+
// Mac never downloads anything, and this reached api.github.com on any Mac
|
|
217
|
+
// whose sensors happened to stay silent — which an Intel Mac's do whenever
|
|
218
|
+
// ioreg publishes no Temperature(C).
|
|
219
|
+
if (arch !== "arm64") return { ok: false, reason: "unsupported_arch" };
|
|
220
|
+
// A macmon the user already has is the other thing the README promises to
|
|
221
|
+
// notice, and until now the skip was emergent rather than checked: a working
|
|
222
|
+
// copy produces a reading, the reading sets thermalEverAnswered, and the
|
|
223
|
+
// give-up branch never fires. That chain breaks on a macmon which runs but
|
|
224
|
+
// reports values this deck rejects as implausible — and then a machine with
|
|
225
|
+
// macmon on PATH downloaded a second one.
|
|
226
|
+
if (await findBin()) return { ok: false, reason: "already_installed" };
|
|
216
227
|
// Both switches, for the reason uv-bootstrap has both: downloading an
|
|
217
228
|
// executable is a bigger step than installing a package with a tool the user
|
|
218
229
|
// already chose, so somebody may want the managed installs and not this.
|
package/src/server/npx.mjs
CHANGED
|
@@ -194,7 +194,12 @@ export function npxPrefetch(spec, {
|
|
|
194
194
|
timer.unref?.();
|
|
195
195
|
|
|
196
196
|
child.on("error", err => settle({ ok: false, error: `could not run npx: ${err?.message ?? err}`, hint: null }));
|
|
197
|
-
|
|
197
|
+
// 'close' rather than 'exit': 'exit' fires when the process ends and says
|
|
198
|
+
// nothing about its pipes, so past one pipe buffer the tail this kept —
|
|
199
|
+
// deliberately the TAIL, because npm's reason is on its last lines — was
|
|
200
|
+
// still only the first 8 KiB when npxFailureSummary read it. 'close' is
|
|
201
|
+
// emitted after both streams have been drained.
|
|
202
|
+
child.on("close", (code, signal) => {
|
|
198
203
|
if (code === 0) { settle({ ok: true, error: null, hint: null }); return; }
|
|
199
204
|
settle({
|
|
200
205
|
ok: false,
|
package/src/server/open-url.mjs
CHANGED
|
@@ -166,11 +166,31 @@ export function startCommand(url, env = process.env, comspec) {
|
|
|
166
166
|
* it and `start` would run it.
|
|
167
167
|
*/
|
|
168
168
|
export function isOpenable(url) {
|
|
169
|
+
return normalizeOpenable(url) !== null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The address to actually hand the desktop, or null when it is not one.
|
|
174
|
+
*
|
|
175
|
+
* The guard used to parse into a local `u`, check its protocol and throw the
|
|
176
|
+
* parse away — so what reached `launchers` was the RAW string. `new URL()`
|
|
177
|
+
* accepts characters it normalizes only in `href`, a `"` among them, and on
|
|
178
|
+
* Windows `startCommand` builds `start "" "<url>"` with
|
|
179
|
+
* `windowsVerbatimArguments: true`, where a quote is syntax. Today the only
|
|
180
|
+
* caller passes a loopback URL this deck built itself, so nothing is reachable;
|
|
181
|
+
* the function's own doc says it exists "for the day a second caller passes a
|
|
182
|
+
* path", and as written it would not have stopped that caller.
|
|
183
|
+
*
|
|
184
|
+
* Returning the normalized `href` means the string that was checked is the
|
|
185
|
+
* string that is launched.
|
|
186
|
+
*/
|
|
187
|
+
export function normalizeOpenable(url) {
|
|
169
188
|
try {
|
|
170
189
|
const u = new URL(String(url));
|
|
171
|
-
|
|
190
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
|
191
|
+
return u.href;
|
|
172
192
|
} catch {
|
|
173
|
-
return
|
|
193
|
+
return null;
|
|
174
194
|
}
|
|
175
195
|
}
|
|
176
196
|
|
|
@@ -183,8 +203,9 @@ export function isOpenable(url) {
|
|
|
183
203
|
* the same recovery an error row would have offered.
|
|
184
204
|
*/
|
|
185
205
|
export function openUrl(url, { platform = process.platform, env = process.env, spawnFn = spawn } = {}) {
|
|
186
|
-
|
|
187
|
-
|
|
206
|
+
const href = normalizeOpenable(url);
|
|
207
|
+
if (href === null) return;
|
|
208
|
+
const tries = launchers(href, { platform, env });
|
|
188
209
|
|
|
189
210
|
const attempt = (i) => {
|
|
190
211
|
if (i >= tries.length) return;
|
package/src/server/quota.mjs
CHANGED
|
@@ -35,6 +35,11 @@ import { readFile } from "node:fs/promises";
|
|
|
35
35
|
import { join } from "node:path";
|
|
36
36
|
import { homedir } from "node:os";
|
|
37
37
|
import { PRODUCT } from "./brand.mjs";
|
|
38
|
+
// One ANSI stripper for the whole deck. The private copy that used to live
|
|
39
|
+
// here accepted only the BEL terminator for an OSC sequence, while term.mjs's
|
|
40
|
+
// also accepts ESC \\ — so a hyperlink written the other legal way survived
|
|
41
|
+
// into text this module then parsed for quota lines.
|
|
42
|
+
import { stripAnsi } from "./term.mjs";
|
|
38
43
|
import { resetLabelIso } from "./reset-label.mjs";
|
|
39
44
|
|
|
40
45
|
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
@@ -83,6 +88,73 @@ async function readOAuthToken() {
|
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Whether we may spend a request of the user's budget right now.
|
|
93
|
+
*
|
|
94
|
+
* Exported for tests — this is the rule that stopped the deck from starving
|
|
95
|
+
* claude-swap, and it is worth pinning down.
|
|
96
|
+
*/
|
|
97
|
+
export function maySelfPoll({ now, force, lastSelfPollAt, rateLimitedUntil }) {
|
|
98
|
+
if (now < rateLimitedUntil) return false;
|
|
99
|
+
return now - lastSelfPollAt >= (force ? FORCE_POLL_MS : SELF_POLL_MS);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A cooldown from a `retry-after`, kept inside limits the deck can live with.
|
|
106
|
+
*
|
|
107
|
+
* Unclamped, the header decided the poller's fate in both directions: `0` (or a
|
|
108
|
+
* value the server rounds down to it) defeats the cooldown entirely and the
|
|
109
|
+
* next tick asks again immediately, which is the loop a 429 exists to stop; a
|
|
110
|
+
* large one — a day is a legal value — freezes the reader for the life of the
|
|
111
|
+
* process, and nothing here re-reads it. Both are the remote side deciding how
|
|
112
|
+
* this deck behaves, which a header is not entitled to do.
|
|
113
|
+
*
|
|
114
|
+
* The floor is the deck's own minimum backoff and the ceiling is an hour: long
|
|
115
|
+
* enough to be a real retreat, short enough that a quota panel is not dead for
|
|
116
|
+
* the rest of the day because one reply said so.
|
|
117
|
+
*/
|
|
118
|
+
export function cooldownFromHeader(raw, fallbackMs, minMs = 30_000, maxMs = 3600_000) {
|
|
119
|
+
const seconds = parseInt(String(raw ?? ""), 10);
|
|
120
|
+
if (!Number.isFinite(seconds)) return fallbackMs;
|
|
121
|
+
return Math.min(Math.max(seconds * 1000, minMs), maxMs);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* WHETHER THIS MACHINE HAS A SUBSCRIPTION TO REPORT ON AT ALL.
|
|
126
|
+
*
|
|
127
|
+
* Every source here needs a Claude.ai OAuth credential: the claude-swap store
|
|
128
|
+
* holds one, `claudeAiOauth` in the credentials file is one, and
|
|
129
|
+
* `claude --print /usage` prints windows only for a session signed in with one.
|
|
130
|
+
* An API-key, Bedrock or Vertex install has none — and there is no quota to
|
|
131
|
+
* read, because those are billed per token rather than in five-hour windows.
|
|
132
|
+
*
|
|
133
|
+
* That mattered because of what the CLI does on such a machine: it RUNS, prints
|
|
134
|
+
* no quota lines, and the branch below used to read that as "genuine <1%" and
|
|
135
|
+
* publish `ok: true` with two zeroes. The panel then drew empty bars, which is
|
|
136
|
+
* a measurement nobody took. Codex already answers this properly, with
|
|
137
|
+
* `api_key_mode` as its own reason and its own sentence.
|
|
138
|
+
*
|
|
139
|
+
* Cheap and synchronous: environment first, because a machine configured for
|
|
140
|
+
* Bedrock or Vertex says so there, then the presence of the OAuth block in the
|
|
141
|
+
* credentials file. `readOAuthToken` above answers a different question — it
|
|
142
|
+
* also rejects an EXPIRED token, and an expired subscription is still a
|
|
143
|
+
* subscription.
|
|
144
|
+
*/
|
|
145
|
+
export async function hasSubscriptionCredential(env = process.env) {
|
|
146
|
+
if (env.CLAUDE_CODE_USE_BEDROCK === "1" || env.CLAUDE_CODE_USE_VERTEX === "1") return false;
|
|
147
|
+
try {
|
|
148
|
+
const raw = await readFile(credentialsPath(), "utf8");
|
|
149
|
+
if (JSON.parse(raw)?.claudeAiOauth?.accessToken) return true;
|
|
150
|
+
} catch { /* absent or unreadable, decided below */ }
|
|
151
|
+
// A key in the environment and no OAuth block beside it is the API-key
|
|
152
|
+
// install. Without either, this deck simply has not been signed in yet, and
|
|
153
|
+
// "sign in" is the right thing to say — which is the `waiting` branch, not
|
|
154
|
+
// this one.
|
|
155
|
+
return !(env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN);
|
|
156
|
+
}
|
|
157
|
+
|
|
86
158
|
// ISO-8601 → "Jun 19, 1:19pm" (local time, matching the CLI display format).
|
|
87
159
|
//
|
|
88
160
|
// The body moved to reset-label.mjs in #374: codex-quota.mjs had a copy that
|
|
@@ -156,9 +228,7 @@ async function fetchOAuthUsage() {
|
|
|
156
228
|
});
|
|
157
229
|
|
|
158
230
|
if (res.status === 429) {
|
|
159
|
-
|
|
160
|
-
const cooldownMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 5 * 60_000;
|
|
161
|
-
_rateLimitedUntil = Date.now() + cooldownMs;
|
|
231
|
+
_rateLimitedUntil = Date.now() + cooldownFromHeader(res.headers.get("retry-after"), 5 * 60_000);
|
|
162
232
|
return null;
|
|
163
233
|
}
|
|
164
234
|
if (!res.ok) return null;
|
|
@@ -236,25 +306,6 @@ export function quotaFromStore(entry) {
|
|
|
236
306
|
return out;
|
|
237
307
|
}
|
|
238
308
|
|
|
239
|
-
/**
|
|
240
|
-
* Whether we may spend a request of the user's budget right now.
|
|
241
|
-
*
|
|
242
|
-
* Exported for tests — this is the rule that stopped the deck from starving
|
|
243
|
-
* claude-swap, and it is worth pinning down.
|
|
244
|
-
*/
|
|
245
|
-
export function maySelfPoll({ now, force, lastSelfPollAt, rateLimitedUntil }) {
|
|
246
|
-
if (now < rateLimitedUntil) return false;
|
|
247
|
-
return now - lastSelfPollAt >= (force ? FORCE_POLL_MS : SELF_POLL_MS);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
251
|
-
|
|
252
|
-
function stripAnsi(s) {
|
|
253
|
-
return s
|
|
254
|
-
.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "")
|
|
255
|
-
.replace(/\x1B\][^\x07]*\x07/g, "")
|
|
256
|
-
.replace(/\x1B[()][AB012]/g, "");
|
|
257
|
-
}
|
|
258
309
|
|
|
259
310
|
// Parse "Jun 18, 4:09pm" (local time, no tz) into unix seconds.
|
|
260
311
|
// Claude shows times in the user's local timezone, so parsing as local is correct.
|
|
@@ -671,12 +722,23 @@ async function _doFetch(now, force = false, gen = _generation) {
|
|
|
671
722
|
return publish(gen, { ..._lastGood, stale: true }, now - (CACHE_MS - 5_000));
|
|
672
723
|
}
|
|
673
724
|
|
|
674
|
-
// Never had good data. CLI
|
|
675
|
-
//
|
|
676
|
-
|
|
725
|
+
// Never had good data. A CLI that RAN and printed no quota lines is two
|
|
726
|
+
// different machines, and they need two different answers:
|
|
727
|
+
//
|
|
728
|
+
// * a subscription install on a cold invocation — the lines come back on a
|
|
729
|
+
// later call, and until then "<1%" is the honest reading of a window that
|
|
730
|
+
// has genuinely just reset;
|
|
731
|
+
// * an API-key, Bedrock or Vertex install, which has no windows at all.
|
|
732
|
+
// Publishing two zeroes there drew empty bars for a measurement nobody
|
|
733
|
+
// took, on a machine where no amount of retrying will ever produce one.
|
|
734
|
+
//
|
|
735
|
+
// A CLI that failed entirely is `ok: false` as it always was, and the reason
|
|
736
|
+
// says which of the two the reader is looking at.
|
|
737
|
+
const subscribed = cliOk ? await hasSubscriptionCredential() : false;
|
|
738
|
+
const result = cliOk && subscribed
|
|
677
739
|
? { ok: true, session5hPct: 0, session5hWindowSec: 18000,
|
|
678
740
|
week7dPct: 0, week7dWindowSec: 604800, fetchedAt: now }
|
|
679
|
-
: { ok: false, fetchedAt: now };
|
|
741
|
+
: { ok: false, reason: cliOk ? "no_subscription" : "cli_failed", fetchedAt: now };
|
|
680
742
|
return publish(gen, result, now - (CACHE_MS - 5_000));
|
|
681
743
|
}
|
|
682
744
|
|
|
@@ -94,10 +94,44 @@ const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
|
|
|
94
94
|
const commandsOf = (entry) =>
|
|
95
95
|
(entry?.hooks ?? []).map(h => (typeof h?.command === "string" ? h.command : ""));
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* THE TAIL, NOT THE WHOLE PATH — and this is a data-loss fix, not a tidy-up.
|
|
99
|
+
*
|
|
100
|
+
* The stored command was written by `shellQuoteArg`, whose POSIX branch
|
|
101
|
+
* single-quotes the argument and rewrites every `'` as `'\''`. So on a config
|
|
102
|
+
* dir like `/mnt/Bob's SSD/claude` the command contains `Bob'\''s` and a
|
|
103
|
+
* `cmd.includes(NOTIFY_PATH)` against the raw path is false — on macOS and
|
|
104
|
+
* Linux, for a perfectly ordinary directory name.
|
|
105
|
+
*
|
|
106
|
+
* What follows is unrecoverable. A mark-less `Stop` entry — the case this
|
|
107
|
+
* module exists for — is then not recognised as ours, so it is kept; and
|
|
108
|
+
* `anythingStillNamesOurScripts`, which has no mark to fall back on, also says
|
|
109
|
+
* no, so the sweep deletes `notify.mjs`. Claude Code throws
|
|
110
|
+
* `Cannot find module` at the end of every turn afterwards, forever, with the
|
|
111
|
+
* deck already uninstalled.
|
|
112
|
+
*
|
|
113
|
+
* The last two segments are fixed whatever the prefix is — the install
|
|
114
|
+
* directory is always `<config dir>/agent-dag` — and they contain no character
|
|
115
|
+
* any quoting rewrites. Case is folded where the filesystem folds it, which is
|
|
116
|
+
* the other half: `exec.mjs`'s own `sameCommand` lowercases "because Windows
|
|
117
|
+
* paths are", and this comparison did not.
|
|
118
|
+
*/
|
|
119
|
+
const SCRIPT_TAILS = ["agent-dag/notify.mjs", "agent-dag/notify.js"];
|
|
120
|
+
|
|
121
|
+
export function namesOurScript(cmd, platform = process.platform) {
|
|
122
|
+
if (typeof cmd !== "string" || cmd === "") return false;
|
|
123
|
+
// Backslashes become separators only where they ARE separators. On POSIX a
|
|
124
|
+
// backslash is an ordinary filename character, and rewriting it there could
|
|
125
|
+
// invent a match that the filesystem does not have.
|
|
126
|
+
let hay = platform === "win32" ? cmd.replace(/\\/g, "/") : cmd;
|
|
127
|
+
if (platform === "win32" || platform === "darwin") hay = hay.toLowerCase();
|
|
128
|
+
return SCRIPT_TAILS.some(tail => hay.includes(tail));
|
|
129
|
+
}
|
|
130
|
+
|
|
97
131
|
/** An entry this deck put there: by its mark, or by the script it runs. */
|
|
98
132
|
function isOurs(entry) {
|
|
99
133
|
if (entry?.[MARK] === true) return true;
|
|
100
|
-
return commandsOf(entry).some(cmd =>
|
|
134
|
+
return commandsOf(entry).some(cmd => namesOurScript(cmd));
|
|
101
135
|
}
|
|
102
136
|
|
|
103
137
|
/** Anywhere in the file — not just `Stop` — that still runs one of our scripts.
|
|
@@ -109,7 +143,7 @@ function anythingStillNamesOurScripts(settings) {
|
|
|
109
143
|
for (const group of Object.values(groups)) {
|
|
110
144
|
if (!Array.isArray(group)) continue;
|
|
111
145
|
for (const entry of group) {
|
|
112
|
-
if (commandsOf(entry).some(cmd =>
|
|
146
|
+
if (commandsOf(entry).some(cmd => namesOurScript(cmd))) return true;
|
|
113
147
|
}
|
|
114
148
|
}
|
|
115
149
|
return false;
|