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.
- package/CHANGELOG.md +50 -2
- package/kit/agents/crew-commit-agent.md +5 -2
- package/kit/eval/doctor.sh +26 -0
- package/kit/eval/smoke-test.sh +98 -2
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-bash.sh +36 -5
- package/kit/skills/frontend-flutter/SKILL.md +39 -9
- package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +29 -3
- package/kit/skills/frontend-flutter/references/platform-async-release.md +41 -0
- package/kit/skills/frontend-flutter/references/sensitive-data.md +54 -0
- package/kit/studio/server/index.js +18 -0
- package/kit/studio/server/lib/graph.js +110 -11
- package/kit/studio/server/lib/permissions.js +11 -3
- package/kit/studio/server/lib/pricing.js +63 -0
- package/kit/studio/server/lib/usage.js +144 -0
- package/kit/studio/web/app.js +158 -37
- package/kit/studio/web/approvals.js +20 -1
- package/kit/studio/web/canvas.js +95 -20
- package/kit/studio/web/chat.js +1 -0
- package/kit/studio/web/convo.js +4 -2
- package/kit/studio/web/dock.js +1 -0
- package/kit/studio/web/graph-plan.js +67 -12
- package/kit/studio/web/index.html +1 -0
- package/kit/studio/web/inspect.js +14 -1
- package/kit/studio/web/list-plan.js +4 -4
- package/kit/studio/web/list.js +2 -0
- package/kit/studio/web/names.js +33 -0
- package/kit/studio/web/style.css +48 -10
- package/kit/studio/web/timeline-plan.js +5 -5
- package/kit/studio/web/timeline.js +10 -4
- package/kit/studio/web/usage.js +77 -0
- package/package.json +1 -1
|
@@ -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(), //
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|