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.
@@ -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.js, which
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
- const NOTIFY_PATH = join(INSTALL_DIR, "notify.js");
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 injectable, so the escaping
55
- * is checked against a path the test names.
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
- return `${shellQuoteArg(node)} ${shellQuoteArg(notifyPath)}`;
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.js,
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.js from sessions that are already running, and toggling the sound
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(join(PKG_ROOT, "hook", "notify.js"), NOTIFY_PATH);
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
- // Both paths are exported so a test can prove it is pointed at a sandbox
387
- // before it writes anything — the real ones are the user's own settings.
388
- export { SETTINGS_PATH, PARKED_PATH };
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 the sampler. */
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
- const m = /^([\d.]+)([KMG])?$/i.exec(s);
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
- const m = /^\s*(\d+)\s+([\d.]+)\s+([\d.]+)\s+(.+?)\s*$/.exec(line);
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
- out.push({ pid: Number(m[1]), cpu: Number(m[2]), mem: Number(m[3]), name: m[4] });
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, and
11
- // deleting that directory undoes all of it.
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, tmpdir } from "node:os";
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 os.tmpdir(), which on Windows is inside the user's own
95
- * profile: C:\Users\O'Brien\AppData\Local\Temp. Pasted in raw, that apostrophe
96
- * ended the string mid-path and Expand-Archive died of a syntax error, so uv
97
- * could never be bootstrapped on an account whose owner has that name.
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
- const staging = join(tmpdir(), `agents-deck-uv-${process.pid}`);
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
- await mkdir(staging, { recursive: true });
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
- await rm(staging, { recursive: true, force: true }).catch(() => {});
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
  }