ccdeck 3.36.9 → 3.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +15 -7
  2. package/bin/agent-dag.js +19 -6
  3. package/bin/cli/help.js +1 -0
  4. package/bin/cli/login-item.js +44 -20
  5. package/bin/cli/one-shot.js +15 -5
  6. package/bin/cli/screen.js +10 -5
  7. package/bin/cli/startup.js +6 -2
  8. package/bin/cli/uninstall.js +41 -5
  9. package/bin/deck.js +50 -8
  10. package/dist/web/assets/BrowserWatchModal-DCHV09J8.js +1 -0
  11. package/dist/web/assets/UsageHistoryModal-oDjpHw4z.js +1 -0
  12. package/dist/web/assets/index-B0SOa-XB.css +1 -0
  13. package/dist/web/assets/index-wCbqcRJd.js +306 -0
  14. package/dist/web/index.html +2 -2
  15. package/hook/hook.js +80 -9
  16. package/package.json +1 -1
  17. package/release-notes.json +2 -1
  18. package/src/server/account-origins.mjs +8 -1
  19. package/src/server/account-projects.mjs +34 -9
  20. package/src/server/activation.mjs +39 -10
  21. package/src/server/app-host.mjs +7 -2
  22. package/src/server/args.mjs +3 -0
  23. package/src/server/browser-watch-routes.mjs +23 -16
  24. package/src/server/browser-watch.mjs +9 -3
  25. package/src/server/claude-accounts.mjs +27 -7
  26. package/src/server/claude-verdicts.mjs +12 -1
  27. package/src/server/cli-versions.mjs +6 -4
  28. package/src/server/codex-auth.mjs +29 -0
  29. package/src/server/codex-quota.mjs +57 -7
  30. package/src/server/deck-home.mjs +23 -1
  31. package/src/server/deck-prefs.mjs +70 -7
  32. package/src/server/depth-facts.mjs +178 -0
  33. package/src/server/detach.mjs +61 -33
  34. package/src/server/event-log.mjs +15 -0
  35. package/src/server/event-pipeline.mjs +5 -2
  36. package/src/server/event-routes.mjs +22 -0
  37. package/src/server/feedback-form.mjs +36 -13
  38. package/src/server/index.mjs +6 -2
  39. package/src/server/install-layout.mjs +35 -0
  40. package/src/server/install-ref.mjs +39 -0
  41. package/src/server/installer.mjs +20 -2
  42. package/src/server/lan-beacon.mjs +18 -3
  43. package/src/server/lan-deck.mjs +7 -1
  44. package/src/server/lan-engine.mjs +10 -1
  45. package/src/server/lan-manifest.mjs +9 -6
  46. package/src/server/lan-requests.mjs +18 -2
  47. package/src/server/lan-serve.mjs +4 -2
  48. package/src/server/lan-sync.mjs +97 -6
  49. package/src/server/lifecycle.mjs +18 -2
  50. package/src/server/log-replay.mjs +9 -3
  51. package/src/server/login-service.mjs +95 -12
  52. package/src/server/npm-upgrade.mjs +12 -4
  53. package/src/server/pinned-build.mjs +3 -0
  54. package/src/server/prefs-routes.mjs +5 -8
  55. package/src/server/prefs-state.mjs +9 -1
  56. package/src/server/quota-oauth.mjs +88 -13
  57. package/src/server/quota-shape.mjs +28 -0
  58. package/src/server/quota.mjs +68 -17
  59. package/src/server/rating.mjs +87 -0
  60. package/src/server/reports-routes.mjs +21 -2
  61. package/src/server/reports.mjs +672 -64
  62. package/src/server/ring-bounds.mjs +11 -0
  63. package/src/server/running-deck.mjs +48 -0
  64. package/src/server/self-update.mjs +1 -1
  65. package/src/server/session-enrichment.mjs +14 -6
  66. package/src/server/stable-node.mjs +68 -0
  67. package/src/server/static-serve.mjs +5 -0
  68. package/src/server/supervisor.mjs +21 -15
  69. package/src/server/tailscale.mjs +8 -4
  70. package/src/server/transcript-scan.mjs +7 -4
  71. package/src/server/usage-day.mjs +76 -11
  72. package/dist/web/assets/BrowserWatchModal-BCrDvuug.js +0 -1
  73. package/dist/web/assets/UsageHistoryModal-BtRCuo33.js +0 -1
  74. package/dist/web/assets/index-BkXTN4dZ.css +0 -1
  75. package/dist/web/assets/index-tVBMXvDv.js +0 -306
@@ -64,8 +64,8 @@
64
64
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
65
65
  })();
66
66
  </script>
67
- <script type="module" crossorigin src="/assets/index-tVBMXvDv.js"></script>
68
- <link rel="stylesheet" crossorigin href="/assets/index-BkXTN4dZ.css">
67
+ <script type="module" crossorigin src="/assets/index-wCbqcRJd.js"></script>
68
+ <link rel="stylesheet" crossorigin href="/assets/index-B0SOa-XB.css">
69
69
  </head>
70
70
  <body>
