ccdeck 1.43.0 → 1.45.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 +210 -12
- package/bin/agent-dag.js +505 -0
- package/bin/deck.js +1051 -0
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +49 -0
- package/hook/hook.js +496 -0
- package/package.json +42 -9
- package/src/server/args.mjs +156 -0
- package/src/server/brand.mjs +16 -0
- package/src/server/ccusage.mjs +1052 -0
- package/src/server/claude-accounts.mjs +653 -0
- package/src/server/claude-dir.mjs +213 -0
- package/src/server/codex-auth.mjs +388 -0
- package/src/server/codex-dir.mjs +171 -0
- package/src/server/codex-quota.mjs +445 -0
- package/src/server/codex-usage.mjs +411 -0
- package/src/server/cswap-admin.mjs +1034 -0
- package/src/server/cswap-auto.mjs +595 -0
- package/src/server/cswap-install.mjs +583 -0
- package/src/server/exec.mjs +938 -0
- package/src/server/index.mjs +5109 -0
- package/src/server/installer.mjs +763 -0
- package/src/server/invoked-as.mjs +144 -0
- package/src/server/log-writer.mjs +283 -0
- package/src/server/npx.mjs +259 -0
- package/src/server/quota.mjs +684 -0
- package/src/server/reset-label.mjs +78 -0
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +1331 -0
- package/src/server/supervisor.mjs +302 -0
- package/src/server/system-metrics.mjs +619 -0
- package/src/server/term.mjs +570 -0
- package/src/server/uv-bootstrap.mjs +301 -0
- package/bin/ccdeck.js +0 -194
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Last-resort acquisition of `uv`, so claude-swap can be installed on a machine
|
|
2
|
+
// with no Python tooling at all.
|
|
3
|
+
//
|
|
4
|
+
// The alternative everyone reaches for is `curl -LsSf https://astral.sh/uv/
|
|
5
|
+
// install.sh | sh`, which executes whatever that URL happens to serve, with the
|
|
6
|
+
// user's privileges, and drops files into their global tool path. This does the
|
|
7
|
+
// narrower thing instead: fetch one named release artifact from Astral's own
|
|
8
|
+
// GitHub releases, check it against the SHA-256 that release publishes beside
|
|
9
|
+
// it, and unpack it inside ~/.agents-deck. Nothing is executed until it has
|
|
10
|
+
// been verified, nothing lands outside a directory the deck already owns — the
|
|
11
|
+
// staging directory included, which is what #551 was about — and deleting that
|
|
12
|
+
// directory undoes all of it.
|
|
13
|
+
//
|
|
14
|
+
// It is still a network fetch of an executable, so it only happens when there
|
|
15
|
+
// is no other way: uv, pipx, and python -m pipx have all already been ruled out
|
|
16
|
+
// by the caller. AGENTS_DECK_NO_INSTALL=1 disables it along with everything else.
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { mkdir, mkdtemp, writeFile, rm, chmod, readdir, copyFile, open } from "node:fs/promises";
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { run } from "./exec.mjs";
|
|
23
|
+
// The same rename used to replace settings.json, for the same reason: on
|
|
24
|
+
// Windows a rename over an existing file fails outright while any other process
|
|
25
|
+
// holds the target open, and a virus scanner or the search indexer opens a
|
|
26
|
+
// freshly written executable the instant it appears. Sharing one implementation
|
|
27
|
+
// keeps the retryable-error list from drifting apart between the two callers.
|
|
28
|
+
import { renameWithRetry } from "./installer.mjs";
|
|
29
|
+
|
|
30
|
+
const TOOL_DIR = join(homedir(), ".agents-deck", "tools");
|
|
31
|
+
const UV_DIR = join(TOOL_DIR, "uv");
|
|
32
|
+
|
|
33
|
+
const RELEASE_API = "https://api.github.com/repos/astral-sh/uv/releases/latest";
|
|
34
|
+
const DOWNLOAD_BASE = "https://github.com/astral-sh/uv/releases/download";
|
|
35
|
+
|
|
36
|
+
// Used when the releases API cannot be reached — rate limiting is per-IP and
|
|
37
|
+
// unauthenticated, so this is a normal outcome rather than an error. Any
|
|
38
|
+
// reasonably recent uv can install claude-swap; it self-updates later.
|
|
39
|
+
const FALLBACK_VERSION = "0.12.3";
|
|
40
|
+
|
|
41
|
+
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Release artifact for this machine, or null if uv does not publish one.
|
|
45
|
+
*
|
|
46
|
+
* Rosetta is deliberately not special-cased: an x64 Node on Apple Silicon
|
|
47
|
+
* reports x64 and gets the x64 build, which runs.
|
|
48
|
+
*/
|
|
49
|
+
export function assetName(platform = process.platform, arch = process.arch) {
|
|
50
|
+
const a = { x64: "x86_64", arm64: "aarch64", ia32: "i686" }[arch];
|
|
51
|
+
if (!a) return null;
|
|
52
|
+
// Apple publishes no i686 build, and never will.
|
|
53
|
+
if (platform === "darwin") return a === "i686" ? null : `uv-${a}-apple-darwin.tar.gz`;
|
|
54
|
+
if (platform === "win32") return `uv-${a}-pc-windows-msvc.zip`;
|
|
55
|
+
if (platform === "linux") return `uv-${a}-unknown-linux-gnu.tar.gz`;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Path to a uv this function installed earlier, or null. */
|
|
60
|
+
export function existingBootstrappedUv() {
|
|
61
|
+
const bin = join(UV_DIR, process.platform === "win32" ? "uv.exe" : "uv");
|
|
62
|
+
return existsSync(bin) ? bin : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function latestVersion() {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(RELEASE_API, {
|
|
68
|
+
headers: { accept: "application/vnd.github+json", "user-agent": "agents-deck" },
|
|
69
|
+
signal: AbortSignal.timeout(8_000),
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) return FALLBACK_VERSION;
|
|
72
|
+
const tag = (await res.json())?.tag_name;
|
|
73
|
+
return typeof tag === "string" && /^\d/.test(tag) ? tag : FALLBACK_VERSION;
|
|
74
|
+
} catch {
|
|
75
|
+
return FALLBACK_VERSION;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function download(url, { asText = false } = {}) {
|
|
80
|
+
const res = await fetch(url, {
|
|
81
|
+
redirect: "follow",
|
|
82
|
+
headers: { "user-agent": "agents-deck" },
|
|
83
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
84
|
+
});
|
|
85
|
+
if (!res.ok) return null;
|
|
86
|
+
return asText ? (await res.text()) : Buffer.from(await res.arrayBuffer());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A path as a PowerShell single-quoted string literal.
|
|
91
|
+
*
|
|
92
|
+
* Inside single quotes PowerShell expands nothing, so a doubled quote is both
|
|
93
|
+
* the only escape it has and the only one needed. Windows filenames cannot
|
|
94
|
+
* contain `"`, `<`, `>` or `|`, but they can contain an apostrophe — and the
|
|
95
|
+
* archive lands under the user's own profile either way, which is where an
|
|
96
|
+
* apostrophe comes from: C:\Users\O'Brien\.agents-deck\tools. Pasted in raw,
|
|
97
|
+
* that apostrophe ended the string mid-path and Expand-Archive died of a syntax
|
|
98
|
+
* error, so uv could never be bootstrapped on an account whose owner has that
|
|
99
|
+
* name.
|
|
100
|
+
*/
|
|
101
|
+
const psQuote = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The command that unpacks the artifact, as file + argv.
|
|
105
|
+
*
|
|
106
|
+
* `tar` is present on macOS, on Linux, and on Windows 10 1803 and later (as
|
|
107
|
+
* bsdtar), but Windows ships .zip and older Windows has no tar at all — so zip
|
|
108
|
+
* goes through PowerShell's Expand-Archive, which is always there.
|
|
109
|
+
*
|
|
110
|
+
* Exported, pure, and with the platform as a parameter, because the branch that
|
|
111
|
+
* needs checking is the one that never runs on the machine running the tests.
|
|
112
|
+
*
|
|
113
|
+
* Only the tar branch gets to pass its paths as arguments. `powershell.exe
|
|
114
|
+
* -Command` has no argument channel to pass them through — everything after
|
|
115
|
+
* -Command is joined back into the script text — so the paths are part of a
|
|
116
|
+
* PowerShell program either way, and quoting them for PowerShell is the whole
|
|
117
|
+
* job. Nothing else is quoting them on the way there: powershell.exe is an .exe,
|
|
118
|
+
* so exec.mjs spawns it directly with shell:false and the -Command string
|
|
119
|
+
* arrives as one intact argv entry, never seeing cmd.exe.
|
|
120
|
+
*/
|
|
121
|
+
export function extractCommand(archivePath, destDir, platform = process.platform) {
|
|
122
|
+
if (platform === "win32" && archivePath.endsWith(".zip")) {
|
|
123
|
+
return {
|
|
124
|
+
file: "powershell.exe",
|
|
125
|
+
args: [
|
|
126
|
+
"-NoProfile", "-NonInteractive", "-Command",
|
|
127
|
+
`Expand-Archive -LiteralPath ${psQuote(archivePath)} ` +
|
|
128
|
+
`-DestinationPath ${psQuote(destDir)} -Force`,
|
|
129
|
+
],
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { file: "tar", args: ["-xzf", archivePath, "-C", destDir] };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Unpack the artifact. */
|
|
136
|
+
async function extract(archivePath, destDir) {
|
|
137
|
+
const { file, args } = extractCommand(archivePath, destDir);
|
|
138
|
+
const r = await run(file, args, { timeout: 120_000 });
|
|
139
|
+
return r.ok;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Find the uv executable somewhere under `dir`.
|
|
144
|
+
*
|
|
145
|
+
* The archives put it in a versioned subdirectory whose name has changed
|
|
146
|
+
* between releases, so it is located rather than assumed.
|
|
147
|
+
*/
|
|
148
|
+
async function findUv(dir, depth = 0) {
|
|
149
|
+
const wanted = process.platform === "win32" ? "uv.exe" : "uv";
|
|
150
|
+
let entries;
|
|
151
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return null; }
|
|
152
|
+
for (const e of entries) {
|
|
153
|
+
if (e.isFile() && e.name === wanted) return join(dir, e.name);
|
|
154
|
+
}
|
|
155
|
+
if (depth >= 2) return null;
|
|
156
|
+
for (const e of entries) {
|
|
157
|
+
if (!e.isDirectory()) continue;
|
|
158
|
+
const hit = await findUv(join(dir, e.name), depth + 1);
|
|
159
|
+
if (hit) return hit;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Download and verify uv into ~/.agents-deck/tools/uv.
|
|
166
|
+
*
|
|
167
|
+
* Returns { ok: true, bin, version } or { ok: false, reason }. Never throws:
|
|
168
|
+
* every caller treats a missing uv as an expected state.
|
|
169
|
+
*/
|
|
170
|
+
export async function bootstrapUv() {
|
|
171
|
+
// A uv from an earlier run is reused, but only once it has proved it still
|
|
172
|
+
// runs. Existing-and-therefore-good was a one-way door: the caller has no
|
|
173
|
+
// other installer left by the time it gets here, and nothing anywhere ever
|
|
174
|
+
// deletes or re-checks this path, so a uv that had been damaged — by an
|
|
175
|
+
// interrupted copy from a deck older than this fix, a half-finished disk
|
|
176
|
+
// restore, a truncating backup tool — made every boot from then on fail with
|
|
177
|
+
// `install_failed` until the user found and deleted the directory by hand.
|
|
178
|
+
// One `--version` costs milliseconds and turns that into a re-download.
|
|
179
|
+
const already = existingBootstrappedUv();
|
|
180
|
+
if (already && (await run(already, ["--version"], { timeout: 15_000 })).ok) {
|
|
181
|
+
return { ok: true, bin: already, version: "existing" };
|
|
182
|
+
}
|
|
183
|
+
// A broken one is deliberately left where it is rather than deleted: the
|
|
184
|
+
// download below renames its replacement over it, and if the download cannot
|
|
185
|
+
// happen the file is harmless — every caller probes `--version` before using
|
|
186
|
+
// it, so a uv that does not answer is already treated as absent.
|
|
187
|
+
|
|
188
|
+
// Downloading an executable is a bigger step than installing a package with a
|
|
189
|
+
// tool the user already chose, so it gets its own off switch as well as the
|
|
190
|
+
// blanket one — someone may want the managed installs and not this.
|
|
191
|
+
if (process.env.AGENTS_DECK_NO_DOWNLOAD === "1") return { ok: false, reason: "download_disabled" };
|
|
192
|
+
|
|
193
|
+
const asset = assetName();
|
|
194
|
+
if (!asset) return { ok: false, reason: "unsupported_platform" };
|
|
195
|
+
|
|
196
|
+
const version = await latestVersion();
|
|
197
|
+
const base = `${DOWNLOAD_BASE}/${version}/${asset}`;
|
|
198
|
+
|
|
199
|
+
// Created inside the try, because it is created rather than merely named:
|
|
200
|
+
// see the mkdtemp below for why that distinction is the whole fix.
|
|
201
|
+
let staging = null;
|
|
202
|
+
// Set while a half-finished binary exists under a name of its own, cleared the
|
|
203
|
+
// moment it is renamed into place; the finally below removes whatever is left.
|
|
204
|
+
let partial = null;
|
|
205
|
+
try {
|
|
206
|
+
const [archive, sumText] = await Promise.all([
|
|
207
|
+
download(base),
|
|
208
|
+
download(`${base}.sha256`, { asText: true }),
|
|
209
|
+
]);
|
|
210
|
+
if (!archive) return { ok: false, reason: "download_failed" };
|
|
211
|
+
|
|
212
|
+
// No published checksum means no way to know what was downloaded, and an
|
|
213
|
+
// unverified binary is not something to run on someone's machine.
|
|
214
|
+
const expected = sumText?.trim().split(/\s+/)[0]?.toLowerCase();
|
|
215
|
+
if (!expected || !/^[0-9a-f]{64}$/.test(expected)) {
|
|
216
|
+
return { ok: false, reason: "no_checksum" };
|
|
217
|
+
}
|
|
218
|
+
const actual = createHash("sha256").update(archive).digest("hex");
|
|
219
|
+
if (actual !== expected) return { ok: false, reason: "checksum_mismatch" };
|
|
220
|
+
|
|
221
|
+
// ── where the archive is unpacked, and why it is not the system temp dir ──
|
|
222
|
+
//
|
|
223
|
+
// This used to be `join(tmpdir(), `agents-deck-uv-${process.pid}`)`, created
|
|
224
|
+
// with `mkdir(..., { recursive: true })` — which does not fail on a
|
|
225
|
+
// directory that is already there.
|
|
226
|
+
//
|
|
227
|
+
// On macOS tmpdir() is a per-user `/var/folders/…/T` and on Windows it is
|
|
228
|
+
// inside the profile, so the header's promise held on both. On Linux it is
|
|
229
|
+
// `/tmp`, mode 1777, writable by every account on the box. The comment two
|
|
230
|
+
// hundred lines up reasons carefully about tmpdir() being inside the user's
|
|
231
|
+
// profile ON WINDOWS and never asks the Linux question.
|
|
232
|
+
//
|
|
233
|
+
// Three things then line up. The name is derived from a pid, so it is
|
|
234
|
+
// cheap to blanket the whole plausible range in advance. `findUv` returns a
|
|
235
|
+
// depth-0 file before it recurses, while the genuine uv always sits one
|
|
236
|
+
// level down inside the archive's versioned directory — so a planted
|
|
237
|
+
// `/tmp/agents-deck-uv-<pid>/uv` wins deterministically rather than by
|
|
238
|
+
// racing. And the SHA-256 above is computed on the downloaded buffer, never
|
|
239
|
+
// on the extracted file, so it does not cover the thing that is executed.
|
|
240
|
+
// What followed was a copy, a chmod 0755, a `--version` run, a rename to
|
|
241
|
+
// the permanent name and reuse of that name forever.
|
|
242
|
+
//
|
|
243
|
+
// `mkdtemp` under TOOL_DIR fixes all three at once: the directory is inside
|
|
244
|
+
// a tree the user already owns, its name is unpredictable, and mkdtemp
|
|
245
|
+
// CREATES — it cannot be handed a directory somebody else made. findUv's
|
|
246
|
+
// ordering is left alone deliberately; it is not wrong on its own, and it
|
|
247
|
+
// stops being reachable once nobody else can write here.
|
|
248
|
+
await mkdir(TOOL_DIR, { recursive: true });
|
|
249
|
+
staging = await mkdtemp(join(TOOL_DIR, "uv-staging-"));
|
|
250
|
+
const archivePath = join(staging, asset);
|
|
251
|
+
await writeFile(archivePath, archive);
|
|
252
|
+
if (!(await extract(archivePath, staging))) return { ok: false, reason: "extract_failed" };
|
|
253
|
+
|
|
254
|
+
const found = await findUv(staging);
|
|
255
|
+
if (!found) return { ok: false, reason: "not_in_archive" };
|
|
256
|
+
|
|
257
|
+
await mkdir(UV_DIR, { recursive: true });
|
|
258
|
+
const dest = join(UV_DIR, process.platform === "win32" ? "uv.exe" : "uv");
|
|
259
|
+
|
|
260
|
+
// Copy rather than rename out of staging: the staging dir is in the system
|
|
261
|
+
// temp directory, which is often a different filesystem, and rename across
|
|
262
|
+
// one fails. But the copy goes to a name of its own INSIDE UV_DIR — same
|
|
263
|
+
// filesystem as the destination, so THAT rename is the atomic kind — and
|
|
264
|
+
// only becomes `uv` once it is whole, flushed, executable and has answered
|
|
265
|
+
// `--version`. Copying 40MB straight to the final name is many writes long,
|
|
266
|
+
// and a Ctrl-C in the middle of them (the boot install runs before the
|
|
267
|
+
// signal handlers are up) left a truncated file under the name every later
|
|
268
|
+
// run trusts. Interrupt this instead and all that is left is an inert temp
|
|
269
|
+
// file: `uv` either does not exist or is a binary that ran once already.
|
|
270
|
+
// The pid keeps two decks starting at once off each other's copy, and the
|
|
271
|
+
// .exe keeps the file executable on Windows, where the version check below
|
|
272
|
+
// would otherwise have nothing to spawn.
|
|
273
|
+
const suffix = process.platform === "win32" ? ".exe" : "";
|
|
274
|
+
partial = join(UV_DIR, `.uv-${process.pid}-${Date.now().toString(36)}${suffix}`);
|
|
275
|
+
await copyFile(found, partial);
|
|
276
|
+
// Flushed before the rename, so a crash or power cut just after a
|
|
277
|
+
// successful bootstrap cannot leave the name pointing at blocks that were
|
|
278
|
+
// never written — the same guarantee writeFileAtomic gives settings.json.
|
|
279
|
+
const handle = await open(partial, "r+");
|
|
280
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
281
|
+
if (process.platform !== "win32") await chmod(partial, 0o755);
|
|
282
|
+
|
|
283
|
+
const check = await run(partial, ["--version"], { timeout: 15_000 });
|
|
284
|
+
if (!check.ok) return { ok: false, reason: "does_not_run" };
|
|
285
|
+
|
|
286
|
+
await renameWithRetry(partial, dest);
|
|
287
|
+
partial = null;
|
|
288
|
+
|
|
289
|
+
return { ok: true, bin: dest, version };
|
|
290
|
+
} catch (err) {
|
|
291
|
+
return { ok: false, reason: "error", detail: String(err?.message ?? err).slice(0, 200) };
|
|
292
|
+
} finally {
|
|
293
|
+
// maxRetries for the reason the ccusage repair gives: the archive was just
|
|
294
|
+
// unpacked here and `partial` was just run out of it, and on Windows a file
|
|
295
|
+
// a handle has not finished releasing cannot be deleted — the directory
|
|
296
|
+
// then refuses to go with ENOTEMPTY. Litter rather than a failure, since
|
|
297
|
+
// this is swallowed, but litter in the user's home that nothing else sweeps.
|
|
298
|
+
if (staging) await rm(staging, { recursive: true, force: true, maxRetries: 10, retryDelay: 25 }).catch(() => {});
|
|
299
|
+
if (partial) await rm(partial, { force: true }).catch(() => {});
|
|
300
|
+
}
|
|
301
|
+
}
|
package/bin/ccdeck.js
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { spawn } from 'child_process';
|
|
3
|
-
import { existsSync } from 'fs';
|
|
4
|
-
import { createRequire } from 'module';
|
|
5
|
-
import { fileURLToPath, pathToFileURL } from 'url';
|
|
6
|
-
import { dirname, resolve } from 'path';
|
|
7
|
-
|
|
8
|
-
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
9
|
-
// This package ships nothing but bin/, so both halves of the deck come out of
|
|
10
|
-
// the agents-deck installed beside us — the binary to hand off to, and the rule
|
|
11
|
-
// for reporting a worker that was killed. Reached by path rather than as the
|
|
12
|
-
// bare specifier `agents-deck/src/server/supervisor.mjs` so the two can never
|
|
13
|
-
// resolve to different copies, and so an `exports` map added to agents-deck
|
|
14
|
-
// later cannot turn this into a startup crash. The dependency is pinned to the
|
|
15
|
-
// exact version at publish time, so the file is always there.
|
|
16
|
-
//
|
|
17
|
-
// TWO LAYOUTS, because npm has two and only one of them was ever handled. Under
|
|
18
|
-
// `npx ccdeck` the dependency is a sibling — `_npx/<hash>/node_modules/{ccdeck,
|
|
19
|
-
// agents-deck}` — and `../../agents-deck` finds it. Under `npm i -g ccdeck` it
|
|
20
|
-
// is NOT: npm stopped hoisting a global package's dependencies in v7, so it
|
|
21
|
-
// lands at `lib/node_modules/ccdeck/node_modules/agents-deck` while
|
|
22
|
-
// `../../agents-deck` points at `lib/node_modules/agents-deck`, which does not
|
|
23
|
-
// exist. That is not a hypothetical — installing the published 1.33.142 this
|
|
24
|
-
// way and running it gives ERR_MODULE_NOT_FOUND before the deck prints
|
|
25
|
-
// anything, so the one command every surface tells people to run has been dead
|
|
26
|
-
// on arrival for every global install. npx was the only shape anybody tested.
|
|
27
|
-
//
|
|
28
|
-
// So: try the nested layout first (it is the one that was broken), then the
|
|
29
|
-
// sibling, then let node resolve it — which covers a workspace, a pnpm store
|
|
30
|
-
// and any future layout none of us predicted. Path-first, resolution last,
|
|
31
|
-
// keeps the guarantee in the paragraph above wherever a path does answer.
|
|
32
|
-
const nested = resolve(__dir, '../node_modules/agents-deck');
|
|
33
|
-
const sibling = resolve(__dir, '../../agents-deck');
|
|
34
|
-
|
|
35
|
-
// The three codes that mean "there is no deck here", as opposed to "the deck is
|
|
36
|
-
// broken". Both of the steps below can fail either way and they deserve opposite
|
|
37
|
-
// answers, so the distinction is made once, by code, and nothing else is caught.
|
|
38
|
-
//
|
|
39
|
-
// MODULE_NOT_FOUND is what the last resort above threw in the shape people
|
|
40
|
-
// actually hit it: a half-removed install, a linked workspace, Yarn PnP without
|
|
41
|
-
// agents-deck in scope. Unguarded it reached the user as an eight-frame stack
|
|
42
|
-
// out of node's CJS loader before a single line of the deck — the same failure
|
|
43
|
-
// shape 0075d57 was written to eliminate, arriving through a different API. A
|
|
44
|
-
// launcher that cannot find its own deck knows exactly what is wrong and can say
|
|
45
|
-
// it in a sentence.
|
|
46
|
-
//
|
|
47
|
-
// ERR_PACKAGE_PATH_NOT_EXPORTED is the case the header above worries about:
|
|
48
|
-
// `./package.json` is not exported by default, so an `exports` map added to
|
|
49
|
-
// agents-deck later would make that resolve throw rather than answer. Resolving
|
|
50
|
-
// `agents-deck/bin/agent-dag.js` instead, as #359 suggests, does not avoid it —
|
|
51
|
-
// every subpath is gated by an exports map, and that one resolves to the bin
|
|
52
|
-
// directory rather than to the package root. The path probes are what actually
|
|
53
|
-
// keep the header's promise; listing the code here only makes the failure legible
|
|
54
|
-
// if it ever arrives.
|
|
55
|
-
//
|
|
56
|
-
// ERR_MODULE_NOT_FOUND is the import below: a pkg that was found but has no
|
|
57
|
-
// supervisor.mjs under it — a truncated or half-written install — is just as
|
|
58
|
-
// much "there is no deck here", and it used to throw its own stack too.
|
|
59
|
-
const NO_DECK = new Set(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED']);
|
|
60
|
-
|
|
61
|
-
let pkg, dieOfSignal;
|
|
62
|
-
try {
|
|
63
|
-
pkg = existsSync(resolve(nested, 'package.json')) ? nested
|
|
64
|
-
: existsSync(resolve(sibling, 'package.json')) ? sibling
|
|
65
|
-
: dirname(createRequire(import.meta.url).resolve('agents-deck/package.json'));
|
|
66
|
-
({ dieOfSignal } = await import(pathToFileURL(resolve(pkg, 'src/server/supervisor.mjs')).href));
|
|
67
|
-
} catch (err) {
|
|
68
|
-
// A supervisor.mjs that throws while it evaluates is a bug in the deck, not a
|
|
69
|
-
// missing install, and it keeps its stack — that stack is the only thing that
|
|
70
|
-
// would explain it.
|
|
71
|
-
if (!NO_DECK.has(err?.code)) throw err;
|
|
72
|
-
console.error([
|
|
73
|
-
'ccdeck: could not find the agents-deck install it runs.',
|
|
74
|
-
'',
|
|
75
|
-
` looked in ${nested}`,
|
|
76
|
-
` and ${sibling}`,
|
|
77
|
-
'',
|
|
78
|
-
' ccdeck ships only this launcher — the deck itself is the agents-deck package',
|
|
79
|
-
' npm installs alongside it. Reinstall with `npm i -g ccdeck`, or skip the',
|
|
80
|
-
' launcher and run the deck directly with `npx agents-deck`.',
|
|
81
|
-
].join('\n'));
|
|
82
|
-
process.exit(1);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const bin = resolve(pkg, 'bin/agent-dag.js');
|
|
86
|
-
const child = spawn(process.execPath, [bin, ...process.argv.slice(2)], { stdio: 'inherit' });
|
|
87
|
-
// The supervisor answers a killed worker by dying of the same signal rather
|
|
88
|
-
// than exiting 128+n, which reaches us as (null, 'SIGTERM'). Exiting with
|
|
89
|
-
// `code ?? 0` turned that into 0: a `kill` or an OOM kill of a deck started as
|
|
90
|
-
// `npx ccdeck` was reported to systemd — or to whatever script started it — as
|
|
91
|
-
// a clean stop, while the same build under `npx agents-deck` reported 143.
|
|
92
|
-
// Ctrl+C hid it: that signals the whole group, so the stub died of SIGINT
|
|
93
|
-
// before this line could run — and a deck that shut itself down cleanly comes
|
|
94
|
-
// back through here as a plain 0 anyway. The handlers below are what stop the
|
|
95
|
-
// stub from dying first, which is what makes this line reachable on that path
|
|
96
|
-
// too; they change nothing about the verdict it reports.
|
|
97
|
-
child.on('exit', (code, signal) => (signal ? dieOfSignal(signal) : process.exit(code ?? 0)));
|
|
98
|
-
|
|
99
|
-
// ── a signal aimed at this process has to reach the deck, and must not reach it
|
|
100
|
-
// twice ─────────────────────────────────────────────────────────────────────
|
|
101
|
-
//
|
|
102
|
-
// Nothing here trapped a signal, so `kill <pid>` against the stub — a systemd
|
|
103
|
-
// unit without KillMode=control-group, any supervisor that signals only the main
|
|
104
|
-
// pid, a hand-typed kill — ended the launcher in about six milliseconds and left
|
|
105
|
-
// agent-dag.js and deck.js running: still serving on 4317, still advertising a
|
|
106
|
-
// live pid in the discovery file. Hooks keep posting to a deck the user believes
|
|
107
|
-
// is stopped, and the next `npx ccdeck` finds the port taken and quietly binds
|
|
108
|
-
// another one.
|
|
109
|
-
//
|
|
110
|
-
// Ctrl+C is why that survived: the terminal signals the whole foreground process
|
|
111
|
-
// group, and the child is in it — nothing here spawns detached — so the deck
|
|
112
|
-
// gets its own copy and tears itself down without us. Measured rather than
|
|
113
|
-
// assumed, by group-signalling a real stub over a real child: under a group-wide
|
|
114
|
-
// SIGINT the child receives exactly one signal and shuts down; under a targeted
|
|
115
|
-
// one it receives none and is still alive seconds later.
|
|
116
|
-
//
|
|
117
|
-
// Which is exactly why forwarding cannot be unconditional. Under Ctrl+C the
|
|
118
|
-
// child has already had the signal, and a forwarded copy lands about a
|
|
119
|
-
// millisecond behind it as a SECOND one — measured the same way, three runs out
|
|
120
|
-
// of three. agent-dag.js reads a second signal as an impatient user pressing
|
|
121
|
-
// Ctrl+C again and answers it by SIGKILLing deck.js, and a SIGKILLed deck is a
|
|
122
|
-
// deck denied its own shutdown: the discovery file it was partway through
|
|
123
|
-
// removing stays behind, naming a pid that is gone. That is this very bug
|
|
124
|
-
// arriving from the other direction, so the obvious one-liner trades an orphaned
|
|
125
|
-
// deck for a stale one.
|
|
126
|
-
//
|
|
127
|
-
// So the forward is delayed, and the child's own exit cancels it. If the child
|
|
128
|
-
// got the signal too it is gone long before the timer and nothing is sent; if it
|
|
129
|
-
// did not, the timer fires and the deck comes down. A second signal means
|
|
130
|
-
// somebody is waiting, so it goes straight through. Deciding this by clock
|
|
131
|
-
// rather than by asking agent-dag to tolerate a duplicate keeps the answer true
|
|
132
|
-
// against a deck this stub did not ship with — a workspace, a pnpm store, an
|
|
133
|
-
// older global agents-deck found by the resolution above — which is the whole
|
|
134
|
-
// reason those layouts are supported at all.
|
|
135
|
-
//
|
|
136
|
-
// Staying alive is the other half of the fix, and it is what the delay is built
|
|
137
|
-
// on. The default action for all three signals is to die, so the stub used to
|
|
138
|
-
// vanish while the deck was still shutting down, handing the shell a prompt and
|
|
139
|
-
// a status of its own before the deck had one. Now the child's status is what
|
|
140
|
-
// the caller gets, the way `npx agents-deck` has always answered — including the
|
|
141
|
-
// 0 deck.js chooses for a Ctrl+C, where the stub used to report 130. That is the
|
|
142
|
-
// parity the exit handler above exists for, extended to the signals that arrive
|
|
143
|
-
// here rather than there. dieOfSignal drops our handler before it re-raises, so
|
|
144
|
-
// a deck that really did die of a signal still comes back as 128+n instead of
|
|
145
|
-
// being swallowed by the handlers below.
|
|
146
|
-
//
|
|
147
|
-
// WINDOWS GETS THE OPPOSITE ANSWER, because it has no signals to forward. The
|
|
148
|
-
// only thing that runs these handlers there is a console event — Ctrl+C,
|
|
149
|
-
// Ctrl+Break, the window closing — and the console delivers those to every
|
|
150
|
-
// process attached to it, so the child always has its own copy already. A
|
|
151
|
-
// targeted kill there is TerminateProcess: no handler runs at all, and there is
|
|
152
|
-
// nothing this file can do about that. Meanwhile `child.kill()` on Windows IS
|
|
153
|
-
// TerminateProcess, so forwarding would take agent-dag.js out mid-shutdown and
|
|
154
|
-
// orphan deck.js underneath it — the failure we are here to fix, caused by the
|
|
155
|
-
// fix. So on Windows the handlers only keep us alive for the child's exit
|
|
156
|
-
// status, and never forward.
|
|
157
|
-
//
|
|
158
|
-
// The delay itself is chosen against what it has to outlast and what it makes
|
|
159
|
-
// wait: longer than a healthy teardown, since deck.js caps its entire shutdown
|
|
160
|
-
// at 1500ms, and shorter than every stop timeout that would be sitting on the
|
|
161
|
-
// other end of a `kill` — docker's 10s is the tightest, systemd's 90s the most
|
|
162
|
-
// common. Ctrl+C never waits it out, because the deck is long gone by then.
|
|
163
|
-
const FORWARD_AFTER_MS = 2000;
|
|
164
|
-
|
|
165
|
-
let pending = null; // a forward waiting to see whether the child needs one
|
|
166
|
-
let forwarded = false; // whether one has already gone out
|
|
167
|
-
|
|
168
|
-
const forward = (sig) => {
|
|
169
|
-
pending = null;
|
|
170
|
-
forwarded = true;
|
|
171
|
-
try { child.kill(sig); } catch { /* already gone */ }
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
175
|
-
process.on(sig, () => {
|
|
176
|
-
// Node sets both of these in the same breath as it emits 'exit', so this is
|
|
177
|
-
// the child's real state and not a guess: there is nothing left to signal,
|
|
178
|
-
// and the exit handler above is already on its way to ending us.
|
|
179
|
-
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
180
|
-
if (process.platform === 'win32') return;
|
|
181
|
-
if (!pending && !forwarded) {
|
|
182
|
-
// unref'd because the child's own handle is what keeps this process alive;
|
|
183
|
-
// a forward that is still waiting should never be the reason the stub
|
|
184
|
-
// outlives the deck.
|
|
185
|
-
pending = setTimeout(() => forward(sig), FORWARD_AFTER_MS).unref();
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
// Anything after the first goes straight out, and `forwarded` keeps it that
|
|
189
|
-
// way: once somebody has asked twice, a later ask must not be slower than
|
|
190
|
-
// the one before it. The clear is a no-op when the delay has already run.
|
|
191
|
-
clearTimeout(pending);
|
|
192
|
-
forward(sig);
|
|
193
|
-
});
|
|
194
|
-
}
|