ccdeck 3.29.11 → 3.31.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.
Files changed (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
package/README.md CHANGED
@@ -191,7 +191,7 @@ Quota is the one thing that is not just reading. It needs a live token, so when
191
191
 
192
192
  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.
193
193
 
194
- 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. 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). `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.
194
+ 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). `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.
195
195
 
196
196
  ## Browser Watch
197
197
 
@@ -376,11 +376,12 @@ Environment:
376
376
  |---|---|
377
377
  | `AGENT_DAG_PORT` | Default port, same as `-p` |
378
378
  | `CODEX_HOME` | Override `~/.codex` |
379
- | `AGENTS_DECK_NO_INSTALL=1` | Never install or update claude-swap / ccusage, and never ask npm about releases |
379
+ | `AGENTS_DECK_NO_INSTALL=1` | Never install or update claude-swap / ccusage, never ask npm about releases, and never read the status pages |
380
380
  | `AGENTS_DECK_NO_DOWNLOAD=1` | Never download the `uv` binary, but keep the managed installs |
381
381
  | `AGENTS_DECK_NO_UPDATE_CHECK=1` | Don't ask npm about releases, but keep everything else |
382
+ | `AGENTS_DECK_NO_STATUS=1` | Don't read Anthropic's and OpenAI's status pages, so no incident chip appears |
382
383
  | `AGENTS_DECK_NO_FRESHEN=1` | Never nudge claude-swap to collect usage early |
383
- | `AGENTS_DECK_NO_NOTIFY=1` | Never raise a desktop notification when a session blocks and no page is open |
384
+ | `AGENTS_DECK_NO_NOTIFY=1` | Never raise a desktop notification: not when a session blocks and no page is open, and not when Browser Watch finds something |
384
385
  | `AGENTS_DECK_NO_LAN=1` | Keep **Local network** off, whatever its switch in the panel says |
385
386
  | `AGENTS_DECK_CSWAP` | Full path to `cswap`, when it lives somewhere unusual |
386
387
  | `AGENTS_DECK_CLAUDE` | Full path to the `claude` CLI |
package/bin/agent-dag.js CHANGED
@@ -28,8 +28,10 @@
28
28
  // happens beside it while it keeps serving, and only a fetch that worked is
29
29
  // answered with the exit that gives up the port. See prefetchUpgrade.
30
30
  //
31
- // Everything else the deck does still lives in bin/deck.js. This file must stay
32
- // boring: it is the one process that is never replaced.
31
+ // Everything else the deck does lives in bin/deck.js and the pieces lifted out
32
+ // of it into bin/cli/, and the rules this file follows are in
33
+ // src/server/supervisor.mjs. This file must stay boring: it is the one process
34
+ // that is never replaced.
33
35
  import { spawn } from "node:child_process";
34
36
  import { existsSync } from "node:fs";
35
37
  import { connect } from "node:net";
@@ -37,19 +39,19 @@ import { dirname, join } from "node:path";
37
39
  import { fileURLToPath } from "node:url";
38
40
  import { killTree } from "../src/server/exec.mjs";
39
41
  import { invokedAs } from "../src/server/invoked-as.mjs";
40
- import { isOneShot, parseArgs } from "../src/server/args.mjs";
41
- import { npxFailureHint, npxFailureSummary, npxLaunch, npxPrefetch } from "../src/server/npx.mjs";
42
+ import { parseArgs } from "../src/server/args.mjs";
43
+ import { holdOutput, npxFailureHint, npxFailureSummary, npxLaunch, npxPrefetch } from "../src/server/npx.mjs";
42
44
  import {
43
45
  bareSpecName, claimRestartFailureKey, clearRestartFailure, currentName, installedName, installedVersion,
44
46
  isNpxInstall, lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure,
45
47
  successorRoot,
46
48
  } from "../src/server/self-update.mjs";
47
49
  import {
48
- CRASH_CEILING, CRASH_WINDOW_MS, crashPolicy, dieOfSignal, dieWithParent, isCrash, replacedNote,
49
- upgradeAttempt, upgradeRefusalText, workerExitAction,
50
+ crashCeilingNote, crashPolicy, crashRestartNote, dieOfSignal, dieWithParent, isCrash, npxRelaunchArgs,
51
+ replacedNote, upgradeAttempt, upgradeRefusalText, workerArgs, workerExitAction,
50
52
  } from "../src/server/supervisor.mjs";
51
53
  import { colorProfile, glyphs, palette, termColumns, unicodeOK } from "../src/server/term.mjs";
