agent-dag 3.8.6 → 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.
@@ -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-BnUiIfx6.js"></script>
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.6",
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": {
@@ -31,6 +31,18 @@
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
+ ],
40
+ "3.8.7": [
41
+ {
42
+ "title": "\ud83e\udde0 Four things in the panel that behaved wrongly",
43
+ "body": "**Today's figure no longer overshoots.** Once a minute, when a fresh reading landed, the headline briefly counted past the true number and back down \u2014 it was measuring the last minute of spend from a starting point one moment out of date. The reading and its starting point are now recorded together, so there is no moment where they can disagree.\n\n**Browser Watch marks findings read when you close it, not when you open it.** It was clearing the badge immediately, and stamping every new finding as read the moment it arrived \u2014 so anything that turned up while you were reading was never badged at all. It was also writing to storage four times a second the whole time the panel was open.\n\n**A hidden tool category can always be brought back.** Hide one, leave the deck running, and once the canvas had turned over the row of chips disappeared while the filter stayed on \u2014 no bubbles, and nothing to press. Only reloading fixed it. The chips now stay for as long as anything is hidden.\n\n**Clicking a session's name no longer switches auto-fit off.** The deck was reading its own camera move as you grabbing the canvas, and remembered it across restarts, with nothing said."
44
+ }
45
+ ],
34
46
  "3.8.6": [
35
47
  {
36
48
  "title": "\ud83d\uddc2\ufe0f Two decks can no longer both claim the same log",
@@ -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
- _repairedThisRun = true;
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
- return !resolveEntry();
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
- if (_timer || _starting) return;
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
- const ms = await tickInterval();
587
- if (!_enabled) return; // turned off while we were asking cswap
588
- _timer = setInterval(() => { tick().catch(() => {}); }, ms);
589
- _timer.unref?.();
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
  }
@@ -1851,21 +1851,57 @@ async function findCodexRolloutPath(sid) {
1851
1851
  return found;
1852
1852
  }
1853
1853
 
1854
- /** Tail-read a Codex rollout JSONL. Returns the last token_count info block
1855
- * plus the most recent observed model + the session's model_context_window
1856
- * (set once from task_started). */
1857
- async function readCodexRollout(path) {
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
- try {
1864
- const buf = Buffer.alloc(s.size);
1865
- await fh.read(buf, 0, s.size, 0);
1866
- text = buf.toString("utf8");
1867
- } finally {
1868
- await fh.close();
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 { /* fall through to freemem */ }
273
- return os.freemem();
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
- const parsed = out ? availableFromVmStat(out, total) : null;
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
- memory = {
1386
- total,
1387
- available,
1388
- usedPct: Math.max(0, Math.min(100, Math.round(((total - available) / total) * 1000) / 10)),
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; }