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,87 @@
|
|
|
1
|
+
// lan.js — the listener on this machine's Wi-Fi address, for viewers on the same network.
|
|
2
|
+
//
|
|
3
|
+
// It exists only while a viewer does: index.js calls sync() with the number of live viewers, and with none it is
|
|
4
|
+
// closed and every connection on it is ended, so nothing beyond 127.0.0.1 is listening. Every request it takes is
|
|
5
|
+
// marked `crewLan`, and the request gate (index.js) answers such a request only for a viewer's token — the panel's
|
|
6
|
+
// own token is not accepted from the network.
|
|
7
|
+
//
|
|
8
|
+
// Which address: the private IPv4 addresses of this machine's interfaces (10/8, 172.16/12, 192.168/16): the ranges a
|
|
9
|
+
// home or office Wi-Fi hands out. A public, link-local or carrier-grade address is not listened on. There is no
|
|
10
|
+
// tunnel here and no way to reach the panel from outside the network.
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
|
|
14
|
+
const PRIVATE = (a) => /^10\./.test(a) || /^192\.168\./.test(a) || /^172\.(1[6-9]|2\d|3[01])\./.test(a);
|
|
15
|
+
|
|
16
|
+
/** This machine's private IPv4 addresses. CREW_STUDIO_LAN_ADDR names one instead (the self-check uses loopback). */
|
|
17
|
+
export function lanAddresses(env = process.env, ifaces = os.networkInterfaces()) {
|
|
18
|
+
if (env.CREW_STUDIO_LAN_ADDR) return [env.CREW_STUDIO_LAN_ADDR];
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const list of Object.values(ifaces ?? {})) {
|
|
21
|
+
for (const i of list ?? []) {
|
|
22
|
+
const v4 = i.family === 'IPv4' || i.family === 4;
|
|
23
|
+
if (v4 && !i.internal && PRIVATE(i.address) && !out.includes(i.address)) out.push(i.address);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return out;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The listener, opened and closed by the number of live viewers.
|
|
31
|
+
* @param handler the server's own request handler; a request is marked `req.crewLan = true` before it is called
|
|
32
|
+
* @param port the panel's port (CREW_STUDIO_LAN_PORT overrides it, for the self-check)
|
|
33
|
+
*/
|
|
34
|
+
export function lanListener({ handler, port, env = process.env, addresses = null }) {
|
|
35
|
+
let servers = [];
|
|
36
|
+
let errors = [];
|
|
37
|
+
let pending = null;
|
|
38
|
+
const lanPort = env.CREW_STUDIO_LAN_PORT !== undefined ? Number(env.CREW_STUDIO_LAN_PORT) : port;
|
|
39
|
+
// The port actually listened on: the asked one, or the one the system picked for 0 (the self-check).
|
|
40
|
+
const portOf = (e) => e.server.address()?.port ?? lanPort;
|
|
41
|
+
|
|
42
|
+
const open = () => {
|
|
43
|
+
const addrs = addresses ?? lanAddresses(env);
|
|
44
|
+
errors = [];
|
|
45
|
+
servers = addrs.map((addr) => {
|
|
46
|
+
const sockets = new Set();
|
|
47
|
+
const s = http.createServer((req, res) => { req.crewLan = true; handler(req, res); });
|
|
48
|
+
s.on('connection', (c) => { sockets.add(c); c.on('close', () => sockets.delete(c)); });
|
|
49
|
+
const entry = { addr, server: s, sockets, listening: false };
|
|
50
|
+
s.on('error', (e) => { errors.push(`${addr}: ${e.code ?? e.message}`); entry.listening = false; });
|
|
51
|
+
s.listen(lanPort, addr, () => { entry.listening = true; });
|
|
52
|
+
return entry;
|
|
53
|
+
});
|
|
54
|
+
return new Promise((resolve) => { let n = servers.length; if (!n) resolve(); for (const e of servers) { e.server.once('listening', () => --n || resolve()); e.server.once('error', () => --n || resolve()); } });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const close = () => {
|
|
58
|
+
const was = servers; servers = [];
|
|
59
|
+
return Promise.all(was.map((e) => new Promise((resolve) => {
|
|
60
|
+
for (const c of e.sockets) c.destroy();
|
|
61
|
+
if (!e.listening) return resolve();
|
|
62
|
+
e.server.close(() => resolve());
|
|
63
|
+
})));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
/** Open with at least one live viewer, close with none. Resolves once the change has happened. */
|
|
68
|
+
sync(liveViewers) {
|
|
69
|
+
pending = (pending ?? Promise.resolve()).then(() => {
|
|
70
|
+
if (liveViewers > 0 && !servers.length) return open();
|
|
71
|
+
if (liveViewers === 0 && servers.length) return close();
|
|
72
|
+
return undefined;
|
|
73
|
+
});
|
|
74
|
+
return pending;
|
|
75
|
+
},
|
|
76
|
+
state() {
|
|
77
|
+
const on = servers.filter((e) => e.listening);
|
|
78
|
+
return { listening: on.length > 0, port: on.length ? portOf(on[0]) : lanPort,
|
|
79
|
+
addresses: servers.filter((e) => e.listening).map((e) => e.addr), errors: [...errors] };
|
|
80
|
+
},
|
|
81
|
+
/** The link a viewer opens on the phone, one per address listened on. */
|
|
82
|
+
urls(token) {
|
|
83
|
+
return servers.filter((e) => e.listening).map((e) => `http://${e.addr}:${portOf(e)}/?token=${encodeURIComponent(token)}`);
|
|
84
|
+
},
|
|
85
|
+
close,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Crewforth writes one line per finished crew agent to `.claude/state/model-outcomes.tsv` (hooks/agent-outcome.sh):
|
|
4
4
|
//
|
|
5
|
-
// ts agent_id agent change risk card model verify fixes escalated_from ran_on confidence
|
|
5
|
+
// ts agent_id agent change risk card model verify fixes escalated_from ran_on confidence [risk_why]
|
|
6
6
|
//
|
|
7
7
|
// Twelve columns, tab-separated, a header on the first line, "-" for an empty value, and any column added later
|
|
8
8
|
// is added at the end. This file reads that and adds nothing to it: a class's first-try success is counted from
|
|
@@ -19,11 +19,18 @@
|
|
|
19
19
|
// failed too often. Lowering such a hold is the user's to approve, with a Crewforth command in their own session:
|
|
20
20
|
// Studio shows which holds could be lowered (`suggestionsOf`) and writes nothing. A panel's button is a request
|
|
21
21
|
// any process of the same user can send, so it cannot stand for the user.
|
|
22
|
+
//
|
|
23
|
+
// The gate holds a floor and no ceiling: normal work may run on a model above the one the table in CLAUDE.md asks
|
|
24
|
+
// for it. Studio shows that work (`aboveTableOf`) and blocks nothing. That the table's model would have done is
|
|
25
|
+
// said only where this record shows it: the class's latest LOOSEN_AFTER verified calls on that model all passed the
|
|
26
|
+
// first time, the same test a hold is offered for lowering by.
|
|
22
27
|
|
|
23
28
|
import fs from 'node:fs';
|
|
24
29
|
import path from 'node:path';
|
|
25
30
|
|
|
26
31
|
export const COLUMNS = ['ts', 'agent_id', 'agent', 'change', 'risk', 'card', 'model', 'verify', 'fixes', 'escalated_from', 'ran_on', 'confidence'];
|
|
32
|
+
// Added at the end later, so a line written before them is still whole without them.
|
|
33
|
+
export const LATER_COLUMNS = ['risk_why'];
|
|
27
34
|
const VERIFIED = new Set(['pass', 'fail']);
|
|
28
35
|
// What the hooks write, and nothing else is a row. The words are hooks/guard-agent-model.sh's and
|
|
29
36
|
// hooks/agent-outcome.sh's; a word they do not use is a line this file cannot read, not a new kind of work.
|
|
@@ -36,6 +43,14 @@ const MAX_BYTES = 4 * 1024 * 1024; // the record is read whole; past this o
|
|
|
36
43
|
// A starting point; it has not been measured.
|
|
37
44
|
export const LOOSEN_AFTER = 10;
|
|
38
45
|
const TRAIL_MAX = 6;
|
|
46
|
+
// What the table in CLAUDE.md asks of normal work, at most. Above it is a choice the gate lets the caller make.
|
|
47
|
+
export const NORMAL_TIER = 'sonnet';
|
|
48
|
+
// The floor hooks/guard-agent-model.sh puts under an agent whatever its card says (its `case "$st"`).
|
|
49
|
+
export const AGENT_FLOORS = {
|
|
50
|
+
'crew-security-expert': 'opus',
|
|
51
|
+
'crew-privacy-agent': 'sonnet', 'crew-review-agent': 'sonnet', 'crew-planner': 'sonnet', 'crew-database-expert': 'sonnet',
|
|
52
|
+
};
|
|
53
|
+
const rankOf = (t) => TIERS.indexOf(t) + 1; // 1..4, 0 for no tier: the gate's _am_rank
|
|
39
54
|
|
|
40
55
|
const stateDir = (cwd) => path.join(cwd, '.claude', 'state');
|
|
41
56
|
const dash = (v) => (v === '-' || v === '' || v == null ? null : v);
|
|
@@ -72,6 +87,8 @@ export function parseOutcomes(text) {
|
|
|
72
87
|
line: i, ts: f[0], at, agentId: f[1], agent: f[2], change: f[3], risk: f[4], card: dash(f[5]),
|
|
73
88
|
model: dash(f[6]), verify: f[7], fixes: Number.parseInt(f[8], 10), escalatedFrom: dash(f[9]), ranOn: dash(f[10]),
|
|
74
89
|
confidence: dash(f[11]),
|
|
90
|
+
// Written by the gate when it called the work critical; a line from before it kept the reason has no column.
|
|
91
|
+
riskWhy: dash(f[12]),
|
|
75
92
|
});
|
|
76
93
|
}
|
|
77
94
|
return out;
|
|
@@ -102,9 +119,53 @@ export function parseLoosened(text) {
|
|
|
102
119
|
return out;
|
|
103
120
|
}
|
|
104
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The `floor <agent> <change> <risk> <model>` lines of the project's .claude/crew-model-rules; `*` matches any.
|
|
124
|
+
* Read as hooks/guard-agent-model.sh reads them (`_am_rules`): a comment is cut at #, the model is found in the word.
|
|
125
|
+
*/
|
|
126
|
+
export function parseRuleFloors(text) {
|
|
127
|
+
const out = [];
|
|
128
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
129
|
+
const l = line.replace(/\r$/, '').replace(/#.*/, '').trim();
|
|
130
|
+
const m = l.match(/^floor\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)/);
|
|
131
|
+
if (!m) continue;
|
|
132
|
+
const model = TIERS.find((t) => m[4].toLowerCase().includes(t));
|
|
133
|
+
if (model) out.push({ agent: m[1], change: m[2], risk: m[3], model });
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
105
138
|
const keyOf = (r) => `${r.agent}\t${r.change}\t${r.risk}`;
|
|
106
139
|
const firstTry = (r) => r.verify === 'pass' && r.fixes === 0;
|
|
107
140
|
|
|
141
|
+
/**
|
|
142
|
+
* The lowest model the gate would have let this call run on, as a rank (see `rankOf`): the highest of what
|
|
143
|
+
* applies, the order of hooks/guard-agent-model.sh. The project's rules are read as they are now; a raise from the
|
|
144
|
+
* record counts for the calls made after it, lowered or not, so a call it held is never said to have run above.
|
|
145
|
+
*/
|
|
146
|
+
export function floorOfRow(r, ruleFloors = [], floors = []) {
|
|
147
|
+
let f = rankOf(AGENT_FLOORS[r.agent]);
|
|
148
|
+
if (r.verify === 'none') f = Math.max(f, 2);
|
|
149
|
+
if (r.change === 'test-write' || r.change === 'audit') f = Math.max(f, 2);
|
|
150
|
+
if (r.risk === 'critical' && r.change !== 'test-run') f = Math.max(f, 3);
|
|
151
|
+
const any = (want, v) => want === '*' || want === v;
|
|
152
|
+
for (const x of ruleFloors) if (any(x.agent, r.agent) && any(x.change, r.change) && any(x.risk, r.risk)) f = Math.max(f, rankOf(x.model));
|
|
153
|
+
for (const x of floors) if (keyOf(x) === keyOf(r) && !(String(r.ts) < String(x.when ?? ''))) f = Math.max(f, rankOf(x.model));
|
|
154
|
+
if (r.escalatedFrom) f = Math.max(f, rankOf(r.escalatedFrom) + 1);
|
|
155
|
+
return f;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* How many of a class's latest `need` verified calls on `model`, since `since`, there are when every one of them
|
|
160
|
+
* passed the first time; 0 when there are fewer, or one did not. The test for lowering a hold and for saying the
|
|
161
|
+
* table's model would have done.
|
|
162
|
+
*/
|
|
163
|
+
function passedLatest(rows, k, model, since, need) {
|
|
164
|
+
const recent = rows.filter((r) => keyOf(r) === k && r.model === model && VERIFIED.has(r.verify) && (r.at ?? 0) >= since)
|
|
165
|
+
.sort((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, need);
|
|
166
|
+
return recent.length >= need && recent.every(firstTry) ? recent.length : 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
108
169
|
/**
|
|
109
170
|
* The lowering that stands for a raised floor, or null. The rule is hooks/guard-agent-model.sh's (`_am_floor_now`),
|
|
110
171
|
* word for word: a lowering counts when it is not older than the raise it answers (the times are UTC text and are
|
|
@@ -161,13 +222,16 @@ export function classesOf(rows, floors = [], loosened = []) {
|
|
|
161
222
|
* `costed` of `runs` were priced; the rest are not in the sum, so the sum is a floor and not the total.
|
|
162
223
|
* @param costOf (agentId) => USD or null
|
|
163
224
|
*/
|
|
164
|
-
export function classCosts(rows, costOf) {
|
|
225
|
+
export function classCosts(rows, costOf, advisorOf = () => null) {
|
|
165
226
|
const out = {};
|
|
166
227
|
for (const r of rows) {
|
|
167
|
-
const c = (out[keyOf(r)] ??= { cost: 0, costed: 0, runs: 0 });
|
|
228
|
+
const c = (out[keyOf(r)] ??= { cost: 0, costed: 0, runs: 0, advisorCalls: 0, advisorCost: 0 });
|
|
168
229
|
c.runs += 1;
|
|
169
230
|
const usd = costOf(r.agentId);
|
|
170
231
|
if (typeof usd === 'number') { c.cost += usd; c.costed += 1; }
|
|
232
|
+
// The advisor's share is part of `cost` already; it is counted apart only to be said.
|
|
233
|
+
const a = advisorOf(r.agentId);
|
|
234
|
+
if (a?.calls > 0) { c.advisorCalls += a.calls; if (typeof a.cost === 'number') c.advisorCost += a.cost; }
|
|
171
235
|
}
|
|
172
236
|
return out;
|
|
173
237
|
}
|
|
@@ -186,11 +250,52 @@ export function suggestionsOf(rows, floors = [], loosened = [], need = LOOSEN_AF
|
|
|
186
250
|
const k = keyOf(f);
|
|
187
251
|
// Once for each raise: a floor whose lowering stands is not offered again (the same rule the gate reads it by).
|
|
188
252
|
if (loweringOf(f, loosened)) continue;
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
253
|
+
const passed = passedLatest(rows, k, f.model, Date.parse(f.when ?? '') || 0, need);
|
|
254
|
+
if (!passed) continue;
|
|
255
|
+
out.push({ key: k, agent: f.agent, change: f.change, risk: f.risk, from: f.model, to: TIERS[tier - 1], passed, heldSince: f.when ?? null });
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Normal work that ran above the table's model, by class, the busiest first: the calls on a model above both
|
|
262
|
+
* NORMAL_TIER and the floor the gate held them to (`floorOfRow`). `passed` is how many of the class's latest
|
|
263
|
+
* verified calls on NORMAL_TIER passed the first time (counted from the class's raise, where it has one), when it
|
|
264
|
+
* is `need` of `need`; otherwise 0, and nothing is said about whether NORMAL_TIER would have done.
|
|
265
|
+
* `ids` are the agents' ids, for pricing; they are not sent to a page (`readModels`).
|
|
266
|
+
*/
|
|
267
|
+
export function aboveTableOf(rows, ruleFloors = [], floors = [], need = LOOSEN_AFTER) {
|
|
268
|
+
const by = new Map();
|
|
269
|
+
const top = rankOf(NORMAL_TIER);
|
|
270
|
+
for (const r of rows) {
|
|
271
|
+
if (r.risk !== 'normal' || rankOf(r.model) <= Math.max(top, floorOfRow(r, ruleFloors, floors))) continue;
|
|
272
|
+
const k = keyOf(r);
|
|
273
|
+
const c = by.get(k) ?? by.set(k, { key: k, agent: r.agent, change: r.change, risk: r.risk, to: NORMAL_TIER, runs: 0, models: {}, ids: [] }).get(k);
|
|
274
|
+
c.runs += 1;
|
|
275
|
+
c.models[r.model] = (c.models[r.model] ?? 0) + 1;
|
|
276
|
+
c.ids.push(r.agentId);
|
|
277
|
+
}
|
|
278
|
+
return [...by.values()].map((c) => {
|
|
279
|
+
const held = floors.find((f) => keyOf(f) === c.key);
|
|
280
|
+
return { ...c, need, passed: passedLatest(rows, c.key, NORMAL_TIER, Date.parse(held?.when ?? '') || 0, need) };
|
|
281
|
+
}).sort((a, b) => b.runs - a.runs || a.key.localeCompare(b.key));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* What the work above the table cost, by class: { classKey: { cost, atNormal, costed, runs } }. `atNormal` is the
|
|
286
|
+
* same tokens at NORMAL_TIER's list price: what that model would have cost had it used as many, which no record
|
|
287
|
+
* can say it would have. As with `classCosts`, only priced calls are in the sums.
|
|
288
|
+
* @param priceOf (agentId) => { cost, atNormal } in USD, or null
|
|
289
|
+
*/
|
|
290
|
+
export function aboveCosts(above, priceOf) {
|
|
291
|
+
const out = {};
|
|
292
|
+
for (const c of above) {
|
|
293
|
+
const o = (out[c.key] = { cost: 0, atNormal: 0, costed: 0, runs: c.ids.length });
|
|
294
|
+
for (const id of c.ids) {
|
|
295
|
+
const p = priceOf(id);
|
|
296
|
+
if (typeof p?.cost !== 'number' || typeof p?.atNormal !== 'number') continue;
|
|
297
|
+
o.cost += p.cost; o.atNormal += p.atNormal; o.costed += 1;
|
|
298
|
+
}
|
|
194
299
|
}
|
|
195
300
|
return out;
|
|
196
301
|
}
|
|
@@ -210,16 +315,18 @@ function readBounded(file) {
|
|
|
210
315
|
|
|
211
316
|
const sigOf = (file) => { try { const st = fs.statSync(file); return `${st.mtimeMs}:${st.size}`; } catch { return '-'; } };
|
|
212
317
|
const FILES = ['model-outcomes.tsv', 'crew-model-floors.auto', 'crew-model-loosened.tsv'];
|
|
318
|
+
const RULES = (cwd) => path.join(cwd, '.claude', 'crew-model-rules'); // the project's own, beside the state
|
|
213
319
|
const records = new Map(); // cwd -> { sig, value }
|
|
214
320
|
export const _stats = { parsed: 0 }; // how many times a record was really read: the claims count it
|
|
215
321
|
|
|
216
322
|
/**
|
|
217
|
-
* The record of one project, read once and kept until one of its
|
|
323
|
+
* The record of one project, read once and kept until one of its files, or the project's rules, changes (its
|
|
324
|
+
* time or its size).
|
|
218
325
|
* A view that asks every few seconds costs three stats, not a parse.
|
|
219
326
|
*/
|
|
220
327
|
export function readRecord(cwd) {
|
|
221
328
|
const dir = stateDir(cwd);
|
|
222
|
-
const sig = FILES.map((f) => sigOf(path.join(dir, f))).join('|');
|
|
329
|
+
const sig = [...FILES.map((f) => sigOf(path.join(dir, f))), sigOf(RULES(cwd))].join('|');
|
|
223
330
|
const hit = records.get(cwd);
|
|
224
331
|
if (hit && hit.sig === sig) return hit.value;
|
|
225
332
|
const value = parseRecord(dir, cwd);
|
|
@@ -231,10 +338,11 @@ export function readRecord(cwd) {
|
|
|
231
338
|
|
|
232
339
|
function parseRecord(dir, cwd) {
|
|
233
340
|
const record = readBounded(path.join(dir, FILES[0]));
|
|
234
|
-
if (!record) return { measured: false, reason: 'no .claude/state/model-outcomes.tsv in this project: no crew agent has finished here since model routing was installed', classes: [], suggestions: [], byAgent: {} };
|
|
341
|
+
if (!record) return { measured: false, reason: 'no .claude/state/model-outcomes.tsv in this project: no crew agent has finished here since model routing was installed', classes: [], suggestions: [], above: [], byAgent: {} };
|
|
235
342
|
const parsed = parseOutcomes(record.text);
|
|
236
343
|
const floors = parseFloors(readBounded(path.join(dir, FILES[1]))?.text ?? '');
|
|
237
344
|
const loosened = parseLoosened(readBounded(path.join(dir, FILES[2]))?.text ?? '');
|
|
345
|
+
const ruleFloors = parseRuleFloors(readBounded(RULES(cwd))?.text ?? '');
|
|
238
346
|
const byAgent = {};
|
|
239
347
|
for (const r of parsed.rows) byAgent[r.agentId] = r; // a resumed agent's latest line is the one that stands
|
|
240
348
|
// A card that failed is given again one model up, to another agent: the calls of one card, oldest first, are
|
|
@@ -258,6 +366,7 @@ function parseRecord(dir, cwd) {
|
|
|
258
366
|
classes: classesOf(parsed.rows, floors, loosened),
|
|
259
367
|
floors,
|
|
260
368
|
suggestions: suggestionsOf(parsed.rows, floors, loosened),
|
|
369
|
+
above: aboveTableOf(parsed.rows, ruleFloors, floors),
|
|
261
370
|
loosened,
|
|
262
371
|
byAgent,
|
|
263
372
|
all: parsed.rows,
|
|
@@ -276,12 +385,18 @@ export function readModels(cwd, { agents = null } = {}) {
|
|
|
276
385
|
if (!rec.measured) return rec;
|
|
277
386
|
const byAgent = {};
|
|
278
387
|
for (const id of agents ?? []) if (Object.hasOwn(rec.byAgent, id)) byAgent[id] = rec.byAgent[id];
|
|
279
|
-
const { all, byAgent: _every, ...rest } = rec;
|
|
280
|
-
return { ...rest, byAgent };
|
|
388
|
+
const { all, byAgent: _every, above, ...rest } = rec;
|
|
389
|
+
return { ...rest, above: above.map(({ ids, ...c }) => c), byAgent };
|
|
281
390
|
}
|
|
282
391
|
|
|
283
392
|
/** What the classes of one project cost, from the kept record: see `classCosts`. Null when there is no record. */
|
|
284
|
-
export function readCosts(cwd, costOf) {
|
|
393
|
+
export function readCosts(cwd, costOf, advisorOf) {
|
|
394
|
+
const rec = readRecord(cwd);
|
|
395
|
+
return rec.measured ? classCosts(rec.all, costOf, advisorOf) : null;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** What the work above the table cost, from the kept record: see `aboveCosts`. Null when there is no record. */
|
|
399
|
+
export function readAboveCosts(cwd, priceOf) {
|
|
285
400
|
const rec = readRecord(cwd);
|
|
286
|
-
return rec.measured ?
|
|
401
|
+
return rec.measured ? aboveCosts(rec.above, priceOf) : null;
|
|
287
402
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// owned-stream.js — the conversations of several owned sessions, replayed then followed, on ONE connection.
|
|
2
|
+
//
|
|
3
|
+
// A browser holds six connections to one host over HTTP/1.1, and a pane per stream reached them at five panes
|
|
4
|
+
// beside the page's own: the panel kept painting, but a message sent and every request the page made itself waited
|
|
5
|
+
// until a pane was closed (measured on Windows, Claude Code 2.1.301: a send did not arrive in 35 s, then in 8 s once
|
|
6
|
+
// two panes closed). Each event and state carries the session id it belongs to.
|
|
7
|
+
// ids=a,b,c the sessions (at most MAX_STREAM_IDS; an unknown id is answered with `gone`)
|
|
8
|
+
// after=a:12,b:3 resume each where the client last was, so a reconnect neither replays nor drops
|
|
9
|
+
|
|
10
|
+
export const MAX_STREAM_IDS = 32;
|
|
11
|
+
|
|
12
|
+
/** Read `ids` and `after` from a URL: the ids, de-duplicated and capped, and each one's resume point. */
|
|
13
|
+
export function parseOwnedQuery(url) {
|
|
14
|
+
const ids = [...new Set((url.searchParams.get('ids') ?? '').split(',').filter(Boolean))].slice(0, MAX_STREAM_IDS);
|
|
15
|
+
const after = new Map();
|
|
16
|
+
for (const part of (url.searchParams.get('after') ?? '').split(',')) {
|
|
17
|
+
const at = part.lastIndexOf(':');
|
|
18
|
+
if (at > 0) after.set(part.slice(0, at), Number(part.slice(at + 1)) || 0);
|
|
19
|
+
}
|
|
20
|
+
return { ids, after };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param getSession id -> an owned session ({ summary(), subscribe(fn, after) }) or null
|
|
25
|
+
* @param stateAfter the record types after which a session's summary is sent again
|
|
26
|
+
* @param beatMs how often every summary is sent regardless
|
|
27
|
+
*/
|
|
28
|
+
export function streamOwned(req, res, url, { getSession, stateAfter = new Set(), beatMs = 15000 }) {
|
|
29
|
+
const { ids, after } = parseOwnedQuery(url);
|
|
30
|
+
res.writeHead(200, {
|
|
31
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
32
|
+
'cache-control': 'no-store',
|
|
33
|
+
connection: 'keep-alive',
|
|
34
|
+
'x-accel-buffering': 'no',
|
|
35
|
+
});
|
|
36
|
+
const write = (event, data) => {
|
|
37
|
+
try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* client gone */ }
|
|
38
|
+
};
|
|
39
|
+
const unsubscribes = [];
|
|
40
|
+
for (const id of ids) {
|
|
41
|
+
const s = getSession(id);
|
|
42
|
+
if (!s) { write('gone', { id }); continue; }
|
|
43
|
+
write('state', { id, session: s.summary() });
|
|
44
|
+
unsubscribes.push(s.subscribe((ev) => {
|
|
45
|
+
write('event', { id, ev });
|
|
46
|
+
if (stateAfter.has(ev.rec?.type)) write('state', { id, session: s.summary() });
|
|
47
|
+
}, after.get(id) ?? 0));
|
|
48
|
+
}
|
|
49
|
+
const beat = setInterval(() => {
|
|
50
|
+
for (const id of ids) { const s = getSession(id); if (s) write('state', { id, session: s.summary() }); }
|
|
51
|
+
}, beatMs);
|
|
52
|
+
const stop = () => { clearInterval(beat); for (const u of unsubscribes) u(); try { res.end(); } catch { /* gone */ } };
|
|
53
|
+
req.on('close', stop);
|
|
54
|
+
req.on('error', stop);
|
|
55
|
+
return stop;
|
|
56
|
+
}
|
|
@@ -36,6 +36,9 @@ export const PRICES = {
|
|
|
36
36
|
'claude-haiku-4-5': { input: 1, output: 5 },
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
// The model the table's normal tier is priced at, for the Models view's "the same tokens on the table's model".
|
|
40
|
+
export const NORMAL_TIER_MODEL = 'claude-sonnet-5-5';
|
|
41
|
+
|
|
39
42
|
/** The row for a model id, or null. A dated snapshot (`<id>-20260101`) is the same model; nothing else matches. */
|
|
40
43
|
export function priceOf(model) {
|
|
41
44
|
if (typeof model !== 'string') return null;
|
|
@@ -24,10 +24,17 @@ import fsp from 'node:fs/promises';
|
|
|
24
24
|
import os from 'node:os';
|
|
25
25
|
import path from 'node:path';
|
|
26
26
|
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
// Where Claude Code writes its transcripts: <config dir>/projects, the config dir being CLAUDE_CONFIG_DIR when it is
|
|
28
|
+
// set and ~/.claude otherwise. Reading ~/.claude alone, sessions started under a moved config dir (Studio's own
|
|
29
|
+
// included) were not listed and read a cost of 0 (measured on Windows). The studio gate hook reads it the same way.
|
|
30
|
+
// CREW_STUDIO_PROJECTS_ROOT still wins: a second instance pointed at a different store, which is also the only way to
|
|
31
|
+
// exercise the peer relay on one machine.
|
|
32
|
+
export function projectsRoot(env = process.env, home = os.homedir()) {
|
|
33
|
+
if (env.CREW_STUDIO_PROJECTS_ROOT) return env.CREW_STUDIO_PROJECTS_ROOT;
|
|
34
|
+
const config = env.CLAUDE_CONFIG_DIR ? path.resolve(env.CLAUDE_CONFIG_DIR) : path.join(home, '.claude');
|
|
35
|
+
return path.join(config, 'projects');
|
|
36
|
+
}
|
|
37
|
+
export const PROJECTS_ROOT = projectsRoot();
|
|
31
38
|
|
|
32
39
|
/** Fold a working directory into the directory name Claude Code uses. */
|
|
33
40
|
export function encodeCwd(cwd) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// redact.js — what a viewer whose link hides transcripts is sent.
|
|
2
|
+
//
|
|
3
|
+
// Text that came from a transcript (a prompt, a reply, a tool's input or output, the short task an agent was given,
|
|
4
|
+
// a session's title) is not sent to such a viewer at all. Hiding it in the page would not do: it would still be on
|
|
5
|
+
// the wire and in the phone's memory.
|
|
6
|
+
//
|
|
7
|
+
// AN ALLOW-LIST, NOT A DENY-LIST. Transcript text only ever travels as a string, so every string in an answer is
|
|
8
|
+
// dropped unless its key is one of the few below, which name things that do not come from a transcript: ids,
|
|
9
|
+
// states, model and agent-type names, tool names, times as text. A field added to an answer tomorrow is therefore
|
|
10
|
+
// hidden until someone decides it is safe and adds its key here. Numbers, booleans and the shape of an answer pass,
|
|
11
|
+
// so the viewer still sees the agents, their states, their timing and what they cost.
|
|
12
|
+
//
|
|
13
|
+
// A few routes are nothing but transcript (a conversation, an agent's detail, the live conversation of a session
|
|
14
|
+
// the panel runs): those are refused outright rather than sent empty.
|
|
15
|
+
|
|
16
|
+
/** Keys whose string values are not transcript text. Checked by key name, wherever in the answer they are. */
|
|
17
|
+
export const SAFE_STRING_KEYS = new Set([
|
|
18
|
+
// identity and links between things
|
|
19
|
+
'id', 'sessionId', 'agentId', 'parentId', 'toolUseId', 'workflowId', 'workflow', 'source', 'target', 'origin',
|
|
20
|
+
// kinds and states
|
|
21
|
+
'kind', 'status', 'launchStatus', 'trigger', 'role', 'reason',
|
|
22
|
+
// which model and which kind of agent, and which tool (a tool's NAME, never its input)
|
|
23
|
+
'model', 'modelAsked', 'models', 'unpriced', 'agentType', 'toolName', 'lastTool', 'version',
|
|
24
|
+
// where, as the machine knows it rather than as anybody wrote it
|
|
25
|
+
'cwd', 'gitBranch', 'dir', 'url', 'read', 'at',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// `label` is safe only where the server writes it from one of the safe things above (a session's short id, an agent
|
|
29
|
+
// type, 'workflow'); measured in lib/graph.js. Anywhere else it is dropped like any other string.
|
|
30
|
+
const LABEL_SAFE_KINDS = new Set(['session', 'agent', 'workflow']);
|
|
31
|
+
|
|
32
|
+
/** Routes that are nothing but transcript: refused for a viewer whose link hides transcripts. */
|
|
33
|
+
export function transcriptOnly(pathname) {
|
|
34
|
+
return /^\/api\/session\/[^/]+\/(conversation|agent\/[^/]+)$/.test(pathname)
|
|
35
|
+
|| pathname === '/api/owned' || pathname === '/api/owned-events' || /^\/api\/owned\/[^/]+(\/.*)?$/.test(pathname);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The answer with every string that may be transcript text removed. A new object; the input is not touched. */
|
|
39
|
+
export function redact(value, key = null, parent = null) {
|
|
40
|
+
if (typeof value === 'string') {
|
|
41
|
+
if (SAFE_STRING_KEYS.has(key)) return value;
|
|
42
|
+
if (key === 'label' && parent && LABEL_SAFE_KINDS.has(parent.kind)) return value;
|
|
43
|
+
// A machine's name (an origin: no sessionId of its own). A session's `name` is its title, written from its
|
|
44
|
+
// first prompt, and is dropped.
|
|
45
|
+
if (key === 'name' && parent && parent.sessionId === undefined && typeof parent.local === 'boolean') return value;
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
return value.map((x) => redact(x, key, parent)).filter((x) => x !== undefined);
|
|
50
|
+
}
|
|
51
|
+
if (value && typeof value === 'object') {
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const [k, v] of Object.entries(value)) {
|
|
54
|
+
const r = redact(v, k, value);
|
|
55
|
+
if (r !== undefined) out[k] = r;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
return value;
|
|
60
|
+
}
|