71
71
  <div id="root"></div>
package/hook/hook.js CHANGED
@@ -818,6 +818,15 @@ function readRecord(file, resolvedCwd, found, done) {
818
818
 
819
819
  if (!isAlive(d.pid)) return fs.unlink(full, () => done());
820
820
 
821
+ // A deck started with --no-claude says so in its record, and is not a
822
+ // target for a Claude event. Starting one removes no hooks an earlier run
823
+ // installed, so without this it drew every Claude session live — and could
824
+ // be elected to log them — while its replay, which does read the flag,
825
+ // dropped them all again at the next boot. Refused here, before the
826
+ // election, so the log goes to a deck that wants the session. A record
827
+ // with no `claude` field predates the flag and is taken, as it always was.
828
+ if (PROVIDER === "claude" && d.claude === false) return done();
829
+
821
830
  // Where this record lives, for the one later verdict that may forget it —
822
831
  // see forgetIfAbandoned.
823
832
  RECORD_FILE.set(d, full);
@@ -942,6 +951,66 @@ function discoverTargets(cwd, cb) {
942
951
  normPathAsync(cwd, scan);
943
952
  }
944
953
 
954
+ // THE DECK REFUSES AN INGEST BODY OVER 5,000,000 CHARACTERS (event-routes.mjs),
955
+ // and every deck has the same cap, so the hand-on below meets the same 413 on
956
+ // each one and the event reaches nobody. Claude Code's PostToolUse for an Edit
957
+ // carries the whole edited file in `tool_response.originalFile`, so editing a
958
+ // file of a few megabytes was enough: the outcome landed on no canvas and in no
959
+ // log, and the turn's end then settled a successful edit as failed. Kept a
960
+ // little under the deck's figure, so the markers that replace what was cut can
961
+ // never carry a trimmed body back over it.
962
+ const MAX_EVENT_CHARS = 4_900_000;
963
+
964
+ /**
965
+ * The event as one line under MAX_EVENT_CHARS, or null when cutting its fields
966
+ * cannot get it there. The biggest values are replaced by a marker saying how
967
+ * large they were, biggest first and only until it fits; a top-level object is
968
+ * opened one level, so an Edit keeps its file path and its strings and loses
969
+ * only `originalFile`. What the deck settles a call by — the event's name, the
970
+ * session, the tool, the call id, the cwd — is a short scalar and is never what
971
+ * goes. `serialized` is the event as it would have been sent whole.
972
+ */
973
+ function withinSizeCap(parsed, serialized) {
974
+ const out = { ...parsed };
975
+ const slots = [];
976
+ for (const [key, value] of Object.entries(out)) {
977
+ if (value && typeof value === "object" && !Array.isArray(value)) {
978
+ const inner = out[key] = { ...value };
979
+ for (const [k, v] of Object.entries(inner)) slots.push({ holder: inner, key: k, size: JSON.stringify(v)?.length ?? 0 });
980
+ } else if (typeof value === "string" || Array.isArray(value)) {
981
+ slots.push({ holder: out, key, size: JSON.stringify(value).length });
982
+ }
983
+ }
984
+ slots.sort((a, b) => b.size - a.size);
985
+ out.ccdeck_truncated = true;
986
+ out.ccdeck_truncation_reason = "size";
987
+ // The two markers above add under 64 characters; the line is measured for
988
+ // real before it is returned, so this only has to know when to stop.
989
+ let size = serialized.length + 64;
990
+ for (const slot of slots) {
991
+ if (size <= MAX_EVENT_CHARS) break;
992
+ const marker = { ccdeck_truncated: true, chars: slot.size };
993
+ slot.holder[slot.key] = marker;
994
+ size += JSON.stringify(marker).length - slot.size;
995
+ }
996
+ const text = JSON.stringify(out);
997
+ return text.length <= MAX_EVENT_CHARS ? text : null;
998
+ }
999
+
1000
+ /** The event's scalar top-level fields alone, marked as cut and why — what is
1001
+ * sent when the event cannot be sent whole and nothing gentler fits. */
1002
+ function shallowStandIn(parsed, reason) {
1003
+ const shallow = {};
1004
+ for (const [key, value] of Object.entries(parsed)) {
1005
+ if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
1006
+ shallow[key] = value;
1007
+ }
1008
+ }
1009
+ shallow.ccdeck_truncated = true;
1010
+ shallow.ccdeck_truncation_reason = reason;
1011
+ return JSON.stringify(shallow);
1012
+ }
1013
+
945
1014
  // JSON.parse can accept nesting that older supported Node releases cannot
946
1015
  // stringify again. Count structural nesting directly in the original text so
947
1016
  // the hook never depends on a runtime-specific recursion limit. Strings are
