ccdeck 3.29.10 → 3.30.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 (176) hide show
  1. package/README.md +1 -1
  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-DP3YMtEi.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
  16. package/dist/web/assets/index-3vS6A0vt.css +1 -0
  17. package/dist/web/assets/index-BVTWHDta.js +298 -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 +28 -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 +2 -2
  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 +1 -1
  67. package/src/server/deck-prefs.mjs +51 -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 +172 -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 +108 -0
  134. package/src/server/prefs-path.mjs +14 -0
  135. package/src/server/prefs-refusal.mjs +77 -0
  136. package/src/server/prefs-routes.mjs +76 -0
  137. package/src/server/prefs-set-aside.mjs +86 -0
  138. package/src/server/prefs-state.mjs +51 -0
  139. package/src/server/process-command.mjs +104 -0
  140. package/src/server/process-list.mjs +547 -0
  141. package/src/server/pulse-line.mjs +132 -0
  142. package/src/server/purge-key.mjs +7 -7
  143. package/src/server/quota-cli.mjs +174 -0
  144. package/src/server/quota-oauth.mjs +190 -0
  145. package/src/server/quota-shape.mjs +229 -0
  146. package/src/server/quota-store-resets.mjs +206 -0
  147. package/src/server/quota.mjs +123 -509
  148. package/src/server/relay-guard.mjs +3 -3
  149. package/src/server/request-gates.mjs +441 -0
  150. package/src/server/restart-note.mjs +180 -0
  151. package/src/server/retire-sound-hook.mjs +3 -3
  152. package/src/server/ring-bounds.mjs +215 -0
  153. package/src/server/running-deck.mjs +1 -1
  154. package/src/server/running-version.mjs +24 -0
  155. package/src/server/self-update.mjs +39 -1371
  156. package/src/server/session-enrichment.mjs +622 -0
  157. package/src/server/session-read-gate.mjs +49 -0
  158. package/src/server/session-tracking.mjs +242 -0
  159. package/src/server/sse-clients.mjs +222 -0
  160. package/src/server/stale-discovery.mjs +30 -0
  161. package/src/server/static-serve.mjs +81 -0
  162. package/src/server/supervisor.mjs +73 -12
  163. package/src/server/system-metrics.mjs +169 -1935
  164. package/src/server/term.mjs +11 -249
  165. package/src/server/thermal-metrics.mjs +520 -0
  166. package/src/server/thermal-sampler.mjs +204 -0
  167. package/src/server/token-redact.mjs +123 -0
  168. package/src/server/transcript-gate.mjs +107 -0
  169. package/src/server/transcript-scan.mjs +563 -0
  170. package/src/server/usage-routes.mjs +93 -0
  171. package/src/server/uv-bootstrap.mjs +1 -1
  172. package/src/server/wordmark.mjs +127 -0
  173. package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
  174. package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
  175. package/dist/web/assets/index-C1YFI9f4.js +0 -298
  176. package/dist/web/assets/index-Ck2RAla3.css +0 -1
