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.
@@ -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-Dya_sxoa.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-XrBtr430.css">
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.11.0",
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": {
@@ -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",
@@ -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
- return guard(readProcesses().then(r => send(res, 200, { ok: true, ...r })), res);
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
- // procps: an explicit CPU sort, and `comm` in `-o` is what keeps argv — and
452
- // any prompt or token on it — out of the panel. Linux `comm` comes from
453
- // /proc/<pid>/comm and is capped at 15 characters.
454
- if (platform === "linux") return ["-eo", "pid,pcpu,pmem,comm", "--sort=-pcpu"];
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
- const m = /^\s*(\d+)\s+([\d.,]+)\s+([\d.,]+)\s+(.+?)\s*$/.exec(line);
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({ pid: Number(m[1]), cpu: num(m[2]), mem: num(m[3]), name: m[4] });
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
- if (procLast && now - procLast.at < PROC_MIN_GAP_MS) return procLast.read;
623
- if (procInFlight) return procInFlight;
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
- procInFlight = readProcessesNow(platform)
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
- "Get-Process | Select-Object Id,ProcessName,CPU,@{n='WorkingSetPrivate';e={$_.PrivateMemorySize64}} | ConvertTo-Json -Compress",
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
- return { procs: pickCandidates(all), total: all.length };
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
  // ---------------------------------------------------------------------------