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
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,10 +24,12 @@ 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';
|
|
32
|
+
import { qrSvg } from './qr.js';
|
|
31
33
|
import { SESSION_MODES } from './convo.js';
|
|
32
34
|
import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, modelLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
|
|
33
35
|
|
|
@@ -79,6 +81,8 @@ const el = {
|
|
|
79
81
|
viewList: document.getElementById('view-list'),
|
|
80
82
|
viewModels: document.getElementById('view-models'),
|
|
81
83
|
models: document.getElementById('models'),
|
|
84
|
+
viewMap: document.getElementById('view-map'),
|
|
85
|
+
map: document.getElementById('map'),
|
|
82
86
|
list: document.getElementById('list'),
|
|
83
87
|
firstRun: document.getElementById('first-run'),
|
|
84
88
|
stageNote: document.getElementById('stage-note'),
|
|
@@ -110,12 +114,37 @@ const getJson = async (p) => {
|
|
|
110
114
|
heard.failed = true;
|
|
111
115
|
throw e;
|
|
112
116
|
}
|
|
113
|
-
if (!r.ok) throw
|
|
117
|
+
if (!r.ok) throw await statusError(r);
|
|
114
118
|
const body = await r.json();
|
|
115
119
|
heardNow();
|
|
116
120
|
return body;
|
|
117
121
|
};
|
|
118
122
|
|
|
123
|
+
// What the server said about this page's link: a viewer whose link hides transcripts is sent no transcript text.
|
|
124
|
+
let transcriptsHidden = false;
|
|
125
|
+
|
|
126
|
+
/** The error for an answer with an error status. A viewer link whose eight hours are up covers the page, once. */
|
|
127
|
+
async function statusError(r) {
|
|
128
|
+
if (r.status === 401) {
|
|
129
|
+
let b = null; try { b = await r.json(); } catch { /* not JSON */ }
|
|
130
|
+
if (b?.expired) showExpired();
|
|
131
|
+
}
|
|
132
|
+
if (r.status === 403 && transcriptsHidden) return new Error('Transcripts are hidden on this link');
|
|
133
|
+
return new Error(`HTTP ${r.status}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function showExpired() {
|
|
137
|
+
if (document.getElementById('expired')) return;
|
|
138
|
+
const box = document.createElement('div');
|
|
139
|
+
box.id = 'expired';
|
|
140
|
+
box.className = 'expired';
|
|
141
|
+
box.setAttribute('role', 'alert');
|
|
142
|
+
const h = document.createElement('h1'); h.textContent = 'Session expired';
|
|
143
|
+
const p = document.createElement('p'); p.textContent = 'Viewer links last eight hours. Ask for a new one on the machine running Studio.';
|
|
144
|
+
box.append(h, p);
|
|
145
|
+
document.body.append(box);
|
|
146
|
+
}
|
|
147
|
+
|
|
119
148
|
/* ---------------------------------------------------------------- theme */
|
|
120
149
|
|
|
121
150
|
try { migrateStorage(localStorage); } catch { /* blocked storage */ }
|
|
@@ -608,16 +637,27 @@ el.tbOptions.addEventListener('click', (e) => {
|
|
|
608
637
|
});
|
|
609
638
|
el.tbExpand.addEventListener('click', () => { (foldTarget() ?? canvas).expandAll(); paintFoldButtons(); });
|
|
610
639
|
el.tbFold.addEventListener('click', () => { (foldTarget() ?? canvas).foldAll(); paintFoldButtons(); });
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
el.
|
|
614
|
-
el.
|
|
640
|
+
// The zoom, Fit and Reset controls are the graph's and the map's: they act on whichever of the two is open.
|
|
641
|
+
const zoomed = () => (view === 'map' ? mapView : canvas);
|
|
642
|
+
el.tbZoomOut.addEventListener('click', () => zoomed().zoomBy(1 / 1.2));
|
|
643
|
+
el.tbZoomIn.addEventListener('click', () => zoomed().zoomBy(1.2));
|
|
644
|
+
el.tbZoom.addEventListener('click', () => zoomed().zoomTo(1));
|
|
645
|
+
el.tbFit.addEventListener('click', () => zoomed().fit());
|
|
615
646
|
|
|
616
647
|
/* Reset layout: the cards moved by hand go back where the layout puts them, and for a moment that can be undone. */
|
|
617
648
|
function resetLayout() {
|
|
618
|
-
const
|
|
649
|
+
const target = zoomed();
|
|
650
|
+
const was = target.resetLayout();
|
|
619
651
|
if (!was) return;
|
|
620
|
-
toast('Layout reset', { label: 'Undo', run: () => { if (
|
|
652
|
+
toast('Layout reset', { label: 'Undo', run: () => { if (target.restoreLayout(was)) toast('Layout restored'); } });
|
|
653
|
+
if (target === mapView) paintZoom(mapView.zoom, mapView.moved);
|
|
654
|
+
}
|
|
655
|
+
/** The zoom and Reset as the map has them: the graph's go through paintToolbar. */
|
|
656
|
+
function paintZoom(k, moved) {
|
|
657
|
+
const pct = `${Math.round(k * 100)}%`;
|
|
658
|
+
el.tbZoom.textContent = pct;
|
|
659
|
+
el.tbZoom.setAttribute('aria-label', `Zoom ${pct}. Back to 100%`);
|
|
660
|
+
if (el.tbReset) el.tbReset.disabled = !(moved > 0);
|
|
621
661
|
}
|
|
622
662
|
el.tbReset.addEventListener('click', resetLayout);
|
|
623
663
|
|
|
@@ -679,6 +719,37 @@ async function loadCosts(force = false) {
|
|
|
679
719
|
const COSTS_POLL_MS = 60000;
|
|
680
720
|
const MODELS_POLL_MS = 5000;
|
|
681
721
|
|
|
722
|
+
/* The project's crew map, as Crewforth's scanner wrote it. Read when the view opens, when the comparison changes, and every
|
|
723
|
+
half minute while it stays open; nothing is scanned from here. */
|
|
724
|
+
let mapFor = null;
|
|
725
|
+
let mapAt = 0;
|
|
726
|
+
let mapBusy = false;
|
|
727
|
+
let mapCompare = null;
|
|
728
|
+
const mapView = new MapView(el.map, {
|
|
729
|
+
onCopy: (text, what) => navigator.clipboard.writeText(text).then(
|
|
730
|
+
() => toast(what === 'path' ? `Copied ${text}` : 'Copied — paste it into a session as a task'),
|
|
731
|
+
() => toast(`Not copied — select it: ${text}`)),
|
|
732
|
+
onCompare: (cwd) => { mapCompare = cwd; loadMap(true); },
|
|
733
|
+
onZoom: (k) => { if (view === 'map') paintZoom(k, mapView.moved); },
|
|
734
|
+
});
|
|
735
|
+
async function loadMap(force = false) {
|
|
736
|
+
if (!current || mapBusy) return;
|
|
737
|
+
if (!force && mapFor === current && Date.now() - mapAt < MAP_POLL_MS) return;
|
|
738
|
+
const asked = current;
|
|
739
|
+
if (mapFor !== asked) mapCompare = null;
|
|
740
|
+
mapAt = Date.now();
|
|
741
|
+
mapBusy = true;
|
|
742
|
+
let data;
|
|
743
|
+
const q = `session=${encodeURIComponent(asked)}${mapCompare ? `&compare=${encodeURIComponent(mapCompare)}` : ''}`;
|
|
744
|
+
// A server older than this page has no Map route: say that, not its 404.
|
|
745
|
+
try { data = await getJson(`/api/map?${q}`); } catch (e) { data = { measured: false, reason: restartNote ?? e.message, others: [] }; }
|
|
746
|
+
finally { mapBusy = false; }
|
|
747
|
+
if (asked !== current) return;
|
|
748
|
+
mapFor = asked;
|
|
749
|
+
mapView.setData(data);
|
|
750
|
+
}
|
|
751
|
+
const MAP_POLL_MS = 30000;
|
|
752
|
+
|
|
682
753
|
const list = new List(el.list, {
|
|
683
754
|
tileOf: (n) => canvas.tileFor(n),
|
|
684
755
|
dimmed: (n) => canvas.dimmed(n),
|
|
@@ -713,7 +784,7 @@ function paintTimelineBar(st) {
|
|
|
713
784
|
*/
|
|
714
785
|
function setView(next, select = null, remember = true) {
|
|
715
786
|
const was = view;
|
|
716
|
-
view = ['graph', 'timeline', 'list', 'models'].includes(next) ? next : 'graph';
|
|
787
|
+
view = ['graph', 'timeline', 'list', 'models', 'map'].includes(next) ? next : 'graph';
|
|
717
788
|
// A view the window opened on by default is not a choice, and is not remembered as one.
|
|
718
789
|
if (remember) store.set('crewforth-studio-view', view);
|
|
719
790
|
const carried = select ?? (was === 'timeline' ? timeline.selected : was === 'list' ? list.selected : canvas.selected) ?? null;
|
|
@@ -722,6 +793,8 @@ function setView(next, select = null, remember = true) {
|
|
|
722
793
|
el.list.hidden = view !== 'list';
|
|
723
794
|
el.models.hidden = view !== 'models';
|
|
724
795
|
el.viewModels.setAttribute('aria-selected', String(view === 'models'));
|
|
796
|
+
el.map.hidden = view !== 'map';
|
|
797
|
+
el.viewMap.setAttribute('aria-selected', String(view === 'map'));
|
|
725
798
|
el.viewGraph.setAttribute('aria-selected', String(view === 'graph'));
|
|
726
799
|
el.viewTimeline.setAttribute('aria-selected', String(view === 'timeline'));
|
|
727
800
|
el.viewList.setAttribute('aria-selected', String(view === 'list'));
|
|
@@ -751,7 +824,14 @@ function setView(next, select = null, remember = true) {
|
|
|
751
824
|
if (costsFor !== current) models.setCosts(null);
|
|
752
825
|
loadModels(true);
|
|
753
826
|
loadCosts(costsFor !== current);
|
|
827
|
+
} else if (view === 'map') {
|
|
828
|
+
// The map is the project's: read when the view opens, drawn with what was last read until the answer comes.
|
|
829
|
+
mapView.render();
|
|
830
|
+
paintZoom(mapView.zoom, mapView.moved);
|
|
831
|
+
loadMap(true);
|
|
754
832
|
} else {
|
|
833
|
+
// Back on the graph: the zoom and Reset say the graph's again.
|
|
834
|
+
paintToolbar(canvas.state());
|
|
755
835
|
canvas.fitIfUntouched();
|
|
756
836
|
if (carried && canvas.nodes.has(carried)) canvas.focus(carried);
|
|
757
837
|
}
|
|
@@ -768,13 +848,14 @@ el.viewGraph.addEventListener('click', () => setView('graph'));
|
|
|
768
848
|
el.viewTimeline.addEventListener('click', () => setView('timeline'));
|
|
769
849
|
el.viewList.addEventListener('click', () => setView('list'));
|
|
770
850
|
el.viewModels.addEventListener('click', () => setView('models'));
|
|
851
|
+
el.viewMap.addEventListener('click', () => setView('map'));
|
|
771
852
|
el.tbFollow.addEventListener('click', () => timeline.setFollow(el.tbFollow.getAttribute('aria-checked') !== 'true'));
|
|
772
853
|
// "Out" is more time on screen, "in" is less: the same way round as the graph's zoom.
|
|
773
854
|
el.tbRangeOut.addEventListener('click', () => timeline.zoom(1));
|
|
774
855
|
el.tbRangeIn.addEventListener('click', () => timeline.zoom(-1));
|
|
775
856
|
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => timeline.render())).observe(el.timeline);
|
|
776
857
|
// 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.
|
|
858
|
+
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => canvas.paneResized())).observe(el.canvasEl);
|
|
778
859
|
|
|
779
860
|
getJson('/api/palette')
|
|
780
861
|
.then((p) => {
|
|
@@ -980,11 +1061,15 @@ function paintAgentOverview(body, n, detail) {
|
|
|
980
1061
|
if (lines.length) {
|
|
981
1062
|
const dl = node('dl', 'icard');
|
|
982
1063
|
for (const l of lines) dl.append(node('dt', null, l.label), node('dd', null, l.text));
|
|
1064
|
+
// Why the gate called it critical, in its own words (`files ... is on a critical path: ...` or `change: ...`).
|
|
1065
|
+
if (work.riskWhy) dl.append(node('dt', null, 'why critical'), node('dd', null, work.riskWhy));
|
|
983
1066
|
const sec = isection('Task card', dl);
|
|
984
1067
|
if (work.risk) { const tag = node('span', 'mv-tag', work.risk); tag.dataset.risk = work.risk; sec.children[0].append(tag); }
|
|
985
1068
|
body.append(sec);
|
|
986
1069
|
}
|
|
987
|
-
//
|
|
1070
|
+
// Not a crew agent: no card and no line, and that is said, so the empty space is not read as a record lost.
|
|
1071
|
+
const outside = !row && !lines.length ? outsideRouting(n.agentType) : null;
|
|
1072
|
+
if (outside) body.append(isection('Model routing', node('p', 'istep-note', outside)));
|
|
988
1073
|
if (row || lines.length) {
|
|
989
1074
|
const ol = node('ol', 'isteps');
|
|
990
1075
|
work.steps.forEach((st, i) => {
|
|
@@ -1381,6 +1466,8 @@ const ICON = {
|
|
|
1381
1466
|
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
1467
|
chevron: 'M6 4l4 4-4 4',
|
|
1383
1468
|
more: 'M3.5 8h.01M8 8h.01M12.5 8h.01',
|
|
1469
|
+
// The Map view's tab mark: three cards and the lines between them.
|
|
1470
|
+
map: 'M2 2.5h5v3.5H2zM9 2.5h5v3.5H9zM5.5 10h5v3.5h-5zM4.5 6v2h7V6M8 8v2',
|
|
1384
1471
|
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
1472
|
};
|
|
1386
1473
|
|
|
@@ -1611,6 +1698,23 @@ function versionBadge(kit) {
|
|
|
1611
1698
|
return tag;
|
|
1612
1699
|
}
|
|
1613
1700
|
|
|
1701
|
+
/** The Map entry under a project: opens the project's map, through its latest session. */
|
|
1702
|
+
function mapRow(p, latest) {
|
|
1703
|
+
const row = node('div', 'srow map-row');
|
|
1704
|
+
row.tabIndex = 0;
|
|
1705
|
+
row.setAttribute('role', 'treeitem');
|
|
1706
|
+
row.dataset.map = p.key;
|
|
1707
|
+
const words = node('span', 'map-word', 'Map');
|
|
1708
|
+
row.append(icon(ICON.map), words, node('span', 'row-fill'));
|
|
1709
|
+
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); }
|
|
1710
|
+
else if (!p.map) row.append(node('span', 'sub', 'no map'));
|
|
1711
|
+
row.title = p.map ? 'Open the project map' : 'This project has no .crewforth/crew-map.json yet';
|
|
1712
|
+
const go = () => { selectSession(latest.sessionId); setView('map'); };
|
|
1713
|
+
row.addEventListener('click', go);
|
|
1714
|
+
row.addEventListener('keydown', (e) => { if (e.target === row && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); go(); } });
|
|
1715
|
+
return row;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1614
1718
|
function sessionRow(project, sn) {
|
|
1615
1719
|
const status = sessionStatus(sn.sessionId, fleetData);
|
|
1616
1720
|
const name = sessionName(sn, labels.all());
|
|
@@ -1775,6 +1879,8 @@ function paintProjects() {
|
|
|
1775
1879
|
group.append(head);
|
|
1776
1880
|
|
|
1777
1881
|
if (open) {
|
|
1882
|
+
// Under the project, its map (spec §11): the orange number is how many findings it shows unasked.
|
|
1883
|
+
if (p.local !== false && p.exists && sessions.length) group.append(mapRow(p, sessions[0]));
|
|
1778
1884
|
for (const sn of sessions) group.append(sessionRow(p, sn));
|
|
1779
1885
|
if (p.total > p.sessions.length) {
|
|
1780
1886
|
group.append(node('div', 'nav-line', `${p.total - p.sessions.length} older session(s) not listed`));
|
|
@@ -1960,10 +2066,149 @@ function paintMachines() {
|
|
|
1960
2066
|
});
|
|
1961
2067
|
frag.append(note);
|
|
1962
2068
|
}
|
|
2069
|
+
if (role === 'owner') frag.append(remoteSection());
|
|
1963
2070
|
el.reach.replaceChildren(frag);
|
|
1964
2071
|
paintOtherMachines();
|
|
1965
2072
|
}
|
|
1966
2073
|
|
|
2074
|
+
/* ------------------------------------------------------- remote viewers
|
|
2075
|
+
A viewer is a second token that reads and changes nothing: the way to watch
|
|
2076
|
+
this panel from another device without handing that device the panel. The
|
|
2077
|
+
server refuses a viewer's writes itself; what is hidden here for a viewer is
|
|
2078
|
+
only what would be refused anyway. */
|
|
2079
|
+
|
|
2080
|
+
let role = null; // 'owner' or 'viewer', once the server has said which
|
|
2081
|
+
let remote = null; // the server's list of viewers, or null before it has answered
|
|
2082
|
+
let handed = null; // the link of the viewer just added: the server says a token once
|
|
2083
|
+
|
|
2084
|
+
async function pollRemote() {
|
|
2085
|
+
if (role !== 'owner') return;
|
|
2086
|
+
try { remote = await getJson('/api/remote'); } catch { return; }
|
|
2087
|
+
// The link shown once leaves with its viewer: revoked from elsewhere (the API, another tab) or past its eight
|
|
2088
|
+
// hours, it no longer opens anything, and a QR code left on screen would say it does.
|
|
2089
|
+
if (handed && !(remote.viewers ?? []).some((v) => v.id === handed.id)) handed = null;
|
|
2090
|
+
paintMachines();
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
async function remoteWrite(method, path, body) {
|
|
2094
|
+
try {
|
|
2095
|
+
const r = await fetch(api(path), {
|
|
2096
|
+
method,
|
|
2097
|
+
headers: { ...writeHeaders, ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
2098
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
2099
|
+
});
|
|
2100
|
+
return await r.json();
|
|
2101
|
+
} catch (e) {
|
|
2102
|
+
return { ok: false, reason: e.message };
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
/** A time today or tomorrow as the clock on the wall says it, e.g. 23:40. */
|
|
2107
|
+
const clockTime = (ms) => (Number.isFinite(ms) ? new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '—');
|
|
2108
|
+
|
|
2109
|
+
let showTranscripts = false; // the owner's choice for the next viewer: transcripts stay hidden unless ticked
|
|
2110
|
+
|
|
2111
|
+
async function addViewer(button) {
|
|
2112
|
+
button.disabled = true;
|
|
2113
|
+
const res = await remoteWrite('POST', '/api/remote', { transcripts: showTranscripts });
|
|
2114
|
+
if (!res.ok) {
|
|
2115
|
+
toast(`No viewer added — ${res.reason ?? 'no reason given'}`);
|
|
2116
|
+
button.disabled = false;
|
|
2117
|
+
return;
|
|
2118
|
+
}
|
|
2119
|
+
remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
|
|
2120
|
+
// The Wi-Fi link when the server listens on one; this machine's own address only when it found no Wi-Fi address.
|
|
2121
|
+
const wifi = res.lanUrls?.[0] ?? null;
|
|
2122
|
+
handed = { id: res.viewer.id, label: res.viewer.label, wifi, expiresAt: res.viewer.expiresAt, transcripts: res.viewer.transcripts,
|
|
2123
|
+
url: wifi ?? `${location.origin}/?token=${encodeURIComponent(res.viewer.token)}`, lanErrors: res.lan?.errors ?? [] };
|
|
2124
|
+
showTranscripts = false;
|
|
2125
|
+
paintMachines();
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
async function revokeViewer(v, button) {
|
|
2129
|
+
button.disabled = true;
|
|
2130
|
+
const res = await remoteWrite('DELETE', `/api/remote/${encodeURIComponent(v.id)}`);
|
|
2131
|
+
if (!res.ok) {
|
|
2132
|
+
toast(`Not revoked — ${res.reason ?? 'no reason given'}`);
|
|
2133
|
+
button.disabled = false;
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
remote = { ...(remote ?? {}), viewers: res.viewers ?? [] };
|
|
2137
|
+
if (handed?.id === v.id) handed = null;
|
|
2138
|
+
toast(res.revoked ? `${v.label} can no longer read this panel` : `${v.label} was already revoked`);
|
|
2139
|
+
paintMachines();
|
|
2140
|
+
}
|
|
2141
|
+
|
|
2142
|
+
function viewerRow(v) {
|
|
2143
|
+
const row = node('div', 'srow remote');
|
|
2144
|
+
const top = node('span', 'srow-top');
|
|
2145
|
+
const revoke = node('button', 'btn sm ghost', 'Revoke');
|
|
2146
|
+
revoke.type = 'button';
|
|
2147
|
+
revoke.setAttribute('aria-label', `Revoke ${v.label}`);
|
|
2148
|
+
revoke.addEventListener('click', () => revokeViewer(v, revoke));
|
|
2149
|
+
top.append(icon(ICON.machine), node('span', 'mname', v.label), node('span', 'row-fill'), revoke);
|
|
2150
|
+
row.append(top);
|
|
2151
|
+
const seen = v.lastSeenAt
|
|
2152
|
+
? `${seenAgo(v.lastSeenAt, Date.now())} · ${v.requests} request${v.requests === 1 ? '' : 's'}${v.open ? ' · watching now' : ''}`
|
|
2153
|
+
: 'link not opened yet';
|
|
2154
|
+
row.append(node('div', 'sub srow-sub', seen));
|
|
2155
|
+
row.append(node('div', 'sub srow-sub', `until ${clockTime(v.expiresAt)} · transcripts ${v.transcripts ? 'shown' : 'hidden'}${v.addresses?.length ? ` · from ${v.addresses.join(', ')}` : ''}`));
|
|
2156
|
+
return row;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function remoteSection() {
|
|
2160
|
+
const box = node('div', 'nav-foot rv');
|
|
2161
|
+
const head = node('div', 'rv-head');
|
|
2162
|
+
const add = node('button', 'btn sm', 'Add viewer');
|
|
2163
|
+
add.type = 'button';
|
|
2164
|
+
const list = remote?.viewers ?? [];
|
|
2165
|
+
if (remote?.max != null && list.length >= remote.max) {
|
|
2166
|
+
add.disabled = true;
|
|
2167
|
+
add.title = `${remote.max} viewers already; revoke one first`;
|
|
2168
|
+
}
|
|
2169
|
+
add.addEventListener('click', () => addViewer(add));
|
|
2170
|
+
head.append(node('span', 'rv-title', 'Remote viewers'), node('span', 'row-fill'), add);
|
|
2171
|
+
box.append(head);
|
|
2172
|
+
|
|
2173
|
+
// Transcripts stay hidden on a new link unless this is ticked before Add viewer.
|
|
2174
|
+
const opt = node('label', 'rv-opt');
|
|
2175
|
+
const tick = document.createElement('input');
|
|
2176
|
+
tick.type = 'checkbox'; tick.checked = showTranscripts;
|
|
2177
|
+
tick.addEventListener('change', () => { showTranscripts = tick.checked; });
|
|
2178
|
+
opt.append(tick, document.createTextNode(' Show transcripts on the next link'));
|
|
2179
|
+
box.append(opt);
|
|
2180
|
+
|
|
2181
|
+
if (handed) {
|
|
2182
|
+
const give = node('div', 'rv-give');
|
|
2183
|
+
give.append(node('div', 'sub rv-say', handed.wifi
|
|
2184
|
+
? `Link for ${handed.label}, shown once. Scan it with a phone on the same Wi-Fi. It lasts until ${clockTime(handed.expiresAt)}; transcripts ${handed.transcripts ? 'shown' : 'hidden'}.`
|
|
2185
|
+
: `Link for ${handed.label}, shown once. No Wi-Fi address was found${handed.lanErrors.length ? ` (${handed.lanErrors.join('; ')})` : ''}, so it opens on this machine only.`));
|
|
2186
|
+
if (handed.wifi) {
|
|
2187
|
+
const code = node('div', 'rv-qr');
|
|
2188
|
+
code.innerHTML = qrSvg(handed.wifi, { px: 3 });
|
|
2189
|
+
give.append(code);
|
|
2190
|
+
}
|
|
2191
|
+
const url = node('code', 'rv-url', handed.url);
|
|
2192
|
+
const copy = node('button', 'btn sm', 'Copy link');
|
|
2193
|
+
copy.type = 'button';
|
|
2194
|
+
copy.addEventListener('click', () => copyText(handed.url, 'Link copied'));
|
|
2195
|
+
const done = node('button', 'btn sm ghost', 'Done');
|
|
2196
|
+
done.type = 'button';
|
|
2197
|
+
done.addEventListener('click', () => { handed = null; paintMachines(); });
|
|
2198
|
+
const acts = node('div', 'rv-acts');
|
|
2199
|
+
acts.append(copy, done);
|
|
2200
|
+
give.append(url, acts);
|
|
2201
|
+
box.append(give);
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
for (const v of list) box.append(viewerRow(v));
|
|
2205
|
+
if (!list.length) {
|
|
2206
|
+
box.append(node('div', 'sub rv-say',
|
|
2207
|
+
'A viewer watches this panel from a phone on the same Wi-Fi and changes nothing. Its link lasts eight hours. With no viewer, the server listens on 127.0.0.1 only.'));
|
|
2208
|
+
}
|
|
2209
|
+
return box;
|
|
2210
|
+
}
|
|
2211
|
+
|
|
1967
2212
|
/** The short form under the project list: the first two, and the way to the rest. */
|
|
1968
2213
|
function paintOtherMachines() {
|
|
1969
2214
|
const m = machines(fleetData);
|
|
@@ -2226,7 +2471,7 @@ function paintPulse() {
|
|
|
2226
2471
|
pulse.detail.textContent = l.detail ? `· ${l.detail}` : '';
|
|
2227
2472
|
}
|
|
2228
2473
|
paintPulse();
|
|
2229
|
-
setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } }, 1000);
|
|
2474
|
+
setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } if (view === 'map') loadMap(); }, 1000);
|
|
2230
2475
|
|
|
2231
2476
|
/* -------------------------------------------------------------- home, keys */
|
|
2232
2477
|
|
|
@@ -2254,6 +2499,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
2254
2499
|
if (shell.classList.contains('side-open')) { setDrawer(false); return; }
|
|
2255
2500
|
if (newPanel.isOpen) { newPanel.close(); return; }
|
|
2256
2501
|
if (view === 'timeline' && timeline.selected) { timeline.select(null); return; }
|
|
2502
|
+
if (view === 'map' && mapView.escape()) return;
|
|
2257
2503
|
// Close the inspector and let go of the selection.
|
|
2258
2504
|
if (!el.inspector.hidden) { canvas.clearSelection(); showInspector(null); }
|
|
2259
2505
|
return;
|
|
@@ -2277,9 +2523,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
2277
2523
|
} else if (key === 'l') {
|
|
2278
2524
|
setView('list');
|
|
2279
2525
|
} else if (key === 'r') {
|
|
2280
|
-
if (view === 'graph') resetLayout();
|
|
2526
|
+
if (view === 'graph' || view === 'map') resetLayout();
|
|
2281
2527
|
} else if (key === 'm') {
|
|
2282
2528
|
setView('models');
|
|
2529
|
+
} else if (key === 'p') {
|
|
2530
|
+
setView('map');
|
|
2283
2531
|
} else if (key === 'j') {
|
|
2284
2532
|
stepAttention(1);
|
|
2285
2533
|
} else if (key === 'k') {
|
|
@@ -2442,7 +2690,9 @@ if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnima
|
|
|
2442
2690
|
// They survive a page reload as long as the server does.
|
|
2443
2691
|
let ownedBusy = false;
|
|
2444
2692
|
async function pollOwned() {
|
|
2445
|
-
|
|
2693
|
+
// A link that hides transcripts is refused the sessions the panel runs (their conversation is all transcript):
|
|
2694
|
+
// asking every few seconds would only fill the machine's log with the same refusal.
|
|
2695
|
+
if (ownedBusy || transcriptsHidden) return;
|
|
2446
2696
|
ownedBusy = true;
|
|
2447
2697
|
try {
|
|
2448
2698
|
const r = await getJson('/api/owned');
|
|
@@ -2465,6 +2715,7 @@ chat.onPermissions = () => pollOwned();
|
|
|
2465
2715
|
view is being shown in a window too narrow for it. Offline is said first. */
|
|
2466
2716
|
|
|
2467
2717
|
let nextPollAt = null;
|
|
2718
|
+
let restartNote = null; // staleServerNote(/api/health): the server runs older code than the page
|
|
2468
2719
|
const stageNote = { text: node('span', 'stage-note-text'), retry: node('span', 'sub'), button: node('button', 'btn sm', 'Retry now') };
|
|
2469
2720
|
stageNote.button.type = 'button';
|
|
2470
2721
|
stageNote.button.addEventListener('click', () => retryNow());
|
|
@@ -2473,11 +2724,12 @@ el.stageNote.append(stageNote.text, stageNote.retry, node('span', 'row-fill'), s
|
|
|
2473
2724
|
function paintStageNote() {
|
|
2474
2725
|
const l = liveness(Date.now(), heard.okAt, heard.failed);
|
|
2475
2726
|
const off = offlineNote(l.state, heard.okAt, nextPollAt, Date.now());
|
|
2476
|
-
|
|
2727
|
+
// Offline first, then a server older than this page, then a window too narrow for the view.
|
|
2728
|
+
const warn = restartNote ?? narrowWarning(view, phoneBand.matches);
|
|
2477
2729
|
// The last picture stays on screen while the server is away, and is drawn as old.
|
|
2478
2730
|
el.stage.dataset.offline = String(Boolean(off));
|
|
2479
2731
|
el.stageNote.hidden = !off && !warn;
|
|
2480
|
-
el.stageNote.dataset.kind = off ? 'offline' : 'narrow';
|
|
2732
|
+
el.stageNote.dataset.kind = off ? 'offline' : restartNote ? 'restart' : 'narrow';
|
|
2481
2733
|
stageNote.text.textContent = off ? off.text : (warn ?? '');
|
|
2482
2734
|
stageNote.retry.textContent = off ? off.retry : '';
|
|
2483
2735
|
stageNote.retry.hidden = !off;
|
|
@@ -2619,7 +2871,25 @@ applyPhoneBand(true);
|
|
|
2619
2871
|
setView(defaultView(stored, phoneBand.matches), null, Boolean(stored));
|
|
2620
2872
|
}
|
|
2621
2873
|
pollFleet(); pollSessions(); pollOwned();
|
|
2874
|
+
// Who this page is to the server. A viewer's page says so and offers no way to start a session; the server
|
|
2875
|
+
// would refuse it either way.
|
|
2876
|
+
getJson('/api/health').then((h) => {
|
|
2877
|
+
restartNote = staleServerNote(h);
|
|
2878
|
+
// A Map read before this answer showed the old server's 404; read it again so it says why.
|
|
2879
|
+
if (restartNote) { paintStageNote(); if (view === 'map') loadMap(true); }
|
|
2880
|
+
role = h.role === 'viewer' ? 'viewer' : 'owner';
|
|
2881
|
+
transcriptsHidden = role === 'viewer' && h.transcripts === false;
|
|
2882
|
+
document.body.dataset.role = role;
|
|
2883
|
+
const badge = document.getElementById('role');
|
|
2884
|
+
badge.hidden = role !== 'viewer';
|
|
2885
|
+
if (transcriptsHidden) {
|
|
2886
|
+
badge.textContent = 'Read-only · transcripts hidden';
|
|
2887
|
+
badge.title = 'This link reads the panel and changes nothing; no transcript text is sent to it';
|
|
2888
|
+
}
|
|
2889
|
+
if (role === 'owner') pollRemote(); else paintMachines();
|
|
2890
|
+
}).catch(() => { /* the Live indicator says the server is unreachable */ });
|
|
2622
2891
|
setInterval(() => { nextPollAt = Date.now() + FLEET_POLL_MS; pollFleet(); }, FLEET_POLL_MS);
|
|
2892
|
+
setInterval(pollRemote, FLEET_POLL_MS);
|
|
2623
2893
|
nextPollAt = Date.now() + FLEET_POLL_MS;
|
|
2624
2894
|
setInterval(pollOwned, FLEET_POLL_MS);
|
|
2625
2895
|
setInterval(pollSessions, SESSION_POLL_MS);
|