ccdeck 3.36.9 → 3.37.1

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.
Files changed (75) hide show
  1. package/README.md +15 -7
  2. package/bin/agent-dag.js +19 -6
  3. package/bin/cli/help.js +1 -0
  4. package/bin/cli/login-item.js +44 -20
  5. package/bin/cli/one-shot.js +15 -5
  6. package/bin/cli/screen.js +10 -5
  7. package/bin/cli/startup.js +6 -2
  8. package/bin/cli/uninstall.js +41 -5
  9. package/bin/deck.js +50 -8
  10. package/dist/web/assets/BrowserWatchModal-njN0zg93.js +1 -0
  11. package/dist/web/assets/UsageHistoryModal-DYSZZZEz.js +1 -0
  12. package/dist/web/assets/index-B0SOa-XB.css +1 -0
  13. package/dist/web/assets/index-DDxnWaTa.js +306 -0
  14. package/dist/web/index.html +2 -2
  15. package/hook/hook.js +80 -9
  16. package/package.json +1 -1
  17. package/release-notes.json +8 -1
  18. package/src/server/account-origins.mjs +8 -1
  19. package/src/server/account-projects.mjs +34 -9
  20. package/src/server/activation.mjs +39 -10
  21. package/src/server/app-host.mjs +7 -2
  22. package/src/server/args.mjs +3 -0
  23. package/src/server/browser-watch-routes.mjs +23 -16
  24. package/src/server/browser-watch.mjs +9 -3
  25. package/src/server/claude-accounts.mjs +27 -7
  26. package/src/server/claude-verdicts.mjs +12 -1
  27. package/src/server/cli-versions.mjs +6 -4
  28. package/src/server/codex-auth.mjs +29 -0
  29. package/src/server/codex-quota.mjs +57 -7
  30. package/src/server/deck-home.mjs +23 -1
  31. package/src/server/deck-prefs.mjs +70 -7
  32. package/src/server/depth-facts.mjs +178 -0
  33. package/src/server/detach.mjs +61 -33
  34. package/src/server/event-log.mjs +15 -0
  35. package/src/server/event-pipeline.mjs +5 -2
  36. package/src/server/event-routes.mjs +22 -0
  37. package/src/server/feedback-form.mjs +36 -13
  38. package/src/server/index.mjs +6 -2
  39. package/src/server/install-layout.mjs +35 -0
  40. package/src/server/install-ref.mjs +39 -0
  41. package/src/server/installer.mjs +20 -2
  42. package/src/server/lan-beacon.mjs +18 -3
  43. package/src/server/lan-deck.mjs +7 -1
  44. package/src/server/lan-engine.mjs +40 -21
  45. package/src/server/lan-manifest.mjs +9 -6
  46. package/src/server/lan-requests.mjs +18 -2
  47. package/src/server/lan-serve.mjs +4 -2
  48. package/src/server/lan-sync.mjs +97 -6
  49. package/src/server/lifecycle.mjs +18 -2
  50. package/src/server/log-replay.mjs +9 -3
  51. package/src/server/login-service.mjs +95 -12
  52. package/src/server/npm-upgrade.mjs +12 -4
  53. package/src/server/pinned-build.mjs +3 -0
  54. package/src/server/prefs-routes.mjs +5 -8
  55. package/src/server/prefs-state.mjs +9 -1
  56. package/src/server/quota-oauth.mjs +88 -13
  57. package/src/server/quota-shape.mjs +28 -0
  58. package/src/server/quota.mjs +68 -17
  59. package/src/server/rating.mjs +87 -0
  60. package/src/server/reports-routes.mjs +21 -2
  61. package/src/server/reports.mjs +672 -64
  62. package/src/server/ring-bounds.mjs +11 -0
  63. package/src/server/running-deck.mjs +48 -0
  64. package/src/server/self-update.mjs +1 -1
  65. package/src/server/session-enrichment.mjs +14 -6
  66. package/src/server/stable-node.mjs +68 -0
  67. package/src/server/static-serve.mjs +5 -0
  68. package/src/server/supervisor.mjs +21 -15
  69. package/src/server/tailscale.mjs +8 -4
  70. package/src/server/transcript-scan.mjs +7 -4
  71. package/src/server/usage-day.mjs +76 -11
  72. package/dist/web/assets/BrowserWatchModal-BCrDvuug.js +0 -1
  73. package/dist/web/assets/UsageHistoryModal-BtRCuo33.js +0 -1
  74. package/dist/web/assets/index-BkXTN4dZ.css +0 -1
  75. package/dist/web/assets/index-tVBMXvDv.js +0 -306
package/README.md CHANGED
@@ -119,7 +119,7 @@ log in. **`ccdeck --stop` is the off switch**; `Ctrl+C` only cancels a start tha
119
119
  is still printing. `ccdeck --status` says what is running, and `--foreground`
120
120
  holds the terminal the way every version before 3.20 did.
121
121
 
122
- No config file. No account. Nothing about your sessions is reported anywhere — the deck does send usage reports (its version, your system, your IP address and a device fingerprint, the errors it hits), on by default, and `AGENTS_DECK_NO_REPORTS=1` turns them off.
122
+ No config file. No account. What your sessions contain — your prompts, the replies, the files they touch — is never reported anywhere. The deck does send usage reports, on by default: its version, your system, your IP address and a device fingerprint, rough counts such as how many sessions ran in a day, and the errors it hits. It has no switch for them; `AGENTS_DECK_NO_REPORTS=1` keeps them off from the first start.
123
123
 
124
124
  **The deck cannot steer your agent.** The hook it installs is a one-way forwarder: it POSTs the event, exits `0`, and writes nothing to stdout. Those are the two channels Claude Code's hook protocol gives a hook for allowing, denying, deferring or rewriting the tool call it was told about, and this one uses neither — it has no way to answer at all. `src/web/__tests__/hook-read-only.test.ts` pins both halves, over the source and by running the real script.
125
125
 
