agent-dag 3.11.0 → 3.12.0
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-XrBtr430.css → index-ClmtUpYO.css} +1 -1
- package/dist/web/assets/{index-Dya_sxoa.js → index-DfcEnmf5.js} +22 -22
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/src/server/index.mjs +9 -1
- package/src/server/system-metrics.mjs +300 -17
package/dist/web/index.html
CHANGED
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-DfcEnmf5.js"></script>
|
|
44
|
+
<link rel="stylesheet" crossorigin href="/assets/index-ClmtUpYO.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
|
47
47
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-dag",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.0",
|
|
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,16 @@
|
|
|
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.12.0": [
|
|
35
|
+
{
|
|
36
|
+
"title": "🔍 Busiest processes tells you which one",
|
|
37
|
+
"body": "The list could say what was eating the machine and not **which** of nine identical `Google Chrome Helper` rows was the headless Chrome you left running. It now shows the arguments after the name — `--type=renderer` beside one, `serve admin-portal --port 44440` beside a node — and four columns it did not have: **memory in MB and GB** instead of a percentage, **threads**, **how long it has been up**, and **user**. Every column sorts; press its heading.\n\n**Command lines have anything secret-shaped removed before they leave your machine** — the value of any flag whose name says token, password, key or secret, the password in a URL, and the shapes that identify themselves. It is a filter and not a guarantee: a secret that looks like an ordinary word looks like an ordinary word. The reading happens only while this modal is open, so a deck whose process list you never open never reads a command line at all.\n\n**Memory is resident set size** — what `ps` and Windows Task Manager report. On macOS this is *not* the number Activity Monitor shows in its Memory column; that one is the physical footprint, and on the machine this was built on the two disagree by up to fourteen times on the same process. No unprivileged command reports the footprint quickly, so the deck shows the figure that means the same thing on all three platforms and says which one it is.\n\n**There is no Ports column**, and the reason is the same kind of honesty: Mach ports do not exist on Linux, are not what Windows calls a handle, and on macOS the only way to count them takes four seconds — against a list that refreshes every four."
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"title": "🔢 A token count that stays a whole number",
|
|
41
|
+
"body": "Switching the Usage panel from **month** to **today** counted down smoothly and then, for a fraction of a second, showed something like `in 594.3268260610639` where a token count belongs.\n\nThe count was never wrong; the printing was. The figures animate between two readings, so the formatter is handed a fraction on most of its frames — every size above a thousand was already rounded and the one below it was not."
|
|
42
|
+
}
|
|
43
|
+
],
|
|
34
44
|
"3.11.0": [
|
|
35
45
|
{
|
|
36
46
|
"title": "🗂 The session list waits to be asked for",
|
package/src/server/index.mjs
CHANGED
|
@@ -5452,7 +5452,15 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5452
5452
|
// `total` is how many the machine is running, against the candidates
|
|
5453
5453
|
// actually sent. The modal says both, so a reader can see this is a
|
|
5454
5454
|
// selection rather than a task manager pretending to be complete.
|
|
5455
|
-
|
|
5455
|
+
// `detail=1` adds threads, uptime and the redacted command tail, and it
|
|
5456
|
+
// is the modal that asks for it. Three reasons it is not the default,
|
|
5457
|
+
// measured rather than assumed: it is a second `ps` child per poll (240ms
|
|
5458
|
+
// against 100), it is 12 KB of the 14 KB payload, and it is the only part
|
|
5459
|
+
// of this reading that has ever been near an argument vector. The panel
|
|
5460
|
+
// draws four columns and needs none of it, so on a deck where the modal
|
|
5461
|
+
// is never opened the argv is never read at all.
|
|
5462
|
+
const detail = url.searchParams.get("detail") === "1";
|
|
5463
|
+
return guard(readProcesses(process.platform, detail).then(r => send(res, 200, { ok: true, ...r })), res);
|
|
5456
5464
|
}
|
|
5457
5465
|
// A day of minute buckets, which is far too much to ride along on
|
|
5458
5466
|
// /api/system's three-second poll for a chart that is usually closed. Its
|
|
@@ -448,13 +448,209 @@ export function pickCandidates(rows, limit = CANDIDATE_N) {
|
|
|
448
448
|
* prove which flags were passed to produce it.
|
|
449
449
|
*/
|
|
450
450
|
export function psArgs(platform = process.platform) {
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
451
|
+
// SEVEN FIELDS, THE SAME SEVEN ON BOTH, and `args` last because it is the one
|
|
452
|
+
// that contains spaces. `etime` is `[[DD-]HH:]MM:SS` on both and `rss` is
|
|
453
|
+
// kibibytes on both, so one parser still reads both — which is why the thread
|
|
454
|
+
// count is fetched separately (readThreads) rather than as an eighth column:
|
|
455
|
+
// procps spells it `nlwp` and BSD has no keyword for it at all, and one
|
|
456
|
+
// divergent column would have cost the shared parser this list is built on.
|
|
457
|
+
//
|
|
458
|
+
// `args` REPLACES `comm`, which is a change with a cost, and redactCommand is
|
|
459
|
+
// the payment. `comm` was chosen so that argv — and any token on it — could
|
|
460
|
+
// not reach the panel; that made every `node`, `dotnet` and `python` on the
|
|
461
|
+
// machine indistinguishable from every other one, which is most of what a
|
|
462
|
+
// reader opens this list to tell apart. The argument vector is redacted and
|
|
463
|
+
// capped before it leaves this module, and the short name is derived from
|
|
464
|
+
// argv[0] rather than asked for a second time.
|
|
465
|
+
if (platform === "linux") return ["-eo", "pid,pcpu,pmem,rss,etime,user:24,comm", "--sort=-pcpu"];
|
|
455
466
|
// BSD/macOS: `-c` prints the accounting name rather than the argument vector,
|
|
456
467
|
// and `-r` sorts by current CPU.
|
|
457
|
-
return ["-Aceo", "pid,pcpu,pmem,comm", "-r"];
|
|
468
|
+
return ["-Aceo", "pid,pcpu,pmem,rss,etime,user,comm", "-r"];
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The second call: how many threads each candidate has, and what it was
|
|
473
|
+
* actually launched with.
|
|
474
|
+
*
|
|
475
|
+
* Two facts, one child, and it has to be a second call for two separate
|
|
476
|
+
* reasons. The thread count has no shared spelling — procps says `nlwp` and BSD
|
|
477
|
+
* has no keyword for it at all, only the `-M` listing — so an eighth column
|
|
478
|
+
* would have cost the single parser psArgs is built around. And `args` cannot
|
|
479
|
+
* sit beside `comm`: only one field in an `-o` list may contain spaces, because
|
|
480
|
+
* only the last one is unambiguous.
|
|
481
|
+
*
|
|
482
|
+
* Scoped to the candidate pids rather than to the machine, which is what makes
|
|
483
|
+
* it cheap: measured here, `ps -M -p` over forty pids is 0.15s against 0.10s
|
|
484
|
+
* for the whole-machine call it follows. `top -stats th,ports` would have
|
|
485
|
+
* answered both in one go and takes 4.25s on an idle machine — see readThreads'
|
|
486
|
+
* note on why ports is not a column at all.
|
|
487
|
+
*/
|
|
488
|
+
export function psDetailArgs(pids, platform = process.platform) {
|
|
489
|
+
const list = pids.join(",");
|
|
490
|
+
// procps: `nlwp` is free here, and `args` is last as ever.
|
|
491
|
+
if (platform === "linux") return ["-o", "pid,nlwp,args", "-p", list];
|
|
492
|
+
// BSD: `-M` lists each process followed by one line per thread, and the
|
|
493
|
+
// process line carries the untruncated argument vector. Counting the lines is
|
|
494
|
+
// the thread count; the process line is the argv. One child, both answers.
|
|
495
|
+
return ["-M", "-p", list];
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* `ps -M` output: a process line, then one line per thread, per pid.
|
|
500
|
+
*
|
|
501
|
+
* The two kinds are told apart by column one. A process line begins with the
|
|
502
|
+
* owning user and a thread line begins with spaces — `ps` leaves USER, TT and
|
|
503
|
+
* COMMAND blank on a thread because a thread has none of its own. So the count
|
|
504
|
+
* is "lines mentioning this pid, less the one that named it", and the argv is
|
|
505
|
+
* whatever the named line ended with.
|
|
506
|
+
*
|
|
507
|
+
* A kernel thread and an exiting process print a bracketed command
|
|
508
|
+
* (`[kworker/0:1]`, `(ccusage)`); that is `ps` reporting a state and it is left
|
|
509
|
+
* exactly as it came.
|
|
510
|
+
*/
|
|
511
|
+
export function parsePsThreadsBsd(text) {
|
|
512
|
+
const out = new Map();
|
|
513
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
514
|
+
if (!line.trim()) continue;
|
|
515
|
+
// USER PID TT %CPU STAT PRI STIME UTIME COMMAND — eight fixed fields, and
|
|
516
|
+
// COMMAND is everything after them.
|
|
517
|
+
const proc = /^(\S+)\s+(\d+)\s+\S+\s+[\d.,]+\s+\S+\s+\S+\s+\S+\s+\S+\s*(.*)$/.exec(line);
|
|
518
|
+
if (proc) {
|
|
519
|
+
if (proc[1] === "USER") continue; // the header
|
|
520
|
+
const pid = Number(proc[2]);
|
|
521
|
+
const row = out.get(pid) ?? { threads: 0, cmd: "" };
|
|
522
|
+
row.cmd = proc[3].trim();
|
|
523
|
+
out.set(pid, row);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
const thread = /^\s+(\d+)\s/.exec(line);
|
|
527
|
+
if (!thread) continue;
|
|
528
|
+
const pid = Number(thread[1]);
|
|
529
|
+
const row = out.get(pid) ?? { threads: 0, cmd: "" };
|
|
530
|
+
row.threads += 1;
|
|
531
|
+
out.set(pid, row);
|
|
532
|
+
}
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** `ps -o pid,nlwp,args` output, which is two numbers and then the rest. */
|
|
537
|
+
export function parsePsThreadsProcps(text) {
|
|
538
|
+
const out = new Map();
|
|
539
|
+
for (const line of String(text ?? "").split("\n")) {
|
|
540
|
+
const m = /^\s*(\d+)\s+(\d+)\s*(.*)$/.exec(line);
|
|
541
|
+
if (!m) continue;
|
|
542
|
+
out.set(Number(m[1]), { threads: Number(m[2]), cmd: m[3].trim() });
|
|
543
|
+
}
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* The pieces of a `ps` ELAPSED field, which is `[[DD-]HH:]MM:SS` on both Unixes.
|
|
549
|
+
*
|
|
550
|
+
* Returns seconds, or null for anything that is not that shape — a header line
|
|
551
|
+
* that slipped through, a locale doing something unexpected. Null prints as a
|
|
552
|
+
* dash rather than as "0s", because a process that has been up for no time and
|
|
553
|
+
* a process whose uptime could not be read are different facts.
|
|
554
|
+
*/
|
|
555
|
+
export function elapsedSeconds(text) {
|
|
556
|
+
const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(String(text ?? "").trim());
|
|
557
|
+
if (!m) return null;
|
|
558
|
+
const [, d, h, min, sec] = m;
|
|
559
|
+
return (Number(d ?? 0) * 86400) + (Number(h ?? 0) * 3600) + (Number(min) * 60) + Number(sec);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* An argument vector with the parts that must not be looked at taken out, and
|
|
564
|
+
* the parts nobody reads cut off.
|
|
565
|
+
*
|
|
566
|
+
* THIS IS A BLOCKLIST AND A BLOCKLIST LEAKS. It is written down here rather
|
|
567
|
+
* than implied, because the alternative — `comm`, which is what this replaced —
|
|
568
|
+
* leaked nothing and told the reader nothing either. What it removes is the
|
|
569
|
+
* shapes a secret actually takes on a command line; what it cannot remove is a
|
|
570
|
+
* secret that looks like an ordinary word, and no rule here pretends otherwise.
|
|
571
|
+
*
|
|
572
|
+
* The cap is the other half, and it is not a nicety. Measured on this machine:
|
|
573
|
+
* one Chrome helper's argv is 906 characters of `--field-trial-handle`,
|
|
574
|
+
* base64 `--gpu-preferences` and shared-memory handles. Forty of those is 36 KB
|
|
575
|
+
* on the wire every four seconds to render a column nobody can read. What
|
|
576
|
+
* identifies a process is its first few arguments — `ng serve admin-portal
|
|
577
|
+
* --port 44440` — and that is what fits.
|
|
578
|
+
*/
|
|
579
|
+
export const CMD_MAX = 180;
|
|
580
|
+
|
|
581
|
+
const SECRET_FLAG =
|
|
582
|
+
/(-{1,2}[\w.-]*(?:token|password|passwd|secret|api[-_]?key|apikey|auth|credential|bearer|cookie|session[-_]?id|private[-_]?key)[\w.-]*)(=|\s+)(\S+)/gi;
|
|
583
|
+
// The shapes that are a secret on their own, with no flag in front of them.
|
|
584
|
+
const BARE_SECRET =
|
|
585
|
+
/\b(?:sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{12,}|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,})/g;
|
|
586
|
+
// user:password@host, in any URL. The password goes; the rest is a location.
|
|
587
|
+
const URL_CREDENTIALS = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+):[^\s@/]+@/gi;
|
|
588
|
+
|
|
589
|
+
export function redactCommand(argv, home = "") {
|
|
590
|
+
let s = String(argv ?? "").trim();
|
|
591
|
+
if (!s) return "";
|
|
592
|
+
s = s.replace(SECRET_FLAG, (_, flag, sep, value) => `${flag}${sep === "=" ? "=" : " "}\u2022\u2022\u2022`);
|
|
593
|
+
s = s.replace(URL_CREDENTIALS, "$1:\u2022\u2022\u2022@");
|
|
594
|
+
s = s.replace(BARE_SECRET, "\u2022\u2022\u2022");
|
|
595
|
+
// `~` last, so a home path inside a redacted value is already gone. Only a
|
|
596
|
+
// whole path segment, so a user called `con` cannot rewrite the middle of an
|
|
597
|
+
// unrelated word.
|
|
598
|
+
if (home && home.length > 1) s = s.split(home + "/").join("~/").split(home + " ").join("~ ");
|
|
599
|
+
if (s.length > CMD_MAX) s = s.slice(0, CMD_MAX - 1).trimEnd() + "\u2026";
|
|
600
|
+
return s;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* The ARGUMENTS, with the executable that carries them taken off the front.
|
|
605
|
+
*
|
|
606
|
+
* The name column already says `Google Chrome Helper`; repeating
|
|
607
|
+
* `/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome
|
|
608
|
+
* Framework.framework/Versions/152.0.7977.76/Helpers/Google Chrome
|
|
609
|
+
* Helper.app/Contents/MacOS/Google Chrome Helper` beside it spends the entire
|
|
610
|
+
* 180-character budget on a path the reader can already see the end of, and
|
|
611
|
+
* cuts off `--type=renderer`, which is the part that says WHICH helper this is.
|
|
612
|
+
*
|
|
613
|
+
* argv[0] cannot be found by splitting on whitespace — on macOS the executable
|
|
614
|
+
* path routinely contains spaces, and `/Applications/Google Chrome.app/…` would
|
|
615
|
+
* be cut at "Google". The name from the first `ps` call is what resolves it:
|
|
616
|
+
* argv begins with a path whose last segment IS that name.
|
|
617
|
+
*
|
|
618
|
+
* "Last segment" is the whole of the rule, and the first version of this got it
|
|
619
|
+
* wrong in a way only the render showed. A plain `indexOf` cuts at the FIRST
|
|
620
|
+
* occurrence, and a macOS bundle contains the name twice — `…/Helpers/Google
|
|
621
|
+
* Chrome Helper.app/Contents/MacOS/Google Chrome Helper` — so every browser row
|
|
622
|
+
* read `.app/Contents/MacOS/Google Chrome Helper --type=gpu-process`, having
|
|
623
|
+
* spent the budget on the half of the path it was supposed to remove. A plain
|
|
624
|
+
* `lastIndexOf` is wrong the other way: `node /srv/node_modules/x` would cut
|
|
625
|
+
* inside `node_modules`.
|
|
626
|
+
*
|
|
627
|
+
* So: the first occurrence that a path segment actually ends at — preceded by a
|
|
628
|
+
* separator or nothing, followed by whitespace or nothing. `node /srv/app.js
|
|
629
|
+
* --port 3000` leaves `/srv/app.js --port 3000`; `/usr/local/bin/dotnet exec
|
|
630
|
+
* --runtimeconfig …` leaves `exec --runtimeconfig …`, which is what identifies
|
|
631
|
+
* it.
|
|
632
|
+
*
|
|
633
|
+
* When the name is not in argv at all — a process that rewrote argv[0], or a
|
|
634
|
+
* `comm` that Linux truncated at 15 characters — the first whitespace token
|
|
635
|
+
* goes only if it looks like a path, because dropping the first word of
|
|
636
|
+
* something already short would take the only word there was.
|
|
637
|
+
*/
|
|
638
|
+
export function commandTail(argv, name) {
|
|
639
|
+
const s = String(argv ?? "").trim();
|
|
640
|
+
if (!s) return "";
|
|
641
|
+
const n = String(name ?? "").trim();
|
|
642
|
+
if (n) {
|
|
643
|
+
for (let at = s.indexOf(n); at >= 0; at = s.indexOf(n, at + 1)) {
|
|
644
|
+
const before = at === 0 ? "" : s[at - 1];
|
|
645
|
+
const after = s[at + n.length] ?? "";
|
|
646
|
+
if ((before === "" || before === "/" || before === "\\" || /\s/.test(before))
|
|
647
|
+
&& (after === "" || /\s/.test(after))) {
|
|
648
|
+
return s.slice(at + n.length).trim();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const first = s.split(/\s+/)[0] ?? "";
|
|
653
|
+
return first.includes("/") ? s.slice(first.length).trim() : s;
|
|
458
654
|
}
|
|
459
655
|
|
|
460
656
|
/**
|
|
@@ -485,10 +681,34 @@ export function parsePsProcesses(text, limit = Infinity) {
|
|
|
485
681
|
// fix, and this is what keeps a stripped environment from emptying the
|
|
486
682
|
// panel. `%CPU` and `%MEM` are percentages printed with %.1f, so a comma in
|
|
487
683
|
// either can only be the decimal point.
|
|
488
|
-
|
|
684
|
+
//
|
|
685
|
+
// Seven fields, and only the last of them may contain a space — which is
|
|
686
|
+
// why `comm` is last and why `args` is not here at all (psDetailArgs).
|
|
687
|
+
// `rss` is an integer of kibibytes, `etime` and `user` cannot contain
|
|
688
|
+
// whitespace, so the shape stays unambiguous with three more columns in it.
|
|
689
|
+
const m = /^\s*(\d+)\s+([\d.,]+)\s+([\d.,]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.+?)\s*$/.exec(line);
|
|
489
690
|
if (!m) continue;
|
|
490
691
|
const num = v => Number(v.replace(",", "."));
|
|
491
|
-
out.push({
|
|
692
|
+
out.push({
|
|
693
|
+
pid: Number(m[1]),
|
|
694
|
+
cpu: num(m[2]),
|
|
695
|
+
mem: num(m[3]),
|
|
696
|
+
// RSS IS NOT WHAT ACTIVITY MONITOR CALLS MEMORY, and on macOS it is not
|
|
697
|
+
// close. Measured here against `top`'s MEM on the same processes at the
|
|
698
|
+
// same moment: `dotnet` 37 MB against 534, `rider` 1300 against 3833,
|
|
699
|
+
// one Brave renderer 240 against 153 — ratios from 0.02 to 1.57. The
|
|
700
|
+
// column Activity Monitor draws is `phys_footprint`, which no
|
|
701
|
+
// unprivileged one-shot command reports; `top -stats mem` has it and
|
|
702
|
+
// takes 4.25 seconds on an idle machine, against 0.10 for this call.
|
|
703
|
+
// So the figure is resident set size, the panel says so, and it is the
|
|
704
|
+
// same measurement on all three platforms rather than a different one
|
|
705
|
+
// per OS. On Linux and Windows it is also the figure their own tools
|
|
706
|
+
// show.
|
|
707
|
+
rssBytes: Number(m[4]) * 1024,
|
|
708
|
+
uptimeSec: elapsedSeconds(m[5]),
|
|
709
|
+
user: m[6],
|
|
710
|
+
name: m[7],
|
|
711
|
+
});
|
|
492
712
|
if (out.length >= limit) break;
|
|
493
713
|
}
|
|
494
714
|
return out;
|
|
@@ -530,6 +750,13 @@ export function parseGetProcessJson(json, totalMem) {
|
|
|
530
750
|
mem: totalMem > 0
|
|
531
751
|
? Math.round((Number(r.WorkingSetPrivate) || 0) / totalMem * 1000) / 10
|
|
532
752
|
: 0,
|
|
753
|
+
// The same three optional fields the POSIX branch attaches, so one client
|
|
754
|
+
// shape covers both. Absent rather than zero when the value did not come
|
|
755
|
+
// back: `StartTime` throws for a process this session may not open, and
|
|
756
|
+
// `Threads.Count` is undefined on a row that failed to project.
|
|
757
|
+
...(Number.isFinite(Number(r.Threads)) && Number(r.Threads) > 0 ? { threads: Number(r.Threads) } : {}),
|
|
758
|
+
...(Number.isFinite(Number(r.StartedAt)) ? { uptimeSec: Number(r.StartedAt) } : {}),
|
|
759
|
+
rssBytes: Number(r.WorkingSet) || 0,
|
|
533
760
|
}));
|
|
534
761
|
}
|
|
535
762
|
|
|
@@ -585,7 +812,8 @@ let prevProcAt = 0;
|
|
|
585
812
|
|
|
586
813
|
/** The run producing the next list, and the last one that finished. */
|
|
587
814
|
let procInFlight = null;
|
|
588
|
-
let procLast = null; // { at, read: { procs, total } }
|
|
815
|
+
let procLast = null; // { at, read: { procs, total }, detail }
|
|
816
|
+
let procInFlightDetail = false;
|
|
589
817
|
|
|
590
818
|
/**
|
|
591
819
|
* How old a finished reading may be and still answer a caller.
|
|
@@ -617,10 +845,13 @@ const PROC_MIN_GAP_MS = 1_500;
|
|
|
617
845
|
* that arrive while a run is going share its promise; callers that arrive just
|
|
618
846
|
* after one finished are served that reading.
|
|
619
847
|
*/
|
|
620
|
-
export async function readProcesses(platform = process.platform) {
|
|
848
|
+
export async function readProcesses(platform = process.platform, detail = false) {
|
|
621
849
|
const now = Date.now();
|
|
622
|
-
|
|
623
|
-
|
|
850
|
+
// A cached reading serves a caller that wants LESS than it holds, never one
|
|
851
|
+
// that wants more: the panel is happy with a detailed reading, and the modal
|
|
852
|
+
// opening onto a plain one would show four empty columns for up to a poll.
|
|
853
|
+
if (procLast && now - procLast.at < PROC_MIN_GAP_MS && (procLast.detail || !detail)) return procLast.read;
|
|
854
|
+
if (procInFlight && (procInFlightDetail || !detail)) return procInFlight;
|
|
624
855
|
// Only a real reading is remembered. Every failure inside readProcessesNow —
|
|
625
856
|
// a spawn that never started, a non-zero exit, the timeout — resolves to an
|
|
626
857
|
// empty array, and no machine has nothing running on it, so an empty list is
|
|
@@ -628,20 +859,30 @@ export async function readProcesses(platform = process.platform) {
|
|
|
628
859
|
// single hiccup into a blank panel that outlives it; the next caller retries
|
|
629
860
|
// instead. The in-flight share still applies, so a burst arriving during a
|
|
630
861
|
// failing read is one failing child, not a burst of them.
|
|
631
|
-
|
|
862
|
+
procInFlightDetail = detail;
|
|
863
|
+
procInFlight = readProcessesNow(platform, detail)
|
|
632
864
|
.then(read => {
|
|
633
|
-
if (read.procs.length) procLast = { at: Date.now(), read };
|
|
865
|
+
if (read.procs.length) procLast = { at: Date.now(), read, detail };
|
|
634
866
|
return read;
|
|
635
867
|
})
|
|
636
|
-
.finally(() => { procInFlight = null; });
|
|
868
|
+
.finally(() => { procInFlight = null; procInFlightDetail = false; });
|
|
637
869
|
return procInFlight;
|
|
638
870
|
}
|
|
639
871
|
|
|
640
|
-
async function readProcessesNow(platform) {
|
|
872
|
+
async function readProcessesNow(platform, detail = false) {
|
|
641
873
|
if (platform === "win32") {
|
|
642
874
|
const out = await run("powershell.exe", [
|
|
643
875
|
"-NoProfile", "-NonInteractive", "-Command",
|
|
644
|
-
|
|
876
|
+
// Threads and StartTime ride along on the call that was already being
|
|
877
|
+
// made — `Get-Process` has both, so the two columns cost nothing here.
|
|
878
|
+
// `WorkingSet64` joins `PrivateMemorySize64` rather than replacing it:
|
|
879
|
+
// the percentage has always been computed from private bytes and moving
|
|
880
|
+
// it would change a number that is on screen today, while the new column
|
|
881
|
+
// wants the resident figure Task Manager itself shows.
|
|
882
|
+
// NOT `-IncludeUserName`: it requires an elevated session, and no part
|
|
883
|
+
// of this deck may ask for one. The user column is simply absent on
|
|
884
|
+
// Windows, which is the same rule the thermal section keeps.
|
|
885
|
+
"Get-Process | Select-Object Id,ProcessName,CPU,@{n='Threads';e={$_.Threads.Count}},@{n='StartedAt';e={if($_.StartTime){[int]((Get-Date)-$_.StartTime).TotalSeconds}else{$null}}},@{n='WorkingSet';e={$_.WorkingSet64}},@{n='WorkingSetPrivate';e={$_.PrivateMemorySize64}} | ConvertTo-Json -Compress",
|
|
645
886
|
], 6_000);
|
|
646
887
|
// The same shape the POSIX branch below returns, and not a bare array: the
|
|
647
888
|
// caller reads `read.procs.length` to decide whether this was a real
|
|
@@ -661,7 +902,49 @@ async function readProcessesNow(platform) {
|
|
|
661
902
|
const out = await run("ps", psArgs(platform), 4_000);
|
|
662
903
|
if (!out) return { procs: [], total: 0 };
|
|
663
904
|
const all = parsePsProcesses(out);
|
|
664
|
-
|
|
905
|
+
const procs = pickCandidates(all);
|
|
906
|
+
if (detail) await attachDetail(procs, platform);
|
|
907
|
+
return { procs, total: all.length };
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Fill in threads and the command tail, for the candidates and no further.
|
|
912
|
+
*
|
|
913
|
+
* AFTER pickCandidates, deliberately: this is a per-pid listing, and asking it
|
|
914
|
+
* about every process on the machine would be several hundred pids to answer a
|
|
915
|
+
* question about forty. The candidates are what any surface can draw, so they
|
|
916
|
+
* are what gets the second child.
|
|
917
|
+
*
|
|
918
|
+
* A failure here is not a failure of the reading. `ps -M` can lose a race with
|
|
919
|
+
* a process that exited between the two calls, a hardened environment can
|
|
920
|
+
* refuse it, and neither is a reason to blank a list whose CPU and memory
|
|
921
|
+
* columns are already correct. The fields are simply absent, and the columns
|
|
922
|
+
* that read them print a dash — the same rule the rest of this module keeps:
|
|
923
|
+
* an unknown is never rendered as a zero.
|
|
924
|
+
*/
|
|
925
|
+
async function attachDetail(procs, platform) {
|
|
926
|
+
if (!procs.length) return;
|
|
927
|
+
const pids = procs.map(p => p.pid).filter(n => Number.isInteger(n) && n > 0);
|
|
928
|
+
if (!pids.length) return;
|
|
929
|
+
let out;
|
|
930
|
+
try { out = await run("ps", psDetailArgs(pids, platform), 4_000); }
|
|
931
|
+
catch { return; }
|
|
932
|
+
if (!out) return;
|
|
933
|
+
const detail = platform === "linux" ? parsePsThreadsProcps(out) : parsePsThreadsBsd(out);
|
|
934
|
+
const home = os.homedir?.() ?? "";
|
|
935
|
+
for (const p of procs) {
|
|
936
|
+
const d = detail.get(p.pid);
|
|
937
|
+
if (!d) continue;
|
|
938
|
+
// Zero threads is not a reading. Every process has at least one, so a zero
|
|
939
|
+
// here means the listing named the pid and gave no thread lines for it —
|
|
940
|
+
// a race with an exit — and a `0` in that column would be a claim.
|
|
941
|
+
if (d.threads > 0) p.threads = d.threads;
|
|
942
|
+
// REDACTED BEFORE IT LEAVES THIS MODULE, not on the way to the screen.
|
|
943
|
+
// /api/system/processes is served to anything that can reach the loopback
|
|
944
|
+
// port, and a token that only the client hides is a token that shipped.
|
|
945
|
+
const cmd = redactCommand(commandTail(d.cmd, p.name), home);
|
|
946
|
+
if (cmd) p.cmd = cmd;
|
|
947
|
+
}
|
|
665
948
|
}
|
|
666
949
|
|
|
667
950
|
// ---------------------------------------------------------------------------
|