agent-dag 3.2.1 → 3.4.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 +18 -6
- package/bin/deck.js +9 -1
- package/dist/web/assets/index-3Q9bZKVe.js +171 -0
- package/dist/web/assets/index-D0YH-emg.css +1 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +60 -14
- package/package.json +6 -3
- package/release-notes.json +36 -0
- package/src/server/agent-activity.mjs +27 -11
- package/src/server/browser-history.mjs +31 -5
- package/src/server/browser-presence.mjs +57 -12
- package/src/server/browser-react.mjs +31 -9
- package/src/server/browser-watch-store.mjs +56 -2
- package/src/server/browser-watch.mjs +90 -10
- package/src/server/ccusage.mjs +100 -3
- package/src/server/codex-quota.mjs +6 -2
- package/src/server/codex-usage.mjs +24 -1
- package/src/server/cswap-admin.mjs +0 -14
- package/src/server/cswap-auto.mjs +39 -1
- package/src/server/cswap-install.mjs +6 -1
- package/src/server/exec.mjs +44 -0
- package/src/server/index.mjs +127 -11
- package/src/server/installer.mjs +58 -3
- package/src/server/macmon.mjs +17 -6
- package/src/server/npx.mjs +6 -1
- package/src/server/open-url.mjs +25 -4
- package/src/server/quota.mjs +88 -26
- package/src/server/retire-sound-hook.mjs +36 -2
- package/src/server/self-update.mjs +17 -5
- package/src/server/system-metrics.mjs +28 -36
- package/src/server/uv-bootstrap.mjs +39 -3
- package/dist/web/assets/index-Bnn7d7u8.css +0 -1
- package/dist/web/assets/index-COdHVcLu.js +0 -149
package/src/server/quota.mjs
CHANGED
|
@@ -35,6 +35,11 @@ import { readFile } from "node:fs/promises";
|
|
|
35
35
|
import { join } from "node:path";
|
|
36
36
|
import { homedir } from "node:os";
|
|
37
37
|
import { PRODUCT } from "./brand.mjs";
|
|
38
|
+
// One ANSI stripper for the whole deck. The private copy that used to live
|
|
39
|
+
// here accepted only the BEL terminator for an OSC sequence, while term.mjs's
|
|
40
|
+
// also accepts ESC \\ — so a hyperlink written the other legal way survived
|
|
41
|
+
// into text this module then parsed for quota lines.
|
|
42
|
+
import { stripAnsi } from "./term.mjs";
|
|
38
43
|
import { resetLabelIso } from "./reset-label.mjs";
|
|
39
44
|
|
|
40
45
|
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
@@ -83,6 +88,73 @@ async function readOAuthToken() {
|
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Whether we may spend a request of the user's budget right now.
|
|
93
|
+
*
|
|
94
|
+
* Exported for tests — this is the rule that stopped the deck from starving
|
|
95
|
+
* claude-swap, and it is worth pinning down.
|
|
96
|
+
*/
|
|
97
|
+
export function maySelfPoll({ now, force, lastSelfPollAt, rateLimitedUntil }) {
|
|
98
|
+
if (now < rateLimitedUntil) return false;
|
|
99
|
+
return now - lastSelfPollAt >= (force ? FORCE_POLL_MS : SELF_POLL_MS);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A cooldown from a `retry-after`, kept inside limits the deck can live with.
|
|
106
|
+
*
|
|
107
|
+
* Unclamped, the header decided the poller's fate in both directions: `0` (or a
|
|
108
|
+
* value the server rounds down to it) defeats the cooldown entirely and the
|
|
109
|
+
* next tick asks again immediately, which is the loop a 429 exists to stop; a
|
|
110
|
+
* large one — a day is a legal value — freezes the reader for the life of the
|
|
111
|
+
* process, and nothing here re-reads it. Both are the remote side deciding how
|
|
112
|
+
* this deck behaves, which a header is not entitled to do.
|
|
113
|
+
*
|
|
114
|
+
* The floor is the deck's own minimum backoff and the ceiling is an hour: long
|
|
115
|
+
* enough to be a real retreat, short enough that a quota panel is not dead for
|
|
116
|
+
* the rest of the day because one reply said so.
|
|
117
|
+
*/
|
|
118
|
+
export function cooldownFromHeader(raw, fallbackMs, minMs = 30_000, maxMs = 3600_000) {
|
|
119
|
+
const seconds = parseInt(String(raw ?? ""), 10);
|
|
120
|
+
if (!Number.isFinite(seconds)) return fallbackMs;
|
|
121
|
+
return Math.min(Math.max(seconds * 1000, minMs), maxMs);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* WHETHER THIS MACHINE HAS A SUBSCRIPTION TO REPORT ON AT ALL.
|
|
126
|
+
*
|
|
127
|
+
* Every source here needs a Claude.ai OAuth credential: the claude-swap store
|
|
128
|
+
* holds one, `claudeAiOauth` in the credentials file is one, and
|
|
129
|
+
* `claude --print /usage` prints windows only for a session signed in with one.
|
|
130
|
+
* An API-key, Bedrock or Vertex install has none — and there is no quota to
|
|
131
|
+
* read, because those are billed per token rather than in five-hour windows.
|
|
132
|
+
*
|
|
133
|
+
* That mattered because of what the CLI does on such a machine: it RUNS, prints
|
|
134
|
+
* no quota lines, and the branch below used to read that as "genuine <1%" and
|
|
135
|
+
* publish `ok: true` with two zeroes. The panel then drew empty bars, which is
|
|
136
|
+
* a measurement nobody took. Codex already answers this properly, with
|
|
137
|
+
* `api_key_mode` as its own reason and its own sentence.
|
|
138
|
+
*
|
|
139
|
+
* Cheap and synchronous: environment first, because a machine configured for
|
|
140
|
+
* Bedrock or Vertex says so there, then the presence of the OAuth block in the
|
|
141
|
+
* credentials file. `readOAuthToken` above answers a different question — it
|
|
142
|
+
* also rejects an EXPIRED token, and an expired subscription is still a
|
|
143
|
+
* subscription.
|
|
144
|
+
*/
|
|
145
|
+
export async function hasSubscriptionCredential(env = process.env) {
|
|
146
|
+
if (env.CLAUDE_CODE_USE_BEDROCK === "1" || env.CLAUDE_CODE_USE_VERTEX === "1") return false;
|
|
147
|
+
try {
|
|
148
|
+
const raw = await readFile(credentialsPath(), "utf8");
|
|
149
|
+
if (JSON.parse(raw)?.claudeAiOauth?.accessToken) return true;
|
|
150
|
+
} catch { /* absent or unreadable, decided below */ }
|
|
151
|
+
// A key in the environment and no OAuth block beside it is the API-key
|
|
152
|
+
// install. Without either, this deck simply has not been signed in yet, and
|
|
153
|
+
// "sign in" is the right thing to say — which is the `waiting` branch, not
|
|
154
|
+
// this one.
|
|
155
|
+
return !(env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN);
|
|
156
|
+
}
|
|
157
|
+
|
|
86
158
|
// ISO-8601 → "Jun 19, 1:19pm" (local time, matching the CLI display format).
|
|
87
159
|
//
|
|
88
160
|
// The body moved to reset-label.mjs in #374: codex-quota.mjs had a copy that
|
|
@@ -156,9 +228,7 @@ async function fetchOAuthUsage() {
|
|
|
156
228
|
});
|
|
157
229
|
|
|
158
230
|
if (res.status === 429) {
|
|
159
|
-
|
|
160
|
-
const cooldownMs = Number.isFinite(retryAfter) ? retryAfter * 1000 : 5 * 60_000;
|
|
161
|
-
_rateLimitedUntil = Date.now() + cooldownMs;
|
|
231
|
+
_rateLimitedUntil = Date.now() + cooldownFromHeader(res.headers.get("retry-after"), 5 * 60_000);
|
|
162
232
|
return null;
|
|
163
233
|
}
|
|
164
234
|
if (!res.ok) return null;
|
|
@@ -236,25 +306,6 @@ export function quotaFromStore(entry) {
|
|
|
236
306
|
return out;
|
|
237
307
|
}
|
|
238
308
|
|
|
239
|
-
/**
|
|
240
|
-
* Whether we may spend a request of the user's budget right now.
|
|
241
|
-
*
|
|
242
|
-
* Exported for tests — this is the rule that stopped the deck from starving
|
|
243
|
-
* claude-swap, and it is worth pinning down.
|
|
244
|
-
*/
|
|
245
|
-
export function maySelfPoll({ now, force, lastSelfPollAt, rateLimitedUntil }) {
|
|
246
|
-
if (now < rateLimitedUntil) return false;
|
|
247
|
-
return now - lastSelfPollAt >= (force ? FORCE_POLL_MS : SELF_POLL_MS);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
251
|
-
|
|
252
|
-
function stripAnsi(s) {
|
|
253
|
-
return s
|
|
254
|
-
.replace(/\x1B\[[0-9;]*[A-Za-z]/g, "")
|
|
255
|
-
.replace(/\x1B\][^\x07]*\x07/g, "")
|
|
256
|
-
.replace(/\x1B[()][AB012]/g, "");
|
|
257
|
-
}
|
|
258
309
|
|
|
259
310
|
// Parse "Jun 18, 4:09pm" (local time, no tz) into unix seconds.
|
|
260
311
|
// Claude shows times in the user's local timezone, so parsing as local is correct.
|
|
@@ -671,12 +722,23 @@ async function _doFetch(now, force = false, gen = _generation) {
|
|
|
671
722
|
return publish(gen, { ..._lastGood, stale: true }, now - (CACHE_MS - 5_000));
|
|
672
723
|
}
|
|
673
724
|
|
|
674
|
-
// Never had good data. CLI
|
|
675
|
-
//
|
|
676
|
-
|
|
725
|
+
// Never had good data. A CLI that RAN and printed no quota lines is two
|
|
726
|
+
// different machines, and they need two different answers:
|
|
727
|
+
//
|
|
728
|
+
// * a subscription install on a cold invocation — the lines come back on a
|
|
729
|
+
// later call, and until then "<1%" is the honest reading of a window that
|
|
730
|
+
// has genuinely just reset;
|
|
731
|
+
// * an API-key, Bedrock or Vertex install, which has no windows at all.
|
|
732
|
+
// Publishing two zeroes there drew empty bars for a measurement nobody
|
|
733
|
+
// took, on a machine where no amount of retrying will ever produce one.
|
|
734
|
+
//
|
|
735
|
+
// A CLI that failed entirely is `ok: false` as it always was, and the reason
|
|
736
|
+
// says which of the two the reader is looking at.
|
|
737
|
+
const subscribed = cliOk ? await hasSubscriptionCredential() : false;
|
|
738
|
+
const result = cliOk && subscribed
|
|
677
739
|
? { ok: true, session5hPct: 0, session5hWindowSec: 18000,
|
|
678
740
|
week7dPct: 0, week7dWindowSec: 604800, fetchedAt: now }
|
|
679
|
-
: { ok: false, fetchedAt: now };
|
|
741
|
+
: { ok: false, reason: cliOk ? "no_subscription" : "cli_failed", fetchedAt: now };
|
|
680
742
|
return publish(gen, result, now - (CACHE_MS - 5_000));
|
|
681
743
|
}
|
|
682
744
|
|
|
@@ -94,10 +94,44 @@ const PARKED_PATH = join(homedir(), ".agents-deck", "parked-sound-hooks.json");
|
|
|
94
94
|
const commandsOf = (entry) =>
|
|
95
95
|
(entry?.hooks ?? []).map(h => (typeof h?.command === "string" ? h.command : ""));
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* THE TAIL, NOT THE WHOLE PATH — and this is a data-loss fix, not a tidy-up.
|
|
99
|
+
*
|
|
100
|
+
* The stored command was written by `shellQuoteArg`, whose POSIX branch
|
|
101
|
+
* single-quotes the argument and rewrites every `'` as `'\''`. So on a config
|
|
102
|
+
* dir like `/mnt/Bob's SSD/claude` the command contains `Bob'\''s` and a
|
|
103
|
+
* `cmd.includes(NOTIFY_PATH)` against the raw path is false — on macOS and
|
|
104
|
+
* Linux, for a perfectly ordinary directory name.
|
|
105
|
+
*
|
|
106
|
+
* What follows is unrecoverable. A mark-less `Stop` entry — the case this
|
|
107
|
+
* module exists for — is then not recognised as ours, so it is kept; and
|
|
108
|
+
* `anythingStillNamesOurScripts`, which has no mark to fall back on, also says
|
|
109
|
+
* no, so the sweep deletes `notify.mjs`. Claude Code throws
|
|
110
|
+
* `Cannot find module` at the end of every turn afterwards, forever, with the
|
|
111
|
+
* deck already uninstalled.
|
|
112
|
+
*
|
|
113
|
+
* The last two segments are fixed whatever the prefix is — the install
|
|
114
|
+
* directory is always `<config dir>/agent-dag` — and they contain no character
|
|
115
|
+
* any quoting rewrites. Case is folded where the filesystem folds it, which is
|
|
116
|
+
* the other half: `exec.mjs`'s own `sameCommand` lowercases "because Windows
|
|
117
|
+
* paths are", and this comparison did not.
|
|
118
|
+
*/
|
|
119
|
+
const SCRIPT_TAILS = ["agent-dag/notify.mjs", "agent-dag/notify.js"];
|
|
120
|
+
|
|
121
|
+
export function namesOurScript(cmd, platform = process.platform) {
|
|
122
|
+
if (typeof cmd !== "string" || cmd === "") return false;
|
|
123
|
+
// Backslashes become separators only where they ARE separators. On POSIX a
|
|
124
|
+
// backslash is an ordinary filename character, and rewriting it there could
|
|
125
|
+
// invent a match that the filesystem does not have.
|
|
126
|
+
let hay = platform === "win32" ? cmd.replace(/\\/g, "/") : cmd;
|
|
127
|
+
if (platform === "win32" || platform === "darwin") hay = hay.toLowerCase();
|
|
128
|
+
return SCRIPT_TAILS.some(tail => hay.includes(tail));
|
|
129
|
+
}
|
|
130
|
+
|
|
97
131
|
/** An entry this deck put there: by its mark, or by the script it runs. */
|
|
98
132
|
function isOurs(entry) {
|
|
99
133
|
if (entry?.[MARK] === true) return true;
|
|
100
|
-
return commandsOf(entry).some(cmd =>
|
|
134
|
+
return commandsOf(entry).some(cmd => namesOurScript(cmd));
|
|
101
135
|
}
|
|
102
136
|
|
|
103
137
|
/** Anywhere in the file — not just `Stop` — that still runs one of our scripts.
|
|
@@ -109,7 +143,7 @@ function anythingStillNamesOurScripts(settings) {
|
|
|
109
143
|
for (const group of Object.values(groups)) {
|
|
110
144
|
if (!Array.isArray(group)) continue;
|
|
111
145
|
for (const entry of group) {
|
|
112
|
-
if (commandsOf(entry).some(cmd =>
|
|
146
|
+
if (commandsOf(entry).some(cmd => namesOurScript(cmd))) return true;
|
|
113
147
|
}
|
|
114
148
|
}
|
|
115
149
|
return false;
|
|
@@ -1069,12 +1069,19 @@ function sweepOrphanedNotes(keep) {
|
|
|
1069
1069
|
}
|
|
1070
1070
|
}
|
|
1071
1071
|
|
|
1072
|
-
// Signal 0 delivers nothing; it
|
|
1073
|
-
//
|
|
1074
|
-
//
|
|
1072
|
+
// Signal 0 delivers nothing; it asks whether the pid could be signalled.
|
|
1073
|
+
//
|
|
1074
|
+
// BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
|
|
1075
|
+
// answers EPERM for a process this account may not signal. On Windows
|
|
1076
|
+
// `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
|
|
1077
|
+
// maps that to EACCES — so a deck started from an elevated terminal, or under
|
|
1078
|
+
// another account, read as DEAD to every probe in this repo. What followed was
|
|
1079
|
+
// silent: the live deck's discovery file was unlinked on the next hook fire,
|
|
1080
|
+
// rewritten five seconds later by keepDiscovery, and its banner went on
|
|
1081
|
+
// claiming it was receiving events it had stopped receiving.
|
|
1075
1082
|
function processAlive(pid) {
|
|
1076
1083
|
try { process.kill(pid, 0); return true; }
|
|
1077
|
-
catch (e) { return e?.code === "EPERM"; }
|
|
1084
|
+
catch (e) { return e?.code === "EPERM" || e?.code === "EACCES"; }
|
|
1078
1085
|
}
|
|
1079
1086
|
|
|
1080
1087
|
/**
|
|
@@ -1198,7 +1205,12 @@ export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
|
|
|
1198
1205
|
// installedVersion() disagree with the running one, and the ordinary
|
|
1199
1206
|
// drift path takes it from there — including its wait for an idle moment.
|
|
1200
1207
|
_upgrade = { state: "done", command, error: null, at: Date.now() };
|
|
1201
|
-
} else if (!timedOut) {
|
|
1208
|
+
} else if (!timedOut && _upgrade?.state !== "failed") {
|
|
1209
|
+
// Not over a failure the 'error' handler already explained. A missing npm
|
|
1210
|
+
// emits 'error' with ENOENT and THEN 'close' with a null code, and this
|
|
1211
|
+
// branch used to replace "spawn npm ENOENT" with "npm exited -2" — the
|
|
1212
|
+
// one message that says what is wrong, overwritten by the one that does
|
|
1213
|
+
// not.
|
|
1202
1214
|
_upgrade = { state: "failed", command, error: lastMeaningfulLine(err) || `npm exited ${code}`, at: Date.now() };
|
|
1203
1215
|
}
|
|
1204
1216
|
});
|
|
@@ -182,10 +182,29 @@ const C_LOCALE = { LC_ALL: "", LC_NUMERIC: "C" };
|
|
|
182
182
|
function run(file, args, timeoutMs = 2_000) {
|
|
183
183
|
return new Promise(resolve => {
|
|
184
184
|
let child;
|
|
185
|
-
try {
|
|
185
|
+
try {
|
|
186
|
+
child = spawn(file, args, {
|
|
187
|
+
windowsHide: true,
|
|
188
|
+
env: { ...process.env, ...C_LOCALE },
|
|
189
|
+
// stderr is PIPED AND NEVER READ, which is a deadlock waiting for a
|
|
190
|
+
// chatty child: a pipe nobody drains fills at 64 KB and the writer
|
|
191
|
+
// blocks there until this function's own deadline kills it. Nothing
|
|
192
|
+
// here has ever looked at it — `run` resolves on stdout or null — so
|
|
193
|
+
// the honest arrangement is not to open it.
|
|
194
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
195
|
+
});
|
|
196
|
+
}
|
|
186
197
|
catch { return resolve(null); }
|
|
187
198
|
let out = "";
|
|
188
199
|
const timer = setTimeout(() => { try { child.kill(); } catch {} resolve(null); }, timeoutMs);
|
|
200
|
+
// DECODE THE STREAM, NOT EACH CHUNK. `out += d` on a Buffer calls toString()
|
|
201
|
+
// per chunk, and a chunk boundary falls wherever the pipe happened to break
|
|
202
|
+
// — measured on `ps` output as three chunks of 8192/8192/5718 — so a
|
|
203
|
+
// multi-byte character split across two of them became two replacement
|
|
204
|
+
// characters. An application called `Яндекс Музыка` in the process list
|
|
205
|
+
// rendered as `Ян��екс Музыка`. setEncoding carries the partial sequence
|
|
206
|
+
// across the boundary, which is the whole reason it exists.
|
|
207
|
+
child.stdout?.setEncoding?.("utf8");
|
|
189
208
|
child.stdout?.on("data", d => { out += d; });
|
|
190
209
|
child.on("error", () => { clearTimeout(timer); resolve(null); });
|
|
191
210
|
child.on("close", code => { clearTimeout(timer); resolve(code === 0 ? out : null); });
|
|
@@ -599,7 +618,14 @@ async function readProcessesNow(platform) {
|
|
|
599
618
|
"-NoProfile", "-NonInteractive", "-Command",
|
|
600
619
|
"Get-Process | Select-Object Id,ProcessName,CPU,@{n='WorkingSetPrivate';e={$_.PrivateMemorySize64}} | ConvertTo-Json -Compress",
|
|
601
620
|
], 6_000);
|
|
602
|
-
|
|
621
|
+
// The same shape the POSIX branch below returns, and not a bare array: the
|
|
622
|
+
// caller reads `read.procs.length` to decide whether this was a real
|
|
623
|
+
// reading, so an array meant a TypeError rather than an empty list. Every
|
|
624
|
+
// Windows failure — a non-zero exit, a spawn that never started, the 6s
|
|
625
|
+
// deadline this module's own comment says the runtime sits right on —
|
|
626
|
+
// therefore answered `/api/system/processes` with a 500 and a logged stack,
|
|
627
|
+
// every four seconds for as long as the machine panel was open.
|
|
628
|
+
if (!out) return { procs: [], total: 0 };
|
|
603
629
|
const rows = parseGetProcessJson(out.trim(), os.totalmem());
|
|
604
630
|
const now = Date.now();
|
|
605
631
|
const ranked = cpuFromDeltas(rows, prevProcCpu, now - prevProcAt);
|
|
@@ -988,40 +1014,6 @@ export function zoneLabel(raw) {
|
|
|
988
1014
|
return name || "Thermal zone";
|
|
989
1015
|
}
|
|
990
1016
|
|
|
991
|
-
/**
|
|
992
|
-
* Windows thermal zones out of MSAcpi_ThermalZoneTemperature.
|
|
993
|
-
*
|
|
994
|
-
* The SECOND source, and only reachable by a deck that happens to be elevated:
|
|
995
|
-
* root\\wmi requires administrator and refuses an ordinary terminal outright.
|
|
996
|
-
* Kept because some boards publish a zone to ACPI and no counter, and because a
|
|
997
|
-
* deck launched from an elevated shell can read it.
|
|
998
|
-
*
|
|
999
|
-
* CurrentTemperature is in tenths of a Kelvin, the same unit as the counter
|
|
1000
|
-
* above, and reading it as anything else gives a number that is
|
|
1001
|
-
* plausible-looking and wrong.
|
|
1002
|
-
*
|
|
1003
|
-
* The zone is labelled "Thermal zone" when there is one and by its own name
|
|
1004
|
-
* when there are several, because ACPI does not say which zone is the CPU and
|
|
1005
|
-
* this module does not guess. `TZ00` is not a friendly label; it is an honest
|
|
1006
|
-
* one, and it only appears on a machine that has more than one.
|
|
1007
|
-
*/
|
|
1008
|
-
export function tempFromMsAcpiJson(json) {
|
|
1009
|
-
let rows;
|
|
1010
|
-
try { rows = typeof json === "string" ? JSON.parse(json) : json; }
|
|
1011
|
-
catch { return []; }
|
|
1012
|
-
if (!rows) return [];
|
|
1013
|
-
if (!Array.isArray(rows)) rows = [rows];
|
|
1014
|
-
const found = [];
|
|
1015
|
-
for (const r of rows) {
|
|
1016
|
-
const k = Number(r?.CurrentTemperature);
|
|
1017
|
-
if (!Number.isFinite(k)) continue;
|
|
1018
|
-
const celsius = Math.round(k / 10 - 273.15);
|
|
1019
|
-
if (!plausible(celsius)) continue;
|
|
1020
|
-
found.push({ label: zoneLabel(r?.InstanceName), celsius, warnAt: WARN_C, critAt: CRIT_C });
|
|
1021
|
-
}
|
|
1022
|
-
if (found.length === 1) found[0].label = "Thermal zone";
|
|
1023
|
-
return found.slice(0, 2);
|
|
1024
|
-
}
|
|
1025
1017
|
|
|
1026
1018
|
/**
|
|
1027
1019
|
* The macOS rows, from whatever the three sources answered.
|
|
@@ -46,16 +46,52 @@ const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
|
46
46
|
* Rosetta is deliberately not special-cased: an x64 Node on Apple Silicon
|
|
47
47
|
* reports x64 and gets the x64 build, which runs.
|
|
48
48
|
*/
|
|
49
|
-
export function assetName(platform = process.platform, arch = process.arch) {
|
|
50
|
-
|
|
49
|
+
export function assetName(platform = process.platform, arch = process.arch, libc = detectLibc(platform)) {
|
|
50
|
+
// `arm` is 32-bit ARM — a Raspberry Pi on the armhf image, which uv publishes
|
|
51
|
+
// as armv7. Left out, that machine got `unsupported_platform` for a target
|
|
52
|
+
// that exists.
|
|
53
|
+
const a = { x64: "x86_64", arm64: "aarch64", ia32: "i686", arm: "armv7" }[arch];
|
|
51
54
|
if (!a) return null;
|
|
52
55
|
// Apple publishes no i686 build, and never will.
|
|
53
56
|
if (platform === "darwin") return a === "i686" ? null : `uv-${a}-apple-darwin.tar.gz`;
|
|
54
57
|
if (platform === "win32") return `uv-${a}-pc-windows-msvc.zip`;
|
|
55
|
-
if (platform === "linux")
|
|
58
|
+
if (platform === "linux") {
|
|
59
|
+
// MUSL IS ITS OWN TARGET, and asking for the gnu build on Alpine is not a
|
|
60
|
+
// degraded install, it is an endless one: the glibc ELF interpreter is
|
|
61
|
+
// absent, so the downloaded binary cannot start, `bootstrapUv` returns
|
|
62
|
+
// `does_not_run`, and because ensureCswap runs whenever cswapVersion() is
|
|
63
|
+
// null the 35 MB archive is fetched, hashed and extracted again on EVERY
|
|
64
|
+
// launch — with the accounts panel permanently unavailable. `node:alpine`
|
|
65
|
+
// is a common enough base image for this to be somebody's whole experience
|
|
66
|
+
// of the deck.
|
|
67
|
+
if (libc === "musl") return `uv-${a}-unknown-linux-musl.tar.gz`;
|
|
68
|
+
return `uv-${a}-unknown-linux-gnu.tar.gz`;
|
|
69
|
+
}
|
|
56
70
|
return null;
|
|
57
71
|
}
|
|
58
72
|
|
|
73
|
+
/**
|
|
74
|
+
* "glibc" or "musl" for this process.
|
|
75
|
+
*
|
|
76
|
+
* Node's own report names the glibc it is linked against, and a musl build has
|
|
77
|
+
* no such field — which is the check every native-addon loader uses, and needs
|
|
78
|
+
* no child process. Anything unexpected reads as glibc, because that is the
|
|
79
|
+
* overwhelmingly common case and the wrong guess there is the state this code
|
|
80
|
+
* was already in.
|
|
81
|
+
*/
|
|
82
|
+
export function detectLibc(platform = process.platform, report = () => process.report?.getReport?.()) {
|
|
83
|
+
// The question only exists on Linux. macOS and Windows have no glibc field
|
|
84
|
+
// either, so asking there and reading the absence as musl would answer "musl"
|
|
85
|
+
// for every Mac — which is why the platform comes first.
|
|
86
|
+
if (platform !== "linux") return "glibc";
|
|
87
|
+
try {
|
|
88
|
+
const header = report()?.header;
|
|
89
|
+
if (!header) return "glibc";
|
|
90
|
+
if (typeof header.glibcVersionRuntime === "string") return "glibc";
|
|
91
|
+
return "musl";
|
|
92
|
+
} catch { return "glibc"; }
|
|
93
|
+
}
|
|
94
|
+
|
|
59
95
|
/** Path to a uv this function installed earlier, or null. */
|
|
60
96
|
export function existingBootstrappedUv() {
|
|
61
97
|
const bin = join(UV_DIR, process.platform === "win32" ? "uv.exe" : "uv");
|