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 +176 -6
- package/bin/agent-dag.js +125 -4
- package/bin/deck.js +532 -4
- package/dist/web/assets/index-BYMVIG6f.css +1 -0
- package/dist/web/assets/index-G0ntHhBn.js +266 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/server/activity.mjs +52 -0
- package/src/server/args.mjs +27 -0
- package/src/server/auto-update.mjs +79 -0
- package/src/server/claude-accounts.mjs +205 -6
- package/src/server/cswap-admin.mjs +104 -11
- package/src/server/deck-home.mjs +243 -0
- package/src/server/deck-prefs.mjs +88 -8
- package/src/server/deck-probe.mjs +111 -0
- package/src/server/detach.mjs +244 -0
- package/src/server/global-install.mjs +67 -0
- package/src/server/index.mjs +275 -88
- package/src/server/installer.mjs +33 -8
- package/src/server/lan-about.mjs +119 -0
- package/src/server/lan-engine.mjs +142 -9
- package/src/server/lan-socket.mjs +94 -11
- package/src/server/lan-sync.mjs +5 -1
- package/src/server/login-service.mjs +473 -0
- package/src/server/presence.mjs +40 -0
- package/src/server/running-deck.mjs +234 -0
- package/src/server/self-update.mjs +5 -2
- package/src/server/stop-deck.mjs +171 -0
- package/src/server/supervisor.mjs +90 -0
- package/src/server/term.mjs +35 -2
- package/dist/web/assets/index-CE-8EKi6.css +0 -1
- package/dist/web/assets/index-DTbVC1d9.js +0 -266
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
|
|
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:
|
|
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.
|
|
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,
|
|
44
|
+
isNpxInstall, lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure,
|
|
45
|
+
successorRoot,
|
|
44
46
|
} from "../src/server/self-update.mjs";
|
|
45
|
-
import {
|
|
46
|
-
|
|
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
|
-
|
|
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");
|