crewforth 3.1.1 → 3.2.0-rc.10
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 +323 -0
- package/README.md +6 -3
- package/VERSION +1 -1
- package/adopt.sh +66 -6
- package/bin/cli.js +3 -2
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +15 -14
- package/kit/agents/crew-commit-agent.md +11 -4
- package/kit/agents/crew-docs-agent.md +71 -0
- package/kit/agents/crew-planner.md +2 -0
- package/kit/agents/crew-refuter.md +77 -0
- package/kit/agents/crew-review-agent.md +2 -0
- package/kit/agents/crew-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +128 -6
- package/kit/eval/gate-report.sh +2 -0
- package/kit/eval/golden-routing.txt +9 -0
- package/kit/eval/lib/crew-map/crew-map.sh +26 -0
- package/kit/eval/lib/crew-map/detect.mjs +238 -0
- package/kit/eval/lib/crew-map/narrative.mjs +132 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +164 -0
- package/kit/eval/lib/crew-map/rules/names.json +38 -0
- package/kit/eval/lib/crew-map/rules/packages.json +142 -0
- package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
- package/kit/eval/lib/crew-map/rules/tech.json +35 -0
- package/kit/eval/lib/crew-map/scan.mjs +266 -0
- package/kit/eval/lib/crew-map/stacks.mjs +547 -0
- package/kit/eval/lib/crew-map/surface.mjs +535 -0
- package/kit/eval/lib/findings/findings.mjs +410 -0
- package/kit/eval/lib/findings/findings.schema.json +122 -0
- package/kit/eval/lib/findings/findings.sh +29 -0
- package/kit/eval/lib/gate-log.sh +52 -0
- package/kit/eval/lib/json-string.awk +27 -0
- package/kit/eval/lib/preset.sh +109 -0
- package/kit/eval/lib/presets/default.json +7 -0
- package/kit/eval/lib/presets/solo.json +7 -0
- package/kit/eval/lib/presets/strict.json +11 -0
- package/kit/eval/lib/preview.sh +150 -0
- package/kit/eval/lib/review-bench/bench.mjs +273 -0
- package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
- package/kit/eval/lib/review-prepass/prepass.sh +23 -0
- package/kit/eval/lib/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +19 -4
- package/kit/eval/lib/star.sh +23 -7
- package/kit/eval/lib/statusline.settings.json +6 -0
- package/kit/eval/lib/statusline.sh +100 -0
- package/kit/eval/lib/verify-cmd.sh +17 -0
- package/kit/eval/lib/version.sh +41 -0
- package/kit/eval/review-bench.sh +26 -0
- package/kit/eval/smoke-test.sh +878 -53
- package/kit/hooks/agent-outcome.sh +44 -12
- package/kit/hooks/board-sync.sh +2 -23
- package/kit/hooks/guard-agent-model.sh +177 -24
- package/kit/hooks/guard-bash.sh +295 -204
- package/kit/hooks/guard-commit-scan.sh +10 -2
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +24 -7
- package/kit/hooks/precompact-snapshot.sh +75 -0
- package/kit/hooks/prompt-approval.sh +389 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/hooks/session-rehydrate.sh +25 -2
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/hooks/trace-blocklist.txt +9 -0
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +51 -0
- package/kit/skills/crew-approve/SKILL.md +7 -0
- package/kit/skills/crew-code-review/SKILL.md +16 -0
- package/kit/skills/crew-doctor/SKILL.md +5 -1
- package/kit/skills/crew-handoff/SKILL.md +3 -2
- package/kit/skills/crew-map/SKILL.md +55 -0
- package/kit/skills/crew-review/SKILL.md +4 -1
- package/kit/skills/crew-ship/SKILL.md +3 -1
- package/kit/skills/crew-update/SKILL.md +1 -1
- package/kit/skills/incident-runbook/SKILL.md +2 -2
- package/kit/skills/security-scan/references/verify.md +6 -3
- package/kit/studio/README.md +25 -2
- package/kit/studio/server/index.js +187 -21
- package/kit/studio/server/lib/code-stamp.js +35 -0
- package/kit/studio/server/lib/crew-map.js +113 -0
- package/kit/studio/server/lib/graph.js +9 -1
- package/kit/studio/server/lib/kit-telemetry.js +30 -5
- package/kit/studio/server/lib/lan.js +87 -0
- package/kit/studio/server/lib/model-outcomes.js +130 -15
- package/kit/studio/server/lib/owned-stream.js +56 -0
- package/kit/studio/server/lib/pricing.js +3 -0
- package/kit/studio/server/lib/projects.js +11 -4
- package/kit/studio/server/lib/redact.js +60 -0
- package/kit/studio/server/lib/remote.js +185 -0
- package/kit/studio/server/lib/session.js +1 -1
- package/kit/studio/server/lib/terminal.js +51 -13
- package/kit/studio/server/lib/transcript.js +32 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +287 -17
- package/kit/studio/web/canvas.js +58 -122
- package/kit/studio/web/chat.js +114 -24
- package/kit/studio/web/graph-plan.js +13 -6
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +410 -0
- package/kit/studio/web/map.js +596 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/qr.js +200 -0
- package/kit/studio/web/style.css +165 -0
- package/kit/studio/web/tokens.css +6 -0
- package/kit/studio/web/usage.js +12 -0
- package/kit/studio/web/viewport.js +160 -0
- package/package.json +6 -5
- package/start.sh +24 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// remote.js — read-only viewers.
|
|
2
|
+
//
|
|
3
|
+
// The panel's own token can start a session and answer a tool call. Handing that token to a phone hands the phone
|
|
4
|
+
// all of it. A viewer token is the smaller thing: it reads what the panel reads and can change nothing. The server
|
|
5
|
+
// decides that, not the page — a viewer's request is refused here before any handler sees it.
|
|
6
|
+
//
|
|
7
|
+
// THE WAY IN FROM ANOTHER DEVICE IS THE SAME WI-FI. While at least one viewer is live, the server also listens on
|
|
8
|
+
// this machine's Wi-Fi address (index.js), and there it answers a viewer's token and nothing else: the panel's own
|
|
9
|
+
// token is not accepted from the network at all. With no viewer, nothing listens beyond 127.0.0.1.
|
|
10
|
+
//
|
|
11
|
+
// A viewer lasts EIGHT HOURS, or until it is revoked, or until the server stops — whichever comes first. Kept in
|
|
12
|
+
// memory, like the owner's token: a link that survived a restart would be a credential nobody remembers handing out.
|
|
13
|
+
// An expired token is remembered as expired (not as unknown) so the phone can say "Session expired" rather than
|
|
14
|
+
// "no access", and only for as long as the server runs.
|
|
15
|
+
//
|
|
16
|
+
// TRANSCRIPTS ARE HIDDEN by default: a viewer made without `transcripts` is answered with no text that came from a
|
|
17
|
+
// transcript (lib/redact.js does that on the server; the page hiding it would not be enough).
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
export const MAX_VIEWERS = 8;
|
|
21
|
+
export const VIEWER_TTL_MS = 8 * 60 * 60 * 1000;
|
|
22
|
+
const LABEL_MAX = 40;
|
|
23
|
+
const LOG_MAX = 200;
|
|
24
|
+
|
|
25
|
+
const viewers = new Map(); // id -> viewer
|
|
26
|
+
const byToken = new Map(); // token -> viewer
|
|
27
|
+
const expired = new Map(); // token -> label, for "Session expired"
|
|
28
|
+
const log = []; // what happened to viewers' links, newest last
|
|
29
|
+
|
|
30
|
+
let clock = () => Date.now();
|
|
31
|
+
let onChange = () => {};
|
|
32
|
+
let note = () => {};
|
|
33
|
+
|
|
34
|
+
/** For the self-check: a clock it can move (eight hours are not waited for). */
|
|
35
|
+
export function _setClock(fn) { clock = fn ?? (() => Date.now()); }
|
|
36
|
+
/** index.js opens and closes the Wi-Fi listener when the number of live viewers changes. */
|
|
37
|
+
export function onViewersChange(fn) { onChange = fn ?? (() => {}); }
|
|
38
|
+
/** Where a log line also goes (the server's own output). */
|
|
39
|
+
export function onLog(fn) { note = fn ?? (() => {}); }
|
|
40
|
+
|
|
41
|
+
function record(entry) {
|
|
42
|
+
const e = { at: clock(), ...entry };
|
|
43
|
+
log.push(e);
|
|
44
|
+
if (log.length > LOG_MAX) log.splice(0, log.length - LOG_MAX);
|
|
45
|
+
note(e);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const publicOf = (v) => ({
|
|
49
|
+
id: v.id,
|
|
50
|
+
label: v.label,
|
|
51
|
+
createdAt: v.createdAt,
|
|
52
|
+
expiresAt: v.expiresAt,
|
|
53
|
+
transcripts: v.transcripts,
|
|
54
|
+
lastSeenAt: v.lastSeenAt,
|
|
55
|
+
requests: v.requests,
|
|
56
|
+
agent: v.agent,
|
|
57
|
+
open: v.conns.size,
|
|
58
|
+
addresses: [...v.addresses],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/** Make a viewer. The token is in this answer and in no later one. */
|
|
62
|
+
export function addViewer(label, { transcripts = false } = {}) {
|
|
63
|
+
sweep();
|
|
64
|
+
if (viewers.size >= MAX_VIEWERS) {
|
|
65
|
+
return { ok: false, reason: `${MAX_VIEWERS} viewers already; revoke one first` };
|
|
66
|
+
}
|
|
67
|
+
const named = typeof label === 'string' ? label.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX) : '';
|
|
68
|
+
const now = clock();
|
|
69
|
+
const v = {
|
|
70
|
+
id: randomUUID().slice(0, 8),
|
|
71
|
+
label: named || `Viewer ${viewers.size + 1}`,
|
|
72
|
+
token: randomUUID(),
|
|
73
|
+
createdAt: now,
|
|
74
|
+
expiresAt: now + VIEWER_TTL_MS,
|
|
75
|
+
transcripts: transcripts === true,
|
|
76
|
+
lastSeenAt: null,
|
|
77
|
+
requests: 0,
|
|
78
|
+
agent: null,
|
|
79
|
+
conns: new Set(),
|
|
80
|
+
addresses: new Set(),
|
|
81
|
+
};
|
|
82
|
+
viewers.set(v.id, v);
|
|
83
|
+
byToken.set(v.token, v);
|
|
84
|
+
record({ event: 'added', viewer: v.label, transcripts: v.transcripts });
|
|
85
|
+
onChange(viewers.size);
|
|
86
|
+
return { ok: true, viewer: { ...publicOf(v), token: v.token } };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function listViewers() {
|
|
90
|
+
sweep();
|
|
91
|
+
return [...viewers.values()].map(publicOf);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function liveCount() {
|
|
95
|
+
sweep();
|
|
96
|
+
return viewers.size;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function remoteLog(n = 50) {
|
|
100
|
+
return log.slice(-n);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The viewer this token belongs to, if it is live; `{ expired: label }` if it was one and its eight hours are up. */
|
|
104
|
+
export function viewerByToken(token) {
|
|
105
|
+
if (typeof token !== 'string') return null;
|
|
106
|
+
const v = byToken.get(token);
|
|
107
|
+
if (v && clock() >= v.expiresAt) { end(v, 'expired'); return { expired: v.label }; }
|
|
108
|
+
if (v) return v;
|
|
109
|
+
if (expired.has(token)) return { expired: expired.get(token) };
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function end(v, why) {
|
|
114
|
+
viewers.delete(v.id);
|
|
115
|
+
byToken.delete(v.token);
|
|
116
|
+
if (why === 'expired') expired.set(v.token, v.label);
|
|
117
|
+
for (const res of v.conns) { try { res.destroy(); } catch { /* already gone */ } }
|
|
118
|
+
v.conns.clear();
|
|
119
|
+
record({ event: why, viewer: v.label });
|
|
120
|
+
onChange(viewers.size);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** End every viewer whose eight hours are up. Called on every read of the list and by index.js's timer. */
|
|
124
|
+
export function sweep() {
|
|
125
|
+
const now = clock();
|
|
126
|
+
for (const v of [...viewers.values()]) if (now >= v.expiresAt) end(v, 'expired');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Take a viewer's access back. Its token stops working with this call, and a stream it holds open is closed:
|
|
131
|
+
* a revoke that left the live graph running on the phone would be a revoke in name only.
|
|
132
|
+
*/
|
|
133
|
+
export function revokeViewer(id) {
|
|
134
|
+
const v = viewers.get(id);
|
|
135
|
+
if (!v) return { ok: true, revoked: false };
|
|
136
|
+
end(v, 'revoked');
|
|
137
|
+
return { ok: true, revoked: true };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Count a viewer's request and remember its response for as long as it is open. A new address is logged once. */
|
|
141
|
+
export function noteRequest(v, req, res) {
|
|
142
|
+
v.requests += 1;
|
|
143
|
+
v.lastSeenAt = clock();
|
|
144
|
+
const ua = req.headers['user-agent'];
|
|
145
|
+
v.agent = typeof ua === 'string' ? ua.slice(0, 160) : null;
|
|
146
|
+
const ip = req.socket?.remoteAddress ?? null;
|
|
147
|
+
if (ip && !v.addresses.has(ip)) {
|
|
148
|
+
v.addresses.add(ip);
|
|
149
|
+
record({ event: 'connected', viewer: v.label, ip, over: req.crewLan ? 'wifi' : 'loopback', result: 'ok' });
|
|
150
|
+
}
|
|
151
|
+
v.conns.add(res);
|
|
152
|
+
res.on('close', () => v.conns.delete(res));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A request that was turned away: logged with who it claimed to be, from where, and why. The same refusal again
|
|
157
|
+
* (same viewer, address, path and answer) is counted on its entry rather than written again, so a page that keeps
|
|
158
|
+
* asking cannot push everything else out of the log.
|
|
159
|
+
*/
|
|
160
|
+
export function noteRefusal(req, { viewer = null, status, reason }) {
|
|
161
|
+
const e = { event: 'refused', viewer, ip: req.socket?.remoteAddress ?? null, over: req.crewLan ? 'wifi' : 'loopback',
|
|
162
|
+
result: `${status} ${reason}`, path: String(req.url ?? '').split('?')[0].slice(0, 120) };
|
|
163
|
+
const same = log.findLast((x) => x.event === 'refused' && x.viewer === e.viewer && x.ip === e.ip && x.path === e.path && x.result === e.result);
|
|
164
|
+
if (same) { same.count = (same.count ?? 1) + 1; same.lastAt = clock(); return; }
|
|
165
|
+
record(e);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* What a viewer may ask for: a GET of anything the panel reads, and nothing about viewers themselves.
|
|
170
|
+
* The list of viewers is the owner's; a viewer that could read it could count who else is watching.
|
|
171
|
+
*/
|
|
172
|
+
export function viewerMay(method, pathname) {
|
|
173
|
+
if (method !== 'GET') return { ok: false, reason: 'read-only token: GET only' };
|
|
174
|
+
if (pathname === '/api/remote' || pathname.startsWith('/api/remote/')) {
|
|
175
|
+
return { ok: false, reason: 'read-only token: viewers are managed from the machine itself' };
|
|
176
|
+
}
|
|
177
|
+
return { ok: true };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** For the self-check: start from nothing. */
|
|
181
|
+
export function _reset() {
|
|
182
|
+
for (const v of [...viewers.values()]) end(v, 'revoked');
|
|
183
|
+
expired.clear();
|
|
184
|
+
log.length = 0;
|
|
185
|
+
}
|
|
@@ -290,7 +290,7 @@ class OwnedSession {
|
|
|
290
290
|
* answer is "yes": an unreadable fleet must not be the reason two processes
|
|
291
291
|
* end up writing one transcript.
|
|
292
292
|
*/
|
|
293
|
-
async function isHeldOpen(id) {
|
|
293
|
+
export async function isHeldOpen(id) {
|
|
294
294
|
try {
|
|
295
295
|
const fleet = await getFleet();
|
|
296
296
|
const rows = fleet?.sessions ?? fleet ?? [];
|
|
@@ -17,21 +17,60 @@ function q(s) {
|
|
|
17
17
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** PowerShell literal string: a quote is doubled, nothing else is special inside one. */
|
|
21
|
+
function psq(s) {
|
|
22
|
+
return `'${String(s).replace(/'/g, "''")}'`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A session id is a UUID. It comes from the URL, and on Windows it ends up in a line cmd.exe interprets, where a
|
|
27
|
+
* quote is not a quote (cmd hands `'` to the program as part of the id) and `&` starts a second command. So
|
|
28
|
+
* nothing is built for anything else, and an id that passes needs no quoting in any shell.
|
|
29
|
+
*/
|
|
30
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
31
|
+
export function isSessionId(s) {
|
|
32
|
+
return typeof s === 'string' && SESSION_ID.test(s);
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/** AppleScript string literal: backslashes and quotes, nothing else. */
|
|
21
36
|
function osaQuote(s) {
|
|
22
37
|
return `"${String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
23
38
|
}
|
|
24
39
|
|
|
40
|
+
/**
|
|
41
|
+
* The command that opens the session in the Claude Desktop app again (`claude --desktop`, CLI 2.1.299), from its
|
|
42
|
+
* own directory: `--resume <id>` looks for the session among that directory's. On Windows it is a PowerShell line
|
|
43
|
+
* (5.1 and 7; Windows Terminal opens PowerShell): cmd needs `&&` and does not take single quotes, PowerShell 5.1
|
|
44
|
+
* rejects `&&`, and no one line works in both. `shell` says which, so the panel can say so.
|
|
45
|
+
*/
|
|
46
|
+
export function desktopLine({ cwd, sessionId, platform = process.platform }) {
|
|
47
|
+
if (!isSessionId(sessionId)) return null;
|
|
48
|
+
if (platform === 'win32') {
|
|
49
|
+
return { shell: 'PowerShell', line: `Set-Location -LiteralPath ${psq(cwd)}; claude --desktop --resume ${sessionId}` };
|
|
50
|
+
}
|
|
51
|
+
return { shell: 'sh', line: `cd ${q(cwd)} && claude --desktop --resume ${q(sessionId)}` };
|
|
52
|
+
}
|
|
53
|
+
|
|
25
54
|
/**
|
|
26
55
|
* Build the launch for this platform.
|
|
27
56
|
* Returns null when nothing here knows how to open a terminal, so the caller
|
|
28
57
|
* can say so rather than fail silently.
|
|
29
58
|
*/
|
|
30
|
-
export function plan({ cwd, sessionId,
|
|
31
|
-
|
|
59
|
+
export function plan({ cwd, sessionId, platform = process.platform }) {
|
|
60
|
+
if (!isSessionId(sessionId)) return null;
|
|
61
|
+
|
|
62
|
+
if (platform === 'win32') {
|
|
63
|
+
// Windows Terminal when present, the console host otherwise. wt sets the directory itself; what runs inside is
|
|
64
|
+
// cmd.exe, so the line shown is a cmd line: double quotes, `cd /d` for another drive, and the id bare (a UUID).
|
|
65
|
+
const inner = `claude --resume ${sessionId}`;
|
|
66
|
+
const wt = ['wt.exe', '-d', cwd, 'cmd.exe', '/k', inner];
|
|
67
|
+
return { platform: 'win32', via: 'wt.exe', shell: 'cmd', line: `cd /d "${cwd}" && ${inner}`, argv: wt };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const inner = `claude --resume ${q(sessionId)}`;
|
|
32
71
|
const line = `cd ${q(cwd)} && ${inner}`;
|
|
33
72
|
|
|
34
|
-
if (
|
|
73
|
+
if (platform === 'darwin') {
|
|
35
74
|
return {
|
|
36
75
|
platform: 'darwin',
|
|
37
76
|
via: 'Terminal.app',
|
|
@@ -41,12 +80,6 @@ export function plan({ cwd, sessionId, command }) {
|
|
|
41
80
|
};
|
|
42
81
|
}
|
|
43
82
|
|
|
44
|
-
if (process.platform === 'win32') {
|
|
45
|
-
// Windows Terminal when present, the console host otherwise.
|
|
46
|
-
const wt = ['wt.exe', '-d', cwd, 'cmd.exe', '/k', inner];
|
|
47
|
-
return { platform: 'win32', via: 'wt.exe', line, argv: wt };
|
|
48
|
-
}
|
|
49
|
-
|
|
50
83
|
// Linux and the rest: whichever emulator is actually installed. Names are
|
|
51
84
|
// tried by running them, not by resolving them — a name on PATH that fails
|
|
52
85
|
// to start is the failure mode this ordering exists for.
|
|
@@ -57,14 +90,19 @@ export function plan({ cwd, sessionId, command }) {
|
|
|
57
90
|
['xfce4-terminal', ['--working-directory', cwd, '-e', `bash -lc ${q(line)}`]],
|
|
58
91
|
['xterm', ['-e', 'bash', '-lc', line]],
|
|
59
92
|
];
|
|
60
|
-
return { platform
|
|
93
|
+
return { platform, via: 'candidates', line, candidates };
|
|
61
94
|
}
|
|
62
95
|
|
|
63
|
-
/**
|
|
64
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Launch it, detached, and report what was run. What runs is always `claude --resume <id>`: the route used to take a
|
|
98
|
+
* `command` from the request body and run it in place of that, with no id check, and the panel never sent one. An
|
|
99
|
+
* unused way to run anything in a terminal, behind a token that remote access puts on the network, was removed.
|
|
100
|
+
*/
|
|
101
|
+
export function open({ cwd, sessionId }) {
|
|
65
102
|
if (!cwd || !fs.existsSync(cwd)) return { ok: false, reason: `no such directory: ${cwd}` };
|
|
66
103
|
|
|
67
|
-
|
|
104
|
+
if (!isSessionId(sessionId)) return { ok: false, reason: `not a session id: ${sessionId}` };
|
|
105
|
+
const p = plan({ cwd, sessionId });
|
|
68
106
|
if (!p) return { ok: false, reason: `no terminal launcher known for ${process.platform}` };
|
|
69
107
|
|
|
70
108
|
const attempts = p.argv ? [p.argv] : p.candidates.map(([bin, args]) => [bin, ...args]);
|
|
@@ -98,3 +98,35 @@ export function contextFill(records) {
|
|
|
98
98
|
}
|
|
99
99
|
return total;
|
|
100
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What the context is made of since the last compaction: the share of its text that is tool output, and how many
|
|
104
|
+
* times the session was compacted, with the last one's tokens before and after. Measured in characters of the main
|
|
105
|
+
* session's records (text, a tool call's input, a tool's result), not tokens: the transcript keeps no per-block
|
|
106
|
+
* token count. A compaction is the transcript's own `compact_boundary` record.
|
|
107
|
+
*/
|
|
108
|
+
export function contextMix(records) {
|
|
109
|
+
let start = 0; let compactions = 0; let last = null;
|
|
110
|
+
records.forEach((r, i) => {
|
|
111
|
+
if (r?.isSidechain !== true && r?.type === 'system' && r?.subtype === 'compact_boundary') { start = i + 1; compactions += 1; last = r.compactMetadata ?? null; }
|
|
112
|
+
});
|
|
113
|
+
const size = (b) => {
|
|
114
|
+
if (typeof b === 'string') return b.length;
|
|
115
|
+
if (b?.type === 'text') return String(b.text ?? '').length;
|
|
116
|
+
if (b?.type === 'tool_use') return JSON.stringify(b.input ?? {}).length;
|
|
117
|
+
if (b?.type === 'tool_result') return typeof b.content === 'string' ? b.content.length : (Array.isArray(b.content) ? b.content.reduce((n, x) => n + (x?.type === 'text' ? String(x.text ?? '').length : 0), 0) : 0);
|
|
118
|
+
return 0;
|
|
119
|
+
};
|
|
120
|
+
let tool = 0; let all = 0;
|
|
121
|
+
for (let i = start; i < records.length; i += 1) {
|
|
122
|
+
const r = records[i];
|
|
123
|
+
if (r?.isSidechain === true || (r?.type !== 'user' && r?.type !== 'assistant')) continue;
|
|
124
|
+
const c = r?.message?.content;
|
|
125
|
+
for (const b of Array.isArray(c) ? c : [c]) { const n = size(b); all += n; if (b?.type === 'tool_result') tool += n; }
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
toolShare: all ? Math.round((100 * tool) / all) : null,
|
|
129
|
+
compactions,
|
|
130
|
+
lastCompact: last ? { trigger: last.trigger ?? null, before: last.preTokens ?? null, after: last.postTokens ?? null } : null,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -4,25 +4,41 @@
|
|
|
4
4
|
// usage. Measured on one machine before this was written: 23,867 assistant records carried usage, for 11,245
|
|
5
5
|
// responses, and every repeat carried the same numbers. So a response is counted once, by its message id — and
|
|
6
6
|
// once across the session's files too, which is what keeps an agent's tokens from being added twice.
|
|
7
|
+
//
|
|
8
|
+
// An advisor call (the advisor tool: the model consults a second one mid-response) is not in that usage. The API
|
|
9
|
+
// keeps it out of the top-level fields, which are the executor's, and lists it in `usage.iterations[]` as an
|
|
10
|
+
// `advisor_message` entry with its own `model`. It is billed at that model's rates, so it is priced on its own.
|
|
11
|
+
// Measured on a real advisor session: the entry is written to the transcript, and repeated, like the rest of the
|
|
12
|
+
// usage, on every record of the response; the message id above counts it once.
|
|
7
13
|
|
|
8
14
|
import fsp from 'node:fs/promises';
|
|
9
15
|
import path from 'node:path';
|
|
10
16
|
|
|
11
17
|
import { readAll } from './transcript.js';
|
|
12
18
|
import { agentMetaFiles } from './projects.js';
|
|
13
|
-
import { costOf, SOURCE } from './pricing.js';
|
|
19
|
+
import { costOf, SOURCE, NORMAL_TIER_MODEL } from './pricing.js';
|
|
14
20
|
|
|
15
21
|
const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
|
|
16
22
|
|
|
23
|
+
/**
|
|
24
|
+
* A usage's cache writes by kind. A write the split does not account for is the 5-minute kind: that is the default,
|
|
25
|
+
* and the cheaper of the two. The split can fall short of the total: measured on a real response, the split said
|
|
26
|
+
* 12,400 one-hour and 0 five-minute against a total of 12,670, and the 270 missing were billed as five-minute
|
|
27
|
+
* writes (its iterations listed them so, and Claude Code's own cost for the session matched only with them in).
|
|
28
|
+
*/
|
|
29
|
+
function writesOf(u) {
|
|
30
|
+
const split = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null;
|
|
31
|
+
const w1h = num(split?.ephemeral_1h_input_tokens);
|
|
32
|
+
const w5m = num(split?.ephemeral_5m_input_tokens);
|
|
33
|
+
return { cacheWrite5m: w5m + Math.max(0, num(u.cache_creation_input_tokens) - w5m - w1h), cacheWrite1h: w1h };
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
/** One response's usage, in the shape pricing.js prices. Null when the record carries none. */
|
|
18
37
|
export function usageOfRecord(r) {
|
|
19
38
|
const m = r?.type === 'assistant' ? r.message : null;
|
|
20
39
|
const u = m?.usage;
|
|
21
40
|
if (!u || typeof u !== 'object') return null;
|
|
22
|
-
const
|
|
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);
|
|
41
|
+
const { cacheWrite5m: w5m, cacheWrite1h: w1h } = writesOf(u);
|
|
26
42
|
return {
|
|
27
43
|
id: typeof m.id === 'string' ? m.id : null,
|
|
28
44
|
model: typeof m.model === 'string' ? m.model : null,
|
|
@@ -33,9 +49,28 @@ export function usageOfRecord(r) {
|
|
|
33
49
|
cacheRead: num(u.cache_read_input_tokens),
|
|
34
50
|
fast: u.speed === 'fast',
|
|
35
51
|
usOnly: u.inference_geo === 'us',
|
|
52
|
+
advisor: advisorCallsOf(u),
|
|
36
53
|
};
|
|
37
54
|
}
|
|
38
55
|
|
|
56
|
+
/** The advisor calls one response's usage lists, each in the shape pricing.js prices: [] when it made none. */
|
|
57
|
+
export function advisorCallsOf(u) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const it of Array.isArray(u?.iterations) ? u.iterations : []) {
|
|
60
|
+
if (it?.type !== 'advisor_message') continue;
|
|
61
|
+
out.push({
|
|
62
|
+
model: typeof it.model === 'string' ? it.model : null,
|
|
63
|
+
input: num(it.input_tokens),
|
|
64
|
+
output: num(it.output_tokens),
|
|
65
|
+
...writesOf(it),
|
|
66
|
+
cacheRead: num(it.cache_read_input_tokens),
|
|
67
|
+
fast: false,
|
|
68
|
+
usOnly: u.inference_geo === 'us',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
39
74
|
const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
|
|
40
75
|
|
|
41
76
|
/** One running sum: tokens by kind, what they cost, and which models had no price. */
|
|
@@ -43,25 +78,29 @@ class Tally {
|
|
|
43
78
|
constructor() {
|
|
44
79
|
this.tokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
|
|
45
80
|
this.usd = 0;
|
|
81
|
+
this.usdAtNormal = 0; // the same tokens at NORMAL_TIER_MODEL's price
|
|
46
82
|
this.responses = 0;
|
|
47
83
|
this.unpriced = new Set();
|
|
48
84
|
}
|
|
49
85
|
|
|
50
|
-
|
|
51
|
-
|
|
86
|
+
/** @param response false for an advisor call: its tokens and cost are added, but it is part of a response, not one. */
|
|
87
|
+
take(u, usd, atNormal = null, response = true) {
|
|
88
|
+
if (response) this.responses += 1;
|
|
52
89
|
this.tokens.input += u.input;
|
|
53
90
|
this.tokens.output += u.output;
|
|
54
91
|
this.tokens.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
|
|
55
92
|
this.tokens.cacheRead += u.cacheRead;
|
|
56
93
|
if (usd === null) this.unpriced.add(u.model ?? 'unknown model');
|
|
57
94
|
else this.usd += usd;
|
|
95
|
+
if (atNormal !== null) this.usdAtNormal += atNormal;
|
|
58
96
|
}
|
|
59
97
|
|
|
60
98
|
/**
|
|
61
99
|
* `fresh` is what was sent and written for the first time: input, output and cache writes. `cacheRead` is the
|
|
62
100
|
* same context read back from the cache on every later response, which is why it dwarfs the rest in a long
|
|
63
101
|
* session and is kept out of the headline. `cost` is null when any of the tokens came from a model the price
|
|
64
|
-
* table has no row for — a sum that leaves some of them out would read as the whole.
|
|
102
|
+
* table has no row for — a sum that leaves some of them out would read as the whole. `costAtNormal` is the same
|
|
103
|
+
* tokens at NORMAL_TIER_MODEL's list price, null under the same rule.
|
|
65
104
|
*/
|
|
66
105
|
result() {
|
|
67
106
|
const t = this.tokens;
|
|
@@ -69,6 +108,7 @@ class Tally {
|
|
|
69
108
|
tokens: { ...t, fresh: t.input + t.output + t.cacheWrite, total: KINDS.reduce((a, k) => a + t[k], 0) },
|
|
70
109
|
responses: this.responses,
|
|
71
110
|
cost: this.unpriced.size ? null : this.usd,
|
|
111
|
+
costAtNormal: this.unpriced.size ? null : this.usdAtNormal,
|
|
72
112
|
unpriced: [...this.unpriced].sort(),
|
|
73
113
|
};
|
|
74
114
|
}
|
|
@@ -80,6 +120,9 @@ export class Usage {
|
|
|
80
120
|
this.all = new Tally();
|
|
81
121
|
this.who = new Map(); // 'session', or an agent's id -> Tally
|
|
82
122
|
this.model = new Map(); // model id -> Tally
|
|
123
|
+
this.advisor = new Tally(); // the advisor calls alone, one take per call
|
|
124
|
+
this.advisorModels = new Map(); // advisor model id -> Tally
|
|
125
|
+
this.advisorWho = new Map(); // 'session' or an agent's id -> Tally of its advisor calls alone
|
|
83
126
|
this.first = null; // the earliest and the latest record of any file added
|
|
84
127
|
this.last = null;
|
|
85
128
|
}
|
|
@@ -104,11 +147,22 @@ export class Usage {
|
|
|
104
147
|
const u = usageOfRecord(r);
|
|
105
148
|
if (!u) continue;
|
|
106
149
|
if (u.id) { if (this.seen.has(u.id)) continue; this.seen.add(u.id); }
|
|
107
|
-
if (!(u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead)) continue; // a record the harness made up carries no tokens
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
150
|
+
if (!(u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead) && !u.advisor.length) continue; // a record the harness made up carries no tokens
|
|
151
|
+
const mine = this.#tally(this.who, who === 'session' && r.isSidechain === true ? 'agents:inline' : who);
|
|
152
|
+
// The response, then each advisor call it made, priced at the advisor's own model.
|
|
153
|
+
for (const x of [u, ...u.advisor]) {
|
|
154
|
+
const usd = costOf(x);
|
|
155
|
+
const atNormal = costOf({ ...x, model: NORMAL_TIER_MODEL });
|
|
156
|
+
const own = x === u;
|
|
157
|
+
this.all.take(x, usd, atNormal, own);
|
|
158
|
+
mine.take(x, usd, atNormal, own);
|
|
159
|
+
this.#tally(this.model, x.model ?? 'unknown model').take(x, usd, atNormal, own);
|
|
160
|
+
if (!own) { // here each call is one
|
|
161
|
+
this.advisor.take(x, usd, atNormal);
|
|
162
|
+
this.#tally(this.advisorWho, who === 'session' && r.isSidechain === true ? 'agents:inline' : who).take(x, usd, atNormal);
|
|
163
|
+
this.#tally(this.advisorModels, x.model ?? 'unknown model').take(x, usd, atNormal);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
112
166
|
}
|
|
113
167
|
return this;
|
|
114
168
|
}
|
|
@@ -134,6 +188,9 @@ export class Usage {
|
|
|
134
188
|
...all,
|
|
135
189
|
models: [...this.model.keys()].filter((m) => !all.unpriced.includes(m)).sort(),
|
|
136
190
|
parts: { session: (this.who.get('session') ?? new Tally()).result(), agents: agents.result() },
|
|
191
|
+
// Null when no advisor call is recorded: a session without one has no advisor line, not a zero.
|
|
192
|
+
advisor: this.advisor.responses ? { ...this.advisor.result(), calls: this.advisor.responses } : null,
|
|
193
|
+
advisorModels: Object.fromEntries([...this.advisorModels].map(([m, t]) => [m, { ...t.result(), calls: t.responses }])),
|
|
137
194
|
byModel: Object.fromEntries([...this.model].map(([m, t]) => [m, t.result()]).sort((a, b) => b[1].tokens.total - a[1].tokens.total)),
|
|
138
195
|
startedAt: this.first,
|
|
139
196
|
lastAt: this.last,
|
|
@@ -192,7 +249,10 @@ export async function sessionUsage(session) {
|
|
|
192
249
|
for (const [who, t] of usage.who) {
|
|
193
250
|
if (who === 'session' || who.startsWith('agents:')) continue;
|
|
194
251
|
const r = t.result();
|
|
195
|
-
|
|
252
|
+
// What of it was the advisor: its calls and their cost, already in `cost`. Zero calls when it consulted none.
|
|
253
|
+
const adv = usage.advisorWho.get(who)?.result() ?? null;
|
|
254
|
+
agents[who] = { fresh: r.tokens.fresh, cacheRead: r.tokens.cacheRead, cost: r.cost, costAtNormal: r.costAtNormal,
|
|
255
|
+
advisorCalls: adv?.responses ?? 0, advisorCost: adv?.cost ?? null };
|
|
196
256
|
}
|
|
197
257
|
return { ...withTimes(usage, records), agents };
|
|
198
258
|
}
|