crewforth 3.1.0-rc.1 → 3.1.0-rc.3

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.
@@ -0,0 +1,54 @@
1
+ # An app that holds sensitive data (Flutter)
2
+
3
+ Apply only when the app stores data its user would not want read off a lost phone, a backup or the network:
4
+ financial, health, identity. Checked against https://pub.dev/packages/sqlite3 and its hook documentation,
5
+ https://pub.dev/packages/sqlcipher_flutter_libs and https://developer.android.com/identity/data/autobackup
6
+ (2026-10-07). The iOS part is from Apple's API names and was **not checked against a fetched page**.
7
+
8
+ ## The database is encrypted
9
+ - The project's database package decides how; the rule is that the file on disk is ciphertext.
10
+ - With `package:sqlite3` 3.x the SQLite build is chosen by a build hook, in the `pubspec.yaml` at the workspace root:
11
+
12
+ ```yaml
13
+ hooks:
14
+ user_defines:
15
+ sqlite3:
16
+ source: sqlcipher # or sqlite3mc; the default is sqlite3, which does not encrypt
17
+ ```
18
+
19
+ - `sqlcipher_flutter_libs` belongs to version 2.x of `package:sqlite3`; its own page says "Not used anymore, update
20
+ to version 3.x of package:sqlite3 instead". Do not add it to a project on 3.x.
21
+ - A test opens the database file without the key and expects the open to fail: an encryption that was configured
22
+ and silently not applied looks the same from the app.
23
+
24
+ ## The key is in the platform's secure store
25
+ - Generated on the device, kept in the Keychain (iOS) or behind the Keystore (Android) through the project's
26
+ secure-storage package. Not in the database's directory, not in shared preferences, not in the code or its assets.
27
+ - A key that is lost makes the data unreadable. Decide and write down what the app does then (an `adr`).
28
+
29
+ ## Out of backup and device transfer
30
+ The database and the secure store's own files are excluded on both platforms; a restored copy without its key is
31
+ unreadable at best, and a copy with its key is the data on another device.
32
+
33
+ - **Android.** `android:allowBackup="false"` on `<application>`. For an app that targets Android 12 (API 31) or
34
+ higher that turns off cloud backup but, in the documentation's words, "doesn't disable device-to-device transfers"
35
+ on some manufacturers' devices. So also `android:dataExtractionRules` pointing at an XML file whose
36
+ `<cloud-backup>` and `<device-transfer>` sections each exclude the database and the secure-storage files. Devices
37
+ on Android 11 or lower read `android:fullBackupContent` instead.
38
+ - **iOS.** The files carry the resource value `isExcludedFromBackup` (`NSURLIsExcludedFromBackupKey`), set from
39
+ native code or a plugin after the file exists. Set it again when the file is created anew.
40
+ - A test (or a check in CI) reads the manifest and the rules file and fails when either attribute is missing.
41
+
42
+ ## Every network call through a transport bound to its allowed hosts
43
+ - A transport is the one object a service's requests leave through. Each holds the host or hosts **it** may talk
44
+ to and **refuses any other**: it throws before the request is sent. An app with one backend has one transport;
45
+ an app that talks to several services gives each its own, with its own list, so a transport for one service
46
+ cannot reach another's host.
47
+ - A test asks each transport for a host that is not on its list and expects the throw. An architecture test fails
48
+ on a network call made outside a transport (a client constructed or a socket opened anywhere else). In a debug
49
+ run the throw is a crash someone sees; it is never swallowed.
50
+
51
+ ## Ads
52
+ In an app that shows ads, **no ad is drawn in a window where a sensitive screen is visible.** In a two-pane layout
53
+ the detail pane may be the sensitive one while the list is not: the rule is about the window, so the list's ad goes
54
+ too. A widget test at an expanded width expects no ad widget while the sensitive detail is shown.
@@ -19,6 +19,9 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
19
19
  import { getFleet, measureSpawnCost } from './lib/fleet.js';
