agent-dag 3.8.7 → 3.8.9
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/bin/agent-dag.js +2 -2
- package/bin/deck.js +13 -4
- package/dist/web/assets/{index-GmdcK9zD.js → index-Dq0gpZ5v.js} +8 -2
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/server/browser-presence.mjs +9 -0
- package/src/server/browser-react.mjs +8 -2
- package/src/server/ccusage.mjs +20 -2
- package/src/server/claude-accounts.mjs +17 -1
- package/src/server/cswap-auto.mjs +30 -5
- package/src/server/index.mjs +47 -11
- package/src/server/self-update.mjs +36 -2
- package/src/server/supervisor.mjs +3 -3
- package/src/server/system-metrics.mjs +42 -10
- package/src/server/term.mjs +9 -2
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-Dq0gpZ5v.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.9",
|
|
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,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.9": [
|
|
35
|
+
{
|
|
36
|
+
"title": "\ud83d\udda5\ufe0f Four things that only went wrong on somebody else's operating system",
|
|
37
|
+
"body": "**Ubuntu:** the deck knew where Chromium and Brave keep their profiles when installed as a snap or a flatpak \u2014 which on a default Ubuntu is the only place Chromium is \u2014 but not what those programs are called. So it reported them as not running while they were, and refused to quit them. Both now work.\n\n**Windows:** the deck asked the system whether it could write to the folder an update installs into, and Windows always answers yes for a folder whatever the permissions say. It offered you an update that then died inside npm with a permission error. It now finds out by trying, and declines up front with the command to run yourself \u2014 which is what every other platform already got.\n\n**Linux:** if `XDG_DATA_HOME` was set to something starting with `~`, as it is from a systemd unit or a Dockerfile, the deck and claude-swap looked in two different places for your accounts. The panel said you had none while `cswap list` showed them all.\n\n**Plain terminals:** six messages used a typographic dash the old Windows console and the Linux text console cannot draw, so a box appeared mid-sentence \u2014 including in the line that explains why no events are arriving. They use the plain form on those terminals now, like everything else already did."
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"3.8.8": [
|
|
41
|
+
{
|
|
42
|
+
"title": "\ud83d\udcc9 A false 99% on the memory chart, and three other quiet failures",
|
|
43
|
+
"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."
|
|
44
|
+
}
|
|
45
|
+
],
|
|
34
46
|
"3.8.7": [
|
|
35
47
|
{
|
|
36
48
|
"title": "\ud83e\udde0 Four things in the panel that behaved wrongly",
|
|
@@ -76,6 +76,15 @@ const APP_NAME = {
|
|
|
76
76
|
"chrome-beta": "chrome",
|
|
77
77
|
"chrome-canary": "chrome",
|
|
78
78
|
chromium: "chromium",
|
|
79
|
+
// The two roots `browserRoots` emits and this table used to miss (#794).
|
|
80
|
+
// The snap path's own comment there says it "is the only Chromium root a
|
|
81
|
+
// default Ubuntu install has" — so on stock Ubuntu the panel reported
|
|
82
|
+
// Chromium as not running while it was running, and the quit reaction,
|
|
83
|
+
// which `available("linux")` does offer, answered `unknown_browser` on
|
|
84
|
+
// every finding from either of them. darwin covers all 8 of its roots and
|
|
85
|
+
// win32 all 7 of its own; only this table was short.
|
|
86
|
+
"chromium-snap": "chromium",
|
|
87
|
+
"brave-flatpak": "brave",
|
|
79
88
|
brave: "brave",
|
|
80
89
|
edge: "msedge",
|
|
81
90
|
vivaldi: "vivaldi-bin",
|
|
@@ -206,10 +206,16 @@ export async function closeTab(browserKey, url, platform = process.platform, dep
|
|
|
206
206
|
|
|
207
207
|
/** Quit a browser. The only reaction that takes the session back. */
|
|
208
208
|
export async function quitBrowser(browserKey, platform = process.platform, deps = {}) {
|
|
209
|
-
const app = appName(browserKey);
|
|
210
|
-
if (!app) return { ok: false, reason: "unknown_browser" };
|
|
211
209
|
const exec = deps.run ?? run;
|
|
212
210
|
if (platform === "darwin") {
|
|
211
|
+
// The darwin display-name table is consulted HERE rather than at the top of
|
|
212
|
+
// the function (#794). It used to gate every platform, so a browser missing
|
|
213
|
+
// from a macOS-only table — `chromium-snap` and `brave-flatpak`, the two
|
|
214
|
+
// roots a default Ubuntu install actually has — answered `unknown_browser`
|
|
215
|
+
// on Linux before the branch that would have known what to do with it was
|
|
216
|
+
// ever reached.
|
|
217
|
+
const app = appName(browserKey);
|
|
218
|
+
if (!app) return { ok: false, reason: "unknown_browser" };
|
|
213
219
|
const r = await exec("osascript", [
|
|
214
220
|
// `--`, as everywhere else that hands osascript an operand. `app` comes
|
|
215
221
|
// from a fixed table so it is safe by construction; the separator costs
|
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
|
/**
|
|
@@ -39,7 +39,23 @@ import { homedir, platform } from "node:os";
|
|
|
39
39
|
export function backupRoot() {
|
|
40
40
|
if (process.env.CLAUDE_SWAP_BACKUP) return process.env.CLAUDE_SWAP_BACKUP;
|
|
41
41
|
if (platform() === "linux") {
|
|
42
|
-
|
|
42
|
+
// `~` IS EXPANDED BEFORE THE ABSOLUTENESS TEST (#796), which is what
|
|
43
|
+
// claude-swap's own paths.py does: `Path(os.path.expanduser(xdg))` and then
|
|
44
|
+
// `is_absolute()`, under a docstring saying it exists so that "values like
|
|
45
|
+
// `~/data` set via systemd unit files or Dockerfiles (which don't get shell
|
|
46
|
+
// expansion) still work". This function claims to mirror that and did not.
|
|
47
|
+
//
|
|
48
|
+
// Unexpanded, `XDG_DATA_HOME=~/data` failed `startsWith("/")` and the deck
|
|
49
|
+
// read ~/.local/share/claude-swap while cswap read and wrote
|
|
50
|
+
// ~/data/claude-swap. The Accounts panel then reported `no_accounts` while
|
|
51
|
+
// `cswap list` showed the roster — and the damaging part is
|
|
52
|
+
// `seedFirstAccount`, which reads a missing sequence file as zero accounts,
|
|
53
|
+
// passes its `before > 0` guard, and runs `cswap add` against a populated
|
|
54
|
+
// store, re-pointing activeAccountNumber with nothing here to restore it.
|
|
55
|
+
const raw = process.env.XDG_DATA_HOME;
|
|
56
|
+
const xdg = raw === "~" ? homedir()
|
|
57
|
+
: raw?.startsWith("~/") ? join(homedir(), raw.slice(2))
|
|
58
|
+
: raw;
|
|
43
59
|
if (xdg && xdg.startsWith("/")) return join(xdg, "claude-swap");
|
|
44
60
|
return join(homedir(), ".local/share/claude-swap");
|
|
45
61
|
}
|
|
@@ -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;
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// it by name and only where it can actually work: a global install, on a
|
|
23
23
|
// directory we can write, outside a git checkout and outside an npx cache.
|
|
24
24
|
// Everywhere else this stays what it has always been — a printed command.
|
|
25
|
-
import { accessSync, constants as FS, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { accessSync, constants as FS, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
26
26
|
import { spawn } from "node:child_process";
|
|
27
27
|
import { homedir } from "node:os";
|
|
28
28
|
import { dirname, join, resolve } from "node:path";
|
|
@@ -894,8 +894,42 @@ export function upgradeMode(blockedReason) {
|
|
|
894
894
|
return blockedReason === "npx" ? "npx" : null;
|
|
895
895
|
}
|
|
896
896
|
|
|
897
|
+
/**
|
|
898
|
+
* Can this user write into `p`?
|
|
899
|
+
*
|
|
900
|
+
* ON WINDOWS THE QUESTION HAS TO BE ASKED BY WRITING (#795). libuv's
|
|
901
|
+
* `fs__access` short-circuits to success for anything carrying
|
|
902
|
+
* FILE_ATTRIBUTE_DIRECTORY — "Directories cannot be read-only on Windows" — and
|
|
903
|
+
* never consults the ACL; Node documents this. Both arguments this is called
|
|
904
|
+
* with are always directories, so `writable` was unconditionally true there,
|
|
905
|
+
* `upgradeBlockedReason` could never answer `not_writable`, and the banner
|
|
906
|
+
* offered "Update & restart" on the common nvm-windows layout where the global
|
|
907
|
+
* prefix is C:\Program Files\nodejs and a non-elevated shell cannot write it.
|
|
908
|
+
* npm then died with EPERM and the user got a truncated npm log tail in a
|
|
909
|
+
* 46-character box — where a POSIX user in the same position gets "the install
|
|
910
|
+
* directory is not writable by this user" and the command to paste. The comment
|
|
911
|
+
* on upgradeBlockedReason states the intent the platform defeated: "Failing
|
|
912
|
+
* inside npm with EACCES tells the user less than declining up front does."
|
|
913
|
+
*
|
|
914
|
+
* The probe is `createTemp`'s "wx" pattern, synchronously: a name nothing else
|
|
915
|
+
* can be using, created exclusively and removed at once. POSIX keeps
|
|
916
|
+
* `accessSync`, where it is correct and cheaper.
|
|
917
|
+
*/
|
|
897
918
|
function dirWritable(p) {
|
|
898
|
-
|
|
919
|
+
if (process.platform !== "win32") {
|
|
920
|
+
try { accessSync(p, FS.W_OK); return true; } catch { return false; }
|
|
921
|
+
}
|
|
922
|
+
// `wx` fails if the name exists, so a collision reads as "not writable"
|
|
923
|
+
// rather than clobbering somebody's file — hence pid and a random suffix.
|
|
924
|
+
const probe = join(p, `.ccdeck-w-${process.pid}-${Math.random().toString(36).slice(2, 8)}`);
|
|
925
|
+
try {
|
|
926
|
+
writeFileSync(probe, "", { flag: "wx" });
|
|
927
|
+
return true;
|
|
928
|
+
} catch {
|
|
929
|
+
return false;
|
|
930
|
+
} finally {
|
|
931
|
+
try { unlinkSync(probe); } catch { /* never created, or already gone */ }
|
|
932
|
+
}
|
|
899
933
|
}
|
|
900
934
|
|
|
901
935
|
/** The same question, answered against the real filesystem and environment. */
|
|
@@ -225,13 +225,13 @@ export function upgradeAttempt({
|
|
|
225
225
|
* banner and the terminal say, so it has to name the version, say what already
|
|
226
226
|
* happened to it, and leave the user somewhere to go. The copyable command is
|
|
227
227
|
* already on screen next to it. */
|
|
228
|
-
export function upgradeRefusalText({ reason, waitMs = 0, attempt = 0 } = {}, target = null) {
|
|
228
|
+
export function upgradeRefusalText({ reason, waitMs = 0, attempt = 0, dash = "—" } = {}, target = null) {
|
|
229
229
|
const what = target ? `v${target}` : "this update";
|
|
230
230
|
if (reason === "exhausted") {
|
|
231
|
-
return `${what} failed to fetch ${attempt} times
|
|
231
|
+
return `${what} failed to fetch ${attempt} times ${dash} not trying again from here; run the command yourself`;
|
|
232
232
|
}
|
|
233
233
|
const left = waitMs >= 60_000 ? `${Math.ceil(waitMs / 60_000)}m` : `${Math.max(1, Math.ceil(waitMs / 1000))}s`;
|
|
234
|
-
return `${what} failed to fetch a moment ago
|
|
234
|
+
return `${what} failed to fetch a moment ago ${dash} waiting ${left} before trying again`;
|
|
235
235
|
}
|
|
236
236
|
|
|
237
237
|
/**
|
|
@@ -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; }
|
package/src/server/term.mjs
CHANGED
|
@@ -628,12 +628,19 @@ export function pulseDot(beat, { registered = true, claude = true, busy = null }
|
|
|
628
628
|
*
|
|
629
629
|
* @param file the discovery file that could not be written.
|
|
630
630
|
* @param claude whether this deck is watching Claude Code at all.
|
|
631
|
+
* @param dash the em dash, or the ASCII stand-in on a console that cannot draw
|
|
632
|
+
* one (#797). A parameter for the same reason `renameNotice` takes one: this
|
|
633
|
+
* module's own header says "every glyph the deck prints — punctuation
|
|
634
|
+
* included, since an em dash is as absent from CP437 as a check mark — comes
|
|
635
|
+
* from one of them", and this sentence had it hardcoded. On a legacy cmd.exe
|
|
636
|
+
* or the Linux virtual console the reader got a box in the middle of the one
|
|
637
|
+
* line explaining why no events are arriving.
|
|
631
638
|
*/
|
|
632
|
-
export function unregisteredDetail({ file, claude = true }) {
|
|
639
|
+
export function unregisteredDetail({ file, claude = true, dash = "—" }) {
|
|
633
640
|
if (claude) {
|
|
634
641
|
return `Claude Code hooks find this deck through ${file}, so until that file exists no Claude Code events arrive.`;
|
|
635
642
|
}
|
|
636
|
-
return `Codex capture does not use ${file}
|
|
643
|
+
return `Codex capture does not use ${file} ${dash} the deck tails the rollout files itself ${dash} so events still arrive. Only decks sharing one events log need it, to agree on which of them records.`;
|
|
637
644
|
}
|
|
638
645
|
|
|
639
646
|
// ── cursor ───────────────────────────────────────────────────────────────────
|