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.
- package/README.md +4 -3
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
- package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
- package/dist/web/assets/index-CZJf1Cpp.js +299 -0
- package/dist/web/assets/index-D4q1ElXU.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +40 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +7 -3
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +2 -2
- package/src/server/deck-prefs.mjs +58 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +174 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +110 -0
- package/src/server/prefs-give-back.mjs +206 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +86 -0
- package/src/server/prefs-routes.mjs +99 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +70 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/provider-status.mjs +307 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +108 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
- package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
- package/dist/web/assets/index-jmrcEsCj.js +0 -298
|
@@ -456,8 +456,9 @@ export function installService({
|
|
|
456
456
|
* ASKED, NOT INFERRED FROM THE REFUSAL. schtasks exits 1 for a missing task and
|
|
457
457
|
* for a permission it does not have, and the two are told apart only by an
|
|
458
458
|
* English sentence that is not English on a localised Windows — the same trap
|
|
459
|
-
* `looksMissing` in exec.mjs documents. So the task is queried first,
|
|
460
|
-
* one extra call and an answer that does not depend on the machine's
|
|
459
|
+
* `looksMissing` in exec-not-found.mjs documents. So the task is queried first,
|
|
460
|
+
* which is one extra call and an answer that does not depend on the machine's
|
|
461
|
+
* language.
|
|
461
462
|
*/
|
|
462
463
|
export function uninstallService({
|
|
463
464
|
platform = process.platform,
|
package/src/server/macmon.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// Silicon Mac reports "x64", and gating on that would skip the one machine this
|
|
27
27
|
// exists for.
|
|
28
28
|
import { run } from "./exec.mjs";
|
|
29
|
-
import { renameWithRetry } from "./
|
|
29
|
+
import { renameWithRetry } from "./atomic-write.mjs";
|
|
30
30
|
import { existsSync } from "node:fs";
|
|
31
31
|
import { chmod, copyFile, mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises";
|
|
32
32
|
import { createHash } from "node:crypto";
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Which memory files a session has in scope: the CLAUDE.md family for a Claude
|
|
2
|
+
// session and AGENTS.md for a Codex one, found by the walk both CLIs make from
|
|
3
|
+
// the working directory up to the filesystem root, plus the user-global file
|
|
4
|
+
// each one adds — and, for Claude, the per-project auto-memory directory.
|
|
5
|
+
//
|
|
6
|
+
// These lived in src/server/session-enrichment.mjs, beside the context pass
|
|
7
|
+
// that sends their answer on, and they read no transcript: they stat a working
|
|
8
|
+
// tree. So they moved on their own, and session-enrichment.mjs imports the two
|
|
9
|
+
// scans its context passes call. index.mjs still re-exports both. The bodies
|
|
10
|
+
// are unchanged.
|
|
11
|
+
import { readdir, stat } from "node:fs/promises";
|
|
12
|
+
import { join, resolve, dirname as pdirname } from "node:path";
|
|
13
|
+
import { ccProjectSlug, claudeConfigDir } from "./claude-dir.mjs";
|
|
14
|
+
import { CODEX_HOME } from "./codex-dir.mjs";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Candidate memory-file paths on the walk from `cwd` up to the filesystem root.
|
|
18
|
+
*
|
|
19
|
+
* Both CLIs load their memory file the same way — nearest-first from the
|
|
20
|
+
* working directory outwards — and differ only in what the file is CALLED and
|
|
21
|
+
* in what else they add on top, so the walk is written once here and the two
|
|
22
|
+
* scanners below supply their own names. `rels` is a list of paths RELATIVE to
|
|
23
|
+
* each directory on the walk rather than bare filenames, because CC also honours
|
|
24
|
+
* `.claude/CLAUDE.md` at every level and Codex does not.
|
|
25
|
+
*
|
|
26
|
+
* Sixteen levels is the same depth this has always used: deep enough for any
|
|
27
|
+
* real checkout, shallow enough that a cwd on a network mount cannot turn one
|
|
28
|
+
* context read into an unbounded number of stat() calls.
|
|
29
|
+
*
|
|
30
|
+
* Returns paths without touching the disk. Statting them is collectMemoryFiles'
|
|
31
|
+
* job, so a caller that wants to add its own paths — a user-global file, a
|
|
32
|
+
* per-project memory directory — can splice them into one ordered list and get
|
|
33
|
+
* a single de-duplicated, existence-checked answer back.
|
|
34
|
+
*/
|
|
35
|
+
function memoryWalkPaths(cwd, rels) {
|
|
36
|
+
const out = [];
|
|
37
|
+
let dir = resolve(cwd);
|
|
38
|
+
for (let depth = 0; depth < 16; depth++) {
|
|
39
|
+
for (const rel of rels) out.push(join(dir, rel));
|
|
40
|
+
const parent = pdirname(dir);
|
|
41
|
+
if (parent === dir) break;
|
|
42
|
+
dir = parent;
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Which of `paths` are real, non-empty files, in the order given and with
|
|
49
|
+
* duplicates dropped.
|
|
50
|
+
*
|
|
51
|
+
* A zero-byte file is skipped on purpose: it contributes nothing to the model's
|
|
52
|
+
* context, and listing it in the modal would have the reader looking for the
|
|
53
|
+
* bytes it claims to cost. A path that cannot be stat()ed is simply absent —
|
|
54
|
+
* this runs against a tree another process is editing, and a permissions error
|
|
55
|
+
* on one candidate is no reason to lose the other fifteen.
|
|
56
|
+
*/
|
|
57
|
+
async function collectMemoryFiles(paths) {
|
|
58
|
+
const found = [];
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (const p of paths) {
|
|
61
|
+
if (seen.has(p)) continue;
|
|
62
|
+
seen.add(p);
|
|
63
|
+
try {
|
|
64
|
+
const s = await stat(p);
|
|
65
|
+
if (s.isFile() && s.size > 0) found.push({ path: p, bytes: s.size });
|
|
66
|
+
} catch {}
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The memory files a CLAUDE session has in scope. Exported alongside its Codex
|
|
72
|
+
* counterpart below so a test can check the pair together — the two must agree
|
|
73
|
+
* on the walk and disagree on the filename, and only one of them showing up in
|
|
74
|
+
* a test is how they would drift. */
|
|
75
|
+
export async function scanClaudeMdFiles(cwd) {
|
|
76
|
+
if (!cwd || typeof cwd !== "string") return [];
|
|
77
|
+
// The CONFIG dir, not the home directory. CLAUDE_CONFIG_DIR relocates it
|
|
78
|
+
// wholesale — it replaces ~/.claude rather than overlaying it — so on a
|
|
79
|
+
// machine where it is set, `homedir()/.claude` is a directory Claude Code
|
|
80
|
+
// does not read. Spelling it by hand here failed in both directions at once:
|
|
81
|
+
// the user-global memory file and every auto-memory file went missing from
|
|
82
|
+
// the modal and from the byte total beside it, while a stale ~/.claude left
|
|
83
|
+
// over from before the variable was set got listed as if it were in context.
|
|
84
|
+
// Resolved per call, like every other claudeConfigDir() reader in
|
|
85
|
+
// src/server, so nothing captures the answer from an environment that has
|
|
86
|
+
// moved.
|
|
87
|
+
const cfg = claudeConfigDir();
|
|
88
|
+
// Walk up from cwd to filesystem root, checking the canonical CC memory
|
|
89
|
+
// filenames plus CLAUDE.local.md (user-private) at each level.
|
|
90
|
+
//
|
|
91
|
+
// The `.claude/` prefix on the last two is NOT the config dir wearing a
|
|
92
|
+
// second spelling: it is CC's per-directory project convention, one such
|
|
93
|
+
// folder per level of the walk, and it stays literal however the config dir
|
|
94
|
+
// moves.
|
|
95
|
+
const paths = memoryWalkPaths(cwd, [
|
|
96
|
+
"CLAUDE.md",
|
|
97
|
+
"CLAUDE.local.md",
|
|
98
|
+
join(".claude", "CLAUDE.md"),
|
|
99
|
+
join(".claude", "CLAUDE.local.md"),
|
|
100
|
+
]);
|
|
101
|
+
// User-global memory.
|
|
102
|
+
paths.push(join(cfg, "CLAUDE.md"));
|
|
103
|
+
paths.push(join(cfg, "CLAUDE.local.md"));
|
|
104
|
+
// Per-project auto-memory: $CLAUDE_CONFIG_DIR/projects/<slug>/memory/*.md
|
|
105
|
+
// (plus MEMORY.md index). CC injects these into context for sessions whose
|
|
106
|
+
// cwd matches the slug. That directory sits beside the <sessionId>.jsonl
|
|
107
|
+
// transcripts, so it moves with the config dir by construction.
|
|
108
|
+
const slug = ccProjectSlug(cwd);
|
|
109
|
+
if (slug) {
|
|
110
|
+
const memDir = join(cfg, "projects", slug, "memory");
|
|
111
|
+
try {
|
|
112
|
+
const entries = await readdir(memDir);
|
|
113
|
+
for (const f of entries) {
|
|
114
|
+
if (f.toLowerCase().endsWith(".md")) paths.push(join(memDir, f));
|
|
115
|
+
}
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
return collectMemoryFiles(paths);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The memory files a CODEX session has in scope: AGENTS.md, not CLAUDE.md.
|
|
123
|
+
*
|
|
124
|
+
* WHY THIS FUNCTION EXISTS AT ALL (#399). The context modal's third section was
|
|
125
|
+
* fed by scanClaudeMdFiles for every session regardless of provider, so the
|
|
126
|
+
* moment the donut became reachable for Codex the modal would have told a Codex
|
|
127
|
+
* user "No CLAUDE.md files found on the path from cwd to ~/.claude" — naming a
|
|
128
|
+
* file and a directory Codex does not read. Before this, `AGENTS.md` did not
|
|
129
|
+
* appear anywhere in this repository.
|
|
130
|
+
*
|
|
131
|
+
* That Codex reads it is not an assumption. Sampled every rollout under this
|
|
132
|
+
* machine's CODEX_HOME (structural search, no record content printed): the
|
|
133
|
+
* literal string `AGENTS.md` appears on 9 lines across 5 of the 8 files — in the
|
|
134
|
+
* `response_item/message` role=user preamble Codex prepends to a turn, in a
|
|
135
|
+
* role=developer message, and in a `world_state` record — while `CLAUDE.md`
|
|
136
|
+
* appears on zero lines in any of them.
|
|
137
|
+
*
|
|
138
|
+
* WHY THE FILESYSTEM AND NOT THE ROLLOUT. The rollout does name the files, but
|
|
139
|
+
* only inside message TEXT, and reading the text of a user's conversation to
|
|
140
|
+
* find a filename is not a trade this deck makes anywhere else — the Claude side
|
|
141
|
+
* has always answered the same question by walking the filesystem, and the two
|
|
142
|
+
* halves of one modal section should be derived the same way or the reader
|
|
143
|
+
* cannot compare them.
|
|
144
|
+
*
|
|
145
|
+
* CODEX_HOME comes from codex-dir.mjs like every other Codex path in the
|
|
146
|
+
* process (#375), so a relocated Codex home is honoured here without this
|
|
147
|
+
* module growing a sixth spelling of the rule.
|
|
148
|
+
*
|
|
149
|
+
* Exported for the tests, like readContextFromTranscript in
|
|
150
|
+
* session-enrichment.mjs: the rule for which files a session has in scope is
|
|
151
|
+
* worth pinning directly, rather than through a watcher, a temp home and a
|
|
152
|
+
* 1.5s poll.
|
|
153
|
+
*/
|
|
154
|
+
export async function scanAgentsMdFiles(cwd) {
|
|
155
|
+
if (!cwd || typeof cwd !== "string") return [];
|
|
156
|
+
// Codex has no `.codex/AGENTS.md` per-directory convention to mirror CC's
|
|
157
|
+
// `.claude/CLAUDE.md`, so the per-level list is the single filename.
|
|
158
|
+
const paths = memoryWalkPaths(cwd, ["AGENTS.md"]);
|
|
159
|
+
// The user-global instructions file, which Codex loads for every session
|
|
160
|
+
// whatever the cwd — the counterpart of ~/.claude/CLAUDE.md.
|
|
161
|
+
paths.push(join(CODEX_HOME, "AGENTS.md"));
|
|
162
|
+
return collectMemoryFiles(paths);
|
|
163
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// The memory section's sources: how much memory a new process could really get,
|
|
2
|
+
// and how much swap — commit charge, on Windows — is in use.
|
|
3
|
+
//
|
|
4
|
+
// Moved out of system-metrics.mjs unchanged. The sampler there decides when to
|
|
5
|
+
// ask — every MEM_INTERVAL_MS, never on the CPU tick — and what a reading that
|
|
6
|
+
// failed does to the meter and to the history (sampleMemory, #789). This file
|
|
7
|
+
// only answers: readAvailable and readSwap return a reading or null per call,
|
|
8
|
+
// and keep nothing between calls.
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import { run } from "./metrics-run.mjs";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bytes of memory a new process could actually get, per platform.
|
|
15
|
+
*
|
|
16
|
+
* `os.freemem()` is the obvious call and it is the wrong one on two of the three
|
|
17
|
+
* platforms, because "free" and "available" are different questions. Pages
|
|
18
|
+
* holding cached files or inactive anonymous memory are not free, but the kernel
|
|
19
|
+
* will hand them over the moment something asks. Reporting them as used is what
|
|
20
|
+
* makes the naive `(total - free) / total` read 99.5% on an idle 32 GB Mac — a
|
|
21
|
+
* number that would send the reader straight to Activity Monitor, which is the
|
|
22
|
+
* one outcome this readout exists to prevent.
|
|
23
|
+
*
|
|
24
|
+
* linux /proc/meminfo MemAvailable — the kernel's own answer, a file read
|
|
25
|
+
* win32 os.freemem() already reports available physical memory
|
|
26
|
+
* darwin vm_stat, because nothing in Node exposes the page classes
|
|
27
|
+
*
|
|
28
|
+
* Only darwin costs a subprocess, and only at MEM_INTERVAL_MS.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Available bytes out of `/proc/meminfo` text, or null when the field is absent.
|
|
32
|
+
*
|
|
33
|
+
* Pure and exported for the same reason codexHome() takes a platform: a Linux
|
|
34
|
+
* answer has to be checkable from a Mac, and the only alternative is trusting
|
|
35
|
+
* that a regex nobody has run is right.
|
|
36
|
+
*/
|
|
37
|
+
export function availableFromMeminfo(text) {
|
|
38
|
+
const m = /^MemAvailable:\s+(\d+)\s*kB/m.exec(String(text ?? ""));
|
|
39
|
+
return m ? Number(m[1]) * 1024 : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Available bytes out of `vm_stat` output, or null when it does not parse.
|
|
44
|
+
*
|
|
45
|
+
* Everything the kernel can hand over without swapping: genuinely free pages,
|
|
46
|
+
* read-ahead it can drop, inactive anonymous pages, and purgeable caches. This
|
|
47
|
+
* is the number `os.freemem()` is missing — it reports only the first of the
|
|
48
|
+
* four, which is why the naive formula reads ~99% on an idle 32 GB Mac.
|
|
49
|
+
*/
|
|
50
|
+
export function availableFromVmStat(text, total) {
|
|
51
|
+
const out = String(text ?? "");
|
|
52
|
+
const pageSize = Number(/page size of (\d+) bytes/.exec(out)?.[1]) || 4096;
|
|
53
|
+
const pages = name => {
|
|
54
|
+
const m = new RegExp(`^Pages ${name}:\\s+(\\d+)`, "m").exec(out);
|
|
55
|
+
return m ? Number(m[1]) : 0;
|
|
56
|
+
};
|
|
57
|
+
const reclaimable = pages("free") + pages("speculative")
|
|
58
|
+
+ pages("inactive") + pages("purgeable");
|
|
59
|
+
if (reclaimable <= 0) return null;
|
|
60
|
+
const avail = reclaimable * pageSize;
|
|
61
|
+
return total != null && avail > total ? null : avail;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* How much memory is really available, or NULL when this machine could not be
|
|
66
|
+
* asked.
|
|
67
|
+
*
|
|
68
|
+
* `os.freemem()` USED TO BE THE FALLBACK ON BOTH REAL PLATFORMS, and it is the
|
|
69
|
+
* one number this function exists to avoid (#789). The header above says why:
|
|
70
|
+
* counting only genuinely free pages makes the naive `(total - free) / total`
|
|
71
|
+
* read 99.5% on an idle 32 GB Mac — "a number that would send the reader
|
|
72
|
+
* straight to Activity Monitor, which is the one outcome this readout exists to
|
|
73
|
+
* prevent". So a failed measurement produced exactly the reading the module was
|
|
74
|
+
* written to suppress.
|
|
75
|
+
*
|
|
76
|
+
* And it did not merely flicker. `record` folds into the minute bucket by
|
|
77
|
+
* MAXIMUM, so one failed poll painted a red 99% peak on the memory chart that
|
|
78
|
+
* survived every good sample for the next twenty-four hours. The failure is
|
|
79
|
+
* ordinary: `run` resolves null on a spawn error (EAGAIN/EMFILE under fork
|
|
80
|
+
* pressure — a deck watching many agents is exactly that), on a non-zero exit,
|
|
81
|
+
* and on its own 2s deadline. 2,880 chances a day.
|
|
82
|
+
*
|
|
83
|
+
* Null instead, and the caller keeps the previous reading and records nothing.
|
|
84
|
+
* A gap in the chart is honest; a 99% peak is not.
|
|
85
|
+
*
|
|
86
|
+
* The last branch still answers `freemem()` because on Windows there is no
|
|
87
|
+
* better source to have failed — it is the measurement, not a substitute for
|
|
88
|
+
* one.
|
|
89
|
+
*/
|
|
90
|
+
export async function readAvailable(platform = process.platform) {
|
|
91
|
+
const total = os.totalmem();
|
|
92
|
+
|
|
93
|
+
if (platform === "linux") {
|
|
94
|
+
try {
|
|
95
|
+
const parsed = availableFromMeminfo(await readFile("/proc/meminfo", "utf8"));
|
|
96
|
+
if (parsed != null) return parsed;
|
|
97
|
+
} catch { /* unreadable /proc — say so rather than guessing */ }
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (platform === "darwin") {
|
|
102
|
+
const out = await run("vm_stat", []);
|
|
103
|
+
return (out ? availableFromVmStat(out, total) : null) ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return os.freemem();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Swap out of macOS `sysctl -n vm.swapusage`, which prints
|
|
111
|
+
* `total = 14336.00M used = 12876.00M free = 1460.00M (encrypted)`.
|
|
112
|
+
*
|
|
113
|
+
* Swap is the reading a percentage cannot give you. A machine at "64% memory
|
|
114
|
+
* used" that is quietly paging 12 GB to disk is not the same machine as one at
|
|
115
|
+
* 64% with an empty swap file, and the difference is the one you can feel.
|
|
116
|
+
*/
|
|
117
|
+
export function swapFromSysctl(text) {
|
|
118
|
+
const unit = s => {
|
|
119
|
+
// `,` as well as `.`: C_LOCALE should mean this never arrives, and a parser
|
|
120
|
+
// that fails closed on a whole continent's default is not a thing to leave
|
|
121
|
+
// resting on one environment variable. Safe to accept both here because
|
|
122
|
+
// sysctl formats with printf's %f, which never groups thousands — so a
|
|
123
|
+
// comma in this field can only ever be the decimal point.
|
|
124
|
+
const m = /^([\d.,]+)([KMG])?$/i.exec(s);
|
|
125
|
+
if (!m) return null;
|
|
126
|
+
const mult = { k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[(m[2] || "M").toLowerCase()] ?? 1;
|
|
127
|
+
return Math.round(Number(m[1].replace(",", ".")) * mult);
|
|
128
|
+
};
|
|
129
|
+
const total = unit(/total\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
|
|
130
|
+
const used = unit(/used\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
|
|
131
|
+
if (total == null || used == null) return null;
|
|
132
|
+
return { total, used };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Swap out of `/proc/meminfo`, where it is two fields rather than one line. */
|
|
136
|
+
export function swapFromMeminfo(text) {
|
|
137
|
+
const s = String(text ?? "");
|
|
138
|
+
const total = /^SwapTotal:\s+(\d+)\s*kB/m.exec(s);
|
|
139
|
+
const free = /^SwapFree:\s+(\d+)\s*kB/m.exec(s);
|
|
140
|
+
if (!total || !free) return null;
|
|
141
|
+
const t = Number(total[1]) * 1024;
|
|
142
|
+
return { total: t, used: Math.max(0, t - Number(free[1]) * 1024) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Windows has no swap file in the Unix sense; the comparable pressure signal is
|
|
147
|
+
* commit charge, which `Win32_OperatingSystem` reports as total and free
|
|
148
|
+
* virtual memory in KB. Labelled "commit" in the UI rather than "swap", because
|
|
149
|
+
* calling it swap would be borrowing a word for a different mechanism.
|
|
150
|
+
*/
|
|
151
|
+
export function swapFromWmicJson(json) {
|
|
152
|
+
try {
|
|
153
|
+
const o = typeof json === "string" ? JSON.parse(json) : json;
|
|
154
|
+
const total = Number(o?.TotalVirtualMemorySize) * 1024;
|
|
155
|
+
const free = Number(o?.FreeVirtualMemory) * 1024;
|
|
156
|
+
if (!Number.isFinite(total) || !Number.isFinite(free) || total <= 0) return null;
|
|
157
|
+
return { total, used: Math.max(0, total - free) };
|
|
158
|
+
} catch { return null; }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function readSwap(platform = process.platform) {
|
|
162
|
+
if (platform === "darwin") {
|
|
163
|
+
const out = await run("sysctl", ["-n", "vm.swapusage"]);
|
|
164
|
+
return out ? swapFromSysctl(out) : null;
|
|
165
|
+
}
|
|
166
|
+
if (platform === "linux") {
|
|
167
|
+
try { return swapFromMeminfo(await readFile("/proc/meminfo", "utf8")); }
|
|
168
|
+
catch { return null; }
|
|
169
|
+
}
|
|
170
|
+
if (platform === "win32") {
|
|
171
|
+
const out = await run("powershell.exe", [
|
|
172
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
173
|
+
"Get-CimInstance Win32_OperatingSystem | Select-Object TotalVirtualMemorySize,FreeVirtualMemory | ConvertTo-Json -Compress",
|
|
174
|
+
], 4_000);
|
|
175
|
+
return out ? swapFromWmicJson(out.trim()) : null;
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The Machine panel's history: a day of minute buckets that every section
|
|
2
|
+
// records into, and the reads its charts are drawn from.
|
|
3
|
+
//
|
|
4
|
+
// Moved out of system-metrics.mjs unchanged, so that a sampler lifted out of it
|
|
5
|
+
// can record here without importing the sampler back — which would be a cycle
|
|
6
|
+
// in src/server, the thing boot-module-graph.test.ts exists to refuse. The
|
|
7
|
+
// sampler still decides what is recorded and when, builds each section's series
|
|
8
|
+
// out of these reads, and answers /api/system/history (historySnapshot). This
|
|
9
|
+
// file holds the ring, and nothing outside it touches the array: record writes
|
|
10
|
+
// it, three named reads answer from it, and resetHistory empties it.
|
|
11
|
+
|
|
12
|
+
/** Minute buckets, oldest first, for every section that keeps a history.
|
|
13
|
+
* See HISTORY_MINUTES. */
|
|
14
|
+
const history = [];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* How much history the panel keeps, and why it is bucketed by minute.
|
|
18
|
+
*
|
|
19
|
+
* Each section answers "what is it now"; the chart behind it answers "what did
|
|
20
|
+
* it do while that build was running", which is a different question and the
|
|
21
|
+
* reason a section opens one at all. 1440 minutes is a day, which covers "since
|
|
22
|
+
* the deck started" for every session anybody actually has.
|
|
23
|
+
*
|
|
24
|
+
* A bucket holds the MAXIMUM of its minute, never the mean, and that choice is
|
|
25
|
+
* the same one for every series here. A machine that touched 94°C for twenty
|
|
26
|
+
* seconds and sat at 60 for the rest of the minute averages to 66 and reads as
|
|
27
|
+
* calm; a load average that spiked to 114 between two quiet stretches averages
|
|
28
|
+
* away entirely. The spike is what somebody opens a chart to find.
|
|
29
|
+
*
|
|
30
|
+
* Kept out of systemSnapshot deliberately. That endpoint is polled every three
|
|
31
|
+
* seconds by a topbar meter that draws none of this; a day of buckets on every
|
|
32
|
+
* one of those responses would be the largest thing the deck sends, for charts
|
|
33
|
+
* that are usually closed. It has its own route, like the process list.
|
|
34
|
+
*/
|
|
35
|
+
const HISTORY_MINUTES = 1440;
|
|
36
|
+
export const BUCKET_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Fold one reading into the minute it belongs to, under a namespaced key.
|
|
40
|
+
*
|
|
41
|
+
* Keys are namespaced by section (`thermal:GPU`, `cpu:all`, `mem:swap`) rather
|
|
42
|
+
* than kept in four rings, because they all share one clock: a bucket is a
|
|
43
|
+
* minute of this machine, and every series that has something to say about that
|
|
44
|
+
* minute says it in the same place. The sections sample at different rates —
|
|
45
|
+
* CPU every three seconds, thermal every ten, memory every thirty — and folding
|
|
46
|
+
* by maximum makes that difference invisible to the reader, which is what it
|
|
47
|
+
* should be.
|
|
48
|
+
*/
|
|
49
|
+
export function record(key, value, nowMs = Date.now()) {
|
|
50
|
+
if (!Number.isFinite(value)) return;
|
|
51
|
+
const minute = Math.floor(nowMs / BUCKET_MS);
|
|
52
|
+
let last = history[history.length - 1];
|
|
53
|
+
if (!last || last.m !== minute) {
|
|
54
|
+
last = { m: minute, v: {} };
|
|
55
|
+
history.push(last);
|
|
56
|
+
while (history.length > HISTORY_MINUTES) history.shift();
|
|
57
|
+
}
|
|
58
|
+
const prev = last.v[key];
|
|
59
|
+
last.v[key] = prev == null ? value : Math.max(prev, value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every reading recorded under `key`, oldest first, one point per minute that
|
|
63
|
+
* has one. */
|
|
64
|
+
export function pointsOf(key) {
|
|
65
|
+
return history.filter(b => b.v[key] != null)
|
|
66
|
+
// Timestamps rather than indices: a bucket only exists for a minute that was
|
|
67
|
+
// sampled, so a gap — the machine asleep, the process paused — stays a gap
|
|
68
|
+
// rather than becoming a straight line across it.
|
|
69
|
+
.map(b => ({ t: b.m * BUCKET_MS, v: b.v[key] }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What follows `prefix` in every key recorded under it, in the order each
|
|
73
|
+
* first appeared — a series per sensor, however late the sensor turned up. */
|
|
74
|
+
export function labelsUnder(prefix) {
|
|
75
|
+
const labels = [];
|
|
76
|
+
for (const b of history) {
|
|
77
|
+
for (const k of Object.keys(b.v)) {
|
|
78
|
+
if (!k.startsWith(prefix)) continue;
|
|
79
|
+
const label = k.slice(prefix.length);
|
|
80
|
+
if (!labels.includes(label)) labels.push(label);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return labels;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Where the oldest minute still held begins, or 0 before anything is. */
|
|
87
|
+
export function oldestBucketMs() {
|
|
88
|
+
return history.length ? history[0].m * BUCKET_MS : 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Empty the ring. See stopSystemMetrics, which is the one caller. */
|
|
92
|
+
export function resetHistory() {
|
|
93
|
+
history.length = 0;
|
|
94
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// How the Machine panel's readers run a command: no shell, a forced number
|
|
2
|
+
// format, stdout or null, and a deadline that kills the child rather than
|
|
3
|
+
// waiting on it.
|
|
4
|
+
//
|
|
5
|
+
// Moved out of system-metrics.mjs unchanged, so that a reader lifted out of the
|
|
6
|
+
// sampler can share the one runner without importing the sampler back — which
|
|
7
|
+
// would be a cycle in src/server, the thing boot-module-graph.test.ts exists to
|
|
8
|
+
// refuse. It is not exec.mjs's `run` and should not become it: that one is for
|
|
9
|
+
// the tools the deck manages, resolves Windows shims and PATHEXT, and answers a
|
|
10
|
+
// result object; this one only ever runs a system binary and answers its stdout,
|
|
11
|
+
// or null, which is all a sampler can use.
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The locale every child of this module runs in — and only the part of it that
|
|
16
|
+
* had to be forced.
|
|
17
|
+
*
|
|
18
|
+
* Not a preference. Every command spawned here has its output read by a regex
|
|
19
|
+
* and every one of those regexes reads a number with a `.` in it. `ps` and
|
|
20
|
+
* `sysctl` honour LC_NUMERIC, so on a machine set to de_DE, fr_FR, ru_RU or
|
|
21
|
+
* pt_BR — comma is the decimal separator for most of Europe and Latin America —
|
|
22
|
+
* the same commands print:
|
|
23
|
+
*
|
|
24
|
+
* ps 1 0,2 0,0 /sbin/launchd
|
|
25
|
+
* sysctl total = 8192,00M used = 7189,75M free = 1002,25M
|
|
26
|
+
*
|
|
27
|
+
* and the parsers matched nothing at all. Not partially: `parsePsProcesses`
|
|
28
|
+
* `continue`s on every row, so the process panel was permanently empty, and
|
|
29
|
+
* `swapFromSysctl` returned null, so the macOS swap meter was permanently
|
|
30
|
+
* blank. Silently, on a machine where everything else worked.
|
|
31
|
+
*
|
|
32
|
+
* THIS USED TO FORCE THE WHOLE LOCALE, and forcing the whole locale also forces
|
|
33
|
+
* the CHARACTER SET. Under `LC_ALL=C`, `ps` escapes every byte it cannot render
|
|
34
|
+
* as ASCII, so an application named in Cyrillic came back as
|
|
35
|
+
*
|
|
36
|
+
* M-PM-/M-PM-=M-PM-4M-PM-5M-PM-:M-QM^A M-PM^\M-QM^CM-PM-7M-QM^KM-PM-:M-PM-0
|
|
37
|
+
*
|
|
38
|
+
* where the name is `Яндекс Музыка`. The panel truncates at 164px so it read as
|
|
39
|
+
* noise; the process list added in #738 has room for all ninety-one characters
|
|
40
|
+
* of it, which is how it was found. Every reader with a non-Latin application
|
|
41
|
+
* on their machine was being shown that.
|
|
42
|
+
*
|
|
43
|
+
* So only the numeric is forced now, and the character set is inherited. All
|
|
44
|
+
* four measured on this machine:
|
|
45
|
+
*
|
|
46
|
+
* LC_ALL=C name mangled, 0.7
|
|
47
|
+
* LC_ALL="" LC_NUMERIC=C Яндекс Музыка, 0.7
|
|
48
|
+
* LANG=de_DE + our override Яндекс Музыка, 0.7 ← the comma case
|
|
49
|
+
* LC_ALL=de_DE + our empty LC_ALL 0.7 ← a hostile env
|
|
50
|
+
*
|
|
51
|
+
* The empty `LC_ALL` is doing real work in the last of those: POSIX ignores an
|
|
52
|
+
* empty LC_ALL, so clearing it is what stops a user's own LC_ALL from
|
|
53
|
+
* outranking the LC_NUMERIC below. Setting LC_NUMERIC alone would not survive
|
|
54
|
+
* it.
|
|
55
|
+
*
|
|
56
|
+
* A reader whose own locale is C still sees the escaped form — but so does
|
|
57
|
+
* their terminal, so that is their machine being consistent rather than this
|
|
58
|
+
* module choosing for them.
|
|
59
|
+
*
|
|
60
|
+
* Meaningless on Windows, where the readers' branches are PowerShell piped
|
|
61
|
+
* through ConvertTo-Json and already culture-invariant — and harmless there for
|
|
62
|
+
* the same reason. The comma tolerance in their parsers stays as defence in
|
|
63
|
+
* depth for a sandbox that strips the environment, not as the primary answer.
|
|
64
|
+
*/
|
|
65
|
+
const C_LOCALE = { LC_ALL: "", LC_NUMERIC: "C" };
|
|
66
|
+
|
|
67
|
+
/** Run a command and resolve its stdout, or null. Never rejects, never inherits
|
|
68
|
+
* a shell, never inherits a locale, and is killed rather than allowed to hang
|
|
69
|
+
* the sampler. */
|
|
70
|
+
export function run(file, args, timeoutMs = 2_000) {
|
|
71
|
+
return new Promise(resolve => {
|
|
72
|
+
let child;
|
|
73
|
+
try {
|
|
74
|
+
child = spawn(file, args, {
|
|
75
|
+
windowsHide: true,
|
|
76
|
+
env: { ...process.env, ...C_LOCALE },
|
|
77
|
+
// stderr is PIPED AND NEVER READ, which is a deadlock waiting for a
|
|
78
|
+
// chatty child: a pipe nobody drains fills at 64 KB and the writer
|
|
79
|
+
// blocks there until this function's own deadline kills it. Nothing
|
|
80
|
+
// here has ever looked at it — `run` resolves on stdout or null — so
|
|
81
|
+
// the honest arrangement is not to open it.
|
|
82
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch { return resolve(null); }
|
|
86
|
+
let out = "";
|
|
87
|
+
const timer = setTimeout(() => { try { child.kill(); } catch {} resolve(null); }, timeoutMs);
|
|
88
|
+
// DECODE THE STREAM, NOT EACH CHUNK. `out += d` on a Buffer calls toString()
|
|
89
|
+
// per chunk, and a chunk boundary falls wherever the pipe happened to break
|
|
90
|
+
// — measured on `ps` output as three chunks of 8192/8192/5718 — so a
|
|
91
|
+
// multi-byte character split across two of them became two replacement
|
|
92
|
+
// characters. An application called `Яндекс Музыка` in the process list
|
|
93
|
+
// rendered as `Ян��екс Музыка`. setEncoding carries the partial sequence
|
|
94
|
+
// across the boundary, which is the whole reason it exists.
|
|
95
|
+
child.stdout?.setEncoding?.("utf8");
|
|
96
|
+
child.stdout?.on("data", d => { out += d; });
|
|
97
|
+
child.on("error", () => { clearTimeout(timer); resolve(null); });
|
|
98
|
+
child.on("close", code => { clearTimeout(timer); resolve(code === 0 ? out : null); });
|
|
99
|
+
});
|
|
100
|
+
}
|