agent-dag 1.42.0 → 1.44.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 +10 -6
- package/bin/agent-dag.js +63 -9
- package/bin/deck.js +46 -11
- package/dist/web/assets/index-DBsxIfdM.js +78 -0
- package/dist/web/assets/index-XtT5NdJI.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/notify.mjs +104 -0
- package/package.json +2 -2
- package/src/server/ccusage.mjs +114 -2
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +108 -3
- package/src/server/cswap-admin.mjs +180 -6
- package/src/server/cswap-auto.mjs +209 -10
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +130 -11
- package/src/server/index.mjs +226 -19
- package/src/server/installer.mjs +145 -22
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/self-update.mjs +262 -21
- package/src/server/sound-hook.mjs +158 -28
- package/src/server/supervisor.mjs +36 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +48 -11
- package/dist/web/assets/index-CHFnwsds.css +0 -1
- package/dist/web/assets/index-DLihciEi.js +0 -78
- package/hook/notify.js +0 -60
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// command — `afplay …` on macOS, a PowerShell one-liner on Windows — ending in
|
|
5
5
|
// `|| true`. Each is a silent no-op on every other machine, so a settings.json
|
|
6
6
|
// synced across devices ends up with several of them stacked, none of which
|
|
7
|
-
// work everywhere. This installs a single entry pointing at notify.
|
|
7
|
+
// work everywhere. This installs a single entry pointing at notify.mjs, which
|
|
8
8
|
// picks its own player at run time.
|
|
9
9
|
//
|
|
10
10
|
// Only ever touches its own entry, tagged `__agent-dag-sound`. Hooks the user
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// learn a second provider, src/web/provider-copy.ts's finishSoundTitle is the
|
|
22
22
|
// sentence that has to move with it, and finish-sound-scope.test.ts fails until
|
|
23
23
|
// it does (#394).
|
|
24
|
-
import { readFile, mkdir } from "node:fs/promises";
|
|
24
|
+
import { readFile, mkdir, rm } from "node:fs/promises";
|
|
25
25
|
import { existsSync } from "node:fs";
|
|
26
26
|
import { join, dirname } from "node:path";
|
|
27
27
|
import { homedir } from "node:os";
|
|
@@ -34,7 +34,32 @@ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
34
34
|
const CLAUDE_DIR = claudeConfigDir();
|
|
35
35
|
const SETTINGS_PATH = join(CLAUDE_DIR, "settings.json");
|
|
36
36
|
const INSTALL_DIR = join(CLAUDE_DIR, "agent-dag");
|
|
37
|
-
|
|
37
|
+
|
|
38
|
+
// `.mjs`, and the extension is the whole feature on the machines it matters on.
|
|
39
|
+
//
|
|
40
|
+
// This script is ESM — `import { spawn } from "node:child_process"` on its first
|
|
41
|
+
// executable line — and in the package that is settled by package.json's
|
|
42
|
+
// `"type": "module"` two directories up. Installed, it lands in <claude config
|
|
43
|
+
// dir>/agent-dag/, where there is normally no package.json between it and the
|
|
44
|
+
// filesystem root, so the extension alone decides the format and a `.js` with
|
|
45
|
+
// nothing above it is CommonJS. Node's module-syntax detection rescued that, but
|
|
46
|
+
// only where detection is on by default: v20.19.0 and v22.7.0 and later. The
|
|
47
|
+
// package's own `engines` says `>=18`, and on 18.x, 19.x, 20.0–20.18.x, 21.x and
|
|
48
|
+
// 22.0–22.6.x the Stop hook was a `SyntaxError: Cannot use import statement
|
|
49
|
+
// outside a module` printed at the end of every turn instead of a sound.
|
|
50
|
+
//
|
|
51
|
+
// `.mjs` is ESM on every Node that has ever had ESM, with nothing above it
|
|
52
|
+
// consulted and no detection involved. A package.json beside the script would
|
|
53
|
+
// have been the other spelling of the fix and is the wrong one here: hook.js
|
|
54
|
+
// lives in this same directory, is deliberately CommonJS because that is what
|
|
55
|
+
// this directory's layout means, and a `{"type":"module"}` next to it would
|
|
56
|
+
// break the event forwarder to fix the sound.
|
|
57
|
+
const NOTIFY_NAME = "notify.mjs";
|
|
58
|
+
const PACKAGED_NOTIFY = join(PKG_ROOT, "hook", NOTIFY_NAME);
|
|
59
|
+
const NOTIFY_PATH = join(INSTALL_DIR, NOTIFY_NAME);
|
|
60
|
+
// What the same script was called before it declared its own format. Swept once
|
|
61
|
+
// the entry that named it has been rewritten — see sweepLegacySoundScript.
|
|
62
|
+
const LEGACY_NOTIFY_PATH = join(INSTALL_DIR, "notify.js");
|
|
38
63
|
|
|
39
64
|
const MARK = "__agent-dag-sound";
|
|
40
65
|
const EVENT = "Stop";
|
|
@@ -51,11 +76,13 @@ const SOUND_HINTS = [/\bafplay\b/i, /Media\.SoundPlayer/i, /\bpaplay\b/i, /\bapl
|
|
|
51
76
|
*
|
|
52
77
|
* Same shape and same reasoning as installer.mjs's hookCommand — see the note
|
|
53
78
|
* there — kept separate because this entry takes no `--provider` and is written
|
|
54
|
-
* to a different key. Exported, with the node path
|
|
55
|
-
*
|
|
79
|
+
* to a different key. Exported, with the node path and the platform injectable,
|
|
80
|
+
* for the reason hookCommand gives: the two quoting rules are different, and a
|
|
81
|
+
* test that cannot name a platform can only ever assert its own.
|
|
56
82
|
*/
|
|
57
|
-
export function soundHookCommand(notifyPath, node = process.execPath
|
|
58
|
-
|
|
83
|
+
export function soundHookCommand(notifyPath, node = process.execPath,
|
|
84
|
+
platform = process.platform) {
|
|
85
|
+
return `${shellQuoteArg(node, platform)} ${shellQuoteArg(notifyPath, platform)}`;
|
|
59
86
|
}
|
|
60
87
|
|
|
61
88
|
/**
|
|
@@ -285,7 +312,7 @@ export async function restoreParkedSoundHooks() {
|
|
|
285
312
|
* Take the toggle off the machine entirely, for `agents-deck --uninstall`.
|
|
286
313
|
*
|
|
287
314
|
* uninstallHooks only knows the `__agent-dag` mark the event forwarders carry;
|
|
288
|
-
* this entry is marked `__agent-dag-sound` and its command points at notify.
|
|
315
|
+
* this entry is marked `__agent-dag-sound` and its command points at notify.mjs,
|
|
289
316
|
* so it used to survive an uninstall and keep playing a sound on every turn. The
|
|
290
317
|
* user's own hooks were the worse half: parked here when the toggle went on,
|
|
291
318
|
* they stayed in a file under ~/.agents-deck that nothing left on the machine
|
|
@@ -318,6 +345,116 @@ export async function uninstallSoundHook() {
|
|
|
318
345
|
return { ok: true, removed, restored: restore.restored ?? 0 };
|
|
319
346
|
}
|
|
320
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Our Stop entry, built fresh from this machine's node and this machine's paths.
|
|
350
|
+
*
|
|
351
|
+
* One function rather than an object literal in each writer, because the entry
|
|
352
|
+
* has to be IDENTICAL wherever it comes from: reassertSoundHook decides whether
|
|
353
|
+
* to rewrite by comparing what is in settings.json against what this returns, so
|
|
354
|
+
* a second copy of the shape that drifted by a field would make every boot look
|
|
355
|
+
* like a change and rewrite the user's settings.json forever.
|
|
356
|
+
*/
|
|
357
|
+
function soundHookEntry() {
|
|
358
|
+
return {
|
|
359
|
+
[MARK]: true,
|
|
360
|
+
hooks: [{
|
|
361
|
+
type: "command",
|
|
362
|
+
// Absolute node path, matching how the event hooks are installed: the
|
|
363
|
+
// shell a hook runs in does not necessarily have the user's PATH. And
|
|
364
|
+
// properly escaped for that shell, for the reason installer.mjs's
|
|
365
|
+
// hookCommand spells out — NOTIFY_PATH is built from $CLAUDE_CONFIG_DIR,
|
|
366
|
+
// double quotes do not suppress `$(…)` or a backtick on POSIX, and this
|
|
367
|
+
// string is executed at the end of every turn.
|
|
368
|
+
command: soundHookCommand(NOTIFY_PATH),
|
|
369
|
+
timeout: 5,
|
|
370
|
+
}],
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Bring the installed sound hook back up to the packaged one, in a settings
|
|
376
|
+
* object the caller is about to write.
|
|
377
|
+
*
|
|
378
|
+
* The forwarder gets this for free: installHooks re-asserts hook.js on every
|
|
379
|
+
* boot, so a machine that upgrades the deck upgrades the script Claude Code
|
|
380
|
+
* actually executes. notify.js had exactly one installer — setSoundHook(true) —
|
|
381
|
+
* so the copy on disk was whatever shipped in the release the user last TOGGLED
|
|
382
|
+
* THE SOUND ON WITH, and every later release stopped at the package directory.
|
|
383
|
+
* That is not a theoretical drift: #548 replaced a `printf "\a"` player that
|
|
384
|
+
* spawned a BEL into `stdio: "ignore"` — silent by construction — and could not
|
|
385
|
+
* reach a single machine that already had the toggle on, which is precisely the
|
|
386
|
+
* set of machines it was written for.
|
|
387
|
+
*
|
|
388
|
+
* The presence of our entry in settings.json is the whole of the permission
|
|
389
|
+
* check, and it is deliberately the only one. A user who turned the sound OFF
|
|
390
|
+
* has no entry, so nothing here writes a script into their config dir on a boot
|
|
391
|
+
* they asked nothing of; a user who has it ON has already consented to this file
|
|
392
|
+
* existing, and keeping it current is the deck's job rather than theirs.
|
|
393
|
+
*
|
|
394
|
+
* The entry is rebuilt rather than inspected, which is the other half of the
|
|
395
|
+
* report. settings.json is commonly synced between machines and the command
|
|
396
|
+
* bakes in `process.execPath` and this machine's $CLAUDE_CONFIG_DIR — so a file
|
|
397
|
+
* carried over from a laptop names that laptop's node binary inside that
|
|
398
|
+
* laptop's home directory, soundHookStatus reports `enabled: true`, and the turn
|
|
399
|
+
* ends in an ENOENT nobody sees. Rewriting it from soundHookEntry() re-derives
|
|
400
|
+
* both against the machine the deck is running on.
|
|
401
|
+
*
|
|
402
|
+
* Mutates `settings` and returns what it did; the caller owns the write, so a
|
|
403
|
+
* boot that would otherwise change nothing still changes nothing.
|
|
404
|
+
*/
|
|
405
|
+
export async function reassertSoundHook(settings) {
|
|
406
|
+
const group = settings?.hooks?.[EVENT];
|
|
407
|
+
if (!Array.isArray(group) || !group.some(isOurs)) return { present: false, script: false, entry: false };
|
|
408
|
+
|
|
409
|
+
if (!existsSync(INSTALL_DIR)) await mkdir(INSTALL_DIR, { recursive: true });
|
|
410
|
+
// Same reason the event forwarder is installed this way: the Stop hook fires
|
|
411
|
+
// this file from sessions that are already running, so replacing it must not
|
|
412
|
+
// leave one of them executing a half-copied program. installScript renames a
|
|
413
|
+
// finished copy over the name, and skips the write entirely when the bytes
|
|
414
|
+
// already match — which is every boot after the first.
|
|
415
|
+
const script = await installScript(PACKAGED_NOTIFY, NOTIFY_PATH);
|
|
416
|
+
|
|
417
|
+
const rebuilt = soundHookEntry();
|
|
418
|
+
const wanted = JSON.stringify(rebuilt);
|
|
419
|
+
const next = [];
|
|
420
|
+
let entry = false;
|
|
421
|
+
let kept = false;
|
|
422
|
+
for (const g of group) {
|
|
423
|
+
if (!isOurs(g)) { next.push(g); continue; }
|
|
424
|
+
// More than one of ours is a settings.json that has been merged by hand or
|
|
425
|
+
// by a sync tool. One sound per turn, so the extras are dropped rather than
|
|
426
|
+
// rewritten alongside the first.
|
|
427
|
+
if (kept) { entry = true; continue; }
|
|
428
|
+
kept = true;
|
|
429
|
+
if (JSON.stringify(g) !== wanted) entry = true;
|
|
430
|
+
next.push(rebuilt);
|
|
431
|
+
}
|
|
432
|
+
settings.hooks[EVENT] = next;
|
|
433
|
+
return { present: true, script, entry };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Delete the `notify.js` an older deck installed, now that nothing names it.
|
|
438
|
+
*
|
|
439
|
+
* Called AFTER settings.json has been written, and that ordering is the point:
|
|
440
|
+
* until the new entry is on disk the old command is still what a live Claude
|
|
441
|
+
* Code session will run at the end of its next turn, and deleting the file it
|
|
442
|
+
* names would turn a stale sound into a "Cannot find module" in the user's
|
|
443
|
+
* session. Best-effort on the way out — a Windows lock or a read-only config dir
|
|
444
|
+
* leaves one stale file behind, which is litter, not a failure worth reporting
|
|
445
|
+
* over a hook that is now installed correctly.
|
|
446
|
+
*/
|
|
447
|
+
export async function sweepLegacySoundScript() {
|
|
448
|
+
if (LEGACY_NOTIFY_PATH === NOTIFY_PATH) return false;
|
|
449
|
+
try {
|
|
450
|
+
if (!existsSync(LEGACY_NOTIFY_PATH)) return false;
|
|
451
|
+
await rm(LEGACY_NOTIFY_PATH, { force: true });
|
|
452
|
+
return true;
|
|
453
|
+
} catch {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
321
458
|
export async function setSoundHook(enabled) {
|
|
322
459
|
// Read before anything else. A file we cannot parse stops the toggle here,
|
|
323
460
|
// with nothing parked, nothing copied and settings.json untouched.
|
|
@@ -354,24 +491,10 @@ export async function setSoundHook(enabled) {
|
|
|
354
491
|
if (enabled) {
|
|
355
492
|
if (!existsSync(INSTALL_DIR)) await mkdir(INSTALL_DIR, { recursive: true });
|
|
356
493
|
// Same reason the event forwarder is installed this way: the Stop hook fires
|
|
357
|
-
// notify.
|
|
494
|
+
// notify.mjs from sessions that are already running, and toggling the sound
|
|
358
495
|
// on must not leave one of them executing a half-copied file.
|
|
359
|
-
await installScript(
|
|
360
|
-
others.push(
|
|
361
|
-
[MARK]: true,
|
|
362
|
-
hooks: [{
|
|
363
|
-
type: "command",
|
|
364
|
-
// Absolute node path, matching how the event hooks are installed: the
|
|
365
|
-
// shell a hook runs in does not necessarily have the user's PATH. And
|
|
366
|
-
// properly escaped for that shell, for the reason installer.mjs's
|
|
367
|
-
// hookCommand spells out — NOTIFY_PATH is built from
|
|
368
|
-
// $CLAUDE_CONFIG_DIR, double quotes do not suppress `$(…)` or a
|
|
369
|
-
// backtick on POSIX, and this string is executed at the end of every
|
|
370
|
-
// turn.
|
|
371
|
-
command: soundHookCommand(NOTIFY_PATH),
|
|
372
|
-
timeout: 5,
|
|
373
|
-
}],
|
|
374
|
-
});
|
|
496
|
+
await installScript(PACKAGED_NOTIFY, NOTIFY_PATH);
|
|
497
|
+
others.push(soundHookEntry());
|
|
375
498
|
settings.hooks[EVENT] = others;
|
|
376
499
|
} else if (others.length) {
|
|
377
500
|
settings.hooks[EVENT] = others;
|
|
@@ -380,9 +503,16 @@ export async function setSoundHook(enabled) {
|
|
|
380
503
|
}
|
|
381
504
|
|
|
382
505
|
await writeSettings(settings);
|
|
506
|
+
// Last, and after the write, for the reason sweepLegacySoundScript gives: the
|
|
507
|
+
// file an older deck installed is only safe to delete once nothing in
|
|
508
|
+
// settings.json still points at it.
|
|
509
|
+
if (enabled) await sweepLegacySoundScript();
|
|
383
510
|
return { ok: true, enabled };
|
|
384
511
|
}
|
|
385
512
|
|
|
386
|
-
//
|
|
387
|
-
// before it writes anything — the real ones are the user's own settings.
|
|
388
|
-
|
|
513
|
+
// All four paths are exported so a test can prove it is pointed at a sandbox
|
|
514
|
+
// before it writes anything — the real ones are the user's own settings. The two
|
|
515
|
+
// script paths are also the only honest way for a test to ask where the sound
|
|
516
|
+
// hook ACTUALLY lands: rebuilding `<config dir>/agent-dag/notify.mjs` in the test
|
|
517
|
+
// would keep passing on the day this module started installing somewhere else.
|
|
518
|
+
export { SETTINGS_PATH, PARKED_PATH, NOTIFY_PATH, LEGACY_NOTIFY_PATH };
|
|
@@ -97,6 +97,42 @@ export function signalExitAction(signal, platform = process.platform, numbers =
|
|
|
97
97
|
*
|
|
98
98
|
* `proc` is a parameter so the whole sequence can be driven in a test child.
|
|
99
99
|
*/
|
|
100
|
+
/**
|
|
101
|
+
* What to say when the upgrade that just succeeded took this install with it,
|
|
102
|
+
* or null when that is not what happened.
|
|
103
|
+
*
|
|
104
|
+
* `npm i -g ccdeck` performed before #340 nests the deck inside a launcher
|
|
105
|
+
* package. Upgrading such an install now writes the deck itself over that
|
|
106
|
+
* launcher, and npm's reify removes everything that was underneath — including
|
|
107
|
+
* the directory this supervisor and its worker are running from. The process
|
|
108
|
+
* survives, because everything it needs is already loaded. The next spawn does
|
|
109
|
+
* not: bin/deck.js is a path, and npm has just deleted it.
|
|
110
|
+
*
|
|
111
|
+
* That reached the user as `could not start …/bin/deck.js: ENOENT` and an exit
|
|
112
|
+
* 1 — a dead deck and an errno, one click after an upgrade that reported
|
|
113
|
+
* success. Nothing was broken. The new version is on the machine, and the
|
|
114
|
+
* command they typed already points at it, because npm rewrote the bin shim in
|
|
115
|
+
* the same install. The only thing missing was a sentence saying so.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately not a hand-off. This process could spawn the successor's worker
|
|
118
|
+
* instead, and that would be a new worker under an old supervisor — a pair
|
|
119
|
+
* nothing has ever tested together, decided at the worst possible moment. One
|
|
120
|
+
* command the user runs themselves is the smaller promise and the one that
|
|
121
|
+
* cannot go wrong.
|
|
122
|
+
*
|
|
123
|
+
* Pure, and given its two filesystem answers rather than asking for them,
|
|
124
|
+
* because the state it describes only ever exists in a process whose own files
|
|
125
|
+
* were deleted after it started — which no test can produce by spawning one.
|
|
126
|
+
*/
|
|
127
|
+
export function replacedNote({ workerExists, moved, product = "ccdeck", command = "ccdeck" }) {
|
|
128
|
+
if (workerExists || !moved) return null;
|
|
129
|
+
return [
|
|
130
|
+
`${product}: the upgrade replaced this install, so it cannot restart in place.`,
|
|
131
|
+
` the new version is in ${moved}`,
|
|
132
|
+
` run \`${command}\` again to start it`,
|
|
133
|
+
].join("\n");
|
|
134
|
+
}
|
|
135
|
+
|
|
100
136
|
export function dieOfSignal(signal, proc = process) {
|
|
101
137
|
const { reraise, code } = signalExitAction(signal, proc.platform);
|
|
102
138
|
if (reraise) {
|
|
@@ -111,12 +111,43 @@ function cpuPercent() {
|
|
|
111
111
|
return Math.max(0, Math.min(100, Math.round(pct * 10) / 10));
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The locale every child of this module is parsed in.
|
|
116
|
+
*
|
|
117
|
+
* Not a preference — a correctness requirement. Every command spawned here has
|
|
118
|
+
* its output read by a regex, and every one of those regexes reads a number
|
|
119
|
+
* with a `.` in it. `ps` and `sysctl` honour LC_NUMERIC, so on a machine set to
|
|
120
|
+
* de_DE, fr_FR, ru_RU or pt_BR — comma is the decimal separator for most of
|
|
121
|
+
* Europe and Latin America — the same commands print:
|
|
122
|
+
*
|
|
123
|
+
* ps 1 0,2 0,0 /sbin/launchd
|
|
124
|
+
* sysctl total = 8192,00M used = 7189,75M free = 1002,25M
|
|
125
|
+
*
|
|
126
|
+
* and the parsers matched nothing at all. Not partially: `parsePsProcesses`
|
|
127
|
+
* `continue`s on every row, so the process panel was permanently empty, and
|
|
128
|
+
* `swapFromSysctl` returned null, so the macOS swap meter was permanently
|
|
129
|
+
* blank. Silently, with nothing in the log, on a machine where everything else
|
|
130
|
+
* worked.
|
|
131
|
+
*
|
|
132
|
+
* Forcing the locale rather than teaching the parsers to read a comma is the
|
|
133
|
+
* fix that scales: it makes the OUTPUT invariant, which is what every parser
|
|
134
|
+
* here was written against and what every parser added later will assume. The
|
|
135
|
+
* comma tolerance below is defence in depth for the case where a sandbox strips
|
|
136
|
+
* the environment, not the primary answer.
|
|
137
|
+
*
|
|
138
|
+
* Meaningless on Windows, where the branches are PowerShell piped through
|
|
139
|
+
* ConvertTo-Json and already culture-invariant — and harmless there for the
|
|
140
|
+
* same reason.
|
|
141
|
+
*/
|
|
142
|
+
const C_LOCALE = { LC_ALL: "C", LANG: "C" };
|
|
143
|
+
|
|
114
144
|
/** Run a command and resolve its stdout, or null. Never rejects, never inherits
|
|
115
|
-
* a shell, and is killed rather than allowed to hang
|
|
145
|
+
* a shell, never inherits a locale, and is killed rather than allowed to hang
|
|
146
|
+
* the sampler. */
|
|
116
147
|
function run(file, args, timeoutMs = 2_000) {
|
|
117
148
|
return new Promise(resolve => {
|
|
118
149
|
let child;
|
|
119
|
-
try { child = spawn(file, args, { windowsHide: true }); }
|
|
150
|
+
try { child = spawn(file, args, { windowsHide: true, env: { ...process.env, ...C_LOCALE } }); }
|
|
120
151
|
catch { return resolve(null); }
|
|
121
152
|
let out = "";
|
|
122
153
|
const timer = setTimeout(() => { try { child.kill(); } catch {} resolve(null); }, timeoutMs);
|
|
@@ -207,10 +238,15 @@ async function readAvailable(platform = process.platform) {
|
|
|
207
238
|
*/
|
|
208
239
|
export function swapFromSysctl(text) {
|
|
209
240
|
const unit = s => {
|
|
210
|
-
|
|
241
|
+
// `,` as well as `.`: C_LOCALE should mean this never arrives, and a parser
|
|
242
|
+
// that fails closed on a whole continent's default is not a thing to leave
|
|
243
|
+
// resting on one environment variable. Safe to accept both here because
|
|
244
|
+
// sysctl formats with printf's %f, which never groups thousands — so a
|
|
245
|
+
// comma in this field can only ever be the decimal point.
|
|
246
|
+
const m = /^([\d.,]+)([KMG])?$/i.exec(s);
|
|
211
247
|
if (!m) return null;
|
|
212
248
|
const mult = { k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[(m[2] || "M").toLowerCase()] ?? 1;
|
|
213
|
-
return Math.round(Number(m[1]) * mult);
|
|
249
|
+
return Math.round(Number(m[1].replace(",", ".")) * mult);
|
|
214
250
|
};
|
|
215
251
|
const total = unit(/total\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
|
|
216
252
|
const used = unit(/used\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
|
|
@@ -320,9 +356,14 @@ export function parsePsProcesses(text, limit = TOP_N) {
|
|
|
320
356
|
const lines = String(text ?? "").trim().split("\n");
|
|
321
357
|
const out = [];
|
|
322
358
|
for (const line of lines.slice(1)) { // drop the header row
|
|
323
|
-
|
|
359
|
+
// Both separators, for the reason swapFromSysctl gives: C_LOCALE is the
|
|
360
|
+
// fix, and this is what keeps a stripped environment from emptying the
|
|
361
|
+
// panel. `%CPU` and `%MEM` are percentages printed with %.1f, so a comma in
|
|
362
|
+
// either can only be the decimal point.
|
|
363
|
+
const m = /^\s*(\d+)\s+([\d.,]+)\s+([\d.,]+)\s+(.+?)\s*$/.exec(line);
|
|
324
364
|
if (!m) continue;
|
|
325
|
-
|
|
365
|
+
const num = v => Number(v.replace(",", "."));
|
|
366
|
+
out.push({ pid: Number(m[1]), cpu: num(m[2]), mem: num(m[3]), name: m[4] });
|
|
326
367
|
if (out.length >= limit) break;
|
|
327
368
|
}
|
|
328
369
|
return out;
|
|
@@ -412,13 +453,66 @@ export function cpuFromDeltas(rows, prev, elapsedMs, limit = TOP_N) {
|
|
|
412
453
|
return out.slice(0, limit);
|
|
413
454
|
}
|
|
414
455
|
|
|
415
|
-
/** The process list, on demand only — never on the ambient timer. */
|
|
416
456
|
/** Previous Windows reading, so the next one can be a rate. Cleared with the
|
|
417
457
|
* rest of the sampler state. */
|
|
418
458
|
let prevProcCpu = null;
|
|
419
459
|
let prevProcAt = 0;
|
|
420
460
|
|
|
461
|
+
/** The run producing the next list, and the last one that finished. */
|
|
462
|
+
let procInFlight = null;
|
|
463
|
+
let procLast = null; // { at, procs }
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* How old a finished reading may be and still answer a caller.
|
|
467
|
+
*
|
|
468
|
+
* Well under SystemMeter's PROC_POLL_MS of 4000, so the panel that this exists
|
|
469
|
+
* for never once gets a cached list; long enough that a second tab, a second
|
|
470
|
+
* browser, or anything else arriving between two of those polls is handed the
|
|
471
|
+
* list the first tab is already looking at rather than starting its own child.
|
|
472
|
+
*/
|
|
473
|
+
const PROC_MIN_GAP_MS = 1_500;
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* The process list, on demand only — never on the ambient timer, and never more
|
|
477
|
+
* than one child at a time.
|
|
478
|
+
*
|
|
479
|
+
* #544: /api/system/processes is a GET with no cache, no dedupe and no
|
|
480
|
+
* throttle, so the number of `powershell.exe Get-Process` children — about six
|
|
481
|
+
* seconds each — was whatever the caller asked for. That is the cheap half of
|
|
482
|
+
* the problem. The expensive half is that concurrent readers also overwrote
|
|
483
|
+
* each other's baseline: cpuFromDeltas needs the PREVIOUS reading's cpuSec per
|
|
484
|
+
* pid, prevProcCpu/prevProcAt are one shared pair, and two readers each stored
|
|
485
|
+
* theirs over the other's, so the CPU column came back computed against a
|
|
486
|
+
* baseline that belonged to somebody else's reading. That is a wrong number on
|
|
487
|
+
* screen, not merely wasted work, and it needed no attacker at all: one
|
|
488
|
+
* Get-Process takes longer than the panel's four-second poll, so a single tab
|
|
489
|
+
* on Windows already overlapped itself.
|
|
490
|
+
*
|
|
491
|
+
* One in-flight run fixes both, because one reader means one baseline. Callers
|
|
492
|
+
* that arrive while a run is going share its promise; callers that arrive just
|
|
493
|
+
* after one finished are served that reading.
|
|
494
|
+
*/
|
|
421
495
|
export async function readProcesses(platform = process.platform) {
|
|
496
|
+
const now = Date.now();
|
|
497
|
+
if (procLast && now - procLast.at < PROC_MIN_GAP_MS) return procLast.procs;
|
|
498
|
+
if (procInFlight) return procInFlight;
|
|
499
|
+
// Only a real reading is remembered. Every failure inside readProcessesNow —
|
|
500
|
+
// a spawn that never started, a non-zero exit, the timeout — resolves to an
|
|
501
|
+
// empty array, and no machine has nothing running on it, so an empty list is
|
|
502
|
+
// a failure by construction. Serving one for the next 1.5s would turn a
|
|
503
|
+
// single hiccup into a blank panel that outlives it; the next caller retries
|
|
504
|
+
// instead. The in-flight share still applies, so a burst arriving during a
|
|
505
|
+
// failing read is one failing child, not a burst of them.
|
|
506
|
+
procInFlight = readProcessesNow(platform)
|
|
507
|
+
.then(procs => {
|
|
508
|
+
if (procs.length) procLast = { at: Date.now(), procs };
|
|
509
|
+
return procs;
|
|
510
|
+
})
|
|
511
|
+
.finally(() => { procInFlight = null; });
|
|
512
|
+
return procInFlight;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function readProcessesNow(platform) {
|
|
422
516
|
if (platform === "win32") {
|
|
423
517
|
const out = await run("powershell.exe", [
|
|
424
518
|
"-NoProfile", "-NonInteractive", "-Command",
|
|
@@ -490,6 +584,10 @@ export function stopSystemMetrics() {
|
|
|
490
584
|
swap = null;
|
|
491
585
|
prevProcCpu = null;
|
|
492
586
|
prevProcAt = 0;
|
|
587
|
+
// The last list goes with the baseline it was computed against. A sampler
|
|
588
|
+
// that stopped and started again must not answer the first caller with a
|
|
589
|
+
// reading from before the stop.
|
|
590
|
+
procLast = null;
|
|
493
591
|
}
|
|
494
592
|
|
|
495
593
|
/**
|
|
@@ -7,17 +7,18 @@
|
|
|
7
7
|
// narrower thing instead: fetch one named release artifact from Astral's own
|
|
8
8
|
// GitHub releases, check it against the SHA-256 that release publishes beside
|
|
9
9
|
// it, and unpack it inside ~/.agents-deck. Nothing is executed until it has
|
|
10
|
-
// been verified, nothing lands outside a directory the deck already owns
|
|
11
|
-
//
|
|
10
|
+
// been verified, nothing lands outside a directory the deck already owns — the
|
|
11
|
+
// staging directory included, which is what #551 was about — and deleting that
|
|
12
|
+
// directory undoes all of it.
|
|
12
13
|
//
|
|
13
14
|
// It is still a network fetch of an executable, so it only happens when there
|
|
14
15
|
// is no other way: uv, pipx, and python -m pipx have all already been ruled out
|
|
15
16
|
// by the caller. AGENTS_DECK_NO_INSTALL=1 disables it along with everything else.
|
|
16
17
|
import { createHash } from "node:crypto";
|
|
17
|
-
import { mkdir, writeFile, rm, chmod, readdir, copyFile, open } from "node:fs/promises";
|
|
18
|
+
import { mkdir, mkdtemp, writeFile, rm, chmod, readdir, copyFile, open } from "node:fs/promises";
|
|
18
19
|
import { existsSync } from "node:fs";
|
|
19
20
|
import { join } from "node:path";
|
|
20
|
-
import { homedir
|
|
21
|
+
import { homedir } from "node:os";
|
|
21
22
|
import { run } from "./exec.mjs";
|
|
22
23
|
// The same rename used to replace settings.json, for the same reason: on
|
|
23
24
|
// Windows a rename over an existing file fails outright while any other process
|
|
@@ -91,10 +92,11 @@ async function download(url, { asText = false } = {}) {
|
|
|
91
92
|
* Inside single quotes PowerShell expands nothing, so a doubled quote is both
|
|
92
93
|
* the only escape it has and the only one needed. Windows filenames cannot
|
|
93
94
|
* contain `"`, `<`, `>` or `|`, but they can contain an apostrophe — and the
|
|
94
|
-
* archive lands under
|
|
95
|
-
*
|
|
96
|
-
* ended the string mid-path and Expand-Archive died of a syntax
|
|
97
|
-
* could never be bootstrapped on an account whose owner has that
|
|
95
|
+
* archive lands under the user's own profile either way, which is where an
|
|
96
|
+
* apostrophe comes from: C:\Users\O'Brien\.agents-deck\tools. Pasted in raw,
|
|
97
|
+
* that apostrophe ended the string mid-path and Expand-Archive died of a syntax
|
|
98
|
+
* error, so uv could never be bootstrapped on an account whose owner has that
|
|
99
|
+
* name.
|
|
98
100
|
*/
|
|
99
101
|
const psQuote = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
100
102
|
|
|
@@ -194,7 +196,9 @@ export async function bootstrapUv() {
|
|
|
194
196
|
const version = await latestVersion();
|
|
195
197
|
const base = `${DOWNLOAD_BASE}/${version}/${asset}`;
|
|
196
198
|
|
|
197
|
-
|
|
199
|
+
// Created inside the try, because it is created rather than merely named:
|
|
200
|
+
// see the mkdtemp below for why that distinction is the whole fix.
|
|
201
|
+
let staging = null;
|
|
198
202
|
// Set while a half-finished binary exists under a name of its own, cleared the
|
|
199
203
|
// moment it is renamed into place; the finally below removes whatever is left.
|
|
200
204
|
let partial = null;
|
|
@@ -214,7 +218,35 @@ export async function bootstrapUv() {
|
|
|
214
218
|
const actual = createHash("sha256").update(archive).digest("hex");
|
|
215
219
|
if (actual !== expected) return { ok: false, reason: "checksum_mismatch" };
|
|
216
220
|
|
|
217
|
-
|
|
221
|
+
// ── where the archive is unpacked, and why it is not the system temp dir ──
|
|
222
|
+
//
|
|
223
|
+
// This used to be `join(tmpdir(), `agents-deck-uv-${process.pid}`)`, created
|
|
224
|
+
// with `mkdir(..., { recursive: true })` — which does not fail on a
|
|
225
|
+
// directory that is already there.
|
|
226
|
+
//
|
|
227
|
+
// On macOS tmpdir() is a per-user `/var/folders/…/T` and on Windows it is
|
|
228
|
+
// inside the profile, so the header's promise held on both. On Linux it is
|
|
229
|
+
// `/tmp`, mode 1777, writable by every account on the box. The comment two
|
|
230
|
+
// hundred lines up reasons carefully about tmpdir() being inside the user's
|
|
231
|
+
// profile ON WINDOWS and never asks the Linux question.
|
|
232
|
+
//
|
|
233
|
+
// Three things then line up. The name is derived from a pid, so it is
|
|
234
|
+
// cheap to blanket the whole plausible range in advance. `findUv` returns a
|
|
235
|
+
// depth-0 file before it recurses, while the genuine uv always sits one
|
|
236
|
+
// level down inside the archive's versioned directory — so a planted
|
|
237
|
+
// `/tmp/agents-deck-uv-<pid>/uv` wins deterministically rather than by
|
|
238
|
+
// racing. And the SHA-256 above is computed on the downloaded buffer, never
|
|
239
|
+
// on the extracted file, so it does not cover the thing that is executed.
|
|
240
|
+
// What followed was a copy, a chmod 0755, a `--version` run, a rename to
|
|
241
|
+
// the permanent name and reuse of that name forever.
|
|
242
|
+
//
|
|
243
|
+
// `mkdtemp` under TOOL_DIR fixes all three at once: the directory is inside
|
|
244
|
+
// a tree the user already owns, its name is unpredictable, and mkdtemp
|
|
245
|
+
// CREATES — it cannot be handed a directory somebody else made. findUv's
|
|
246
|
+
// ordering is left alone deliberately; it is not wrong on its own, and it
|
|
247
|
+
// stops being reachable once nobody else can write here.
|
|
248
|
+
await mkdir(TOOL_DIR, { recursive: true });
|
|
249
|
+
staging = await mkdtemp(join(TOOL_DIR, "uv-staging-"));
|
|
218
250
|
const archivePath = join(staging, asset);
|
|
219
251
|
await writeFile(archivePath, archive);
|
|
220
252
|
if (!(await extract(archivePath, staging))) return { ok: false, reason: "extract_failed" };
|
|
@@ -258,7 +290,12 @@ export async function bootstrapUv() {
|
|
|
258
290
|
} catch (err) {
|
|
259
291
|
return { ok: false, reason: "error", detail: String(err?.message ?? err).slice(0, 200) };
|
|
260
292
|
} finally {
|
|
261
|
-
|
|
293
|
+
// maxRetries for the reason the ccusage repair gives: the archive was just
|
|
294
|
+
// unpacked here and `partial` was just run out of it, and on Windows a file
|
|
295
|
+
// a handle has not finished releasing cannot be deleted — the directory
|
|
296
|
+
// then refuses to go with ENOTEMPTY. Litter rather than a failure, since
|
|
297
|
+
// this is swallowed, but litter in the user's home that nothing else sweeps.
|
|
298
|
+
if (staging) await rm(staging, { recursive: true, force: true, maxRetries: 10, retryDelay: 25 }).catch(() => {});
|
|
262
299
|
if (partial) await rm(partial, { force: true }).catch(() => {});
|
|
263
300
|
}
|
|
264
301
|
}
|