@@ -129,7 +129,7 @@ Step by step: [Install ccdeck with npx and see your first session](https://ccdec
129
129
 
130
130
  ### Desktop app
131
131
 
132
- The same deck as an app: it starts the deck itself and puts an icon in the menu bar (the tray on Windows and Linux) that counts the Claude Code sessions stopped on a permission prompt or a question — the number sits beside the icon on macOS, and in the icon's tooltip and menu on Windows and Linux. With **Notifications while closed** ticked in that menu, which starts off, it also sends a notification while the window is closed wherever an open page would have played a sound, with the deck's own tone on macOS. It needs no Node.js. If a deck from `npx ccdeck` is already running, the app uses that one rather than starting a second, and replaces it only when it is older than the one the app carries.
132
+ The same deck as an app: it starts the deck itself and puts an icon in the menu bar (the tray on Windows and Linux) that counts the Claude Code sessions stopped on a permission prompt or a question — the number sits beside the icon on macOS, and in the icon's tooltip and menu on Windows and Linux. With **Notifications while closed** ticked in that menu, which starts off, it also sends a notification while the window is closed wherever an open page would have played a sound, with the deck's own tone on macOS. It needs no Node.js. The deck it starts is given whichever of the variables in the environment table further down your login shell sets — `AGENTS_DECK_NO_REPORTS` and `CLAUDE_CONFIG_DIR` among them — however the app was opened, as a deck started from a terminal would be. If a deck from `npx ccdeck` is already running, the app uses that one rather than starting a second, and replaces it only when it is older than the one the app carries.
133
133
 
134
134
  | System | Download |
135
135
  | --- | --- |
@@ -208,13 +208,13 @@ Quota is the one thing that is not just reading. It needs a live token, so when
208
208
 
209
209
  It never steers an agent or edits your code, but it is not read-only either — besides the hook entry and its own event log, it manages the two tools it leans on, and it refreshes the Codex token it reads quota with, rewriting `~/.codex/auth.json` the way `codex` itself does. It also reads your browser's history, because Browser Watch is on unless you switch it off; that is its own section below, because it is the one thing here that is about you rather than about an agent.
210
210
 
211
- What does go out is short and ordinary: a ~20-byte version check against the npm registry (plus one small request to confirm a version it has not seen before), installs and daily version checks for the two tools the deck manages (claude-swap from PyPI, ccusage from npm), on an Apple Silicon Mac whose sensors stay silent one release lookup and one binary download from GitHub for `macmon`, and, while the page is open, quota reads to Anthropic and OpenAI signed with your own credentials — that is where those numbers live. While the page is open, and while the desktop app has a session running or waiting, it also reads Anthropic's and OpenAI's public status pages (`status.claude.com`, `status.openai.com`), unsigned, about every three minutes, and once a minute at most while a page is not answering, so an outage on their side shows as a chip in the topbar instead of looking like a fault on yours. The [desktop app](#desktop-app) also asks this repository's GitHub releases for its own updates. With **Local network** on, which it is unless you switch it off, the deck also announces itself to other decks on your local network over UDP (port 45317) and pairs with the ones that answer — see [Local network](#local-network). The deck also sends usage reports to `api.ccdeck.dev`: an install event, an update event, one "active" a day, one "uninstall" when you run `ccdeck --uninstall` (which asks, at a terminal, one skippable question about why, from a short list), one "activated" when a new install's first session arrives (how long after the install, in rough buckets such as "under five minutes", and whether it was a Claude or a Codex session), and the errors it runs into with folders, addresses and keys scrubbed out. Each carries the version, your OS and CPU architecture, the channel (desktop, npm or a source checkout), the runtime, your IP address and a device fingerprint (a stable hashed machine id), and the machine's rough shape — the number of CPU cores, the amount of RAM, the locale, the shell, the terminal, and the Claude Code and Codex versions — and whether the Claude hooks went in and Codex is being watched, with the daily "active" also carrying how many sessions, subagents and projects you ran on the last day you used the deck, that day's date, and which of the deck's own features you used that day — fixed names such as "usage history" or "account switch", a yes for each and nothing about what you did with them. The fingerprint is a one-way hash of stable machine traits, never those traits in the clear; everything else is coarse counts and short tokens — no path, no project name, no free text. These reports are no longer anonymous: the install id is random, but the fingerprint follows this machine and the server records the IP the report arrives from. What still never leaves is your sessions, prompts, files, project names and paths. The deck has no switch for them; `AGENTS_DECK_NO_REPORTS=1` keeps them off from the first start. While the deck runs it also sends a tiny heartbeat every few minutes, the install id and nothing else, so the count of decks running right now can be seen. **Send feedback** (the topbar's Feedback button) sends what you write, and any screenshots you add, to the same place, where the people who make ccdeck read it and may open a public issue from it, never with the contact you gave or your screenshots. `AGENTS_DECK_NO_INSTALL=1` turns off everything but the quota reads; `AGENTS_DECK_NO_DOWNLOAD=1` is the narrower version — no `uv` binary is fetched, the managed installs stay.
211
+ What does go out is short and ordinary: a ~20-byte version check against the npm registry (plus one small request to confirm a version it has not seen before), installs and daily version checks for the two tools the deck manages (claude-swap from PyPI, ccusage from npm), on an Apple Silicon Mac whose sensors stay silent one release lookup and one binary download from GitHub for `macmon`, and, while the page is open, quota reads to Anthropic and OpenAI signed with your own credentials — that is where those numbers live. While the page is open, and while the desktop app has a session running or waiting, it also reads Anthropic's and OpenAI's public status pages (`status.claude.com`, `status.openai.com`), unsigned, about every three minutes, and once a minute at most while a page is not answering, so an outage on their side shows as a chip in the topbar instead of looking like a fault on yours. While the machine panel is open, on a machine with Claude Code, it times the way to Claude every thirty seconds — a DNS lookup of `api.anthropic.com` and a TCP handshake on port 443, with nothing sent over it — for the panel's latency reading. While the Browser Watch panel is open, it looks up the address of Claude's browser relay, `bridge.claudeusercontent.com`, about every thirty seconds — a DNS query to your resolver — to tell whether a running browser holds a connection to it. The [desktop app](#desktop-app) also asks this repository's GitHub releases for its own updates. With **Local network** on, which it is unless you switch it off, the deck also announces itself to other decks on your local network over UDP (port 45317) and pairs with the ones that answer — see [Local network](#local-network). The deck also sends usage reports to `api.ccdeck.dev`: an install event (naming the ccdeck.dev page the command was copied from, when the command says so with `--ref`, which the site's copy buttons add; a page name and nothing else, sent once), an update event (saying how the new version arrived: the deck's own update, the desktop app's updater, npx, npm or a source checkout), one "active" a day, one "uninstall" when you run `ccdeck --uninstall` (which asks, at a terminal, one skippable question about why, from a short list), one "activated" when a new install's first session arrives (how long after the install, in rough buckets such as "under five minutes", and whether it was a Claude or a Codex session), one "rated" if you answer the one question the deck asks about itself after about a week of use — "How useful is ccdeck to you?", 0 to 10, which you can skip — with the number you picked and how many days you had used the deck, as a range (nothing is sent for a question you put off or leave), and the errors it runs into, with every path outside ccdeck's own code, project folder names, the machine name in every web address, every IP address and the machine name a network error carries (the loopback, such as `127.0.0.1:4317`, stays, so a local port error still reads as one), email addresses and keys scrubbed out (a line of ccdeck's own code keeps its place in the package, such as `ccdeck/src/server/tail.mjs:42`, or in the page, such as `<deck>/assets/index-abc.js:1:2345`, so the error still says where it happened). Each carries the version, your OS and CPU architecture, the channel (desktop, npm or a source checkout), the runtime, your IP address and a device fingerprint (a stable hashed machine id), and the machine's rough shape — the number of CPU cores, the amount of RAM, the locale, the shell, the terminal, and the Claude Code and Codex versions — and whether the Claude hooks went in and Codex is being watched, with the daily "active" also carrying how many sessions, subagents and projects you ran on the last day you used the deck, that day's date, and which of the deck's own features you used that day — fixed names such as "usage history" or "account switch", a yes for each and nothing about what you did with them — plus how the deck held up and what it runs with: how long this run took to start, that day's peak memory and how many events it took in, each as a rough bucket such as "under two seconds" or "256–512 MB", the Claude and Codex plan as its category only (such as "Max 5x" or "Plus", read from the file each CLI keeps beside its login, and nothing else from it), and how many Claude accounts and paired machines the deck holds, as counts. The fingerprint is a one-way hash of stable machine traits, never those traits in the clear; everything else is coarse counts and short tokens — no path, no project name, no free text. These reports are no longer anonymous: the install id is random, but the fingerprint follows this machine and the server records the IP the report arrives from. What still never leaves is your sessions, prompts, files, project names and paths. The deck has no switch for them; `AGENTS_DECK_NO_REPORTS=1` keeps them off from the first start. While the deck runs it also sends a tiny heartbeat every few minutes, the install id and nothing else, so the count of decks running right now, and how many hours a day a deck stays open, can be seen. **Send feedback** (the topbar's Feedback button) sends what you write, and any screenshots you add, to the same place, where the people who make ccdeck read it and may open a public issue from it, never with the contact you gave or your screenshots. `AGENTS_DECK_NO_INSTALL=1` turns off everything but the quota reads; `AGENTS_DECK_NO_DOWNLOAD=1` is the narrower version — no `uv` binary is fetched, the managed installs stay.
212
212
 
213
213
  ## Browser Watch
214
214
 
215
215
  The panel behind the eye icon answers one question: **did something drive your browser while you were away?** An agent with browser control opens pages through an API, and Chromium records that — `visits.transition` says `FROM_API` rather than `LINK` or `TYPED`. So a run of those, in a stretch when you were not touching the machine, is a finding worth showing.
216
216
 
217
- To answer it the deck reads the browsers' own history. That means, on each poll: it finds every Chromium profile (Chrome, Brave, Edge, Vivaldi), **copies each profile's `History` database** to a temp file — a live database cannot be opened safely — queries the visits newer than the moment this deck started, and deletes the copy. Nothing leaves the machine, and the copy exists for as long as one query takes.
217
+ To answer it the deck reads the browsers' own history. That means, on each poll: it finds every Chromium profile (Chrome, Brave, Edge, Vivaldi), **copies each profile's `History` database** to a temp file — a live database cannot be opened safely — queries the visits newer than the moment this deck started, and deletes the copy. None of what it reads leaves the machine, and the copy exists for as long as one query takes. The one thing the watch sends out is a DNS query: while the panel is open, it looks up the address of Claude's browser relay (`bridge.claudeusercontent.com`) about every thirty seconds, to show whether a running browser is connected to it. The background poll does not.
218
218
 
219
219
  **The switch decides whether any of that happens in the background, and it starts on.** With Browser Watch off, the deck's five-minute poll reads no browser at all; with it on, the poll reads, keeps what it finds in `~/.claude/agent-dag/browser-watch/state.json`, writes a line per finding to `watch.log` beside it, and performs whatever reaction you chose. Opening the panel always reads live, on either setting — that is you looking, and it is the only way the panel can tell you what is there.
220
220
 
@@ -228,6 +228,8 @@ The Accounts panel reads the store [claude-swap](https://github.com/realiti4/cla
228
228
 
229
229
  The deck installs that package itself, so it installs it **bounded**: `claude-swap~=0.26`, which is `>= 0.26, == 0.*`, and when PyPI can be reached the exact version it resolved rather than the range. What `cswap --version` reports afterwards has to be that version, or the deck says so and leaves the panel dark rather than driving a copy it cannot account for — this is the tool that holds your Claude logins. The daily upgrade is bounded by the same specifier, and what it left behind is written to `~/.agents-deck/cswap-upgrade.json`.
230
230
 
231
+ **Switch** on an account's row makes it the active one. New sessions start on it; sessions already running are not restarted, and pick it up on their next message — up to about 30 seconds later on macOS. **Auto-switch**, off until you turn it on, does the same without a press: about once a minute the deck runs claude-swap's own engine, which, once the active account passes your threshold (90% unless you choose another) of its 5-hour or 7-day window, moves Claude Code to the account with the most room left. Every account keeps its own limits; when no other account is under the threshold, nothing switches.
232
+
231
233
  **`+` → Sign in** runs `claude auth login`, shows you the link, and hands the result to `cswap add`. The sign-in usually completes by itself once you approve it in the browser; only when the page shows a code — as it does on a deck opened from another machine — is there one to paste back into the dialog. The account you were using **stays active** — signing in replaces the live credentials, so the previous one is switched back the moment the new one is recorded. A pasted code goes straight into the CLI's stdin on this machine; it is never stored, logged, or sent anywhere else.
232
234
 
233
235
  **`share`** on an account produces a `ccdeck2:…` blob to paste into another deck's **`+` → Paste a share**.
@@ -274,7 +276,7 @@ Four pictures, which are also the guide the section opens from `See how it works
274
276
 
275
277
  **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.
276
278
 
277
- **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 a machine that is asked waits for somebody there to press `accept` — unless its `Say yes to every deck that asks` switch, in the same dialog, is on. 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.
279
+ **What is shared, and with whom.** Nothing until you tick a login, and then only that login, and only with paired decks — the deck asks the machines it finds, and a machine that is asked waits for somebody there to press `accept` unless its `Say yes to every deck that asks` switch, in the same dialog, is on. That switch starts on, so your own machines pair without a press; what still takes a person is the tick, because a paired deck is handed nothing until somebody ticks a login here. On a network you do not own, turn the switch off. A login that arrives from a deck somebody here accepted or paired by invite is ticked here too, so this machine can repair the next one; a deck the switch paired is handed only the logins a person ticked, so untick and tick that one again to hand it to those decks as well. 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.
278
280
 
279
281
  On a Mac, **cannot share here** is different from **expired here**. It means this ccdeck process cannot read the Keychain copy, so that machine is not used as a credential source and another deck does not keep trying to “repair” the same healthy slot every minute. A LAN import is checked after it lands too; if the receiving Mac still cannot read the Keychain, the round reports that problem instead of claiming the login was repaired.
280
282
 
@@ -316,6 +318,8 @@ ccdeck [options]
316
318
  --claude Force Claude capture even if Claude Code wasn't found
317
319
  --no-claude Skip Claude entirely — no hooks, no claude-swap,
318
320
  no Accounts panel (Codex only)
321
+ --ref <name> The ccdeck.dev page this command was copied from;
322
+ sent once, with the first install report
319
323
  --uninstall Remove ccdeck's hooks from settings files, and name
320
324
  the files that still hold this deck's private key
321
325
  --purge With --uninstall: delete those files too
@@ -354,7 +358,9 @@ Scheduler logon task on Windows — none of them carrying a restart policy of it
354
358
  own, because the one that decides when a crashed deck stops coming back lives in
355
359
  ccdeck and two policies over one process is how a stop becomes a suggestion. An
356
360
  `npx` run never installs one: it would name a path inside npm's cache, which npm
357
- deletes without warning. On Linux, `systemd --user` is torn down at logout unless
361
+ deletes without warning — nor does a `pnpm dlx`, `bunx` or `yarn dlx` run, for the
362
+ same reason. A Linux machine that does not run systemd gets none either, and the
363
+ first start says so. On Linux, `systemd --user` is torn down at logout unless
358
364
  `loginctl enable-linger` is on for your account — the install says so rather than
359
365
  changing that for you.
360
366
 
@@ -381,7 +387,8 @@ so unconditionally. Two starts at the same moment, such as the login item and a
381
387
  terminal opened at login, wait for each other instead of both starting. The rule
382
388
  holds per Claude config directory: pointing `CLAUDE_CONFIG_DIR` somewhere else
383
389
  is a second deck with its own identity, as it always was, and the login item is
384
- given the same directory as the shell that installed it.
390
+ given the same directory as the shell that installed it, along with every other
391
+ variable in the table below that the shell had set.
385
392
 
386
393
  ccdeck looks for each CLI before it does anything on that CLI's behalf. Claude
387
394
  Code counts as present when its binary is on `PATH` (or in one of the places its
@@ -486,6 +493,7 @@ What the banner offers depends on how this copy was installed:
486
493
  |---|---|
487
494
  | global npm install | **Update now** — runs `npm install -g` on the package you installed, then restarts once nothing is running |
488
495
  | `npx` | **Update & restart** — re-runs the spec through npx, which fetches a fresh copy and takes over the same port |
496
+ | `pnpm dlx`, `bunx` or `yarn dlx` | the command — a one-off copy cannot be upgraded in place, so the global install is what is shown |
489
497
  | git checkout | the command, because your working copy leads npm: `git pull && npm run build` |
490
498
  | directory not writable | the command — a root-owned prefix is declined up front rather than failing inside npm |
491
499
  | `AGENTS_DECK_NO_INSTALL=1` | the command only; you asked for no installs |
package/bin/agent-dag.js CHANGED
@@ -43,7 +43,7 @@ import { parseArgs } from "../src/server/args.mjs";
43
43
  import { holdOutput, npxFailureHint, npxFailureSummary, npxLaunch, npxPrefetch } from "../src/server/npx.mjs";
44
44
  import {
45
45
  bareSpecName, claimRestartFailureKey, clearRestartFailure, currentName, installedName, installedVersion,
46
- isNpxInstall, lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure,
46
+ lastKnownLatest, npxRestartSpec, oneOffRunner, readRestartFailure, recordRestartFailure,
47
47
  successorRoot,
48
48
  } from "../src/server/self-update.mjs";
49
49
  import {
@@ -98,22 +98,27 @@ const LEASHED = typeof process.send === "function";
98
98
  const FLAGS = parseArgs(process.argv.slice(2));
99
99
  if (shouldDetach({ detached: DETACHED, leashed: LEASHED, flags: FLAGS })) {
100
100
  const { deckLogDir } = await import("../src/server/deck-home.mjs");
101
- const { registeredDecks } = await import("../src/server/running-deck.mjs");
101
+ const { deckRegistryDir, registeredDecks } = await import("../src/server/running-deck.mjs");
102
+ const { BOOT_LOCK_FILE } = await import("../src/server/boot-lock.mjs");
102
103
  const isTTY = Boolean(process.stdout.isTTY);
103
104
  const profile = colorProfile({ isTTY });
104
105
  const tone = palette(profile);
105
- const npx = isNpxInstall(PKG_ROOT);
106
+ // npx, pnpm dlx, bunx or yarn dlx: the runner the background line's two
107
+ // commands go through, since none of them leaves the command on PATH.
108
+ const runner = oneOffRunner(PKG_ROOT);
106
109
  const outcome = await detachAndWatch({
107
110
  file: fileURLToPath(import.meta.url),
108
111
  argv: process.argv.slice(2),
109
112
  logDir: deckLogDir(),
110
- // Only the count, and only to decide whether deck.log is anybody's — see
111
- // logMode. No handshake: this is a directory listing and a signal-0 each.
113
+ // Only the count and the boot lock, and only to decide whether deck.log is
114
+ // anybody's — see startsFresh. No handshake: this is a directory listing
115
+ // and a signal-0 each.
112
116
  liveCount: (await registeredDecks().catch(() => [])).length,
117
+ booting: existsSync(join(deckRegistryDir(), BOOT_LOCK_FILE)),
113
118
  isTTY,
114
119
  profile,
115
120
  columns: termColumns(process.stdout),
116
- backgroundLine: backgroundNote({ npx, invokedAs: INVOKED_AS, product: PRODUCT, tone, g: G }),
121
+ backgroundLine: backgroundNote({ runner, invokedAs: INVOKED_AS, product: PRODUCT, tone, g: G }),
117
122
  });
118
123
  // detachAndWatch never returns on the paths that worked. Reaching this line
119
124
  // means the log could not be opened at all — a read-only home, a full disk —
@@ -136,6 +141,10 @@ claimRestartFailureKey();
136
141
  // Re-launching without this is how a restart silently moves the deck out from
137
142
  // under an open tab.
138
143
  let boundPort = null;
144
+ // What the last worker said about the Claude hooks ("ok", "failed", "off"),
145
+ // handed to the next one: a respawn on the same version installs nothing, so
146
+ // this is the only way it can say what the session's hooks are (activation.mjs).
147
+ let bootHooks = "";
139
148
  let restarts = 0;
140
149
  let child = null;
141
150
  // The npx process fetching a replacement, while the worker above keeps serving.
@@ -212,6 +221,9 @@ function launch(respawn) {
212
221
  // different one installs them again. Empty for "we could not tell",
213
222
  // which the worker reads as different.
214
223
  AGENTS_DECK_BOOT_VERSION: VERSION === "?" ? "" : VERSION,
224
+ // And how the hooks went, as the last worker said. Empty for "nobody has
225
+ // said", which the worker reports as nothing rather than a guess.
226
+ AGENTS_DECK_BOOT_HOOKS: bootHooks,
215
227
  AGENTS_DECK_RESTARTS: String(restarts),
216
228
  // Empty for "we could not tell", which knownCommand reads as unknown just
217
229
  // like an absent one. Set on the worker's environment only: the npx
@@ -227,6 +239,7 @@ function launch(respawn) {
227
239
  worker.on("message", (m) => {
228
240
  if (!m || typeof m !== "object") return;
229
241
  if (m.type === "listening" && typeof m.port === "number") boundPort = m.port;
242
+ else if (m.type === "setup" && ["ok", "failed", "off"].includes(m.claudeHooks)) bootHooks = m.claudeHooks;
230
243
  // The worker saying its boot is finished — every row printed, the browser
231
244
  // launched. Forwarded to whoever detached us, who has been tailing the log
232
245
  // into the user's terminal and is waiting for exactly this to stop.
package/bin/cli/help.js CHANGED
@@ -45,6 +45,7 @@ Options:
45
45
  --no-codex Skip Codex capture (Claude only)
46
46
  --claude Force-enable Claude capture even if Claude Code wasn't found
47
47
  --no-claude Skip Claude entirely: no hooks, no claude-swap, no accounts panel
48
+ --ref <name> The site page this command was copied from (first run)
48
49
  --uninstall Remove ${PRODUCT}'s hooks from ~/.claude/settings.json and
49
50
  ~/.codex/hooks.json, and restore any sound hooks of yours it parked.
50
51
  Hook entries only: the forwarder script under
@@ -29,6 +29,18 @@ const loginJob = (script, deckLogDir) => ({ script, logPath: join(deckLogDir(),
29
29
  const installedRecord = (path) => ({ installed: PKG_VERSION, at: new Date().toISOString(), path });
30
30
  export const removedRecord = () => ({ removed: new Date().toISOString(), version: PKG_VERSION });
31
31
 
32
+ /**
33
+ * A Linux unit written and not enabled, said as what it is. systemd starts at
34
+ * login only an enabled user unit, so a refused `systemctl --user enable` is a
35
+ * file nothing reads until somebody runs the command the verdict carries —
36
+ * unlike a refused `launchctl load`, whose plist launchd still loads at the
37
+ * next login. Two lines, the way every warning here is said.
38
+ */
39
+ function sayNotEnabled(out, { say, tone, gWarn, dash }) {
40
+ say(` ${tone.warn}${gWarn} the login item is written but not enabled ${dash} ${out.reason}${tone.reset}`);
41
+ say(` ${tone.muted}systemd starts only an enabled unit at login ${dash} \`${out.enable}\` turns it on${tone.reset}`);
42
+ }
43
+
32
44
  /** The one place the three platforms genuinely differ in what a login item buys you. */
33
45
  function warnWhenLingerOff(svc, { say, tone, gWarn }) {
34
46
  if (svc.lingerState() === "off") {
@@ -79,7 +91,8 @@ export async function installGlobally({ say, tone, dash, gOk, gWarn, bullet, gEl
79
91
  return 0;
80
92
  }
81
93
  svc.writeServiceRecord(deckDataDir(), installedRecord(out.path));
82
- say(` ${tone.ok}${gOk}${tone.reset} and starts when you log in${tone.muted} ${bullet} ${out.path}${tone.reset}`);
94
+ if (out.enable) sayNotEnabled(out, { say, tone, gWarn, dash });
95
+ else say(` ${tone.ok}${gOk}${tone.reset} and starts when you log in${tone.muted} ${bullet} ${out.path}${tone.reset}`);
83
96
  warnWhenLingerOff(svc, { say, tone, gWarn });
84
97
  say(` ${tone.muted}\`${pkg} --uninstall-service\` undoes the login part${tone.reset}\n`);
85
98
  return 0;
@@ -91,7 +104,7 @@ export async function installGlobally({ say, tone, dash, gOk, gWarn, bullet, gEl
91
104
  // with three.
92
105
  export async function loginItemCommand(flags, { say, tone, dash, gOk, gWarn, bullet, deckDataDir, deckLogDir }) {
93
106
  const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
94
- const { isGitCheckout, isNpxInstall } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
107
+ const { isGitCheckout, isNpxInstall, isOneOffRun } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
95
108
  if (flags.uninstallService) {
96
109
  const out = svc.uninstallService();
97
110
  // Recorded either way. The record is what stops the next ordinary start
@@ -116,11 +129,14 @@ export async function loginItemCommand(flags, { say, tone, dash, gOk, gWarn, bul
116
129
  say(`\n ${tone.warn}${gWarn} this is a checkout, so the login item would name ${PKG_ROOT}${tone.reset}`);
117
130
  say(` ${tone.muted}a renamed, moved or deleted working tree leaves a login item pointing at nothing${tone.reset}\n`);
118
131
  }
119
- if (isNpxInstall(PKG_ROOT)) {
132
+ if (isOneOffRun(PKG_ROOT)) {
120
133
  // The item would name a path inside ~/.npm/_npx/<hash>/, which npm deletes
121
134
  // whenever it feels like it — a login item pointing at nothing, forever,
122
- // on a machine where nothing was ever installed.
123
- say(`\n ${tone.warn}${gWarn} an npx run cannot start at login ${dash} its files live in npm's cache and are deleted without warning.${tone.reset}`);
135
+ // on a machine where nothing was ever installed. pnpm dlx, bunx and yarn
136
+ // dlx keep theirs in a cache or a temp folder the same way.
137
+ say(isNpxInstall(PKG_ROOT)
138
+ ? `\n ${tone.warn}${gWarn} an npx run cannot start at login ${dash} its files live in npm's cache and are deleted without warning.${tone.reset}`
139
+ : `\n ${tone.warn}${gWarn} a pnpm dlx, bunx or yarn dlx run cannot start at login ${dash} its files live in a cache or temp folder and are deleted without warning.${tone.reset}`);
124
140
  say(` ${tone.muted}install it first: \`npm i -g ${COMMAND}\`${tone.reset}\n`);
125
141
  return 1;
126
142
  }
@@ -130,12 +146,17 @@ export async function loginItemCommand(flags, { say, tone, dash, gOk, gWarn, bul
130
146
  return 1;
131
147
  }
132
148
  svc.writeServiceRecord(deckDataDir(), installedRecord(out.path));
133
- say(`\n ${tone.ok}${gOk}${tone.reset} starts when you log in${tone.muted} ${bullet} ${out.path}${tone.reset}`);
134
- if (out.how === "file-only") {
135
- // The file is on disk and both launchd and systemd read their directories
136
- // at the next login, so this works from then on. Said rather than hidden:
137
- // "it will work tomorrow" is a different promise from "it works now".
138
- say(` ${tone.warn}${gWarn} not started now ${dash} ${out.reason}. It will come up at your next login.${tone.reset}`);
149
+ if (out.enable) {
150
+ say("");
151
+ sayNotEnabled(out, { say, tone, gWarn, dash });
152
+ } else {
153
+ say(`\n ${tone.ok}${gOk}${tone.reset} starts when you log in${tone.muted} ${bullet} ${out.path}${tone.reset}`);
154
+ if (out.how === "file-only") {
155
+ // The plist is on disk and launchd loads its directory at the next
156
+ // login, so this works from then on. Said rather than hidden: "it will
157
+ // work tomorrow" is a different promise from "it works now".
158
+ say(` ${tone.warn}${gWarn} not started now ${dash} ${out.reason}. It will come up at your next login.${tone.reset}`);
159
+ }
139
160
  }
140
161
  warnWhenLingerOff(svc, { say, tone, gWarn });
141
162
  say(` ${tone.muted}\`${COMMAND} --uninstall-service\` undoes it${tone.reset}\n`);
@@ -148,20 +169,21 @@ export async function loginItemCommand(flags, { say, tone, dash, gOk, gWarn, bul
148
169
  // makes that true: without it, `--uninstall-service` would be undone by the next
149
170
  // start, which is not an uninstall — it is a tool arguing with its user.
150
171
  //
151
- // npx is excluded and AGENTS_DECK_NO_INSTALL is honoured — see
152
- // shouldOfferService, which owns both rules and says why. A failure is one line
153
- // and nothing else: the deck is already running, and the worst case is the
154
- // behaviour every version before this one had.
172
+ // npx and the other one-off runners are excluded and AGENTS_DECK_NO_INSTALL is
173
+ // honoured — see shouldOfferService, which owns both rules and says why. A
174
+ // failure is one line and nothing else: the deck is already running, and the
175
+ // worst case is the behaviour every version before this one had.
155
176
  //
156
177
  // Called by the boot, not by a one-shot, so it writes the boot's way: `P`, `G`
157
178
  // and `write`, straight from bin/cli/screen.js.
158
179
  export async function offerLoginItem({ deckDataDir, deckLogDir }) {
159
180
  try {
160
181
  const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
161
- const { isGitCheckout, isNpxInstall } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
182
+ const { isGitCheckout, isOneOffRun } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
162
183
  if (svc.shouldOfferService({
163
184
  record: svc.readServiceRecord(deckDataDir()),
164
- npx: isNpxInstall(PKG_ROOT),
185
+ // npx, pnpm dlx, bunx and yarn dlx alike: none has a path to name.
186
+ npx: isOneOffRun(PKG_ROOT),
165
187
  checkout: isGitCheckout(PKG_ROOT),
166
188
  })) {
167
189
  const out = svc.installService(loginJob(OWN_SCRIPT, deckLogDir));
@@ -171,9 +193,11 @@ export async function offerLoginItem({ deckDataDir, deckLogDir }) {
171
193
  // Said once, on the one run that does it, and never again. A tool that
172
194
  // adds itself to your login items and does not mention it is a tool you
173
195
  // find later, in a settings pane, and stop trusting.
174
- write(out.ok
175
- ? ` ${P.muted}${G.dash} ${PRODUCT} will now start when you log in ${G.dash} \`${COMMAND} --uninstall-service\` undoes it${P.reset}\n\n`
176
- : ` ${P.muted}${G.dash} could not set ${PRODUCT} to start at login (${out.reason}) ${G.dash} it still starts when you type it${P.reset}\n\n`);
196
+ write(!out.ok
197
+ ? ` ${P.muted}${G.dash} could not set ${PRODUCT} to start at login (${out.reason}) ${G.dash} it still starts when you type it${P.reset}\n\n`
198
+ : out.enable
199
+ ? ` ${P.muted}${G.dash} ${PRODUCT}'s login item is written but systemd did not enable it (${out.reason}) ${G.dash} \`${out.enable}\` turns it on${P.reset}\n\n`
200
+ : ` ${P.muted}${G.dash} ${PRODUCT} will now start when you log in ${G.dash} \`${COMMAND} --uninstall-service\` undoes it${P.reset}\n\n`);
177
201
  }
178
202
  } catch (err) {
179
203
  // Never fatal. The deck is up; this is a convenience that did not happen.
@@ -16,7 +16,7 @@ import { pathToFileURL } from "node:url";
16
16
  import { isPortValue } from "../../src/server/args.mjs";
17
17
  import { sinceLabel } from "../../src/server/term.mjs";
18
18
  import { installGlobally, loginItemCommand } from "./login-item.js";
19
- import { COMMAND, PKG_ROOT } from "./package.js";
19
+ import { COMMAND, PKG_ROOT, PKG_VERSION } from "./package.js";
20
20
  import { G, P } from "./screen.js";
21
21
 
22
22
  /**
@@ -124,7 +124,7 @@ function printLogs(deckLogDir, { say, tone, dash, bullet }) {
124
124
  say(`\n ${tone.muted}${dash} nothing logged yet ${dash} ${logPath}${tone.reset}\n`);
125
125
  return 0;
126
126
  }
127
- // The tail, not the file. It is truncated at the first start on an idle
127
+ // The tail, not the file. It starts afresh at the first start on an idle
128
128
  // machine, so it is normally small — but an attach appends to a running
129
129
  // deck's log every time, and a long-lived deck's log is somebody's week.
130
130
  const lines = text.split("\n");
@@ -170,7 +170,7 @@ async function defaultShape(deckLogDir) {
170
170
 
171
171
  /** `--status`: every deck on this machine, and which one a bare start would open. */
172
172
  async function printStatus(decks, mine, { say, tone, dash, gOk, bullet, arrow }) {
173
- const { sameShape } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
173
+ const { olderVersion, sameShape, serves } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
174
174
  if (!decks.length) {
175
175
  say(`\n ${tone.muted}${dash} no deck is running ${dash} \`${COMMAND}\` starts one${tone.reset}\n`);
176
176
  return 0;
@@ -178,7 +178,13 @@ async function printStatus(decks, mine, { say, tone, dash, gOk, bullet, arrow })
178
178
  // The one a bare `ccdeck` would open is marked, because with two decks up
179
179
  // that is the only question this command is really being asked. Never a
180
180
  // deck between workers: a start typed now finds nothing to open there.
181
- const opens = decks.find(d => !d.restarting && sameShape(d, mine)) ?? null;
181
+ //
182
+ // By the start's own rule, serves — the shape AND a version no older than
183
+ // this one. The shape alone marked an older deck, the one still up after an
184
+ // upgrade, as the deck the next `ccdeck` opens, and that `ccdeck` replaced
185
+ // it. With nothing to open, an older deck of the start's shape says so.
186
+ const opens = decks.find(d => !d.restarting && serves(d, { want: mine, ours: PKG_VERSION })) ?? null;
187
+ const replaced = (d) => !opens && !d.restarting && sameShape(d, mine) && olderVersion(d.version, PKG_VERSION);
182
188
  say("");
183
189
  for (const d of decks) {
184
190
  // The version chunk is dropped rather than printed as "v?" for a deck too
@@ -191,7 +197,11 @@ async function printStatus(decks, mine, { say, tone, dash, gOk, bullet, arrow })
191
197
  const head = [d.version ? `v${d.version}` : "", `pid ${pidOf(d)}`, d.restarting ? "restarting after a crash" : `up ${age(d)}`]
192
198
  .filter(Boolean).join(` ${bullet} `);
193
199
  const mark = d === opens ? `${tone.ok}${gOk}${tone.reset}` : `${tone.muted}${bullet}${tone.reset}`;
194
- const tail = d === opens ? `${tone.muted} ${arrow} \`${COMMAND}\` opens this one${tone.reset}` : "";
200
+ const tail = d === opens
201
+ ? `${tone.muted} ${arrow} \`${COMMAND}\` opens this one${tone.reset}`
202
+ : replaced(d)
203
+ ? `${tone.muted} ${arrow} \`${COMMAND}\` replaces this one ${dash} older than v${PKG_VERSION}${tone.reset}`
204
+ : "";
195
205
  say(` ${mark} ${tone.muted}${head}${tone.reset}${tail}`);
196
206
  say(` ${tone.accent}${tone.bold}${url(d)}${tone.reset}`);
197
207
  say(` ${tone.muted}${where(d)} ${bullet} ${d.persist ?? "no log (--no-persist)"}${tone.reset}`);
package/bin/cli/screen.js CHANGED
@@ -56,13 +56,18 @@ export const showCursor = () => {
56
56
  export function takeCursor() {
57
57
  if (MOTION) { cursorHidden = true; write(CURSOR_HIDE); }
58
58
  process.on("exit", showCursor);
59
- // SIGHUP is the one signal this process does not otherwise handle, so its
60
- // default action would end us before 'exit' could run. Handled only to put the
61
- // cursor back and then die of it exactly as before — the supervisor reads the
62
- // signal, not an exit code.
63
- process.on("SIGHUP", () => { showCursor(); dieOfSignal("SIGHUP"); });
59
+ // SIGHUP's default action would end us before 'exit' could run. Handled to
60
+ // put the cursor back and then die of it exactly as before — the supervisor
61
+ // reads the signal, not an exit code — until the boot hands it to its
62
+ // shutdown with onHangup.
63
+ process.on("SIGHUP", () => { showCursor(); hangup(); });
64
64
  }
65
65
 
66
+ let hangup = () => dieOfSignal("SIGHUP");
67
+ /** What a hangup does once the boot can do better than die of it: bin/deck.js
68
+ * routes it into the same shutdown SIGINT and SIGTERM run. */
69
+ export const onHangup = (fn) => { hangup = fn; };
70
+
66
71
  // ── rows ──────────────────────────────────────────────────────────────────────
67
72
  // The status column is computed from the longest label. It used to be counted
68
73
  // into each string as trailing spaces, so any new row, or any label a character
@@ -162,15 +162,19 @@ export function startupWork({ wantClaude, installHooks, leftoverCodexHooks }) {
162
162
  *
163
163
  * A failure is one line and the restart goes on: the deck is serving, and the
164
164
  * previous forwarder, which is still in place, still reaches it.
165
+ *
166
+ * Answers like startupWork's hook job — `{ ok: true, v }` or `{ ok: false, err }`
167
+ * — when it installed, and null when it did not try, so the usage reports can
168
+ * say what this respawn saw (respawnHooksJob in activation.mjs).
165
169
  */
166
170
  export async function respawnHooks({ wantClaude, installHooks, bootVersion }) {
167
171
  if (!wantClaude || bootVersion === PKG_VERSION) return null;
168
172
  try {
169
- return await installHooks({ provider: "claude" });
173
+ return { ok: true, v: await installHooks({ provider: "claude" }) };
170
174
  } catch (err) {
171
175
  const why = String(err?.message ?? err).split("\n")[0];
172
176
  console.error(`${PRODUCT}: Claude hooks not updated to v${PKG_VERSION} ${G.dash} ${why}`);
173
- return null;
177
+ return { ok: false, err };
174
178
  }
175
179
  }
176
180
 
@@ -6,6 +6,7 @@
6
6
  // well before the heavy imports, exactly where the block used to sit. Every
7
7
  // module the uninstall touches is still imported off PKG_ROOT at the moment it
8
8
  // is needed, so `--uninstall` loads only what it uses and starts nothing.
9
+ import { readFile } from "node:fs/promises";
9
10
  import { join } from "node:path";
10
11
  import { pathToFileURL } from "node:url";
11
12
  import { PRODUCT } from "../../src/server/brand.mjs";
@@ -124,6 +125,14 @@ export async function uninstall(flags) {
124
125
  // HERE, and the position is the point: after every hook is out, and before
125
126
  // --purge takes the key files, so no deck is left running to write them back.
126
127
  if (!(await stopLiveDecks())) refused = true;
128
+ // The reporter the goodbye at the end goes out through, loaded HERE rather
129
+ // than where it is used. Importing reports.mjs is what reads prefs.json into
130
+ // memory, install id and all, and `--purge` just below deletes that file:
131
+ // read after it, the reporter found no id, decided nothing would go out, and
132
+ // the uninstall that most meant it was the one never counted or asked why.
133
+ // Null when no goodbye can go out — see goodbyeReporter for why that is
134
+ // decided before the import rather than by it.
135
+ const reporter = await goodbyeReporter();
127
136
  // ── THE PRIVATE KEY ────────────────────────────────────────────────────────
128
137
  //
129
138
  // The one thing left on the disk that is a CREDENTIAL rather than data. Every
@@ -181,16 +190,43 @@ export async function uninstall(flags) {
181
190
 
182
191
  // Last, once everything above is done, and only while reports are on: the
183
192
  // usage reports hear that this install left, with a reason if the person at
184
- // the terminal picks one. See bin/cli/leaving.js.
185
- {
186
- const { reporter } = await import(pathToFileURL(join(PKG_ROOT, "src/server/reports.mjs")).href);
187
- await sayGoodbye({ reporter });
188
- }
193
+ // the terminal picks one. See bin/cli/leaving.js, and the reporter's own
194
+ // comment above the key section for why it was loaded there.
195
+ await sayGoodbye({ reporter });
189
196
  // Non-zero when any half of it refused, so `ccdeck --uninstall && …` and every
190
197
  // CI step that runs this stops on the failure instead of continuing past it.
191
198
  return refused ? 1 : 0;
192
199
  }
193
200
 
201
+ /**
202
+ * The usage reporter the goodbye goes out through, with the install id already
203
+ * read into memory — or null, when no goodbye can go out.
204
+ *
205
+ * NOT A BARE IMPORT. Importing reports.mjs reads prefs.json with the deck's own
206
+ * boot read (prefs-state.mjs), and that read MOVES ASIDE a file it cannot parse
207
+ * or that another user owns, and announces that the deck is starting with fresh
208
+ * settings. In an uninstall that is a rename, under the user, of the very file
209
+ * the key section names as the one to remove, by a command that promises to
210
+ * leave their data where it is. So with reports vetoed nothing is read at all,
211
+ * and otherwise the file is read here first, touching nothing: a file the boot
212
+ * read would not take as it is has no install id to say goodbye under either.
213
+ */
214
+ async function goodbyeReporter() {
215
+ const { normalise, prefsPath, reportsVetoed } =
216
+ await import(pathToFileURL(join(PKG_ROOT, "src/server/deck-prefs.mjs")).href);
217
+ if (reportsVetoed(process.env)) return null;
218
+ const { stripBom } = await import(pathToFileURL(join(PKG_ROOT, "src/server/atomic-write.mjs")).href);
219
+ try {
220
+ normalise(JSON.parse(stripBom(await readFile(prefsPath(), "utf8"))));
221
+ } catch {
222
+ return null;
223
+ }
224
+ const { reporter } = await import(pathToFileURL(join(PKG_ROOT, "src/server/reports.mjs")).href);
225
+ // willReport() waits for the import's read, so the id is held from here on.
226
+ await reporter.willReport();
227
+ return reporter;
228
+ }
229
+
194
230
  /**
195
231
  * Stop every live deck on this machine, one line per deck, and answer whether
196
232
  * all of them went.
package/bin/deck.js CHANGED
@@ -13,7 +13,7 @@
13
13
  import { join } from "node:path";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import { existsSync } from "node:fs";
16
- import { RESTART_CODE, UPGRADE_CODE, dieWithParent } from "../src/server/supervisor.mjs";
16
+ import { RESTART_CODE, UPGRADE_CODE, dieWithParent, signalExitAction } from "../src/server/supervisor.mjs";
17
17
  import { parseArgs, startPort, wantsCli } from "../src/server/args.mjs";
18
18
  import { unregisteredDetail } from "../src/server/pulse-line.mjs";
19
19
  import { PRODUCT } from "../src/server/brand.mjs";
@@ -33,7 +33,7 @@ import { offerLoginItem } from "./cli/login-item.js";
33
33
  import { oneShot } from "./cli/one-shot.js";
34
34
  // The terminal the boot draws in: palette, glyphs, rows, the wordmark and the spinner.
35
35
  import {
36
- G, P, fileLink, printBanner, showCursor, step, takeCursor, tty, write,
36
+ G, P, fileLink, onHangup, printBanner, showCursor, step, takeCursor, tty, write,
37
37
  } from "./cli/screen.js";
38
38
  // The once-per-session work and the rows that report it.
39
39
  import { reportReady, reportRestarted, reportStartup, respawnHooks, startupWork } from "./cli/startup.js";
@@ -200,7 +200,11 @@ const { installHooks, keepDiscovery, removeDiscovery, hasCodexInstalled, leftove
200
200
  const { startServer, hookToken, releaseRestart, markDeckReady, CODEX_SESSIONS_DIR, canonicalWorkspace } =
201
201
  await import(pathToFileURL(join(PKG_ROOT, "src/server/index.mjs")).href);
202
202
  // What this boot set up, for the usage reports — see activation.mjs.
203
- const { noteSetup } = await import(pathToFileURL(join(PKG_ROOT, "src/server/activation.mjs")).href);
203
+ const { noteSetup, respawnHooksJob } = await import(pathToFileURL(join(PKG_ROOT, "src/server/activation.mjs")).href);
204
+ // And the site page the command came from, which only a new install's first
205
+ // report carries — see install-ref.mjs.
206
+ const { noteRef } = await import(pathToFileURL(join(PKG_ROOT, "src/server/install-ref.mjs")).href);
207
+ noteRef(flags.ref);
204
208
 
205
209
  // Resolved here rather than left as typed, for the reason the events log above
206
210
  // is: the discovery file publishes this path, and the hook that reads it runs in
@@ -398,15 +402,33 @@ const starting = startServer({
398
402
  // the one the session started on — see respawnHooks.
399
403
  if (!RESPAWN) {
400
404
  const jobs = startupWork({ wantClaude, installHooks, leftoverCodexHooks });
401
- noteSetup({ claude: jobs.hooks, codex: wantCodex });
405
+ handDownSetup(noteSetup({ claude: jobs.hooks, codex: wantCodex }));
402
406
  jobs.cswapQuiet.then(settleCswap);
403
407
  await printBanner();
404
408
  await reportStartup(jobs, { workspace, wantClaude, wantCodex, CODEX_SESSIONS_DIR });
405
409
  } else {
406
410
  settleCswap();
407
- // The session's first boot installed the hooks; a respawn says the same.
408
- noteSetup({ claude: wantClaude ? { ok: true } : null, codex: wantCodex });
409
- await respawnHooks({ wantClaude, installHooks, bootVersion: process.env.AGENTS_DECK_BOOT_VERSION });
411
+ // What a respawn says about the hooks is what it saw: its own re-install when
412
+ // the package changed under the session, else the first boot's answer as the
413
+ // supervisor handed it down — never an "ok" it assumed. See respawnHooksJob.
414
+ const reinstall = respawnHooks({ wantClaude, installHooks, bootVersion: process.env.AGENTS_DECK_BOOT_VERSION });
415
+ handDownSetup(noteSetup({
416
+ claude: respawnHooksJob({ wantClaude, reinstall, carried: process.env.AGENTS_DECK_BOOT_HOOKS }),
417
+ codex: wantCodex,
418
+ }));
419
+ await reinstall;
420
+ }
421
+
422
+ /** The hooks' answer, told to the supervisor, which hands it to the next
423
+ * respawn in AGENTS_DECK_BOOT_HOOKS: a respawn on the same version installs
424
+ * nothing, and this is how it can still say what the session's hooks are.
425
+ * Guarded, and given a callback, like every send down a channel the other end
426
+ * may have closed. */
427
+ function handDownSetup(said) {
428
+ said.then(({ claudeHooks }) => {
429
+ if (!claudeHooks || !process.connected) return;
430
+ try { process.send({ type: "setup", claudeHooks }, () => {}); } catch { /* nobody supervising */ }
431
+ }).catch(() => {});
410
432
  }
411
433
 
412
434
  // Usually settled long ago by the time we get here, which is the point: `step`
@@ -558,7 +580,9 @@ async function shutdown(code = 0) {
558
580
  // Before anything that can take time: a Ctrl+C the user has to watch for a
559
581
  // second and a half is a second and a half without a cursor.
560
582
  showCursor();
561
- if (tty && code !== RESTART_CODE && code !== UPGRADE_CODE) {
583
+ // Nothing for a terminal that hung up (onHangup, below): it is not there.
584
+ const hungUp = code === hangupCode();
585
+ if (tty && code !== RESTART_CODE && code !== UPGRADE_CODE && !hungUp) {
562
586
  write(`\n\n ${P.warn}${G.stop} shutting down${G.ellipsis}${P.reset}\n`);
563
587
  }
564
588
  // WHAT THIS DECK STARTED, ENDED WITH IT. Every deadline in exec.mjs lives
@@ -626,6 +650,17 @@ async function shutdown(code = 0) {
626
650
  }
627
651
  process.on("SIGINT", () => shutdown(0));
628
652
  process.on("SIGTERM", () => shutdown(0));
653
+ // A terminal closed under a `--foreground` deck, or a console window closed on
654
+ // Windows, which Node reports as SIGHUP. Dying of it on the spot dropped every
655
+ // append already acknowledged, left the discovery record for the hooks and
656
+ // gave the LAN no goodbye, so it takes the way out the other two take. With
657
+ // the hangup's own exit code, so the supervisor reads what it read before; and
658
+ // with nothing written to the terminal that is gone — a write there fails, and
659
+ // an unhandled 'error' on stdout would end the shutdown halfway.
660
+ onHangup(() => {
661
+ for (const out of [process.stdout, process.stderr]) out.on("error", () => {});
662
+ shutdown(hangupCode());
663
+ });
629
664
  process.on("beforeExit", () => { discovery?.stop(); if (discoveryFile) removeDiscovery(discoveryFile); });
630
665
  // The boot lock's `exit` handler is NOT here with its siblings. It is armed up
631
666
  // beside `let bootLock = null;`, above the start gate, because the three ways
@@ -634,6 +669,13 @@ process.on("beforeExit", () => { discovery?.stop(); if (discoveryFile) removeDis
634
669
 
635
670
  // ── helpers ───────────────────────────────────────────────────────────────────
636
671
 
672
+ /** The code a death by SIGHUP is reported as, 129: what a hangup that runs
673
+ * shutdown instead of dying of it exits with. A function, like shutdown, so
674
+ * it is there at any instant of the boot. */
675
+ function hangupCode() {
676
+ return signalExitAction("SIGHUP").code;
677
+ }
678
+
637
679
  // The deck is up and the discovery file could not be written. Said in full —
638
680
  // the path, the reason — because the alternative is what this replaced: an
639
681
  // ordinary-looking deck that simply never shows a session.