ccdeck 3.36.9 → 3.37.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 +15 -7
- package/bin/agent-dag.js +19 -6
- package/bin/cli/help.js +1 -0
- package/bin/cli/login-item.js +44 -20
- package/bin/cli/one-shot.js +15 -5
- package/bin/cli/screen.js +10 -5
- package/bin/cli/startup.js +6 -2
- package/bin/cli/uninstall.js +41 -5
- package/bin/deck.js +50 -8
- package/dist/web/assets/BrowserWatchModal-DCHV09J8.js +1 -0
- package/dist/web/assets/UsageHistoryModal-oDjpHw4z.js +1 -0
- package/dist/web/assets/index-B0SOa-XB.css +1 -0
- package/dist/web/assets/index-wCbqcRJd.js +306 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +80 -9
- package/package.json +1 -1
- package/release-notes.json +2 -1
- package/src/server/account-origins.mjs +8 -1
- package/src/server/account-projects.mjs +34 -9
- package/src/server/activation.mjs +39 -10
- package/src/server/app-host.mjs +7 -2
- package/src/server/args.mjs +3 -0
- package/src/server/browser-watch-routes.mjs +23 -16
- package/src/server/browser-watch.mjs +9 -3
- package/src/server/claude-accounts.mjs +27 -7
- package/src/server/claude-verdicts.mjs +12 -1
- package/src/server/cli-versions.mjs +6 -4
- package/src/server/codex-auth.mjs +29 -0
- package/src/server/codex-quota.mjs +57 -7
- package/src/server/deck-home.mjs +23 -1
- package/src/server/deck-prefs.mjs +70 -7
- package/src/server/depth-facts.mjs +178 -0
- package/src/server/detach.mjs +61 -33
- package/src/server/event-log.mjs +15 -0
- package/src/server/event-pipeline.mjs +5 -2
- package/src/server/event-routes.mjs +22 -0
- package/src/server/feedback-form.mjs +36 -13
- package/src/server/index.mjs +6 -2
- package/src/server/install-layout.mjs +35 -0
- package/src/server/install-ref.mjs +39 -0
- package/src/server/installer.mjs +20 -2
- package/src/server/lan-beacon.mjs +18 -3
- package/src/server/lan-deck.mjs +7 -1
- package/src/server/lan-engine.mjs +10 -1
- package/src/server/lan-manifest.mjs +9 -6
- package/src/server/lan-requests.mjs +18 -2
- package/src/server/lan-serve.mjs +4 -2
- package/src/server/lan-sync.mjs +97 -6
- package/src/server/lifecycle.mjs +18 -2
- package/src/server/log-replay.mjs +9 -3
- package/src/server/login-service.mjs +95 -12
- package/src/server/npm-upgrade.mjs +12 -4
- package/src/server/pinned-build.mjs +3 -0
- package/src/server/prefs-routes.mjs +5 -8
- package/src/server/prefs-state.mjs +9 -1
- package/src/server/quota-oauth.mjs +88 -13
- package/src/server/quota-shape.mjs +28 -0
- package/src/server/quota.mjs +68 -17
- package/src/server/rating.mjs +87 -0
- package/src/server/reports-routes.mjs +21 -2
- package/src/server/reports.mjs +672 -64
- package/src/server/ring-bounds.mjs +11 -0
- package/src/server/running-deck.mjs +48 -0
- package/src/server/self-update.mjs +1 -1
- package/src/server/session-enrichment.mjs +14 -6
- package/src/server/stable-node.mjs +68 -0
- package/src/server/static-serve.mjs +5 -0
- package/src/server/supervisor.mjs +21 -15
- package/src/server/tailscale.mjs +8 -4
- package/src/server/transcript-scan.mjs +7 -4
- package/src/server/usage-day.mjs +76 -11
- package/dist/web/assets/BrowserWatchModal-BCrDvuug.js +0 -1
- package/dist/web/assets/UsageHistoryModal-BtRCuo33.js +0 -1
- package/dist/web/assets/index-BkXTN4dZ.css +0 -1
- 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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
111
|
-
//
|
|
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({
|
|
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
|
package/bin/cli/login-item.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
152
|
-
// shouldOfferService, which owns both rules and says why. A
|
|
153
|
-
// and nothing else: the deck is already running, and 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,
|
|
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:
|
|
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}
|
|
176
|
-
:
|
|
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.
|
package/bin/cli/one-shot.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
process.on("SIGHUP", () => { showCursor();
|
|
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
|
package/bin/cli/startup.js
CHANGED
|
@@ -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
|
|
177
|
+
return { ok: false, err };
|
|
174
178
|
}
|
|
175
179
|
}
|
|
176
180
|
|
package/bin/cli/uninstall.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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.
|