@@ -0,0 +1,132 @@
1
+ // The pulse line — the one line that stays on screen for hours under the boot
2
+ // report — and the "not registered" sentence printed above it when this deck's
3
+ // discovery file could not be written.
4
+ //
5
+ // Lifted out of term.mjs, whose glyph tier it takes its dash and bullet from:
6
+ // term.mjs keeps the rules about the terminal, and this is what the deck says
7
+ // on it. bin/cli/pulse.js paints the line and bin/deck.js prints the sentence,
8
+ // and both are decided here, where every input is a parameter and nothing
9
+ // writes.
10
+ import { glyphs } from "./term.mjs";
11
+
12
+ /**
13
+ * The one line that stays on screen for hours, sized to the terminal it is on.
14
+ *
15
+ * It is redrawn over itself with `\r`, so both messages are padded to one width
16
+ * — the shorter has to cover the longer — and that width is clamped to the real
17
+ * terminal: at 40 columns the old 58-character message wrapped, after which the
18
+ * `\r` only ever reached the second row and every repaint left the first one
19
+ * behind. A deck no hook can find is not listening in any sense the user cares
20
+ * about, which is why that state has a message here at all.
21
+ *
22
+ * `claude` is what makes that last sentence conditional. A deck watching only
23
+ * Codex is not waiting on a hook: startCodexWatcher is started by startServer
24
+ * and never consults the discovery file, and writesCodexLog keeps a deck with no
25
+ * record on disk writing its log. Capture and persistence both work perfectly
26
+ * there, while this line used to repaint "hooks cannot find this deck" every
27
+ * 800ms, forever (#404) — a permanent alarm about a mechanism that deck does not
28
+ * use. The one-time report at boot still says what IS lost; see
29
+ * unregisteredDetail.
30
+ */
31
+ export function pulseText({
32
+ registered = true, claude = true, columns = 80, unicode = true, indent = 2, busy = null,
33
+ } = {}) {
34
+ const g = glyphs(unicode);
35
+ const room = Math.max(4, columns - indent - 3 - 1);
36
+ const pick = (options) => options.find((o) => o.length <= room) ?? options[options.length - 1].slice(0, room);
37
+
38
+ const rest = pick([`listening ${g.dash} Ctrl+C to stop`, "listening"]);
39
+ const bad = pick([
40
+ `listening, but not registered ${g.dash} hooks cannot find this deck`,
41
+ `not registered ${g.dash} hooks cannot find this deck`,
42
+ "not registered",
43
+ ]);
44
+ // Both branches are still measured, registered or not, because the line is
45
+ // redrawn over itself and the shorter message has to cover the longer one on
46
+ // the beat after a deck loses its registration.
47
+ //
48
+ // The width is deliberately computed from the two FIXED messages only. `busy`
49
+ // comes and goes on a single boot — an install starts, the line names it, the
50
+ // install ends and the line goes back to Ctrl+C — so a width that grew to fit
51
+ // the label would have to shrink again afterwards, and the shorter line would
52
+ // leave the tail of the longer one on screen. Instead the label is shown only
53
+ // where it already fits — 60 columns and wider, measured — and below that the
54
+ // line says the true thing it has always said.
55
+ const width = Math.min(room, Math.max(rest.length, bad.length));
56
+ // What is still happening, rather than what is always true. `Ctrl+C to stop`
57
+ // is the right thing to say to somebody with nothing left to wait for, and
58
+ // the wrong thing to say to somebody watching an install.
59
+ const label = typeof busy === "string" && busy.trim() ? busy.trim() : null;
60
+ const working = label ? `listening ${g.bullet} ${label}` : null;
61
+ const ok = working && working.length <= width ? working : rest;
62
+ return (registered || !claude ? ok : bad).padEnd(width);
63
+ }
64
+
65
+ /**
66
+ * Whether the line has anything left to say by moving.
67
+ *
68
+ * #742. The dot alternated green and grey every 800ms for as long as the deck
69
+ * ran, and a blinking indicator beside a status line is the vocabulary of
70
+ * "working on it" — so a boot that had finished in a second read as one that
71
+ * never finished, and people said so. The deck's own web UI already retired
72
+ * this once: the pill goes quiet at rest (#720). The terminal did not.
73
+ *
74
+ * Motion is now spent on the two states where something is genuinely
75
+ * outstanding — a deck no hook can find, and a background job still running —
76
+ * and nowhere else. At rest the dot is painted once, in the healthy colour, and
77
+ * left alone. Movement then means something changed, which is the only thing
78
+ * movement should ever mean on a line somebody leaves open for hours.
79
+ */
80
+ export function pulseMoves({ registered = true, claude = true, busy = null } = {}) {
81
+ if (!registered && claude) return true;
82
+ return typeof busy === "string" && busy.trim() !== "";
83
+ }
84
+
85
+ /**
86
+ * Whether the dot is lit on this beat.
87
+ *
88
+ * `"on"` on every beat of a deck at rest, which is what makes the line still:
89
+ * bin/cli/pulse.js paints a beat only when the frame differs from the one
90
+ * already on screen, so a dot that is always lit is a line written once and
91
+ * then left alone. Alternating is reserved for the states pulseMoves admits.
92
+ *
93
+ * The beat is a parameter rather than counted here so this is a function of its
94
+ * inputs and nothing else — and so a test can ask what the twentieth beat of an
95
+ * idle deck looks like without waiting sixteen seconds for it.
96
+ */
97
+ export function pulseDot(beat, { registered = true, claude = true, busy = null } = {}) {
98
+ if (!pulseMoves({ registered, claude, busy })) return "on";
99
+ return beat % 2 === 0 ? "on" : "off";
100
+ }
101
+
102
+ /**
103
+ * The second line of the "not registered" report, which bin/deck.js prints once
104
+ * per change of state rather than every beat.
105
+ *
106
+ * What a missing discovery file costs is not the same on the two capture paths,
107
+ * and the old sentence — "hooks find this deck through <file>, so until that
108
+ * file exists no events arrive" — stated the Claude Code answer as if it were
109
+ * both. On a Codex-only deck it is false twice over: no hook is looking for this
110
+ * deck, and events do keep arriving, because the rollout watcher reads the files
111
+ * directly. What that deck really loses is the writer election in
112
+ * log-election.mjs, which is how several decks tailing one rollout agree on
113
+ * which of them appends it to a shared events log. A deck with no record on
114
+ * disk is assumed to be writing, so nothing is dropped — the log can gain the
115
+ * same line twice instead.
116
+ *
117
+ * @param file the discovery file that could not be written.
118
+ * @param claude whether this deck is watching Claude Code at all.
119
+ * @param dash the em dash, or the ASCII stand-in on a console that cannot draw
120
+ * one (#797). A parameter for the same reason `renameNotice` takes one:
121
+ * term.mjs says, above its glyphs, "every glyph the deck prints — punctuation
122
+ * included, since an em dash is as absent from CP437 as a check mark — comes
123
+ * from one of them", and this sentence had it hardcoded. On a legacy cmd.exe
124
+ * or the Linux virtual console the reader got a box in the middle of the one
125
+ * line explaining why no events are arriving.
126
+ */
127
+ export function unregisteredDetail({ file, claude = true, dash = "—" }) {
128
+ if (claude) {
129
+ return `Claude Code hooks find this deck through ${file}, so until that file exists no Claude Code events arrive.`;
130
+ }
131
+ return `Codex capture does not use ${file} ${dash} the deck tails the rollout files itself ${dash} so events still arrive. Only decks sharing one events log need it, to agree on which of them records.`;
132
+ }
@@ -32,6 +32,7 @@ import { readFile, rm } from "node:fs/promises";
32
32
  import { homedir } from "node:os";
