crewforth 3.1.1 → 3.2.0-rc.2
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 +171 -0
- package/README.md +4 -3
- package/VERSION +1 -1
- package/adopt.sh +56 -5
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +14 -13
- package/kit/agents/crew-commit-agent.md +7 -2
- 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-security-expert.md +3 -1
- package/kit/agents/crew-session-manager.md +11 -9
- package/kit/eval/doctor.sh +117 -6
- 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 +98 -0
- package/kit/eval/lib/crew-map/parse.mjs +305 -0
- package/kit/eval/lib/crew-map/project.mjs +163 -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/scan.mjs +214 -0
- package/kit/eval/lib/crew-map/stacks.mjs +441 -0
- package/kit/eval/lib/crew-map/surface.mjs +457 -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/settings-default.sh +36 -0
- package/kit/eval/lib/settings-json.awk +15 -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/version.sh +41 -0
- package/kit/eval/smoke-test.sh +576 -41
- package/kit/hooks/agent-outcome.sh +26 -12
- package/kit/hooks/guard-agent-model.sh +109 -9
- package/kit/hooks/guard-bash.sh +240 -182
- package/kit/hooks/guard-question.sh +73 -0
- package/kit/hooks/guard-write.sh +21 -0
- package/kit/hooks/prompt-approval.sh +320 -103
- package/kit/hooks/refuter-ledger.sh +80 -0
- package/kit/hooks/session-update-check.sh +9 -8
- package/kit/owned-blobs.tsv +4 -4
- package/kit/settings.json +33 -0
- package/kit/skills/crew-approve/SKILL.md +7 -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 +44 -0
- package/kit/skills/crew-review/SKILL.md +2 -0
- 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 +12 -0
- package/kit/studio/server/index.js +125 -11
- 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/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/remote.js +99 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +223 -15
- package/kit/studio/web/canvas.js +44 -120
- package/kit/studio/web/chat.js +82 -17
- package/kit/studio/web/graph-plan.js +10 -4
- package/kit/studio/web/index.html +10 -5
- package/kit/studio/web/liveness.js +14 -0
- package/kit/studio/web/map-plan.js +341 -0
- package/kit/studio/web/map.js +503 -0
- package/kit/studio/web/models-plan.js +61 -7
- package/kit/studio/web/models.js +19 -3
- package/kit/studio/web/style.css +131 -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 +10 -0
|
@@ -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
|
}
|
package/kit/studio/web/app.js
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
summaryChips, sessionStatus, sessionName, sessionSub, ago, matchesSession, highlight, badgeFor,
|
|
15
15
|
liveSessions, machines, seenAgo, isFirstRun,
|
|
16
16
|
} from './nav.js';
|
|
17
|
-
import { liveness, offlineNote } from './liveness.js';
|
|
17
|
+
import { liveness, offlineNote, staleServerNote } from './liveness.js';
|
|
18
18
|
import { attention, settle, AUTO } from './graph-plan.js';
|
|
19
19
|
import { summaryRows, usageLine, costNote } from './usage.js';
|
|
20
20
|
import { ServerClock, queue, settled, terminalWait, OUTCOME_WORD } from './approvals.js';
|
|
@@ -24,7 +24,8 @@ import { Timeline } from './timeline.js';
|
|
|
24
24
|
import { List } from './list.js';
|
|
25
25
|
import { defaultView, narrowWarning } from './list-plan.js';
|
|
26
26
|
import { Models } from './models.js';
|
|
27
|
-
import {
|
|
27
|
+
import { MapView } from './map.js';
|
|
28
|
+
import { cardOf, cardLines, workSteps, outsideRouting } from './models-plan.js';
|
|
28
29
|
import { fitLevel, runsOver } from './toolbar-fit.js';
|
|
29
30
|
import { roleName, shownType, modelFamily } from './names.js';
|
|
30
31
|
import { shortcutOf } from './keys.js';
|
|
@@ -79,6 +80,8 @@ const el = {
|
|
|
79
80
|
viewList: document.getElementById('view-list'),
|
|
80
81
|
viewModels: document.getElementById('view-models'),
|
|
81
82
|
models: document.getElementById('models'),
|
|
83
|
+
viewMap: document.getElementById('view-map'),
|
|
84
|
+
map: document.getElementById('map'),
|
|
82
85
|
list: document.getElementById('list'),
|
|
83
86
|
firstRun: document.getElementById('first-run'),
|
|
84
87
|
stageNote: document.getElementById('stage-note'),
|
|
@@ -608,16 +611,27 @@ el.tbOptions.addEventListener('click', (e) => {
|
|
|
608
611
|
});
|
|
609
612
|
el.tbExpand.addEventListener('click', () => { (foldTarget() ?? canvas).expandAll(); paintFoldButtons(); });
|
|
610
613
|
el.tbFold.addEventListener('click', () => { (foldTarget() ?? canvas).foldAll(); paintFoldButtons(); });
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
el.
|
|
614
|
-
el.
|
|
614
|
+
// The zoom, Fit and Reset controls are the graph's and the map's: they act on whichever of the two is open.
|
|
615
|
+
const zoomed = () => (view === 'map' ? mapView : canvas);
|
|
616
|
+
el.tbZoomOut.addEventListener('click', () => zoomed().zoomBy(1 / 1.2));
|
|
617
|
+
el.tbZoomIn.addEventListener('click', () => zoomed().zoomBy(1.2));
|
|
618
|
+
el.tbZoom.addEventListener('click', () => zoomed().zoomTo(1));
|
|
619
|
+
el.tbFit.addEventListener('click', () => zoomed().fit());
|
|
615
620
|
|
|
616
621
|
/* Reset layout: the cards moved by hand go back where the layout puts them, and for a moment that can be undone. */
|
|
617
622
|
function resetLayout() {
|
|
618
|
-
const
|
|
623
|
+
const target = zoomed();
|
|
624
|
+
const was = target.resetLayout();
|
|
619
625
|
if (!was) return;
|
|
620
|
-
toast('Layout reset', { label: 'Undo', run: () => { if (
|
|
626
|
+
toast('Layout reset', { label: 'Undo', run: () => { if (target.restoreLayout(was)) toast('Layout restored'); } });
|
|
627
|
+
if (target === mapView) paintZoom(mapView.zoom, mapView.moved);
|
|
628
|
+
}
|
|
629
|
+
/** The zoom and Reset as the map has them: the graph's go through paintToolbar. */
|
|
630
|
+
function paintZoom(k, moved) {
|
|
631
|
+
const pct = `${Math.round(k * 100)}%`;
|
|
632
|
+
el.tbZoom.textContent = pct;
|
|
633
|
+
el.tbZoom.setAttribute('aria-label', `Zoom ${pct}. Back to 100%`);
|
|
634
|
+
if (el.tbReset) el.tbReset.disabled = !(moved > 0);
|
|
621
635
|
}
|
|
622
636
|
el.tbReset.addEventListener('click', resetLayout);
|
|
623
637
|
|
|
@@ -679,6 +693,37 @@ async function loadCosts(force = false) {
|
|
|
679
693
|
const COSTS_POLL_MS = 60000;
|
|
680
694
|
const MODELS_POLL_MS = 5000;
|
|
681
695
|
|
|
696
|
+
/* The project's crew map, as Crewforth's scanner wrote it. Read when the view opens, when the comparison changes, and every
|
|
697
|
+
half minute while it stays open; nothing is scanned from here. */
|
|
698
|
+
let mapFor = null;
|
|
699
|
+
let mapAt = 0;
|
|
700
|
+
let mapBusy = false;
|
|
701
|
+
let mapCompare = null;
|
|
702
|
+
const mapView = new MapView(el.map, {
|
|
703
|
+
onCopy: (text, what) => navigator.clipboard.writeText(text).then(
|
|
704
|
+
() => toast(what === 'path' ? `Copied ${text}` : 'Copied — paste it into a session as a task'),
|
|
705
|
+
() => toast(`Not copied — select it: ${text}`)),
|
|
706
|
+
onCompare: (cwd) => { mapCompare = cwd; loadMap(true); },
|
|
707
|
+
onZoom: (k) => { if (view === 'map') paintZoom(k, mapView.moved); },
|
|
708
|
+
});
|
|
709
|
+
async function loadMap(force = false) {
|
|
710
|
+
if (!current || mapBusy) return;
|
|
711
|
+
if (!force && mapFor === current && Date.now() - mapAt < MAP_POLL_MS) return;
|
|
712
|
+
const asked = current;
|
|
713
|
+
if (mapFor !== asked) mapCompare = null;
|
|
714
|
+
mapAt = Date.now();
|
|
715
|
+
mapBusy = true;
|
|
716
|
+
let data;
|
|
717
|
+
const q = `session=${encodeURIComponent(asked)}${mapCompare ? `&compare=${encodeURIComponent(mapCompare)}` : ''}`;
|
|
718
|
+
// A server older than this page has no Map route: say that, not its 404.
|
|
719
|
+
try { data = await getJson(`/api/map?${q}`); } catch (e) { data = { measured: false, reason: restartNote ?? e.message, others: [] }; }
|
|
720
|
+
finally { mapBusy = false; }
|
|
721
|
+
if (asked !== current) return;
|
|
722
|
+
mapFor = asked;
|
|
723
|
+
mapView.setData(data);
|
|
724
|
+
}
|
|
725
|
+
const MAP_POLL_MS = 30000;
|
|
726
|
+
|
|
682
727
|
const list = new List(el.list, {
|
|
683
728
|
tileOf: (n) => canvas.tileFor(n),
|
|
684
729
|
dimmed: (n) => canvas.dimmed(n),
|
|
@@ -713,7 +758,7 @@ function paintTimelineBar(st) {
|
|
|
713
758
|
*/
|
|
714
759
|
function setView(next, select = null, remember = true) {
|
|
715
760
|
const was = view;
|
|
716
|
-
view = ['graph', 'timeline', 'list', 'models'].includes(next) ? next : 'graph';
|
|
761
|
+
view = ['graph', 'timeline', 'list', 'models', 'map'].includes(next) ? next : 'graph';
|
|
717
762
|
// A view the window opened on by default is not a choice, and is not remembered as one.
|
|
718
763
|
if (remember) store.set('crewforth-studio-view', view);
|
|
719
764
|
const carried = select ?? (was === 'timeline' ? timeline.selected : was === 'list' ? list.selected : canvas.selected) ?? null;
|
|
@@ -722,6 +767,8 @@ function setView(next, select = null, remember = true) {
|
|
|
722
767
|
el.list.hidden = view !== 'list';
|
|
723
768
|
el.models.hidden = view !== 'models';
|
|
724
769
|
el.viewModels.setAttribute('aria-selected', String(view === 'models'));
|
|
770
|
+
el.map.hidden = view !== 'map';
|
|
771
|
+
el.viewMap.setAttribute('aria-selected', String(view === 'map'));
|
|
725
772
|
el.viewGraph.setAttribute('aria-selected', String(view === 'graph'));
|
|
726
773
|
el.viewTimeline.setAttribute('aria-selected', String(view === 'timeline'));
|
|
727
774
|
el.viewList.setAttribute('aria-selected', String(view === 'list'));
|
|
@@ -751,7 +798,14 @@ function setView(next, select = null, remember = true) {
|
|
|
751
798
|
if (costsFor !== current) models.setCosts(null);
|
|
752
799
|
loadModels(true);
|
|
753
800
|
loadCosts(costsFor !== current);
|
|
801
|
+
} else if (view === 'map') {
|
|
802
|
+
// The map is the project's: read when the view opens, drawn with what was last read until the answer comes.
|
|
803
|
+
mapView.render();
|
|
804
|
+
paintZoom(mapView.zoom, mapView.moved);
|
|
805
|
+
loadMap(true);
|
|
754
806
|
} else {
|
|
807
|
+
// Back on the graph: the zoom and Reset say the graph's again.
|
|
808
|
+
paintToolbar(canvas.state());
|
|
755
809
|
canvas.fitIfUntouched();
|
|
756
810
|
if (carried && canvas.nodes.has(carried)) canvas.focus(carried);
|
|
757
811
|
}
|
|
@@ -768,13 +822,14 @@ el.viewGraph.addEventListener('click', () => setView('graph'));
|
|
|
768
822
|
el.viewTimeline.addEventListener('click', () => setView('timeline'));
|
|
769
823
|
el.viewList.addEventListener('click', () => setView('list'));
|
|
770
824
|
el.viewModels.addEventListener('click', () => setView('models'));
|
|
825
|
+
el.viewMap.addEventListener('click', () => setView('map'));
|
|
771
826
|
el.tbFollow.addEventListener('click', () => timeline.setFollow(el.tbFollow.getAttribute('aria-checked') !== 'true'));
|
|
772
827
|
// "Out" is more time on screen, "in" is less: the same way round as the graph's zoom.
|
|
773
828
|
el.tbRangeOut.addEventListener('click', () => timeline.zoom(1));
|
|
774
829
|
el.tbRangeIn.addEventListener('click', () => timeline.zoom(-1));
|
|
775
830
|
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => timeline.render())).observe(el.timeline);
|
|
776
831
|
// The canvas's pane changing size changes what fits in it, and with that what Auto density draws.
|
|
777
|
-
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => canvas.
|
|
832
|
+
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => canvas.paneResized())).observe(el.canvasEl);
|
|
778
833
|
|
|
779
834
|
getJson('/api/palette')
|
|
780
835
|
.then((p) => {
|
|
@@ -980,11 +1035,15 @@ function paintAgentOverview(body, n, detail) {
|
|
|
980
1035
|
if (lines.length) {
|
|
981
1036
|
const dl = node('dl', 'icard');
|
|
982
1037
|
for (const l of lines) dl.append(node('dt', null, l.label), node('dd', null, l.text));
|
|
1038
|
+
// Why the gate called it critical, in its own words (`files ... is on a critical path: ...` or `change: ...`).
|
|
1039
|
+
if (work.riskWhy) dl.append(node('dt', null, 'why critical'), node('dd', null, work.riskWhy));
|
|
983
1040
|
const sec = isection('Task card', dl);
|
|
984
1041
|
if (work.risk) { const tag = node('span', 'mv-tag', work.risk); tag.dataset.risk = work.risk; sec.children[0].append(tag); }
|
|
985
1042
|
body.append(sec);
|
|
986
1043
|
}
|
|
987
|
-
//
|
|
1044
|
+
// Not a crew agent: no card and no line, and that is said, so the empty space is not read as a record lost.
|
|
1045
|
+
const outside = !row && !lines.length ? outsideRouting(n.agentType) : null;
|
|
1046
|
+
if (outside) body.append(isection('Model routing', node('p', 'istep-note', outside)));
|
|
988
1047
|
if (row || lines.length) {
|
|
989
1048
|
const ol = node('ol', 'isteps');
|
|
990
1049
|
work.steps.forEach((st, i) => {
|
|
@@ -1381,6 +1440,8 @@ const ICON = {
|
|
|
1381
1440
|
copy: 'M5.5 5.5v-2A1.5 1.5 0 0 1 7 2h5.5A1.5 1.5 0 0 1 14 3.5V9a1.5 1.5 0 0 1-1.5 1.5h-2M3.5 5.5H9A1.5 1.5 0 0 1 10.5 7v5.5A1.5 1.5 0 0 1 9 14H3.5A1.5 1.5 0 0 1 2 12.5V7a1.5 1.5 0 0 1 1.5-1.5z',
|
|
1382
1441
|
chevron: 'M6 4l4 4-4 4',
|
|
1383
1442
|
more: 'M3.5 8h.01M8 8h.01M12.5 8h.01',
|
|
1443
|
+
// The Map view's tab mark: three cards and the lines between them.
|
|
1444
|
+
map: 'M2 2.5h5v3.5H2zM9 2.5h5v3.5H9zM5.5 10h5v3.5h-5zM4.5 6v2h7V6M8 8v2',
|
|
1384
1445
|
machine: 'M3.5 3h9A1.5 1.5 0 0 1 14 4.5v5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 9.5v-5A1.5 1.5 0 0 1 3.5 3zM6 14h4M8 11v3',
|
|
1385
1446
|
};
|
|
1386
1447
|
|
|
@@ -1611,6 +1672,23 @@ function versionBadge(kit) {
|
|
|
1611
1672
|
return tag;
|
|
1612
1673
|
}
|
|
1613
1674
|
|
|
1675
|
+
/** The Map entry under a project: opens the project's map, through its latest session. */
|
|
1676
|
+
function mapRow(p, latest) {
|
|
1677
|
+
const row = node('div', 'srow map-row');
|
|
1678
|
+
row.tabIndex = 0;
|
|
1679
|
+
row.setAttribute('role', 'treeitem');
|
|
1680
|
+
row.dataset.map = p.key;
|
|
1681
|
+
const words = node('span', 'map-word', 'Map');
|
|
1682
|
+
row.append(icon(ICON.map), words, node('span', 'row-fill'));
|
|
1683
|
+
if (p.map?.findings) { const n = node('span', 'map-count', String(p.map.findings)); n.title = `${p.map.findings} finding(s) on the map`; row.append(n); }
|
|
1684
|
+
else if (!p.map) row.append(node('span', 'sub', 'no map'));
|
|
1685
|
+
row.title = p.map ? 'Open the project map' : 'This project has no .crewforth/crew-map.json yet';
|
|
1686
|
+
const go = () => { selectSession(latest.sessionId); setView('map'); };
|
|
1687
|
+
row.addEventListener('click', go);
|
|
1688
|
+
row.addEventListener('keydown', (e) => { if (e.target === row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); go(); } });
|
|
1689
|
+
return row;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1614
1692
|
function sessionRow(project, sn) {
|
|
1615
1693
|
const status = sessionStatus(sn.sessionId, fleetData);
|
|
1616
1694
|
const name = sessionName(sn, labels.all());
|
|
@@ -1775,6 +1853,8 @@ function paintProjects() {
|
|
|
1775
1853
|
group.append(head);
|
|
1776
1854
|
|
|
1777
1855
|
if (open) {
|
|
1856
|
+
// Under the project, its map (spec §11): the orange number is how many findings it shows unasked.
|
|
1857
|
+
if (p.local !== false && p.exists && sessions.length) group.append(mapRow(p, sessions[0]));
|
|
1778
1858
|
for (const sn of sessions) group.append(sessionRow(p, sn));
|
|
1779
1859
|
if (p.total > p.sessions.length) {
|
|
1780
1860
|
group.append(node('div', 'nav-line', `${p.total - p.sessions.length} older session(s) not listed`));
|
|
@@ -1960,10 +2040,121 @@ function paintMachines() {
|
|
|
1960
2040
|
});
|
|
1961
2041
|
frag.append(note);
|
|
1962
2042
|
}
|
|
2043
|
+
if (role === 'owner') frag.append(remoteSection());
|
|
1963
2044
|
el.reach.replaceChildren(frag);
|
|
1964
2045
|
paintOtherMachines();
|
|
1965
2046
|
}
|
|
1966
2047
|
|
|
2048
|
+
/* ------------------------------------------------------- remote viewers
|
|
2049
|
+
A viewer is a second token that reads and changes nothing: the way to watch
|
|
2050
|
+
this panel from another device without handing that device the panel. The
|
|
2051
|
+
server refuses a viewer's writes itself; what is hidden here for a viewer is
|
|
2052
|
+
only what would be refused anyway. */
|
|
2053
|
+
|
|
2054
|
+
let role = null; // 'owner' or 'viewer', once the server has said which
|
|
2055
|
+
let remote = null; // the server's list of viewers, or null before it has answered
|
|
2056
|
+
let handed = null; // the link of the viewer just added: the server says a token once
|
|
2057
|
+
|
|
2058
|
+
async function pollRemote() {
|
|
2059
|
+
if (role !== 'owner') return;
|
|
2060
|
+
try { remote = await getJson('/api/remote'); } catch { return; }
|
|
2061
|
+
paintMachines();
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
async function remoteWrite(method, path, body) {
|
|
2065
|
+
try {
|
|
2066
|
+
const r = await fetch(api(path), {
|
|
2067
|
+
method,
|
|
2068
|
+
headers: { ...writeHeaders, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
2069
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
2070
|
+
});
|
|
2071
|
+
return await r.json();
|
|
2072
|
+
} catch (e) {
|
|
2073
|
+
return { ok: false, reason: e.message };
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
async function addViewer(button) {
|
|
2078
|
+
button.disabled = true;
|
|
2079
|
+
const res = await remoteWrite('POST', '/api/remote', {});
|
|
2080
|
+
if (!res.ok) {
|
|
2081
|
+
toast(`No viewer added — ${res.reason ?? 'no reason given'}`);
|
|
2082
|
+
button.disabled = false;
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
|
|
2086
|
+
handed = { id: res.viewer.id, label: res.viewer.label, url: `${location.origin}/?token=${encodeURIComponent(res.viewer.token)}` };
|
|
2087
|
+
paintMachines();
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
async function revokeViewer(v, button) {
|
|
2091
|
+
button.disabled = true;
|
|
2092
|
+
const res = await remoteWrite('DELETE', `/api/remote/${encodeURIComponent(v.id)}`);
|
|
2093
|
+
if (!res.ok) {
|
|
2094
|
+
toast(`Not revoked — ${res.reason ?? 'no reason given'}`);
|
|
2095
|
+
button.disabled = false;
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
|
|
2099
|
+
if (handed?.id === v.id) handed = null;
|
|
2100
|
+
toast(res.revoked ? `${v.label} can no longer read this panel` : `${v.label} was already revoked`);
|
|
2101
|
+
paintMachines();
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function viewerRow(v) {
|
|
2105
|
+
const row = node('div', 'srow remote');
|
|
2106
|
+
const top = node('span', 'srow-top');
|
|
2107
|
+
const revoke = node('button', 'btn sm ghost', 'Revoke');
|
|
2108
|
+
revoke.type = 'button';
|
|
2109
|
+
revoke.setAttribute('aria-label', `Revoke ${v.label}`);
|
|
2110
|
+
revoke.addEventListener('click', () => revokeViewer(v, revoke));
|
|
2111
|
+
top.append(icon(ICON.machine), node('span', 'mname', v.label), node('span', 'row-fill'), revoke);
|
|
2112
|
+
row.append(top);
|
|
2113
|
+
const seen = v.lastSeenAt
|
|
2114
|
+
? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests} request${v.requests === 1 ? '' : 's'}${v.open ? ' · watching now' : ''}`
|
|
2115
|
+
: 'link not opened yet';
|
|
2116
|
+
row.append(node('div', 'sub srow-sub', seen));
|
|
2117
|
+
return row;
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
function remoteSection() {
|
|
2121
|
+
const box = node('div', 'nav-foot rv');
|
|
2122
|
+
const head = node('div', 'rv-head');
|
|
2123
|
+
const add = node('button', 'btn sm', 'Add viewer');
|
|
2124
|
+
add.type = 'button';
|
|
2125
|
+
const list = remote?.viewers ?? [];
|
|
2126
|
+
if (remote?.max != null && list.length >= remote.max) {
|
|
2127
|
+
add.disabled = true;
|
|
2128
|
+
add.title = `${remote.max} viewers already; revoke one first`;
|
|
2129
|
+
}
|
|
2130
|
+
add.addEventListener('click', () => addViewer(add));
|
|
2131
|
+
head.append(node('span', 'rv-title', 'Remote viewers'), node('span', 'row-fill'), add);
|
|
2132
|
+
box.append(head);
|
|
2133
|
+
|
|
2134
|
+
if (handed) {
|
|
2135
|
+
const give = node('div', 'rv-give');
|
|
2136
|
+
give.append(node('div', 'sub rv-say', `Link for ${handed.label}. Shown once; open it on the other device, through your tunnel.`));
|
|
2137
|
+
const url = node('code', 'rv-url', handed.url);
|
|
2138
|
+
const copy = node('button', 'btn sm', 'Copy link');
|
|
2139
|
+
copy.type = 'button';
|
|
2140
|
+
copy.addEventListener('click', () => copyText(handed.url, 'Link copied'));
|
|
2141
|
+
const done = node('button', 'btn sm ghost', 'Done');
|
|
2142
|
+
done.type = 'button';
|
|
2143
|
+
done.addEventListener('click', () => { handed = null; paintMachines(); });
|
|
2144
|
+
const acts = node('div', 'rv-acts');
|
|
2145
|
+
acts.append(copy, done);
|
|
2146
|
+
give.append(url, acts);
|
|
2147
|
+
box.append(give);
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
for (const v of list) box.append(viewerRow(v));
|
|
2151
|
+
if (!list.length) {
|
|
2152
|
+
box.append(node('div', 'sub rv-say',
|
|
2153
|
+
'A viewer reads this panel from another device and changes nothing. The server stays on 127.0.0.1; the link opens through a tunnel you run.'));
|
|
2154
|
+
}
|
|
2155
|
+
return box;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
1967
2158
|
/** The short form under the project list: the first two, and the way to the rest. */
|
|
1968
2159
|
function paintOtherMachines() {
|
|
1969
2160
|
const m = machines(fleetData);
|
|
@@ -2226,7 +2417,7 @@ function paintPulse() {
|
|
|
2226
2417
|
pulse.detail.textContent = l.detail ? `· ${l.detail}` : '';
|
|
2227
2418
|
}
|
|
2228
2419
|
paintPulse();
|
|
2229
|
-
setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } }, 1000);
|
|
2420
|
+
setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } if (view === 'map') loadMap(); }, 1000);
|
|
2230
2421
|
|
|
2231
2422
|
/* -------------------------------------------------------------- home, keys */
|
|
2232
2423
|
|
|
@@ -2254,6 +2445,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
2254
2445
|
if (shell.classList.contains('side-open')) { setDrawer(false); return; }
|
|
2255
2446
|
if (newPanel.isOpen) { newPanel.close(); return; }
|
|
2256
2447
|
if (view === 'timeline' && timeline.selected) { timeline.select(null); return; }
|
|
2448
|
+
if (view === 'map' && mapView.escape()) return;
|
|
2257
2449
|
// Close the inspector and let go of the selection.
|
|
2258
2450
|
if (!el.inspector.hidden) { canvas.clearSelection(); showInspector(null); }
|
|
2259
2451
|
return;
|
|
@@ -2277,9 +2469,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
2277
2469
|
} else if (key === 'l') {
|
|
2278
2470
|
setView('list');
|
|
2279
2471
|
} else if (key === 'r') {
|
|
2280
|
-
if (view === 'graph') resetLayout();
|
|
2472
|
+
if (view === 'graph' || view === 'map') resetLayout();
|
|
2281
2473
|
} else if (key === 'm') {
|
|
2282
2474
|
setView('models');
|
|
2475
|
+
} else if (key === 'p') {
|
|
2476
|
+
setView('map');
|
|
2283
2477
|
} else if (key === 'j') {
|
|
2284
2478
|
stepAttention(1);
|
|
2285
2479
|
} else if (key === 'k') {
|
|
@@ -2465,6 +2659,7 @@ chat.onPermissions = () => pollOwned();
|
|
|
2465
2659
|
view is being shown in a window too narrow for it. Offline is said first. */
|
|
2466
2660
|
|
|
2467
2661
|
let nextPollAt = null;
|
|
2662
|
+
let restartNote = null; // staleServerNote(/api/health): the server runs older code than the page
|
|
2468
2663
|
const stageNote = { text: node('span', 'stage-note-text'), retry: node('span', 'sub'), button: node('button', 'btn sm', 'Retry now') };
|
|
2469
2664
|
stageNote.button.type = 'button';
|
|
2470
2665
|
stageNote.button.addEventListener('click', () => retryNow());
|
|
@@ -2473,11 +2668,12 @@ el.stageNote.append(stageNote.text, stageNote.retry, node('span', 'row-fill'), s
|
|
|
2473
2668
|
function paintStageNote() {
|
|
2474
2669
|
const l = liveness(Date.now(), heard.okAt, heard.failed);
|
|
2475
2670
|
const off = offlineNote(l.state, heard.okAt, nextPollAt, Date.now());
|
|
2476
|
-
|
|
2671
|
+
// Offline first, then a server older than this page, then a window too narrow for the view.
|
|
2672
|
+
const warn = restartNote ?? narrowWarning(view, phoneBand.matches);
|
|
2477
2673
|
// The last picture stays on screen while the server is away, and is drawn as old.
|
|
2478
2674
|
el.stage.dataset.offline = String(Boolean(off));
|
|
2479
2675
|
el.stageNote.hidden = !off && !warn;
|
|
2480
|
-
el.stageNote.dataset.kind = off ? 'offline' : 'narrow';
|
|
2676
|
+
el.stageNote.dataset.kind = off ? 'offline' : restartNote ? 'restart' : 'narrow';
|
|
2481
2677
|
stageNote.text.textContent = off ? off.text : (warn ?? '');
|
|
2482
2678
|
stageNote.retry.textContent = off ? off.retry : '';
|
|
2483
2679
|
stageNote.retry.hidden = !off;
|
|
@@ -2619,7 +2815,19 @@ applyPhoneBand(true);
|
|
|
2619
2815
|
setView(defaultView(stored, phoneBand.matches), null, Boolean(stored));
|
|
2620
2816
|
}
|
|
2621
2817
|
pollFleet(); pollSessions(); pollOwned();
|
|
2818
|
+
// Who this page is to the server. A viewer's page says so and offers no way to start a session; the server
|
|
2819
|
+
// would refuse it either way.
|
|
2820
|
+
getJson('/api/health').then((h) => {
|
|
2821
|
+
restartNote = staleServerNote(h);
|
|
2822
|
+
// A Map read before this answer showed the old server's 404; read it again so it says why.
|
|
2823
|
+
if (restartNote) { paintStageNote(); if (view === 'map') loadMap(true); }
|
|
2824
|
+
role = h.role === 'viewer' ? 'viewer' : 'owner';
|
|
2825
|
+
document.body.dataset.role = role;
|
|
2826
|
+
document.getElementById('role').hidden = role !== 'viewer';
|
|
2827
|
+
if (role === 'owner') pollRemote(); else paintMachines();
|
|
2828
|
+
}).catch(() => { /* the Live indicator says the server is unreachable */ });
|
|
2622
2829
|
setInterval(() => { nextPollAt = Date.now() + FLEET_POLL_MS; pollFleet(); }, FLEET_POLL_MS);
|
|
2830
|
+
setInterval(pollRemote, FLEET_POLL_MS);
|
|
2623
2831
|
nextPollAt = Date.now() + FLEET_POLL_MS;
|
|
2624
2832
|
setInterval(pollOwned, FLEET_POLL_MS);
|
|
2625
2833
|
setInterval(pollSessions, SESSION_POLL_MS);
|