crewforth 3.1.1 → 3.2.0-rc.1
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 +138 -0
- package/README.md +4 -3
- package/VERSION +1 -1
- package/adopt.sh +11 -0
- package/kit/AGENT_TEMPLATE.md +2 -1
- package/kit/CLAUDE.md +9 -8
- 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 +91 -3
- 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-json.awk +5 -0
- package/kit/eval/lib/star.sh +23 -7
- package/kit/eval/lib/statusline.settings.json +6 -0
- package/kit/eval/lib/statusline.sh +115 -0
- package/kit/eval/smoke-test.sh +465 -38
- package/kit/hooks/agent-outcome.sh +6 -3
- package/kit/hooks/guard-agent-model.sh +15 -5
- 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/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 +114 -11
- 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/pricing.js +3 -0
- package/kit/studio/server/lib/remote.js +99 -0
- package/kit/studio/server/lib/usage.js +74 -14
- package/kit/studio/web/app.js +214 -12
- package/kit/studio/web/canvas.js +44 -120
- package/kit/studio/web/graph-plan.js +10 -4
- package/kit/studio/web/index.html +10 -5
- 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 +125 -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
package/kit/studio/web/app.js
CHANGED
|
@@ -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,36 @@ 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
|
+
try { data = await getJson(`/api/map?${q}`); } catch (e) { data = { measured: false, reason: e.message, others: [] }; }
|
|
719
|
+
finally { mapBusy = false; }
|
|
720
|
+
if (asked !== current) return;
|
|
721
|
+
mapFor = asked;
|
|
722
|
+
mapView.setData(data);
|
|
723
|
+
}
|
|
724
|
+
const MAP_POLL_MS = 30000;
|
|
725
|
+
|
|
682
726
|
const list = new List(el.list, {
|
|
683
727
|
tileOf: (n) => canvas.tileFor(n),
|
|
684
728
|
dimmed: (n) => canvas.dimmed(n),
|
|
@@ -713,7 +757,7 @@ function paintTimelineBar(st) {
|
|
|
713
757
|
*/
|
|
714
758
|
function setView(next, select = null, remember = true) {
|
|
715
759
|
const was = view;
|
|
716
|
-
view = ['graph', 'timeline', 'list', 'models'].includes(next) ? next : 'graph';
|
|
760
|
+
view = ['graph', 'timeline', 'list', 'models', 'map'].includes(next) ? next : 'graph';
|
|
717
761
|
// A view the window opened on by default is not a choice, and is not remembered as one.
|
|
718
762
|
if (remember) store.set('crewforth-studio-view', view);
|
|
719
763
|
const carried = select ?? (was === 'timeline' ? timeline.selected : was === 'list' ? list.selected : canvas.selected) ?? null;
|
|
@@ -722,6 +766,8 @@ function setView(next, select = null, remember = true) {
|
|
|
722
766
|
el.list.hidden = view !== 'list';
|
|
723
767
|
el.models.hidden = view !== 'models';
|
|
724
768
|
el.viewModels.setAttribute('aria-selected', String(view === 'models'));
|
|
769
|
+
el.map.hidden = view !== 'map';
|
|
770
|
+
el.viewMap.setAttribute('aria-selected', String(view === 'map'));
|
|
725
771
|
el.viewGraph.setAttribute('aria-selected', String(view === 'graph'));
|
|
726
772
|
el.viewTimeline.setAttribute('aria-selected', String(view === 'timeline'));
|
|
727
773
|
el.viewList.setAttribute('aria-selected', String(view === 'list'));
|
|
@@ -751,7 +797,14 @@ function setView(next, select = null, remember = true) {
|
|
|
751
797
|
if (costsFor !== current) models.setCosts(null);
|
|
752
798
|
loadModels(true);
|
|
753
799
|
loadCosts(costsFor !== current);
|
|
800
|
+
} else if (view === 'map') {
|
|
801
|
+
// The map is the project's: read when the view opens, drawn with what was last read until the answer comes.
|
|
802
|
+
mapView.render();
|
|
803
|
+
paintZoom(mapView.zoom, mapView.moved);
|
|
804
|
+
loadMap(true);
|
|
754
805
|
} else {
|
|
806
|
+
// Back on the graph: the zoom and Reset say the graph's again.
|
|
807
|
+
paintToolbar(canvas.state());
|
|
755
808
|
canvas.fitIfUntouched();
|
|
756
809
|
if (carried && canvas.nodes.has(carried)) canvas.focus(carried);
|
|
757
810
|
}
|
|
@@ -768,13 +821,14 @@ el.viewGraph.addEventListener('click', () => setView('graph'));
|
|
|
768
821
|
el.viewTimeline.addEventListener('click', () => setView('timeline'));
|
|
769
822
|
el.viewList.addEventListener('click', () => setView('list'));
|
|
770
823
|
el.viewModels.addEventListener('click', () => setView('models'));
|
|
824
|
+
el.viewMap.addEventListener('click', () => setView('map'));
|
|
771
825
|
el.tbFollow.addEventListener('click', () => timeline.setFollow(el.tbFollow.getAttribute('aria-checked') !== 'true'));
|
|
772
826
|
// "Out" is more time on screen, "in" is less: the same way round as the graph's zoom.
|
|
773
827
|
el.tbRangeOut.addEventListener('click', () => timeline.zoom(1));
|
|
774
828
|
el.tbRangeIn.addEventListener('click', () => timeline.zoom(-1));
|
|
775
829
|
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => timeline.render())).observe(el.timeline);
|
|
776
830
|
// 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.
|
|
831
|
+
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => canvas.paneResized())).observe(el.canvasEl);
|
|
778
832
|
|
|
779
833
|
getJson('/api/palette')
|
|
780
834
|
.then((p) => {
|
|
@@ -980,11 +1034,15 @@ function paintAgentOverview(body, n, detail) {
|
|
|
980
1034
|
if (lines.length) {
|
|
981
1035
|
const dl = node('dl', 'icard');
|
|
982
1036
|
for (const l of lines) dl.append(node('dt', null, l.label), node('dd', null, l.text));
|
|
1037
|
+
// Why the gate called it critical, in its own words (`files ... is on a critical path: ...` or `change: ...`).
|
|
1038
|
+
if (work.riskWhy) dl.append(node('dt', null, 'why critical'), node('dd', null, work.riskWhy));
|
|
983
1039
|
const sec = isection('Task card', dl);
|
|
984
1040
|
if (work.risk) { const tag = node('span', 'mv-tag', work.risk); tag.dataset.risk = work.risk; sec.children[0].append(tag); }
|
|
985
1041
|
body.append(sec);
|
|
986
1042
|
}
|
|
987
|
-
//
|
|
1043
|
+
// Not a crew agent: no card and no line, and that is said, so the empty space is not read as a record lost.
|
|
1044
|
+
const outside = !row && !lines.length ? outsideRouting(n.agentType) : null;
|
|
1045
|
+
if (outside) body.append(isection('Model routing', node('p', 'istep-note', outside)));
|
|
988
1046
|
if (row || lines.length) {
|
|
989
1047
|
const ol = node('ol', 'isteps');
|
|
990
1048
|
work.steps.forEach((st, i) => {
|
|
@@ -1381,6 +1439,8 @@ const ICON = {
|
|
|
1381
1439
|
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
1440
|
chevron: 'M6 4l4 4-4 4',
|
|
1383
1441
|
more: 'M3.5 8h.01M8 8h.01M12.5 8h.01',
|
|
1442
|
+
// The Map view's tab mark: three cards and the lines between them.
|
|
1443
|
+
map: 'M2 2.5h5v3.5H2zM9 2.5h5v3.5H9zM5.5 10h5v3.5h-5zM4.5 6v2h7V6M8 8v2',
|
|
1384
1444
|
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
1445
|
};
|
|
1386
1446
|
|
|
@@ -1611,6 +1671,23 @@ function versionBadge(kit) {
|
|
|
1611
1671
|
return tag;
|
|
1612
1672
|
}
|
|
1613
1673
|
|
|
1674
|
+
/** The Map entry under a project: opens the project's map, through its latest session. */
|
|
1675
|
+
function mapRow(p, latest) {
|
|
1676
|
+
const row = node('div', 'srow map-row');
|
|
1677
|
+
row.tabIndex = 0;
|
|
1678
|
+
row.setAttribute('role', 'treeitem');
|
|
1679
|
+
row.dataset.map = p.key;
|
|
1680
|
+
const words = node('span', 'map-word', 'Map');
|
|
1681
|
+
row.append(icon(ICON.map), words, node('span', 'row-fill'));
|
|
1682
|
+
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); }
|
|
1683
|
+
else if (!p.map) row.append(node('span', 'sub', 'no map'));
|
|
1684
|
+
row.title = p.map ? 'Open the project map' : 'This project has no .crewforth/crew-map.json yet';
|
|
1685
|
+
const go = () => { selectSession(latest.sessionId); setView('map'); };
|
|
1686
|
+
row.addEventListener('click', go);
|
|
1687
|
+
row.addEventListener('keydown', (e) => { if (e.target === row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); go(); } });
|
|
1688
|
+
return row;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1614
1691
|
function sessionRow(project, sn) {
|
|
1615
1692
|
const status = sessionStatus(sn.sessionId, fleetData);
|
|
1616
1693
|
const name = sessionName(sn, labels.all());
|
|
@@ -1775,6 +1852,8 @@ function paintProjects() {
|
|
|
1775
1852
|
group.append(head);
|
|
1776
1853
|
|
|
1777
1854
|
if (open) {
|
|
1855
|
+
// Under the project, its map (spec §11): the orange number is how many findings it shows unasked.
|
|
1856
|
+
if (p.local !== false && p.exists && sessions.length) group.append(mapRow(p, sessions[0]));
|
|
1778
1857
|
for (const sn of sessions) group.append(sessionRow(p, sn));
|
|
1779
1858
|
if (p.total > p.sessions.length) {
|
|
1780
1859
|
group.append(node('div', 'nav-line', `${p.total - p.sessions.length} older session(s) not listed`));
|
|
@@ -1960,10 +2039,121 @@ function paintMachines() {
|
|
|
1960
2039
|
});
|
|
1961
2040
|
frag.append(note);
|
|
1962
2041
|
}
|
|
2042
|
+
if (role === 'owner') frag.append(remoteSection());
|
|
1963
2043
|
el.reach.replaceChildren(frag);
|
|
1964
2044
|
paintOtherMachines();
|
|
1965
2045
|
}
|
|
1966
2046
|
|
|
2047
|
+
/* ------------------------------------------------------- remote viewers
|
|
2048
|
+
A viewer is a second token that reads and changes nothing: the way to watch
|
|
2049
|
+
this panel from another device without handing that device the panel. The
|
|
2050
|
+
server refuses a viewer's writes itself; what is hidden here for a viewer is
|
|
2051
|
+
only what would be refused anyway. */
|
|
2052
|
+
|
|
2053
|
+
let role = null; // 'owner' or 'viewer', once the server has said which
|
|
2054
|
+
let remote = null; // the server's list of viewers, or null before it has answered
|
|
2055
|
+
let handed = null; // the link of the viewer just added: the server says a token once
|
|
2056
|
+
|
|
2057
|
+
async function pollRemote() {
|
|
2058
|
+
if (role !== 'owner') return;
|
|
2059
|
+
try { remote = await getJson('/api/remote'); } catch { return; }
|
|
2060
|
+
paintMachines();
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
async function remoteWrite(method, path, body) {
|
|
2064
|
+
try {
|
|
2065
|
+
const r = await fetch(api(path), {
|
|
2066
|
+
method,
|
|
2067
|
+
headers: { ...writeHeaders, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
2068
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
2069
|
+
});
|
|
2070
|
+
return await r.json();
|
|
2071
|
+
} catch (e) {
|
|
2072
|
+
return { ok: false, reason: e.message };
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
async function addViewer(button) {
|
|
2077
|
+
button.disabled = true;
|
|
2078
|
+
const res = await remoteWrite('POST', '/api/remote', {});
|
|
2079
|
+
if (!res.ok) {
|
|
2080
|
+
toast(`No viewer added — ${res.reason ?? 'no reason given'}`);
|
|
2081
|
+
button.disabled = false;
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
|
|
2085
|
+
handed = { id: res.viewer.id, label: res.viewer.label, url: `${location.origin}/?token=${encodeURIComponent(res.viewer.token)}` };
|
|
2086
|
+
paintMachines();
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
async function revokeViewer(v, button) {
|
|
2090
|
+
button.disabled = true;
|
|
2091
|
+
const res = await remoteWrite('DELETE', `/api/remote/${encodeURIComponent(v.id)}`);
|
|
2092
|
+
if (!res.ok) {
|
|
2093
|
+
toast(`Not revoked — ${res.reason ?? 'no reason given'}`);
|
|
2094
|
+
button.disabled = false;
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
|
|
2098
|
+
if (handed?.id === v.id) handed = null;
|
|
2099
|
+
toast(res.revoked ? `${v.label} can no longer read this panel` : `${v.label} was already revoked`);
|
|
2100
|
+
paintMachines();
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
function viewerRow(v) {
|
|
2104
|
+
const row = node('div', 'srow remote');
|
|
2105
|
+
const top = node('span', 'srow-top');
|
|
2106
|
+
const revoke = node('button', 'btn sm ghost', 'Revoke');
|
|
2107
|
+
revoke.type = 'button';
|
|
2108
|
+
revoke.setAttribute('aria-label', `Revoke ${v.label}`);
|
|
2109
|
+
revoke.addEventListener('click', () => revokeViewer(v, revoke));
|
|
2110
|
+
top.append(icon(ICON.machine), node('span', 'mname', v.label), node('span', 'row-fill'), revoke);
|
|
2111
|
+
row.append(top);
|
|
2112
|
+
const seen = v.lastSeenAt
|
|
2113
|
+
? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests} request${v.requests === 1 ? '' : 's'}${v.open ? ' · watching now' : ''}`
|
|
2114
|
+
: 'link not opened yet';
|
|
2115
|
+
row.append(node('div', 'sub srow-sub', seen));
|
|
2116
|
+
return row;
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
function remoteSection() {
|
|
2120
|
+
const box = node('div', 'nav-foot rv');
|
|
2121
|
+
const head = node('div', 'rv-head');
|
|
2122
|
+
const add = node('button', 'btn sm', 'Add viewer');
|
|
2123
|
+
add.type = 'button';
|
|
2124
|
+
const list = remote?.viewers ?? [];
|
|
2125
|
+
if (remote?.max != null && list.length >= remote.max) {
|
|
2126
|
+
add.disabled = true;
|
|
2127
|
+
add.title = `${remote.max} viewers already; revoke one first`;
|
|
2128
|
+
}
|
|
2129
|
+
add.addEventListener('click', () => addViewer(add));
|
|
2130
|
+
head.append(node('span', 'rv-title', 'Remote viewers'), node('span', 'row-fill'), add);
|
|
2131
|
+
box.append(head);
|
|
2132
|
+
|
|
2133
|
+
if (handed) {
|
|
2134
|
+
const give = node('div', 'rv-give');
|
|
2135
|
+
give.append(node('div', 'sub rv-say', `Link for ${handed.label}. Shown once; open it on the other device, through your tunnel.`));
|
|
2136
|
+
const url = node('code', 'rv-url', handed.url);
|
|
2137
|
+
const copy = node('button', 'btn sm', 'Copy link');
|
|
2138
|
+
copy.type = 'button';
|
|
2139
|
+
copy.addEventListener('click', () => copyText(handed.url, 'Link copied'));
|
|
2140
|
+
const done = node('button', 'btn sm ghost', 'Done');
|
|
2141
|
+
done.type = 'button';
|
|
2142
|
+
done.addEventListener('click', () => { handed = null; paintMachines(); });
|
|
2143
|
+
const acts = node('div', 'rv-acts');
|
|
2144
|
+
acts.append(copy, done);
|
|
2145
|
+
give.append(url, acts);
|
|
2146
|
+
box.append(give);
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
for (const v of list) box.append(viewerRow(v));
|
|
2150
|
+
if (!list.length) {
|
|
2151
|
+
box.append(node('div', 'sub rv-say',
|
|
2152
|
+
'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.'));
|
|
2153
|
+
}
|
|
2154
|
+
return box;
|
|
2155
|
+
}
|
|
2156
|
+
|
|
1967
2157
|
/** The short form under the project list: the first two, and the way to the rest. */
|
|
1968
2158
|
function paintOtherMachines() {
|
|
1969
2159
|
const m = machines(fleetData);
|
|
@@ -2226,7 +2416,7 @@ function paintPulse() {
|
|
|
2226
2416
|
pulse.detail.textContent = l.detail ? `· ${l.detail}` : '';
|
|
2227
2417
|
}
|
|
2228
2418
|
paintPulse();
|
|
2229
|
-
setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } }, 1000);
|
|
2419
|
+
setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } if (view === 'map') loadMap(); }, 1000);
|
|
2230
2420
|
|
|
2231
2421
|
/* -------------------------------------------------------------- home, keys */
|
|
2232
2422
|
|
|
@@ -2254,6 +2444,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
2254
2444
|
if (shell.classList.contains('side-open')) { setDrawer(false); return; }
|
|
2255
2445
|
if (newPanel.isOpen) { newPanel.close(); return; }
|
|
2256
2446
|
if (view === 'timeline' && timeline.selected) { timeline.select(null); return; }
|
|
2447
|
+
if (view === 'map' && mapView.escape()) return;
|
|
2257
2448
|
// Close the inspector and let go of the selection.
|
|
2258
2449
|
if (!el.inspector.hidden) { canvas.clearSelection(); showInspector(null); }
|
|
2259
2450
|
return;
|
|
@@ -2277,9 +2468,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
2277
2468
|
} else if (key === 'l') {
|
|
2278
2469
|
setView('list');
|
|
2279
2470
|
} else if (key === 'r') {
|
|
2280
|
-
if (view === 'graph') resetLayout();
|
|
2471
|
+
if (view === 'graph' || view === 'map') resetLayout();
|
|
2281
2472
|
} else if (key === 'm') {
|
|
2282
2473
|
setView('models');
|
|
2474
|
+
} else if (key === 'p') {
|
|
2475
|
+
setView('map');
|
|
2283
2476
|
} else if (key === 'j') {
|
|
2284
2477
|
stepAttention(1);
|
|
2285
2478
|
} else if (key === 'k') {
|
|
@@ -2619,7 +2812,16 @@ applyPhoneBand(true);
|
|
|
2619
2812
|
setView(defaultView(stored, phoneBand.matches), null, Boolean(stored));
|
|
2620
2813
|
}
|
|
2621
2814
|
pollFleet(); pollSessions(); pollOwned();
|
|
2815
|
+
// Who this page is to the server. A viewer's page says so and offers no way to start a session; the server
|
|
2816
|
+
// would refuse it either way.
|
|
2817
|
+
getJson('/api/health').then((h) => {
|
|
2818
|
+
role = h.role === 'viewer' ? 'viewer' : 'owner';
|
|
2819
|
+
document.body.dataset.role = role;
|
|
2820
|
+
document.getElementById('role').hidden = role !== 'viewer';
|
|
2821
|
+
if (role === 'owner') pollRemote(); else paintMachines();
|
|
2822
|
+
}).catch(() => { /* the Live indicator says the server is unreachable */ });
|
|
2622
2823
|
setInterval(() => { nextPollAt = Date.now() + FLEET_POLL_MS; pollFleet(); }, FLEET_POLL_MS);
|
|
2824
|
+
setInterval(pollRemote, FLEET_POLL_MS);
|
|
2623
2825
|
nextPollAt = Date.now() + FLEET_POLL_MS;
|
|
2624
2826
|
setInterval(pollOwned, FLEET_POLL_MS);
|
|
2625
2827
|
setInterval(pollSessions, SESSION_POLL_MS);
|
package/kit/studio/web/canvas.js
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
// A status is never colour alone: every dot has its word beside it.
|
|
12
12
|
// A poll that changed nothing writes nothing. Each element remembers what it last drew.
|
|
13
13
|
import {
|
|
14
|
-
plan, sequence, crowdFolds,
|
|
14
|
+
plan, sequence, crowdFolds, tallestFor, stateOf, GROUPINGS, DENSITIES, INFERRED_NOTE, AUTO,
|
|
15
15
|
} from './graph-plan.js';
|
|
16
16
|
import { clock } from './timeline-plan.js';
|
|
17
|
+
import { Viewport, draggable, swallowClick } from './viewport.js';
|
|
17
18
|
import { agentStatus } from './nav.js';
|
|
18
19
|
import { roleName, shownType, hoverOf, originOf, orderTag, modelOf, modelMix, modelFamily, modelBadge } from './names.js';
|
|
19
20
|
import { fmtCount, fmtCost, COST_NOTE } from './usage.js';
|
|
@@ -38,6 +39,11 @@ const MOTION_BUDGET = 60;
|
|
|
38
39
|
// How long the pointer rests on a card before its details are shown.
|
|
39
40
|
const TIP_MS = 400;
|
|
40
41
|
|
|
42
|
+
// How long a pane has to stay one size before Auto decides again between cards and chips. While its edge is being
|
|
43
|
+
// dragged the view follows at once, but the choice waits: measured 2026-10-14 with 43 agents, an edge moved to and
|
|
44
|
+
// fro across Auto's limit for 60 frames turned every card into a chip and back 57 times, each a full redraw.
|
|
45
|
+
export const RESIZE_SETTLE_MS = 150;
|
|
46
|
+
|
|
41
47
|
// Marks, so a card says what kind of thing it is before it is read.
|
|
42
48
|
//
|
|
43
49
|
// Crewforth's own is the three chevrons, drawn here because the shape is three strokes and its colours are the
|
|
@@ -77,7 +83,6 @@ export class Canvas {
|
|
|
77
83
|
this.onSelect = onSelect ?? (() => {});
|
|
78
84
|
this.onMenu = onMenu ?? (() => {});
|
|
79
85
|
this.onChange = onChange ?? (() => {});
|
|
80
|
-
this.view = { x: 0, y: 0, k: 1 };
|
|
81
86
|
this.nodes = new Map(); // id -> the server's node
|
|
82
87
|
this.pos = new Map(); // drawn item id -> {x, y}
|
|
83
88
|
this.pinned = new Map(); // item id -> {x, y} the viewer set by hand
|
|
@@ -108,7 +113,6 @@ export class Canvas {
|
|
|
108
113
|
this.sessionY = null;
|
|
109
114
|
this.selected = null;
|
|
110
115
|
this.firstRender = true;
|
|
111
|
-
this.touched = false; // the viewer moved the view themselves
|
|
112
116
|
this.drawn = 0;
|
|
113
117
|
|
|
114
118
|
this.#build();
|
|
@@ -286,51 +290,13 @@ export class Canvas {
|
|
|
286
290
|
/* ------------------------------------------------------------ input */
|
|
287
291
|
|
|
288
292
|
#wire() {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (t.closest?.('.cv-node') || t.closest?.('.cv-minimap') || t.closest?.('.cv-legend')) return; // the summary box is a .cv-legend too
|
|
295
|
-
panning = { px: e.clientX, py: e.clientY, x: this.view.x, y: this.view.y };
|
|
296
|
-
this.root.setPointerCapture(e.pointerId);
|
|
297
|
-
this.root.classList.add('cv-panning');
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
this.root.addEventListener('pointermove', (e) => {
|
|
301
|
-
if (!panning) return;
|
|
302
|
-
this.view.x = panning.x + (e.clientX - panning.px);
|
|
303
|
-
this.view.y = panning.y + (e.clientY - panning.py);
|
|
304
|
-
this.touched = true;
|
|
305
|
-
this.applyView();
|
|
293
|
+
// Pan, zoom and Fit are the shared viewport's: the same on the graph and on the project map.
|
|
294
|
+
this.vp = new Viewport(this.root, this.viewport, {
|
|
295
|
+
// the summary box is a .cv-legend too
|
|
296
|
+
skip: (t) => Boolean(t.closest?.('.cv-node') || t.closest?.('.cv-minimap') || t.closest?.('.cv-legend')),
|
|
297
|
+
onApply: () => { this.#mapView(); this.onChange(this.state()); },
|
|
306
298
|
});
|
|
307
299
|
|
|
308
|
-
const endPan = () => { panning = null; this.root.classList.remove('cv-panning'); };
|
|
309
|
-
this.root.addEventListener('pointerup', endPan);
|
|
310
|
-
this.root.addEventListener('pointercancel', endPan);
|
|
311
|
-
|
|
312
|
-
this.root.addEventListener('wheel', (e) => {
|
|
313
|
-
e.preventDefault();
|
|
314
|
-
const r = this.root.getBoundingClientRect();
|
|
315
|
-
const mx = e.clientX - r.left;
|
|
316
|
-
const my = e.clientY - r.top;
|
|
317
|
-
|
|
318
|
-
// deltaMode 1 is lines and 2 is pages; Firefox reports ~3 where Chrome reports ~100, which made zoom
|
|
319
|
-
// imperceptible and pan crawl.
|
|
320
|
-
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? r.height : 1;
|
|
321
|
-
const dY = e.deltaY * unit;
|
|
322
|
-
const dX = e.deltaX * unit;
|
|
323
|
-
|
|
324
|
-
if (e.ctrlKey || e.metaKey || Math.abs(dY) > Math.abs(dX)) {
|
|
325
|
-
this.#zoomAbout(this.view.k * Math.exp(-dY * 0.0016), mx, my);
|
|
326
|
-
} else {
|
|
327
|
-
this.view.x -= dX;
|
|
328
|
-
this.view.y -= dY;
|
|
329
|
-
}
|
|
330
|
-
this.touched = true;
|
|
331
|
-
this.applyView();
|
|
332
|
-
}, { passive: false });
|
|
333
|
-
|
|
334
300
|
// The minimap's frame is the pane: put the pointer somewhere on the map and the pane goes there.
|
|
335
301
|
let mapping = false;
|
|
336
302
|
const mapTo = (e) => {
|
|
@@ -352,28 +318,14 @@ export class Canvas {
|
|
|
352
318
|
this.mapEl.addEventListener('pointercancel', () => { mapping = false; });
|
|
353
319
|
}
|
|
354
320
|
|
|
355
|
-
/**
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
this.view.y = my - (my - this.view.y) * (next / this.view.k);
|
|
360
|
-
this.view.k = next;
|
|
361
|
-
}
|
|
321
|
+
/** Where the pane looks: the viewport's, kept under these names because the page and the claims use them. */
|
|
322
|
+
get view() { return this.vp.view; }
|
|
323
|
+
get touched() { return this.vp.touched; }
|
|
324
|
+
set touched(v) { this.vp.touched = v; }
|
|
362
325
|
|
|
363
326
|
/** Step the zoom from the toolbar: about the middle of the pane. */
|
|
364
|
-
zoomBy(factor) {
|
|
365
|
-
|
|
366
|
-
this.#zoomAbout(this.view.k * factor, r.width / 2, r.height / 2);
|
|
367
|
-
this.touched = true;
|
|
368
|
-
this.applyView();
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
zoomTo(k) {
|
|
372
|
-
const r = this.root.getBoundingClientRect();
|
|
373
|
-
this.#zoomAbout(k, r.width / 2, r.height / 2);
|
|
374
|
-
this.touched = true;
|
|
375
|
-
this.applyView();
|
|
376
|
-
}
|
|
327
|
+
zoomBy(factor) { this.vp.zoomBy(factor); }
|
|
328
|
+
zoomTo(k) { this.vp.zoomTo(k); }
|
|
377
329
|
|
|
378
330
|
/**
|
|
379
331
|
* Turn `view` into pixels. Public because it is the single place that does so — the wheel, the pan, fit() and
|
|
@@ -382,12 +334,7 @@ export class Canvas {
|
|
|
382
334
|
* It writes one transform. Nothing a card draws depends on the zoom, so a zoom restyles nothing: that was the
|
|
383
335
|
* cost of the old counter-scaled labels, 454 ms of style work per second at 250 nodes.
|
|
384
336
|
*/
|
|
385
|
-
applyView() {
|
|
386
|
-
const { x, y, k } = this.view;
|
|
387
|
-
this.viewport.style.transform = `translate(${x}px, ${y}px) scale(${k})`;
|
|
388
|
-
this.#mapView();
|
|
389
|
-
this.onChange(this.state());
|
|
390
|
-
}
|
|
337
|
+
applyView() { this.vp.apply(); }
|
|
391
338
|
|
|
392
339
|
/** What the toolbar shows. */
|
|
393
340
|
state() {
|
|
@@ -587,25 +534,31 @@ export class Canvas {
|
|
|
587
534
|
return tallestFor(this.paneHeight);
|
|
588
535
|
}
|
|
589
536
|
|
|
537
|
+
/** The pane is changing size. The view follows it now; whether it is cards or chips is asked once it stops. */
|
|
538
|
+
paneResized() {
|
|
539
|
+
if (!this.touched && !this.pinned.size) this.fit({ readable: true });
|
|
540
|
+
clearTimeout(this.settleTimer);
|
|
541
|
+
this.settleTimer = setTimeout(() => { this.settleTimer = null; this.fitIfUntouched(); }, RESIZE_SETTLE_MS);
|
|
542
|
+
}
|
|
543
|
+
|
|
590
544
|
/** Re-fit only while the view is still the canvas's own. Once the viewer has panned, zoomed or placed a card,
|
|
591
545
|
* moving the view under them is not helpful. */
|
|
592
546
|
fitIfUntouched() {
|
|
593
547
|
// The pane changed size: what fits in it may have changed with it, and so may Auto's answer.
|
|
594
548
|
const h = this.root.getBoundingClientRect?.().height ?? 0;
|
|
595
549
|
if (this.lastPlan && this.density === 'auto' && tallestFor(h) !== tallestFor(this.paneHeight ?? 0)) this.#redraw();
|
|
596
|
-
if (!this.touched && !this.pinned.size) this.fit();
|
|
550
|
+
if (!this.touched && !this.pinned.size) this.fit({ readable: true });
|
|
597
551
|
}
|
|
598
552
|
|
|
599
|
-
|
|
553
|
+
/**
|
|
554
|
+
* Fit the picture to the pane. On its own (`readable`), the view does not go under the zoom a card is read at
|
|
555
|
+
* when Auto kept cards for a picture taller than the pane: that is the overflow Auto allows, panned to, not
|
|
556
|
+
* shrunk away. The toolbar's Fit asks for the whole picture.
|
|
557
|
+
*/
|
|
558
|
+
fit({ readable = false } = {}) {
|
|
600
559
|
const p = this.lastPlan;
|
|
601
560
|
if (!p || !p.items.length) return;
|
|
602
|
-
|
|
603
|
-
const k = fitZoom(p.width, p.height, r.width, r.height);
|
|
604
|
-
this.view.k = k;
|
|
605
|
-
this.view.x = Math.max(ZOOM.pad, (r.width - p.width * k) / 2);
|
|
606
|
-
this.view.y = Math.max(ZOOM.pad, (r.height - p.height * k) / 2);
|
|
607
|
-
this.touched = false;
|
|
608
|
-
this.applyView();
|
|
561
|
+
this.vp.fit(p.width, p.height, readable && this.density === 'auto' && p.density === 'comfortable' ? AUTO.readable : 0);
|
|
609
562
|
}
|
|
610
563
|
|
|
611
564
|
/* -------------------------------------------------------- rendering */
|
|
@@ -699,7 +652,7 @@ export class Canvas {
|
|
|
699
652
|
|
|
700
653
|
this.#drawEdges(p);
|
|
701
654
|
this.#drawMap(p);
|
|
702
|
-
if (fit) this.fit(); else this.applyView();
|
|
655
|
+
if (fit) this.fit({ readable: true }); else this.applyView();
|
|
703
656
|
}
|
|
704
657
|
|
|
705
658
|
/** The card's position and size, and whatever only changes with them. */
|
|
@@ -983,7 +936,7 @@ export class Canvas {
|
|
|
983
936
|
// Beside the bar: how the members went — or, at compact density where the card has no line for it, the
|
|
984
937
|
// models they ran on, with how they went left to the bar and its tooltip.
|
|
985
938
|
const mix = modelMix(it.members).replace(/, /g, ' \u00b7 ');
|
|
986
|
-
if (!it.boxed) { p.count.textContent = it.compact && mix ? mix : says; p.count.title = it.compact && mix ? `${says}\nThe models its agents ran on` : ''; }
|
|
939
|
+
if (!it.boxed) { p.count.textContent = it.compact && mix ? `${says} \u00b7 ${mix}` : says; p.count.title = it.compact && mix ? `${says}\nThe models its agents ran on: ${mix}` : ''; }
|
|
987
940
|
if (!it.boxed) this.#fillTotals(p, it);
|
|
988
941
|
el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
|
|
989
942
|
el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
|
|
@@ -1302,13 +1255,8 @@ export class Canvas {
|
|
|
1302
1255
|
const cell = item ? null : host.cells?.find((c) => c.id === id);
|
|
1303
1256
|
const cx = host.x + (cell ? cell.x + cell.w / 2 : host.w / 2);
|
|
1304
1257
|
const cy = host.y + (cell ? cell.y + cell.h / 2 : host.h / 2);
|
|
1305
|
-
const r = this.root.getBoundingClientRect();
|
|
1306
1258
|
// Close enough to read, and no closer than the viewer already was.
|
|
1307
|
-
this.
|
|
1308
|
-
this.view.x = r.width / 2 - cx * this.view.k;
|
|
1309
|
-
this.view.y = r.height / 2 - cy * this.view.k;
|
|
1310
|
-
this.touched = true;
|
|
1311
|
-
this.applyView();
|
|
1259
|
+
this.vp.centerOn(cx, cy, 1);
|
|
1312
1260
|
}
|
|
1313
1261
|
this.#persist();
|
|
1314
1262
|
this.onSelect(this.nodes.get(id) ?? null);
|
|
@@ -1327,38 +1275,14 @@ export class Canvas {
|
|
|
1327
1275
|
/* ------------------------------------------------------ interaction */
|
|
1328
1276
|
|
|
1329
1277
|
#interact(el, id) {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
const p = this.pos.get(id) ?? { x: 0, y: 0 };
|
|
1337
|
-
drag = { px: e.clientX, py: e.clientY, x: p.x, y: p.y, moved: false };
|
|
1338
|
-
el.setPointerCapture(e.pointerId);
|
|
1278
|
+
draggable(el, {
|
|
1279
|
+
scale: () => this.view.k,
|
|
1280
|
+
at: () => this.pos.get(id),
|
|
1281
|
+
move: (x, y) => { this.pinned.set(id, { x, y }); this.#redraw(); },
|
|
1282
|
+
end: () => this.#persist(),
|
|
1283
|
+
ignore: (t) => Boolean(t.closest?.('.cv-fold')),
|
|
1339
1284
|
});
|
|
1340
1285
|
|
|
1341
|
-
el.addEventListener('pointermove', (e) => {
|
|
1342
|
-
if (!drag) return;
|
|
1343
|
-
const dx = (e.clientX - drag.px) / this.view.k;
|
|
1344
|
-
const dy = (e.clientY - drag.py) / this.view.k;
|
|
1345
|
-
if (!drag.moved && Math.abs(dx) <= 3 && Math.abs(dy) <= 3) return;
|
|
1346
|
-
drag.moved = true;
|
|
1347
|
-
el.classList.add('cv-dragging');
|
|
1348
|
-
this.pinned.set(id, { x: Math.round(drag.x + dx), y: Math.round(drag.y + dy) });
|
|
1349
|
-
this.#redraw();
|
|
1350
|
-
});
|
|
1351
|
-
|
|
1352
|
-
const stop = () => {
|
|
1353
|
-
if (!drag) return;
|
|
1354
|
-
// A drag ends with a click on the same element. Without this guard every reposition also toggled selection.
|
|
1355
|
-
if (drag.moved) { this.#persist(); el.dataset.suppressClick = '1'; }
|
|
1356
|
-
drag = null;
|
|
1357
|
-
el.classList.remove('cv-dragging');
|
|
1358
|
-
};
|
|
1359
|
-
el.addEventListener('pointerup', stop);
|
|
1360
|
-
el.addEventListener('pointercancel', stop);
|
|
1361
|
-
|
|
1362
1286
|
const act = () => {
|
|
1363
1287
|
const it = this.lastPlan?.items.find((x) => x.id === id);
|
|
1364
1288
|
if (!it) return;
|
|
@@ -1368,7 +1292,7 @@ export class Canvas {
|
|
|
1368
1292
|
this.#select(id);
|
|
1369
1293
|
};
|
|
1370
1294
|
el.addEventListener('click', (e) => {
|
|
1371
|
-
if (el
|
|
1295
|
+
if (swallowClick(el)) return;
|
|
1372
1296
|
if (e.target.closest?.('.cv-member')) return; // the member answers for itself
|
|
1373
1297
|
act();
|
|
1374
1298
|
});
|