52
- import { DETACHED_ENV, detachAndWatch, stopCommand } from "../src/server/detach.mjs";
54
+ import { DETACHED_ENV, backgroundNote, detachAndWatch, noConsoleOptions, shouldDetach } from "../src/server/detach.mjs";
53
55
  import { PRODUCT } from "../src/server/brand.mjs";
54
56
 
55
57
  const BIN_DIR = dirname(fileURLToPath(import.meta.url));
@@ -76,81 +78,31 @@ const INVOKED_AS = invokedAs({ pkgRoot: PKG_ROOT, argv1: process.argv[1], platfo
76
78
  const P = palette(colorProfile({ isTTY: Boolean(process.stdout.isTTY) }));
77
79
  const G = glyphs(unicodeOK());
78
80
 
79
- // Who the restart-failure note below belongs to. Several decks of the same
80
- // package run out of one home directory — two `npx ccdeck` runs even share the
81
- // _npx directory and therefore the version — so a note named after the package
82
- // alone was read by every one of them, and a deck that had never asked for an
83
- // update reported someone else's failed npx as its own. Our pid is unique among
84
- // the decks alive on the machine, and putting it in our own environment is what
85
- // carries it to the worker: launch() spawns with a copy of it.
86
81
  // ── the terminal stops being the deck's leash ────────────────────────────────
87
82
  //
88
- // Everything about why is in src/server/detach.mjs. Here is only the decision,
89
- // and it has exactly two ways out:
90
- //
91
- // ALREADY DETACHED — we ARE the background copy. Carry on as this file always
92
- // has: spawn the worker, supervise it, never come back here.
93
- //
94
- // A ONE-SHOT — `--version`, `--stop`, `--status`, `--help`, `--uninstall`.
95
- // Those answer and leave, and a one-shot that detached would print its answer
96
- // into a log file and hand the terminal back empty. They run in the
97
- // foreground exactly as they always have.
98
- //
99
- // Anything else is a start, and a start goes to the background.
83
+ // Everything about why is in src/server/detach.mjs, and so is the decision —
84
+ // shouldDetach, which names every way of staying in the foreground. Here is
85
+ // only what it is asked, and what a yes does.
100
86
  const DETACHED = process.env[DETACHED_ENV] === "1";
101
87
 
102
- /**
103
- * How the worker (and an upgrade's replacement) is started when this
104
- * supervisor has no console of its own — detached from a terminal, or run by
105
- * the desktop app.
106
- *
107
- * ON WINDOWS, A CONSOLE PROGRAM STARTED BY A PROCESS WITH NO CONSOLE IS GIVEN A
108
- * NEW ONE, and with Windows Terminal as the default terminal that new console
109
- * is a window. Measured on a Windows 10 box: `npx ccdeck` put a second
110
- * Windows Terminal window on screen, hosting the deck's own node.exe, after the
111
- * one the user typed into. `windowsHide` alone does not stop it: with stdio
112
- * inherited, libuv only asks for a hidden window, which Windows Terminal's
113
- * default-terminal handoff does not honour. DETACHED_PROCESS gives the child
114
- * no console at all, so there is nothing to show.
115
- *
116
- * Only when detached: a supervisor in the user's own terminal shares that
117
- * console with its worker, and Ctrl+C has to reach both.
118
- */
119
- const NO_CONSOLE = DETACHED && process.platform === "win32"
120
- ? { detached: true, windowsHide: true }
121
- : {};
88
+ // How the worker (and an upgrade's replacement) is started when this
89
+ // supervisor has no console of its own — without one either, on Windows,
90
+ // where a console program given none opens a window. See noConsoleOptions.
91
+ const NO_CONSOLE = noConsoleOptions({ detached: DETACHED, platform: process.platform });
122
92
  // A parent already holding our lifecycle. `process.send` exists only when
123
93
  // somebody spawned us with an IPC channel, and that somebody has armed
124
- // dieWithParent below and is waiting on our exit code — running away from them
125
- // into our own process group is precisely the wrong answer to being supervised.
126
- // The suite's spawnSupervised is the caller that does this today.
94
+ // dieWithParent below and is waiting on our exit code. The suite's
95
+ // spawnSupervised is the caller that does this today.
127
96
  const LEASHED = typeof process.send === "function";
128
- // And the way to ask for the old behaviour out loud.
129
- //
130
- // Every version before this one held the terminal, and something out there
131
- // depends on that: a wrapper script, a CI step, a supervisor of somebody else's
132
- // that starts `ccdeck` and waits on it, a `ccdeck && open …`. Handing all of
133
- // those an immediate exit and no way to say otherwise would be a breaking change
134
- // with no escape hatch — and the marker above is an internal one, not something
135
- // to tell a user to export.
97
+ // `--foreground` and the one-shots are read off these.
136
98
  const FLAGS = parseArgs(process.argv.slice(2));
137
- if (!DETACHED && !LEASHED && FLAGS.foreground !== true && !isOneShot(FLAGS)) {
99
+ if (shouldDetach({ detached: DETACHED, leashed: LEASHED, flags: FLAGS })) {
138
100
  const { deckLogDir } = await import("../src/server/deck-home.mjs");
139
101
  const { registeredDecks } = await import("../src/server/running-deck.mjs");
140
102
  const isTTY = Boolean(process.stdout.isTTY);
141
103
  const profile = colorProfile({ isTTY });
142
104
  const tone = palette(profile);
143
105
  const npx = isNpxInstall(PKG_ROOT);
144
- const stop = stopCommand({ npx, invokedAs: INVOKED_AS, product: PRODUCT });
145
- // THE ONE THING AN NPX RUN CANNOT HAVE, said where it is missing. A login item
146
- // must name a path that will still be there tomorrow, and npx runs out of a
147
- // cache npm deletes whenever it likes — so an npx deck runs in the background
148
- // and cannot come back after a reboot. One line, no disk written, offered
149
- // rather than done: `npx` means "run without installing", and a tool that
150
- // installs itself anyway is the one people uninstall.
151
- const offer = npx
152
- ? ` ${tone.muted}\`${INVOKED_AS ?? PRODUCT} --install\` also starts it at login${tone.reset}\n`
153
- : "";
154
106
  const outcome = await detachAndWatch({
155
107
  file: fileURLToPath(import.meta.url),
156
108
  argv: process.argv.slice(2),
@@ -161,7 +113,7 @@ if (!DETACHED && !LEASHED && FLAGS.foreground !== true && !isOneShot(FLAGS)) {
161
113
  isTTY,
162
114
  profile,
163
115
  columns: termColumns(process.stdout),
164
- backgroundLine: ` ${tone.muted}${G.dash} running in the background ${G.bullet} \`${stop}\` ends it${tone.reset}\n${offer}\n`,
116
+ backgroundLine: backgroundNote({ npx, invokedAs: INVOKED_AS, product: PRODUCT, tone, g: G }),
165
117
  });
166
118
  // detachAndWatch never returns on the paths that worked. Reaching this line
167
119
  // means the log could not be opened at all — a read-only home, a full disk —
@@ -170,6 +122,13 @@ if (!DETACHED && !LEASHED && FLAGS.foreground !== true && !isOneShot(FLAGS)) {
170
122
  console.error(`${PRODUCT}: could not open ${PRODUCT}'s log (${outcome.reason}) ${G.dash} staying in the foreground.`);
171
123
  }
172
124
 
125
+ // Who the restart-failure note below belongs to. Several decks of the same
126
+ // package run out of one home directory — two `npx ccdeck` runs even share the
127
+ // _npx directory and therefore the version — so a note named after the package
128
+ // alone was read by every one of them, and a deck that had never asked for an
129
+ // update reported someone else's failed npx as its own. Our pid is unique among
130
+ // the decks alive on the machine, and putting it in our own environment is what
131
+ // carries it to the worker: launch() spawns with a copy of it.
173
132
  claimRestartFailureKey();
174
133
 
175
134
  // The port the worker actually bound, which is not necessarily the one it was
@@ -199,6 +158,23 @@ let stopping = false;
199
158
  // answer to broken is to stop and say so rather than to spin.
200
159
  let crashes = [];
201
160
 
161
+ /**
162
+ * Stop the npx fetch in flight, if there is one, and say whether there was.
163
+ *
164
+ * A fetch ends early three ways — the worker it was for exits, a signal
165
+ * reaches this process, the parent holding our leash goes away — and all three
166
+ * stop it with killTree, which on Windows is the only thing that reaches npm
167
+ * under cmd.exe. The answer is for the first of them: a worker that leaves
168
+ * mid-fetch writes its attempt off, while one that leaves after a fetch that
169
+ * worked is handing the attempt to launchNpx.
170
+ */
171
+ function stopFetch(signal) {
172
+ if (!fetching) return false;
173
+ killTree(fetching, signal);
174
+ fetching = null;
175
+ return true;
176
+ }
177
+
202
178
  function launch(respawn) {
203
179
  // Asked on every spawn rather than once at boot: the deletion this catches
204
180
  // happens while the deck is running, so a constant read at import time would
@@ -213,9 +189,9 @@ function launch(respawn) {
213
189
  console.error(replaced);
214
190
  process.exit(1);
215
191
  }
216
- const args = [WORKER, ...process.argv.slice(2)];
217
- // Appended last so it wins: the worker's parser keeps the final --port.
218
- if (respawn && boundPort != null) args.push("--port", String(boundPort));
192
+ // On a respawn, the port the last worker bound goes last so it wins — see
193
+ // workerArgs.
194
+ const args = workerArgs(WORKER, process.argv.slice(2), { respawn, boundPort });
219
195
 
220
196
  const worker = spawn(process.execPath, args, {
221
197
  ...NO_CONSOLE,
@@ -272,7 +248,7 @@ function launch(respawn) {
272
248
  // download is spent effort and the process holding it has to be stopped:
273
249
  // left running it would keep writing into the npx cache directory the next
274
250
  // attempt reads, minutes after the deck stopped waiting for it.
275
- if (fetching) { killTree(fetching); fetching = null; attempting = null; }
251
+ if (stopFetch()) attempting = null;
276
252
  // `stopping` outranks the exit code — see supervisor.mjs. A restart and a
277
253
  // Ctrl+C can land together, and honouring the code first is how the deck
278
254
  // came back to life after the user stopped it.
@@ -292,14 +268,16 @@ function launch(respawn) {
292
268
  // Before this ran in the background a crash was self-reporting: the terminal
293
269
  // came back with the stack on it. Detached, the first sign is noticing hours
294
270
  // later that a day of work was never recorded — so it goes back up. The
295
- // whole of which crashes qualify is in isCrash, and the ceiling that stops
296
- // this becoming a spin loop is in crashPolicy.
271
+ // whole of which crashes qualify is in isCrash, the ceiling that stops this
272
+ // becoming a spin loop is in crashPolicy, and what each answer says is in
273
+ // crashRestartNote and crashCeilingNote.
297
274
  if (isCrash({ code, signal, served: boundPort != null, stopping })) {
298
275
  const verdict = crashPolicy(crashes);
299
276
  if (verdict.restart) {
300
277
  crashes = verdict.history;
301
- const how = signal ? `killed by ${signal}` : `exit ${code}`;
302
- console.error(`${PRODUCT}: the deck stopped on its own (${how}) ${G.dash} starting it again in ${Math.round(verdict.delayMs / 1000)}s (${verdict.recent}/${CRASH_CEILING}).`);
278
+ console.error(crashRestartNote({
279
+ code, signal, delayMs: verdict.delayMs, recent: verdict.recent, product: PRODUCT, dash: G.dash,
280
+ }));
303
281
  restarts++;
304
282
  // Unref'd would be wrong here: this timer IS the supervisor's reason to
305
283
  // stay alive, and without it the event loop empties and the process
@@ -310,7 +288,7 @@ function launch(respawn) {
310
288
  // Said once, with the two numbers that make it actionable, and then this
311
289
  // process really does end — a supervisor that keeps trying forever is the
312
290
  // failure the ceiling exists to prevent.
313
- console.error(`${PRODUCT}: the deck has stopped ${CRASH_CEILING} times in ${Math.round(CRASH_WINDOW_MS / 60000)} minutes ${G.dash} not starting it again. Run \`${INVOKED_AS ?? PRODUCT}\` when you have looked at the log above.`);
291
+ console.error(crashCeilingNote({ product: PRODUCT, command: INVOKED_AS ?? PRODUCT, dash: G.dash }));
314
292
  }
315
293
 
316
294
  // Anything else is the worker's own verdict and belongs to whoever started
@@ -328,26 +306,12 @@ function launch(respawn) {
328
306
  });
329
307
  }
330
308
 
331
- /** Drop the two flags launchNpx sets itself. `--port` takes a value, and both
332
- * spellings npm's parser accepts (`--port 4317`, `--port=4317`) have to go. */
333
- function withoutPortAndOpen(args) {
334
- const out = [];
335
- for (let i = 0; i < args.length; i++) {
336
- const a = args[i];
337
- if (a === "--no-open") continue;
338
- if (a === "--port") { i++; continue; } // and its value
339
- if (a.startsWith("--port=")) continue;
340
- out.push(a);
341
- }
342
- return out;
343
- }
344
-
345
309
  /**
346
310
  * Which package an npx upgrade here names, and the spec that installs it — or
347
311
  * null when this deck was not started by npx and there is nothing to re-run.
348
312
  *
349
313
  * One function because there is one answer, and the worker above us reaches it
350
- * independently: `upgradeName` in self-update.mjs resolves the npx case as
314
+ * independently: `upgradeName` in install-layout.mjs resolves the npx case as
351
315
  * `bareSpecName(npxRestartSpec(pkgRoot, installedName(pkgRoot)))`, and
352
316
  * everything registry-shaped on that side — the dist-tag it fetched, the marker
353
317
  * it cached the answer in, the failure note the browser reads — is keyed by it.
@@ -487,14 +451,9 @@ function launchNpx() {
487
451
  // one answer, so the spec that is run, the note that records it and the
488
452
  // marker its target came from all name one package. See npxUpgrade.
489
453
  const { spec, pkgName } = upgrade;
490
- // Our two are appended, so the originals are dropped rather than left to be
491
- // overridden — `--port 4317 --no-open --port 4317 --no-open` works, but it is
492
- // what the next person reads in `ps`.
493
- const args = ["-y", spec, ...withoutPortAndOpen(process.argv.slice(2))];
494
- if (boundPort != null) args.push("--port", String(boundPort));
495
- // The tab that asked for this is open and reconnecting; a second one would be
496
- // the deck talking over itself.
497
- args.push("--no-open");
454
+ // On the port the deck is bound to, and without opening a tab — see
455
+ // npxRelaunchArgs.
456
+ const args = npxRelaunchArgs(spec, process.argv.slice(2), boundPort);
498
457
 
499
458
  // A retry answers for itself: whatever the last attempt left on disk is about
500
459
  // to be replaced by this attempt's outcome, and leaving it there would keep
@@ -526,18 +485,8 @@ function launchNpx() {
526
485
  // Held, not discarded: the moment the replacement is serving, everything it
527
486
  // wrote goes to the terminal and every later byte passes straight through.
528
487
  // Until then it is only evidence for a failure that may not happen.
529
- let tail = "";
530
- let teeing = false;
531
- const tee = () => {
532
- if (teeing) return;
533
- teeing = true;
534
- if (tail) process.stderr.write(tail);
535
- };
536
- started.stderr?.on("data", (d) => {
537
- const s = String(d);
538
- if (teeing) { process.stderr.write(s); return; }
539
- tail = (tail + s).slice(-8000);
540
- });
488
+ const held = holdOutput({ write: (s) => process.stderr.write(s) });
489
+ started.stderr?.on("data", held.take);
541
490
 
542
491
  // Whether the replacement ever got as far as serving. An npx that cannot
543
492
  // resolve exits in seconds having bound nothing; a deck the user stops with
@@ -547,7 +496,7 @@ function launchNpx() {
547
496
  if (boundPort == null || served) return;
548
497
  const sock = connect({ port: boundPort, host: "127.0.0.1" });
549
498
  sock.setTimeout(1000);
550
- sock.on("connect", () => { served = true; tee(); sock.destroy(); });
499
+ sock.on("connect", () => { served = true; held.release(); sock.destroy(); });
551
500
  sock.on("timeout", () => sock.destroy());
552
501
  sock.on("error", () => { /* not up yet */ });
553
502
  }, 1000);
@@ -557,8 +506,8 @@ function launchNpx() {
557
506
  clearInterval(probe);
558
507
  child = null;
559
508
  if (stopping || served) return; // the user stopped it, or it ran and ended
560
- const summary = npxFailureSummary(tail);
561
- const hint = npxFailureHint(tail);
509
+ const summary = npxFailureSummary(held.tail);
510
+ const hint = npxFailureHint(held.tail);
562
511
  console.error(`${PRODUCT}: ${why} ${G.dash} staying on v${VERSION}`);
563
512
  if (summary) console.error(` ${summary}`);
564
513
  if (hint) console.error(` ${hint}`);
@@ -610,7 +559,7 @@ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
610
559
  // Windows the shim path leaves npm as a grandchild of cmd.exe that only
611
560
  // killTree can reach. Stopped before the worker, since the worker's own
612
561
  // exit path is what ends this process.
613
- if (fetching) { killTree(fetching, second ? "SIGKILL" : sig); fetching = null; }
562
+ stopFetch(second ? "SIGKILL" : sig);
614
563
  if (!child) { process.exit(0); return; }
615
564
  try { child.kill(second ? "SIGKILL" : sig); } catch { /* already gone */ }
616
565
  // The npx step is a shell that exec's the new deck, and a signal can land in
@@ -643,7 +592,7 @@ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
643
592
  // the deck kill itself a second after every successful start.
644
593
  if (!DETACHED) dieWithParent(() => {
645
594
  stopping = true;
646
- if (fetching) { killTree(fetching, "SIGTERM"); fetching = null; }
595
+ stopFetch("SIGTERM");
647
596
  if (child) killTree(child, "SIGTERM");
648
597
  // Long enough for the worker to unregister its discovery file and let the
649
598
  // port go, short enough that nothing is waiting on us. Unref'd: if the worker
@@ -0,0 +1,67 @@
1
+ // The `--help` text, and nothing else.
2
+ //
3
+ // Lifted out of bin/deck.js, which answers `--help` before anything else in the
4
+ // boot has run and was carrying sixty lines of prose it prints once and never
5
+ // reads. A leaf, like the modules that file imports at the top: brand.mjs and
6
+ // nothing more, so asking for help still starts nothing.
7
+ import { PRODUCT } from "../../src/server/brand.mjs";
8
+
9
+ export function printHelp() {
10
+ process.stdout.write(`${PRODUCT} — live deck of Claude Code + Codex agents
11
+
12
+ Usage:
13
+ ${PRODUCT} [options]
14
+
15
+ Options:
16
+ -p, --port <number> Preferred port (default: 4317; falls back to random 4318–4400)
17
+ --no-open Don't open the browser automatically
18
+ --foreground Hold the terminal, the way every version before 3.20
19
+ did. Ctrl+C stops the deck again
20
+ --new Replace the running deck with a fresh one.
21
+ Without it, a bare \`${PRODUCT}\` beside a deck that is
22
+ already up opens that deck's tab instead of building
23
+ a rival on another port
24
+ --stop Stop the running deck.
25
+ With --port <n>, stop that one; with --all, stop every
26
+ deck on this machine
27
+ --status What is running on this machine, and on which ports
28
+ --logs What the deck wrote where a terminal would have shown
29
+ it, and where that file is
30
+ --install Put the deck on your PATH and start it at login.
31
+ What an \`npx\` run needs to survive a reboot
32
+ --install-service Start the deck when you log in. Set up on first run;
33
+ this is only for putting it back
34
+ --uninstall-service Stop starting at login. \`--uninstall\` does this too
35
+ --workspace <path> Only capture sessions whose cwd is inside <path>
36
+ --scope Restrict to current working directory
37
+ --all Capture every session (default). Accepted and
38
+ ignored — it is what a bare run already does, and
39
+ \`--stop --port <n>\` is the only way to narrow a stop
40
+ --history <path> Override events log file (default: this platform's log directory)
41
+ --no-persist Don't write or replay events log (RAM-only)
42
+ --codex Force-enable Codex capture even if ~/.codex/ missing
43
+ --no-codex Skip Codex capture (Claude only)
44
+ --claude Force-enable Claude capture even if Claude Code wasn't found
45
+ --no-claude Skip Claude entirely: no hooks, no claude-swap, no accounts panel
46
+ --uninstall Remove ${PRODUCT}'s hooks from ~/.claude/settings.json and
47
+ ~/.codex/hooks.json, and restore any sound hooks of yours it parked.
48
+ Hook entries only: the forwarder script under
49
+ ~/.claude/agent-dag/, the deck's own state and log
50
+ directories (\`--status\` names them), ~/.agents-deck/
51
+ and claude-swap all stay. It NAMES the prefs.json
52
+ files that still hold this deck's LAN private key
53
+ --purge With --uninstall: delete those prefs.json files too, so the
54
+ private key every deck you paired with has pinned does not
55
+ outlive the uninstall. Your ${PRODUCT} settings go with them
56
+ -h, --help Show this help
57
+ -v, --version Print the version and exit
58
+
59
+ Anything else on the command line is reported as an unknown option and then
60
+ ignored: the deck still starts.
61
+
62
+ A flag that takes a value never swallows the next flag. If the value is missing,
63
+ empty, or itself looks like a flag — \`${PRODUCT} --workspace \$UNSET --no-persist\`
64
+ after the shell has dropped an unset variable — the flag is reported, left on its
65
+ default, and the token it would have eaten is parsed as the flag it is.
66
+ `);
67
+ }
@@ -0,0 +1,182 @@
1
+ // The login item, from the command line: `--install`, `--install-service` and
2
+ // `--uninstall-service`.
3
+ //
4
+ // Lifted out of the one-shot block in bin/deck.js — oneShot in
5
+ // bin/cli/one-shot.js now — which still answers all three before the registry
6
+ // is read and before anything else in the boot has run. Each resolves to the
7
+ // exit code, and deck.js exits with it.
8
+ //
9
+ // The one-shots' voice comes in with every call — the screen's glyphs and
10
+ // palette by the names those lines use, and `say`.
11
+ import { join } from "node:path";
12
+ import { pathToFileURL } from "node:url";
13
+ import { PRODUCT } from "../../src/server/brand.mjs";
14
+ import { COMMAND, PKG_ROOT, PKG_VERSION } from "./package.js";
15
+ import { G, P, write } from "./screen.js";
16
+
17
+ // The copy of the deck this file belongs to: what `--install-service` and a
18
+ // first start put at login. `--install` names the global copy instead.
19
+ const OWN_SCRIPT = join(PKG_ROOT, "bin", "agent-dag.js");
20
+
21
+ /** The login item's job, for whichever copy of the deck it should start. */
22
+ const loginJob = (script, deckLogDir) => ({ script, logPath: join(deckLogDir(), "deck.log"), product: PRODUCT });
23
+
24
+ /**
25
+ * What the service record says once a login item is written, and once one is
26
+ * taken away. The record is what shouldOfferService reads, so every writer of
27
+ * it goes through these two rather than spelling the shape again.
28
+ */
29
+ const installedRecord = (path) => ({ installed: PKG_VERSION, at: new Date().toISOString(), path });
30
+ export const removedRecord = () => ({ removed: new Date().toISOString(), version: PKG_VERSION });
31
+
32
+ /** The one place the three platforms genuinely differ in what a login item buys you. */
33
+ function warnWhenLingerOff(svc, { say, tone, gWarn }) {
34
+ if (svc.lingerState() === "off") {
35
+ say(` ${tone.warn}${gWarn} systemd tears your session down at logout, so the deck goes with it.${tone.reset}`);
36
+ say(` ${tone.muted}\`sudo loginctl enable-linger $USER\` keeps it running when you are logged out.${tone.reset}`);
37
+ }
38
+ }
39
+
40
+ // ── --install ─────────────────────────────────────────────────────────────
41
+ //
42
+ // The one command that turns an npx run into a deck that comes back after a
43
+ // reboot. `npx ccdeck` cannot start at login — a login item must name a path
44
+ // that will still be there tomorrow, and npx runs out of a cache npm deletes
45
+ // whenever it likes — so this puts the package on PATH and points the service
46
+ // at THAT.
47
+ //
48
+ // BEHIND A FLAG, because `npx` means "run without installing" and a tool that
49
+ // installs itself anyway is the tool people uninstall. Somebody typed this.
50
+ export async function installGlobally({ say, tone, dash, gOk, gWarn, bullet, gEllipsis, deckDataDir, deckLogDir }) {
51
+ const gi = await import(pathToFileURL(join(PKG_ROOT, "src/server/global-install.mjs")).href);
52
+ const svc = await import(pathToFileURL(join(PKG_ROOT, "src/server/login-service.mjs")).href);
53
+ const { installedName } = await import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href);
54
+ const { run } = await import(pathToFileURL(join(PKG_ROOT, "src/server/exec.mjs")).href);
55
+ // The name they typed, not ours. Three packages publish this deck, and
56
+ // installing `ccdeck` for somebody who ran `npx agent-dag` hands them a
57
+ // command they did not ask for.
58
+ const pkg = installedName(PKG_ROOT, PRODUCT);
59
+
60
+ say(`\n ${tone.muted}${dash} installing ${pkg} globally${gEllipsis}${tone.reset}`);
61
+ const got = await run("npm", ["i", "-g", pkg], { timeout: gi.INSTALL_TIMEOUT_MS });
62
+ if (!got?.ok) {
63
+ say(` ${tone.err}${gWarn} ${gi.installFailure(got, { pkg })}${tone.reset}\n`);
64
+ return 1;
65
+ }
66
+ say(` ${tone.ok}${gOk}${tone.reset} ${pkg} is on your PATH${tone.muted} ${bullet} type \`${pkg}\` to start it${tone.reset}`);
67
+
68
+ // WHERE npm PUT IT, asked rather than assumed: a prefix the user set
69
+ // themselves is common and nothing here can guess it.
70
+ const root = gi.readGlobalRoot(await run("npm", ["root", "-g"], { timeout: 30_000 }));
71
+ const script = gi.globalScript(root, pkg);
72
+ if (!script) {
73
+ say(` ${tone.warn}${gWarn} installed, but npm did not say where ${dash} run \`${pkg} --install-service\` to start it at login${tone.reset}\n`);
74
+ return 0;
75
+ }
76
+ const out = svc.installService(loginJob(script, deckLogDir));
77
+ if (!out.ok) {
78
+ say(` ${tone.warn}${gWarn} installed, but it will not start at login ${dash} ${out.reason}${tone.reset}\n`);
79
+ return 0;
80
+ }
81
+ 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}`);
83
+ warnWhenLingerOff(svc, { say, tone, gWarn });
84
+ say(` ${tone.muted}\`${pkg} --uninstall-service\` undoes the login part${tone.reset}\n`);
85
+ return 0;
86
+ }
87
+
88
+ // ── --install-service / --uninstall-service ───────────────────────────────
89
+ // Answered before the registry is read: neither one is about a deck that is
90
+ // running, and both are as meaningful on a machine with no deck up as on one
91
+ // with three.
92
+ export async function loginItemCommand(flags, { say, tone, dash, gOk, gWarn, bullet, deckDataDir, deckLogDir }) {
93
+ 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);
95
+ if (flags.uninstallService) {
96
+ const out = svc.uninstallService();
97
+ // Recorded either way. The record is what stops the next ordinary start
98
+ // putting back what was just taken away, and a tool that argues with its
99
+ // user about a login item is a tool that gets uninstalled entirely.
100
+ svc.writeServiceRecord(deckDataDir(), removedRecord());
101
+ // `existed` rather than the record: the record says what THIS tool last
102
+ // did, and the machine is what actually has a login item on it. Somebody
103
+ // who removed the plist by hand should be told the truth about the
104
+ // machine, not about our bookkeeping.
105
+ say(!out.ok
106
+ ? `\n ${tone.err}${gWarn} could not remove it ${dash} ${out.reason}${tone.reset}\n`
107
+ : out.existed
108
+ ? `\n ${tone.ok}${gOk}${tone.reset} no longer starts at login${tone.muted} ${bullet} ${out.path}${tone.reset}\n`
109
+ : `\n ${tone.muted}${dash} it was not starting at login${tone.reset}\n`);
110
+ return out.ok ? 0 : 1;
111
+ }
112
+ if (isGitCheckout(PKG_ROOT)) {
113
+ // Not refused outright — somebody running from a checkout may genuinely
114
+ // want this — but not done silently either: the item would name a working
115
+ // tree, and that is worth knowing before it is written.
116
+ say(`\n ${tone.warn}${gWarn} this is a checkout, so the login item would name ${PKG_ROOT}${tone.reset}`);
117
+ say(` ${tone.muted}a renamed, moved or deleted working tree leaves a login item pointing at nothing${tone.reset}\n`);
118
+ }
119
+ if (isNpxInstall(PKG_ROOT)) {
120
+ // The item would name a path inside ~/.npm/_npx/<hash>/, which npm deletes
121
+ // 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}`);
124
+ say(` ${tone.muted}install it first: \`npm i -g ${COMMAND}\`${tone.reset}\n`);
125
+ return 1;
126
+ }
127
+ const out = svc.installService(loginJob(OWN_SCRIPT, deckLogDir));
128
+ if (!out.ok) {
129
+ say(`\n ${tone.err}${gWarn} could not set it up ${dash} ${out.reason}${tone.reset}\n`);
130
+ return 1;
131
+ }
132
+ 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}`);
139
+ }
140
+ warnWhenLingerOff(svc, { say, tone, gWarn });
141
+ say(` ${tone.muted}\`${COMMAND} --uninstall-service\` undoes it${tone.reset}\n`);
142
+ return 0;
143
+ }
144
+
145
+ // ── starting at login ─────────────────────────────────────────────────────────
146
+ //
147
+ // ONCE PER MACHINE, EVER. The record in the deck's own data directory is what
148
+ // makes that true: without it, `--uninstall-service` would be undone by the next
149
+ // start, which is not an uninstall — it is a tool arguing with its user.
150
+ //
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.
155
+ //
156
+ // Called by the boot, not by a one-shot, so it writes the boot's way: `P`, `G`
157
+ // and `write`, straight from bin/cli/screen.js.
158
+ export async function offerLoginItem({ deckDataDir, deckLogDir }) {
159
+ try {
160
+ 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);
162
+ if (svc.shouldOfferService({
163
+ record: svc.readServiceRecord(deckDataDir()),
164
+ npx: isNpxInstall(PKG_ROOT),
165
+ checkout: isGitCheckout(PKG_ROOT),
166
+ })) {
167
+ const out = svc.installService(loginJob(OWN_SCRIPT, deckLogDir));
168
+ svc.writeServiceRecord(deckDataDir(), out.ok
169
+ ? installedRecord(out.path)
170
+ : { failed: out.reason ?? "unknown", at: new Date().toISOString(), version: PKG_VERSION });
171
+ // Said once, on the one run that does it, and never again. A tool that
172
+ // adds itself to your login items and does not mention it is a tool you
173
+ // 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`);
177
+ }
178
+ } catch (err) {
179
+ // Never fatal. The deck is up; this is a convenience that did not happen.
180
+ console.error(`${PRODUCT}: could not check the login item:`, err?.message ?? err);
181
+ }
182
+ }