ccdeck 3.22.14 → 3.23.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 (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +252 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -182
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +385 -51
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +495 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BdYsAtN8.js +0 -274
  51. package/dist/web/assets/index-CgwmcBHv.css +0 -1
@@ -130,8 +130,25 @@ ${vars}
130
130
  */
131
131
  export function unitFor({ execPath, script, logPath, args = [], env = {}, product = "ccdeck" } = {}) {
132
132
  const cmd = [execPath, script, ...args].map(a => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
133
+ // QUOTED, AND `%` ESCAPED. systemd.exec(5) splits an Environment= assignment
134
+ // on whitespace unless the whole thing is double-quoted, and it expands `%`
135
+ // specifiers in the value. ExecStart above quotes its own arguments; this
136
+ // line did neither, so a scope directory with a space in it was truncated at
137
+ // the space. Handed to systemd itself:
138
+ //
139
+ // $ systemd-analyze verify ccdeck.service
140
+ // ccdeck.service:6: Invalid environment assignment, ignoring: Configs/.claude
141
+ //
142
+ // from `Environment=CLAUDE_CONFIG_DIR=/home/ana/My Configs/.claude`. The deck
143
+ // then received `/home/ana/My` — neither the shell's value nor the default,
144
+ // which is the two-registries outcome scopeEnv's own header describes: "two
145
+ // decks and two LAN keys on one machine". `%b` is the boot-ID specifier, so
146
+ // `/home/ana/100%backup` was rewritten just as quietly.
147
+ //
148
+ // The macOS branch was always safe: plistFor puts each value in its own
149
+ // <string> and XML-escapes it. Windows carries none of them.
133
150
  const vars = Object.entries({ AGENTS_DECK_DETACHED: "1", ...env })
134
- .map(([k, v]) => `Environment=${k}=${v}`).join("\n");
151
+ .map(([k, v]) => `Environment="${k}=${String(v).replace(/%/g, "%%")}"`).join("\n");
135
152
  return `[Unit]
136
153
  Description=${product} — live deck of Claude Code + Codex agents
137
154
  After=default.target
@@ -359,9 +376,19 @@ export function installService({
359
376
  // shell that installed it: launchd hands the job a minimal environment,
360
377
  // systemd --user the same. That is right in production — the deck should see
361
378
  // the user's real home — and it is exactly what makes a login item impossible
362
- // to test in a sandbox without this parameter. The end-to-end test passes
363
- // HOME and the deck's own directories through here; nothing else does.
364
- serviceEnv = {},
379
+ // to test in a sandbox without this parameter.
380
+ //
381
+ // DEFAULTED, NOT LEFT TO EACH CALLER TO REMEMBER. The scope directories
382
+ // belong in every login item this deck writes, and two of the three callers
383
+ // did not pass them: `--install` and `--install-service` wrote an item keyed
384
+ // to the default registries while the shell that typed them was keyed to
385
+ // CLAUDE_CONFIG_DIR — the two-decks-on-one-machine split scopeEnv's own
386
+ // header describes, arrived at from the two entry points whose whole purpose
387
+ // is to install. Only the first-start offer carried them. A caller that
388
+ // genuinely wants a different job environment — the sandboxed end-to-end
389
+ // test, which passes HOME and the deck's own directories — still says so,
390
+ // and replaces this outright rather than adding to it.
391
+ serviceEnv = scopeEnv(env),
365
392
  product = "ccdeck",
366
393
  fs = { mkdirSync, writeFileSync },
367
394
  run = spawnSync,
@@ -98,8 +98,28 @@ export function launchers(url, { platform = process.platform, env = process.env,
98
98
  // not wait for openUrl.
99
99
  const tries = [{
100
100
  file: "powershell.exe",
101
- // The URL as its own argv entry rather than inside a script string, where
102
- // `;` and `&` are PowerShell's own operators.
101
+ // THIS IS NOT A SEPARATE ARGV CHANNEL, and the comment that used to sit
102
+ // here said it was: "the URL as its own argv entry rather than inside a
103
+ // script string, where `;` and `&` are PowerShell's own operators."
104
+ // There is no such entry. `powershell.exe -Command` documents that the
105
+ // value must be the LAST parameter, *"because any characters typed after
106
+ // the command are interpreted as the command parameters"* — everything
107
+ // after it is appended to the command TEXT and parsed by PowerShell, so
108
+ // `Start-Process` and the URL arrive as one script line whatever argv
109
+ // they were handed in as.
110
+ //
111
+ // The repo has already discovered this one module over. browser-react.mjs
112
+ // records it verbatim for the toast: the same shape, the same wrong
113
+ // assumption, and a notification that "never appeared on Windows, for the
114
+ // reaction that is the default".
115
+ //
116
+ // Nothing is broken today, which is why this is a comment repair rather
117
+ // than a code one: the only caller passes a loopback URL this deck built
118
+ // itself, and the `cmd /c start` fallback below would catch a parse
119
+ // failure anyway. What was wrong is a file whose stated job is to encode
120
+ // platform knowledge recording a safety property it does not have — and
121
+ // the next caller to pass a URL with a query string in it would have
122
+ // trusted that sentence.
103
123
  args: ["-NoProfile", "-NonInteractive", "-Command", "Start-Process", url],
104
124
  }];
105
125
  const viaCmd = startCommand(url, env);
@@ -120,8 +120,11 @@ export function blocksIn(chunk) {
120
120
  export function createOutputWatch(io = {}) {
121
121
  const statFile = io.stat ?? (p => stat(p));
122
122
  const openFile = io.open ?? (p => open(p, "r"));
123
- /** sid -> { path, offset, size } — where this session's file is and how far
124
- * into it the watch has already looked. */
123
+ /** sid -> { path, offset } — where this session's file is and how far into it
124
+ * the watch has already looked. No size beside them: every decision in
125
+ * `pollOnce` is made from the size its own `stat` just returned, and the one
126
+ * that used to be remembered here was written four times and read by
127
+ * nothing (#1089). */
125
128
  const seen = new Map();
126
129
 
127
130
  /** Remember where a session's transcript is. Called for every hook payload
@@ -135,7 +138,7 @@ export function createOutputWatch(io = {}) {
135
138
  // Start at the END of a file the watch has not seen before. Everything
136
139
  // already in it happened before the deck looked, and replaying it would
137
140
  // report a morning's thinking as having just occurred.
138
- seen.set(sid, { path, offset: null, size: null });
141
+ seen.set(sid, { path, offset: null });
139
142
  }
140
143
 
141
144
  function forget(sid) { seen.delete(sid); }
@@ -152,8 +155,15 @@ export function createOutputWatch(io = {}) {
152
155
  * Sessions the caller believes are live, and no others: a finished session's
153
156
  * file can still be appended to by a later turn, and the deck has its own
154
157
  * rules about what counts as live. This asks no questions about that.
158
+ *
159
+ * `onLines`, when given, is handed each session's new whole lines as one
160
+ * string, with the file they came from. The watch is the only reader that
161
+ * looks at a transcript while a session is resting, and resting is exactly
162
+ * when Claude Code writes its recap (see session-recap.mjs). It is a tap, not
163
+ * a filter: nothing it does changes the blocks returned, and a throw inside
164
+ * it stops at the tap rather than at the watch.
155
165
  */
156
- async function poll(liveSids) {
166
+ async function pollOnce(liveSids, onLines) {
157
167
  const out = [];
158
168
  for (const sid of liveSids) {
159
169
  const entry = seen.get(sid);
@@ -163,15 +173,15 @@ export function createOutputWatch(io = {}) {
163
173
  const size = Number(st?.size ?? 0);
164
174
  // First sight: record where the end is and report nothing. The file's
165
175
  // whole history is older than this deck's interest in it.
166
- if (entry.offset == null) { entry.offset = size; entry.size = size; continue; }
176
+ if (entry.offset == null) { entry.offset = size; continue; }
167
177
  // TRUNCATED OR REPLACED. A file that is now shorter than the byte this
168
178
  // watch was going to read from is not the file it was reading; seeking to
169
179
  // the old offset would return whatever now lives there. Start again at
170
180
  // the new end rather than guessing which bytes are which.
171
- if (size < entry.offset) { entry.offset = size; entry.size = size; continue; }
181
+ if (size < entry.offset) { entry.offset = size; continue; }
172
182
  if (size === entry.offset) continue;
173
183
  // Past the ceiling, skip to the end. See MAX_TAIL_BYTES.
174
- if (size - entry.offset > MAX_TAIL_BYTES) { entry.offset = size; entry.size = size; continue; }
184
+ if (size - entry.offset > MAX_TAIL_BYTES) { entry.offset = size; continue; }
175
185
 
176
186
  const from = entry.offset;
177
187
  const want = size - from;
@@ -187,7 +197,6 @@ export function createOutputWatch(io = {}) {
187
197
  const dec = new StringDecoder("utf8");
188
198
  text = dec.write(buf.subarray(0, Math.max(0, bytesRead))) + dec.end();
189
199
  entry.offset = from + Math.max(0, bytesRead);
190
- entry.size = size;
191
200
  } catch {
192
201
  continue;
193
202
  } finally {
@@ -214,9 +223,51 @@ export function createOutputWatch(io = {}) {
214
223
  // Oldest first, so a reader that refuses to move backwards accepts the
215
224
  // whole run rather than only its last member.
216
225
  for (const b of blocksIn(text)) out.push({ sid, kind: b.kind, at: b.at });
226
+ if (onLines) {
227
+ try { onLines(sid, text, entry.path); } catch { /* the tap's failure, not the watch's */ }
228
+ }
217
229
  }
218
230
  return out;
219
231
  }
220
232
 
233
+ /** Whether a call is inside `pollOnce` right now. */
234
+ let polling = false;
235
+
236
+ /**
237
+ * `pollOnce`, one call at a time (#1089).
238
+ *
239
+ * A session's offset is read at the top of `pollOnce` and written back only
240
+ * after the `stat`, the `open` and the `read` it waits on, so a second call
241
+ * that enters while the first is inside any of them reads the same offset,
242
+ * reads the same bytes and answers with the same blocks — and the server
243
+ * pushes an OutputObserved for each copy. Measured through this module's own
244
+ * `io` seam, with the interleave exact rather than timed: one block appended,
245
+ * two overlapping ticks, two events. They are persisted, so the double count
246
+ * lands in events.jsonl and is drawn onto the activity chart again after
247
+ * every restart. The server ticks this off a 1500ms setInterval, which does
248
+ * not wait for the last tick to finish, and a tick that outruns 1500ms needs
249
+ * nothing more unusual than several live sessions on a slow or
250
+ * network-backed transcript directory.
251
+ *
252
+ * Its two siblings in index.mjs already refused re-entry: codexScanOnce with
253
+ * a boolean, scanTranscript by handing a second caller the read already in
254
+ * flight. The second shape is wrong here — a caller handed the same answer
255
+ * would push the same blocks again — so this is the boolean. An overlapping
256
+ * call answers with nothing and the call already inside answers for both.
257
+ * Nothing is lost by it: whatever lands meanwhile is past the offset the
258
+ * first call writes back, and the next tick reads it. It sits here rather
259
+ * than in outputWatchOnce because this is where the offset lives, and where
260
+ * the suite can drive the overlap without a clock.
261
+ */
262
+ async function poll(liveSids, onLines) {
263
+ if (polling) return [];
264
+ polling = true;
265
+ try {
266
+ return await pollOnce(liveSids, onLines);
267
+ } finally {
268
+ polling = false;
269
+ }
270
+ }
271
+
221
272
  return { note, forget, clear, size, poll };
222
273
  }
@@ -0,0 +1,135 @@
1
+ // The one thing an uninstall leaves behind that is not data: this deck's LAN
2
+ // private key.
3
+ //
4
+ // `--uninstall` is deliberately narrow — it takes the hook entries out and says
5
+ // so, because the event log and the port registry are things somebody may still
6
+ // want. That narrowness has bent once already, for the login item, on the
7
+ // argument that a machine which keeps starting a deck whose hooks were just
8
+ // removed is not data either. The key is the second case and a sharper one.
9
+ //
10
+ // WHAT IS ACTUALLY LEFT. `prefs.json` is the one file the deck writes with a
11
+ // secret in it — deck-home.mjs says so where it names the mode: "0600 on the
12
+ // preferences is load-bearing: it is the one file with a private key in it".
13
+ // That key is this deck's LAN identity: every machine paired with it has pinned
14
+ // the key, and it is what decrypts what crosses between them. An uninstall that
15
+ // reports success and leaves it on the disk has left a credential behind.
16
+ //
17
+ // AND IT IS LEFT IN TWO PLACES, WHICH IS THE PART NOBODY COULD HAVE GUESSED.
18
+ // `migrateDeckFiles` moved prefs.json from `~/.claude/agent-dag` to the
19
+ // platform's data directory by COPYING it and leaving the original exactly
20
+ // where it was — deliberately, so a downgrade finds what it had. So an upgraded
21
+ // machine has the key twice, and the second copy sits in a directory the deck's
22
+ // own documentation describes as holding "the forwarder and the discovery
23
+ // directory". Deleting one of the two is not deleting the key.
24
+ //
25
+ // SO THIS MODULE ANSWERS TWO QUESTIONS AND NOTHING ELSE: where, on THIS machine,
26
+ // is there still a prefs.json that could hold a key, and — when somebody asks
27
+ // for it — take those files off the disk. It does not rewrite prefs.json to
28
+ // strip the field: a partial rewrite of the file that holds the pairings is a
29
+ // worse thing to get wrong than an unlink, and after an uninstall there is
30
+ // nothing left the rest of the file is for.
31
+ import { readFile, rm } from "node:fs/promises";
32
+ import { homedir } from "node:os";
33
+ import { join } from "node:path";
34
+ import { deckDataDir, legacyDeckDir } from "./deck-home.mjs";
35
+
36
+ /** The file. Named once, because it is spelled in deck-prefs.mjs too and the
37
+ * two must not drift — see `prefsPath`, which is the same join. */
38
+ export const KEY_FILE = "prefs.json";
39
+
40
+ /**
41
+ * Every directory on this machine that may still hold one.
42
+ *
43
+ * Deduplicated, and that is not tidiness: on a machine with CLAUDE_CONFIG_DIR
44
+ * set, `deckDataDir` deliberately answers `legacyDeckDir` — those people keep
45
+ * one directory rather than being moved — so the two are the SAME path and
46
+ * reporting it twice would tell somebody there are two copies of their key when
47
+ * there is one.
48
+ */
49
+ export function keyDirs({ env = process.env, home = homedir(), platform = process.platform } = {}) {
50
+ return [...new Set([deckDataDir(platform, env, home), legacyDeckDir(env, home, platform)])];
51
+ }
52
+
53
+ /**
54
+ * What is really on disk, per directory, and whether a key is in it.
55
+ *
56
+ * `holds` is three-valued on purpose:
57
+ *
58
+ * "key" parsed, and `lan.secret` is a non-empty string. This is the file
59
+ * the whole module is about.
60
+ * "no-key" parsed, and there is no secret in it. A deck that never turned
61
+ * LAN sync on, or one whose file predates it. Reported so a caller
62
+ * can offer to remove it without claiming a key is in there —
63
+ * telling somebody their private key is on the machine when it is
64
+ * not is the same defect as the silence, pointing the other way.
65
+ * "unknown" there is a file and this could not read it as JSON: a BOM from
66
+ * Notepad, a half-written file, a permission. UNKNOWN IS NOT
67
+ * "NO": a file that cannot be parsed is exactly the one nobody can
68
+ * rule a key out of, so it is offered for removal like a "key".
69
+ *
70
+ * A file that is not there at all is not in the list. ENOENT is the ordinary
71
+ * answer on a machine where the deck never ran, and an uninstall that announces
72
+ * the absence of a file is noise at the moment somebody least wants it.
73
+ */
74
+ export async function findKeyFiles(dirs = keyDirs(), deps = {}) {
75
+ const read = deps.readFile ?? readFile;
76
+ const out = [];
77
+ for (const dir of dirs) {
78
+ const path = join(dir, KEY_FILE);
79
+ let raw;
80
+ try {
81
+ raw = await read(path, "utf8");
82
+ } catch (err) {
83
+ // Anything but "no such file" means there IS something there and this
84
+ // could not see into it, which is the "unknown" case with the read having
85
+ // failed rather than the parse.
86
+ if (err?.code === "ENOENT") continue;
87
+ out.push({ path, holds: "unknown", why: err?.message ?? String(err) });
88
+ continue;
89
+ }
90
+ 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);
98
+ const secret = parsed?.lan?.secret;
99
+ out.push({ path, holds: typeof secret === "string" && secret !== "" ? "key" : "no-key", why: "" });
100
+ } catch (err) {
101
+ out.push({ path, holds: "unknown", why: err?.message ?? String(err) });
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Take those files off the disk, and never let one failure hide another.
109
+ *
110
+ * `force: true` so a file that vanished between the listing and the unlink — a
111
+ * second deck, a person deleting it by hand in the other window — is a removal
112
+ * that succeeded rather than an error somebody has to interpret. `maxRetries`
113
+ * is the Windows rule the rest of this repo already follows: a scanner or the
114
+ * indexer holds a handle for a moment after the file is touched, and the first
115
+ * unlink answers EBUSY. rm-temp-dir.ts paid for that knowledge twice.
116
+ *
117
+ * Returns both halves rather than throwing on the first failure, because an
118
+ * uninstall that removed one of two copies and stopped has to say WHICH — the
119
+ * caller's exit code and the user's next move both depend on it.
120
+ */
121
+ export async function purgeKeyFiles(files, deps = {}) {
122
+ const remove = deps.rm ?? rm;
123
+ const removed = [];
124
+ const failed = [];
125
+ for (const file of files) {
126
+ const path = typeof file === "string" ? file : file.path;
127
+ try {
128
+ await remove(path, { force: true, maxRetries: 5, retryDelay: 20 });
129
+ removed.push(path);
130
+ } catch (err) {
131
+ failed.push({ path, why: err?.message ?? String(err) });
132
+ }
133
+ }
134
+ return { removed, failed };
135
+ }
@@ -266,6 +266,35 @@ const FORCE_POLL_MS = 60_000;
266
266
  // collector is backing off or not running — the case self-polling exists for.
267
267
  const STORE_TRUSTED_MS = 45 * 60_000;
268
268
 
269
+ /**
270
+ * How long a caller may be kept waiting before this module answers anyway.
271
+ *
272
+ * Source 3 has no ceiling of its own and never had one. Its cost is the sum of
273
+ * every step below it — three spawns under a 15-second deadline each, with two
274
+ * 1.2-second sleeps between them — and nobody had ever added it up. Measured on
275
+ * a sandboxed deck with a `claude` that never prints (#1011):
276
+ *
277
+ * $ curl -s -m 120 -w "[%{http_code}] time_total=%{time_total}s" .../api/quota
278
+ * {"ok":false,"reason":"cli_failed",...}
279
+ * [200] time_total=47.411343s
280
+ *
281
+ * 3 × 15s + 2 × 1.2s = 47.4, to the tenth. The rest of the deck stayed
282
+ * responsive throughout — /api/health answered in 0.0008s while that request
283
+ * was out — so what this bounds is one pinned request rather than a stalled
284
+ * server: a panel on "Checking…" for three quarters of a minute, and one fewer
285
+ * socket in the browser's per-origin pool for as long as it is out.
286
+ *
287
+ * FIVE SECONDS, and it is a budget rather than a guess at how long the CLI
288
+ * takes. A sandbox with a real `claude` and no credentials answered in 4.2s and
289
+ * 4.5s, which is the slow END of the good case, and the good case is already
290
+ * served from the store or the API in milliseconds. What is left above five
291
+ * seconds is the CLI in trouble, and for that the honest answer is "not yet".
292
+ *
293
+ * Nothing is cancelled when it expires — see answerWithin. The read runs on,
294
+ * publishes to the cache, and the panel's next poll gets the real numbers.
295
+ */
296
+ export const QUOTA_DEADLINE_MS = 5_000;
297
+
269
298
  /**
270
299
  * claude-swap's row for the active account, in the shape the panel speaks.
271
300
  *
@@ -477,14 +506,25 @@ export function quotaClaudeBin(platform = process.platform, env = process.env,
477
506
  return "claude";
478
507
  }
479
508
 
480
- export async function fetchClaudeQuota({ force = false } = {}) {
509
+ /**
510
+ * @param deadlineMs how long the CALLER is prepared to wait. Zero — the
511
+ * default, and what every internal caller uses — waits for the read however
512
+ * long it takes, which is what a test driving the chain end to end wants.
513
+ * The route passes QUOTA_DEADLINE_MS; see answerWithin for what expiring
514
+ * means, which is not cancelling.
515
+ */
516
+ export async function fetchClaudeQuota({ force = false, deadlineMs = 0 } = {}) {
481
517
  const now = Date.now();
482
518
  if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
483
519
 
484
520
  // If another CLI probe is already in flight, wait for it instead of spawning a
485
521
  // second concurrent process (which can return empty output and overwrite the
486
522
  // good result with 0%).
487
- if (_inflight) return _inflight;
523
+ //
524
+ // Under a deadline the joiner is bounded too, and has to be: joining a read
525
+ // that started 46 seconds ago is the same 47-second wait reached by the other
526
+ // door, and it is the door the panel's own 60-second poll walks through.
527
+ if (_inflight) return answerWithin(_inflight, deadlineMs, now);
488
528
 
489
529
  // `_inflight === mine` rather than a bare clear: invalidateQuotaCache drops
490
530
  // `_inflight` so the next caller starts a read that knows the account moved,
@@ -495,7 +535,51 @@ export async function fetchClaudeQuota({ force = false } = {}) {
495
535
  const mine = _doFetch(now, force, _generation)
496
536
  .finally(() => { if (_inflight === mine) _inflight = null; });
497
537
  _inflight = mine;
498
- return mine;
538
+ return answerWithin(mine, deadlineMs, now);
539
+ }
540
+
541
+ /**
542
+ * The read's answer, or the best thing we can say by the time the caller's
543
+ * patience runs out.
544
+ *
545
+ * THE READ IS NOT CANCELLED, and that is the whole design rather than a
546
+ * shortcut. It keeps running, keeps `_inflight` filled so nothing spawns a
547
+ * second Claude Code beside it, and ends in publish() like any other read — so
548
+ * the numbers it eventually produces are in the cache for whoever asks next.
549
+ * The panel polls every 60 seconds and presses ↻ into the same slot, so "not
550
+ * yet" is a state it leaves on its own within a poll. Cancelling would spend a
551
+ * whole `claude --print /usage` and throw the result away, and the budget this
552
+ * module is built around (28-30 requests an hour, shared with claude-swap) is
553
+ * the one thing it must not do.
554
+ *
555
+ * WHAT "NOT YET" SAYS. The freshest real reading this module holds, marked
556
+ * stale — the same answer, in the same shape, that the poll-floor branch of
557
+ * _doFetch already gives for the same question — and only when there is none,
558
+ * the `waiting` reason the panel has rendered a sentence for all along. Its
559
+ * timestamp is the reading's own and never `now`: an age indicator that
560
+ * vouches for numbers collected hours ago is what quota-held-age.test.ts
561
+ * exists to stop, and a deadline is not a licence to re-stamp them.
562
+ *
563
+ * Exported because a deadline nothing can point at is a deadline nobody can
564
+ * test.
565
+ */
566
+ export function answerWithin(read, deadlineMs, now = Date.now()) {
567
+ if (!(deadlineMs > 0)) return read;
568
+ let bell;
569
+ const expired = new Promise(resolve => {
570
+ bell = setTimeout(() => resolve(notYet(now)), deadlineMs);
571
+ // A deadline is not a reason for the process to stay alive: this timer
572
+ // outlives nothing, and an exit waiting on it would be this function
573
+ // holding the deck open for an answer nobody is there to read.
574
+ bell.unref?.();
575
+ });
576
+ return Promise.race([read, expired]).finally(() => clearTimeout(bell));
577
+ }
578
+
579
+ /** What the deck can honestly say about a reading it has not finished taking. */
580
+ function notYet(now) {
581
+ if (_lastGood) return { ..._lastGood, stale: true };
582
+ return { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
499
583
  }
500
584
 
501
585
  /**
@@ -640,7 +724,13 @@ async function _execOnce(bin) {
640
724
  _saidFailure = null;
641
725
  }
642
726
  const cliOk = /subscription/i.test(combined) || /claude code usage/i.test(combined);
643
- return { cliOk, missing, parsed: parseUsageText(combined) };
727
+ // WHETHER THE RUN ITSELF SUCCEEDED, which `cliOk` does not answer. `cliOk` is
728
+ // a test of the OUTPUT — it means "we recognised what came back" — and a CLI
729
+ // that printed its banner and then failed satisfies it. That is the right
730
+ // rule for the parse above (see the note there: the quota lines can be on
731
+ // stdout and the exit non-zero), and the wrong one for the no-numbers
732
+ // fallback in _doFetch, which was publishing 0% for a run that errored.
733
+ return { cliOk, ran: r.ok, missing, parsed: parseUsageText(combined) };
644
734
  }
645
735
 
646
736
  async function _doFetch(now, force = false, gen = _generation) {
@@ -693,11 +783,13 @@ async function _doFetch(now, force = false, gen = _generation) {
693
783
  // refreshed). The real lines appear on a subsequent call. Retry a couple
694
784
  // times before giving up so the first paint already shows real values.
695
785
  let cliOk = false;
786
+ let cliRan = false;
696
787
  let parsed = null;
697
788
  for (let attempt = 0; attempt < 3; attempt++) {
698
789
  if (attempt > 0) await sleep(1200);
699
790
  const r = await _execOnce(bin);
700
791
  cliOk = r.cliOk || cliOk;
792
+ cliRan = r.ran || cliRan;
701
793
  if (r.parsed) { parsed = r.parsed; break; }
702
794
  // The retry exists for a CLI that RAN and left the quota lines out of a cold
703
795
  // invocation. A CLI that is not installed will not be installed 1.2 seconds
@@ -734,11 +826,24 @@ async function _doFetch(now, force = false, gen = _generation) {
734
826
  //
735
827
  // A CLI that failed entirely is `ok: false` as it always was, and the reason
736
828
  // says which of the two the reader is looking at.
737
- const subscribed = cliOk ? await hasSubscriptionCredential() : false;
738
- const result = cliOk && subscribed
829
+ //
830
+ // `cliRan` IS WHAT MAKES THAT SENTENCE TRUE. It used to rest on `cliOk`
831
+ // alone, which is a regex over stdout+stderr and never looked at the exit
832
+ // status — so a CLI that printed its header and then failed (a network
833
+ // error, a rate limit, a timeout after the banner) took this branch and the
834
+ // panel drew two full-looking bars reading "5-hour window 0%". The deck
835
+ // logged `claude CLI failed` in the same second. Someone reading an empty
836
+ // window then starts a long run against one that is nearly spent.
837
+ //
838
+ // Only this branch is gated. The parse above still keeps its output on a
839
+ // non-zero exit, deliberately, because the quota lines can be printed and
840
+ // the exit still be non-zero — 0% is a claim made in the ABSENCE of numbers,
841
+ // and absence plus failure is not a measurement.
842
+ const subscribed = cliOk && cliRan ? await hasSubscriptionCredential() : false;
843
+ const result = cliOk && cliRan && subscribed
739
844
  ? { ok: true, session5hPct: 0, session5hWindowSec: 18000,
740
845
  week7dPct: 0, week7dWindowSec: 604800, fetchedAt: now }
741
- : { ok: false, reason: cliOk ? "no_subscription" : "cli_failed", fetchedAt: now };
846
+ : { ok: false, reason: cliOk && cliRan ? "no_subscription" : "cli_failed", fetchedAt: now };
742
847
  return publish(gen, result, now - (CACHE_MS - 5_000));
743
848
  }
744
849
 
@@ -61,6 +61,7 @@ import { readdir, readFile } from "node:fs/promises";
61
61
  import { join } from "node:path";
62
62
  import { claudeConfigDir } from "./claude-dir.mjs";
63
63
  import { challengeDeck, isProcessAlive } from "./deck-probe.mjs";
64
+ import { sameCodexTree } from "./log-writer.mjs";
64
65
 
65
66
  /**
66
67
  * Where every deck on this machine registers itself.
@@ -84,13 +85,26 @@ export function deckRegistryDir(env = process.env, home = undefined) {
84
85
  * either field existed carries `undefined` and fails — and a deck that old is
85
86
  * replaced rather than attached to, which is what a start does with any deck
86
87
  * it cannot vouch for.
88
+ *
89
+ * AND THE CODEX TREE, which is the one field that is not strict. #1110 put the
90
+ * tree a deck tails on its record, and until this read it a start for another
91
+ * tree matched on the four fields above and attached: `CODEX_HOME=/srv/codex
92
+ * ccdeck` opened the deck already reading ~/.codex, and the tree it was started
93
+ * for was never read by anything. A different tree is a different canvas, so it
94
+ * is a different shape. Compared by sameCodexTree, the log election's own rule
95
+ * for the same field — so the two cannot disagree about whether two spellings
96
+ * are one tree — which also means a side that does not say is taken as the
97
+ * same: a record written before #1110 keeps the answer it had. Every caller
98
+ * that asks on behalf of a start names its tree: the start itself, and
99
+ * `--status` marking the deck a bare start would open (#1134).
87
100
  */
88
101
  export function sameShape(record, want = {}) {
89
102
  if (!record) return false;
90
103
  return (record.workspace ?? "") === (want.workspace ?? "")
91
104
  && (record.persist ?? null) === (want.persist ?? null)
92
105
  && record.codex === (want.codex !== false)
93
- && record.claude === (want.claude !== false);
106
+ && record.claude === (want.claude !== false)
107
+ && sameCodexTree(record.codexHome, want.codexHome);
94
108
  }
95
109
 
96
110
  /**