33
33
  import { join } from "node:path";
34
34
  import { deckDataDir, legacyDeckDir } from "./deck-home.mjs";
35
+ import { stripBom } from "./atomic-write.mjs";
35
36
 
36
37
  /** The file. Named once, because it is spelled in deck-prefs.mjs too and the
37
38
  * two must not drift — see `prefsPath`, which is the same join. */
@@ -88,13 +89,12 @@ export async function findKeyFiles(dirs = keyDirs(), deps = {}) {
88
89
  continue;
89
90
  }
90
91
  try {
91
- // The BOM, stripped the way installer.mjs strips it off settings.json:
92
- // `JSON.parse` throws on one and a file Notepad saved is not damaged.
93
- // Spelled out rather than imported because installer.mjs's `stripBom` is
94
- // private to that module today — #1056 exports it, and this call should
95
- // become that import once it lands, so the rule has one spelling.
96
- const text = String(raw);
97
- const parsed = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
92
+ // The BOM, stripped by the rule settings.json, prefs.json and the
93
+ // browser-watch state are all read with: `JSON.parse` throws on one and
94
+ // a file Notepad saved is not damaged. This was a copy of it, spelled out
95
+ // while stripBom was private; it is exported now (atomic-write.mjs), so
96
+ // the rule has one spelling.
97
+ const parsed = JSON.parse(stripBom(String(raw)));
98
98
  const secret = parsed?.lan?.secret;
99
99
  out.push({ path, holds: typeof secret === "string" && secret !== "" ? "key" : "no-key", why: "" });
100
100
  } catch (err) {
@@ -0,0 +1,174 @@
1
+ // Source 3 of quota.mjs's chain: `claude --print /usage`, and the `claude` it
2
+ // runs.
3
+ //
4
+ // The most expensive of the three sources — a whole Claude Code process per
5
+ // attempt — and the only self-service one on a Mac, where the token is in the
6
+ // Keychain and there is no credentials file to read. What lives here is the
7
+ // part that is about the CLI itself: which binary to run, one run of it, and
8
+ // the one line said when that run fails. How many attempts, how far apart, and
9
+ // what to publish when none of them prints a window stay in quota.mjs, beside
10
+ // the floors and the cache those answers are decided against.
11
+ import { claudeCliCandidates } from "./claude-dir.mjs";
12
+ import { pathLookup, run } from "./exec.mjs";
13
+ import { existsSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { PRODUCT } from "./brand.mjs";
16
+ import { stripAnsi } from "./term.mjs";
17
+ import { parseUsageText } from "./quota-shape.mjs";
18
+
19
+ // Where the `claude` CLI can be. The list moved to claude-dir.mjs, which is the
20
+ // module that owns every "where does Claude Code live" answer the deck has —
21
+ // the config dir was already there, and the boot-time presence check that reads
22
+ // this same list had no business importing a quota poller to get at it.
23
+
24
+ /** Which `claude` to run for `--print /usage`: the first candidate that exists.
25
+ *
26
+ * This used to hand back a whole shell command line — `"<bin>" --print /usage
27
+ * < /dev/null` — for `exec()` to parse. Double quotes are not escaping on
28
+ * POSIX: `$(…)`, backticks and `\` all still work inside them, and every
29
+ * ingredient of that line came from the environment (`%APPDATA%`, `homedir()`),
30
+ * so a home directory named `/home/a$(id)b` was shell code the quota poll ran
31
+ * every minute. A bare `$` was the duller half of the same bug — it expanded
32
+ * to nothing and the probe looked for a binary at a path that did not exist.
33
+ *
34
+ * There is nothing left to escape once there is no shell: exec.mjs's `run`
35
+ * spawns the argument vector as given, resolves the Windows `.cmd`/`.exe`
36
+ * spelling itself, and closes the child's stdin — which is what `< /dev/null`
37
+ * was for, since `claude --print` waits three seconds on a stdin pipe nobody
38
+ * is writing to.
39
+ *
40
+ * Exported, with everything it touches injectable, so the Windows branch is
41
+ * testable from the platforms this repo is actually developed on.
42
+ *
43
+ * WHY THE BARE NAME HAS TO EARN ITS PLACE (#553). This used to be a `.find`
44
+ * over `!c.includes(sep) || exists(c)`, which reads as "a bare name always
45
+ * answers, a full path only when it is there". On Windows that is harmless —
46
+ * the bare name is LAST in the list — but on POSIX it is FIRST, so the `||`
47
+ * short-circuited on candidate one and `exists` was never called even once:
48
+ * `~/.local/bin/claude`, `/usr/local/bin/claude` and `/opt/homebrew/bin/claude`
49
+ * were in a list nothing ever read. The user this broke is the one
50
+ * claude-dir.mjs names out loud: Claude Code installed by the official
51
+ * installer, so the binary is at `~/.local/bin/claude`, and the deck launched
52
+ * from something whose PATH never sourced a shell rc — a LaunchAgent, a
53
+ * systemd user unit, pm2, a desktop shortcut. `hasClaudeInstalled()` stats the
54
+ * absolute paths and says yes, so hooks install and the Claude surface turns
55
+ * on; every `claude --print /usage` spawn is then a bare-name ENOENT logged as
56
+ * `quota: claude CLI failed`. On macOS there is no `.credentials.json` to fall
57
+ * back to (the token is in the Keychain, #360), so the quota panel simply stays
58
+ * dark on a machine that plainly has Claude Code. The identical install on
59
+ * Windows worked, because there the ordering already said what this now says.
60
+ *
61
+ * WHICH WINS. The candidate list's own order decides, unchanged on both
62
+ * platforms — PATH first on POSIX, the two known install directories first on
63
+ * Windows — because the ordering question here is the one getRunner in
64
+ * ccusage.mjs already answered: preferring a different copy would silently
65
+ * change which binary runs on every machine that has two, and a deck that
66
+ * works today must not start running a `claude` it has never run. A user with
67
+ * a current claude on PATH via nvm/mise/volta and a stale one left in
68
+ * `~/.local/bin` keeps getting the one their own shell gives them. All that
69
+ * changes is that a bare name is now only ANSWERED WITH when PATH actually
70
+ * holds it, which is the same rule claudeCliOnDisk in claude-dir.mjs has
71
+ * always applied to this very list — the two readers of one list can no longer
72
+ * disagree about whether the deck can run what it says is installed.
73
+ *
74
+ * WHAT IT COSTS. One PATH walk, stopping at the first hit, and only for the
75
+ * bare candidate; the absolute paths are stat'ed only once PATH has come up
76
+ * empty. That is the trade ccusage.mjs already priced for the same shape of
77
+ * question — "a handful of stats, once per uncached fetch, against a process
78
+ * spawn that follows it" — and here the spawn that follows is a whole Claude
79
+ * Code process measured at ~3s, behind the SELF_POLL_MS floor.
80
+ *
81
+ * `pathLookup` is used as a yes/no gate rather than for the path it found, on
82
+ * purpose: answering with the bare name keeps spawn's own resolution (and, on
83
+ * Windows, exec.mjs's PATHEXT candidate walk) in charge of the PATH case
84
+ * exactly as before, so a PATH entry that merely LOOKS like a hit — a
85
+ * directory named `claude` — cannot become the answer.
86
+ */
87
+ export function quotaClaudeBin(platform = process.platform, env = process.env,
88
+ home = homedir(), exists = existsSync) {
89
+ const sep = platform === "win32" ? "\\" : "/";
90
+ // process.env is case-insensitive on Windows; an injected plain object in a
91
+ // test is not, and %Path% is how the variable is actually spelled there.
92
+ const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
93
+ for (const c of claudeCliCandidates(platform, env, home)) {
94
+ // A full path is worth a single stat; a bare name means "ask PATH", which
95
+ // is pathLookup's walk — PATHEXT included, since `claude` on Windows is
96
+ // spelled `claude.exe` or `claude.cmd` and never the bare word.
97
+ if (c.includes(sep)) { if (exists(c)) return c; }
98
+ else if (pathLookup(c, platform, { pathEnv, exists })) return c;
99
+ }
100
+ // Nothing on PATH and nothing at any known install directory. The bare name
101
+ // is still the right last resort — POSIX `execvp` and cmd.exe's own search
102
+ // both deserve their turn at a layout no list here knows — and the ENOENT it
103
+ // produces is what `quota: claude CLI failed` reports.
104
+ return "claude";
105
+ }
106
+
107
+ /**
108
+ * The failure this last said out loud, so a standing one is said once.
109
+ *
110
+ * #742. A Windows user with no Claude Code installed sent a screenshot of three
111
+ * identical lines — `ccdeck quota: claude CLI failed: claude exited ENOENT` —
112
+ * interleaved with the deck's pulse line, and they keep coming for as long as
113
+ * the deck runs. Every poll ran the retry loop in quota.mjs's _doFetch three
114
+ * times, and every attempt printed. A CLI that is not installed is not news
115
+ * three times a minute; it is a condition, and a condition is worth exactly
116
+ * one line.
117
+ *
118
+ * Cleared on the first run that works, so a `claude` installed while the deck
119
+ * is up can still report its next genuine failure.
120
+ */
121
+ let _saidFailure = null;
122
+
123
+ /** Exported for its test, and for the same reason resetCswapBin is: a module
124
+ * that remembers something across calls needs a way to be asked twice.
125
+ *
126
+ * Deliberately NOT folded into invalidateQuotaCache, which production calls
127
+ * after an account switch — forgetting the notice there would put the same
128
+ * sentence back on the terminal every time somebody changed accounts. */
129
+ export function forgetQuotaFailureNotice() { _saidFailure = null; }
130
+
131
+ // Run `claude --print /usage` once. Returns { cliOk, ran, missing, parsed }.
132
+ // cliOk — we recognized its output (preamble present), whatever the exit
133
+ // ran — the run itself succeeded, which cliOk does not say
134
+ // missing — there is no `claude` to run at all
135
+ // parsed — quota percentages object, or null if the "Current session/week"
136
+ // lines were absent (CLI cold-start, or genuinely <1% usage)
137
+ export async function runUsageOnce(bin) {
138
+ const r = await run(bin, ["--print", "/usage"], {
139
+ timeout: 15_000,
140
+ maxBuffer: 1024 * 1024,
141
+ // Marks this Claude Code run as the deck's own. `claude --print /usage`
142
+ // is a full invocation, so it fires the hooks we installed, and every
143
+ // quota poll was drawing itself onto the canvas as a fresh session with
144
+ // no prompt and no tools. Hooks inherit the environment, so hook.js
145
+ // sees this and stays quiet.
146
+ env: { ...process.env, NO_COLOR: "1", TERM: "dumb", AGENTS_DECK_INTERNAL: "1" },
147
+ });
148
+ // `run` never rejects, so there is one path rather than two — and the output
149
+ // is kept either way, which matters because the CLI writes the quota lines to
150
+ // stdout and can still exit non-zero afterwards.
151
+ const combined = r.stdout + "\n" + r.stderr;
152
+ // `run` normalises a binary that is not there to this, on every platform —
153
+ // see exec.mjs. It is the difference between "Claude Code answered badly",
154
+ // which is worth retrying and worth saying, and "there is no Claude Code on
155
+ // this machine", which is neither.
156
+ const missing = r.code === "ENOENT";
157
+ if (!r.ok) {
158
+ const msg = stripAnsi(r.stderr).trim() || `claude exited ${r.code}`;
159
+ if (msg !== _saidFailure) {
160
+ _saidFailure = msg;
161
+ console.error(`${PRODUCT} quota: claude CLI failed:`, msg);
162
+ }
163
+ } else {
164
+ _saidFailure = null;
165
+ }
166
+ const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
167
+ // WHETHER THE RUN ITSELF SUCCEEDED, which `cliOk` does not answer. `cliOk` is
168
+ // a test of the OUTPUT — it means "we recognised what came back" — and a CLI
169
+ // that printed its banner and then failed satisfies it. That is the right
170
+ // rule for the parse above (see the note there: the quota lines can be on
171
+ // stdout and the exit non-zero), and the wrong one for the no-numbers
172
+ // fallback in _doFetch, which was publishing 0% for a run that errored.
173
+ return { cliOk, ran: r.ok, missing, parsed: parseUsageText(combined) };
174
+ }
@@ -0,0 +1,190 @@
1
+ // Claude Code's OAuth token, as quota.mjs borrows it: where it is kept, whether
2
+ // this machine has one at all, how a request made with it introduces itself,
3
+ // and the two limits every such request is under — the floor between two the
4
+ // deck pays for, and the 429 cooldown they all share.
5
+ //
6
+ // Source 2 of the chain lives here too — one GET of the usage endpoint, mapped
7
+ // by quota-shape.mjs — because it is nothing more than a request with this
8
+ // token. Whether to make it is quota.mjs's decision, taken against the
9
+ // self-poll floor; this module knows only how, and when a 429 says to stop.
10
+ import { claudeConfigDir } from "./claude-dir.mjs";
11
+ import { readFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { mapOAuthUsage } from "./quota-shape.mjs";
14
+
15
+ export const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
16
+ const BETA_HEADER = "oauth-2025-04-20";
17
+
18
+ // Source 2's request, asking for the reset inventory as well as the windows.
19
+ const USAGE_WITH_RESETS_URL = `${USAGE_URL}?cedar_ember=1`;
20
+
21
+ /**
22
+ * How these requests introduce themselves, in the format Claude Code uses for
23
+ * its own.
24
+ *
25
+ * The endpoint decides whether to send the reset inventory by client surface:
26
+ * a User-Agent it does not recognise as Claude Code's CLI gets `eligible:
27
+ * false, ineligible_reason: "surface"` and no grants, and a CLI version it
28
+ * considers too old gets `"cli_version"`. This deck used to send
29
+ * `claude-code/2.1.0`: written to look like Claude Code, in a format the
30
+ * endpoint does not take for it. The windows do not depend on any of this —
31
+ * claude-swap reads the same ones as `claude-swap/1.0`.
32
+ *
33
+ * Pinned rather than read off the installed binary: the version lives in a
34
+ * different place under every install method, and a pin that ages out fails
35
+ * closed — the endpoint stops sending grants, the row disappears, and no number
36
+ * on the panel is wrong. Raising it is the fix when that happens.
37
+ */
38
+ const USER_AGENT = "claude-cli/2.1.283 (external, cli)";
39
+
40
+ // Floor between two polls WE pay for. Twelve an hour against a budget of
41
+ // ~28-30 leaves claude-swap room to collect for every account, which is what
42
+ // the accounts panel is made of. Only reached when the store cannot answer.
43
+ export const SELF_POLL_MS = 5 * 60_000;
44
+
45
+ // 429 cooldown gate — after a rate-limit, skip the API until this passes.
46
+ let _rateLimitedUntil = 0;
47
+
48
+ /** When the cooldown ends, or 0 when there is none. For the budget rules,
49
+ * which take it as an argument so they stay pure. */
50
+ export function cooldownUntil() { return _rateLimitedUntil; }
51
+
52
+ /** Whether a 429 is still being waited out at `now`. */
53
+ export function coolingDown(now) { return now < _rateLimitedUntil; }
54
+
55
+ /** A 429 arrived: hold every request this token makes off for as long as its
56
+ * `retry-after` says, within the limits cooldownFromHeader keeps. One token,
57
+ * one budget, so both requests that can be refused back off the same way. */
58
+ export function startCooldown(res) {
59
+ _rateLimitedUntil = Date.now() + cooldownFromHeader(res.headers.get("retry-after"), 5 * 60_000);
60
+ }
61
+
62
+ /** The cooldown, forgotten — for resetQuotaPollFloor. */
63
+ export function clearCooldown() { _rateLimitedUntil = 0; }
64
+
65
+ /**
66
+ * Where Claude Code keeps the OAuth credentials this module borrows a token
67
+ * from.
68
+ *
69
+ * It is `.credentials.json` inside the Claude config dir, and that dir moves:
70
+ * CLAUDE_CONFIG_DIR replaces ~/.claude wholesale rather than overlaying it, so
71
+ * on a machine where it is set there is no ~/.claude to read at all. Hardcoding
72
+ * ~/.claude here did not fail loudly — it made readOAuthToken() return null
73
+ * forever, which reads exactly like "this machine keeps its credentials in the
74
+ * Keychain", and the quota chain quietly fell through to source 3 on every poll
75
+ * it was allowed to make. See src/server/claude-dir.mjs, which owns the rule
76
+ * and is the only place it is spelled.
77
+ *
78
+ * Resolved per call rather than frozen into a module-level constant, for the
79
+ * same reason claudeConfigDir() is a function: a constant captured at import
80
+ * time is a value nothing can observe or correct afterwards, and this module is
81
+ * imported lazily by the /api/quota route rather than at a point in startup
82
+ * anyone here controls.
83
+ *
84
+ * Exported for tests — it is the whole of the bug, and it is pure.
85
+ */
86
+ export function credentialsPath() {
87
+ return join(claudeConfigDir(), ".credentials.json");
88
+ }
89
+
90
+ export async function readOAuthToken() {
91
+ try {
92
+ const raw = await readFile(credentialsPath(), "utf8");
93
+ const auth = JSON.parse(raw)?.claudeAiOauth;
94
+ if (!auth?.accessToken) return null;
95
+ // expiresAt is epoch milliseconds. If expired, the CLI fallback handles it.
96
+ if (auth.expiresAt && Date.now() >= auth.expiresAt) return null;
97
+ return auth.accessToken;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * A cooldown from a `retry-after`, kept inside limits the deck can live with.
105
+ *
106
+ * Unclamped, the header decided the poller's fate in both directions: `0` (or a
107
+ * value the server rounds down to it) defeats the cooldown entirely and the
108
+ * next tick asks again immediately, which is the loop a 429 exists to stop; a
109
+ * large one — a day is a legal value — freezes the reader for the life of the
110
+ * process, and nothing here re-reads it. Both are the remote side deciding how
111
+ * this deck behaves, which a header is not entitled to do.
112
+ *
113
+ * The floor is the deck's own minimum backoff and the ceiling is an hour: long
114
+ * enough to be a real retreat, short enough that a quota panel is not dead for
115
+ * the rest of the day because one reply said so.
116
+ */
117
+ export function cooldownFromHeader(raw, fallbackMs, minMs = 30_000, maxMs = 3600_000) {
118
+ const seconds = parseInt(String(raw ?? ""), 10);
119
+ if (!Number.isFinite(seconds)) return fallbackMs;
120
+ return Math.min(Math.max(seconds * 1000, minMs), maxMs);
121
+ }
122
+
123
+ /**
124
+ * WHETHER THIS MACHINE HAS A SUBSCRIPTION TO REPORT ON AT ALL.
125
+ *
126
+ * Every source here needs a Claude.ai OAuth credential: the claude-swap store
127
+ * holds one, `claudeAiOauth` in the credentials file is one, and
128
+ * `claude --print /usage` prints windows only for a session signed in with one.
129
+ * An API-key, Bedrock or Vertex install has none — and there is no quota to
130
+ * read, because those are billed per token rather than in five-hour windows.
131
+ *
132
+ * That mattered because of what the CLI does on such a machine: it RUNS, prints
133
+ * no quota lines, and the no-numbers branch of quota.mjs's _doFetch used to
134
+ * read that as "genuine <1%" and publish `ok: true` with two zeroes. The panel
135
+ * then drew empty bars, which is a measurement nobody took. Codex already
136
+ * answers this properly, with `api_key_mode` as its own reason and its own
137
+ * sentence.
138
+ *
139
+ * Cheap and synchronous: environment first, because a machine configured for
140
+ * Bedrock or Vertex says so there, then the presence of the OAuth block in the
141
+ * credentials file. `readOAuthToken` above answers a different question — it
142
+ * also rejects an EXPIRED token, and an expired subscription is still a
143
+ * subscription.
144
+ */
145
+ export async function hasSubscriptionCredential(env = process.env) {
146
+ if (env.CLAUDE_CODE_USE_BEDROCK === "1" || env.CLAUDE_CODE_USE_VERTEX === "1") return false;
147
+ try {
148
+ const raw = await readFile(credentialsPath(), "utf8");
149
+ if (JSON.parse(raw)?.claudeAiOauth?.accessToken) return true;
150
+ } catch { /* absent or unreadable, decided below */ }
151
+ // A key in the environment and no OAuth block beside it is the API-key
152
+ // install. Without either, this deck simply has not been signed in yet, and
153
+ // "sign in" is the right thing to say — which is the `waiting` branch, not
154
+ // this one.
155
+ return !(env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN);
156
+ }
157
+
158
+ /** The headers every request made with the Claude Code token sends. */
159
+ export function oauthHeaders(token) {
160
+ return {
161
+ "Authorization": `Bearer ${token}`,
162
+ "anthropic-beta": BETA_HEADER,
163
+ "Accept": "application/json",
164
+ "Content-Type": "application/json",
165
+ "User-Agent": USER_AGENT,
166
+ };
167
+ }
168
+
169
+ export async function fetchOAuthUsage() {
170
+ if (coolingDown(Date.now())) return null;
171
+ const token = await readOAuthToken();
172
+ if (!token) return null;
173
+
174
+ try {
175
+ const res = await fetch(USAGE_WITH_RESETS_URL, {
176
+ headers: oauthHeaders(token),
177
+ signal: AbortSignal.timeout(15_000),
178
+ });
179
+
180
+ if (res.status === 429) {
181
+ startCooldown(res);
182
+ return null;
183
+ }
184
+ if (!res.ok) return null;
185
+
186
+ return mapOAuthUsage(await res.json());
187
+ } catch {
188
+ return null;
189
+ }
190
+ }