agent-dag 3.0.0 → 3.2.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 +33 -0
- package/bin/deck.js +266 -61
- package/dist/web/assets/{index-6cAJDiBs.js → index-7UsJp5Ht.js} +40 -22
- package/dist/web/index.html +1 -1
- package/package.json +1 -4
- package/release-notes.json +28 -0
- package/src/server/boot-deadline.mjs +127 -0
- package/src/server/cswap-install.mjs +61 -8
- package/src/server/hwmonitor.mjs +56 -0
- package/src/server/index.mjs +102 -23
- package/src/server/lhm-parse.mjs +91 -0
- package/src/server/log-tail.mjs +139 -0
- package/src/server/macmon.mjs +299 -0
- package/src/server/open-url.mjs +226 -0
- package/src/server/quota.mjs +48 -2
- package/src/server/system-metrics.mjs +211 -18
- package/src/server/term.mjs +79 -3
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Reading a log backwards, because only its end is ever kept.
|
|
2
|
+
//
|
|
3
|
+
// #742. The event log is replayed before the port opens, and the replay parsed
|
|
4
|
+
// every line in it from the beginning — 12,079 lines and 31 MB on the machine
|
|
5
|
+
// this was measured on, 690ms of JSON.parse, growing with every session until
|
|
6
|
+
// rotation cuts it at 50 MB. The ring it fills holds MAX_BUFFER = 2000 events.
|
|
7
|
+
// So roughly five sixths of that work was parsing events that were evicted by
|
|
8
|
+
// the ones parsed after them, on the critical path of a boot, every time.
|
|
9
|
+
//
|
|
10
|
+
// Reading from the end fixes the asymmetry rather than the constant: the loop
|
|
11
|
+
// stops as soon as the ring is full, so the cost becomes a property of the ring
|
|
12
|
+
// instead of a property of how long the user has been running the deck. When
|
|
13
|
+
// the ring cannot be filled — a young log, or a workspace-scoped deck whose
|
|
14
|
+
// events are a thin slice of a shared one — it reads all the way back to the
|
|
15
|
+
// start and costs exactly what the forward read did. There is no case where
|
|
16
|
+
// this is slower and no case where it sees less.
|
|
17
|
+
//
|
|
18
|
+
// Bytes, not characters. Lines are split on 0x0A and decoded one at a time,
|
|
19
|
+
// which is safe in UTF-8 because no byte of a multi-byte sequence can be 0x0A —
|
|
20
|
+
// a chunk boundary landing inside a three-byte character joins back together
|
|
21
|
+
// before anything is decoded. Decoding the chunk first and splitting the string
|
|
22
|
+
// is what would corrupt it, and it is the obvious way to write this.
|
|
23
|
+
import { open } from "node:fs/promises";
|
|
24
|
+
import { createReadStream } from "node:fs";
|
|
25
|
+
import { createInterface } from "node:readline";
|
|
26
|
+
|
|
27
|
+
/** One line out of a buffer, minus the carriage return a CRLF file leaves on
|
|
28
|
+
* the end of it. Sliced before decoding — see the note about 0x0A above. */
|
|
29
|
+
function line(buf, from, to) {
|
|
30
|
+
const end = to > from && buf[to - 1] === 0x0D ? to - 1 : to;
|
|
31
|
+
return buf.subarray(from, end).toString("utf8");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** How much is read at a time. One megabyte holds about 400 events at the size
|
|
35
|
+
* this log's lines actually run to, so a full ring is usually five reads. */
|
|
36
|
+
export const CHUNK_BYTES = 1 << 20;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The file's lines, newest first, as an async iterable.
|
|
40
|
+
*
|
|
41
|
+
* Stops reading the moment the consumer stops asking — that is the whole point,
|
|
42
|
+
* and it is why this is a generator rather than a function returning an array.
|
|
43
|
+
* A `break` in the caller closes the handle through the generator's `finally`.
|
|
44
|
+
*
|
|
45
|
+
* Lines are yielded WITHOUT their newline, and the sequence is exactly the
|
|
46
|
+
* reverse of what `readline` yields reading the same file forwards — empty
|
|
47
|
+
* lines included, and a trailing newline at EOF is a line terminator rather
|
|
48
|
+
* than an empty line after it. A CRLF file reads the same as it does forwards,
|
|
49
|
+
* because the carriage return is stripped here too. That equivalence is the
|
|
50
|
+
* contract, and it is what the round-trip case in the test file checks against
|
|
51
|
+
* readline itself rather than against a hand-written expectation.
|
|
52
|
+
*
|
|
53
|
+
* The ONE difference: `readline` also breaks on a lone carriage return, for the
|
|
54
|
+
* sake of files written by software that predates OS X. Walking those backwards
|
|
55
|
+
* would mean scanning every byte of every chunk instead of asking Buffer for
|
|
56
|
+
* the next 0x0A, and nothing that writes a line into this deck's log has
|
|
57
|
+
* produced one since 2001. A file full of lone carriage returns reads here as a
|
|
58
|
+
* single very long line, which JSON.parse then declines — one skipped line,
|
|
59
|
+
* counted and reported, rather than a wrong answer.
|
|
60
|
+
*/
|
|
61
|
+
export async function* linesFromEnd(filePath, { chunkBytes = CHUNK_BYTES, openFile = open } = {}) {
|
|
62
|
+
const fh = await openFile(filePath, "r");
|
|
63
|
+
try {
|
|
64
|
+
const { size } = await fh.stat();
|
|
65
|
+
let pos = size;
|
|
66
|
+
// The bytes at the front of what has been read that have no newline before
|
|
67
|
+
// them yet: the first line of the chunk, which may continue into the chunk
|
|
68
|
+
// that comes before it. Carried, never yielded, until a newline turns up or
|
|
69
|
+
// the start of the file does.
|
|
70
|
+
let carry = Buffer.alloc(0);
|
|
71
|
+
// The newline that ends the last line is a terminator, not the start of an
|
|
72
|
+
// empty line after it. Trimmed once, on the chunk that holds EOF.
|
|
73
|
+
let atEof = true;
|
|
74
|
+
|
|
75
|
+
while (pos > 0) {
|
|
76
|
+
const len = Math.min(chunkBytes, pos);
|
|
77
|
+
pos -= len;
|
|
78
|
+
const buf = Buffer.alloc(len);
|
|
79
|
+
// Node reads short at the end of a file and at a pipe; a regular file
|
|
80
|
+
// opened for reading at a known offset does not, but the loop is written
|
|
81
|
+
// to survive it rather than to assume it.
|
|
82
|
+
let got = 0;
|
|
83
|
+
while (got < len) {
|
|
84
|
+
const { bytesRead } = await fh.read(buf, got, len - got, pos + got);
|
|
85
|
+
if (bytesRead === 0) break;
|
|
86
|
+
got += bytesRead;
|
|
87
|
+
}
|
|
88
|
+
const hay = carry.length ? Buffer.concat([buf.subarray(0, got), carry]) : buf.subarray(0, got);
|
|
89
|
+
|
|
90
|
+
// Walk the newlines from the end. `end` is one past the last byte of the
|
|
91
|
+
// line being cut; every cut is a complete line, because everything to its
|
|
92
|
+
// right has already been yielded.
|
|
93
|
+
let end = hay.length;
|
|
94
|
+
if (atEof && end > 0 && hay[end - 1] === 0x0A) end--;
|
|
95
|
+
atEof = false;
|
|
96
|
+
while (end > 0) {
|
|
97
|
+
const nl = hay.lastIndexOf(0x0A, end - 1);
|
|
98
|
+
if (nl === -1) break;
|
|
99
|
+
yield line(hay, nl + 1, end);
|
|
100
|
+
end = nl;
|
|
101
|
+
}
|
|
102
|
+
carry = hay.subarray(0, end);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Whatever is left has the start of the file in front of it, so it is a
|
|
106
|
+
// whole line — and an EMPTY one is still a line, which is why this is not
|
|
107
|
+
// conditional on `carry.length`. A file that opens with a newline opens
|
|
108
|
+
// with an empty line, and reading it forwards says so.
|
|
109
|
+
if (size > 0) yield line(carry, 0, carry.length);
|
|
110
|
+
} finally {
|
|
111
|
+
await fh.close().catch(() => {});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The same file, the ordinary way round.
|
|
117
|
+
*
|
|
118
|
+
* Here rather than at the one call site so the two readers sit together and a
|
|
119
|
+
* reader of either finds the other: replayLog picks between them per boot, on
|
|
120
|
+
* whether its scope predicate can be fed a log backwards, and a pair of
|
|
121
|
+
* functions in one file is what makes that choice legible.
|
|
122
|
+
*
|
|
123
|
+
* Streams, so a scoped deck on a 50 MB log holds one line at a time exactly as
|
|
124
|
+
* it did before any of this.
|
|
125
|
+
*/
|
|
126
|
+
export async function* linesFromStart(filePath) {
|
|
127
|
+
const input = createReadStream(filePath, { encoding: "utf8" });
|
|
128
|
+
const rl = createInterface({ input });
|
|
129
|
+
try {
|
|
130
|
+
for await (const line of rl) yield line;
|
|
131
|
+
} finally {
|
|
132
|
+
// Both, and the stream second. `rl.close()` stops the interface and leaves
|
|
133
|
+
// the descriptor under it open, which is fine for the one caller here
|
|
134
|
+
// because it reads to the end — and is a leak the day somebody breaks out
|
|
135
|
+
// of this loop the way the backwards reader is designed to be broken out of.
|
|
136
|
+
rl.close();
|
|
137
|
+
input.destroy();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// The one thermal reading a Mac will not give up on its own.
|
|
2
|
+
//
|
|
3
|
+
// #747. On Apple Silicon there is no command shipped with macOS that prints a
|
|
4
|
+
// CPU or GPU temperature. `powermetrics` needs root. `pmset -g therm` records
|
|
5
|
+
// nothing on M-series and answers "No CPU power status has been recorded", so
|
|
6
|
+
// there is no throttle row either. The AGX driver does not publish the
|
|
7
|
+
// `"Temperature(C)"` that ioreg reads on Intel GPUs. And the SMC keys changed
|
|
8
|
+
// with M1 and are inconsistent between models sharing one chip — an M1 Mac mini
|
|
9
|
+
// uses different FourCCs from an M1 MacBook Pro, with no public mapping — so
|
|
10
|
+
// there is nothing to hard-code either.
|
|
11
|
+
//
|
|
12
|
+
// The sensors are reachable, and without root: they come through a HID sensor
|
|
13
|
+
// hub, which is a private C API. Every tool that shows a temperature on Apple
|
|
14
|
+
// Silicon calls it, and every one of them is native code. A Node process cannot,
|
|
15
|
+
// and this package has zero runtime dependencies, which is worth more than a
|
|
16
|
+
// number.
|
|
17
|
+
//
|
|
18
|
+
// So it asks a tool the user already has, exactly the way the deck already asks
|
|
19
|
+
// `cswap` about accounts and `ccusage` about spend. `macmon` is in
|
|
20
|
+
// homebrew-core — `brew install macmon`, no third-party tap — runs without
|
|
21
|
+
// sudo, supports M1 through M5, and prints JSON.
|
|
22
|
+
//
|
|
23
|
+
// COSTS INTEL NOTHING. This is reached only when ioreg has already answered
|
|
24
|
+
// with nothing, which on an Intel Mac it never does. Deliberately not gated on
|
|
25
|
+
// `process.arch`: a Node built for x64 running under Rosetta on an Apple
|
|
26
|
+
// Silicon Mac reports "x64", and gating on that would skip the one machine this
|
|
27
|
+
// exists for.
|
|
28
|
+
import { run } from "./exec.mjs";
|
|
29
|
+
import { renameWithRetry } from "./installer.mjs";
|
|
30
|
+
import { existsSync } from "node:fs";
|
|
31
|
+
import { chmod, copyFile, mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises";
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
import { homedir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
|
|
36
|
+
const TOOL_DIR = join(homedir(), ".agents-deck", "tools");
|
|
37
|
+
const MACMON_DIR = join(TOOL_DIR, "macmon");
|
|
38
|
+
|
|
39
|
+
/** Where the deck puts its own copy, and where brew puts one on both prefixes —
|
|
40
|
+
* because ~/.local/bin's lesson applies here too: a tool the user installed is
|
|
41
|
+
* not always on the PATH of the shell that launched the deck. Ours first, so a
|
|
42
|
+
* machine that has both uses the one whose version this code was written
|
|
43
|
+
* against. Apple Silicon's brew prefix before Intel's; that is the only
|
|
44
|
+
* machine that reaches this file. */
|
|
45
|
+
export const MACMON_CANDIDATES = [
|
|
46
|
+
join(MACMON_DIR, "macmon"),
|
|
47
|
+
"/opt/homebrew/bin/macmon",
|
|
48
|
+
"/usr/local/bin/macmon",
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** One sample, then exit. `-i` is the sampling window and its default is a full
|
|
52
|
+
* second; 200ms is long enough for an average the tool is willing to publish
|
|
53
|
+
* and short enough that a poll every ten seconds costs nothing anybody can
|
|
54
|
+
* feel. */
|
|
55
|
+
export const MACMON_ARGS = ["pipe", "-s", "1", "-i", "200"];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The resolution, remembered — including the failure.
|
|
59
|
+
*
|
|
60
|
+
* `null` means "not looked yet" and `false` means "looked, not there", which is
|
|
61
|
+
* the distinction that keeps a machine without macmon from paying a lookup
|
|
62
|
+
* every ten seconds forever. cswapBin memoizes only success and re-probes on
|
|
63
|
+
* failure; that is right for a tool the deck installs and wrong for one it
|
|
64
|
+
* never will.
|
|
65
|
+
*/
|
|
66
|
+
let _bin = null;
|
|
67
|
+
|
|
68
|
+
/** Forget it. For the test, and for the day something installs macmon while the
|
|
69
|
+
* deck is up — nothing calls this on that path yet, and it is one line. */
|
|
70
|
+
export function resetMacmonBin() { _bin = null; }
|
|
71
|
+
|
|
72
|
+
export async function macmonBin({
|
|
73
|
+
exists = existsSync, probe = defaultProbe, candidates = MACMON_CANDIDATES,
|
|
74
|
+
} = {}) {
|
|
75
|
+
if (_bin !== null) return _bin || null;
|
|
76
|
+
// The bare name first, because PATH is the cheap answer and the one a user
|
|
77
|
+
// who installed it themselves will usually have.
|
|
78
|
+
if (await probe("macmon")) return (_bin = "macmon");
|
|
79
|
+
for (const c of candidates) {
|
|
80
|
+
if (exists(c) && await probe(c)) return (_bin = c);
|
|
81
|
+
}
|
|
82
|
+
_bin = false;
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function defaultProbe(bin) {
|
|
87
|
+
return (await run(bin, ["--version"], { timeout: 4_000 })).ok;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* CPU and GPU degrees out of one `macmon pipe` sample.
|
|
92
|
+
*
|
|
93
|
+
* The shape is `{ temp: { cpu_temp_avg, gpu_temp_avg } }`, both in Celsius —
|
|
94
|
+
* read off macmon's own `TempMetrics` struct rather than guessed from an
|
|
95
|
+
* example, because a field renamed upstream should read as "no sensor" and not
|
|
96
|
+
* as zero.
|
|
97
|
+
*
|
|
98
|
+
* A zero is dropped rather than shown. macmon defaults both fields to 0.0 and
|
|
99
|
+
* fills what it read, so a machine that answered for the CPU and not the GPU
|
|
100
|
+
* arrives as `{ cpu_temp_avg: 47.3, gpu_temp_avg: 0 }` — and 0 °C is not a
|
|
101
|
+
* reading, it is the absence of one wearing a number.
|
|
102
|
+
*/
|
|
103
|
+
export function tempsFromMacmonJson(json) {
|
|
104
|
+
let d;
|
|
105
|
+
try { d = typeof json === "string" ? JSON.parse(json) : json; }
|
|
106
|
+
catch { return {}; }
|
|
107
|
+
const t = d?.temp;
|
|
108
|
+
if (!t || typeof t !== "object") return {};
|
|
109
|
+
const out = {};
|
|
110
|
+
for (const [key, field] of [["cpu", "cpu_temp_avg"], ["gpu", "gpu_temp_avg"]]) {
|
|
111
|
+
const v = Number(t[field]);
|
|
112
|
+
// The same plausibility floor the rest of the thermal code uses, stated
|
|
113
|
+
// here rather than imported so this module has no opinion to disagree with.
|
|
114
|
+
if (Number.isFinite(v) && v > 0 && v < 150) out[key] = Math.round(v);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Ask macmon, or answer nothing.
|
|
121
|
+
*
|
|
122
|
+
* Never throws and never waits long: `run` does not reject, the sample is
|
|
123
|
+
* capped, and a machine without macmon returns before it spawns anything at
|
|
124
|
+
* all after the first lookup.
|
|
125
|
+
*/
|
|
126
|
+
export async function readMacmonTemps(deps = {}) {
|
|
127
|
+
const bin = await macmonBin(deps);
|
|
128
|
+
if (!bin) return {};
|
|
129
|
+
const r = await run(bin, MACMON_ARGS, { timeout: 6_000 });
|
|
130
|
+
if (!r.ok) return {};
|
|
131
|
+
// `pipe` prints one JSON object per sample and `-s 1` asks for one, but a
|
|
132
|
+
// build that ever printed a banner first would put it on the same stream.
|
|
133
|
+
const line = r.stdout.split("\n").find(l => l.trim().startsWith("{"));
|
|
134
|
+
return line ? tempsFromMacmonJson(line) : {};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── fetching it, so the user does not have to ────────────────────────────────
|
|
138
|
+
//
|
|
139
|
+
// `npx ccdeck` should work, and on Apple Silicon "work" includes the two rows
|
|
140
|
+
// this file exists for. Telling somebody to run a brew command first is a step,
|
|
141
|
+
// and a step is a thing most people will not take.
|
|
142
|
+
//
|
|
143
|
+
// So the deck fetches macmon the same way it already fetches uv — see
|
|
144
|
+
// uv-bootstrap.mjs, whose shape this follows including the parts that were
|
|
145
|
+
// learned the hard way. NOT through Homebrew: a machine without brew would then
|
|
146
|
+
// need brew installed first, which is a very large thing to do to somebody who
|
|
147
|
+
// asked for a dashboard.
|
|
148
|
+
//
|
|
149
|
+
// WHAT MAKES THIS SAFE TO RUN ON SOMEBODY'S MACHINE, each verified against the
|
|
150
|
+
// real release rather than assumed:
|
|
151
|
+
//
|
|
152
|
+
// * There is a prebuilt binary. `macmon-v0.8.2.tar.gz`, 746 KB, containing a
|
|
153
|
+
// `Mach-O 64-bit executable arm64`. No compiler, no toolchain, no Rust.
|
|
154
|
+
// * There is a checksum. The release publishes no `.sha256` file, but the
|
|
155
|
+
// GitHub releases API carries a `digest` field per asset, and it matched
|
|
156
|
+
// the bytes actually downloaded. An unverified binary is not something to
|
|
157
|
+
// run on someone's machine, which is uv-bootstrap's rule and is kept here.
|
|
158
|
+
// * It will execute. Apple Silicon refuses an unsigned binary; this one is
|
|
159
|
+
// `adhoc, linker-signed`, which is what the Rust toolchain emits and what
|
|
160
|
+
// macOS accepts. And a programmatic download carries no
|
|
161
|
+
// com.apple.quarantine — only com.apple.provenance, which blocks nothing —
|
|
162
|
+
// so there is no Gatekeeper prompt and nothing for the user to click.
|
|
163
|
+
//
|
|
164
|
+
// NEVER ON THE BOOT'S CRITICAL PATH. It is started by the thermal sampler, on
|
|
165
|
+
// the tick where it found no reading, and nothing waits for it. The boot was
|
|
166
|
+
// just taught not to wait for an install (#742) and this does not undo that.
|
|
167
|
+
|
|
168
|
+
const RELEASE_API = "https://api.github.com/repos/vladkens/macmon/releases/latest";
|
|
169
|
+
const DOWNLOAD_TIMEOUT_MS = 60_000;
|
|
170
|
+
|
|
171
|
+
/** One attempt per process. A machine that is offline, or behind a proxy that
|
|
172
|
+
* refuses GitHub, must not re-download every ten seconds for as long as the
|
|
173
|
+
* deck runs — and a success does not need a second attempt either. */
|
|
174
|
+
let _fetched = false;
|
|
175
|
+
|
|
176
|
+
/** Cleared with the resolution, so a test can run the whole thing twice. */
|
|
177
|
+
export function resetMacmonFetch() { _fetched = false; }
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The asset to download, out of what the release actually published.
|
|
181
|
+
*
|
|
182
|
+
* Pure and exported: this is the part that has to keep working when the project
|
|
183
|
+
* changes its file names, and the only way to check that from here is to feed
|
|
184
|
+
* it a release document. `digest` is `sha256:<hex>` — the prefix is part of the
|
|
185
|
+
* field and dropping it silently would make every download fail verification.
|
|
186
|
+
*/
|
|
187
|
+
export function macmonAsset(release) {
|
|
188
|
+
const tag = release?.tag_name;
|
|
189
|
+
const a = (release?.assets ?? []).find(x => typeof x?.name === "string" && x.name.endsWith(".tar.gz"));
|
|
190
|
+
if (!a?.browser_download_url) return null;
|
|
191
|
+
const m = /^sha256:([0-9a-f]{64})$/.exec(String(a.digest ?? ""));
|
|
192
|
+
if (!m) return null;
|
|
193
|
+
return { version: typeof tag === "string" ? tag : "unknown", url: a.browser_download_url, sha256: m[1] };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A macmon this function installed earlier, or null. */
|
|
197
|
+
export function existingBootstrappedMacmon() {
|
|
198
|
+
const bin = join(MACMON_DIR, "macmon");
|
|
199
|
+
return existsSync(bin) ? bin : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Download macmon into ~/.agents-deck/tools/macmon.
|
|
204
|
+
*
|
|
205
|
+
* Returns `{ ok: true, bin, version }` or `{ ok: false, reason }`, and never
|
|
206
|
+
* throws: every caller treats a missing macmon as an ordinary state.
|
|
207
|
+
*
|
|
208
|
+
* Apple Silicon only, because that is the only build published and the only
|
|
209
|
+
* machine that has anything to gain — an Intel Mac already answers through
|
|
210
|
+
* ioreg and never reaches this file at all.
|
|
211
|
+
*/
|
|
212
|
+
export async function bootstrapMacmon({
|
|
213
|
+
platform = process.platform, env = process.env, fetchFn = fetch, dir = MACMON_DIR,
|
|
214
|
+
} = {}) {
|
|
215
|
+
if (platform !== "darwin") return { ok: false, reason: "unsupported_platform" };
|
|
216
|
+
// Both switches, for the reason uv-bootstrap has both: downloading an
|
|
217
|
+
// executable is a bigger step than installing a package with a tool the user
|
|
218
|
+
// already chose, so somebody may want the managed installs and not this.
|
|
219
|
+
if (env.AGENTS_DECK_NO_INSTALL === "1") return { ok: false, reason: "installs_disabled" };
|
|
220
|
+
if (env.AGENTS_DECK_NO_DOWNLOAD === "1") return { ok: false, reason: "download_disabled" };
|
|
221
|
+
if (_fetched) return { ok: false, reason: "already_tried" };
|
|
222
|
+
_fetched = true;
|
|
223
|
+
|
|
224
|
+
let staging = null;
|
|
225
|
+
let partial = null;
|
|
226
|
+
try {
|
|
227
|
+
const res = await fetchFn(RELEASE_API, {
|
|
228
|
+
headers: { accept: "application/vnd.github+json", "user-agent": "agents-deck" },
|
|
229
|
+
signal: AbortSignal.timeout(10_000),
|
|
230
|
+
});
|
|
231
|
+
if (!res.ok) return { ok: false, reason: "release_lookup_failed" };
|
|
232
|
+
const asset = macmonAsset(await res.json());
|
|
233
|
+
// No published digest means no way to know what was downloaded. There is no
|
|
234
|
+
// fallback version here on purpose: uv can have one because any recent uv
|
|
235
|
+
// installs claude-swap, while a hard-coded macmon URL would be a checksum
|
|
236
|
+
// this file invented for bytes it has never seen.
|
|
237
|
+
if (!asset) return { ok: false, reason: "no_verifiable_asset" };
|
|
238
|
+
|
|
239
|
+
const dl = await fetchFn(asset.url, {
|
|
240
|
+
redirect: "follow",
|
|
241
|
+
headers: { "user-agent": "agents-deck" },
|
|
242
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
243
|
+
});
|
|
244
|
+
if (!dl.ok) return { ok: false, reason: "download_failed" };
|
|
245
|
+
const archive = Buffer.from(await dl.arrayBuffer());
|
|
246
|
+
if (createHash("sha256").update(archive).digest("hex") !== asset.sha256) {
|
|
247
|
+
return { ok: false, reason: "checksum_mismatch" };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// mkdtemp under a directory the user already owns, for every reason
|
|
251
|
+
// uv-bootstrap gives: the name cannot be guessed, and mkdtemp CREATES
|
|
252
|
+
// rather than accepting a directory somebody else made.
|
|
253
|
+
// Staged beside the destination rather than in the system temp directory,
|
|
254
|
+
// so the rename below is same-filesystem and therefore atomic.
|
|
255
|
+
await mkdir(dir, { recursive: true });
|
|
256
|
+
staging = await mkdtemp(join(dir, "macmon-staging-"));
|
|
257
|
+
const archivePath = join(staging, "macmon.tar.gz");
|
|
258
|
+
await writeFile(archivePath, archive);
|
|
259
|
+
if (!(await run("tar", ["-xzf", archivePath, "-C", staging], { timeout: 60_000 })).ok) {
|
|
260
|
+
return { ok: false, reason: "extract_failed" };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// The archive is flat — readme.md, LICENSE, macmon — so this is a name
|
|
264
|
+
// rather than a search. It is checked instead of assumed because a layout
|
|
265
|
+
// change upstream should read as "not in the archive", not as a crash.
|
|
266
|
+
const found = join(staging, "macmon");
|
|
267
|
+
if (!existsSync(found)) return { ok: false, reason: "not_in_archive" };
|
|
268
|
+
|
|
269
|
+
// Copied to a name of its own inside the destination — same filesystem, so
|
|
270
|
+
// the rename below is the atomic kind — and only becomes `macmon` once it
|
|
271
|
+
// is whole, flushed, executable, and has answered `--version`. Interrupt
|
|
272
|
+
// this and all that is left is an inert temp file.
|
|
273
|
+
partial = join(dir, `.macmon-${process.pid}-${Date.now().toString(36)}`);
|
|
274
|
+
await copyFile(found, partial);
|
|
275
|
+
const handle = await open(partial, "r+");
|
|
276
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
277
|
+
await chmod(partial, 0o755);
|
|
278
|
+
|
|
279
|
+
// The binary is adhoc/linker-signed and carries no quarantine, so this
|
|
280
|
+
// should simply run — and if some future build does not, the deck finds out
|
|
281
|
+
// here rather than by leaving a file every later boot trusts.
|
|
282
|
+
if (!(await run(partial, ["--version"], { timeout: 20_000 })).ok) {
|
|
283
|
+
return { ok: false, reason: "does_not_run" };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const dest = join(dir, "macmon");
|
|
287
|
+
await renameWithRetry(partial, dest);
|
|
288
|
+
partial = null;
|
|
289
|
+
// The resolution memo remembers a failure, and the failure it remembers is
|
|
290
|
+
// "there is no macmon". There is one now.
|
|
291
|
+
resetMacmonBin();
|
|
292
|
+
return { ok: true, bin: dest, version: asset.version };
|
|
293
|
+
} catch (err) {
|
|
294
|
+
return { ok: false, reason: "error", detail: String(err?.message ?? err).slice(0, 200) };
|
|
295
|
+
} finally {
|
|
296
|
+
if (staging) await rm(staging, { recursive: true, force: true, maxRetries: 10, retryDelay: 25 }).catch(() => {});
|
|
297
|
+
if (partial) await rm(partial, { force: true }).catch(() => {});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Opening the browser, without the ten packages it used to cost.
|
|
2
|
+
//
|
|
3
|
+
// `open@10` was this package's ONLY runtime dependency, and it brought nine
|
|
4
|
+
// more with it — bundle-name, default-browser, default-browser-id,
|
|
5
|
+
// define-lazy-prop, is-docker, is-inside-container, is-wsl, run-applescript,
|
|
6
|
+
// wsl-utils. Ten tarballs to fetch, extract and link on a machine whose npm
|
|
7
|
+
// cache is empty, for one call, made once, whose whole job is to hand a
|
|
8
|
+
// localhost URL to whatever the desktop already uses. On a fast link that is a
|
|
9
|
+
// second; on the links the people who reported a hung `npx ccdeck` are on, it
|
|
10
|
+
// is ten more round trips before the deck's own tarball is even unpacked.
|
|
11
|
+
//
|
|
12
|
+
// What it did that is worth keeping is the platform knowledge, and that is
|
|
13
|
+
// small enough to hold here: three commands, plus the WSL case where
|
|
14
|
+
// `process.platform` says linux and the browser is on the Windows side.
|
|
15
|
+
//
|
|
16
|
+
// DELIBERATELY FIRE AND FORGET. `open` returned a promise the boot awaited;
|
|
17
|
+
// nothing downstream of that await needed the child, and a launcher that takes
|
|
18
|
+
// its time — xdg-open on a machine with no desktop session hunting through
|
|
19
|
+
// every handler it knows — held the boot behind it. Here the candidates are
|
|
20
|
+
// tried in order, the failures move to the next one on their own, and the boot
|
|
21
|
+
// never waits for any of it.
|
|
22
|
+
import { spawn } from "node:child_process";
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
|
|
25
|
+
/** How long a launcher gets to prove it worked before the next one is tried.
|
|
26
|
+
* A launcher that is still running is a launcher that found something: macOS
|
|
27
|
+
* `open` and cmd's `start` both exit at once, and xdg-open stays up as the
|
|
28
|
+
* browser's parent. So only an EARLY non-zero exit moves on. */
|
|
29
|
+
export const LAUNCH_GRACE_MS = 1_500;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Is this a Linux that is really Windows?
|
|
33
|
+
*
|
|
34
|
+
* WSL reports `process.platform === "linux"`, has no desktop session of its
|
|
35
|
+
* own on a default install, and its browser lives on the Windows side — so
|
|
36
|
+
* xdg-open there either is not installed or opens nothing anybody can see.
|
|
37
|
+
* Both signals are read because either can be absent: the env var is set by
|
|
38
|
+
* the WSL launcher and lost by anything that scrubs the environment, and
|
|
39
|
+
* /proc/version is the kernel's own answer and cannot be.
|
|
40
|
+
*/
|
|
41
|
+
export function isWsl(platform = process.platform, env = process.env, readProcVersion = defaultProcVersion) {
|
|
42
|
+
if (platform !== "linux") return false;
|
|
43
|
+
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) return true;
|
|
44
|
+
return /microsoft/i.test(readProcVersion());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function defaultProcVersion() {
|
|
48
|
+
try {
|
|
49
|
+
return readFileSync("/proc/version", "utf8");
|
|
50
|
+
} catch {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The launchers to try for `url`, best first.
|
|
57
|
+
*
|
|
58
|
+
* A list rather than one answer, because every platform below has a case where
|
|
59
|
+
* the first choice is missing: a Linux without xdg-utils, a WSL without
|
|
60
|
+
* wslview, a Windows whose comspec has been moved. Each entry is exactly what
|
|
61
|
+
* `spawn` is given, so the test can read the command line rather than infer it.
|
|
62
|
+
*
|
|
63
|
+
* Exported with platform and env as parameters for the reason exec.mjs exports
|
|
64
|
+
* its own that way: the Windows and Linux answers have to be checkable from the
|
|
65
|
+
* Mac this is written on.
|
|
66
|
+
*/
|
|
67
|
+
export function launchers(url, { platform = process.platform, env = process.env, wsl } = {}) {
|
|
68
|
+
const inWsl = wsl === undefined ? isWsl(platform, env) : wsl;
|
|
69
|
+
|
|
70
|
+
if (platform === "darwin") {
|
|
71
|
+
// The absolute path first: `open` is also a shell builtin in a few setups
|
|
72
|
+
// and a user's own script called `open` on the PATH is not unheard of, and
|
|
73
|
+
// /usr/bin/open has been on every macOS this package supports.
|
|
74
|
+
return [
|
|
75
|
+
{ file: "/usr/bin/open", args: [url] },
|
|
76
|
+
{ file: "open", args: [url] },
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (platform === "win32") {
|
|
81
|
+
// PowerShell FIRST, and this order is the one thing here that was decided by
|
|
82
|
+
// evidence rather than by taste.
|
|
83
|
+
//
|
|
84
|
+
// `open@10` — the dependency this file replaces — launches a Windows URL
|
|
85
|
+
// through `powershell -EncodedCommand` running `Start "<url>"`. That is what
|
|
86
|
+
// every ccdeck install on Windows has been using, so it is the path with a
|
|
87
|
+
// year of field evidence behind it and `cmd /c start` has none.
|
|
88
|
+
//
|
|
89
|
+
// And the fallback below cannot rescue a wrong first choice. `start` exits 0
|
|
90
|
+
// whether or not anything opened — measured on a real Windows 10 box, exit
|
|
91
|
+
// code 0 with no browser process created — so a candidate list that led with
|
|
92
|
+
// it would never reach a second candidate, however badly the first had done.
|
|
93
|
+
// Leading with the one whose failure is visible is the only ordering where
|
|
94
|
+
// having a fallback means anything.
|
|
95
|
+
//
|
|
96
|
+
// The cost is PowerShell's startup, a few hundred milliseconds, and it is
|
|
97
|
+
// paid by nobody: openUrl does not wait for the launcher and the boot does
|
|
98
|
+
// not wait for openUrl.
|
|
99
|
+
const tries = [{
|
|
100
|
+
file: "powershell.exe",
|
|
101
|
+
// The URL as its own argv entry rather than inside a script string, where
|
|
102
|
+
// `;` and `&` are PowerShell's own operators.
|
|
103
|
+
args: ["-NoProfile", "-NonInteractive", "-Command", "Start-Process", url],
|
|
104
|
+
}];
|
|
105
|
+
const viaCmd = startCommand(url, env);
|
|
106
|
+
tries.push(viaCmd);
|
|
107
|
+
// comspec is read by startCommand, and a comspec pointing somewhere that is
|
|
108
|
+
// no longer there is the one way that line fails on a machine which is
|
|
109
|
+
// otherwise fine. The bare name is what the PATH would have answered.
|
|
110
|
+
if (viaCmd.file.toLowerCase() !== "cmd.exe") tries.push(startCommand(url, env, "cmd.exe"));
|
|
111
|
+
return tries;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (inWsl) {
|
|
115
|
+
return [
|
|
116
|
+
// Ships with wslu and is what a WSL user's own `xdg-open` is usually
|
|
117
|
+
// symlinked to anyway. It knows how to hand a URL across the boundary.
|
|
118
|
+
{ file: "wslview", args: [url] },
|
|
119
|
+
// No wslu: go to Windows directly. cmd.exe is reachable from WSL through
|
|
120
|
+
// the interop path and needs no PATH entry of its own.
|
|
121
|
+
startCommand(url, env, "/mnt/c/Windows/System32/cmd.exe"),
|
|
122
|
+
startCommand(url, env, "cmd.exe"),
|
|
123
|
+
// Last, and only useful on a WSL that really does run a desktop.
|
|
124
|
+
{ file: "xdg-open", args: [url] },
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Plain Linux, and every other Unix. xdg-open is the standard answer; the
|
|
129
|
+
// rest are the ones that exist on machines that never installed xdg-utils,
|
|
130
|
+
// in the order of how likely they are to be configured rather than merely
|
|
131
|
+
// present.
|
|
132
|
+
return [
|
|
133
|
+
{ file: "xdg-open", args: [url] },
|
|
134
|
+
{ file: "gio", args: ["open", url] },
|
|
135
|
+
{ file: "x-www-browser", args: [url] },
|
|
136
|
+
{ file: "sensible-browser", args: [url] },
|
|
137
|
+
{ file: "wslview", args: [url] },
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* `start` the way cmd.exe needs to be given it.
|
|
143
|
+
*
|
|
144
|
+
* Three details, each of which is a bug if it is missing. The empty `""` is the
|
|
145
|
+
* window TITLE — `start "http://…"` opens a console window titled with the URL
|
|
146
|
+
* and nothing else happens. The URL is quoted so an `&` in a query string ends
|
|
147
|
+
* the argument instead of the command. And `windowsVerbatimArguments` stops
|
|
148
|
+
* Node quoting the line a second time, which is exactly what exec.mjs's
|
|
149
|
+
* `viaCmd` does for the same reason.
|
|
150
|
+
*/
|
|
151
|
+
export function startCommand(url, env = process.env, comspec) {
|
|
152
|
+
const shell = comspec || env.comspec || env.ComSpec || "cmd.exe";
|
|
153
|
+
return {
|
|
154
|
+
file: shell,
|
|
155
|
+
args: ["/d", "/s", "/c", `start "" "${url}"`],
|
|
156
|
+
opts: { windowsVerbatimArguments: true },
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Refuse anything that is not an http(s) URL.
|
|
162
|
+
*
|
|
163
|
+
* The one caller passes a localhost address it built itself, so this is not
|
|
164
|
+
* guarding against a hostile input today — it is guarding against the day a
|
|
165
|
+
* second caller passes a path, because every launcher above would happily open
|
|
166
|
+
* it and `start` would run it.
|
|
167
|
+
*/
|
|
168
|
+
export function isOpenable(url) {
|
|
169
|
+
try {
|
|
170
|
+
const u = new URL(String(url));
|
|
171
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Hand `url` to the desktop. Returns nothing, waits for nothing, throws never.
|
|
179
|
+
*
|
|
180
|
+
* The boot calls this between binding the port and starting the pulse line, and
|
|
181
|
+
* neither of those has anything to learn from how it went — a browser that does
|
|
182
|
+
* not open leaves a URL on screen that the user can click or paste, which is
|
|
183
|
+
* the same recovery an error row would have offered.
|
|
184
|
+
*/
|
|
185
|
+
export function openUrl(url, { platform = process.platform, env = process.env, spawnFn = spawn } = {}) {
|
|
186
|
+
if (!isOpenable(url)) return;
|
|
187
|
+
const tries = launchers(url, { platform, env });
|
|
188
|
+
|
|
189
|
+
const attempt = (i) => {
|
|
190
|
+
if (i >= tries.length) return;
|
|
191
|
+
const { file, args, opts } = tries[i];
|
|
192
|
+
let child;
|
|
193
|
+
try {
|
|
194
|
+
child = spawnFn(file, args, {
|
|
195
|
+
stdio: "ignore",
|
|
196
|
+
// The launcher outlives us on purpose: xdg-open is the browser's parent
|
|
197
|
+
// process on a cold start, and a deck stopped with Ctrl+C two seconds
|
|
198
|
+
// later should not take the window with it. Not on Windows, where a
|
|
199
|
+
// detached child is a child with its own console — the thing
|
|
200
|
+
// windowsHide exists to prevent.
|
|
201
|
+
detached: platform !== "win32",
|
|
202
|
+
windowsHide: true,
|
|
203
|
+
...opts,
|
|
204
|
+
});
|
|
205
|
+
} catch {
|
|
206
|
+
attempt(i + 1);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// ENOENT and EACCES both land here rather than throwing, because the failure
|
|
210
|
+
// happens after spawn returns.
|
|
211
|
+
child.on("error", () => attempt(i + 1));
|
|
212
|
+
// An early non-zero exit is xdg-open saying it found no handler (exit 3) or
|
|
213
|
+
// cmd saying it could not find `start`. A launcher still alive after the
|
|
214
|
+
// grace period is one that worked, so the timer is what closes the question.
|
|
215
|
+
let open = true;
|
|
216
|
+
const settled = setTimeout(() => { open = false; }, LAUNCH_GRACE_MS);
|
|
217
|
+
settled.unref?.();
|
|
218
|
+
child.on("exit", (code) => {
|
|
219
|
+
clearTimeout(settled);
|
|
220
|
+
if (open && code !== 0) attempt(i + 1);
|
|
221
|
+
});
|
|
222
|
+
child.unref?.();
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
attempt(0);
|
|
226
|
+
}
|