agent-dag 3.19.0 → 3.21.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  <div align="center">
2
2
 
3
- # ccdeck
3
+ # ccdeck — a live dashboard for Claude Code and Codex
4
4
 
5
5
  **Know which agent is waiting on you, and for how long.**
6
6
 
@@ -22,7 +22,7 @@ npx ccdeck
22
22
 
23
23
  who is blocked on you · tool calls · one canvas · cost · quota · local · no telemetry
24
24
 
25
- [What you get](#what-you-get) · [Quick start](#quick-start) · [How it works](#how-it-works) · [What it touches](#what-it-touches) · [Accounts](#accounts) · [Options](#options)
25
+ [What you get](#what-you-get) · [Quick start](#quick-start) · [How it works](#how-it-works) · [What it touches](#what-it-touches) · [Accounts](#accounts) · [Local network](#local-network) · [Options](#options) · [FAQ](#questions-people-ask)
26
26
 
27
27
 
28
28
  </div>
@@ -43,6 +43,43 @@ ccdeck draws the tree instead. It is local and needs no configuration: it regist
43
43
 
44
44
  One canvas. No tabs. No kanban.
45
45
 
46
+ The deck opens on these eight pictures the first time it runs — they are the whole tour, and `Take the tour` on an empty canvas brings them back.
47
+
48
+ <table>
49
+ <tr>
50
+ <td><img src="assets/guide/welcome-1.svg" width="440" alt="Sessions waiting on you rise to the top, longest wait first."></td>
51
+ <td><img src="assets/guide/welcome-2.svg" width="440" alt="Every agent and subagent is a node. Tool calls light up as they run."></td>
52
+ </tr>
53
+ <tr>
54
+ <td align="center"><sub>Sessions waiting on you rise to the top, longest wait first.</sub></td>
55
+ <td align="center"><sub>Every agent and subagent is a node. Tool calls light up as they run.</sub></td>
56
+ </tr>
57
+ <tr>
58
+ <td><img src="assets/guide/welcome-3.svg" width="440" alt="Click any node: its prompt, every tool call, tokens and timing."></td>
59
+ <td><img src="assets/guide/welcome-4.svg" width="440" alt="What each session costs, and how much quota is left."></td>
60
+ </tr>
61
+ <tr>
62
+ <td align="center"><sub>Click any node: its prompt, every tool call, tokens and timing.</sub></td>
63
+ <td align="center"><sub>What each session costs, and how much quota is left.</sub></td>
64
+ </tr>
65
+ <tr>
66
+ <td><img src="assets/guide/welcome-5.svg" width="440" alt="Cores, memory and heat while the agents run, and what is hogging them."></td>
67
+ <td><img src="assets/guide/welcome-6.svg" width="440" alt="Several Claude accounts: switch, add one, share one to another machine."></td>
68
+ </tr>
69
+ <tr>
70
+ <td align="center"><sub>Cores, memory and heat while the agents run, and what is hogging them.</sub></td>
71
+ <td align="center"><sub>Several Claude accounts: switch, add one, share one to another machine.</sub></td>
72
+ </tr>
73
+ <tr>
74
+ <td><img src="assets/guide/welcome-7.svg" width="440" alt="Your machines repair each other's expired logins over the local network."></td>
75
+ <td><img src="assets/guide/welcome-8.svg" width="440" alt="Run claude or codex in any folder. It shows up here on its own."></td>
76
+ </tr>
77
+ <tr>
78
+ <td align="center"><sub>Your machines repair each other's expired logins over the local network.</sub></td>
79
+ <td align="center"><sub>Run <code>claude</code> or <code>codex</code> in any folder. It shows up here on its own.</sub></td>
80
+ </tr>
81
+ </table>
82
+
46
83
  | | |
47
84
  |---|---|
48
85
  | **Blocked on you** | A permission prompt, or a finished turn waiting for your next instruction, sorts that session to the top of the sidebar with how long it has been stuck — longest wait first, so the oldest block is the first row. A permission prompt also puts a count in the topbar that jumps straight to it. Claude Code only — Codex emits no such signal. |
@@ -52,6 +89,7 @@ One canvas. No tabs. No kanban.
52
89
  | **Click to inspect** | Any node opens its prompt, tool calls, token usage and timing. |
53
90
  | **Survives restarts** | Events are appended to `~/.claude/agent-dag/events.jsonl` and replayed on open. |
54
91
  | **Accounts without a terminal** | Sign a new Claude account in, move one or your whole set to another machine, rename, reorder, remove — from the panel. |
92
+ | **Logins that repair each other** | A Claude login that expires on one of your machines is copied back from another machine on the same network that still has it — see [Local network](#local-network). |
55
93
  | **Knows when it is stale** | Node caches modules at startup, so an upgraded-while-running deck keeps executing old code. This one says so, and can restart itself when nothing is running. |
56
94
  | **Workspace scoping** | `--scope` for the current directory, `--workspace <path>` for any subtree — for Claude Code and Codex alike. |
57
95
 
@@ -61,7 +99,12 @@ One canvas. No tabs. No kanban.
61
99
  npx ccdeck # or: npx agents-deck · npx agent-dag — same deck
62
100
  ```
63
101
 
64
- Opens **http://127.0.0.1:4317** and registers the Claude Code hook on first run. If something else already holds 4317, the deck takes a port between 4318 and 4400 instead and prints the address it ended up on — that line in the terminal is the one to trust. Start any Claude Code or Codex session and the graph fills in live. `Ctrl+C` stops it.
102
+ Opens **http://127.0.0.1:4317**, shows an eight-picture tour the first time, and registers the Claude Code hook on first run. If something else already holds 4317, the deck takes a port between 4318 and 4400 instead and prints the address it ended up on — that line in the terminal is the one to trust. Start any Claude Code or Codex session and the graph fills in live.
103
+
104
+ The deck keeps running after you close the terminal, and starts again when you
105
+ log in. **`ccdeck --stop` is the off switch**; `Ctrl+C` only cancels a start that
106
+ is still printing. `ccdeck --status` says what is running, and `--foreground`
107
+ holds the terminal the way every version before 3.20 did.
65
108
 
66
109
  No config file. No account. No telemetry — nothing about your sessions is reported anywhere.
67
110
 
@@ -158,6 +201,37 @@ An import adds what is missing and leaves a working account exactly as it is. Th
158
201
 
159
202
  Renaming, reordering and removing are on the same row menu. Removal takes two clicks and cannot be undone.
160
203
 
204
+ ## Local network
205
+
206
+ A Claude login expires on the machine that has not used it for a while, and stays alive on the one that has. With two or three machines that is a `claude login` a week, each time on the machine you are not sitting at. **Local network** is the last section of the Claude accounts panel, and it is that chore done for you: decks on one network find each other, pair, and a login that has expired on one is copied back from another that still has it.
207
+
208
+ Four pictures, which are also the guide the section opens from `See how it works`:
209
+
210
+ <table>
211
+ <tr>
212
+ <td><img src="assets/guide/lan-1.svg" width="440" alt="A login can expire on one machine while it still works on another."></td>
213
+ <td><img src="assets/guide/lan-2.svg" width="440" alt="Turn this on, on both machines. They find each other and pair."></td>
214
+ </tr>
215
+ <tr>
216
+ <td align="center"><sub><b>1.</b> A login can expire on one machine while it still works on another.</sub></td>
217
+ <td align="center"><sub><b>2.</b> Turn this on, on both machines. They find each other and pair.</sub></td>
218
+ </tr>
219
+ <tr>
220
+ <td><img src="assets/guide/lan-3.svg" width="440" alt="Tick which logins this machine may hand out. Nothing is shared until you do."></td>
221
+ <td><img src="assets/guide/lan-4.svg" width="440" alt="An expired login is copied from a paired machine within a minute."></td>
222
+ </tr>
223
+ <tr>
224
+ <td align="center"><sub><b>3.</b> Tick which logins this machine may hand out. Nothing is shared until you do.</sub></td>
225
+ <td align="center"><sub><b>4.</b> An expired login is copied from a paired machine within a minute.</sub></td>
226
+ </tr>
227
+ </table>
228
+
229
+ **On each machine, in order:** open the Claude accounts panel (`A`), switch **Local network** on, and in the dialog that opens tick the logins this machine may hand out. That is all — the two decks find each other over a UDP beacon on port 45317, pair on their own, and from then on every paired deck is asked once a minute for anything this deck's expired logins need.
230
+
231
+ **When they do not find each other** — a VPN, a guest network, two subnets — `+` at the top of the section reaches a deck by address, or with an invite the other deck minted. An invite is the route when the machine that cannot be seen is this one.
232
+
233
+ **What is shared, and with whom.** Nothing until you tick a login, and then only that login, and only with decks somebody at this machine accepted — the deck asks the machines it finds and says yes to the ones that ask, and both switches are in the same dialog if you would rather press `accept` yourself. A paired deck can fill an *expired* slot of this deck's and nothing else: it cannot overwrite a login that still works here. What crosses the wire is the same live credential a share carries, sealed to the deck it is addressed to, so treat the pairing decision as the moment that matters. Unpairing stops future rounds; a login already copied stays where it went.
234
+
161
235
  ## Options
162
236
 
163
237
  ```
@@ -165,11 +239,22 @@ ccdeck [options]
165
239
 
166
240
  -p, --port <number> Preferred port (default: 4317; fallback: random 4318–4400)
167
241
  --no-open Don't open the browser automatically
242
+ --foreground Hold the terminal, the way every version before 3.20
243
+ did (Ctrl+C stops the deck again)
244
+ --new Start a second deck even if one is already running
245
+ --stop Stop the deck a bare `ccdeck` would open
246
+ (--port <n> stops that one; --all stops every deck)
247
+ --status What is running on this machine, and on which ports
248
+ --logs What a backgrounded deck wrote where a terminal would
249
+ have shown it
168
250
  --workspace <path> Only capture sessions whose cwd is inside <path>
169
251
  --scope Restrict to the current working directory
170
252
  --all Capture every session on this machine (default)
253
+ (beside --stop: every deck, not every session)
171
254
  --history <path> Override the events log file
172
- (default: ~/.claude/agent-dag/events.jsonl)
255
+ (default: this platform's log directory —
256
+ ~/Library/Logs/ccdeck on macOS, %LOCALAPPDATA%\ccdeck\Log
257
+ on Windows, $XDG_STATE_HOME/ccdeck on Linux)
173
258
  --no-persist RAM-only mode — don't write or replay the log
174
259
  --codex Force Codex capture even if ~/.codex/ is missing
175
260
  --no-codex Skip Codex capture (Claude only)
@@ -185,6 +270,57 @@ Anything else on the command line is reported as an unknown option and then
185
270
  ignored — the deck still starts, so a typo costs you a warning rather than a
186
271
  dashboard.
187
272
 
273
+ `--foreground` is there for anything that was relying on the old behaviour — a
274
+ wrapper script, a CI step, a supervisor of your own that starts the deck and
275
+ waits on it. On the first run after upgrading you may also see a line about a
276
+ deck from an older version still running: those publish nothing this one can
277
+ check, so it starts beside them rather than attaching to something it cannot
278
+ identify, and `ccdeck --stop --all` clears them.
279
+
280
+ `ccdeck` runs in the background. The boot report prints in your terminal exactly
281
+ as it always has — the hooks, the port, the URL — and then the prompt comes back
282
+ and the deck stays up: closing the window does not take it with you any more,
283
+ because it is in its own process group and the terminal's hangup never reaches
284
+ it. `ccdeck --stop` ends it, `ccdeck --status` says what is running, and
285
+ `ccdeck --logs` shows what it wrote after you stopped watching. Ctrl+C while the
286
+ boot is still printing cancels the start, which is the one thing it still means.
287
+
288
+ One Windows exception, and it is `ssh`'s rather than the deck's: OpenSSH puts a
289
+ session's processes in a job object that it kills when the session ends, and
290
+ nothing started from inside one survives it. A deck started from an ordinary
291
+ Windows terminal, or by the login task below, keeps running — measured on a real
292
+ machine, from a second SSH session, still serving.
293
+
294
+ It also starts when you log in, so a reboot does not cost you the morning's
295
+ events. That is set up once, on the first run, and said out loud when it happens;
296
+ `ccdeck --uninstall-service` undoes it and `ccdeck --uninstall` takes it with the
297
+ hooks. A launchd agent on macOS, a `systemd --user` unit on Linux, a Task
298
+ Scheduler logon task on Windows — none of them carrying a restart policy of its
299
+ own, because the one that decides when a crashed deck stops coming back lives in
300
+ ccdeck and two policies over one process is how a stop becomes a suggestion. An
301
+ `npx` run never installs one: it would name a path inside npm's cache, which npm
302
+ deletes without warning. On Linux, `systemd --user` is torn down at logout unless
303
+ `loginctl enable-linger` is on for your account — the install says so rather than
304
+ changing that for you.
305
+
306
+ If the deck falls over on its own, it comes back — five times in ten minutes,
307
+ with the wait doubling each time, and then it stops and says so in the log rather
308
+ than spinning on a machine nobody is watching. A clean `--stop` is never answered
309
+ with a restart, and neither is a deck that failed to start in the first place:
310
+ retrying a port the OS will not give you just prints the same refusal six times.
311
+
312
+ Typing plain `ccdeck` beside a deck that is already running opens **that deck's
313
+ tab** rather than building a second one. It used to build the second one: port
314
+ 4317 was busy, so the new deck took a random port out of 4318–4400 and stood
315
+ there beside a perfectly healthy first one, and neither mentioned the other.
316
+ The port fallback is still there — 4317 is also the standard OTLP collector
317
+ port, and Windows reserves whole TCP ranges for Hyper-V, WSL2 and Docker — but
318
+ it now runs only when the thing holding the port is *not* one of your decks. The
319
+ deck on the port has to prove it is yours, with the same token handshake the
320
+ hooks use, before its tab is opened. Any flag that changes what the deck **is**
321
+ — a port, a workspace, a log, either `--codex` or `--claude` pair — still starts
322
+ its own, and `--new` starts one unconditionally.
323
+
188
324
  ccdeck looks for each CLI before it does anything on that CLI's behalf. Claude
189
325
  Code counts as present when its binary is on `PATH` (or in one of the places its
190
326
  installers put it), or when its config dir carries traces of having been used;
@@ -225,7 +361,7 @@ Being told to restart after an upgrade is local only — no network involved —
225
361
  npx ccdeck --uninstall
226
362
  ```
227
363
 
228
- Removes every hook entry ccdeck injected from `~/.claude/settings.json`, and `~/.codex/hooks.json` if present.
364
+ Removes every hook entry ccdeck injected from `~/.claude/settings.json`, and `~/.codex/hooks.json` if present — and the login item, if this machine had one. That one exception to the narrowness below is deliberate: a login item left behind would keep starting a deck whose hooks had just been removed.
229
365
 
230
366
  It removes the hook entries and nothing else. The forwarder script
231
367
  (`~/.claude/agent-dag/hook.js`), the discovery directory around it, the events
@@ -253,7 +389,7 @@ Nothing is ever installed unless you click, the argument vector is fixed in the
253
389
 
254
390
  ### Restarting
255
391
 
256
- ccdeck runs as a two-process pair: a supervisor that owns nothing but the lifecycle, and the deck itself. When newer code is found, the deck exits with code 75 and the supervisor brings it back **on the port it actually bound**, which is not always the one it asked for. Ctrl+C, stdout and exit codes behave exactly as before — same terminal, same process group.
392
+ ccdeck runs as a two-process pair: a supervisor that owns nothing but the lifecycle, and the deck itself. When newer code is found, the deck exits with code 75 and the supervisor brings it back **on the port it actually bound**, which is not always the one it asked for. Both live in their own process group since 3.20, so stdout goes to `deck.log` rather than to the terminal you started from — `ccdeck --logs` reads it back. The supervisor also puts the deck back after a crash, five times in ten minutes with the wait doubling, and then stops and says why rather than spinning.
257
393
 
258
394
  It restarts on its own only after 30 seconds with nothing running, because hook events are fire-and-forget and anything fired during the gap is lost. The toggle in the banner turns that off; the preference is per-browser. Under `--no-persist` a restart is refused outright — with no event log there is nothing to replay, and the canvas would be gone.
259
395
 
@@ -287,6 +423,40 @@ It is now the same build as the other two.
287
423
  The repository was previously named `agents-deck`; the old URL redirects here,
288
424
  so existing clones, links and bookmarks keep working.
289
425
 
426
+ ## Questions people ask
427
+
428
+ **Does it work with Codex, or only Claude Code?**
429
+ Both, on one canvas. Claude Code arrives through a hook, Codex through its
430
+ rollout log, and the model chip tells them apart. The *blocked on you* queue is
431
+ Claude Code only — Codex emits no signal for it.
432
+
433
+ **Does anything leave my machine?**
434
+ Your sessions, never. The deck binds `127.0.0.1` and has no telemetry. The only
435
+ outbound requests are a ~20-byte version check to the npm registry at most once
436
+ an hour, and whatever the usage panels ask Anthropic and OpenAI for about your
437
+ own quota. [What it touches](#what-it-touches) lists all of it.
438
+
439
+ **Can it interfere with what my agent does?**
440
+ No. The hook it installs is a one-way forwarder: it POSTs the event, exits `0`,
441
+ and writes nothing to stdout — the two channels Claude Code's hook protocol
442
+ gives a hook for allowing, denying or rewriting a tool call. It uses neither, so
443
+ it has no way to answer at all, and a test pins both halves.
444
+
445
+ **Does it need an account, an API key or a config file?**
446
+ None of the three. `npx ccdeck` and it runs.
447
+
448
+ **macOS, Linux, Windows?**
449
+ All three, and each release is tested on all three.
450
+
451
+ **What is the difference between `ccdeck`, `agents-deck` and `agent-dag`?**
452
+ One deck, three names on npm. `ccdeck` is the one to use; the other two are the
453
+ names it shipped under before, kept working so nobody's command breaks. See
454
+ [Names](#names).
455
+
456
+ **Do I have to keep a terminal open?**
457
+ No. Since 3.20 the deck runs in the background, survives the terminal closing,
458
+ and starts again when you log in. `ccdeck --stop` ends it.
459
+
290
460
  ## License
291
461
 
292
462
  MIT © [Bargan Constantin](https://github.com/BarganConstantin)
package/bin/agent-dag.js CHANGED
@@ -37,13 +37,19 @@ import { dirname, join } from "node:path";
37
37
  import { fileURLToPath } from "node:url";
38
38
  import { killTree } from "../src/server/exec.mjs";
39
39
  import { invokedAs } from "../src/server/invoked-as.mjs";
40
+ import { isOneShot, parseArgs } from "../src/server/args.mjs";
40
41
  import { npxFailureHint, npxFailureSummary, npxLaunch, npxPrefetch } from "../src/server/npx.mjs";
41
42
  import {
42
43
  bareSpecName, claimRestartFailureKey, clearRestartFailure, installedName, installedVersion,
43
- lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure, successorRoot,
44
+ isNpxInstall, lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure,
45
+ successorRoot,
44
46
  } from "../src/server/self-update.mjs";
45
- import { dieOfSignal, dieWithParent, replacedNote, upgradeAttempt, upgradeRefusalText, workerExitAction } from "../src/server/supervisor.mjs";
46
- import { colorProfile, glyphs, palette, unicodeOK } from "../src/server/term.mjs";
47
+ import {
48
+ CRASH_CEILING, CRASH_WINDOW_MS, crashPolicy, dieOfSignal, dieWithParent, isCrash, replacedNote,
49
+ upgradeAttempt, upgradeRefusalText, workerExitAction,
50
+ } from "../src/server/supervisor.mjs";
51
+ import { colorProfile, glyphs, palette, termColumns, unicodeOK } from "../src/server/term.mjs";
52
+ import { DETACHED_ENV, detachAndWatch, stopCommand } from "../src/server/detach.mjs";
47
53
  import { PRODUCT } from "../src/server/brand.mjs";
48
54
 
49
55
  const BIN_DIR = dirname(fileURLToPath(import.meta.url));
@@ -77,6 +83,72 @@ const G = glyphs(unicodeOK());
77
83
  // update reported someone else's failed npx as its own. Our pid is unique among
78
84
  // the decks alive on the machine, and putting it in our own environment is what
79
85
  // carries it to the worker: launch() spawns with a copy of it.
86
+ // ── the terminal stops being the deck's leash ────────────────────────────────
87
+ //
88
+ // Everything about why is in src/server/detach.mjs. Here is only the decision,
89
+ // and it has exactly two ways out:
90
+ //
91
+ // ALREADY DETACHED — we ARE the background copy. Carry on as this file always
92
+ // has: spawn the worker, supervise it, never come back here.
93
+ //
94
+ // A ONE-SHOT — `--version`, `--stop`, `--status`, `--help`, `--uninstall`.
95
+ // Those answer and leave, and a one-shot that detached would print its answer
96
+ // into a log file and hand the terminal back empty. They run in the
97
+ // foreground exactly as they always have.
98
+ //
99
+ // Anything else is a start, and a start goes to the background.
100
+ const DETACHED = process.env[DETACHED_ENV] === "1";
101
+ // A parent already holding our lifecycle. `process.send` exists only when
102
+ // somebody spawned us with an IPC channel, and that somebody has armed
103
+ // dieWithParent below and is waiting on our exit code — running away from them
104
+ // into our own process group is precisely the wrong answer to being supervised.
105
+ // The suite's spawnSupervised is the caller that does this today.
106
+ const LEASHED = typeof process.send === "function";
107
+ // And the way to ask for the old behaviour out loud.
108
+ //
109
+ // Every version before this one held the terminal, and something out there
110
+ // depends on that: a wrapper script, a CI step, a supervisor of somebody else's
111
+ // that starts `ccdeck` and waits on it, a `ccdeck && open …`. Handing all of
112
+ // those an immediate exit and no way to say otherwise would be a breaking change
113
+ // with no escape hatch — and the marker above is an internal one, not something
114
+ // to tell a user to export.
115
+ const FLAGS = parseArgs(process.argv.slice(2));
116
+ if (!DETACHED && !LEASHED && FLAGS.foreground !== true && !isOneShot(FLAGS)) {
117
+ const { deckLogDir } = await import("../src/server/deck-home.mjs");
118
+ const { registeredDecks } = await import("../src/server/running-deck.mjs");
119
+ const isTTY = Boolean(process.stdout.isTTY);
120
+ const profile = colorProfile({ isTTY });
121
+ const tone = palette(profile);
122
+ const npx = isNpxInstall(PKG_ROOT);
123
+ const stop = stopCommand({ npx, invokedAs: INVOKED_AS, product: PRODUCT });
124
+ // THE ONE THING AN NPX RUN CANNOT HAVE, said where it is missing. A login item
125
+ // must name a path that will still be there tomorrow, and npx runs out of a
126
+ // cache npm deletes whenever it likes — so an npx deck runs in the background
127
+ // and cannot come back after a reboot. One line, no disk written, offered
128
+ // rather than done: `npx` means "run without installing", and a tool that
129
+ // installs itself anyway is the one people uninstall.
130
+ const offer = npx
131
+ ? ` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --install\` also starts it at login${tone.reset}\n`
132
+ : "";
133
+ const outcome = await detachAndWatch({
134
+ file: fileURLToPath(import.meta.url),
135
+ argv: process.argv.slice(2),
136
+ logDir: deckLogDir(),
137
+ // Only the count, and only to decide whether deck.log is anybody's — see
138
+ // logMode. No handshake: this is a directory listing and a signal-0 each.
139
+ liveCount: (await registeredDecks().catch(() => [])).length,
140
+ isTTY,
141
+ profile,
142
+ columns: termColumns(process.stdout),
143
+ backgroundLine: ` ${tone.muted}${G.dash} running in the background ${G.bullet} \`${stop}\` ends it${tone.reset}\n${offer}\n`,
144
+ });
145
+ // detachAndWatch never returns on the paths that worked. Reaching this line
146
+ // means the log could not be opened at all — a read-only home, a full disk —
147
+ // and a deck that will not start over a LOG is a worse answer than one that
148
+ // stays in the terminal, which is what every version before this did anyway.
149
+ console.error(`${PRODUCT}: could not open ${PRODUCT}'s log (${outcome.reason}) ${G.dash} staying in the foreground.`);
150
+ }
151
+
80
152
  claimRestartFailureKey();
81
153
 
82
154
  // The port the worker actually bound, which is not necessarily the one it was
@@ -100,6 +172,11 @@ let attempting = null;
100
172
  // worker that was already exiting 75 or 76 when the signal landed reads as a
101
173
  // restart request — both resurrect the deck the user just stopped.
102
174
  let stopping = false;
175
+ // When this supervisor has put a crashed worker back, pruned to the window the
176
+ // ceiling is counted in. See crashPolicy: a deck that dies once an hour comes
177
+ // back every time; one that dies five times in ten minutes is broken, and the
178
+ // answer to broken is to stop and say so rather than to spin.
179
+ let crashes = [];
103
180
 
104
181
  function launch(respawn) {
105
182
  // Asked on every spawn rather than once at boot: the deletion this catches
@@ -146,6 +223,21 @@ function launch(respawn) {
146
223
  worker.on("message", (m) => {
147
224
  if (!m || typeof m !== "object") return;
148
225
  if (m.type === "listening" && typeof m.port === "number") boundPort = m.port;
226
+ // The worker saying its boot is finished — every row printed, the browser
227
+ // launched. Forwarded to whoever detached us, who has been tailing the log
228
+ // into the user's terminal and is waiting for exactly this to stop.
229
+ //
230
+ // GUARDED BY `process.connected`, AND GIVEN A CALLBACK, and it took a real
231
+ // crash restart to find out why: the launcher disconnects the moment the
232
+ // first boot finishes, so every LATER boot forwards this down a dead
233
+ // channel — and `process.send` on a closed channel does not throw where the
234
+ // call is, it emits 'error' on `process` a tick later. Unhandled, that ends
235
+ // the supervisor. So a try/catch here was decoration: the guard is the
236
+ // check, and the callback is what turns the remaining race (disconnect
237
+ // between the check and the send) into a value nobody has to catch.
238
+ else if (m.type === "booted" && process.connected) {
239
+ try { process.send({ type: "booted" }, () => {}); } catch { /* the launcher left */ }
240
+ }
149
241
  // The worker asking to be replaced, while it is still serving. Answered by
150
242
  // prefetchUpgrade, which is the whole of this file's new shape: the fetch
151
243
  // happens here, and only then does that worker exit — see UPGRADE_CODE.
@@ -173,6 +265,32 @@ function launch(respawn) {
173
265
  launchNpx();
174
266
  return;
175
267
  }
268
+ // THE DECK FELL OVER, and nobody is watching it any more.
269
+ //
270
+ // Before this ran in the background a crash was self-reporting: the terminal
271
+ // came back with the stack on it. Detached, the first sign is noticing hours
272
+ // later that a day of work was never recorded — so it goes back up. The
273
+ // whole of which crashes qualify is in isCrash, and the ceiling that stops
274
+ // this becoming a spin loop is in crashPolicy.
275
+ if (isCrash({ code, signal, served: boundPort != null, stopping })) {
276
+ const verdict = crashPolicy(crashes);
277
+ if (verdict.restart) {
278
+ crashes = verdict.history;
279
+ const how = signal ? `killed by ${signal}` : `exit ${code}`;
280
+ console.error(`${PRODUCT}: the deck stopped on its own (${how}) ${G.dash} starting it again in ${Math.round(verdict.delayMs / 1000)}s (${verdict.recent}/${CRASH_CEILING}).`);
281
+ restarts++;
282
+ // Unref'd would be wrong here: this timer IS the supervisor's reason to
283
+ // stay alive, and without it the event loop empties and the process
284
+ // exits before the deck it promised to bring back.
285
+ setTimeout(() => { if (!stopping) launch(true); }, verdict.delayMs);
286
+ return;
287
+ }
288
+ // Said once, with the two numbers that make it actionable, and then this
289
+ // process really does end — a supervisor that keeps trying forever is the
290
+ // failure the ceiling exists to prevent.
291
+ console.error(`${PRODUCT}: the deck has stopped ${CRASH_CEILING} times in ${Math.round(CRASH_WINDOW_MS / 60000)} minutes ${G.dash} not starting it again. Run \`${INVOKED_AS ?? PRODUCT}\` when you have looked at the log above.`);
292
+ }
293
+
176
294
  // Anything else is the worker's own verdict and belongs to whoever started
177
295
  // us — including the ccdeck wrapper, which exits with our code in turn. A
178
296
  // worker killed by a signal is reported by dying of the same one; doing
@@ -492,7 +610,10 @@ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
492
610
  // worker's exit is not read as a request to bring it back; the kill is the
493
611
  // signal on POSIX and, through killTree, `taskkill /T /F` on Windows, which has
494
612
  // neither SIGTERM nor a process group to aim at.
495
- dieWithParent(() => {
613
+ // NOT IN THE DETACHED COPY. There the parent is the launcher, which disconnects
614
+ // and exits the moment the deck is up — by design — and arming this would have
615
+ // the deck kill itself a second after every successful start.
616
+ if (!DETACHED) dieWithParent(() => {
496
617
  stopping = true;
497
618
  if (fetching) { killTree(fetching, "SIGTERM"); fetching = null; }
498
619
  if (child) killTree(child, "SIGTERM");