agent-dag 3.8.7 → 3.8.8
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/dist/web/assets/{index-GmdcK9zD.js → index-DLRqo4Ty.js} +8 -2
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/server/ccusage.mjs +20 -2
- package/src/server/cswap-auto.mjs +30 -5
- package/src/server/index.mjs +47 -11
- package/src/server/system-metrics.mjs +42 -10
package/dist/web/index.html
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-DLRqo4Ty.js"></script>
|
|
44
44
|
<link rel="stylesheet" crossorigin href="/assets/index-ByAgTqB8.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.8",
|
|
4
4
|
"description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
"about a defect in the type, and the suite refuses both it and no space at",
|
|
32
32
|
"all."
|
|
33
33
|
],
|
|
34
|
+
"3.8.8": [
|
|
35
|
+
{
|
|
36
|
+
"title": "\ud83d\udcc9 A false 99% on the memory chart, and three other quiet failures",
|
|
37
|
+
"body": "**The machine meter no longer invents a reading.** When the deck could not measure memory \u2014 a subprocess that would not start under load, or one that took too long \u2014 it substituted a number that means something else, and on a Mac that reads as about 99% used. The meter went red for half a minute, and because the chart keeps the highest value in each minute, that false peak stayed on it for a full day. A poll that cannot measure now keeps the last reading and records nothing: a gap in the chart is honest.\n\n**A failed ccusage repair no longer disables the repair.** If the deck found a half-installed copy it removes it and starts again. On Windows that removal can fail for a few seconds because the program it just ran still has the folder open \u2014 and the deck then treated the attempt as spent and never tried again for the rest of the session, leaving the panel permanently unable to read your usage. It now only counts a repair that happened.\n\n**Changing the auto-switch interval takes effect even during startup.** Set it in the first seconds after launch and the timer kept the old value for the life of the process, while the panel showed the new one.\n\n**Codex sessions with very large histories no longer cost hundreds of megabytes to read.** The deck read the whole file to find four values that live at its two ends \u2014 measured at 185 MB of memory and 95ms of frozen interface for one 44 MB session. It now reads only the ends."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
34
40
|
"3.8.7": [
|
|
35
41
|
{
|
|
36
42
|
"title": "\ud83e\udde0 Four things in the panel that behaved wrongly",
|
package/src/server/ccusage.mjs
CHANGED
|
@@ -682,7 +682,19 @@ let _repairedThisRun = false;
|
|
|
682
682
|
function discardDamagedInstall(runner, err) {
|
|
683
683
|
if (_repairedThisRun || runner.kind !== "node" || installsDisabled()) return false;
|
|
684
684
|
if (!cannotLoadModule(err?.message)) return false;
|
|
685
|
-
|
|
685
|
+
// THE FLAG IS SPENT ON A REPAIR THAT HAPPENED, not on one that was attempted
|
|
686
|
+
// (#790). It used to be set here, before the try — and the rm below fails on
|
|
687
|
+
// Windows for the reason its own maxRetries comment gives: the `node <entry>`
|
|
688
|
+
// child that just exited still holds a handle, the unlink marks the file
|
|
689
|
+
// delete-pending, and rmdir answers ENOTEMPTY past all ten retries. The catch
|
|
690
|
+
// returned false with NOTHING removed, and every later modal open and every
|
|
691
|
+
// 60s poll for the life of the deck then short-circuited on this same flag —
|
|
692
|
+
// including seconds later, once the handle was gone and the rm would have
|
|
693
|
+
// worked. The user's only way out was deleting ~/.agents-deck/ccusage by
|
|
694
|
+
// hand, which nothing tells them.
|
|
695
|
+
//
|
|
696
|
+
// The budget exists to stop a loop of INSTALLS. Here it was being consumed by
|
|
697
|
+
// a repair that never happened and never reached an install.
|
|
686
698
|
try {
|
|
687
699
|
// maxRetries because of what has just happened: the deck ran `node <entry>`
|
|
688
700
|
// out of this very directory a moment ago, and on Windows a file any handle
|
|
@@ -700,7 +712,13 @@ function discardDamagedInstall(runner, err) {
|
|
|
700
712
|
// rather than retrying into the same broken entry point.
|
|
701
713
|
return false;
|
|
702
714
|
}
|
|
703
|
-
|
|
715
|
+
const gone = !resolveEntry();
|
|
716
|
+
// Only now. The directory is really gone, so this deck has spent its one
|
|
717
|
+
// repair and the budget is doing its job. A failed `rm` returned false above
|
|
718
|
+
// without touching the flag, so the next poll — seconds later, once the
|
|
719
|
+
// handle is released — is free to try again.
|
|
720
|
+
if (gone) _repairedThisRun = true;
|
|
721
|
+
return gone;
|
|
704
722
|
}
|
|
705
723
|
|
|
706
724
|
/**
|
|
@@ -268,6 +268,8 @@ export async function setCswapConfig(key, value) {
|
|
|
268
268
|
// Lowering it was equally inert.
|
|
269
269
|
if (r.ok && key === "autoswitch.intervalSeconds" && _enabled) {
|
|
270
270
|
stopLoop();
|
|
271
|
+
// startLoop records the ask when one is already in flight, so this can no
|
|
272
|
+
// longer be swallowed by the boot's own start — see the note there (#791).
|
|
271
273
|
await startLoop();
|
|
272
274
|
}
|
|
273
275
|
return r.ok ? { ok: true } : { ok: false, reason: "set_failed", detail: (r.stderr || r.stdout).trim().slice(0, 300) };
|
|
@@ -580,19 +582,42 @@ function tick() {
|
|
|
580
582
|
* has turned it off is the same defect from the other side.
|
|
581
583
|
*/
|
|
582
584
|
async function startLoop() {
|
|
583
|
-
|
|
585
|
+
// A start requested while one is already in flight is REMEMBERED, not dropped
|
|
586
|
+
// (#791). `initCswapAuto()` is fired unawaited at boot, so this sits inside
|
|
587
|
+
// `await tickInterval()` — a `cswap config` spawn, preceded on Windows by
|
|
588
|
+
// cswapBin() probing up to four spellings, two of them through cmd.exe, each
|
|
589
|
+
// with an 8s deadline — while the panel is already serving. A user setting
|
|
590
|
+
// the interval in that window called stopLoop() (no timer yet: a no-op) and
|
|
591
|
+
// then startLoop(), which returned here having done nothing; the boot's own
|
|
592
|
+
// start then resumed with the interval it had read BEFORE the write and
|
|
593
|
+
// installed the timer at the old value. The panel read back the new number
|
|
594
|
+
// while the loop kept the old one for the life of the process.
|
|
595
|
+
if (_starting) { _restartWanted = true; return; }
|
|
596
|
+
if (_timer) return;
|
|
584
597
|
_starting = true;
|
|
585
598
|
try {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
599
|
+
// Loop rather than a single pass: the config may be written again while
|
|
600
|
+
// THIS read is in flight, and the answer must be the last one written.
|
|
601
|
+
for (;;) {
|
|
602
|
+
_restartWanted = false;
|
|
603
|
+
const ms = await tickInterval();
|
|
604
|
+
if (!_enabled) return; // turned off while we were asking cswap
|
|
605
|
+
if (_restartWanted) continue; // the interval changed under this read
|
|
606
|
+
_timer = setInterval(() => { tick().catch(() => {}); }, ms);
|
|
607
|
+
_timer.unref?.();
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
590
610
|
} finally {
|
|
591
611
|
_starting = false;
|
|
592
612
|
}
|
|
593
613
|
tick().catch(() => {}); // don't make the user wait a full interval for the first one
|
|
594
614
|
}
|
|
595
615
|
|
|
616
|
+
/** Set when a restart is asked for while `startLoop` is mid-read, so the read
|
|
617
|
+
* that is already running takes the new value instead of installing the old
|
|
618
|
+
* one. Module-level beside `_starting`, which it exists to answer for. */
|
|
619
|
+
let _restartWanted = false;
|
|
620
|
+
|
|
596
621
|
function stopLoop() {
|
|
597
622
|
if (_timer) { clearInterval(_timer); _timer = null; }
|
|
598
623
|
}
|
package/src/server/index.mjs
CHANGED
|
@@ -1851,21 +1851,57 @@ async function findCodexRolloutPath(sid) {
|
|
|
1851
1851
|
return found;
|
|
1852
1852
|
}
|
|
1853
1853
|
|
|
1854
|
-
/**
|
|
1855
|
-
*
|
|
1856
|
-
*
|
|
1857
|
-
|
|
1854
|
+
/**
|
|
1855
|
+
* The head and the tail of a Codex rollout, never the middle.
|
|
1856
|
+
*
|
|
1857
|
+
* WHAT THIS FIXES (#792). It called itself a tail-read and read the whole file:
|
|
1858
|
+
* `Buffer.alloc(s.size)` in one go, then `split` and `JSON.parse` over every
|
|
1859
|
+
* line. Measured against this machine's own largest rollout — 44.4 MB — that is
|
|
1860
|
+
* 181ms to read and decode, 95ms of SYNCHRONOUS parsing blocking the event
|
|
1861
|
+
* loop, and 185 MB of resident memory for the buffer, the string and the split
|
|
1862
|
+
* array together. Past about 512 MB `toString` throws outright, the catch
|
|
1863
|
+
* answers null, and the deck reports no Codex usage or model for the rest of
|
|
1864
|
+
* its life.
|
|
1865
|
+
*
|
|
1866
|
+
* It is reached from `/api/event`, which is in OPEN_MUTATIONS — no token, no
|
|
1867
|
+
* browser identity — so any local process can ask for it, and the per-session
|
|
1868
|
+
* throttle is keyed on `sid`, which means distinct sid strings each get their
|
|
1869
|
+
* own budget. That note's own rule is the one this violated: the deck's memory
|
|
1870
|
+
* "cannot be a function of anything but the two constants named here".
|
|
1871
|
+
*
|
|
1872
|
+
* WHY HEAD AND TAIL RATHER THAN A CURSOR. The four fields do not live in one
|
|
1873
|
+
* place. `session_meta` is the FIRST record — cwd, sometimes the model — while
|
|
1874
|
+
* the last `token_count`, the last `task_started` and the newest
|
|
1875
|
+
* `response_item` model are all at the END. A forward cursor would have to keep
|
|
1876
|
+
* the head's answers across polls, which is a second cache to invalidate; two
|
|
1877
|
+
* bounded reads answer the same question with no state at all.
|
|
1878
|
+
*
|
|
1879
|
+
* A file smaller than both windows is read once, so nothing changes for the
|
|
1880
|
+
* ordinary rollout — the median here is well under a megabyte.
|
|
1881
|
+
*/
|
|
1882
|
+
const CODEX_HEAD_BYTES = 256 * 1024;
|
|
1883
|
+
const CODEX_TAIL_BYTES = 2 * 1024 * 1024;
|
|
1884
|
+
|
|
1885
|
+
// Exported for the suite, which is the only way to drive the head-and-tail
|
|
1886
|
+
// branch: building a rollout larger than both windows is cheap, and reaching
|
|
1887
|
+
// this function through /api/event would mean a server, a discovery file and a
|
|
1888
|
+
// throttle that all have nothing to do with what is being measured.
|
|
1889
|
+
export async function readCodexRollout(path) {
|
|
1858
1890
|
try {
|
|
1859
1891
|
const s = await stat(path);
|
|
1860
1892
|
if (s.size === 0) return null;
|
|
1861
|
-
const fh = await open(path, "r");
|
|
1862
1893
|
let text;
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1894
|
+
if (s.size <= CODEX_HEAD_BYTES + CODEX_TAIL_BYTES) {
|
|
1895
|
+
text = await readByteRange(path, 0, s.size);
|
|
1896
|
+
} else {
|
|
1897
|
+
// A newline between them so the two windows cannot splice a half line
|
|
1898
|
+
// from the head onto a half line from the tail and hand the parser a
|
|
1899
|
+
// record that never existed. The tail's own first line is partial by
|
|
1900
|
+
// construction and is dropped the same way: `JSON.parse` refuses it and
|
|
1901
|
+
// the loop below skips it.
|
|
1902
|
+
const head = await readByteRange(path, 0, CODEX_HEAD_BYTES);
|
|
1903
|
+
const tail = await readByteRange(path, s.size - CODEX_TAIL_BYTES, s.size);
|
|
1904
|
+
text = `${head}\n${tail}`;
|
|
1869
1905
|
}
|
|
1870
1906
|
let lastUsage = null;
|
|
1871
1907
|
let model = null;
|
|
@@ -262,6 +262,32 @@ export function availableFromVmStat(text, total) {
|
|
|
262
262
|
return total != null && avail > total ? null : avail;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
/**
|
|
266
|
+
* How much memory is really available, or NULL when this machine could not be
|
|
267
|
+
* asked.
|
|
268
|
+
*
|
|
269
|
+
* `os.freemem()` USED TO BE THE FALLBACK ON BOTH REAL PLATFORMS, and it is the
|
|
270
|
+
* one number this function exists to avoid (#789). The header above says why:
|
|
271
|
+
* counting only genuinely free pages makes the naive `(total - free) / total`
|
|
272
|
+
* read 99.5% on an idle 32 GB Mac — "a number that would send the reader
|
|
273
|
+
* straight to Activity Monitor, which is the one outcome this readout exists to
|
|
274
|
+
* prevent". So a failed measurement produced exactly the reading the module was
|
|
275
|
+
* written to suppress.
|
|
276
|
+
*
|
|
277
|
+
* And it did not merely flicker. `record` folds into the minute bucket by
|
|
278
|
+
* MAXIMUM, so one failed poll painted a red 99% peak on the memory chart that
|
|
279
|
+
* survived every good sample for the next twenty-four hours. The failure is
|
|
280
|
+
* ordinary: `run` resolves null on a spawn error (EAGAIN/EMFILE under fork
|
|
281
|
+
* pressure — a deck watching many agents is exactly that), on a non-zero exit,
|
|
282
|
+
* and on its own 2s deadline. 2,880 chances a day.
|
|
283
|
+
*
|
|
284
|
+
* Null instead, and the caller keeps the previous reading and records nothing.
|
|
285
|
+
* A gap in the chart is honest; a 99% peak is not.
|
|
286
|
+
*
|
|
287
|
+
* The last branch still answers `freemem()` because on Windows there is no
|
|
288
|
+
* better source to have failed — it is the measurement, not a substitute for
|
|
289
|
+
* one.
|
|
290
|
+
*/
|
|
265
291
|
async function readAvailable(platform = process.platform) {
|
|
266
292
|
const total = os.totalmem();
|
|
267
293
|
|
|
@@ -269,14 +295,13 @@ async function readAvailable(platform = process.platform) {
|
|
|
269
295
|
try {
|
|
270
296
|
const parsed = availableFromMeminfo(await readFile("/proc/meminfo", "utf8"));
|
|
271
297
|
if (parsed != null) return parsed;
|
|
272
|
-
} catch { /*
|
|
273
|
-
return
|
|
298
|
+
} catch { /* unreadable /proc — say so rather than guessing */ }
|
|
299
|
+
return null;
|
|
274
300
|
}
|
|
275
301
|
|
|
276
302
|
if (platform === "darwin") {
|
|
277
303
|
const out = await run("vm_stat", []);
|
|
278
|
-
|
|
279
|
-
return parsed ?? os.freemem();
|
|
304
|
+
return (out ? availableFromVmStat(out, total) : null) ?? null;
|
|
280
305
|
}
|
|
281
306
|
|
|
282
307
|
return os.freemem();
|
|
@@ -1382,15 +1407,22 @@ async function sampleMemory() {
|
|
|
1382
1407
|
try {
|
|
1383
1408
|
const total = os.totalmem();
|
|
1384
1409
|
const available = await readAvailable();
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1410
|
+
// A poll that could not measure leaves the last reading standing and puts
|
|
1411
|
+
// nothing in the history (#789). Recording a guess here is worse than
|
|
1412
|
+
// recording nothing twice over: the meter would go red for 30 seconds, and
|
|
1413
|
+
// the bucket's Math.max would keep that peak on the chart for a day.
|
|
1414
|
+
// Swap below is a separate measurement and is still taken.
|
|
1415
|
+
if (available != null) {
|
|
1416
|
+
memory = {
|
|
1417
|
+
total,
|
|
1418
|
+
available,
|
|
1419
|
+
usedPct: Math.max(0, Math.min(100, Math.round(((total - available) / total) * 1000) / 10)),
|
|
1420
|
+
};
|
|
1421
|
+
record("mem:physical", memory.usedPct);
|
|
1422
|
+
}
|
|
1390
1423
|
// Same 30s cadence as memory, and for the same reason: it moves in minutes
|
|
1391
1424
|
// and costs a subprocess on two of the three platforms.
|
|
1392
1425
|
swap = await readSwap();
|
|
1393
|
-
record("mem:physical", memory.usedPct);
|
|
1394
1426
|
if (swap && swap.total > 0) record("mem:swap", Math.round((swap.used / swap.total) * 1000) / 10);
|
|
1395
1427
|
} catch { /* keep the previous reading rather than blanking the meter */ }
|
|
1396
1428
|
finally { memInFlight = false; }
|