ccdeck 3.29.10 → 3.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/agent-dag.js +67 -118
- package/bin/cli/help.js +67 -0
- package/bin/cli/login-item.js +182 -0
- package/bin/cli/one-shot.js +254 -0
- package/bin/cli/package.js +39 -0
- package/bin/cli/pulse.js +98 -0
- package/bin/cli/restart.js +179 -0
- package/bin/cli/screen.js +166 -0
- package/bin/cli/second-start.js +120 -0
- package/bin/cli/startup.js +459 -0
- package/bin/cli/uninstall.js +169 -0
- package/bin/deck.js +112 -1559
- package/dist/web/assets/BrowserWatchModal-DP3YMtEi.js +1 -0
- package/dist/web/assets/UsageHistoryModal-CcdPVXAw.js +1 -0
- package/dist/web/assets/index-3vS6A0vt.css +1 -0
- package/dist/web/assets/index-BVTWHDta.js +298 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +15 -14
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/src/server/account-projects.mjs +25 -11
- package/src/server/account-routes.mjs +254 -0
- package/src/server/activity.mjs +3 -3
- package/src/server/append-budget.mjs +185 -0
- package/src/server/append-failures.mjs +147 -0
- package/src/server/args.mjs +37 -5
- package/src/server/atomic-write.mjs +300 -0
- package/src/server/block-notify.mjs +0 -5
- package/src/server/boot-lock.mjs +1 -1
- package/src/server/brand.mjs +7 -6
- package/src/server/browser-react.mjs +10 -2
- package/src/server/browser-watch-decks.mjs +146 -0
- package/src/server/browser-watch-feed.mjs +69 -0
- package/src/server/browser-watch-log.mjs +173 -0
- package/src/server/browser-watch-mtime.mjs +18 -0
- package/src/server/browser-watch-relay.mjs +112 -0
- package/src/server/browser-watch-routes.mjs +172 -0
- package/src/server/browser-watch-seen.mjs +127 -0
- package/src/server/browser-watch-store.mjs +14 -143
- package/src/server/browser-watch.mjs +278 -626
- package/src/server/canonical-path.mjs +150 -0
- package/src/server/ccusage-failure.mjs +64 -0
- package/src/server/ccusage-install.mjs +459 -0
- package/src/server/ccusage-runner.mjs +271 -0
- package/src/server/ccusage.mjs +21 -756
- package/src/server/claude-accounts.mjs +220 -464
- package/src/server/claude-collector.mjs +189 -0
- package/src/server/claude-dir.mjs +2 -2
- package/src/server/claude-identity.mjs +5 -4
- package/src/server/claude-reset-credits.mjs +130 -0
- package/src/server/claude-verdicts.mjs +174 -0
- package/src/server/clear-route.mjs +191 -0
- package/src/server/codex-auth.mjs +4 -4
- package/src/server/codex-dir.mjs +52 -8
- package/src/server/codex-enrichment.mjs +310 -0
- package/src/server/codex-quota.mjs +5 -3
- package/src/server/codex-translate.mjs +407 -0
- package/src/server/codex-usage.mjs +8 -5
- package/src/server/codex-watch.mjs +496 -0
- package/src/server/cswap-auto-loop.mjs +342 -0
- package/src/server/cswap-auto-readers.mjs +303 -0
- package/src/server/cswap-auto.mjs +51 -609
- package/src/server/cswap-install.mjs +69 -265
- package/src/server/cswap-layout.mjs +242 -0
- package/src/server/deck-home.mjs +1 -1
- package/src/server/deck-prefs.mjs +51 -184
- package/src/server/deck-scope.mjs +52 -0
- package/src/server/desktop-update-routes.mjs +75 -0
- package/src/server/detach.mjs +78 -0
- package/src/server/discovery.mjs +298 -0
- package/src/server/event-log.mjs +328 -0
- package/src/server/event-pipeline.mjs +235 -0
- package/src/server/event-ring.mjs +214 -0
- package/src/server/event-routes.mjs +352 -0
- package/src/server/event-sink.mjs +26 -0
- package/src/server/exec-children.mjs +128 -0
- package/src/server/exec-not-found.mjs +213 -0
- package/src/server/exec-output.mjs +23 -0
- package/src/server/exec-spec.mjs +341 -0
- package/src/server/exec.mjs +74 -661
- package/src/server/health-route.mjs +42 -0
- package/src/server/hook-challenge.mjs +37 -0
- package/src/server/http-io.mjs +104 -0
- package/src/server/index.mjs +172 -7620
- package/src/server/install-layout.mjs +506 -0
- package/src/server/installer.mjs +79 -608
- package/src/server/jsonl-chunks.mjs +139 -0
- package/src/server/lan-accounts.mjs +27 -0
- package/src/server/lan-beacon.mjs +457 -0
- package/src/server/lan-call.mjs +239 -0
- package/src/server/lan-copies.mjs +270 -0
- package/src/server/lan-deck.mjs +583 -0
- package/src/server/lan-dials.mjs +161 -0
- package/src/server/lan-engine.mjs +475 -1024
- package/src/server/lan-hearing.mjs +49 -0
- package/src/server/lan-inbound.mjs +100 -0
- package/src/server/lan-invite-offer.mjs +77 -0
- package/src/server/lan-invite.mjs +207 -0
- package/src/server/lan-lines.mjs +73 -0
- package/src/server/lan-manifest.mjs +74 -0
- package/src/server/lan-reach.mjs +82 -69
- package/src/server/lan-requests.mjs +249 -0
- package/src/server/lan-round-record.mjs +45 -0
- package/src/server/lan-round-timer.mjs +62 -0
- package/src/server/lan-routes.mjs +143 -0
- package/src/server/lan-serve.mjs +112 -0
- package/src/server/lan-socket.mjs +243 -925
- package/src/server/lan-sync.mjs +58 -1026
- package/src/server/lan-tailnet-poll.mjs +57 -0
- package/src/server/lan-turns.mjs +80 -0
- package/src/server/lan-wire.mjs +620 -0
- package/src/server/lifecycle.mjs +421 -0
- package/src/server/listen.mjs +110 -0
- package/src/server/live-decks.mjs +138 -0
- package/src/server/load-average.mjs +32 -0
- package/src/server/log-election.mjs +235 -0
- package/src/server/log-replay.mjs +374 -0
- package/src/server/log-writer.mjs +72 -557
- package/src/server/login-service.mjs +3 -2
- package/src/server/macmon.mjs +1 -1
- package/src/server/memory-files.mjs +163 -0
- package/src/server/memory-metrics.mjs +178 -0
- package/src/server/metrics-history.mjs +94 -0
- package/src/server/metrics-run.mjs +100 -0
- package/src/server/music-routes.mjs +134 -0
- package/src/server/network-metrics.mjs +5 -3
- package/src/server/network-sampler.mjs +256 -0
- package/src/server/npm-latest.mjs +385 -0
- package/src/server/npm-upgrade.mjs +352 -0
- package/src/server/npx.mjs +41 -7
- package/src/server/output-watch.mjs +5 -4
- package/src/server/pinned-build.mjs +108 -0
- package/src/server/prefs-path.mjs +14 -0
- package/src/server/prefs-refusal.mjs +77 -0
- package/src/server/prefs-routes.mjs +76 -0
- package/src/server/prefs-set-aside.mjs +86 -0
- package/src/server/prefs-state.mjs +51 -0
- package/src/server/process-command.mjs +104 -0
- package/src/server/process-list.mjs +547 -0
- package/src/server/pulse-line.mjs +132 -0
- package/src/server/purge-key.mjs +7 -7
- package/src/server/quota-cli.mjs +174 -0
- package/src/server/quota-oauth.mjs +190 -0
- package/src/server/quota-shape.mjs +229 -0
- package/src/server/quota-store-resets.mjs +206 -0
- package/src/server/quota.mjs +123 -509
- package/src/server/relay-guard.mjs +3 -3
- package/src/server/request-gates.mjs +441 -0
- package/src/server/restart-note.mjs +180 -0
- package/src/server/retire-sound-hook.mjs +3 -3
- package/src/server/ring-bounds.mjs +215 -0
- package/src/server/running-deck.mjs +1 -1
- package/src/server/running-version.mjs +24 -0
- package/src/server/self-update.mjs +39 -1371
- package/src/server/session-enrichment.mjs +622 -0
- package/src/server/session-read-gate.mjs +49 -0
- package/src/server/session-tracking.mjs +242 -0
- package/src/server/sse-clients.mjs +222 -0
- package/src/server/stale-discovery.mjs +30 -0
- package/src/server/static-serve.mjs +81 -0
- package/src/server/supervisor.mjs +73 -12
- package/src/server/system-metrics.mjs +169 -1935
- package/src/server/term.mjs +11 -249
- package/src/server/thermal-metrics.mjs +520 -0
- package/src/server/thermal-sampler.mjs +204 -0
- package/src/server/token-redact.mjs +123 -0
- package/src/server/transcript-gate.mjs +107 -0
- package/src/server/transcript-scan.mjs +563 -0
- package/src/server/usage-routes.mjs +93 -0
- package/src/server/uv-bootstrap.mjs +1 -1
- package/src/server/wordmark.mjs +127 -0
- package/dist/web/assets/BrowserWatchModal-CMn28FHb.js +0 -1
- package/dist/web/assets/UsageHistoryModal-C-sPhsKT.js +0 -1
- package/dist/web/assets/index-C1YFI9f4.js +0 -298
- package/dist/web/assets/index-Ck2RAla3.css +0 -1
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
// Reading a Claude Code transcript: one cursor per JSONL, and the fold of each
|
|
2
|
+
// appended line into the running totals. The bounded chunk read that moves the
|
|
3
|
+
// cursor is jsonl-chunks.mjs, which the Codex side reads rollouts through as
|
|
4
|
+
// well, and the rule for which paths are worth opening at all is
|
|
5
|
+
// transcript-gate.mjs.
|
|
6
|
+
//
|
|
7
|
+
// These lived in src/server/index.mjs, between the log rotation and the model
|
|
8
|
+
// enrichment, and nothing in them ever reached the event pipeline: the
|
|
9
|
+
// enrichment passes (session-enrichment.mjs now) ask `scanTranscript` for a
|
|
10
|
+
// state and decide what to emit from it, the Codex watcher borrows the chunked
|
|
11
|
+
// reader, and pushEvent asks the path gate before any of that runs. So the whole
|
|
12
|
+
// reader moved as one piece, with its caches, and its callers import the
|
|
13
|
+
// operations they call. The names are the ones the bodies always used, and
|
|
14
|
+
// index.mjs still re-exports the five it exported: two from here, and the
|
|
15
|
+
// rest from jsonl-chunks.mjs and transcript-gate.mjs, where they have since
|
|
16
|
+
// moved.
|
|
17
|
+
import { stat } from "node:fs/promises";
|
|
18
|
+
import { resolve } from "node:path";
|
|
19
|
+
// Claude Code's "※ recap:" line — see session-recap.mjs.
|
|
20
|
+
import { foldRecapLine } from "./session-recap.mjs";
|
|
21
|
+
// The bounded, cursor-moving chunk read every pass below goes through — see
|
|
22
|
+
// jsonl-chunks.mjs.
|
|
23
|
+
import { readAppendedLines } from "./jsonl-chunks.mjs";
|
|
24
|
+
|
|
25
|
+
// ─── Incremental transcript scanning ─────────────────────────────────────
|
|
26
|
+
// Model, usage and context enrichment all derive from the same append-only
|
|
27
|
+
// transcript JSONL, and each one used to re-read and re-parse the whole file
|
|
28
|
+
// on every throttled pass. A session's transcript grows to tens of MB, so
|
|
29
|
+
// that cost O(n) per pass, O(n²) over the session, and — because the parse
|
|
30
|
+
// loop is one synchronous block — it stalled SSE broadcasts and /api/event
|
|
31
|
+
// ingest for as long as it ran.
|
|
32
|
+
//
|
|
33
|
+
// Instead we keep one cursor per file plus the running totals derived so
|
|
34
|
+
// far, read only the bytes appended since the last pass, and fold them into
|
|
35
|
+
// that state. The three scanners share it, so the first one to run in a
|
|
36
|
+
// cycle pays for the read and the other two reuse the result. This mirrors
|
|
37
|
+
// the offset tailing the Codex rollout watcher in codex-watch.mjs already does.
|
|
38
|
+
const transcriptScans = new Map(); // path -> scan state
|
|
39
|
+
const transcriptScanInFlight = new Map(); // path -> in-progress scan promise
|
|
40
|
+
const transcriptScanSessions = new Map(); // session key -> Set<path>, LRU order
|
|
41
|
+
|
|
42
|
+
// What the cap protects is unbounded growth across the sessions a long-lived
|
|
43
|
+
// deck has seen and will never hear from again — so a SESSION is the unit it
|
|
44
|
+
// has to be counted in. It used to count paths, which is not the same thing:
|
|
45
|
+
// a session occupies one entry for its own JSONL plus one for every
|
|
46
|
+
// `subagents/agent-*.jsonl` beside it, and that count is set by how heavily
|
|
47
|
+
// the session delegates, not by anything the deck controls. Measured on this
|
|
48
|
+
// machine, the two live sessions that produced #611 hold 198 and 133 subagent
|
|
49
|
+
// files — 333 entries between them against a cap of 256, so each session's
|
|
50
|
+
// throttled pass evicted the other's cursors and re-read from byte 0. One of
|
|
51
|
+
// those directories alone is 130.8 MB and takes 6456 ms to fold cold against
|
|
52
|
+
// 18 ms warm, inside a 2500 ms throttle: the pass could not finish before the
|
|
53
|
+
// next one was due.
|
|
54
|
+
//
|
|
55
|
+
// So eviction drops a whole session at a time and never splits one. That is
|
|
56
|
+
// also the answer to a session whose subagent count exceeds any fixed number
|
|
57
|
+
// of entries: it is never evicted for being big, because the only thing a cap
|
|
58
|
+
// can take is some OTHER, older session. A session's cursors live and die
|
|
59
|
+
// together, which is the only grouping that makes the next pass cheap.
|
|
60
|
+
const MAX_TRANSCRIPT_SCAN_SESSIONS = 256;
|
|
61
|
+
// A session cap alone bounds identities, not bytes, so a second ceiling bounds
|
|
62
|
+
// the memory — again by dropping whole sessions, never part of one. A scan
|
|
63
|
+
// state retains 477 bytes measured, so 8192 entries is under 4 MB; it is also
|
|
64
|
+
// ten times every agent-*.jsonl that exists on this machine across its whole
|
|
65
|
+
// history (803), and 24x the 333 the two heaviest live sessions need.
|
|
66
|
+
const MAX_TRANSCRIPT_SCAN_ENTRIES = 8192;
|
|
67
|
+
|
|
68
|
+
// The transcript's `message.model`, and the only filter standing between it and
|
|
69
|
+
// every model the deck shows. Bedrock and Mantle put a provider namespace in
|
|
70
|
+
// front of the id — `us.anthropic.claude-opus-5`, `anthropic.claude-opus-5` —
|
|
71
|
+
// so a bare `^claude` dropped every line a Bedrock session writes and the deck
|
|
72
|
+
// showed those users no model, no context window and no cost at all (#475).
|
|
73
|
+
//
|
|
74
|
+
// The prefix list is `VENDOR_PREFIX_RE` in src/web/model-id.ts, written out a
|
|
75
|
+
// second time here rather than imported: this file is plain .mjs that node runs
|
|
76
|
+
// straight off disk with no build step, so it cannot reach a `.ts` module.
|
|
77
|
+
// bedrock-model-ids.test.ts sweeps both against one list of ids so the copies
|
|
78
|
+
// cannot drift apart.
|
|
79
|
+
const MODEL_ID_RE = /^(?:(?:us-gov|global|apac|us|eu|jp|au)\.anthropic\.|anthropic\.)?claude[-_]/i;
|
|
80
|
+
const USAGE_BLOCK_RE = /"usage"\s*:\s*\{([^}]+)\}/g;
|
|
81
|
+
// CC `/clear` and `/compact` write a marker into the transcript and reset the
|
|
82
|
+
// context window to ~0 while the JSONL keeps growing — everything before the
|
|
83
|
+
// last marker is stale.
|
|
84
|
+
const CONTEXT_RESET_RE = /<command-name>\s*\/(?:clear|compact)\s*<\/command-name>/g;
|
|
85
|
+
const TYPE_USER_RE = /"type"\s*:\s*"user"/g;
|
|
86
|
+
const TYPE_ASSISTANT_RE = /"type"\s*:\s*"assistant"/g;
|
|
87
|
+
const TYPE_TOOL_USE_RE = /"type"\s*:\s*"tool_use"/g;
|
|
88
|
+
const TYPE_TOOL_RESULT_RE = /"type"\s*:\s*"tool_result"/g;
|
|
89
|
+
const SYSTEM_REMINDER_RE = /<system-reminder>/g;
|
|
90
|
+
const USAGE_FIELD_RE = {
|
|
91
|
+
input_tokens: /"input_tokens"\s*:\s*(\d+)/,
|
|
92
|
+
output_tokens: /"output_tokens"\s*:\s*(\d+)/,
|
|
93
|
+
cache_read_input_tokens: /"cache_read_input_tokens"\s*:\s*(\d+)/,
|
|
94
|
+
cache_creation_input_tokens: /"cache_creation_input_tokens"\s*:\s*(\d+)/,
|
|
95
|
+
ephemeral_1h_input_tokens: /"ephemeral_1h_input_tokens"\s*:\s*(\d+)/,
|
|
96
|
+
ephemeral_5m_input_tokens: /"ephemeral_5m_input_tokens"\s*:\s*(\d+)/,
|
|
97
|
+
};
|
|
98
|
+
// The flat `cache_creation_input_tokens` says how many tokens were written to
|
|
99
|
+
// cache but not at which TTL, and Anthropic bills a 1-hour write at 2x input
|
|
100
|
+
// against the 5-minute 1.25x — CC writes 1-hour caches for most of its prefix,
|
|
101
|
+
// so pricing the whole lot at the cheaper rate under-reports the bill. The
|
|
102
|
+
// split lives in a `cache_creation` sub-object that USAGE_BLOCK_RE cannot
|
|
103
|
+
// reach: its `[^}]+` stops at the first `}`, which in a real transcript closes
|
|
104
|
+
// `server_tool_use`, several fields earlier. Match the sub-object on the raw
|
|
105
|
+
// line instead of on the extracted blob.
|
|
106
|
+
const CACHE_CREATION_BLOCK_RE = /"cache_creation"\s*:\s*\{([^}]*)\}/g;
|
|
107
|
+
// A finished `Task`/`Agent` call is written into the PARENT's transcript as a
|
|
108
|
+
// top-level `toolUseResult`, and that object carries a `usage` block of its own
|
|
109
|
+
// — the subagent's LAST API turn, restated on the parent's line. Those same
|
|
110
|
+
// tokens are already in the subagent's own `subagents/agent-<id>.jsonl`, which
|
|
111
|
+
// #685 folds into the session's totals, so counting the restated copy here
|
|
112
|
+
// would charge them twice. Measured on a real transcript: the restated block
|
|
113
|
+
// reports 181,387 cache-read tokens and the last usage block inside that
|
|
114
|
+
// subagent's file reports 181,387 — the same tokens, written twice.
|
|
115
|
+
//
|
|
116
|
+
// Matched as a TOP-LEVEL key: a `{` or `,` and then the name unescaped. The
|
|
117
|
+
// same text quoted inside a message — an assistant writing about this very
|
|
118
|
+
// field, as this comment does — reaches the line as `\"toolUseResult\"`, whose
|
|
119
|
+
// preceding character is a backslash, so a transcript that talks about the key
|
|
120
|
+
// is not mistaken for one that carries it.
|
|
121
|
+
const TOOL_USE_RESULT_KEY_RE = /[{,]"toolUseResult"\s*:/;
|
|
122
|
+
|
|
123
|
+
/** The stretch of a transcript line whose `usage` blocks are the model's own
|
|
124
|
+
* billing records — everything before a top-level `toolUseResult`. See
|
|
125
|
+
* TOOL_USE_RESULT_KEY_RE. A line without one is billed whole, which is every
|
|
126
|
+
* assistant line and therefore every line that legitimately has usage. */
|
|
127
|
+
function billedUsageText(line) {
|
|
128
|
+
const m = TOOL_USE_RESULT_KEY_RE.exec(line);
|
|
129
|
+
return m ? line.slice(0, m.index) : line;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function grabUsageField(blob, key) {
|
|
133
|
+
const m = blob.match(USAGE_FIELD_RE[key]);
|
|
134
|
+
return m ? Number(m[1]) : 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// How many DISTINCT models one transcript may keep a usage bucket for.
|
|
138
|
+
//
|
|
139
|
+
// A session reaches two or three: `/model` mid-run, CC dropping to Sonnet when
|
|
140
|
+
// the weekly Opus allowance runs low, a subagent turn on a different tier. The
|
|
141
|
+
// cap is not about those. `MODEL_ID_RE` accepts anything beginning `claude-`,
|
|
142
|
+
// and every line of a transcript is bytes this process was handed rather than
|
|
143
|
+
// bytes it wrote — so a file naming a fresh `claude-<n>` on every line would
|
|
144
|
+
// otherwise grow one bucket per line, inside the same scanner #674 put a
|
|
145
|
+
// ceiling on for exactly this shape of reason. Past the cap the extra models'
|
|
146
|
+
// tokens still reach `state.usage`, which is what every token count on screen
|
|
147
|
+
// reads; they simply stop being attributed, and `usageByModelEntries` on the
|
|
148
|
+
// client prices whatever the map does not explain at the session's current
|
|
149
|
+
// model — the behaviour the whole deck had before #686.
|
|
150
|
+
const MAX_TRANSCRIPT_USAGE_MODELS = 32;
|
|
151
|
+
|
|
152
|
+
/** The per-model usage bucket for `model`, created on first sight. Null for a
|
|
153
|
+
* line whose tokens we cannot attribute — no model seen yet in this file, or
|
|
154
|
+
* the cap above already reached. */
|
|
155
|
+
function usageBucketFor(state, model) {
|
|
156
|
+
if (!model) return null;
|
|
157
|
+
const existing = state.usageByModel[model];
|
|
158
|
+
if (existing) return existing;
|
|
159
|
+
if (Object.keys(state.usageByModel).length >= MAX_TRANSCRIPT_USAGE_MODELS) return null;
|
|
160
|
+
const fresh = newUsageTotals();
|
|
161
|
+
state.usageByModel[model] = fresh;
|
|
162
|
+
return fresh;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The counters a `"usage"` block carries flat, and the two its
|
|
166
|
+
* `cache_creation` sub-object splits cache writes into by TTL. */
|
|
167
|
+
const USAGE_BLOCK_FIELDS = ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"];
|
|
168
|
+
const CACHE_SPLIT_FIELDS = ["ephemeral_1h_input_tokens", "ephemeral_5m_input_tokens"];
|
|
169
|
+
|
|
170
|
+
/** Charge `fields` of one usage blob to the file's flat totals and, when the
|
|
171
|
+
* tokens can be attributed, to their model's bucket too — the same number
|
|
172
|
+
* into both, read once, so the split and the total it splits go on summing
|
|
173
|
+
* to each other. */
|
|
174
|
+
function chargeUsage(state, bucket, blob, fields) {
|
|
175
|
+
for (const k of fields) {
|
|
176
|
+
const n = grabUsageField(blob, k);
|
|
177
|
+
state.usage[k] += n;
|
|
178
|
+
if (bucket) bucket[k] += n;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Add `src`'s per-model buckets into `dst`, key for key, and return `dst`.
|
|
183
|
+
* Under the same cap as the per-file map, so a session that delegates to
|
|
184
|
+
* hundreds of files cannot assemble an unbounded one out of bounded parts. */
|
|
185
|
+
function mergeUsageByModel(dst, src) {
|
|
186
|
+
if (!src) return dst;
|
|
187
|
+
for (const [model, u] of Object.entries(src)) {
|
|
188
|
+
let bucket = dst[model];
|
|
189
|
+
if (!bucket) {
|
|
190
|
+
if (Object.keys(dst).length >= MAX_TRANSCRIPT_USAGE_MODELS) continue;
|
|
191
|
+
bucket = newUsageTotals();
|
|
192
|
+
dst[model] = bucket;
|
|
193
|
+
}
|
|
194
|
+
for (const k of Object.keys(bucket)) bucket[k] += u[k] ?? 0;
|
|
195
|
+
}
|
|
196
|
+
return dst;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function newContextBreakdown() {
|
|
200
|
+
return {
|
|
201
|
+
msgsUser: 0,
|
|
202
|
+
msgsAssistant: 0,
|
|
203
|
+
toolUses: 0,
|
|
204
|
+
toolResults: 0,
|
|
205
|
+
systemReminders: 0,
|
|
206
|
+
currentContextTokens: 0,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** A zeroed set of the six token counters a transcript reports. One shape, so
|
|
211
|
+
* the per-file totals and the per-session sum of them add key for key. */
|
|
212
|
+
function newUsageTotals() {
|
|
213
|
+
return {
|
|
214
|
+
input_tokens: 0, output_tokens: 0,
|
|
215
|
+
cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
|
|
216
|
+
ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Have these totals been billed for anything at all?
|
|
221
|
+
*
|
|
222
|
+
* The four flat counters are the whole of what a usage block charges. The two
|
|
223
|
+
* `ephemeral_*` counters split cache_creation_input_tokens by TTL rather than
|
|
224
|
+
* adding to it, so they are not asked. One file's totals, one model's bucket,
|
|
225
|
+
* each subagent file's totals and each line the Projects report folds
|
|
226
|
+
* (account-projects.mjs) are all asked this, and each of those four used to
|
|
227
|
+
* spell the four tests out for itself. */
|
|
228
|
+
function hasSpend(u) {
|
|
229
|
+
return USAGE_BLOCK_FIELDS.some(k => u[k] !== 0);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function newTranscriptState() {
|
|
233
|
+
return {
|
|
234
|
+
offset: 0, // bytes already folded in
|
|
235
|
+
midLine: false, // the cursor sits inside a line longer than MAX_SCAN_CHUNK
|
|
236
|
+
rootModel: null,
|
|
237
|
+
lastModel: null, // last claude-* model on any line, sidechain included
|
|
238
|
+
subagentModels: {},
|
|
239
|
+
aiTitle: null, // newest "ai-title" entry, the session's sentence title
|
|
240
|
+
agentName: null, // newest "agent-name" entry, the session's short name
|
|
241
|
+
recap: null, // newest "away_summary" no later turn retired — session-recap.mjs
|
|
242
|
+
usage: newUsageTotals(),
|
|
243
|
+
// The same totals, split by the model that produced them (#686). The flat
|
|
244
|
+
// bucket above stays the whole-transcript sum and is what every token count
|
|
245
|
+
// reads; this is what the DOLLARS are built from, because a session that
|
|
246
|
+
// switched model has tokens from two rate cards in one file and one rate
|
|
247
|
+
// applied to the lot is wrong by the ratio between them — measured at 60%
|
|
248
|
+
// under on a mostly-Opus session that ended on Sonnet, and 150% over on the
|
|
249
|
+
// mirror of it. Costs no extra read: `message.model` and the `"usage"` block
|
|
250
|
+
// are on the same line, and this pass already parses both.
|
|
251
|
+
usageByModel: {},
|
|
252
|
+
ctx: newContextBreakdown(),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ─── Session naming ──────────────────────────────────────────────────────
|
|
257
|
+
// CC writes two whole-line records that name the session, and nothing else in
|
|
258
|
+
// the transcript carries either fact:
|
|
259
|
+
//
|
|
260
|
+
// {"type":"ai-title","aiTitle":"Inspect repository to understand current state","sessionId":"…"}
|
|
261
|
+
// {"type":"agent-name","agentName":"account-management-oauth-flow","sessionId":"…"}
|
|
262
|
+
//
|
|
263
|
+
// Both are re-emitted on roughly every turn rather than only when they change.
|
|
264
|
+
// Measured on the two largest transcripts on this machine: 685 `ai-title`
|
|
265
|
+
// entries carrying 2 DISTINCT values (46.4 MB session) and 406 carrying 1
|
|
266
|
+
// (19.6 MB session). So "the last one wins" is right, but the value is nearly
|
|
267
|
+
// always the value we already had — which is why the emit in
|
|
268
|
+
// session-enrichment.mjs is gated on a change rather than fired per pass.
|
|
269
|
+
//
|
|
270
|
+
// `aiTitle` is NOT reliably the sentence it looks like. In the 46.4 MB session
|
|
271
|
+
// the first 332 entries read "Inspect repository to understand current state"
|
|
272
|
+
// and every one of the last 353 reads "account-management-oauth-flow" —
|
|
273
|
+
// byte-identical to `agentName`, and 353 is exactly the `agent-name` count. CC
|
|
274
|
+
// overwrites the title with the slug once a session has a name. The client
|
|
275
|
+
// therefore has to treat "title equals name" as "no title", or the tooltip
|
|
276
|
+
// would repeat the card.
|
|
277
|
+
const AI_TITLE_MARK = '"ai-title"';
|
|
278
|
+
const AGENT_NAME_MARK = '"agent-name"';
|
|
279
|
+
|
|
280
|
+
/** Fold one line's naming records into `out`. Last value wins. Pure: `out` is
|
|
281
|
+
* the only thing written, and a line that is not one of the two records — or
|
|
282
|
+
* is a truncated fragment of one — leaves it untouched. */
|
|
283
|
+
export function foldSessionNamingLine(out, line) {
|
|
284
|
+
if (!line) return;
|
|
285
|
+
const hasTitle = line.includes(AI_TITLE_MARK);
|
|
286
|
+
const hasName = line.includes(AGENT_NAME_MARK);
|
|
287
|
+
if (!hasTitle && !hasName) return;
|
|
288
|
+
let obj = null;
|
|
289
|
+
try { obj = JSON.parse(line); } catch { return; }
|
|
290
|
+
if (!obj || typeof obj !== "object") return;
|
|
291
|
+
if (obj.type === "ai-title" && typeof obj.aiTitle === "string" && obj.aiTitle) {
|
|
292
|
+
out.aiTitle = obj.aiTitle;
|
|
293
|
+
} else if (obj.type === "agent-name" && typeof obj.agentName === "string" && obj.agentName) {
|
|
294
|
+
out.agentName = obj.agentName;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Fold one transcript line into the running state. Every fact the three
|
|
299
|
+
* scanners need lives on a single line, so line-at-a-time folding sees
|
|
300
|
+
* exactly what a whole-file pass would. */
|
|
301
|
+
function foldTranscriptLine(state, line) {
|
|
302
|
+
if (!line) return;
|
|
303
|
+
|
|
304
|
+
// Rides the scan that is already reading these bytes for the model, the usage
|
|
305
|
+
// totals and the context counts, so naming costs no read of its own — see the
|
|
306
|
+
// block above `maybeResolveSessionName` in session-enrichment.mjs for why
|
|
307
|
+
// that beat a tail read.
|
|
308
|
+
foldSessionNamingLine(state, line);
|
|
309
|
+
// The recap rides the same pass for the same reason, and costs the ordinary
|
|
310
|
+
// line two substring tests. See session-recap.mjs.
|
|
311
|
+
foldRecapLine(state, line);
|
|
312
|
+
// Parsed once, and only when the line mentions a model: no other line can
|
|
313
|
+
// change the model, and every assistant line names its model, so the usage
|
|
314
|
+
// and context folds read the record off this same parse.
|
|
315
|
+
const record = line.includes('"model"') ? parseRecord(line) : null;
|
|
316
|
+
// Model before usage, always: a usage block is charged to the model the same
|
|
317
|
+
// line names, which is the one foldModelLine has just read off it.
|
|
318
|
+
foldModelLine(state, record);
|
|
319
|
+
foldUsageLine(state, line, record);
|
|
320
|
+
foldContextLine(state, line, record);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** One transcript line as the object it records, or null when it is not JSON. */
|
|
324
|
+
function parseRecord(line) {
|
|
325
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The model a line's record names, folded in: the newest one seen, and the
|
|
329
|
+
* root's or a legacy inline subagent's. `obj` is null for a line that names no
|
|
330
|
+
* model — only a line that mentions one can change it, and parsing the rest is
|
|
331
|
+
* what made the full rescan expensive. */
|
|
332
|
+
function foldModelLine(state, obj) {
|
|
333
|
+
const msg = obj && obj.message;
|
|
334
|
+
const model = (msg && typeof msg.model === "string" && MODEL_ID_RE.test(msg.model)) ? msg.model
|
|
335
|
+
: (obj && typeof obj.model === "string" && MODEL_ID_RE.test(obj.model)) ? obj.model
|
|
336
|
+
: null;
|
|
337
|
+
if (model) {
|
|
338
|
+
state.lastModel = model;
|
|
339
|
+
const isSide = obj.isSidechain === true || obj.is_sidechain === true;
|
|
340
|
+
const ptid = obj.parentToolUseID || obj.parent_tool_use_id || obj.parentToolUseId || null;
|
|
341
|
+
if (isSide && ptid) state.subagentModels[ptid] = model;
|
|
342
|
+
else if (!isSide) state.rootModel = model;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Does this record repeat the usage an earlier line of the same API request
|
|
348
|
+
* already carried?
|
|
349
|
+
*
|
|
350
|
+
* Claude Code writes one assistant line per content block of a response —
|
|
351
|
+
* thinking, text, each tool call — and every one of them carries the whole
|
|
352
|
+
* request's `usage`. Only the first, `apiBlockIndex` 0, bills it (#1641).
|
|
353
|
+
* Measured across the forty most recent transcripts on one machine: 6,765 of
|
|
354
|
+
* 14,138 assistant usage records were such repeats, identical to the block 0
|
|
355
|
+
* before them, and summing them all came to 1.94x the request-by-request
|
|
356
|
+
* total. This is the rule the Projects report (account-projects.mjs) and
|
|
357
|
+
* ccusage count by, and the Projects report asks this function. A record
|
|
358
|
+
* without the field was written before Claude Code marked its blocks, and is
|
|
359
|
+
* counted as it always was.
|
|
360
|
+
*
|
|
361
|
+
* The same lines are one reply, so the context breakdown asks this too before
|
|
362
|
+
* it counts an assistant message (#1650).
|
|
363
|
+
*/
|
|
364
|
+
function repeatsRequestUsage(record) {
|
|
365
|
+
return record?.apiBlockIndex !== undefined && record.apiBlockIndex !== 0;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The usage blocks a line was billed for, charged to the file's totals and to
|
|
369
|
+
* their model's bucket. Runs after foldModelLine on the same line — see
|
|
370
|
+
* below for why the order is the attribution. `record` is the line parsed,
|
|
371
|
+
* or null when it names no model. */
|
|
372
|
+
function foldUsageLine(state, line, record) {
|
|
373
|
+
// A later content block of a request already charged — see
|
|
374
|
+
// repeatsRequestUsage.
|
|
375
|
+
if (repeatsRequestUsage(record)) return;
|
|
376
|
+
// Usage totals sum every block in the file, resets included — every block the
|
|
377
|
+
// model was actually billed for, which is why the `toolUseResult` tail is cut
|
|
378
|
+
// off first (see billedUsageText) — and are summed a second time into the
|
|
379
|
+
// bucket of the model that produced them (#686).
|
|
380
|
+
//
|
|
381
|
+
// `state.lastModel` is the attribution, and it is the LINE's model whenever
|
|
382
|
+
// the line has one: foldModelLine has already run on it and assigned it.
|
|
383
|
+
// That is the whole trick — CC writes `message.model` and `message.usage`
|
|
384
|
+
// into the same JSON object, so by the time this loop reads the tokens the
|
|
385
|
+
// model that produced them is the most recent thing the scanner saw. A usage
|
|
386
|
+
// block on a line naming no model falls back to the last model seen, which
|
|
387
|
+
// is the turn it belongs to; a usage block before ANY model line gets no
|
|
388
|
+
// bucket at all and stays in the flat total alone, where the client prices it
|
|
389
|
+
// at the session's current model exactly as it did before.
|
|
390
|
+
//
|
|
391
|
+
// The bucket takes exactly what the flat total takes, off the same `billed`
|
|
392
|
+
// text: a split that read a wider stretch of the line than the total it splits
|
|
393
|
+
// would re-introduce #685's double count on one side of the arithmetic only,
|
|
394
|
+
// and the two would stop summing to each other.
|
|
395
|
+
const billed = billedUsageText(line);
|
|
396
|
+
const bucket = usageBucketFor(state, state.lastModel);
|
|
397
|
+
for (const m of billed.matchAll(USAGE_BLOCK_RE)) {
|
|
398
|
+
chargeUsage(state, bucket, m[1], USAGE_BLOCK_FIELDS);
|
|
399
|
+
}
|
|
400
|
+
for (const m of billed.matchAll(CACHE_CREATION_BLOCK_RE)) {
|
|
401
|
+
chargeUsage(state, bucket, m[1], CACHE_SPLIT_FIELDS);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The line's share of the context breakdown. Context counts only what follows
|
|
406
|
+
* the most recent /clear or /compact. `record` is the line parsed, or null
|
|
407
|
+
* when it names no model — see foldTranscriptLine. */
|
|
408
|
+
function foldContextLine(state, line, record) {
|
|
409
|
+
let ctxText = line;
|
|
410
|
+
let resetEnd = -1;
|
|
411
|
+
for (const m of line.matchAll(CONTEXT_RESET_RE)) resetEnd = (m.index ?? -1) + m[0].length;
|
|
412
|
+
if (resetEnd >= 0) {
|
|
413
|
+
state.ctx = newContextBreakdown();
|
|
414
|
+
ctxText = line.slice(resetEnd);
|
|
415
|
+
}
|
|
416
|
+
const ctx = state.ctx;
|
|
417
|
+
ctx.msgsUser += (ctxText.match(TYPE_USER_RE) ?? []).length;
|
|
418
|
+
// One reply is one message, however many content blocks it was written in:
|
|
419
|
+
// a later block's line is a reply block 0 has already counted — see
|
|
420
|
+
// repeatsRequestUsage. Its tool call is its own, and is counted below.
|
|
421
|
+
if (!repeatsRequestUsage(record)) ctx.msgsAssistant += (ctxText.match(TYPE_ASSISTANT_RE) ?? []).length;
|
|
422
|
+
ctx.toolUses += (ctxText.match(TYPE_TOOL_USE_RE) ?? []).length;
|
|
423
|
+
ctx.toolResults += (ctxText.match(TYPE_TOOL_RESULT_RE) ?? []).length;
|
|
424
|
+
ctx.systemReminders += (ctxText.match(SYSTEM_REMINDER_RE) ?? []).length;
|
|
425
|
+
// Current context size = the LAST usage block after the reset. Stays 0
|
|
426
|
+
// right after a /clear, which is what CC's own /context reports.
|
|
427
|
+
let lastBlob = null;
|
|
428
|
+
for (const m of ctxText.matchAll(USAGE_BLOCK_RE)) lastBlob = m[1];
|
|
429
|
+
if (lastBlob) {
|
|
430
|
+
ctx.currentContextTokens =
|
|
431
|
+
grabUsageField(lastBlob, "input_tokens") +
|
|
432
|
+
grabUsageField(lastBlob, "cache_read_input_tokens") +
|
|
433
|
+
grabUsageField(lastBlob, "cache_creation_input_tokens");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** The session a transcript path belongs to, and the unit eviction works in.
|
|
438
|
+
* CC's two layouts collapse onto the same key: the main transcript
|
|
439
|
+
* `<slug>/<sessionId>.jsonl` loses its extension, and a subagent's
|
|
440
|
+
* `<slug>/<sessionId>/subagents/agent-<id>.jsonl` loses everything below the
|
|
441
|
+
* session directory, so both land on `<slug>/<sessionId>`. Anything else — a
|
|
442
|
+
* Codex rollout, a shape we do not recognise — is a session of its own,
|
|
443
|
+
* which is the old one-entry-per-path accounting and the safe default.
|
|
444
|
+
*
|
|
445
|
+
* `resolve` first so the two halves agree on Windows. The main path arrives
|
|
446
|
+
* from the hook payload as CC wrote it while the subagent path is built with
|
|
447
|
+
* `join`, so `C:/x/y.jsonl` and `C:\x\y\subagents\agent-1.jsonl` would
|
|
448
|
+
* otherwise be two sessions instead of one; `resolve` settles the separator
|
|
449
|
+
* on all three platforms, and leaves a backslash inside a POSIX filename
|
|
450
|
+
* alone rather than reading it as a directory break. */
|
|
451
|
+
export function transcriptSessionKey(path) {
|
|
452
|
+
const full = resolve(path);
|
|
453
|
+
const sub = /^(.*)[\\/]subagents[\\/]agent-[0-9a-f]+\.jsonl$/i.exec(full);
|
|
454
|
+
return sub ? sub[1] : full.replace(/\.jsonl$/i, "");
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Record a use of `path` and keep the cache under both caps. Re-inserting the
|
|
458
|
+
* session on every touch makes the Map's own insertion order the LRU order,
|
|
459
|
+
* so eviction reads one key instead of scanning for the smallest timestamp.
|
|
460
|
+
* Scanning a timestamp is also what broke the cache once: a state is created
|
|
461
|
+
* with no stamp yet, so the entry the scan had just inserted was the smallest
|
|
462
|
+
* of all and the one deleted, every time. Whatever the cache freezes on, the
|
|
463
|
+
* symptom is the same — every later transcript re-reads its whole JSONL from
|
|
464
|
+
* byte 0 on every throttled pass, the O(n)-per-pass stall the cursor exists
|
|
465
|
+
* to remove. */
|
|
466
|
+
function touchTranscriptScan(path, state) {
|
|
467
|
+
transcriptScans.set(path, state);
|
|
468
|
+
const key = transcriptSessionKey(path);
|
|
469
|
+
const paths = transcriptScanSessions.get(key) ?? new Set();
|
|
470
|
+
transcriptScanSessions.delete(key); // re-insert = move to the back of the LRU
|
|
471
|
+
paths.add(path);
|
|
472
|
+
transcriptScanSessions.set(key, paths);
|
|
473
|
+
pruneTranscriptScans();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function pruneTranscriptScans() {
|
|
477
|
+
// The last session standing is never evicted, however many files it holds:
|
|
478
|
+
// dropping the cursors of the session currently being scanned is precisely
|
|
479
|
+
// the re-read this cache exists to avoid, and no cap can make its file
|
|
480
|
+
// count smaller.
|
|
481
|
+
while (
|
|
482
|
+
transcriptScanSessions.size > 1 &&
|
|
483
|
+
(transcriptScanSessions.size > MAX_TRANSCRIPT_SCAN_SESSIONS ||
|
|
484
|
+
transcriptScans.size > MAX_TRANSCRIPT_SCAN_ENTRIES)
|
|
485
|
+
) {
|
|
486
|
+
const oldest = transcriptScanSessions.keys().next().value;
|
|
487
|
+
for (const p of transcriptScanSessions.get(oldest)) transcriptScans.delete(p);
|
|
488
|
+
transcriptScanSessions.delete(oldest);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// How far ONE pass will walk a file that is behind by more than a chunk.
|
|
493
|
+
//
|
|
494
|
+
// The cursor makes catching up cheap in steady state, but the FIRST pass over
|
|
495
|
+
// an existing transcript has the whole file to fold, and a fix that made that
|
|
496
|
+
// take one throttle window (2500 ms) per 8 MiB would have turned a 130.8 MB
|
|
497
|
+
// transcript's first attach into forty seconds of a deck showing no model, no
|
|
498
|
+
// name and no cost — the reintroduction, by a different route, of exactly the
|
|
499
|
+
// stall #611 removed. So a pass loops over chunks until it is caught up, and
|
|
500
|
+
// this bounds what one hook event can be made to walk.
|
|
501
|
+
//
|
|
502
|
+
// 256 MiB is twice the heaviest transcript this repo has ever measured (130.8
|
|
503
|
+
// MB, #611), so an honest first attach never meets it and pays nothing for it.
|
|
504
|
+
// What it stops is a caller who has got past `isClaudeTranscriptPath` in
|
|
505
|
+
// transcript-gate.mjs pointing one POST at something arbitrarily large: the read still terminates,
|
|
506
|
+
// the cursor keeps whatever it reached, and the next throttled pass continues
|
|
507
|
+
// from there.
|
|
508
|
+
const MAX_SCAN_BYTES_PER_PASS = 256 * 1024 * 1024;
|
|
509
|
+
|
|
510
|
+
/** Bring a transcript's scan state up to date and return it. Concurrent
|
|
511
|
+
* callers share one read — folding the same appended bytes twice would
|
|
512
|
+
* double the usage totals. Never throws; an unreadable file just leaves
|
|
513
|
+
* the state where it was. */
|
|
514
|
+
function scanTranscript(path) {
|
|
515
|
+
if (!path || typeof path !== "string") return Promise.resolve(null);
|
|
516
|
+
const inFlight = transcriptScanInFlight.get(path);
|
|
517
|
+
if (inFlight) return inFlight;
|
|
518
|
+
const run = (async () => {
|
|
519
|
+
let state = transcriptScans.get(path);
|
|
520
|
+
if (!state) state = newTranscriptState();
|
|
521
|
+
touchTranscriptScan(path, state);
|
|
522
|
+
try {
|
|
523
|
+
const s = await stat(path);
|
|
524
|
+
// Shorter than the cursor means the file was truncated, rotated or
|
|
525
|
+
// replaced — the offset now points at unrelated bytes, so start over.
|
|
526
|
+
if (s.size < state.offset) {
|
|
527
|
+
state = newTranscriptState();
|
|
528
|
+
// Touch again rather than set: the await above yielded, and another
|
|
529
|
+
// path's scan may have re-ordered or evicted this entry meanwhile.
|
|
530
|
+
touchTranscriptScan(path, state);
|
|
531
|
+
}
|
|
532
|
+
if (s.size <= state.offset) return state;
|
|
533
|
+
// Chunk by chunk rather than in one allocation, and loop rather than
|
|
534
|
+
// leave the rest for the next throttle window — see MAX_SCAN_CHUNK in
|
|
535
|
+
// jsonl-chunks.mjs and MAX_SCAN_BYTES_PER_PASS above for why it is both
|
|
536
|
+
// of those and not either one alone. `readAppendedLines` advances the
|
|
537
|
+
// cursor BEFORE this folds, which is the same rule the single-shot read
|
|
538
|
+
// kept: a fold that throws half-way must not leave the cursor where the
|
|
539
|
+
// next pass would count those lines again. `advanced === 0` is "nothing
|
|
540
|
+
// complete to fold yet".
|
|
541
|
+
let budget = MAX_SCAN_BYTES_PER_PASS;
|
|
542
|
+
while (budget > 0 && state.offset < s.size) {
|
|
543
|
+
const { text, advanced } = await readAppendedLines(path, state, s.size);
|
|
544
|
+
if (advanced === 0) break;
|
|
545
|
+
budget -= advanced;
|
|
546
|
+
for (const line of text.split("\n")) foldTranscriptLine(state, line);
|
|
547
|
+
}
|
|
548
|
+
} catch { /* keep whatever we already folded */ }
|
|
549
|
+
return state;
|
|
550
|
+
})();
|
|
551
|
+
transcriptScanInFlight.set(path, run);
|
|
552
|
+
return run.finally(() => {
|
|
553
|
+
if (transcriptScanInFlight.get(path) === run) transcriptScanInFlight.delete(path);
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// What session-enrichment.mjs calls, and the two rules the Projects report
|
|
558
|
+
// (account-projects.mjs) reads its own pass of the transcripts by. Listed here
|
|
559
|
+
// rather than marked at each declaration so that every declaration above reads
|
|
560
|
+
// exactly as it did where it came from.
|
|
561
|
+
export {
|
|
562
|
+
scanTranscript, newUsageTotals, hasSpend, mergeUsageByModel, repeatsRequestUsage,
|
|
563
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The usage panel's reads: Claude's quota windows, Codex's quota and usage, and
|
|
2
|
+
// ccusage's daily history.
|
|
3
|
+
//
|
|
4
|
+
// These lived in src/server/index.mjs after the shutdown route, and each is the
|
|
5
|
+
// same few lines: read `?refresh=1`, import the module that does the work,
|
|
6
|
+
// hand the flag on, and answer with what comes back. What bounds a forced read
|
|
7
|
+
// is those modules' business — codex-usage-forced-read-guard.test.ts keeps the
|
|
8
|
+
// census of it there — and each is imported lazily by the package-root URL
|
|
9
|
+
// PINNED_MODULES loads. The one rule decided here is isCliDate, because this is
|
|
10
|
+
// where a string somebody else chose enters on its way to a child's argv.
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
13
|
+
import { send } from "./http-io.mjs";
|
|
14
|
+
|
|
15
|
+
// Resolved the way pinned-build.mjs resolves it, from a file in the same
|
|
16
|
+
// directory, so every lazy import below is the URL the pin has already evaluated.
|
|
17
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* WITH A DEADLINE OF ITS OWN (#1011). Source 3 is `claude --print /usage`, and
|
|
21
|
+
* its cost is the sum of three spawns under a 15-second timeout with two
|
|
22
|
+
* 1.2-second sleeps between them — 47.4 seconds, measured — which nothing here
|
|
23
|
+
* had ever added up. The budget is quota.mjs's, because the "not yet" answer it
|
|
24
|
+
* expires into is made of that module's own last-known-good reading; the read
|
|
25
|
+
* carries on behind it and publishes for the next poll.
|
|
26
|
+
*/
|
|
27
|
+
export async function handleQuota(req, res) {
|
|
28
|
+
const { fetchClaudeQuota, QUOTA_DEADLINE_MS } = await import(
|
|
29
|
+
pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
|
|
30
|
+
);
|
|
31
|
+
const url = new URL(req.url, "http://localhost");
|
|
32
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
33
|
+
const quota = await fetchClaudeQuota({ force, deadlineMs: QUOTA_DEADLINE_MS });
|
|
34
|
+
send(res, 200, quota);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function handleCodexUsage(req, res) {
|
|
38
|
+
const { fetchCodexUsage } = await import(
|
|
39
|
+
pathToFileURL(join(PKG_ROOT, "src/server/codex-usage.mjs")).href
|
|
40
|
+
);
|
|
41
|
+
const url = new URL(req.url, "http://localhost");
|
|
42
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
43
|
+
const usage = await fetchCodexUsage({ force });
|
|
44
|
+
send(res, 200, usage);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function handleCodexQuota(req, res) {
|
|
48
|
+
const { fetchCodexQuota } = await import(
|
|
49
|
+
pathToFileURL(join(PKG_ROOT, "src/server/codex-quota.mjs")).href
|
|
50
|
+
);
|
|
51
|
+
const url = new URL(req.url, "http://localhost");
|
|
52
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
53
|
+
const quota = await fetchCodexQuota({ force });
|
|
54
|
+
send(res, 200, quota);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* ccusage's `--since`/`--until` grammar: a `YYYYMMDD` calendar date, or nothing
|
|
59
|
+
* at all — an absent parameter lets ccusage.mjs pick the default 30-day range.
|
|
60
|
+
*
|
|
61
|
+
* Eight digits is a shape, not a calendar, and deliberately so. What this gate
|
|
62
|
+
* is for is keeping anything that is not a date out of a child process's
|
|
63
|
+
* argument vector; `99999999` is exactly as inert an argument as `20260101`,
|
|
64
|
+
* and whether a date outside the logs means an empty chart is ccusage's
|
|
65
|
+
* question to answer rather than the deck's to guess at.
|
|
66
|
+
*/
|
|
67
|
+
export const isCliDate = (v) => v === undefined || /^\d{8}$/.test(v);
|
|
68
|
+
|
|
69
|
+
export async function handleCcusage(req, res) {
|
|
70
|
+
const url = new URL(req.url, "http://localhost");
|
|
71
|
+
const force = url.searchParams.get("refresh") === "1";
|
|
72
|
+
const since = url.searchParams.get("since") || undefined;
|
|
73
|
+
const until = url.searchParams.get("until") || undefined;
|
|
74
|
+
// Refused here, at the boundary, because this is where a string chosen by
|
|
75
|
+
// someone else enters: /api/ccusage is a GET, so it needs no CORS, no
|
|
76
|
+
// preflight and no ability to read the response — any page the user has open
|
|
77
|
+
// can fire it at the loopback port — and both values end up in the argv of a
|
|
78
|
+
// spawned CLI. The spawn no longer goes through a shell (see ccusage.mjs), so
|
|
79
|
+
// this is the second lock rather than the only one, and it is the one that
|
|
80
|
+
// keeps the pair honest if a shell is ever reintroduced downstream.
|
|
81
|
+
if (!isCliDate(since) || !isCliDate(until)) {
|
|
82
|
+
return send(res, 400, {
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: "bad_range",
|
|
85
|
+
error: "since and until must be YYYYMMDD dates, e.g. 20260101",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const { fetchCcusageDaily } = await import(
|
|
89
|
+
pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href
|
|
90
|
+
);
|
|
91
|
+
const data = await fetchCcusageDaily({ since, until, force });
|
|
92
|
+
send(res, 200, data);
|
|
93
|
+
}
|
|
@@ -25,7 +25,7 @@ import { run } from "./exec.mjs";
|
|
|
25
25
|
// holds the target open, and a virus scanner or the search indexer opens a
|
|
26
26
|
// freshly written executable the instant it appears. Sharing one implementation
|
|
27
27
|
// keeps the retryable-error list from drifting apart between the two callers.
|
|
28
|
-
import { renameWithRetry } from "./
|
|
28
|
+
import { renameWithRetry } from "./atomic-write.mjs";
|
|
29
29
|
|
|
30
30
|
const TOOL_DIR = join(homedir(), ".agents-deck", "tools");
|
|
31
31
|
const UV_DIR = join(TOOL_DIR, "uv");
|