@@ -1088,15 +1157,17 @@ function main() {
1088
1157
  // top-level metadata is safe to copy without walking the deep value,
1089
1158
  // which preserves the event/session/tool identity, canonical cwd and
1090
1159
  // provider while making the loss explicit to the deck. (#1180)
1091
- const shallow = {};
1092
- for (const [key, value] of Object.entries(parsed)) {
1093
- if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
1094
- shallow[key] = value;
1095
- }
1096
- }
1097
- shallow.ccdeck_truncated = true;
1098
- shallow.ccdeck_truncation_reason = "serialization-depth";
1099
- taggedInput = JSON.stringify(shallow);
1160
+ taggedInput = shallowStandIn(parsed, "serialization-depth");
1161
+ }
1162
+ // Too large for any deck to take — the same loss by the other door, and
1163
+ // the same answer: send what fits and say what was cut. See
1164
+ // MAX_EVENT_CHARS.
1165
+ if (taggedInput.length > MAX_EVENT_CHARS) {
1166
+ let fitted = null;
1167
+ // Measuring a field walks it, and a field too deep to serialize throws
1168
+ // here as it did above: the stand-in is the answer for that one too.
1169
+ try { fitted = withinSizeCap(parsed, taggedInput); } catch {}
1170
+ taggedInput = fitted ?? shallowStandIn(parsed, "size");
1100
1171
  }
1101
1172
 
1102
1173
  // See proveTargets for what the other order cost. A record whose pid is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.36.9",
3
+ "version": "3.37.0",
4
4
  "description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -68,7 +68,8 @@
68
68
  "3.36.6",
69
69
  "3.36.7",
70
70
  "3.36.8",
71
- "3.36.9"
71
+ "3.36.9",
72
+ "3.37.0"
72
73
  ],