20
20
  import { projectDir, listSessions, findSession, listProjects, sessionCwd } from './lib/projects.js';
21
21
  import { buildGraph, agentDetail, conversation, STALE_MS } from './lib/graph.js';
22
+ import { cachedUsage } from './lib/usage.js';
23
+
24
+ const USAGE_BATCH = 12;
22
25
  import { palette } from './lib/palette.js';
23
26
  import { latestVersion, latestVersionCached, kitStatus } from './lib/kit.js';
24
27
  import { parsePeers, askAll, ask } from './lib/peers.js';
@@ -192,6 +195,21 @@ async function handle(req, res) {
192
195
  });
193
196
  }
194
197
 
198
+ // What a handful of sessions spent, for the navigator's rows. Asked for a few at a time and answered from a
199
+ // cache that lasts until a transcript grows: reading every transcript on the machine to draw a list is not
200
+ // something a page load should do.
201
+ if (url.pathname === '/api/usage') {
202
+ const ids = (url.searchParams.get('ids') ?? '').split(',').filter(Boolean).slice(0, USAGE_BATCH);
203
+ const usage = {};
204
+ for (const id of ids) {
205
+ const session = await findSession(id);
206
+ if (!session) continue; // a session on another machine: not read here, and not guessed
207
+ const u = await cachedUsage(session);
208
+ usage[id] = { durationMs: u.durationMs, workedMs: u.workedMs, tokens: u.tokens.total, cost: u.cost, unpriced: u.unpriced };
209
+ }
210
+ return sendJson(res, 200, { measured: true, usage });
211
+ }
212
+
195
213
  if (url.pathname === '/api/fleet') {
196
214
  const local = await getFleet();
197
215
  const mine = (local.sessions ?? []).map((sn) => ({ ...sn, origin: SELF_NAME, local: true }));
@@ -19,6 +19,8 @@ import path from 'node:path';
19
19
 
20
20
  import { readAll, contextFill } from './transcript.js';
21
21
  import { agentMetaFiles } from './projects.js';
22
+ import { Usage, timesOf } from './usage.js';
23
+ import { detailOf } from './permissions.js';
22
24
 
23
25
  const SESSION_NODE = 'session';
24
26
 
@@ -37,8 +39,12 @@ function textOf(content) {
37
39
  * pairs the first id with the status swallows the rest; measured on this
38
40
  * machine, one notice carried six ids and five were being dropped. Every id
39
41
  * seen since the previous status takes that status.
42
+ *
43
+ * `at` is when the record carrying the notice was written. A notice is not the end of an agent: Claude Code sends
44
+ * one each time a background agent stops, and the agent can be sent another message and work again. Whether a
45
+ * notice still stands is decided by comparing `at` with what the agent wrote afterwards.
40
46
  */
41
- function harvestCompletions(text, into) {
47
+ function harvestCompletions(text, into, at = null) {
42
48
  if (!text || !text.includes('<task-id>')) return;
43
49
  const token = /<task-id>([^<]+)<\/task-id>|<status>([^<]+)<\/status>/g;
44
50
  let pending = [];
@@ -48,7 +54,7 @@ function harvestCompletions(text, into) {
48
54
  pending.push(m[1].trim());
49
55
  } else {
50
56
  const status = m[2].trim();
51
- for (const id of pending) into.set(id, status);
57
+ for (const id of pending) into.set(id, { status, at });
52
58
  pending = [];
53
59
  }
54
60
  }
@@ -60,7 +66,9 @@ function scanMain(records) {
60
66
  startedAt: null, updatedAt: null,
61
67
  agentCalls: new Map(), // toolUseId -> { subagentType, description }
62
68
  links: new Map(), // toolUseId -> { agentId, status, model }
63
- completions: new Map(), // agentId -> status
69
+ completions: new Map(), // task id (an agent's, or a background command's) -> { status, at }
70
+ commands: new Map(), // toolUseId -> the command a Bash call ran
71
+ background: new Map(), // background task id -> { toolUseId, startedAt }
64
72
  owners: new Map(), // toolUseId -> owner node id
65
73
  userTurns: 0,
66
74
  };
@@ -71,9 +79,11 @@ function scanMain(records) {
71
79
  if (r?.cwd && !out.cwd) out.cwd = r.cwd;
72
80
  if (r?.gitBranch && !out.gitBranch) out.gitBranch = r.gitBranch;
73
81
  if (r?.version && !out.version) out.version = r.version;
82
+ let at = null;
74
83
  if (r?.timestamp) {
75
84
  const t = Date.parse(r.timestamp);
76
85
  if (Number.isFinite(t)) {
86
+ at = t;
77
87
  if (out.startedAt === null || t < out.startedAt) out.startedAt = t;
78
88
  if (out.updatedAt === null || t > out.updatedAt) out.updatedAt = t;
79
89
  }
@@ -86,6 +96,7 @@ function scanMain(records) {
86
96
  for (const c of content) {
87
97
  if (c?.type === 'tool_use' && c.id) {
88
98
  out.owners.set(c.id, SESSION_NODE);
99
+ if (typeof c.input?.command === 'string') out.commands.set(c.id, c.input.command);
89
100
  if (c.name === 'Agent') {
90
101
  out.agentCalls.set(c.id, {
91
102
  subagentType: c.input?.subagent_type ?? null,
@@ -95,12 +106,12 @@ function scanMain(records) {
95
106
  }
96
107
  }
97
108
  }
98
- harvestCompletions(textOf(content), out.completions);
109
+ harvestCompletions(textOf(content), out.completions, at);
99
110
  }
100
111
 
101
112
  if (r?.type === 'user') {
102
113
  out.userTurns += 1;
103
- harvestCompletions(textOf(r.message?.content), out.completions);
114
+ harvestCompletions(textOf(r.message?.content), out.completions, at);
104
115
  }
105
116
 
106
117
  // Present on the record that carries a subagent's result, whatever its type.
@@ -112,15 +123,81 @@ function scanMain(records) {
112
123
  if (id) out.links.set(id, entry);
113
124
  else out.links.set(`agent:${tur.agentId}`, entry);
114
125
  }
115
- if (r?.type === 'attachment') harvestCompletions(JSON.stringify(r.attachment ?? ''), out.completions);
126
+ // A command sent to the background answers at once with the id it runs under; its end comes later, as a notice.
127
+ if (tur && typeof tur === 'object' && typeof tur.backgroundTaskId === 'string') {
128
+ const id = r.message?.content?.find?.((c) => c?.type === 'tool_result')?.tool_use_id ?? null;
129
+ out.background.set(tur.backgroundTaskId, { toolUseId: id, startedAt: at });
130
+ }
131
+ if (r?.type === 'attachment') harvestCompletions(JSON.stringify(r.attachment ?? ''), out.completions, at);
116
132
  }
117
133
 
118
134
  return out;
119
135
  }
120
136
 
137
+ /**
138
+ * Does a transcript stop in the middle of a turn? It does when the last thing in it is a tool call with no answer,
139
+ * or an answer the agent has not replied to. A turn that is over ends on the agent's own words.
140
+ */
141
+ export function midTurn(records) {
142
+ for (let i = records.length - 1; i >= 0; i -= 1) {
143
+ const r = records[i];
144
+ if (r?.type !== 'assistant' && r?.type !== 'user') continue;
145
+ const content = r.message?.content;
146
+ if (r.type === 'user') return true;
147
+ return Array.isArray(content) && content.some((c) => c?.type === 'tool_use');
148
+ }
149
+ return false;
150
+ }
151
+
152
+ // How long after a notice an agent's own last record may be and still belong to the turn the notice ended. The
153
+ // notice is written after the agent's last record, so anything later than this is the agent working again.
154
+ const NOTICE_SLACK_MS = 5000;
155
+
156
+ /**
157
+ * Does a completion notice still say how the agent stands? Not when the agent wrote to its transcript after it:
158
+ * it was sent another message and is working, or has worked, again.
159
+ */
160
+ export function noticeStands(notice, lastOwnAt) {
161
+ if (!notice) return false;
162
+ if (notice.at == null || lastOwnAt == null) return true;
163
+ return lastOwnAt <= notice.at + NOTICE_SLACK_MS;
164
+ }
165
+
166
+ // A call that hands work to an agent is open for as long as that agent runs; it is not a call anyone is asked about.
167
+ const DELEGATES = new Set(['Agent', 'Task']);
168
+ const OPEN_DETAIL_MAX = 2000;
169
+
170
+ /**
171
+ * The tool calls a transcript has asked for and has no result for yet, oldest first.
172
+ *
173
+ * A call is open while it runs and while it waits for someone to allow it; the transcript does not say which. It
174
+ * is what a session started in a terminal is asking about when the machine reports it as waiting for its user —
175
+ * the panel has no hook in such a session, so this is the only place the question can be read from.
176
+ */
177
+ export function openCalls(records) {
178
+ const open = new Map();
179
+ for (const r of records) {
180
+ const content = r?.message?.content;
181
+ if (!Array.isArray(content)) continue;
182
+ for (const c of content) {
183
+ if (r.type === 'assistant' && c?.type === 'tool_use' && c.id && !DELEGATES.has(c.name)) {
184
+ const at = Date.parse(r.timestamp ?? '');
185
+ const detail = detailOf(c.input);
186
+ open.set(c.id, {
187
+ toolUseId: c.id, toolName: c.name ?? 'unknown',
188
+ detail: detail === null ? null : detail.slice(0, OPEN_DETAIL_MAX),
189
+ at: Number.isFinite(at) ? at : null,
190
+ });
191
+ } else if (r.type === 'user' && c?.type === 'tool_result' && c.tool_use_id) open.delete(c.tool_use_id);
192
+ }
193
+ }
194
+ return [...open.values()];
195
+ }
196
+
121
197
  /** Roll one agent's own transcript into the numbers its node shows. */
122
- async function scanAgent(file) {
198
+ async function scanAgent(file, usage = null) {
123
199
  const { records } = await readAll(file);
200
+ usage?.add(records);
124
201
  const stats = {
125
202
  tools: {}, toolCount: 0, lastTool: null, errors: 0,
126
203
  tokens: null, startedAt: null, endedAt: null, turns: 0,
@@ -164,10 +241,12 @@ async function scanAgent(file) {
164
241
  stats.durationMs = stats.startedAt !== null && stats.endedAt !== null
165
242
  ? stats.endedAt - stats.startedAt
166
243
  : null;
244
+ stats.midTurn = midTurn(records);
245
+ stats.open = openCalls(records);
167
246
  return stats;
168
247
  }
169
248
 
170
- async function readAgentDir(subagentsDir) {
249
+ async function readAgentDir(subagentsDir, usage = null) {
171
250
  const out = [];
172
251
  // Nested too: a workflow puts its agents under subagents/workflows/<id>/.
173
252
  for (const metaPath of await agentMetaFiles(subagentsDir)) {
@@ -185,7 +264,7 @@ async function readAgentDir(subagentsDir) {
185
264
  // `stats` needs the transcript read; a missing file is a normal state (the
186
265
  // agent has not written yet), so absence is a null rather than a throw.
187
266
  let stats = null;
188
- if (mtime !== null) stats = await scanAgent(jsonl);
267
+ if (mtime !== null) stats = await scanAgent(jsonl, usage);
189
268
 
190
269
  out.push({
191
270
  agentId,
@@ -220,7 +299,9 @@ export const STALE_MS = 120000;
220
299
  export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
221
300
  const { records, malformed } = await readAll(session.file);
222
301
  const main = scanMain(records);
223
- const agents = await readAgentDir(session.subagentsDir);
302
+ // What the session spent, its agents' tokens included and each response counted once.
303
+ const usage = new Usage().add(records);
304
+ const agents = await readAgentDir(session.subagentsDir, usage);
224
305
  const now = Date.now();
225
306
 
226
307
  // Ownership: session first, then every agent's own tool_use ids, so a nested
@@ -248,13 +329,26 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
248
329
  tokens: contextFill(records),
249
330
  startedAt: main.startedAt,
250
331
  endedAt: main.updatedAt,
332
+ usage: { ...usage.result(), ...timesOf(records) },
333
+ // Commands sent to the background that no notice has ended, oldest first. A session that is over has none
334
+ // running whatever this says: the page shows them only while the session is live.
335
+ backgroundCommands: [...main.background]
336
+ .filter(([id]) => !main.completions.has(id))
337
+ .map(([id, b]) => ({ id, toolName: 'Bash', detail: main.commands.get(b.toolUseId) ?? null, startedAt: b.startedAt }))
338
+ .sort((x, y) => (x.startedAt ?? 0) - (y.startedAt ?? 0)),
339
+ // The newest call nothing has answered, the session's own or an agent's. Null when every call has its result.
340
+ openCall: [
341
+ ...openCalls(records).map((c) => ({ ...c, agentId: null, agentType: null })),
342
+ ...agents.flatMap((a) => (a.stats?.open ?? []).map((c) => ({ ...c, agentId: a.agentId, agentType: a.agentType ?? null }))),
343
+ ].sort((a, b) => (b.at ?? 0) - (a.at ?? 0))[0] ?? null,
251
344
  }];
252
345
  const edges = [];
253
346
 
254
347
  for (const a of agents) {
255
348
  const call = a.toolUseId ? main.agentCalls.get(a.toolUseId) : null;
256
349
  const link = a.toolUseId ? byToolUse.get(a.toolUseId) : null;
257
- const completion = main.completions.get(a.agentId) ?? null;
350
+ const notice = main.completions.get(a.agentId) ?? null;
351
+ const completion = noticeStands(notice, a.stats?.endedAt ?? null) ? notice.status : null;
258
352
 
259
353
  // Order matters. A synchronous Agent call never produces a task
260
354
  // notification — it reports through toolUseResult.status instead. Reading
@@ -288,6 +382,11 @@ export async function buildGraph(session, { staleMs = STALE_MS } = {}) {
288
382
  toolUseId: a.toolUseId,
289
383
  model: link?.model ?? null,
290
384
  launchStatus: link?.status ?? null,
385
+ // Started in the background, and its transcript stops in the middle of a turn. Such an agent can be quiet
386
+ // for a long time inside one tool call; whether it is still working is the live session's to say, so the
387
+ // page decides (graph-plan.js `settle`), not this file.
388
+ background: link?.status === 'async_launched',
389
+ midTurn: a.stats?.midTurn ?? false,
291
390
  parentId,
292
391
  tools: a.stats?.tools ?? {},
293
392
  toolCount: a.stats?.toolCount ?? 0,
@@ -74,6 +74,16 @@ function gateMode(mode) {
74
74
  return typeof mode === 'string' && /^[A-Za-z]+$/.test(mode) ? `CREW_GATE_MODE=${mode} ` : '';
75
75
  }
76
76
 
77
+ /**
78
+ * The most identifying field each tool has. Showing "Bash" alone would ask someone to approve a command they
79
+ * cannot see.
80
+ */
81
+ export function detailOf(input) {
82
+ const i = input && typeof input === 'object' ? input : {};
83
+ const d = i.command ?? i.file_path ?? i.pattern ?? i.query ?? i.description ?? null;
84
+ return typeof d === 'string' ? d : null;
85
+ }
86
+
77
87
  /** One pending request, as the panel needs to show it. */
78
88
  function readRequest(spool, file) {
79
89
  const toolUseId = file.replace(/\.json$/, '');
@@ -87,9 +97,7 @@ function readRequest(spool, file) {
87
97
  return {
88
98
  toolUseId,
89
99
  toolName: payload.tool_name ?? 'unknown',
90
- // The most identifying field each tool has. Showing "Bash" alone would ask
91
- // someone to approve a command they cannot see.
92
- detail: input.command ?? input.file_path ?? input.pattern ?? input.query ?? input.description ?? null,
100
+ detail: detailOf(input),
93
101
  input,
94
102
  cwd: payload.cwd ?? null,
95
103
  // Who asked. Claude Code puts these in the hook's input only when the call comes from inside a subagent, so
@@ -0,0 +1,63 @@
1
+ // API list prices, for the estimate the panel shows beside a session's tokens.
2
+ //
3
+ // THIS IS THE ONLY PLACE A PRICE IS WRITTEN. Everything is USD per million tokens, copied from the page named
4
+ // in SOURCE on the day named there. A model that is not in this table has no price here: its cost is not
5
+ // estimated from a neighbour, and the panel shows a dash.
6
+ //
7
+ // What the page says and this file follows:
8
+ // - a cache write costs 1.25x the input price for the 5-minute cache and 2x for the 1-hour cache;
9
+ // - a cache read costs 0.1x the input price, except where a model's row says otherwise (`cacheRead`);
10
+ // - Claude Haiku 5.5 has a second set of prices for a prompt of over 100,000 tokens (`over`);
11
+ // - fast mode has its own input and output prices (`fast`), and the cache multipliers apply on top of them;
12
+ // - US-only inference multiplies every category by 1.1.
13
+ // What it does not cover: the Batch API discount, web search requests ($10 per 1,000), and prices on other
14
+ // cloud platforms. A session billed under any of those is estimated at the standard rate.
15
+
16
+ export const SOURCE = {
17
+ url: 'https://platform.claude.com/docs/en/about-claude/pricing',
18
+ read: '2026-10-08',
19
+ };
20
+
21
+ export const CACHE_WRITE_5M = 1.25;
22
+ export const CACHE_WRITE_1H = 2;
23
+ export const CACHE_READ = 0.1;
24
+ export const US_ONLY = 1.1;
25
+
26
+ // Keyed by the Claude API model id as a transcript records it. Only ids that were checked are here: the four
27
+ // current ones against the models overview page, and the four others against transcripts that name them.
28
+ export const PRICES = {
29
+ 'claude-fable-5-1': { input: 10, output: 50, cacheRead: 0.025 },
30
+ 'claude-opus-5-5': { input: 4, output: 20, cacheRead: 0.05, fast: { input: 8, output: 40 } },
31
+ 'claude-sonnet-5-5': { input: 2, output: 10, cacheRead: 0.05 },
32
+ 'claude-haiku-5-5': { input: 0.10, output: 0.50, over: { promptTokens: 100_000, input: 0.50, output: 2.50 } },
33
+ 'claude-opus-5': { input: 5, output: 25, fast: { input: 10, output: 50 } },
34
+ 'claude-opus-4-8': { input: 5, output: 25, fast: { input: 10, output: 50 } },
35
+ 'claude-sonnet-5': { input: 2, output: 10 },
36
+ 'claude-haiku-4-5': { input: 1, output: 5 },
37
+ };
38
+
39
+ /** The row for a model id, or null. A dated snapshot (`<id>-20260101`) is the same model; nothing else matches. */
40
+ export function priceOf(model) {
41
+ if (typeof model !== 'string') return null;
42
+ if (Object.hasOwn(PRICES, model)) return PRICES[model];
43
+ const m = model.match(/^(.*)-\d{8}$/);
44
+ return m && Object.hasOwn(PRICES, m[1]) ? PRICES[m[1]] : null;
45
+ }
46
+
47
+ /**
48
+ * What one API response cost at list price, in USD. Null when the model has no row.
49
+ * @param u { model, input, output, cacheWrite5m, cacheWrite1h, cacheRead, fast, usOnly } — token counts
50
+ */
51
+ export function costOf(u) {
52
+ const row = priceOf(u.model);
53
+ if (!row) return null;
54
+ const prompt = u.input + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead;
55
+ const tier = (u.fast && row.fast) || (row.over && prompt > row.over.promptTokens ? row.over : row);
56
+ const read = row.cacheRead ?? CACHE_READ;
57
+ const usd = (u.input * tier.input
58
+ + u.cacheWrite5m * tier.input * CACHE_WRITE_5M
59
+ + u.cacheWrite1h * tier.input * CACHE_WRITE_1H
60
+ + u.cacheRead * tier.input * read
61
+ + u.output * tier.output) / 1_000_000;
62
+ return u.usOnly ? usd * US_ONLY : usd;
63
+ }
@@ -0,0 +1,144 @@
1
+ // What a session spent: tokens read from its transcripts, and what they would cost at API list price.
2
+ //
3
+ // Claude Code writes one record per content block, and every record of one API response repeats that response's
4
+ // usage. Measured on one machine before this was written: 23,867 assistant records carried usage, for 11,245
5
+ // responses, and every repeat carried the same numbers. So a response is counted once, by its message id — and
6
+ // once across the session's files too, which is what keeps an agent's tokens from being added twice.
7
+
8
+ import fsp from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ import { readAll } from './transcript.js';
12
+ import { agentMetaFiles } from './projects.js';
13
+ import { costOf, SOURCE } from './pricing.js';
14
+
15
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
16
+
17
+ /** One response's usage, in the shape pricing.js prices. Null when the record carries none. */
18
+ export function usageOfRecord(r) {
19
+ const m = r?.type === 'assistant' ? r.message : null;
20
+ const u = m?.usage;
21
+ if (!u || typeof u !== 'object') return null;
22
+ const split = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null;
23
+ const w1h = num(split?.ephemeral_1h_input_tokens);
24
+ // With no split recorded, a cache write is the 5-minute kind: that is the default, and the cheaper of the two.
25
+ const w5m = split ? num(split.ephemeral_5m_input_tokens) : num(u.cache_creation_input_tokens);
26
+ return {
27
+ id: typeof m.id === 'string' ? m.id : null,
28
+ model: typeof m.model === 'string' ? m.model : null,
29
+ input: num(u.input_tokens),
30
+ output: num(u.output_tokens),
31
+ cacheWrite5m: w5m,
32
+ cacheWrite1h: w1h,
33
+ cacheRead: num(u.cache_read_input_tokens),
34
+ fast: u.speed === 'fast',
35
+ usOnly: u.inference_geo === 'us',
36
+ };
37
+ }
38
+
39
+ export class Usage {
40
+ constructor() {
41
+ this.seen = new Set();
42
+ this.tokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
43
+ this.usd = 0;
44
+ this.responses = 0;
45
+ this.priced = new Set();
46
+ this.unpriced = new Set();
47
+ }
48
+
49
+ /** Add a transcript's records. A response already counted, from this file or another, is not counted again. */
50
+ add(records) {
51
+ for (const r of records ?? []) {
52
+ const u = usageOfRecord(r);
53
+ if (!u) continue;
54
+ if (u.id) { if (this.seen.has(u.id)) continue; this.seen.add(u.id); }
55
+ const total = u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead;
56
+ if (!total) continue; // a record the harness made up for itself carries no tokens
57
+ this.responses += 1;
58
+ this.tokens.input += u.input;
59
+ this.tokens.output += u.output;
60
+ this.tokens.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
61
+ this.tokens.cacheRead += u.cacheRead;
62
+ const usd = costOf(u);
63
+ if (usd === null) this.unpriced.add(u.model ?? 'unknown model');
64
+ else { this.usd += usd; this.priced.add(u.model); }
65
+ }
66
+ return this;
67
+ }
68
+
69
+ /**
70
+ * @returns tokens by kind and in total; `cost` in USD at list price, or null when any of the tokens came from
71
+ * a model the table has no row for — a sum that leaves some of them out would read as the whole.
72
+ */
73
+ result() {
74
+ const t = this.tokens;
75
+ return {
76
+ tokens: { ...t, total: t.input + t.output + t.cacheWrite + t.cacheRead },
77
+ responses: this.responses,
78
+ cost: this.unpriced.size ? null : this.usd,
79
+ models: [...this.priced].sort(),
80
+ unpriced: [...this.unpriced].sort(),
81
+ priceSource: SOURCE,
82
+ };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * How long a session took, two ways. `durationMs` is from its first record to its last, pauses included.
88
+ * `workedMs` is the turns Claude Code timed itself (`system` / `turn_duration` records), added up; zero when it
89
+ * recorded none, and a turn that is still running is not in it.
90
+ */
91
+ export function timesOf(records) {
92
+ let first = null;
93
+ let last = null;
94
+ let workedMs = 0;
95
+ let turns = 0;
96
+ for (const r of records ?? []) {
97
+ const t = r?.timestamp ? Date.parse(r.timestamp) : NaN;
98
+ if (Number.isFinite(t)) {
99
+ if (first === null || t < first) first = t;
100
+ if (last === null || t > last) last = t;
101
+ }
102
+ if (r?.type === 'system' && r.subtype === 'turn_duration' && typeof r.durationMs === 'number' && r.durationMs > 0) {
103
+ workedMs += r.durationMs;
104
+ turns += 1;
105
+ }
106
+ }
107
+ return { durationMs: first !== null && last !== null ? last - first : null, workedMs, turns };
108
+ }
109
+
110
+ /** A session's usage with its agents', and how long it took. */
111
+ export async function sessionUsage(session) {
112
+ const usage = new Usage();
113
+ const { records } = await readAll(session.file);
114
+ usage.add(records);
115
+ for (const meta of await agentMetaFiles(session.subagentsDir)) {
116
+ const jsonl = meta.replace(/\.meta\.json$/, '.jsonl');
117
+ try { usage.add((await readAll(jsonl)).records); } catch { /* an agent that has not written yet */ }
118
+ }
119
+ return { ...usage.result(), ...timesOf(records) };
120
+ }
121
+
122
+ // One answer per session, kept until one of its files changes. The key is what the stream's own signature
123
+ // watches: the transcript's size, and every agent file's.
124
+ const cache = new Map(); // sessionId -> { sig, value }
125
+
126
+ async function sigOf(session) {
127
+ const parts = [];
128
+ try { parts.push(String((await fsp.stat(session.file)).size)); } catch { parts.push('0'); }
129
+ for (const meta of await agentMetaFiles(session.subagentsDir)) {
130
+ const jsonl = meta.replace(/\.meta\.json$/, '.jsonl');
131
+ try { parts.push(`${path.basename(jsonl)}:${(await fsp.stat(jsonl)).size}`); } catch { /* not written yet */ }
132
+ }
133
+ return parts.join('|');
134
+ }
135
+
136
+ export async function cachedUsage(session) {
137
+ const sig = await sigOf(session);
138
+ const hit = cache.get(session.sessionId);
139
+ if (hit && hit.sig === sig) return hit.value;
140
+ const value = await sessionUsage(session);
141
+ if (cache.size > 500) cache.clear();
142
+ cache.set(session.sessionId, { sig, value });
143
+ return value;
144
+ }