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.
@@ -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
- }