agent-dag 3.18.3 → 3.20.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,234 @@
1
+ // Why a second `ccdeck` must not quietly become a second deck.
2
+ //
3
+ // THE DEFECT. `startServer` is handed `portRange: [4318, 4400]` and answers a
4
+ // refused bind on 4317 by taking a random port out of it. That fallback is
5
+ // right and stays: 4317 is also the standard OTLP collector port, and on
6
+ // Windows `winnat` reserves contiguous TCP blocks for Hyper-V, WSL2 and Docker
7
+ // Desktop, so 4317 can be unavailable on a machine with nothing listening on it
8
+ // at all. A deck that comes up on 4322 beats a deck that refuses to come up.
9
+ //
10
+ // What was wrong is that the SAME fallback ran when the thing holding 4317 was
11
+ // another ccdeck. Typing `ccdeck` a second time therefore built a second
12
+ // everything — supervisor, worker, server, hook registration, LAN identity,
13
+ // browser tab — beside a perfectly healthy first one, and neither said a word
14
+ // about the other. Twenty of them were counted on one machine.
15
+ //
16
+ // THE QUESTION IS NOT "IS THE PORT FREE". It is "is one of MY decks already
17
+ // running", and the registry answers that without touching a port: every deck
18
+ // writes ~/.claude/agent-dag/<pid>.json with its pid, port, token and shape.
19
+ //
20
+ // A PID IS NOT EVIDENCE, and that is #695's whole lesson. A record left behind
21
+ // by a deck that is gone — SIGKILL, an OOM kill, a power cut, a console window
22
+ // closed on Windows, none of which run the shutdown that unlinks it — passes a
23
+ // signal-0 probe forever once the OS recycles that number, and the port it
24
+ // names may by then belong to anything. So the port has to prove itself: the
25
+ // record carries the deck's own token in plaintext (mode 0600, same user,
26
+ // written there precisely so another process can challenge with it), and
27
+ // challengeDeck asks that port to hash the token against a nonce it has never
28
+ // seen. A collector, a container, a stranger on a recycled port cannot answer.
29
+ // The deck that wrote the file can.
30
+ //
31
+ // THE DECK FOUND MUST ALSO BE THE DECK WE WOULD HAVE BUILT. Attaching a plain
32
+ // `ccdeck` to a deck started with `--workspace ~/proj` would open a canvas
33
+ // filtered to a directory the user never mentioned, and attaching to a
34
+ // `--no-claude` deck would open one with no accounts panel and no hooks. So the
35
+ // shape is compared field for field, and anything that does not match starts
36
+ // its own deck exactly as before.
37
+ //
38
+ // WHAT THIS FILE DOES NOT IMPORT: src/server/index.mjs. That module is the whole
39
+ // server, and it arms its timers the moment it is loaded — but this one is read
40
+ // on the boot path, and by `ccdeck --stop`, which is a command that talks to a
41
+ // deck and exits. Starting a server to ask a server to stop is absurd on its
42
+ // face and, on a cold start, slower than the thing it is asking for. The
43
+ // handshake and the liveness probe therefore live in deck-probe.mjs, a leaf
44
+ // that imports two node builtins; index.mjs takes them from the same place and
45
+ // re-exports them, so there is still exactly one spelling in the package.
46
+ import { readdir, readFile } from "node:fs/promises";
47
+ import { join } from "node:path";
48
+ import { claudeConfigDir } from "./claude-dir.mjs";
49
+ import { challengeDeck, isProcessAlive } from "./deck-probe.mjs";
50
+
51
+ /**
52
+ * Where every deck on this machine registers itself.
53
+ *
54
+ * Still under ~/.claude, and deliberately: deck-home.mjs moved the deck's own
55
+ * state out of Claude Code's directory and left exactly two things behind, of
56
+ * which this is one. A deck of any version has to be able to list the others,
57
+ * and an older deck reads this directory by name — move it and two versions
58
+ * running side by side stop seeing each other, which is precisely the blindness
59
+ * this module exists to end.
60
+ */
61
+ export function deckRegistryDir(env = process.env, home = undefined) {
62
+ return join(home === undefined ? claudeConfigDir(env) : claudeConfigDir(env, home), "agent-dag");
63
+ }
64
+
65
+ /**
66
+ * The flags that mean "I want a deck of my own", as opposed to "show me the
67
+ * deck".
68
+ *
69
+ * Every one of them changes what the deck IS — which sessions it captures,
70
+ * which log it appends to, which of the two CLIs it serves, which port it
71
+ * binds. A command line carrying any of them is a request that an existing deck
72
+ * cannot satisfy, so it is never answered by attaching to one.
73
+ *
74
+ * `--no-open` is deliberately absent: it changes what the LAUNCHER does once it
75
+ * has a URL, not what the deck is, and it is honoured on the attach path too.
76
+ * `--all` is absent because it has been a no-op since it became the default.
77
+ */
78
+ export const SHAPING_FLAGS = Object.freeze([
79
+ "port", "workspace", "scope", "history", "noPersist",
80
+ "codex", "noCodex", "claude", "noClaude",
81
+ ]);
82
+
83
+ /**
84
+ * Is this command line one that must build its own deck?
85
+ *
86
+ * Only a shaping flag, and `--new` — the deliberate escape hatch for the person
87
+ * who really does want two.
88
+ *
89
+ * A TYPO IS NOT ONE, and the first version of this had it the other way round.
90
+ * `unknown` and `incomplete` both forced a new deck, on the argument that an
91
+ * attach prints no startup report and would swallow the warning that names the
92
+ * bad token. The consequence of that argument was `ccdeck --stpo` — a typo in
93
+ * the flag that STOPS a deck — building a second one, which is the exact
94
+ * outcome this whole module exists to prevent, reached through the guard meant
95
+ * to protect it.
96
+ *
97
+ * The warning was the real requirement, not the new process, so the attach path
98
+ * prints it instead: see the call to reportUnknownFlags beside the attach in
99
+ * bin/deck.js. Nothing is swallowed, and no misspelling can leave a rival deck
100
+ * on a random port behind it.
101
+ */
102
+ export function asksForOwnDeck(flags = {}) {
103
+ if (flags.new === true) return true;
104
+ return SHAPING_FLAGS.some((k) => flags[k] !== undefined);
105
+ }
106
+
107
+ /**
108
+ * Would this record's deck serve what we were about to build?
109
+ *
110
+ * Compared field for field rather than by a version or a heuristic. `claude`
111
+ * and `codex` are strict identity against a boolean, so a record written before
112
+ * either field existed carries `undefined`, fails, and its deck is left alone —
113
+ * which is the old behaviour, reached by construction rather than by a version
114
+ * check nobody would remember to update.
115
+ */
116
+ export function sameShape(record, want = {}) {
117
+ if (!record) return false;
118
+ return (record.workspace ?? "") === (want.workspace ?? "")
119
+ && (record.persist ?? null) === (want.persist ?? null)
120
+ && record.codex === (want.codex !== false)
121
+ && record.claude === (want.claude !== false);
122
+ }
123
+
124
+ /** A record complete enough to be worth a challenge. A missing token is a deck
125
+ * older than the handshake: it cannot prove anything, so it cannot be attached
126
+ * to, and it keeps the behaviour it has always had. */
127
+ function usable(d) {
128
+ return Boolean(d)
129
+ && Number.isInteger(d.pid)
130
+ && Number.isInteger(d.port) && d.port > 0 && d.port < 65536
131
+ && typeof d.token === "string" && d.token !== "";
132
+ }
133
+
134
+ /**
135
+ * Every registered deck whose pid is still there, ordered.
136
+ *
137
+ * NOT PROVED — this is the cheap half, a directory listing and a signal-0 each,
138
+ * and its three callers want different things from it. `detach.mjs` wants only
139
+ * the count, to decide whether deck.log belongs to a deck that is still running
140
+ * or to nobody; a round trip per record to answer that would be absurd. Ordered by port with pid breaking the tie, which is the rule
141
+ * electWriters uses for the log and is here for the same reason: several decks
142
+ * can qualify, and the answer has to be the same one every time it is asked
143
+ * rather than whatever `readdir` happened to return first.
144
+ *
145
+ * A failure to read the directory is "no decks", not an error. This runs on the
146
+ * boot path of a program whose job is to start, and there is no reading of that
147
+ * directory whose failure is worth refusing to start over.
148
+ */
149
+ export async function registeredDecks({
150
+ dir = deckRegistryDir(),
151
+ fs = { readdir, readFile },
152
+ self = process.pid,
153
+ alive = isProcessAlive,
154
+ } = {}) {
155
+ let names;
156
+ try { names = await fs.readdir(dir); } catch { return []; }
157
+ const out = [];
158
+ for (const name of names) {
159
+ if (!name.endsWith(".json")) continue;
160
+ let d;
161
+ try { d = JSON.parse(await fs.readFile(join(dir, name), "utf8")); } catch { continue; }
162
+ if (!usable(d)) continue;
163
+ if (d.pid === self) continue;
164
+ if (!alive(d.pid)) continue;
165
+ out.push(d);
166
+ }
167
+ return out.sort((a, b) => a.port - b.port || a.pid - b.pid);
168
+ }
169
+
170
+ /**
171
+ * The deck already serving what this process was about to serve, or null.
172
+ *
173
+ * Only records that already match the shape are challenged, and the walk stops
174
+ * at the first that proves itself. On the ordinary machine that is one loopback
175
+ * round trip; on a machine with no deck running it is a directory listing and
176
+ * nothing else. That is why this is not `liveDecks().find(…)`: the boot path
177
+ * must not pay a round trip per deck to answer a question the first one settles.
178
+ */
179
+ export async function runningDeck({
180
+ want = {},
181
+ dir = deckRegistryDir(),
182
+ fs = { readdir, readFile },
183
+ self = process.pid,
184
+ alive = isProcessAlive,
185
+ prove = challengeDeck,
186
+ } = {}) {
187
+ for (const d of await registeredDecks({ dir, fs, self, alive })) {
188
+ if (!sameShape(d, want)) continue;
189
+ if (await prove(d.port, d.token)) return d;
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Every deck on this machine that answered a challenge, in port order.
196
+ *
197
+ * The list `--status` prints and the list `--stop` chooses from. Everything is
198
+ * challenged here, unlike runningDeck: a list that quietly omitted a deck it
199
+ * could not be bothered to ask about would be worse than no list, because the
200
+ * whole reason to run `--status` is to find the process you did not know was
201
+ * there. The round trips go out together — one deadline for the lot, not one
202
+ * after another — since they are independent and each is bounded at 400ms.
203
+ */
204
+ export async function liveDecks({
205
+ dir = deckRegistryDir(),
206
+ fs = { readdir, readFile },
207
+ self = process.pid,
208
+ alive = isProcessAlive,
209
+ prove = challengeDeck,
210
+ } = {}) {
211
+ const all = await registeredDecks({ dir, fs, self, alive });
212
+ const proved = await Promise.all(all.map(d => prove(d.port, d.token).then(ok => (ok ? d : null))));
213
+ return proved.filter(Boolean);
214
+ }
215
+
216
+ /**
217
+ * What to say about the version of the deck we attached to.
218
+ *
219
+ * A mismatch does NOT stop the attach, and that is the deliberate half. Someone
220
+ * who runs `npx ccdeck@latest` while an older deck is up has asked for the new
221
+ * one, but the way to give it to them is not to stand a rival deck on a random
222
+ * port beside the old one — that is the failure this whole module removes. So
223
+ * they are attached and TOLD, with the one sentence that gets them the version
224
+ * they asked for.
225
+ *
226
+ * Empty when the versions agree, and empty when the running deck is too old to
227
+ * report one: "unknown" beside a number is noise, and there is nothing useful
228
+ * to do about it either way.
229
+ */
230
+ export function versionNote(running, ours) {
231
+ if (typeof running !== "string" || running === "" || typeof ours !== "string" || ours === "") return "";
232
+ if (running === ours) return "";
233
+ return `running v${running}, you launched v${ours} — restart it from the deck to upgrade`;
234
+ }
@@ -211,7 +211,10 @@ export function isNpxInstall(pkgRoot) {
211
211
  * ahead of npm, and telling someone to `npm i -g` over their working copy is
212
212
  * actively wrong, so the registry side of the check is skipped there.
213
213
  *
214
- * Not exported (#383). It gates three answers and each of the three is already
214
+ * Exported for one caller outside this file: the login item, which must not be
215
+ * installed from a checkout for the same reason it must not be installed from
216
+ * an npx run — the path is not one anybody promised to keep. Everything else it
217
+ * gates stays internal, and each of those is already
215
218
  * asserted against a directory with a real `.git` in it — `upgradeCommand`
216
219
  * returning "git pull && npm run build", `upgradeName` refusing to move a
217
220
  * checkout onto a published alias, and `startUpgrade` refusing with
@@ -226,7 +229,7 @@ export function isNpxInstall(pkgRoot) {
226
229
  * carries a drive letter and backslashes. Both shapes are covered in
227
230
  * worktree-git-file-587.test.ts and beside every checkout fixture in the
228
231
  * suite — narrowing this to `.isDirectory()` fails them. */
229
- function isGitCheckout(pkgRoot) {
232
+ export function isGitCheckout(pkgRoot) {
230
233
  try { return existsSync(join(pkgRoot, ".git")); } catch { return false; }
231
234
  }
232
235
 
@@ -0,0 +1,171 @@
1
+ // Ending a deck that is not our child.
2
+ //
3
+ // `ccdeck --stop` holds a pid and a port out of a discovery file and nothing
4
+ // else — no ChildProcess, no IPC channel, no shell job. That rules out
5
+ // exec.mjs's killTree, which takes a ChildProcess and calls `child.kill`, and it
6
+ // is why the Windows half of that function is spelled a second time here rather
7
+ // than shared: the two have the same shape and different inputs.
8
+ //
9
+ // THREE RUNGS, and each one exists for a failure the one above it cannot cover:
10
+ //
11
+ // THE POST. /api/shutdown, with the token out of the record, ending in the
12
+ // deck's own shutdown(): the listener closes, the registration is unlinked,
13
+ // the LAN beacon says goodbye so paired colleagues see it LEAVE rather than
14
+ // time out. This is the rung that behaves identically on all three platforms,
15
+ // which is the whole reason the route exists — Windows has no signals.
16
+ //
17
+ // SIGTERM, POSIX only. Not a fallback for a wedged deck — a wedged deck is
18
+ // wedged for this too — but the only way to end a deck OLDER than the route,
19
+ // which answers the POST with a 404 and would otherwise be unstoppable by its
20
+ // own command. bin/deck.js has handled SIGTERM with shutdown(0) for years, so
21
+ // this is still a clean exit on every version that has ever shipped.
22
+ //
23
+ // THE HARD KILL. SIGKILL, or `taskkill /T /F` on Windows. Leaves the discovery
24
+ // file behind for the next boot to sweep and gives the LAN no goodbye, and is
25
+ // still better than a process the user cannot end with the tool that started
26
+ // it — which is the outcome this whole file is written against.
27
+ //
28
+ // THE PARENT GOES FIRST on both kill rungs. A worker killed under a live
29
+ // supervisor is a worker the supervisor puts back: that is what it is for. So
30
+ // the supervisor is ended first and the worker second, which is also what the
31
+ // record's `parent` field is written for.
32
+ import { request as httpRequest } from "node:http";
33
+ import { spawn } from "node:child_process";
34
+ import { isProcessAlive } from "./deck-probe.mjs";
35
+
36
+ /** Long enough for a loopback POST and the deck's own teardown to begin; short
37
+ * enough that a wedged deck does not hold the terminal. */
38
+ export const STOP_ASK_MS = 2000;
39
+ /** How long a deck gets to actually disappear after each rung. A clean shutdown
40
+ * drains SSE connections and closes the listener; 1500ms of that is the
41
+ * fallback timer in bin/deck.js's shutdown(), so this has to outlast it. */
42
+ export const STOP_GONE_MS = 3000;
43
+
44
+ /**
45
+ * Ask one deck to end itself, politely.
46
+ *
47
+ * Never rejects: every failure is a verdict the caller has a next rung for. A
48
+ * 404 is a deck too old to know the route, a refused connection is a record
49
+ * whose port is already gone, a timeout is a deck that is not answering — and
50
+ * all three lead to the same place.
51
+ */
52
+ export function askDeckToStop(rec, { timeoutMs = STOP_ASK_MS, request = httpRequest } = {}) {
53
+ return new Promise(resolve => {
54
+ let settled = false;
55
+ const finish = (v) => { if (settled) return; settled = true; resolve(v); };
56
+ let req;
57
+ try {
58
+ req = request({
59
+ hostname: "127.0.0.1",
60
+ port: rec.port,
61
+ path: "/api/shutdown",
62
+ method: "POST",
63
+ timeout: timeoutMs,
64
+ headers: {
65
+ // The one spelling the server reads. The token came out of a file
66
+ // only this user can open, which is the whole of the access control.
67
+ "x-ccdeck-token": String(rec.token ?? ""),
68
+ "content-length": "0",
69
+ },
70
+ }, res => {
71
+ // The body is drained rather than read: the verdict is the status, and
72
+ // a response left unconsumed keeps the socket open past our exit.
73
+ res.resume();
74
+ res.on("end", () => finish({
75
+ ok: res.statusCode === 200,
76
+ status: res.statusCode ?? 0,
77
+ // A route that is not there is a deck older than this feature, and
78
+ // the caller says so rather than reporting a mysterious refusal.
79
+ old: res.statusCode === 404,
80
+ }));
81
+ });
82
+ } catch (err) {
83
+ return finish({ ok: false, status: 0, old: false, reason: err?.code ?? "request_failed" });
84
+ }
85
+ req.on("error", (err) => finish({ ok: false, status: 0, old: false, reason: err?.code ?? "unreachable" }));
86
+ req.on("timeout", () => { req.destroy(); finish({ ok: false, status: 0, old: false, reason: "timeout" }); });
87
+ req.end();
88
+ });
89
+ }
90
+
91
+ /**
92
+ * End a pid and everything under it.
93
+ *
94
+ * exec.mjs's killTree with a pid where its ChildProcess goes — see the note at
95
+ * the top of this file. `/T` is the half that matters: the supervisor's worker
96
+ * is a child of the pid being ended, and on Windows there is no process group
97
+ * to signal as one.
98
+ *
99
+ * On Windows a "polite" rung does not exist. `taskkill` without `/F` posts
100
+ * WM_CLOSE to a window, and the deck has none, so the only thing to do there is
101
+ * the forceful one — which is why the caller skips the SIGTERM rung on win32
102
+ * rather than running it twice under a different name.
103
+ */
104
+ export function killPidTree(pid, signal = "SIGTERM", {
105
+ platform = process.platform, spawnFn = spawn, kill = (p, s) => process.kill(p, s),
106
+ } = {}) {
107
+ if (!Number.isInteger(pid) || pid <= 0) return;
108
+ const plain = () => { try { kill(pid, signal); } catch { /* already gone */ } };
109
+ if (platform !== "win32") return plain();
110
+ try {
111
+ const root = process.env.SystemRoot || process.env.systemroot;
112
+ const exe = root ? `${root}\\System32\\taskkill.exe` : "taskkill";
113
+ const killer = spawnFn(exe, ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
114
+ killer.on("error", plain);
115
+ killer.on("exit", (code) => { if (code !== 0) plain(); });
116
+ killer.unref?.();
117
+ } catch {
118
+ plain();
119
+ }
120
+ }
121
+
122
+ /** Has this pid actually gone, within the deadline? Polled rather than waited
123
+ * on: this process is not the parent, so there is no exit to listen for. */
124
+ async function gone(pid, { alive, deadlineMs, sleep, now }) {
125
+ const until = now() + deadlineMs;
126
+ for (;;) {
127
+ if (!alive(pid)) return true;
128
+ if (now() >= until) return false;
129
+ await sleep(50);
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Stop one deck, and say how it went out.
135
+ *
136
+ * `how` is not decoration. "asked" means the deck closed its listener, unlinked
137
+ * its registration and left the LAN cleanly; "killed" means none of that
138
+ * happened and the next boot has litter to sweep. A command that reported both
139
+ * as "stopped" would hide the one case worth knowing about.
140
+ */
141
+ export async function stopDeck(rec, {
142
+ ask = askDeckToStop,
143
+ alive = isProcessAlive,
144
+ kill = killPidTree,
145
+ platform = process.platform,
146
+ now = Date.now,
147
+ sleep = (ms) => new Promise(r => setTimeout(r, ms)),
148
+ askMs = STOP_ASK_MS,
149
+ goneMs = STOP_GONE_MS,
150
+ } = {}) {
151
+ const wait = () => gone(rec.pid, { alive, deadlineMs: goneMs, sleep, now });
152
+ const parent = Number.isInteger(rec.parent) ? rec.parent : null;
153
+
154
+ const answer = await ask(rec, { timeoutMs: askMs });
155
+ if (answer.ok && await wait()) return { ok: true, how: "asked" };
156
+
157
+ if (platform !== "win32") {
158
+ // Parent first: see the note at the top. A supervisor left alive over a
159
+ // killed worker is a supervisor doing its job, which here means undoing
160
+ // ours.
161
+ if (parent !== null) kill(parent, "SIGTERM", { platform });
162
+ kill(rec.pid, "SIGTERM", { platform });
163
+ if (await wait()) return { ok: true, how: "signalled", old: answer.old === true };
164
+ }
165
+
166
+ if (parent !== null) kill(parent, "SIGKILL", { platform });
167
+ kill(rec.pid, "SIGKILL", { platform });
168
+ if (await wait()) return { ok: true, how: "killed", old: answer.old === true };
169
+
170
+ return { ok: false, how: "stuck", reason: answer.reason ?? `http ${answer.status}` };
171
+ }
@@ -53,6 +53,96 @@ export function workerExitAction(code, stopping) {
53
53
  return { relaunch: null, code: ours ? 0 : code ?? 0 };
54
54
  }
55
55
 
56
+ // ── putting a crashed deck back ──────────────────────────────────────────────
57
+ //
58
+ // Before the deck ran in the background, a crash was self-reporting: the
59
+ // terminal came back, the stack was on screen, and you knew within a second.
60
+ // Detached, nothing says anything. The deck stops receiving hook events, stops
61
+ // answering the LAN beacon, stops watching the quota — and the first sign is
62
+ // noticing, hours later, that a day of work was never recorded.
63
+ //
64
+ // So the supervisor puts it back. WITH A CEILING, because the other failure is
65
+ // worse than the one being fixed: a deck that dies on its own boot, respawned
66
+ // forever, is a process spinning on a machine nobody is watching, writing the
67
+ // same stack into the same log a thousand times an hour.
68
+ //
69
+ // The window is what makes the ceiling mean "is this broken" rather than "how
70
+ // long has this machine been up". Five crashes in ten minutes is a deck that
71
+ // cannot run; five crashes over three weeks is a machine that went to sleep
72
+ // oddly three times, and putting it back each time is exactly right.
73
+
74
+ /** Five, then stop and say why. */
75
+ export const CRASH_CEILING = 5;
76
+ /** The window those five are counted in. */
77
+ export const CRASH_WINDOW_MS = 10 * 60 * 1000;
78
+ /** The first wait, doubling per crash inside the window. */
79
+ export const CRASH_BACKOFF_MS = 1000;
80
+ /** …and where the doubling stops. Past this the delay is no longer protecting
81
+ * anything: five attempts at 30s already span most of the window. */
82
+ export const CRASH_BACKOFF_MAX_MS = 30_000;
83
+
84
+ /**
85
+ * Should this crash be answered with another deck, and after how long?
86
+ *
87
+ * `history` is the timestamps of the crashes already answered; the caller adds
88
+ * this one only if the answer is yes, so a refusal does not push the count
89
+ * further out of reach.
90
+ *
91
+ * The backoff doubles rather than waiting a fixed beat, because the two shapes
92
+ * of crash want opposite things. A deck that falls over once an hour should
93
+ * come back immediately — a second of downtime is a second of unrecorded work.
94
+ * A deck that dies in its own first instruction should be tried slowly enough
95
+ * that the log is readable and the CPU is idle between attempts.
96
+ */
97
+ export function crashPolicy(history = [], {
98
+ now = Date.now(),
99
+ ceiling = CRASH_CEILING,
100
+ windowMs = CRASH_WINDOW_MS,
101
+ backoffMs = CRASH_BACKOFF_MS,
102
+ maxMs = CRASH_BACKOFF_MAX_MS,
103
+ } = {}) {
104
+ const recent = history.filter(t => now - t < windowMs);
105
+ if (recent.length >= ceiling) {
106
+ return { restart: false, delayMs: 0, recent: recent.length, history: recent };
107
+ }
108
+ return {
109
+ restart: true,
110
+ delayMs: Math.min(backoffMs * 2 ** recent.length, maxMs),
111
+ recent: recent.length + 1,
112
+ // The pruned list, so a caller that keeps it does not carry crashes from
113
+ // last Tuesday into this decision forever.
114
+ history: [...recent, now],
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Was this a deck falling over, as opposed to one leaving?
120
+ *
121
+ * THREE THINGS HAVE TO BE TRUE, and each one removes a way this could respawn
122
+ * something nobody asked for:
123
+ *
124
+ * It was UP. `served` is the supervisor having seen the worker bind a port. A
125
+ * worker that never got that far did not crash, it failed to start — a port
126
+ * the OS will not give us, a `dist/` that was never built — and retrying that
127
+ * five times prints the same refusal six times and fixes nothing.
128
+ *
129
+ * We are not STOPPING. Ctrl+C, a SIGTERM, and `--stop`'s fallback ladder all
130
+ * set it, and every one of them is somebody asking for this deck to be gone.
131
+ *
132
+ * It did not exit ZERO. That is the deck ending itself — the shutdown that
133
+ * /api/shutdown runs, which is what `ccdeck --stop` asks for. Answering a
134
+ * clean stop with a restart would make the off switch a no-op.
135
+ *
136
+ * A signal counts as a crash. `kill -9` on the worker alone is an OOM killer or
137
+ * a stray hand, not a request to end the deck: `--stop` ends the supervisor
138
+ * FIRST precisely so that this rule and that ladder do not fight.
139
+ */
140
+ export function isCrash({ code, signal, served, stopping } = {}) {
141
+ if (!served || stopping) return false;
142
+ if (signal) return true;
143
+ return code !== 0 && code !== null && code !== undefined;
144
+ }
145
+
56
146
  /**
57
147
  * How to report a worker that did not exit at all but was killed by a signal.
58
148
  *
@@ -240,6 +240,32 @@ export function elapsedSuffix(ms, after = SPINNER_ELAPSED_AFTER_MS) {
240
240
  return ms < after ? "" : ` ${Math.floor(ms / 1000)}s`;
241
241
  }
242
242
 
243
+ /**
244
+ * How long a deck has been up, in the two units that matter and no more.
245
+ *
246
+ * `--status` and `--stop` both print this, and the question behind it is always
247
+ * "is this the deck I started, or one I forgot about" — which "3h 12m" answers
248
+ * and "11543s" does not. Two units, because the second one stops being
249
+ * interesting as soon as the first is large: nobody reading "2d" wants the
250
+ * minutes.
251
+ *
252
+ * Seconds under a minute, because that is the unit a deck started moments ago
253
+ * is measured in and "up just now" is not an answer to "how long". A clock that
254
+ * went backwards, or a `startedAt` from a machine whose time has since been
255
+ * corrected, reads as `0s` rather than as a negative duration.
256
+ */
257
+ export function sinceLabel(ms) {
258
+ const s = Math.floor(Number(ms) / 1000);
259
+ if (!Number.isFinite(s) || s < 0) return "0s";
260
+ if (s < 60) return `${s}s`;
261
+ const m = Math.floor(s / 60);
262
+ if (m < 60) return `${m}m`;
263
+ const h = Math.floor(m / 60);
264
+ if (h < 24) return h > 0 && m % 60 > 0 ? `${h}h ${m % 60}m` : `${h}h`;
265
+ const d = Math.floor(h / 24);
266
+ return h % 24 > 0 ? `${d}d ${h % 24}h` : `${d}d`;
267
+ }
268
+
243
269
  // ── motion ───────────────────────────────────────────────────────────────────
244
270
 
245
271
  /**
@@ -275,9 +301,16 @@ export function visibleWidth(s) {
275
301
  /** How wide the terminal is, or 80 when it will not say — a pipe, a CI runner,
276
302
  * a terminal that never sent SIGWINCH. 80 is the assumption every one of them
277
303
  * is already built around, and the layout below never needs more than 40. */
278
- export function termColumns(stream = process.stdout) {
304
+ export function termColumns(stream = process.stdout, env = process.env) {
279
305
  const c = stream?.columns;
280
- return Number.isInteger(c) && c > 0 ? c : 80;
306
+ if (Number.isInteger(c) && c > 0) return c;
307
+ // COLUMNS, the POSIX spelling of this question, and the only answer a DETACHED
308
+ // deck has. Its stdout is the log file, so the stream knows nothing about a
309
+ // width — but the launcher is watching that file from a real terminal and
310
+ // passes its own width down, so the boot report is laid out for the terminal
311
+ // a person is actually reading it in rather than for the 80 below.
312
+ const n = Number.parseInt(String(env?.COLUMNS ?? "").trim(), 10);
313
+ return Number.isInteger(n) && n > 0 ? n : 80;
281
314
  }
282
315
 
283
316
  /** The status column, computed from the labels rather than counted into each