ccdeck 3.29.11 → 3.31.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.
Files changed (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -0,0 +1,104 @@
1
+ // What of a process's command line the Machine panel may show: the arguments,
2
+ // with the executable that carries them taken off the front (commandTail), and
3
+ // then with the shapes a secret takes removed and the rest capped
4
+ // (redactCommand).
5
+ //
6
+ // Moved out of process-list.mjs unchanged. attachDetail there runs both on
7
+ // every candidate's argument vector before it leaves that module, because
8
+ // /api/system/processes answers anything that can reach the loopback port;
9
+ // process-list.mjs re-exports CMD_MAX, redactCommand and commandTail, which is
10
+ // where the suite reaches them.
11
+
12
+ /**
13
+ * An argument vector with the parts that must not be looked at taken out, and
14
+ * the parts nobody reads cut off.
15
+ *
16
+ * THIS IS A BLOCKLIST AND A BLOCKLIST LEAKS. It is written down here rather
17
+ * than implied, because the alternative — `comm`, which is what this replaced —
18
+ * leaked nothing and told the reader nothing either. What it removes is the
19
+ * shapes a secret actually takes on a command line; what it cannot remove is a
20
+ * secret that looks like an ordinary word, and no rule here pretends otherwise.
21
+ *
22
+ * The cap is the other half, and it is not a nicety. Measured on this machine:
23
+ * one Chrome helper's argv is 906 characters of `--field-trial-handle`,
24
+ * base64 `--gpu-preferences` and shared-memory handles. Forty of those is 36 KB
25
+ * on the wire every four seconds to render a column nobody can read. What
26
+ * identifies a process is its first few arguments — `ng serve admin-portal
27
+ * --port 44440` — and that is what fits.
28
+ */
29
+ export const CMD_MAX = 180;
30
+
31
+ const SECRET_FLAG =
32
+ /(-{1,2}[\w.-]*(?:token|password|passwd|secret|api[-_]?key|apikey|auth|credential|bearer|cookie|session[-_]?id|private[-_]?key)[\w.-]*)(=|\s+)(\S+)/gi;
33
+ // The shapes that are a secret on their own, with no flag in front of them.
34
+ const BARE_SECRET =
35
+ /\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;
36
+ // user:password@host, in any URL. The password goes; the rest is a location.
37
+ const URL_CREDENTIALS = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/]+):[^\s@/]+@/gi;
38
+
39
+ export function redactCommand(argv, home = "") {
40
+ let s = String(argv ?? "").trim();
41
+ if (!s) return "";
42
+ s = s.replace(SECRET_FLAG, (_, flag, sep, value) => `${flag}${sep === "=" ? "=" : " "}\u2022\u2022\u2022`);
43
+ s = s.replace(URL_CREDENTIALS, "$1:\u2022\u2022\u2022@");
44
+ s = s.replace(BARE_SECRET, "\u2022\u2022\u2022");
45
+ // `~` last, so a home path inside a redacted value is already gone. Only a
46
+ // whole path segment, so a user called `con` cannot rewrite the middle of an
47
+ // unrelated word.
48
+ if (home && home.length > 1) s = s.split(home + "/").join("~/").split(home + " ").join("~ ");
49
+ if (s.length > CMD_MAX) s = s.slice(0, CMD_MAX - 1).trimEnd() + "\u2026";
50
+ return s;
51
+ }
52
+
53
+ /**
54
+ * The ARGUMENTS, with the executable that carries them taken off the front.
55
+ *
56
+ * The name column already says `Google Chrome Helper`; repeating
57
+ * `/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome
58
+ * Framework.framework/Versions/152.0.7977.76/Helpers/Google Chrome
59
+ * Helper.app/Contents/MacOS/Google Chrome Helper` beside it spends the entire
60
+ * 180-character budget on a path the reader can already see the end of, and
61
+ * cuts off `--type=renderer`, which is the part that says WHICH helper this is.
62
+ *
63
+ * argv[0] cannot be found by splitting on whitespace — on macOS the executable
64
+ * path routinely contains spaces, and `/Applications/Google Chrome.app/…` would
65
+ * be cut at "Google". The name from the first `ps` call is what resolves it:
66
+ * argv begins with a path whose last segment IS that name.
67
+ *
68
+ * "Last segment" is the whole of the rule, and the first version of this got it
69
+ * wrong in a way only the render showed. A plain `indexOf` cuts at the FIRST
70
+ * occurrence, and a macOS bundle contains the name twice — `…/Helpers/Google
71
+ * Chrome Helper.app/Contents/MacOS/Google Chrome Helper` — so every browser row
72
+ * read `.app/Contents/MacOS/Google Chrome Helper --type=gpu-process`, having
73
+ * spent the budget on the half of the path it was supposed to remove. A plain
74
+ * `lastIndexOf` is wrong the other way: `node /srv/node_modules/x` would cut
75
+ * inside `node_modules`.
76
+ *
77
+ * So: the first occurrence that a path segment actually ends at — preceded by a
78
+ * separator or nothing, followed by whitespace or nothing. `node /srv/app.js
79
+ * --port 3000` leaves `/srv/app.js --port 3000`; `/usr/local/bin/dotnet exec
80
+ * --runtimeconfig …` leaves `exec --runtimeconfig …`, which is what identifies
81
+ * it.
82
+ *
83
+ * When the name is not in argv at all — a process that rewrote argv[0], or a
84
+ * `comm` that Linux truncated at 15 characters — the first whitespace token
85
+ * goes only if it looks like a path, because dropping the first word of
86
+ * something already short would take the only word there was.
87
+ */
88
+ export function commandTail(argv, name) {
89
+ const s = String(argv ?? "").trim();
90
+ if (!s) return "";
91
+ const n = String(name ?? "").trim();
92
+ if (n) {
93
+ for (let at = s.indexOf(n); at >= 0; at = s.indexOf(n, at + 1)) {
94
+ const before = at === 0 ? "" : s[at - 1];
95
+ const after = s[at + n.length] ?? "";
96
+ if ((before === "" || before === "/" || before === "\\" || /\s/.test(before))
97
+ && (after === "" || /\s/.test(after))) {
98
+ return s.slice(at + n.length).trim();
99
+ }
100
+ }
101
+ }
102
+ const first = s.split(/\s+/)[0] ?? "";
103
+ return first.includes("/") ? s.slice(first.length).trim() : s;
104
+ }
@@ -0,0 +1,547 @@
1
+ // The process list: which rows /api/system/processes answers with, and how each
2
+ // platform is asked for them.
3
+ //
4
+ // Moved out of system-metrics.mjs unchanged, because it is the one reading there
5
+ // that never rides the sampler's timers. It is read on demand only — while the
6
+ // process dialog is open — keeps its own one-child-at-a-time guard and its own
7
+ // Windows CPU baseline (#544), and shares nothing with the ambient readings but
8
+ // the command runner. system-metrics.mjs re-exports readProcesses, which is where
9
+ // index.mjs asks for it, and its stopSystemMetrics calls resetProcessList so a
10
+ // reset still clears this list along with everything else.
11
+ import os from "node:os";
12
+ import { run } from "./metrics-run.mjs";
13
+ // What of a command line may leave this module: see process-command.mjs.
14
+ import { CMD_MAX, commandTail, redactCommand } from "./process-command.mjs";
15
+
16
+ /**
17
+ * How far down each ranking the payload reaches.
18
+ *
19
+ * The panel draws eight rows and decides their order on the client (#739), so
20
+ * whichever column it ranks by has to be rankable from the rows it was handed.
21
+ * `ps` returns a CPU-sorted list, and cutting that at eight and then sorting
22
+ * those eight by memory produced a table that was honest about its rows and
23
+ * wrong about its question: the machine's heaviest memory consumer need never
24
+ * have appeared in a CPU top eight at all. Same shape as #492 — a list that is
25
+ * never empty, never errors, and is not the rows being asked for.
26
+ *
27
+ * So what goes over the wire is a candidate SET rather than a ranking.
28
+ * pickCandidates takes this many by CPU and this many by memory and sends the
29
+ * union, which makes the true top eight of either column present by
30
+ * construction. Wide enough that drawing more rows later cannot quietly
31
+ * re-break that, small enough that the whole thing stays a few kilobytes of
32
+ * four-field rows, fetched only while the panel is open.
33
+ */
34
+ const CANDIDATE_N = 40;
35
+
36
+ /**
37
+ * The rows worth sending, given that the ordering happens on the client.
38
+ *
39
+ * A union of two rankings, deduplicated by object identity rather than by pid:
40
+ * parseGetProcessJson falls back to pid 0 for a row whose `Id` did not parse,
41
+ * more than one row can do that, and a pid-keyed set would drop real processes
42
+ * in order to deduplicate a placeholder.
43
+ *
44
+ * An unknown CPU sorts last here, for the same reason the column prints a dash
45
+ * rather than a zero — a Windows first reading has no percentage yet, and
46
+ * unknown is not idle and is not busiest either. Such a row still reaches the
47
+ * payload through the memory half, which is the reading that does exist on that
48
+ * pass.
49
+ */
50
+ export function pickCandidates(rows, limit = CANDIDATE_N) {
51
+ const byCpu = [...rows].sort((a, b) => (b.cpu ?? -1) - (a.cpu ?? -1) || b.mem - a.mem);
52
+ const byMem = [...rows].sort((a, b) => b.mem - a.mem || (b.cpu ?? -1) - (a.cpu ?? -1));
53
+ const out = byCpu.slice(0, limit);
54
+ const seen = new Set(out);
55
+ for (const r of byMem.slice(0, limit)) if (!seen.has(r)) out.push(r);
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * The `ps` argument list, which is not the same list on both Unixes.
61
+ *
62
+ * `-r` was shipped for both and means two different things. On BSD it sorts the
63
+ * output by current CPU, which is the ordering the panel is built around. On
64
+ * Linux procps it is *"restrict the selection to only running processes"* — a
65
+ * filter on state `R`, applied in PID order. A Linux deck therefore listed
66
+ * whatever happened to be on a CPU at the instant of the sample: usually one or
67
+ * two rows on an idle machine, and never the busiest ones, since a process
68
+ * pinning a core while blocked on I/O sits in `D` and one merely burning CPU
69
+ * over time is normally caught in `S`. Nothing errored and nothing was empty,
70
+ * which is why it survived two releases (#492).
71
+ *
72
+ * `--sort=-pcpu` is procps' own way to say what `-r` says on BSD. The column
73
+ * order is deliberately identical on both so one parser reads both, and `comm`
74
+ * stays last so a name containing a space survives intact.
75
+ *
76
+ * Keyed on linux rather than on darwin, because linux is the platform that is
77
+ * wrong: `-r` sorts on every BSD, while `--sort` is a procps long option that
78
+ * would make FreeBSD and OpenBSD exit non-zero. This way the only branch that
79
+ * changes is the one that was broken.
80
+ *
81
+ * Pure and exported for the same reason the parsers are: the command
82
+ * construction is the part that differs per platform, and a fixture cannot
83
+ * prove which flags were passed to produce it.
84
+ */
85
+ export function psArgs(platform = process.platform) {
86
+ // SEVEN FIELDS, THE SAME SEVEN ON BOTH, and `args` last because it is the one
87
+ // that contains spaces. `etime` is `[[DD-]HH:]MM:SS` on both and `rss` is
88
+ // kibibytes on both, so one parser still reads both — which is why the thread
89
+ // count is fetched separately (attachDetail) rather than as an eighth column:
90
+ // procps spells it `nlwp` and BSD has no keyword for it at all, and one
91
+ // divergent column would have cost the shared parser this list is built on.
92
+ //
93
+ // `args` REPLACES `comm`, which is a change with a cost, and redactCommand is
94
+ // the payment. `comm` was chosen so that argv — and any token on it — could
95
+ // not reach the panel; that made every `node`, `dotnet` and `python` on the
96
+ // machine indistinguishable from every other one, which is most of what a
97
+ // reader opens this list to tell apart. The argument vector is redacted and
98
+ // capped before it leaves this module, and the short name is derived from
99
+ // argv[0] rather than asked for a second time.
100
+ if (platform === "linux") return ["-eo", "pid,pcpu,pmem,rss,etime,user:24,comm", "--sort=-pcpu"];
101
+ // BSD/macOS: `-c` prints the accounting name rather than the argument vector,
102
+ // and `-r` sorts by current CPU.
103
+ return ["-Aceo", "pid,pcpu,pmem,rss,etime,user,comm", "-r"];
104
+ }
105
+
106
+ /**
107
+ * The second call: how many threads each candidate has, and what it was
108
+ * actually launched with.
109
+ *
110
+ * Two facts, one child, and it has to be a second call for two separate
111
+ * reasons. The thread count has no shared spelling — procps says `nlwp` and BSD
112
+ * has no keyword for it at all, only the `-M` listing — so an eighth column
113
+ * would have cost the single parser psArgs is built around. And `args` cannot
114
+ * sit beside `comm`: only one field in an `-o` list may contain spaces, because
115
+ * only the last one is unambiguous.
116
+ *
117
+ * Scoped to the candidate pids rather than to the machine, which is what makes
118
+ * it cheap: measured here, `ps -M -p` over forty pids is 0.15s against 0.10s
119
+ * for the whole-machine call it follows. `top -stats th,ports` would have
120
+ * answered both in one go and takes 4.25s on an idle machine.
121
+ */
122
+ export function psDetailArgs(pids, platform = process.platform) {
123
+ const list = pids.join(",");
124
+ // procps: `nlwp` is free here, and `args` is last as ever.
125
+ if (platform === "linux") return ["-o", "pid,nlwp,args", "-p", list];
126
+ // BSD: `-M` lists each process followed by one line per thread, and the
127
+ // process line carries the untruncated argument vector. Counting the lines is
128
+ // the thread count; the process line is the argv. One child, both answers.
129
+ return ["-M", "-p", list];
130
+ }
131
+
132
+ /**
133
+ * `ps -M` output: a process line, then one line per thread, per pid.
134
+ *
135
+ * The two kinds are told apart by column one. A process line begins with the
136
+ * owning user and a thread line begins with spaces — `ps` leaves USER, TT and
137
+ * COMMAND blank on a thread because a thread has none of its own. So the count
138
+ * is "lines mentioning this pid, less the one that named it", and the argv is
139
+ * whatever the named line ended with.
140
+ *
141
+ * A kernel thread and an exiting process print a bracketed command
142
+ * (`[kworker/0:1]`, `(ccusage)`); that is `ps` reporting a state and it is left
143
+ * exactly as it came.
144
+ */
145
+ export function parsePsThreadsBsd(text) {
146
+ const out = new Map();
147
+ for (const line of String(text ?? "").split("\n")) {
148
+ if (!line.trim()) continue;
149
+ // USER PID TT %CPU STAT PRI STIME UTIME COMMAND — eight fixed fields, and
150
+ // COMMAND is everything after them.
151
+ const proc = /^(\S+)\s+(\d+)\s+\S+\s+[\d.,]+\s+\S+\s+\S+\s+\S+\s+\S+\s*(.*)$/.exec(line);
152
+ if (proc) {
153
+ if (proc[1] === "USER") continue; // the header
154
+ const pid = Number(proc[2]);
155
+ const row = out.get(pid) ?? { threads: 0, cmd: "" };
156
+ row.cmd = proc[3].trim();
157
+ out.set(pid, row);
158
+ continue;
159
+ }
160
+ const thread = /^\s+(\d+)\s/.exec(line);
161
+ if (!thread) continue;
162
+ const pid = Number(thread[1]);
163
+ const row = out.get(pid) ?? { threads: 0, cmd: "" };
164
+ row.threads += 1;
165
+ out.set(pid, row);
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /** `ps -o pid,nlwp,args` output, which is two numbers and then the rest. */
171
+ export function parsePsThreadsProcps(text) {
172
+ const out = new Map();
173
+ for (const line of String(text ?? "").split("\n")) {
174
+ const m = /^\s*(\d+)\s+(\d+)\s*(.*)$/.exec(line);
175
+ if (!m) continue;
176
+ out.set(Number(m[1]), { threads: Number(m[2]), cmd: m[3].trim() });
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /**
182
+ * The pieces of a `ps` ELAPSED field, which is `[[DD-]HH:]MM:SS` on both Unixes.
183
+ *
184
+ * Returns seconds, or null for anything that is not that shape — a header line
185
+ * that slipped through, a locale doing something unexpected. Null prints as a
186
+ * dash rather than as "0s", because a process that has been up for no time and
187
+ * a process whose uptime could not be read are different facts.
188
+ */
189
+ export function elapsedSeconds(text) {
190
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(String(text ?? "").trim());
191
+ if (!m) return null;
192
+ const [, d, h, min, sec] = m;
193
+ return (Number(d ?? 0) * 86400) + (Number(h ?? 0) * 3600) + (Number(min) * 60) + Number(sec);
194
+ }
195
+
196
+ // The command column — argv without its executable, redacted and capped before
197
+ // it leaves this module (#807): process-command.mjs.
198
+ export { CMD_MAX, commandTail, redactCommand };
199
+
200
+ /**
201
+ * Rows out of `ps -o pid,pcpu,pmem,comm`, in that column order on both Unixes —
202
+ * see psArgs for how each platform is asked for it.
203
+ *
204
+ * `pcpu` is a percentage of ONE core on both, so a multi-threaded process runs
205
+ * past 100 and that is information rather than an error: 157 is one and a half
206
+ * cores. cpuFromDeltas puts the Windows column on this same scale.
207
+ *
208
+ * There is no row limit in the query because neither `ps` has one and `run`
209
+ * deliberately never inherits a shell, so there is no `| head` to pipe into.
210
+ *
211
+ * And there is none by default here either, which is a change: the loop used to
212
+ * stop at eight, and stopping at eight is what made the memory ranking a lie
213
+ * once the panel could ask for one (#739). Selection belongs to pickCandidates,
214
+ * which cannot rank a column out of rows this function has already thrown away.
215
+ * The cost is a regex over every line of `ps` — a few hundred on a busy
216
+ * machine, once every four seconds and only while the panel is open — against a
217
+ * string that has already been read and allocated. `limit` stays for callers
218
+ * that do want a truncation, which is now only the tests.
219
+ */
220
+ export function parsePsProcesses(text, limit = Infinity) {
221
+ const lines = String(text ?? "").trim().split("\n");
222
+ const out = [];
223
+ for (const line of lines.slice(1)) { // drop the header row
224
+ // Both separators, for the reason swapFromSysctl gives: C_LOCALE is the
225
+ // fix, and this is what keeps a stripped environment from emptying the
226
+ // panel. `%CPU` and `%MEM` are percentages printed with %.1f, so a comma in
227
+ // either can only be the decimal point.
228
+ //
229
+ // Seven fields, and only the last of them may contain a space — which is
230
+ // why `comm` is last and why `args` is not here at all (psDetailArgs).
231
+ // `rss` is an integer of kibibytes, `etime` and `user` cannot contain
232
+ // whitespace, so the shape stays unambiguous with three more columns in it.
233
+ const m = /^\s*(\d+)\s+([\d.,]+)\s+([\d.,]+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.+?)\s*$/.exec(line);
234
+ if (!m) continue;
235
+ const num = v => Number(v.replace(",", "."));
236
+ out.push({
237
+ pid: Number(m[1]),
238
+ cpu: num(m[2]),
239
+ mem: num(m[3]),
240
+ // RSS IS NOT WHAT ACTIVITY MONITOR CALLS MEMORY, and on macOS it is not
241
+ // close. Measured here against `top`'s MEM on the same processes at the
242
+ // same moment: `dotnet` 37 MB against 534, `rider` 1300 against 3833,
243
+ // one Brave renderer 240 against 153 — ratios from 0.02 to 1.57. The
244
+ // column Activity Monitor draws is `phys_footprint`, which no
245
+ // unprivileged one-shot command reports; `top -stats mem` has it and
246
+ // takes 4.25 seconds on an idle machine, against 0.10 for this call.
247
+ // So the figure is resident set size, the panel says so, and it is the
248
+ // same measurement on all three platforms rather than a different one
249
+ // per OS. On Linux and Windows it is also the figure their own tools
250
+ // show.
251
+ rssBytes: Number(m[4]) * 1024,
252
+ uptimeSec: elapsedSeconds(m[5]),
253
+ user: m[6],
254
+ name: m[7],
255
+ });
256
+ if (out.length >= limit) break;
257
+ }
258
+ return out;
259
+ }
260
+
261
+ /**
262
+ * Rows out of `Get-Process`.
263
+ *
264
+ * NOT `Win32_PerfFormattedData_PerfProc_Process`, which is what this used and
265
+ * which is not a class you may assume exists. It is published by perflib, and
266
+ * perflib is deregistered often enough to matter — a corporate image, a bad
267
+ * in-place upgrade, a half-run `lodctr`. On a machine reported from the field
268
+ * the class was simply absent (`Get-CimInstance: Invalid class`), and `typeperf`
269
+ * failed identically, which places the fault below WMI rather than in it. WMI
270
+ * was only mirroring what perflib had stopped publishing.
271
+ *
272
+ * `Get-Process` reads through NtQuerySystemInformation instead, so it depends on
273
+ * nothing that can be unregistered. The cost is that its `CPU` is total
274
+ * processor SECONDS since the process started, not a rate — so a percentage has
275
+ * to be derived from two readings, exactly the way the machine-wide figure is
276
+ * already derived from two tick samples. Reliability is worth one extra poll:
277
+ * an instant number from a class that may not exist is worth nothing at all.
278
+ */
279
+ export function parseGetProcessJson(json, totalMem) {
280
+ let rows;
281
+ try { rows = typeof json === "string" ? JSON.parse(json) : json; }
282
+ catch { return []; }
283
+ if (!rows) return [];
284
+ if (!Array.isArray(rows)) rows = [rows];
285
+ return rows
286
+ .filter(r => r && r.ProcessName)
287
+ .map(r => ({
288
+ pid: Number(r.Id) || 0,
289
+ name: String(r.ProcessName),
290
+ // Null rather than 0 when the process denies the read: a system process
291
+ // we cannot query has an unknown CPU time, and calling that zero would
292
+ // rank it as idle.
293
+ cpuSec: typeof r.CPU === "number" ? r.CPU : null,
294
+ mem: totalMem > 0
295
+ ? Math.round((Number(r.WorkingSetPrivate) || 0) / totalMem * 1000) / 10
296
+ : 0,
297
+ // The same three optional fields the POSIX branch attaches, so one client
298
+ // shape covers both. Absent rather than zero when the value did not come
299
+ // back: `StartTime` throws for a process this session may not open, and
300
+ // `Threads.Count` is undefined on a row that failed to project.
301
+ //
302
+ // `typeof` rather than `Number.isFinite(Number(…))` on the uptime, and the
303
+ // difference is the whole of that rule: `Number(null)` is 0, and 0 is
304
+ // finite, so the projection's own `else{$null}` — the branch it takes for
305
+ // every process whose StartTime this session may not read — arrived as an
306
+ // uptime of ZERO SECONDS. Every protected process on the machine was
307
+ // reported to the panel as having started this instant, which is the
308
+ // reading the comment above says must never be invented. A genuine zero
309
+ // is still kept: `[int]` of a sub-second TimeSpan is 0, and a process that
310
+ // started this instant is a real answer when it is the real answer. Same
311
+ // guard the CPU field two lines up already used, for the same reason.
312
+ ...(Number.isFinite(Number(r.Threads)) && Number(r.Threads) > 0 ? { threads: Number(r.Threads) } : {}),
313
+ ...(typeof r.StartedAt === "number" && Number.isFinite(r.StartedAt) ? { uptimeSec: r.StartedAt } : {}),
314
+ rssBytes: Number(r.WorkingSet) || 0,
315
+ }));
316
+ }
317
+
318
+ /**
319
+ * Turn two `Get-Process` readings into a percentage per process.
320
+ *
321
+ * `prev` maps pid to the cpuSec of the previous reading. A pid absent from it —
322
+ * a process that started since — has no delta and reports null rather than a
323
+ * number invented from its whole lifetime, which would rank a freshly spawned
324
+ * compiler as though it had been burning a core since boot.
325
+ *
326
+ * Per core, NOT per machine, because that is what the column beside it means:
327
+ * `ps -o pcpu` is a percentage of one core on both Unixes and is reported
328
+ * unmodified, so a row reading 157 there is a process using one and a half
329
+ * cores. This used to divide by the core count and clamp to 100 on the reasoning
330
+ * that Unix reported 0-100 — it does not, and never did, so the normalisation
331
+ * corrected a scale that already matched and introduced the mismatch it was
332
+ * written to prevent: on a 12-core machine six busy cores read 600 on macOS and
333
+ * 50 on Windows (#493). One CPU-second burned per wall-second is 100 here, on
334
+ * every platform.
335
+ *
336
+ * Core count is deliberately not a parameter any more. The aggregate meter's
337
+ * 0-100 convention (see cpuPercent in system-metrics.mjs) is a different
338
+ * question with a different answer, and the only way this drifts back is if a core count is in reach.
339
+ *
340
+ * THE ROW IS CARRIED, NOT REBUILT, and that is the half this used to get wrong
341
+ * (#954). It listed the four fields it knew about — pid, cpu, mem, name — so
342
+ * `threads`, `uptimeSec` and `rssBytes` were silently dropped on their way
343
+ * through, and this is the only step the Windows reading takes: readProcessesNow
344
+ * returns straight out of the win32 branch, on the other side of the
345
+ * `attachDetail` call that puts those three back on POSIX. Every Windows row
346
+ * therefore reached the panel with three of its seven columns missing, always,
347
+ * and the memory header — which sorts on `rssBytes` and treats `undefined` as a
348
+ * missing reading — reordered nothing when clicked, because every row was
349
+ * missing it. Invisible to anyone developing on macOS or Linux.
350
+ *
351
+ * `cpuSec` is the one field deliberately removed: it is the raw counter this
352
+ * function exists to turn into a rate, the POSIX rows never carry one, and
353
+ * shipping it would put two spellings of the same quantity on the wire.
354
+ */
355
+ export function cpuFromDeltas(rows, prev, elapsedMs, limit = Infinity) {
356
+ const secs = elapsedMs / 1000;
357
+ const out = rows.map(r => {
358
+ const before = prev instanceof Map ? prev.get(r.pid) : undefined;
359
+ let cpu = null;
360
+ if (r.cpuSec != null && before != null && secs > 0) {
361
+ const d = r.cpuSec - before;
362
+ // A counter that went backwards means the pid was reused by a different
363
+ // process; report nothing rather than a negative or a wild number.
364
+ if (d >= 0) cpu = Math.round((d / secs) * 1000) / 10;
365
+ }
366
+ const { cpuSec: _raw, ...rest } = r;
367
+ return { ...rest, cpu };
368
+ });
369
+ // Until the second reading lands there is no CPU to sort on, so the list is
370
+ // ordered by memory — which is a real answer to "what is this machine doing",
371
+ // not a placeholder.
372
+ const haveCpu = out.some(r => r.cpu != null);
373
+ out.sort(haveCpu
374
+ ? (a, b) => (b.cpu ?? -1) - (a.cpu ?? -1)
375
+ : (a, b) => b.mem - a.mem);
376
+ return out.slice(0, limit);
377
+ }
378
+
379
+ /** Previous Windows reading, so the next one can be a rate. Cleared with the
380
+ * rest of the sampler state — see resetProcessList. */
381
+ let prevProcCpu = null;
382
+ let prevProcAt = 0;
383
+
384
+ /** The run producing the next list, and the last one that finished. */
385
+ let procInFlight = null;
386
+ let procLast = null; // { at, read: { procs, total }, detail }
387
+ let procInFlightDetail = false;
388
+
389
+ /**
390
+ * How old a finished reading may be and still answer a caller.
391
+ *
392
+ * Well under ProcessListModal's PROC_POLL_MS of 4000, so the dialog this exists
393
+ * for never once gets a cached list; long enough that a second tab, a second
394
+ * browser, or anything else arriving between two of those polls is handed the
395
+ * list the first tab is already looking at rather than starting its own child.
396
+ */
397
+ const PROC_MIN_GAP_MS = 1_500;
398
+
399
+ /**
400
+ * The process list, on demand only — never on the ambient timer, and never more
401
+ * than one child at a time.
402
+ *
403
+ * #544: /api/system/processes is a GET with no cache, no dedupe and no
404
+ * throttle, so the number of `powershell.exe Get-Process` children — about six
405
+ * seconds each — was whatever the caller asked for. That is the cheap half of
406
+ * the problem. The expensive half is that concurrent readers also overwrote
407
+ * each other's baseline: cpuFromDeltas needs the PREVIOUS reading's cpuSec per
408
+ * pid, prevProcCpu/prevProcAt are one shared pair, and two readers each stored
409
+ * theirs over the other's, so the CPU column came back computed against a
410
+ * baseline that belonged to somebody else's reading. That is a wrong number on
411
+ * screen, not merely wasted work, and it needed no attacker at all: one
412
+ * Get-Process takes longer than the panel's four-second poll, so a single tab
413
+ * on Windows already overlapped itself.
414
+ *
415
+ * One in-flight run fixes both, because one reader means one baseline. Callers
416
+ * that arrive while a run is going share its promise; callers that arrive just
417
+ * after one finished are served that reading.
418
+ */
419
+ export async function readProcesses(platform = process.platform, detail = false) {
420
+ const now = Date.now();
421
+ // A cached reading serves a caller that wants LESS than it holds, never one
422
+ // that wants more: the panel is happy with a detailed reading, and the modal
423
+ // opening onto a plain one would show four empty columns for up to a poll.
424
+ if (procLast && now - procLast.at < PROC_MIN_GAP_MS && (procLast.detail || !detail)) return procLast.read;
425
+ if (procInFlight && (procInFlightDetail || !detail)) return procInFlight;
426
+ // Only a real reading is remembered. Every failure inside readProcessesNow —
427
+ // a spawn that never started, a non-zero exit, the timeout — resolves to an
428
+ // empty array, and no machine has nothing running on it, so an empty list is
429
+ // a failure by construction. Serving one for the next 1.5s would turn a
430
+ // single hiccup into a blank panel that outlives it; the next caller retries
431
+ // instead. The in-flight share still applies, so a burst arriving during a
432
+ // failing read is one failing child, not a burst of them.
433
+ procInFlightDetail = detail;
434
+ procInFlight = readProcessesNow(platform, detail)
435
+ .then(read => {
436
+ if (read.procs.length) procLast = { at: Date.now(), read, detail };
437
+ return read;
438
+ })
439
+ .finally(() => { procInFlight = null; procInFlightDetail = false; });
440
+ return procInFlight;
441
+ }
442
+
443
+ /**
444
+ * The one-liner the Windows process list is read with.
445
+ *
446
+ * Threads and StartTime ride along on the call that was already being made —
447
+ * `Get-Process` has both, so the two columns cost nothing here. `WorkingSet64`
448
+ * joins `PrivateMemorySize64` rather than replacing it: the percentage has
449
+ * always been computed from private bytes and moving it would change a number
450
+ * that is on screen today, while the new column wants the resident figure Task
451
+ * Manager itself shows.
452
+ *
453
+ * NOT `-IncludeUserName`: it requires an elevated session, and no part of this
454
+ * deck may ask for one. The user column is simply absent on Windows, which is
455
+ * the same rule the thermal section keeps.
456
+ *
457
+ * A named export for the reason WIN_THERMAL_PS is one, and #954 is why it
458
+ * became one: three of the seven columns this projection is built to fill were
459
+ * being dropped after it, and every assertion in the suite was made against a
460
+ * payload somebody typed. A constant can be handed to the real powershell.exe
461
+ * on the Windows leg of the matrix, which is the only place the claim "Windows
462
+ * really does answer with these seven keys" can be measured rather than
463
+ * reasoned about.
464
+ */
465
+ export const WIN_PROCESS_PS =
466
+ "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";
467
+
468
+ async function readProcessesNow(platform, detail = false) {
469
+ if (platform === "win32") {
470
+ const out = await run("powershell.exe", [
471
+ "-NoProfile", "-NonInteractive", "-Command", WIN_PROCESS_PS,
472
+ ], 6_000);
473
+ // The same shape the POSIX branch below returns, and not a bare array: the
474
+ // caller reads `read.procs.length` to decide whether this was a real
475
+ // reading, so an array meant a TypeError rather than an empty list. Every
476
+ // Windows failure — a non-zero exit, a spawn that never started, the 6s
477
+ // deadline this module's own comment says the runtime sits right on —
478
+ // therefore answered `/api/system/processes` with a 500 and a logged stack,
479
+ // every four seconds for as long as the machine panel was open.
480
+ if (!out) return { procs: [], total: 0 };
481
+ const rows = parseGetProcessJson(out.trim(), os.totalmem());
482
+ const now = Date.now();
483
+ const ranked = cpuFromDeltas(rows, prevProcCpu, now - prevProcAt);
484
+ prevProcCpu = new Map(rows.filter(r => r.cpuSec != null).map(r => [r.pid, r.cpuSec]));
485
+ prevProcAt = now;
486
+ return { procs: pickCandidates(ranked), total: ranked.length };
487
+ }
488
+ const out = await run("ps", psArgs(platform), 4_000);
489
+ if (!out) return { procs: [], total: 0 };
490
+ const all = parsePsProcesses(out);
491
+ const procs = pickCandidates(all);
492
+ if (detail) await attachDetail(procs, platform);
493
+ return { procs, total: all.length };
494
+ }
495
+
496
+ /**
497
+ * Fill in threads and the command tail, for the candidates and no further.
498
+ *
499
+ * AFTER pickCandidates, deliberately: this is a per-pid listing, and asking it
500
+ * about every process on the machine would be several hundred pids to answer a
501
+ * question about forty. The candidates are what any surface can draw, so they
502
+ * are what gets the second child.
503
+ *
504
+ * A failure here is not a failure of the reading. `ps -M` can lose a race with
505
+ * a process that exited between the two calls, a hardened environment can
506
+ * refuse it, and neither is a reason to blank a list whose CPU and memory
507
+ * columns are already correct. The fields are simply absent, and the columns
508
+ * that read them print a dash — the same rule the rest of this module keeps:
509
+ * an unknown is never rendered as a zero.
510
+ */
511
+ async function attachDetail(procs, platform) {
512
+ if (!procs.length) return;
513
+ const pids = procs.map(p => p.pid).filter(n => Number.isInteger(n) && n > 0);
514
+ if (!pids.length) return;
515
+ let out;
516
+ try { out = await run("ps", psDetailArgs(pids, platform), 4_000); }
517
+ catch { return; }
518
+ if (!out) return;
519
+ const detail = platform === "linux" ? parsePsThreadsProcps(out) : parsePsThreadsBsd(out);
520
+ const home = os.homedir?.() ?? "";
521
+ for (const p of procs) {
522
+ const d = detail.get(p.pid);
523
+ if (!d) continue;
524
+ // Zero threads is not a reading. Every process has at least one, so a zero
525
+ // here means the listing named the pid and gave no thread lines for it —
526
+ // a race with an exit — and a `0` in that column would be a claim.
527
+ if (d.threads > 0) p.threads = d.threads;
528
+ // REDACTED BEFORE IT LEAVES THIS MODULE, not on the way to the screen.
529
+ // /api/system/processes is served to anything that can reach the loopback
530
+ // port, and a token that only the client hides is a token that shipped.
531
+ const cmd = redactCommand(commandTail(d.cmd, p.name), home);
532
+ if (cmd) p.cmd = cmd;
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Forget the Windows baseline and the last list, for stopSystemMetrics.
538
+ *
539
+ * The last list goes with the baseline it was computed against. A sampler that
540
+ * stopped and started again must not answer the first caller with a reading
541
+ * from before the stop.
542
+ */
543
+ export function resetProcessList() {
544
+ prevProcCpu = null;
545
+ prevProcAt = 0;
546
+ procLast = null;
547
+ }