73
74
  "3.36.3": [
74
75
  {
@@ -183,11 +183,18 @@ export function needsSignIn(trouble, collector) {
183
183
  * the new login has not happened yet, and saying "expired" over a sign-in that
184
184
  * just succeeded is the one moment this must stay quiet.
185
185
  */
186
- export function reauthFor({ entry, trouble, collector, fetchedAt = null, attemptedAt = null }) {
186
+ export function reauthFor({ entry, trouble, collector, fetchedAt = null, attemptedAt = null, verdictAt = null }) {
187
187
  if (entry?.origin !== SIGNED_IN_HERE) return null;
188
188
  if (!needsSignIn(trouble, collector)) return null;
189
189
  const signedInAt = entry.signedInAt ?? 0;
190
190
  if (!Number.isFinite(attemptedAt) || attemptedAt <= signedInAt) return null;
191
+ // A collector that stopped is a dead login only on claude-swap's own
192
+ // verdict, and a verdict asked before the last sign-in here is about the
193
+ // login that sign-in replaced. Without this, the first collection after a
194
+ // re-sign-in — which stamps its attempt as it claims the slot, long before
195
+ // it records anything — read as a refusal of the new login, on a row whose
196
+ // last good read is still from before (`cswap add` does not move it).
197
+ if (trouble?.kind === "stopped" && !(Number.isFinite(verdictAt) && verdictAt > signedInAt)) return null;
191
198
  const since = Math.floor(Math.max(Number.isFinite(fetchedAt) ? fetchedAt : 0, signedInAt));
192
199
  return { since, dismissed: entry.dismissed === since };
193
200
  }
@@ -29,17 +29,19 @@ import { renameWithRetry } from "./atomic-write.mjs";
29
29
  import { ccProjectSlug, claudeConfigDir } from "./claude-dir.mjs";
30
30
  import { accountKey } from "./lan-sync.mjs";
31
31
  import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from "./swap-log.mjs";
32
- // The two rules the live transcript scan counts a line's usage by, so this
33
- // rollup and a session's card cannot disagree about which lines billed
34
- // anything — see transcript-scan.mjs.
35
- import { hasSpend, repeatsRequestUsage } from "./transcript-scan.mjs";
32
+ // The rules the live transcript scan counts a line's usage by, so this rollup
33
+ // and a session's card cannot disagree about which lines billed anything, nor
34
+ // about the speed they were billed at — see transcript-scan.mjs.
35
+ import {
36
+ billedSpeed, hasSpend, MAX_SPEEDS_PER_MODEL, repeatsRequestUsage, UNRECOGNISED_SPEED,
37
+ } from "./transcript-scan.mjs";
36
38
 
37
39
  /** The pseudo-account for messages the swap log cannot place — everything
38
40
  * before tracking began, or a gap. Shown once, apart from any real account, so
39
41
  * a per-account total is never quietly inflated by work that is not that
40
42
  * account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
41
43
  export const UNATTRIBUTED = "\u0000unattributed";
42
- const STATE_VERSION = 3;
44
+ const STATE_VERSION = 4;
43
45
 
44
46
  /** Where the tally and cursors live, beside cswap-auto's own state. */
45
47
  export function statePath(home = homedir()) {
@@ -94,12 +96,29 @@ function asUsageTotals(c) {
94
96
  };
95
97
  }
96
98
 
99
+ /** Add `src` into `dst`, and its speed shares into `dst`'s. */
97
100
  function addInto(dst, src) {
98
101
  dst.i += src.i; dst.o += src.o; dst.cr += src.cr;
99
102
  dst.cc += src.cc; dst.c1h += src.c1h; dst.c5m += src.c5m;
103
+ if (src.s) for (const [speed, share] of Object.entries(src.s)) addInto(speedShareOf(dst, speed), share);
100
104
  return dst;
101
105
  }
102
106
 
107
+ /** The share of `counters` billed at `speed` (#754), created on first sight:
108
+ * `s[speed]`, a SUBSET of the counters it sits on, the way the 1h/5m split is
109
+ * of `cc`, so the totals read exactly as they did and the client prices the
110
+ * share at its own speed's rate. Capped like a session's buckets in
111
+ * transcript-scan.mjs, the value being bytes out of a transcript; whatever is
112
+ * past the cap is kept as UNRECOGNISED_SPEED, which no rate table names, so it
113
+ * is counted and left unpriced rather than priced at the standard rate. */
114
+ function speedShareOf(counters, speed) {
115
+ const shares = counters.s ??= {};
116
+ if (Object.hasOwn(shares, speed)) return shares[speed];
117
+ const key = Object.keys(shares).length < MAX_SPEEDS_PER_MODEL ? speed : UNRECOGNISED_SPEED;
118
+ if (!Object.hasOwn(shares, key)) shares[key] = zero();
119
+ return shares[key];
120
+ }
121
+
103
122
  function zero() { return { i: 0, o: 0, cr: 0, cc: 0, c1h: 0, c5m: 0 }; }
104
123
 
105
124
  /** Local calendar day of a timestamp, `YYYY-MM-DD`. Local because that is the
@@ -227,7 +246,12 @@ export function foldLine(tally, line, timeline, from) {
227
246
  const key = who ? accountKey(who.email, who.orgUuid) : UNATTRIBUTED;
228
247
  const day = localDay(ts);
229
248
  const proj = (((tally[key] ??= {})[cwd] ??= {})[day] ??= {});
230
- addInto(proj[model] ??= zero(), c);
249
+ const counters = addInto(proj[model] ??= zero(), c);
250
+ // A fast turn costs more than a standard one of the same model, and the
251
+ // board prices it so (#754). Kept as a share rather than a model of its own,
252
+ // so a row's tokens and its model read exactly as before.
253
+ const speed = billedSpeed(obj);
254
+ if (speed) addInto(speedShareOf(counters, speed), c);
231
255
  }
232
256
 
233
257
  /** Read the bytes of `path` from `start` to `size`, folding each COMPLETE line,
@@ -413,10 +437,11 @@ export function createProjectRollup({
413
437
  if (disk && disk.version === STATE_VERSION && disk.tally && disk.cursors) {
414
438
  state = disk;
415
439
  state.folders ??= {};
416
- } else if (disk && (disk.version === 1 || disk.version === 2)) {
440
+ } else if (disk && (disk.version === 1 || disk.version === 2 || disk.version === 3)) {
417
441
  // Version 1 counted every API content block; version 2 keyed each row
418
- // by the line's own cwd, a row per folder the agent cd'd into (#1278).
419
- // Neither tally can be re-keyed, since it no longer knows which
442
+ // by the line's own cwd, a row per folder the agent cd'd into (#1278);
443
+ // version 3 kept no speed, so every fast turn read at the standard
444
+ // rate. None of them can be re-keyed, since it no longer knows which
420
445
  // transcript a count came from. Keep the heartbeat so a restart gap
421
446
  // remains fenced, but rebuild the tally from transcripts.
422
447
  state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, lastAlive: disk.lastAlive };
@@ -18,31 +18,60 @@
18
18
  // never a time.
19
19
  //
20
20
  // The boot (bin/deck.js) is what knows the setup, and it reaches this module
21
- // through noteSetup. This file imports nothing, so the boot can load it early.
21
+ // through noteSetup. A respawn installs the hooks only when the package changed,
22
+ // so the supervisor carries the first boot's answer down to it (respawnHooksJob).
23
+ // This file imports nothing, so the boot can load it early.
22
24
 
23
25
  /** Not known yet, until the boot says. */
24
26
  let setup = null;
25
27
  let settle;
26
28
  const known = new Promise(r => { settle = r; });
27
29
 
30
+ /** What the boot can say about the Claude hooks. */
31
+ const HOOK_STATES = ["ok", "failed", "off"];
32
+
33
+ /** A hook job's answer as its token: null is "off" (not attempted), `{ ok }` is
34
+ * "ok" or "failed", and a token — the one a respawn was handed down — is kept
35
+ * when it is one. Anything else cannot be told, and is left out. */
36
+ function hooksState(job) {
37
+ if (job == null) return "off";
38
+ if (typeof job === "string") return HOOK_STATES.includes(job) ? job : undefined;
39
+ return job.ok ? "ok" : "failed";
40
+ }
41
+
28
42
  /**
29
43
  * What the boot set up. `claude` is the hook install job — a promise of null
30
- * (not attempted), `{ ok: true }` or `{ ok: false }` — or null when Claude is
31
- * not wanted; `codex` is whether the rollout watcher runs. Never throws, and a
32
- * job that rejects reads as "failed".
44
+ * (not attempted), `{ ok: true }` or `{ ok: false }`, or on a respawn the
45
+ * first boot's token (respawnHooksJob) — or null when Claude is not wanted;
46
+ * `codex` is whether the rollout watcher runs. Never throws, and a job that
47
+ * rejects reads as "failed". Resolves to the setup once it is known, for the
48
+ * boot to hand the hooks' answer to its supervisor.
33
49
  */
34
50
  export function noteSetup({ claude = null, codex = false } = {}) {
35
- Promise.resolve(claude)
36
- .then(
37
- job => (job == null ? "off" : job.ok ? "ok" : "failed"),
38
- () => "failed",
39
- )
51
+ return Promise.resolve(claude)
52
+ .then(hooksState, () => "failed")
40
53
  .then(claudeHooks => {
41
- setup = { claudeHooks, codexWatch: codex ? "on" : "off" };
54
+ setup = { codexWatch: codex ? "on" : "off" };
55
+ if (claudeHooks) setup.claudeHooks = claudeHooks;
42
56
  settle();
57
+ return { ...setup };
43
58
  });
44
59
  }
45
60
 
61
+ /**
62
+ * A respawn's hook job, for noteSetup: what this process saw, never a success
63
+ * it did not. A respawn re-installs the hooks only when the package under the
64
+ * session has changed (respawnHooks, bin/cli/startup.js), and then it is that
65
+ * install's own answer. Otherwise it installed nothing, and the answer is the
66
+ * session's first boot's — `carried`, which the supervisor hands down in
67
+ * AGENTS_DECK_BOOT_HOOKS — or, under a supervisor that handed nothing down,
68
+ * none at all.
69
+ */
70
+ export function respawnHooksJob({ wantClaude, reinstall, carried }) {
71
+ if (!wantClaude) return null;
72
+ return Promise.resolve(reinstall).then(job => job ?? String(carried ?? ""));
73
+ }
74
+
46
75
  /** The setup facts, or none while the boot has not said. */
47
76
  export function setupFacts() {
48
77
  return setup ? { ...setup } : {};
@@ -20,6 +20,8 @@
20
20
  // Environment, not flags: the supervisor re-spawns the worker with its own
21
21
  // argv, and the environment is what both of them inherit unchanged.
22
22
 
23
+ import { stableNodePath } from "./stable-node.mjs";
24
+
23
25
  /** "1" when the desktop app started this deck. */
24
26
  export const APP_ENV = "CCDECK_APP";
25
27
 
@@ -31,7 +33,10 @@ export function inApp(env = process.env) {
31
33
  return env[APP_ENV] === "1";
32
34
  }
33
35
 
34
- export function hookRuntime(env = process.env, execPath = process.execPath) {
36
+ /** Outside the app, this node under the name an upgrade does not delete — see
37
+ * stable-node.mjs. `execPath` is taken as given when a caller names one. */
38
+ export function hookRuntime(env = process.env, execPath = undefined) {
35
39
  const given = env[HOOK_RUNTIME_ENV];
36
- return typeof given === "string" && given.trim() ? given : execPath;
40
+ if (typeof given === "string" && given.trim()) return given;
41
+ return execPath ?? stableNodePath({ env });
37
42
  }
@@ -245,6 +245,9 @@ export function parseArgs(args) {
245
245
  else if (a === "--no-codex") out.noCodex = true;
246
246
  else if (a === "--claude") out.claude = true;
247
247
  else if (a === "--no-claude") out.noClaude = true;
248
+ // The ccdeck.dev page the command was copied from, for the first install
249
+ // report (install-ref.mjs). A value that is not a ref is dropped there.
250
+ else if (a === "--ref") set("ref", "a page name");
248
251
  else out.unknown.push(argv[i]); // the token as typed, `=` half included
249
252
  }
250
253
  return out;
@@ -45,30 +45,37 @@ export async function handleBrowserWatchSettings(req, res) {
45
45
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
46
46
  if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
47
47
 
48
- const { readStore, updateStore, normalise } = await import(
48
+ const { updateStore, normalise } = await import(
49
49
  pathToFileURL(join(PKG_ROOT, "src/server/browser-watch-store.mjs")).href
50
50
  );
51
51
  const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
52
52
  pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
53
53
  );
54
- const store = await readStore();
55
- // normalise() is the one place a value is judged, so a field this route has
56
- // never heard of cannot arrive through it and a bad one falls back rather
57
- // than reaching classify().
58
- const settings = normalise({ ...store.settings, ...body });
59
54
  // `dismissed` carried forward, and it has to be spelled: writeStore takes a
60
55
  // whole state and writes exactly what it is handed, so a caller that omits
61
56
  // this field ERASES it. Measured — changing the reaction wiped every
62
57
  // dismissal, so every episode the reader had reviewed came straight back on
63
58
  // the next poll, from a settings change that had nothing to do with them.
64
- // Only the settings are this route's to change. `updateStore` re-reads inside
65
- // the write queue, so a poll that landed between the read above and this line
66
- // cannot have its archive thrown away by a settings change — which is what
67
- // writing a whole state read seconds earlier used to do.
68
- await updateStore(cur => ({ ...cur, settings }));
59
+ // Only the settings are this route's to change.
60
+ //
61
+ // AND THE PATCH IS MERGED INSIDE THE JOB, against the state the queue hands
62
+ // in. Each control in the panel sends one field, and this used to read the
63
+ // store before the queue and build the whole settings object from that: a
64
+ // second change whose read came before the first one's write put the first
65
+ // field back — the watch switched off, the reaction changed straight after,
66
+ // and the watch was on again. Merged against `cur`, two one-field patches
67
+ // cannot lose each other, and neither can a poll's archive.
68
+ let before;
69
+ const { settings } = await updateStore(cur => {
70
+ before = cur.settings;
71
+ // normalise() is the one place a value is judged, so a field this route
72
+ // has never heard of cannot arrive through it and a bad one falls back
73
+ // rather than reaching classify().
74
+ return { ...cur, settings: normalise({ ...cur.settings, ...body }) };
75
+ });
69
76
  // The one line in the log that is somebody acting rather than the deck
70
77
  // reading, which is exactly why it is worth its own entry.
71
- if (settings.enabled !== store.settings.enabled) {
78
+ if (settings.enabled !== before.enabled) {
72
79
  noteWatchSetting(settings.enabled ? "watch on — keeping its own copy" : "watch off — reading live only");
73
80
  } else {
74
81
  noteWatchSetting(`settings: quiet ${settings.quietMinutes}m, gap ${settings.gapMinutes}m`);
@@ -107,10 +114,10 @@ export async function handleBrowserWatchDismiss(req, res) {
107
114
  const { invalidateBrowserWatchCache, noteWatchSetting } = await import(
108
115
  pathToFileURL(join(PKG_ROOT, "src/server/browser-watch.mjs")).href
109
116
  );
110
- // COMPUTED INSIDE THE JOB, like the settings route four lines up, whose
111
- // comment says why: "updateStore re-reads inside the write queue, so a poll
112
- // that landed between the read above and this line cannot have its archive
113
- // thrown away." This read the whole `dismissed` array before the queue, so
117
+ // COMPUTED INSIDE THE JOB, like the settings route above, and for the same
118
+ // reason: updateStore re-reads inside the write queue, and only what is
119
+ // computed from that read can never be stale. This read the whole
120
+ // `dismissed` array before the queue, so
114
121
  // two Dismiss presses in one turn both read before either job ran and the
115
122
  // second wrote an array without the first key — both rows left the list,
116
123
  // both answered 200, and the first came back on the next ten-second poll.
@@ -278,8 +278,9 @@ function occupy(read) {
278
278
  *
279
279
  * It costs a `dig`, a `pgrep` per browser and an `lsof` per running one — up to
280
280
  * a dozen subprocesses — and none of what it reports moves quickly: a browser
281
- * does not get installed twice a minute. Thirty seconds is far below the
282
- * interval the badge polls on and far above the rate a person clicks Refresh.
281
+ * does not get installed twice a minute. Thirty seconds is far above the rate
282
+ * a person clicks Refresh. Only the panel's own reads come here: the badge's
283
+ * background poll does not survey (see browserWatchSnapshot).
283
284
  */
284
285
  const SURVEY_TTL_MS = 30_000;
285
286
  let surveyCache = { atMs: 0, rows: [] };
@@ -857,7 +858,12 @@ export async function browserWatchSnapshot({
857
858
  // database and can take a second.
858
859
  _checkedMs = now;
859
860
 
860
- const browsers = await surveyBrowsers(platform, env, now, deps);
861
+ // THE SURVEY ONLY FOR SOMEBODY LOOKING. Its first step is a `dig` for the
862
+ // relay's address, a DNS query to whatever resolver the machine uses, which
863
+ // leaves the machine — and the badge's background poll reads none of what it
864
+ // finds, only the episodes and the switch. So that poll answers with the last
865
+ // survey the panel asked for, and asks nobody anything.
866
+ const browsers = readBrowsers ? await surveyBrowsers(platform, env, now, deps) : surveyCache.rows;
861
867
  // Two small reads, and the answer to the one question this panel exists
862
868
  // beside: whether somebody else's Claude Code can drive this browser (#799).
863
869
  const relay = await relayGuard(profiles, { platform, env, deps }).catch(() => null);
@@ -42,7 +42,7 @@ import { withStoreLock } from "./store-lock.mjs";
42
42
  // read off the store. The roster puts them on each row; verdictNow and
43
43
  // verdictsNow are re-exported because cswap-admin.mjs and lan-deck.mjs reach
44
44
  // them through this module.
45
- import { verdictFor } from "./claude-verdicts.mjs";
45
+ import { verdictFor, verdictsAskedAt } from "./claude-verdicts.mjs";
46
46
  export { verdictNow, verdictsNow } from "./claude-verdicts.mjs";
47
47
  // Asking claude-swap to collect, which every roster read does when something is
48
48
  // due, and when each account will next be read.
@@ -387,13 +387,20 @@ function originsNow() {
387
387
  * Local network — exactly what the mark must never be. Safe to decide off one
388
388
  * read: claude-swap replaces sequence.json whole (temp file and rename), and an
389
389
  * import refreshes a slot in place rather than removing it first. Asked of every
390
- * account in the store, not of the ordered list drawn above, and never of a
391
- * store with none: an emptied store is the one state where waiting costs
392
- * nothing.
390
+ * account in the store, not of the ordered list drawn above.
391
+ *
392
+ * AN EMPTIED STORE IS AN ANSWER TOO. Removing the last account leaves
393
+ * sequence.json with `accounts: {}`, and this used to be the one store the
394
+ * read waited out — but a mark kept there is inherited by the same address
395
+ * pasted from a share later, which then reads as one this deck signed in and
396
+ * is prompted for as one. Only a file that could not be read says nothing, and
397
+ * readRoster never gets this far with one. `held` that is not a map of slots is
398
+ * no store at all, and forgets nothing.
393
399
  */
394
400
  function tidyOrigins(origins, recovered, held) {
395
- const present = new Set(Object.values(held ?? {}).map(a => accountKey(a?.email, a?.organizationUuid)));
396
- const gone = present.size ? Object.keys(origins).filter(k => !present.has(k)) : [];
401
+ const isStore = held != null && typeof held === "object" && !Array.isArray(held);
402
+ const present = new Set(Object.values(isStore ? held : {}).map(a => accountKey(a?.email, a?.organizationUuid)));
403
+ const gone = isStore ? Object.keys(origins).filter(k => !present.has(k)) : [];
397
404
  if (!recovered.length && !gone.length) return;
398
405
  // `seen` rides along so the write changes only what this read judged: a mark
399
406
  // re-made, or an incident put off, since then is not this read's to undo.
@@ -530,7 +537,9 @@ function rosterRow({ seq, num, acct, row, identity, now, key, origin = null }) {
530
537
  ].filter(Boolean);
531
538
 
532
539
  const collector = verdictFor(num, now, acct.email, acct.organizationUuid);
533
- const reauth = reauthFor({ entry: origin, trouble, collector, fetchedAt: fetchedAtMs, attemptedAt: attemptedAtMs });
540
+ const reauth = reauthFor({
541
+ entry: origin, trouble, collector, fetchedAt: fetchedAtMs, attemptedAt: attemptedAtMs, verdictAt: verdictsAskedAt(),
542
+ });
534
543
  return {
535
544
  num: Number(num),
536
545
  email: acct.email ?? null,
@@ -753,6 +762,17 @@ export function invalidateClaudeAccountsCache() {
753
762
  _lastReadAt = 0;
754
763
  }
755
764
 
765
+ /**
766
+ * How many accounts the last roster read held, for the daily report's count
767
+ * (depth-facts.mjs): 0 when claude-swap is installed with nothing added, null
768
+ * before any read or when the read could not say. Answered from the cache —
769
+ * never starts a read, so the report costs claude-swap nothing.
770
+ */
771
+ export function knownAccountCount() {
772
+ if (_cache?.ok && Array.isArray(_cache.accounts)) return _cache.accounts.length;
773
+ return _cache?.reason === "no_accounts" ? 0 : null;
774
+ }
775
+
756
776
  /**
757
777
  * The slot an account argument names, as a number, or null when it names none.
758
778
  *
@@ -106,6 +106,10 @@ export async function verdictNow(email, org, { runner = run, bin = cswapBin } =
106
106
  * collection that can take a minute on a cold network.
107
107
  */
108
108
  async function collectVerdicts({ runner, bin }) {
109
+ // Stamped when the question was ASKED, not when it was answered. A
110
+ // collection that began before a sign-in reports on the login it began
111
+ // with, and the re-sign-in prompt must be able to tell (see verdictsAskedAt).
112
+ const asked = Date.now();
109
113
  try {
110
114
  const out = await runner(await bin(), ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS });
111
115
  if (!out?.ok) return null;
@@ -119,7 +123,7 @@ async function collectVerdicts({ runner, bin }) {
119
123
  identities[String(a.number)] = accountKey(a.email, a.organizationUuid);
120
124
  }
121
125
  }
122
- _verdicts = { at: Date.now(), byNum, identities };
126
+ _verdicts = { at: asked, byNum, identities };
123
127
  return (Array.isArray(d?.accounts) ? d.accounts : []).map(a => ({
124
128
  number: a?.number,
125
129
  email: String(a?.email ?? "").trim().toLowerCase(),
@@ -143,6 +147,13 @@ export function verdictsNow({ runner = run, bin = cswapBin, fresh = false } = {}
143
147
  return verdictQueue.ask({ fresh });
144
148
  }
145
149
 
150
+ /** When the cached verdicts were asked for, in ms, or null before the first.
151
+ * A verdict asked before the deck last signed an account in is about the
152
+ * login that sign-in replaced — see reauthFor in account-origins.mjs. */
153
+ export function verdictsAskedAt() {
154
+ return _verdicts.at > 0 ? _verdicts.at : null;
155
+ }
156
+
146
157
  /** claude-swap's verdict for a slot, or null when there is none fresh enough. */
147
158
  export function verdictFor(num, now, email, org) {
148
159
  return cachedVerdictFor(_verdicts, num, now, email, org);
@@ -3,10 +3,12 @@
3
3
  //
4
4
  // SPAWNED, BUT NEVER ON THE BOOT PATH. claude-dir.mjs measured a Claude Code
5
5
  // child at ~3.0s and says out loud that boot is the wrong place to spend one, so
6
- // this is run once, lazily, in the background — reports.mjs kicks it from the
7
- // first check-in, which is already past boot — and its result rides along on the
8
- // reports that come after. A version that is not known yet is simply not sent;
9
- // the field is optional and the API records nothing for an absent one.
6
+ // this runs in the background — reports.mjs starts it once the deck is listening
7
+ // and the prefs say reports are on, and asks again on each new UTC day so a deck
8
+ // left running reports the CLI it has now. The first report waits a few seconds
9
+ // for the answer, beside the wait for the boot's setup, rather than going out
10
+ // without it. A version that is not known by then is simply not sent; the field
11
+ // is optional and the API records nothing for an absent one.
10
12
  //
11
13
  // WHAT LEAVES IS A VERSION AND NOTHING ELSE. parseVersion keeps only a
12
14
  // dotted-number token (with the `^[0-9A-Za-z.+_-]+$` shape the API enforces, and
@@ -13,6 +13,7 @@
13
13
  // new access token is never treated as success, and nothing here throws —
14
14
  // a rejected promise from a background poll would take the server down.
15
15
  import { readFile, chmod, unlink } from "node:fs/promises";
16
+ import { createHash } from "node:crypto";
16
17
  import { join } from "node:path";
17
18
  import { CODEX_HOME } from "./codex-dir.mjs";
18
19
  import { createTemp, renameWithRetry, resolveWriteTarget } from "./atomic-write.mjs";
@@ -230,6 +231,16 @@ function refreshErrorCode(body) {
230
231
  return typeof raw === "string" ? raw.toLowerCase() : null;
231
232
  }
232
233
 
234
+ // The last refresh token the endpoint refused for good, by hash — the token
235
+ // itself is not kept. A refused token is dead until `codex login` replaces it,
236
+ // and the expiry-driven path asks for a refresh on every poll once the access
237
+ // token's `exp` has passed, so without this the same dead token was POSTed to
238
+ // auth.openai.com once a minute for as long as the panel stayed open. Matched
239
+ // against what auth.json holds at each attempt, so it lapses by itself the
240
+ // moment the file carries a different one.
241
+ let _refusedRefresh = null;
242
+ const tokenHash = (token) => createHash("sha256").update(String(token)).digest("hex");
243
+
233
244
  /**
234
245
  * Spend the refresh token. Never throws — every failure is a return value,
235
246
  * because callers include a 60s background poll whose rejection would reach
@@ -262,6 +273,7 @@ async function doRefresh(auth) {
262
273
  if (!res.ok) {
263
274
  const code = refreshErrorCode(body);
264
275
  const permanent = (code && PERMANENT_CODES.has(code)) || res.status === 401;
276
+ if (permanent) _refusedRefresh = tokenHash(auth.tokens.refresh_token);
265
277
  return { ok: false, reason: permanent ? "refresh_rejected" : "refresh_failed", code };
266
278
  }
267
279
 
@@ -322,6 +334,9 @@ function refreshCredentials({ ifStale = false, staleAccessToken = null } = {}) {
322
334
  if (staleAccessToken && auth.tokens.access_token !== staleAccessToken) {
323
335
  return { ok: true, auth }; // someone else already rotated past it
324
336
  }
337
+ if (tokenHash(auth.tokens.refresh_token) === _refusedRefresh) {
338
+ return { ok: false, reason: "refresh_rejected", code: "refused_before" };
339
+ }
325
340
  return doRefresh(auth);
326
341
  });
327
342
  }
@@ -374,6 +389,20 @@ export async function getCodexAuth({ allowRefresh = true } = {}) {
374
389
  return identityFrom(auth, refreshed);
375
390
  }
376
391
 
392
+ /**
393
+ * Which credential auth.json holds right now, as a hash of its two tokens, or
394
+ * null when it holds neither. Never the tokens themselves.
395
+ *
396
+ * For codex-quota.mjs, whose cooldown after a refused login is about the
397
+ * credential that was refused: `codex login` writes a new pair, and a wait
398
+ * that outlived it answered the user's ↻ with the old refusal.
399
+ */
400
+ export async function codexCredentialFingerprint() {
401
+ const tokens = (await readAuthFile())?.tokens;
402
+ if (!tokens?.access_token && !tokens?.refresh_token) return null;
403
+ return tokenHash(`${tokens.access_token ?? ""}\n${tokens.refresh_token ?? ""}`);
404
+ }
405
+
377
406
  /**
378
407
  * Refresh because the backend rejected a token that looked valid locally —
379
408
  * OpenAI revokes server-side, so the JWT's own `exp` is not the last word.