ccdeck 3.29.11 → 3.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
package/hook/hook.js CHANGED
@@ -56,8 +56,9 @@ const PROVIDER = parseProvider(process.argv.slice(2));
56
56
  * arrived short — `%TEMP%` under a shortened profile directory, which is what
57
57
  * every GitHub Windows runner has — the two canonicalisers that exist to agree
58
58
  * disagreed by a whole path: C:\Users\RUNNER~1\… against C:\Users\runneradmin\….
59
- * canonicalWorkspace in src/server/index.mjs says the rest of it, including why
60
- * the long form is the canonical one; all three sites name `.native` out loud.
59
+ * canonicalWorkspace in src/server/canonical-path.mjs says the rest of it,
60
+ * including why the long form is the canonical one; all three sites name
61
+ * `.native` out loud.
61
62
  *
62
63
  * Exported for that test: it is half of what `--workspace` means, and a
63
64
  * predicate handed an already-canonical path cannot show that the caller
@@ -179,7 +180,7 @@ function cwdInWorkspace(cwd, workspace, platform = process.platform) {
179
180
  * payload with no cwd — it is here because the rule has to be stated the same
180
181
  * way on both sides to be pinned against the other one.
181
182
  *
182
- * src/server/log-writer.mjs answers this same question, for the sessions the
183
+ * src/server/log-election.mjs answers this same question, for the sessions the
183
184
  * server builds itself out of Codex's rollout files, under the name
184
185
  * codexCwdInWorkspace — this script is copied out of the package and run
185
186
  * standalone, so it cannot import that copy. A test walks one table of paths
@@ -233,11 +234,11 @@ function isAlive(pid) {
233
234
  * The platform is a parameter, like cwdInWorkspace's, so the case-folding half
234
235
  * is testable from any machine.
235
236
  *
236
- * src/server/log-writer.mjs repeats this rule for the events no hook delivers —
237
- * the ones the server builds itself from Codex's rollout files — because this
238
- * script is copied out of the package and cannot import it. A test compares the
239
- * two directly: they decide for the same decks, and a disagreement is a line
240
- * written twice or not at all.
237
+ * src/server/log-election.mjs repeats this rule for the events no hook
238
+ * delivers — the ones the server builds itself from Codex's rollout files —
239
+ * because this script is copied out of the package and cannot import it. A test
240
+ * compares the two directly: they decide for the same decks, and a disagreement
241
+ * is a line written twice or not at all.
241
242
  */
242
243
  function electWriters(decks, platform = process.platform) {
243
244
  const byLog = new Map();
@@ -527,7 +528,7 @@ function prove(d, cb, attempt = 0) {
527
528
  * The election has to be decided over the decks that are actually going to be
528
529
  * handed the payload, and the only thing that establishes that is the handshake.
529
530
  * So: prove, then elect, then post. It costs no extra round trip, only this
530
- * ordering, and it is the same reordering src/server/index.mjs makes in
531
+ * ordering, and it is the same reordering src/server/live-decks.mjs makes in
531
532
  * readLiveDecks for the Codex rollouts no hook ever sees.
532
533
  *
533
534
  * A FAILED CHALLENGE ON ITS OWN IS STILL NOT PROOF THE DECK IS GONE. A dead pid
@@ -793,10 +794,10 @@ function readRecord(file, resolvedCwd, found, done) {
793
794
  // every tool call, and nothing removes the record: pid 1 is init, so
794
795
  // isAlive is true forever and the unlink below never fires.
795
796
  //
796
- // index.mjs:2156 reads these same files and always refused them. Two
797
- // readers of one directory disagreeing is the bug; this is the stronger
798
- // half, which is the one that belongs in the process that cannot afford
799
- // to throw.
797
+ // sweepStaleDiscovery, in src/server/stale-discovery.mjs, reads these same
798
+ // files and always refused them. Two readers of one directory disagreeing
799
+ // is the bug; this is the stronger half, which is the one that belongs in
800
+ // the process that cannot afford to throw.
800
801
  //
801
802
  // IT IS INSIDE THE fs CALLBACK NOW, and that is where it has to be rather
802
803
  // than merely where it ended up. The reads are asynchronous (#1018), so the
@@ -1040,7 +1041,7 @@ function main() {
1040
1041
  // /private/tmp/proj) and then comes back empty after a restart. Silently —
1041
1042
  // replayLog does not count or warn about an out-of-scope line.
1042
1043
  //
1043
- // The Codex watcher already stores the canonical form (index.mjs:2203,
1044
+ // The Codex watcher already stores the canonical form (codex-watch.mjs,
1044
1045
  // `cwd: await canonicalCwd(...)`, the same resolve+realpath this is), which
1045
1046
  // is why the two providers disagreed on one board. One spelling in the log
1046
1047
  // is the whole fix.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.29.11",
3
+ "version": "3.31.0",
4
4
  "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,6 +59,46 @@
59
59
  "3.29.7",
60
60
  "3.29.9"
61
61
  ],
62
+ "3.31.0": [
63
+ {
64
+ "title": "📊 Account capacity",
65
+ "body": "A gauge button in the accounts panel's header opens Account capacity: how many of your Claude accounts are ready now, what the 5-hour and 7-day windows have left across all of them, and a row for each account with its use, its state (Ready, Limited, Exhausted or Stale) and when it was last read. Each window also says whose reset comes next."
66
+ },
67
+ {
68
+ "title": "🚦 Claude and Codex incidents",
69
+ "body": "When Anthropic's or OpenAI's status page reports an incident on something your sessions use, the deck says so in the topbar, the Usage panel and the tray, and every mark links to the status page. A provider with nothing wrong draws nothing."
70
+ },
71
+ {
72
+ "title": "↕️ Order your other accounts",
73
+ "body": "The Other accounts row can sort the list by slot, by how full a window is, or by most room, and open every account at once. Both stay as you left them after a reload."
74
+ },
75
+ {
76
+ "title": "🔐 A settings folder left by sudo can be given back",
77
+ "body": "On a Mac, running ccdeck once with sudo left its settings folder owned by another user, and every settings change was refused, sharing an account included. The message now offers \"give it back\": macOS asks for your password, the folder is yours again, and the deck reads its settings back without a restart."
78
+ }
79
+ ],
80
+ "3.30.0": [
81
+ {
82
+ "title": "🧮 Token counts and costs are no longer doubled",
83
+ "body": "Claude Code writes a reply that has thinking, text and tool calls as several transcript lines, each carrying the whole request's usage. The deck added up every line, so a session's tokens and cost on its card came out close to twice what it was billed, and the context panel counted each part of a reply as its own message. Each request is now counted once. Expect the totals on your cards to come down."
84
+ },
85
+ {
86
+ "title": "⏳ Claude's saved limit resets on the Usage panel",
87
+ "body": "When your Claude account has free limit resets to spend (\"Reset for free\" in Claude's Settings → Usage), the Claude card now says how many there are and when the first one expires, the way the Codex card already did. The deck only reports them; you spend them in Claude's own settings."
88
+ },
89
+ {
90
+ "title": "🔑 LAN invites need the other deck updated",
91
+ "body": "An invite's code is now a long random secret instead of six digits, so nobody else on the network can work it out and pair in your place, and a wrong code is refused by name. An invite made on this version can only be joined from a deck on this version or later: an older deck answers \"That is not an invite\". Update that deck, or make the invite on the older one and join it here."
92
+ },
93
+ {
94
+ "title": "🔕 AGENTS_DECK_NO_NOTIFY keeps Browser Watch quiet too",
95
+ "body": "A deck started with AGENTS_DECK_NO_NOTIFY=1 still raised a desktop notification for every new Browser Watch finding. It no longer does. The finding is still in the Browser Watch panel and its log, which says why no notification came."
96
+ },
97
+ {
98
+ "title": "🛠️ Smaller fixes",
99
+ "body": "Delete removes the card you just clicked. A LAN address you typed stays in the list when a round cannot reach it, and the status line under the LAN switch now agrees with the rows below it. In the desktop app, a dismissed \"update is ready\" notice stays dismissed after you answer the first-launch question. Sizes read the same in every panel (\"1.1 KB\"). Faded text stays readable, a keyboard can see the scrollbar, and numbers use a monospaced font on Windows and Linux as well."
100
+ }
101
+ ],
62
102
  "3.29.11": [
63
103
  {
64
104
  "title": "💲 Claude Sonnet 5.5 has a price",
@@ -4,11 +4,11 @@
4
4
  // The account cards show account-wide rate-limit utilisation, which has no
5
5
  // project dimension and cannot be sliced. This is the other axis: for one
6
6
  // account, the tokens it spent per project, which the web side prices with the
7
- // board's own pricing table (pricing.ts). Cost is NOT computed here — the
8
- // server is plain .mjs with no build step and cannot import the .ts price
9
- // table, and keeping one source of prices beats a second copy that drifts. So
10
- // this file tallies TOKENS per (account, project, day, model) and the client
11
- // multiplies by the rates it already owns.
7
+ // board's own rates (rate-table.ts, through pricing.ts's costForUsage). Cost is
8
+ // NOT computed here — the server is plain .mjs with no build step and cannot
9
+ // import the .ts rate table, and keeping one source of prices beats a second
10
+ // copy that drifts. So this file tallies TOKENS per (account, project, day,
11
+ // model) and the client multiplies by the rates it already owns.
12
12
  //
13
13
  // Attribution is per MESSAGE, not per session. A running session migrates to a
14
14
  // newly-activated account on its next message, so a whole session can span two
@@ -25,10 +25,14 @@ import { open, stat, readdir, readFile, writeFile, mkdir } from "node:fs/promise
25
25
  import { homedir } from "node:os";
26
26
  import { join, resolve, basename, dirname, relative } from "node:path";
27
27
  import { StringDecoder } from "node:string_decoder";
28
- import { renameWithRetry } from "./installer.mjs";
28
+ 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
36
 
33
37
  /** The pseudo-account for messages the swap log cannot place — everything
34
38
  * before tracking began, or a gap. Shown once, apart from any real account, so
@@ -80,6 +84,16 @@ export function countersFrom(usage) {
80
84
  };
81
85
  }
82
86
 
87
+ /** The same counters under the names a usage block gives them, which is the
88
+ * shape transcript-scan.mjs's hasSpend reads. */
89
+ function asUsageTotals(c) {
90
+ return {
91
+ input_tokens: c.i, output_tokens: c.o,
92
+ cache_read_input_tokens: c.cr, cache_creation_input_tokens: c.cc,
93
+ ephemeral_1h_input_tokens: c.c1h, ephemeral_5m_input_tokens: c.c5m,
94
+ };
95
+ }
96
+
83
97
  function addInto(dst, src) {
84
98
  dst.i += src.i; dst.o += src.o; dst.cr += src.cr;
85
99
  dst.cc += src.cc; dst.c1h += src.c1h; dst.c5m += src.c5m;
@@ -187,16 +201,16 @@ export function foldLine(tally, line, timeline, from) {
187
201
  if (!line || !line.includes('"usage"')) return;
188
202
  let obj = null;
189
203
  try { obj = JSON.parse(line); } catch { return; }
190
- // Claude writes one assistant record for each content block in a request.
191
- // Every block repeats the request's usage, so only block 0 is billable for
192
- // this rollup. Older records without the field are kept compatible.
193
- if (obj?.apiBlockIndex !== undefined && obj.apiBlockIndex !== 0) return;
204
+ // Claude writes one assistant record for each content block in a request,
205
+ // and every block repeats the request's usage, so only block 0 is billable
206
+ // — see repeatsRequestUsage.
207
+ if (repeatsRequestUsage(obj)) return;
194
208
  const usage = obj?.message?.usage;
195
209
  if (!usage || typeof usage !== "object") return;
196
210
  const ts = Date.parse(obj?.timestamp);
197
211
  if (!Number.isFinite(ts)) return;
198
212
  const c = countersFrom(usage);
199
- if (!(c.i || c.o || c.cr || c.cc)) return; // a usage block that billed nothing
213
+ if (!hasSpend(asUsageTotals(c))) return; // a usage block that billed nothing
200
214
  const model = typeof obj?.message?.model === "string" ? obj.message.model : "";
201
215
  const lineCwd = (typeof obj?.cwd === "string" && obj.cwd) ? obj.cwd : "";
202
216
  const cwd = projectPath(folderOf(lineCwd, from)); // a worktree counts under the repo it checks out
@@ -0,0 +1,254 @@
1
+ // The HTTP face of the Claude accounts surface: the roster, the switch, sign-in
2
+ // and the store's admin verbs, auto-switch, and the per-account Projects report.
3
+ //
4
+ // These lived in src/server/index.mjs, on either side of the self-update pin,
5
+ // and they are the thinnest layer in the deck: each one reads a query or a
6
+ // body, hands it to claude-accounts.mjs, cswap-admin.mjs, cswap-auto.mjs or the
7
+ // project rollup, and answers with what came back. Every one of those modules
8
+ // is still imported lazily and by the same package-root URL PINNED_MODULES
9
+ // loads, so moving the routes here puts no import on the path to a listening
10
+ // socket and takes none out of the pin.
11
+ //
12
+ // Four things are exported beyond the handlers. Three because index.mjs reaches
13
+ // for them: wireStaleCopyRepair, which startServer runs to hand the roster its
14
+ // stale-copy repair, cswapAutoModule, which it uses to start auto-switch, and
15
+ // getProjectRollup, which it starts at boot. The fourth is CHECKS_IMPORTS,
16
+ // which lan-deck.mjs reads because a LAN round checks the imports it lands the
17
+ // same way the paste box does.
18
+ import { dirname, join, resolve } from "node:path";
19
+ import { fileURLToPath, pathToFileURL } from "node:url";
20
+ import { readBody, send } from "./http-io.mjs";
21
+
22
+ // Resolved the way pinned-build.mjs resolves it, from a file in the same
23
+ // directory, so every lazy import below is the URL the pin has already evaluated.
24
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
25
+
26
+ // Whether every import is followed by checkImports, which only asks on a Mac.
27
+ // When it does, the import skips its own detached collection — see importAccount.
28
+ export const CHECKS_IMPORTS = process.platform === "darwin";
29
+
30
+ export async function handleClaudeAccounts(req, res) {
31
+ const { fetchClaudeAccounts } = await import(
32
+ pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
33
+ );
34
+ const url = new URL(req.url, "http://localhost");
35
+ const force = url.searchParams.get("refresh") === "1";
36
+ send(res, 200, await fetchClaudeAccounts({ force }));
37
+ }
38
+
39
+ export async function handleClaudeAccountSwitch(req, res) {
40
+ const { switchClaudeAccount, invalidateClaudeAccountsCache } = await import(
41
+ pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
42
+ );
43
+ const body = await readBody(req, res).catch(() => null);
44
+ let parsed = null;
45
+ try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
46
+ if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
47
+
48
+ const result = await switchClaudeAccount(parsed.account);
49
+ // The active account just moved; the next poll should see it immediately
50
+ // rather than serving the pre-switch roster for another few seconds.
51
+ invalidateClaudeAccountsCache();
52
+ // The 5h/7d percentages moved with it — they are read for whichever account is
53
+ // active — and only this side knows: /api/quota is polled by the usage panel,
54
+ // which has no idea the accounts panel just switched and so never sends
55
+ // ?refresh=1. Without this the two panels sit on one screen disagreeing about
56
+ // the same account for a full poll cycle, and the stale one is the one the
57
+ // user just acted on. Only on a switch that took: a refused one left the
58
+ // account where it was, and throwing away a reading we paid for costs a real
59
+ // answer to fix nothing.
60
+ if (result.ok) {
61
+ const { invalidateQuotaCache } = await import(
62
+ pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
63
+ );
64
+ invalidateQuotaCache();
65
+ // Note who became active, and when, for the account-projects report. Best
66
+ // effort — a failed log write must never fail the switch — so it is not
67
+ // awaited into the response.
68
+ import(pathToFileURL(join(PKG_ROOT, "src/server/swap-log.mjs")).href)
69
+ .then(({ recordSwap }) => recordSwap(parsed.account, "manual"))
70
+ .catch(() => {});
71
+ }
72
+ send(res, result.ok ? 200 : 400, result);
73
+ }
74
+
75
+ // The account-projects rollup, started once and shared. Its timer is unref'd
76
+ // inside start(), so holding the instance here never keeps the process alive.
77
+ let _projectRollup = null;
78
+ export function getProjectRollup() {
79
+ if (!_projectRollup) {
80
+ _projectRollup = import(pathToFileURL(join(PKG_ROOT, "src/server/account-projects.mjs")).href)
81
+ .then(m => { const r = m.createProjectRollup(); r.start().catch(() => {}); return r; });
82
+ }
83
+ return _projectRollup;
84
+ }
85
+
86
+ /**
87
+ * The "Projects" report for one account: how many tokens it spent per project
88
+ * over a window. The server tallies only tokens (per model); the web side
89
+ * prices them with its own table, so cost never lives in two places. `num` is
90
+ * the account's slot, resolved to its `(email, org)` key here; `days` is 1, 7,
91
+ * 30, or 0 for all tracked.
92
+ */
93
+ export async function handleAccountProjects(req, res) {
94
+ const url = new URL(req.url, "http://localhost");
95
+ const num = Number(url.searchParams.get("num"));
96
+ const d = Number(url.searchParams.get("days"));
97
+ const days = d === 1 ? 1 : d === 30 ? 30 : d === 0 ? 0 : 7;
98
+ if (!Number.isInteger(num) || num <= 0) return send(res, 400, { ok: false, reason: "bad_account" });
99
+ const { identityForSlot } = await import(pathToFileURL(join(PKG_ROOT, "src/server/swap-log.mjs")).href);
100
+ const id = await identityForSlot(num);
101
+ if (!id) return send(res, 400, { ok: false, reason: "bad_account" });
102
+ const { accountKey } = await import(pathToFileURL(join(PKG_ROOT, "src/server/lan-sync.mjs")).href);
103
+ const rollup = await getProjectRollup();
104
+ const report = await rollup.report(accountKey(id.email, id.orgUuid), days);
105
+ send(res, 200, { ok: true, ...report });
106
+ }
107
+
108
+ function cswapAdminModule() {
109
+ return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-admin.mjs")).href);
110
+ }
111
+
112
+ /**
113
+ * Hand cswap-admin's autoRecapture to the roster read, so a `stale-copy` row
114
+ * carries its own repair rather than a button — see repairStaleCopyWith.
115
+ *
116
+ * startServer calls this once per boot on a deck that watches Claude Code, and
117
+ * does not wait for it. It lived in startServer itself and moved here, beside
118
+ * cswapAdminModule and the package root it imports the pair by; the body is
119
+ * unchanged.
120
+ */
121
+ export function wireStaleCopyRepair() {
122
+ // THIS WIRING IS WHERE AN IMPORT CYCLE SHOWED UP AS A SILENT NO-OP, and it
123
+ // is the cycle that has been fixed rather than this line — see
124
+ // claude-identity.mjs.
125
+ //
126
+ // claude-accounts.mjs used to take `currentIdentity` from cswap-admin.mjs
127
+ // while cswap-admin.mjs took `backupRoot`, `invalidateClaudeAccountsCache`
128
+ // and `verdictNow` back, which was the only static import cycle in
129
+ // src/server. Two dynamic imports entering a cycle concurrently are each
130
+ // handed the other module's HALF-BUILT namespace rather than waiting for
131
+ // it, and a half-built namespace has no exports on it at all: measured on
132
+ // CI, both came back with zero keys, so `accounts.repairStaleCopyWith` was
133
+ // a TypeError in a promise nothing awaits. The repair was never wired and
134
+ // nothing said so — every test passed, and the run exited 1 on an unhandled
135
+ // rejection alone.
136
+ //
137
+ // Several importers reach this pair within a few ticks at boot —
138
+ // startServer's `cswapAutoModule()` imports claude-accounts.mjs too, and
139
+ // `startServer` can be called again before this has settled — so it was a
140
+ // timing defect that any change to those import lists could trip, and
141
+ // sequencing one call site was never going to be enough.
142
+ //
143
+ // Asked for one at a time anyway, which is now belt as well as braces: with
144
+ // no cycle left, a concurrent pair would simply wait for each other.
145
+ // boot-module-graph.test.ts asserts the braces — that src/server has no
146
+ // import cycles at all — rather than trying to police call sites.
147
+ void (async () => {
148
+ let accounts, admin;
149
+ // An import that genuinely fails stays tolerated, exactly as the
150
+ // `() => {}` this replaced tolerated it: the wiring is best-effort. A
151
+ // namespace that arrives WITHOUT the function is a different thing and
152
+ // must stay loud, because that is the failure described above.
153
+ try {
154
+ accounts = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
155
+ admin = await cswapAdminModule();
156
+ } catch { return; }
157
+ accounts.repairStaleCopyWith(admin.autoRecapture);
158
+ })();
159
+ }
160
+
161
+ // Reading the login's progress. The browser polls this while its dialog is
162
+ // open, the same way the upgrade notice polls /api/version.
163
+ export async function handleAccountLoginState(_req, res) {
164
+ const { loginState } = await cswapAdminModule();
165
+ send(res, 200, { ok: true, ...loginState() });
166
+ }
167
+
168
+ /**
169
+ * Everything that changes the account store, behind one verb switch — the shape
170
+ * handleCswapAutoAction already uses.
171
+ *
172
+ * The sign-in code is the one field here that is a credential. It is read out
173
+ * of the body, handed straight to the child's stdin, and never logged, echoed
174
+ * back, or written anywhere — which is also why this is a POST body and not a
175
+ * query parameter.
176
+ */
177
+ export async function handleClaudeAccountAdmin(req, res) {
178
+ const admin = await cswapAdminModule();
179
+ const body = await readBody(req, res).catch(() => null);
180
+ let parsed = null;
181
+ try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
182
+ if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
183
+
184
+ let result;
185
+ switch (parsed.action) {
186
+ case "login": result = await admin.startLogin({ email: parsed.email }); break;
187
+ case "login-code": result = await admin.submitLoginCode(parsed.code); break;
188
+ case "login-cancel": result = await admin.cancelLogin(); break;
189
+ // `accounts` is a chosen set, `account` the single row's own button. Both
190
+ // go to shareAccounts, which treats one account as a bundle of one so the
191
+ // two entry points cannot drift into two envelope shapes.
192
+ case "share": result = await admin.shareAccounts(parsed.accounts ?? parsed.account); break;
193
+ // `only` names one account inside the pasted bundle, and is the only way
194
+ // `force` is honoured at all - see importAccount for why the pair is
195
+ // required rather than the flag alone.
196
+ case "import": {
197
+ const out = await admin.importAccount(parsed.blob, {
198
+ force: parsed.force === true,
199
+ only: parsed.only ?? null,
200
+ collect: !CHECKS_IMPORTS,
201
+ });
202
+ // Checked the way a LAN round's arrivals are (#1244): `cswap import`
203
+ // exiting 0 on a Mac is not proof this process can read what it wrote.
204
+ result = out?.ok ? { ...out, results: await admin.checkImportResults(out.results) } : out;
205
+ break;
206
+ }
207
+ case "remove": result = await admin.removeAccount(parsed.account); break;
208
+ // #721. Re-captures the active slot's credentials in place; see
209
+ // recaptureActive for why this is not a login and cannot become one.
210
+ case "recapture": result = await admin.recaptureActive(); break;
211
+ case "alias": result = await admin.setAlias(parsed.account, parsed.alias); break;
212
+ case "move": result = await admin.moveAccount(parsed.account, parsed.slot); break;
213
+ default: return send(res, 400, { ok: false, reason: "unknown_action" });
214
+ }
215
+ send(res, result.ok ? 200 : 400, result);
216
+ }
217
+
218
+ export function cswapAutoModule() {
219
+ return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-auto.mjs")).href);
220
+ }
221
+
222
+ export async function handleCswapAuto(req, res) {
223
+ const { autoStatus } = await cswapAutoModule();
224
+ send(res, 200, await autoStatus());
225
+ }
226
+
227
+ /**
228
+ * One POST for every auto-switch control, keyed by `action`. Each one can move
229
+ * the user's live Claude account or change when it moves, so nothing here is
230
+ * reachable by GET.
231
+ */
232
+ export async function handleCswapAutoAction(req, res) {
233
+ const mod = await cswapAutoModule();
234
+ const body = await readBody(req, res).catch(() => null);
235
+ let parsed = null;
236
+ try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
237
+ if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
238
+
239
+ let result;
240
+ switch (parsed.action) {
241
+ case "enable":
242
+ result = await mod.setAutoEnabled(parsed.enabled === true);
243
+ break;
244
+ case "setting":
245
+ result = await mod.setCswapConfig(String(parsed.key ?? ""), parsed.value);
246
+ break;
247
+ case "account":
248
+ result = await mod.setAccountEnabled(parsed.account, parsed.enabled === true);
249
+ break;
250
+ default:
251
+ return send(res, 400, { ok: false, reason: "unknown_action" });
252
+ }
253
+ send(res, result.ok ? 200 : 400, result);
254
+ }
@@ -23,9 +23,9 @@ const CLOSES = new Set(["Stop", "SessionEnd"]);
23
23
 
24
24
  /** More turns open at once than any machine runs — the same figure as the
25
25
  * deck's own working limit for the sessions it tracks (MAX_TRACKED_SESSIONS in
26
- * index.mjs). A bound because the ids arrive from outside: this is fed from
27
- * pushEvent for every live event, and `/api/event` takes them without a
28
- * credential. presence.mjs caps its map of tab ids for the same reason.
26
+ * session-tracking.mjs). A bound because the ids arrive from outside: this is
27
+ * fed from pushEvent for every live event, and `/api/event` takes them without
28
+ * a credential. presence.mjs caps its map of tab ids for the same reason.
29
29
  *
30
30
  * Past it the turn heard from least recently goes, and that costs `busy` no
31
31
  * accuracy at all: the map is kept in the order turns were last heard from
@@ -0,0 +1,185 @@
1
+ // What the events log's append queue is allowed to weigh (#1030): the ceiling
2
+ // on lines accepted and not yet written, the charge each line carries until
3
+ // its write settles, and what the queue refused at the door because of it.
4
+ //
5
+ // Moved out of log-writer.mjs unchanged. appendLogLine asks queueIsFull at the
6
+ // door, then either counts a refusal (noteRefused) or takes the line's charge
7
+ // (takeCharge) and gives it back once the write has landed or failed
8
+ // (releaseCharge). log-writer.mjs re-exports MAX_PENDING_APPEND_CHARS and
9
+ // appendQueueStats, which is where the suite reaches them.
10
+ import { PRODUCT } from "./brand.mjs";
11
+
12
+ // ─── What the queue is allowed to weigh ───────────────────────────────────
13
+ //
14
+ // The chain in log-writer.mjs ORDERS appends. Nothing bounded them. Every line
15
+ // handed over is retained by the closure that will eventually write it, and
16
+ // the only brake on how many of those exist at once is how fast write(2)
17
+ // returns — while pushEvent hands them over fire-and-forget and answers
18
+ // `{ok:true, seq}` on the next statement, so `POST /api/event` admits lines as
19
+ // fast as a socket can deliver them.
20
+ //
21
+ // MEASURED (Linux 7.0 / Node 24.21 / ext4 on NVMe), eight sockets posting 1 MB
22
+ // `tool_response` bodies to a sandboxed deck for four seconds, sampled twice a
23
+ // second:
24
+ //
25
+ // t=1.0s log_MB=19 rss_MB=881
26
+ // t=2.5s log_MB=51 rss_MB=1741
27
+ // t=4.1s log_MB=85 rss_MB=2260 <- ingest stops; 1540 MB acknowledged
28
+ // t=5.5s log_MB=1135 rss_MB=935
29
+ // t=6.0s log_MB=1540 rss_MB=546 <- queue finally drained
30
+ //
31
+ // 85 MB on disk against 1540 MB the deck had said it had: 1455 MB of serialized
32
+ // lines held in the heap, and an RSS peak of 2260 MB — 17x MAX_BUFFER_CHARS,
33
+ // the budget the ring beside this keeps exactly. The disk is not the amplifier;
34
+ // the event loop is. Ingest and the writer share it, so the log grew at 21 MB/s
35
+ // while the posting continued and at 800 MB/s the instant it stopped.
36
+ //
37
+ // The same burst under `--max-old-space-size=1024`:
38
+ //
39
+ // FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
40
+ // [deck exited code=null sig=SIGABRT] 3.2s in, 50 MB written
41
+ //
42
+ // That abort is not catchable, so the SSE stream, the hook ingest and the log
43
+ // stop together — and `/api/event` is a deliberate OPEN_MUTATION, so this is a
44
+ // few hundred unauthenticated posts from any local process ending the deck.
45
+ // It is the failure MAX_BUFFER_CHARS was added to close (#625), reached through
46
+ // the door beside the one it guards (#1030).
47
+ //
48
+ // Who gets there without trying: `--history` accepts any path. The 40 events/s
49
+ // of 1 MB that is trivial on NVMe is roughly 30 MB/s of pure accumulation on a
50
+ // 5-10 MB/s network mount or a slow external drive, where the queue's only
51
+ // brake is that much slower than the traffic filling it.
52
+
53
+ /**
54
+ * The ceiling on pending append bytes, and the ring's sibling on purpose.
55
+ *
56
+ * 128 MiB, the same number and the same three readings that pick
57
+ * MAX_BUFFER_CHARS in ring-bounds.mjs:
58
+ *
59
+ * - It is 26 times the largest single line ingest can produce (5,000,000
60
+ * characters plus the deck's envelope), so a burst of maximum-size tool
61
+ * responses — eight subagents each returning a big Read — is queued whole
62
+ * rather than shed.
63
+ * - It is thirteen times a completely full 2000-event ring of ordinary
64
+ * traffic. The mean serialized event is about 5 KB, so the ~26,000 lines
65
+ * this holds are far more than any honest burst produces: ordinary traffic
66
+ * never meets this bound at all.
67
+ * - Its worst case is a bounded fraction of the heap, and a bounded ADDITION
68
+ * to the ring's. 128 MiB of charged characters is at most 256 MiB retained
69
+ * — a two-byte string costs twice what it is charged, the same 2x
70
+ * MAX_CLIENT_BUFFER_BYTES' note describes — so the ring's 320 MiB and this
71
+ * together are about 576 MiB of the 2 GB heap an 8 GB laptop picks, against
72
+ * the 2260 MB one four-second burst reached with no bound here at all.
73
+ *
74
+ * CHARGED IN CHARACTERS, not in UTF-8 bytes, and the unit is deliberate. These
75
+ * lines are already serialized, so `line.length` is the size of the thing being
76
+ * retained and costs nothing to read; `Buffer.byteLength` would be a second
77
+ * full scan of a five-megabyte string on the hottest path in the process for a
78
+ * number that is FURTHER from the retained heap, not closer — JSON.stringify
79
+ * leaves non-ASCII unescaped, so a CJK line is two bytes of heap per character
80
+ * and three bytes of UTF-8.
81
+ *
82
+ * Deliberately NOT summed into MAX_BUFFER_CHARS, which #1030 raises as an
83
+ * option. The two hold different things — the ring holds parsed payloads
84
+ * charged by payloadChars' walk, this holds their serialization — so one
85
+ * counter would be adding two units; and eviction cannot free a queued line
86
+ * anyway, so a shared budget would answer a slow disk by collapsing the replay
87
+ * depth of the ring, which is the one part of this that was never broken.
88
+ */
89
+ export const MAX_PENDING_APPEND_CHARS = 128 * 1024 * 1024;
90
+
91
+ // What has been accepted and not yet written, what has been refused because of
92
+ // it, and whether we are inside an episode of refusing. These move with the
93
+ // chain in appendLogLine and nowhere else, through takeCharge, noteRefused and
94
+ // releaseCharge below — the same rule `bufferedChars` and `events` keep in
95
+ // event-ring.mjs, and for the same reason: a total that names lines the queue
96
+ // no longer holds is a permanent debt against the budget.
97
+ let pendingLines = 0;
98
+ let pendingChars = 0;
99
+ let droppedLines = 0;
100
+ let droppedChars = 0;
101
+ let dropEpisodes = 0;
102
+ // Reset at the start of each episode, so the line printed when the queue
103
+ // drains describes THAT burst rather than the life of the process.
104
+ let episodeLines = 0;
105
+ let episodeChars = 0;
106
+ let shedding = false;
107
+
108
+ /**
109
+ * What the append queue holds right now, and what it has refused.
110
+ *
111
+ * Exported for exactly the reason eventBufferStats and MAX_BUFFER_CHARS are —
112
+ * "a bound whose only observable failure is the process running out of memory
113
+ * is a bound no test can assert". With this, the bound is observable without
114
+ * watching a process die, and so is the loss it trades for.
115
+ *
116
+ * `droppedLines` / `droppedChars` are cumulative for the life of the process.
117
+ * They count lines that were never ATTEMPTED; an append that was attempted and
118
+ * failed is a different number, which #991 is open about and which belongs
119
+ * beside these rather than tangled into them.
120
+ */
121
+ export function appendQueueStats() {
122
+ return { pendingLines, pendingChars, droppedLines, droppedChars, dropEpisodes };
123
+ }
124
+
125
+ const mb = chars => `${(chars / 1024 / 1024).toFixed(0)}MB`;
126
+
127
+ /**
128
+ * Would a line of `charged` characters be refused at the door right now?
129
+ *
130
+ * The queue always accepts a line when it is EMPTY, whatever that line
131
+ * weighs. Ingest admits 5,000,000 characters and a Codex rollout line read
132
+ * off disk has no length bound at all, so refusing an oversized line outright
133
+ * would mean a deck that silently never records its largest events — and the
134
+ * ring one file over makes the same exception for the same reason ("a single
135
+ * event is allowed to be larger than the entire budget"). So the true ceiling
136
+ * is MAX_PENDING_APPEND_CHARS plus one line, stated here rather than
137
+ * pretended away.
138
+ */
139
+ export function queueIsFull(charged) {
140
+ return pendingLines > 0 && pendingChars + charged > MAX_PENDING_APPEND_CHARS;
141
+ }
142
+
143
+ /** One line accepted: charge it until releaseCharge gives the charge back. */
144
+ export function takeCharge(charged) {
145
+ pendingLines++;
146
+ pendingChars += charged;
147
+ }
148
+
149
+ /** One line refused at the door: count it, and open an episode if this is the
150
+ * first refusal since the queue last drained. */
151
+ export function noteRefused(filePath, charged) {
152
+ droppedLines++;
153
+ droppedChars += charged;
154
+ episodeLines++;
155
+ episodeChars += charged;
156
+ if (!shedding) {
157
+ shedding = true;
158
+ dropEpisodes++;
159
+ // One line, at the start of the episode. Per refusal it would be
160
+ // thousands of lines onto the terminal the deck paints over, which is its
161
+ // own version of the problem being fixed.
162
+ console.error(`${PRODUCT}: the log append queue is full (${mb(pendingChars)} waiting for ${filePath}) — dropping events until it drains`);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * One line has left the queue. Give its charge back, and close the episode if
168
+ * that was the last of them.
169
+ *
170
+ * The episode ends when the queue is EMPTY rather than the moment it dips back
171
+ * under the bound, because it dips under the bound once per completed write:
172
+ * keyed on the bound this would print a pair of lines per event for the length
173
+ * of the burst. Empty is also the honest boundary for the number being
174
+ * reported — while anything is still queued the next line can still be refused,
175
+ * and the total would have to be retracted.
176
+ */
177
+ export function releaseCharge(charged) {
178
+ pendingLines--;
179
+ pendingChars -= charged;
180
+ if (pendingLines > 0 || !shedding) return;
181
+ console.error(`${PRODUCT}: the log append queue drained — ${episodeLines} event(s) (${mb(episodeChars)}) were dropped and are not in the log`);
182
+ shedding = false;
183
+ episodeLines = 0;
184
+ episodeChars = 0;
185
+ }