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
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
|
});
|
package/kit/studio/web/chat.js
CHANGED
|
@@ -48,10 +48,76 @@ export function quickReplies(text) {
|
|
|
48
48
|
return (opts.length >= 2 && opts.length <= 5 && /\?/.test(text)) ? opts : [];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/* ============================================== every open conversation === */
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One connection for every open conversation. A pane per EventSource reached the browser's six connections to a
|
|
55
|
+
* host at five panes beside the page's own stream, and from there a message sent and every request the page made
|
|
56
|
+
* waited for a pane to close (measured on Windows). The panes register here; the stream is opened again, with
|
|
57
|
+
* each session's last event number, whenever the set changes or the connection drops, so nothing is replayed
|
|
58
|
+
* twice and nothing is missed.
|
|
59
|
+
*/
|
|
60
|
+
export class OwnedStream {
|
|
61
|
+
constructor(api, { EventSourceImpl = globalThis.EventSource, retryMs = 1500 } = {}) {
|
|
62
|
+
this.api = api;
|
|
63
|
+
this.ES = EventSourceImpl;
|
|
64
|
+
this.retryMs = retryMs;
|
|
65
|
+
this.panes = new Map(); // session id -> pane
|
|
66
|
+
this.seq = new Map(); // session id -> the last event number taken
|
|
67
|
+
this.source = null;
|
|
68
|
+
this.timer = null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
attach(pane) { this.panes.set(pane.id, pane); this.#soon(0); }
|
|
72
|
+
|
|
73
|
+
detach(pane) {
|
|
74
|
+
if (this.panes.get(pane.id) !== pane) return;
|
|
75
|
+
this.panes.delete(pane.id); this.seq.delete(pane.id); this.#soon(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The URL the stream is opened at now: what a reconnect resumes from. */
|
|
79
|
+
url() {
|
|
80
|
+
const ids = [...this.panes.keys()];
|
|
81
|
+
const after = ids.map((id) => `${id}:${this.seq.get(id) ?? 0}`).join(',');
|
|
82
|
+
return this.api(`/api/owned-events?ids=${encodeURIComponent(ids.join(','))}&after=${encodeURIComponent(after)}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Panes opened together (a page restoring its tabs) cost one reconnect, not one each.
|
|
86
|
+
#soon(ms) { clearTimeout(this.timer); this.timer = setTimeout(() => this.#open(), ms); }
|
|
87
|
+
|
|
88
|
+
#open() {
|
|
89
|
+
if (this.source) { this.source.close(); this.source = null; }
|
|
90
|
+
if (!this.panes.size) return;
|
|
91
|
+
const src = new this.ES(this.url());
|
|
92
|
+
const data = (e) => { try { return JSON.parse(e.data); } catch { return null; } };
|
|
93
|
+
src.addEventListener('state', (e) => { const d = data(e); if (d) this.panes.get(d.id)?.takeState(d.session); });
|
|
94
|
+
src.addEventListener('event', (e) => {
|
|
95
|
+
const d = data(e); const pane = d && this.panes.get(d.id);
|
|
96
|
+
if (!pane || !d.ev) return;
|
|
97
|
+
if (Number.isFinite(d.ev.seq)) {
|
|
98
|
+
if (d.ev.seq <= (this.seq.get(d.id) ?? 0)) return; // already taken before a reconnect
|
|
99
|
+
this.seq.set(d.id, d.ev.seq);
|
|
100
|
+
}
|
|
101
|
+
pane.takeEvent(d.ev);
|
|
102
|
+
});
|
|
103
|
+
src.addEventListener('gone', (e) => { const d = data(e); if (d) this.panes.get(d.id)?.streamLost(); });
|
|
104
|
+
// The browser would reconnect at the same URL, from the same event numbers, and replay what was taken since.
|
|
105
|
+
// So the stream is closed and opened again here, from where each session is now.
|
|
106
|
+
src.onerror = () => {
|
|
107
|
+
if (this.source !== src) return;
|
|
108
|
+
for (const pane of this.panes.values()) pane.streamLost();
|
|
109
|
+
this.#soon(this.retryMs);
|
|
110
|
+
};
|
|
111
|
+
this.source = src;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
close() { clearTimeout(this.timer); if (this.source) { this.source.close(); this.source = null; } this.panes.clear(); this.seq.clear(); }
|
|
115
|
+
}
|
|
116
|
+
|
|
51
117
|
/* ========================================================== one session === */
|
|
52
118
|
|
|
53
119
|
class Pane {
|
|
54
|
-
constructor(session, { api, headers, onChange, onPermissions, hooks = {}, readOnly = false }) {
|
|
120
|
+
constructor(session, { api, headers, onChange, onPermissions, hub = null, hooks = {}, readOnly = false }) {
|
|
55
121
|
this.api = api;
|
|
56
122
|
this.headers = headers;
|
|
57
123
|
this.onChange = onChange;
|
|
@@ -62,7 +128,8 @@ class Pane {
|
|
|
62
128
|
this.readOnly = readOnly;
|
|
63
129
|
this.session = session;
|
|
64
130
|
this.id = session.sessionId;
|
|
65
|
-
this.
|
|
131
|
+
this.hub = hub;
|
|
132
|
+
this.connected = false;
|
|
66
133
|
this.messages = [];
|
|
67
134
|
this.streaming = null;
|
|
68
135
|
this.permissions = [];
|
|
@@ -163,25 +230,22 @@ class Pane {
|
|
|
163
230
|
}
|
|
164
231
|
|
|
165
232
|
connect() {
|
|
166
|
-
if (this.
|
|
167
|
-
this.
|
|
168
|
-
this.
|
|
169
|
-
try { this.session = JSON.parse(e.data); } catch { return; }
|
|
170
|
-
this.paintState();
|
|
171
|
-
this.onChange(this);
|
|
172
|
-
});
|
|
173
|
-
this.source.addEventListener('event', (e) => {
|
|
174
|
-
let ev;
|
|
175
|
-
try { ev = JSON.parse(e.data); } catch { return; }
|
|
176
|
-
this.absorb(ev.rec);
|
|
177
|
-
});
|
|
178
|
-
this.source.onerror = () => { this.streamBroken = true; this.onChange(this); };
|
|
233
|
+
if (this.connected || !this.hub) return;
|
|
234
|
+
this.connected = true;
|
|
235
|
+
this.hub.attach(this);
|
|
179
236
|
}
|
|
180
237
|
|
|
181
238
|
disconnect() {
|
|
182
|
-
if (this.
|
|
239
|
+
if (!this.connected) return;
|
|
240
|
+
this.connected = false;
|
|
241
|
+
this.hub.detach(this);
|
|
183
242
|
}
|
|
184
243
|
|
|
244
|
+
// What the shared stream hands this pane.
|
|
245
|
+
takeState(session) { if (!session) return; this.session = session; this.streamBroken = false; this.paintState(); this.onChange(this); }
|
|
246
|
+
takeEvent(ev) { this.streamBroken = false; this.absorb(ev.rec); }
|
|
247
|
+
streamLost() { this.streamBroken = true; this.onChange(this); }
|
|
248
|
+
|
|
185
249
|
/* --------------------------------------------------------- ingestion */
|
|
186
250
|
|
|
187
251
|
absorb(rec) {
|
|
@@ -591,6 +655,7 @@ export class Chat {
|
|
|
591
655
|
this.headers = headers;
|
|
592
656
|
this.hooks = hooks;
|
|
593
657
|
this.panes = new Map();
|
|
658
|
+
this.hub = new OwnedStream(api);
|
|
594
659
|
this.activeId = null;
|
|
595
660
|
this.onActivate = () => {};
|
|
596
661
|
// (pane, { pending }) — a session's waiting requests changed.
|
|
@@ -654,7 +719,7 @@ export class Chat {
|
|
|
654
719
|
}
|
|
655
720
|
if (!pane) {
|
|
656
721
|
pane = new Pane(session, {
|
|
657
|
-
api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(),
|
|
722
|
+
api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(), hub: this.hub,
|
|
658
723
|
onPermissions: (p, rec) => this.onPermissions(p, rec),
|
|
659
724
|
});
|
|
660
725
|
this.panesEl.append(pane.root);
|
|
@@ -52,7 +52,13 @@ export const AUTO = {
|
|
|
52
52
|
readable: 0.75,
|
|
53
53
|
// Same-type siblings fold into one card from this many, once the picture is too tall.
|
|
54
54
|
stackAt: 3,
|
|
55
|
+
// How far past what fits a picture may run and still be drawn as cards: the rest is a pan away, at a zoom the
|
|
56
|
+
// cards are read at (the canvas's own fit does not go under `readable` then). A long column of agent types beside
|
|
57
|
+
// an empty half of the pane turned to chips at 1.0; 1.5 is a starting point, not a measured one.
|
|
58
|
+
overflow: 1.5,
|
|
55
59
|
};
|
|
60
|
+
/** The height past which Auto gives cards up: what fits, and the overflow it allows on top. */
|
|
61
|
+
const overLimit = (h, tallest) => h > tallest * AUTO.overflow;
|
|
56
62
|
|
|
57
63
|
export const GROUPINGS = ['run', 'type', 'parent', 'order', 'none'];
|
|
58
64
|
export const DENSITIES = ['auto', 'comfortable', 'compact'];
|
|
@@ -422,12 +428,12 @@ export function plan(nodes, opts = {}) {
|
|
|
422
428
|
let compact = asked === 'compact';
|
|
423
429
|
let stacked = false;
|
|
424
430
|
let box = lay(pass.shown, compact);
|
|
425
|
-
if (asked === 'auto' && box.height
|
|
431
|
+
if (asked === 'auto' && overLimit(box.height, tallest)) {
|
|
426
432
|
if (group !== 'none' && group !== 'type') {
|
|
427
433
|
const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true, aside }));
|
|
428
434
|
if (folded.shown.some((it) => it.kind === 'group' && it.type === 'type')) { pass = folded; stacked = true; box = lay(pass.shown, false); }
|
|
429
435
|
}
|
|
430
|
-
if (box.height
|
|
436
|
+
if (overLimit(box.height, tallest)) { compact = true; box = lay(pass.shown, true); }
|
|
431
437
|
}
|
|
432
438
|
const drawnItems = pass.shown;
|
|
433
439
|
const drawn = pass.count;
|
|
@@ -523,7 +529,7 @@ function planOrder(nodes, asked, opts, tallest = AUTO.tallest) {
|
|
|
523
529
|
};
|
|
524
530
|
let compact = asked === 'compact';
|
|
525
531
|
let box = lay(compact);
|
|
526
|
-
if (asked === 'auto' && box.height
|
|
532
|
+
if (asked === 'auto' && overLimit(box.height, tallest)) { compact = true; box = lay(true); }
|
|
527
533
|
const { items } = box;
|
|
528
534
|
let { height } = box;
|
|
529
535
|
|
|
@@ -586,7 +592,7 @@ export function crowdFolds(nodes, opts = {}) {
|
|
|
586
592
|
const folds = [];
|
|
587
593
|
for (let guard = 0; guard < 64; guard += 1) {
|
|
588
594
|
const p = plan(nodes, { ...opts, open, pinned: new Map(), sessionY: null });
|
|
589
|
-
if (p.height
|
|
595
|
+
if (!overLimit(p.height, Math.max(AUTO.tallest, opts.tallest ?? 0))) break;
|
|
590
596
|
const next = p.items.filter((it) => it.kind === 'group' && it.open && it.members.length > 1)
|
|
591
597
|
.sort((a, b) => b.members.length - a.members.length || String(a.id).localeCompare(String(b.id)))[0];
|
|
592
598
|
if (!next) break;
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
<span class="bar-fill"></span>
|
|
26
26
|
<div id="graph-summary" class="chips" role="group" aria-label="Session summary"></div>
|
|
27
27
|
<span class="bar-sep" aria-hidden="true"></span>
|
|
28
|
+
<span id="role" class="badge" title="This link reads the panel and changes nothing" hidden>Read-only</span>
|
|
28
29
|
<span id="pulse" class="live" role="status" data-state="connecting"><span class="dot"></span><strong>Connecting</strong><span class="live-detail"></span></span>
|
|
29
30
|
<button id="theme" class="btn icon ghost" type="button" aria-label="Switch theme">
|
|
30
31
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M13 9.5A5.5 5.5 0 0 1 6.5 3a5.5 5.5 0 1 0 6.5 6.5z"/></svg>
|
|
@@ -95,6 +96,9 @@
|
|
|
95
96
|
<button id="view-models" class="seg-btn" type="button" role="tab" aria-label="Models view" aria-selected="false" aria-controls="models">
|
|
96
97
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 13V8M8 13V3M13 13V6"/></svg><span class="tb-words">Models</span>
|
|
97
98
|
</button>
|
|
99
|
+
<button id="view-map" class="seg-btn" type="button" role="tab" aria-label="Map view" aria-selected="false" aria-controls="map">
|
|
100
|
+
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2.5" width="5" height="3.5" rx="1"/><rect x="9" y="2.5" width="5" height="3.5" rx="1"/><rect x="5.5" y="10" width="5" height="3.5" rx="1"/><path d="M4.5 6v2h7V6M8 8v2"/></svg><span class="tb-words">Map</span>
|
|
101
|
+
</button>
|
|
98
102
|
</div>
|
|
99
103
|
<button id="tb-group" data-view="graph timeline" class="btn sm ghost tb-menu" type="button" aria-haspopup="menu">Group <span>Workflow run</span><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>
|
|
100
104
|
<button id="tb-density" data-view="graph" class="btn sm ghost tb-menu" type="button" aria-haspopup="menu">Density <span>Auto</span><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>
|
|
@@ -119,17 +123,17 @@
|
|
|
119
123
|
<button id="tb-fold" data-view="graph timeline list" class="btn sm" type="button" aria-label="Fold all groups">
|
|
120
124
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg><span class="tb-words">Fold all</span>
|
|
121
125
|
</button>
|
|
122
|
-
<button id="tb-zoom-out" data-view="graph" class="btn icon sm ghost" type="button" aria-label="Zoom out">
|
|
126
|
+
<button id="tb-zoom-out" data-view="graph map" class="btn icon sm ghost" type="button" aria-label="Zoom out">
|
|
123
127
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h10"/></svg>
|
|
124
128
|
</button>
|
|
125
|
-
<button id="tb-zoom" data-view="graph" class="tb-zoom" type="button" title="Back to 100%" aria-label="Zoom 100%. Back to 100%">100%</button>
|
|
126
|
-
<button id="tb-zoom-in" data-view="graph" class="btn icon sm ghost" type="button" aria-label="Zoom in">
|
|
129
|
+
<button id="tb-zoom" data-view="graph map" class="tb-zoom" type="button" title="Back to 100%" aria-label="Zoom 100%. Back to 100%">100%</button>
|
|
130
|
+
<button id="tb-zoom-in" data-view="graph map" class="btn icon sm ghost" type="button" aria-label="Zoom in">
|
|
127
131
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>
|
|
128
132
|
</button>
|
|
129
|
-
<button id="tb-reset" data-view="graph" class="btn sm" type="button" title="Reset layout (r)" aria-label="Reset layout: put the cards moved by hand back where they belong" disabled>
|
|
133
|
+
<button id="tb-reset" data-view="graph map" class="btn sm" type="button" title="Reset layout (r)" aria-label="Reset layout: put the cards moved by hand back where they belong" disabled>
|
|
130
134
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8a5 5 0 1 0 1.6-3.7M3 3v3h3"/></svg><span class="tb-words">Reset layout</span>
|
|
131
135
|
</button>
|
|
132
|
-
<button id="tb-fit" data-view="graph" class="btn sm" type="button" aria-label="Fit the graph to the view">
|
|
136
|
+
<button id="tb-fit" data-view="graph map" class="btn sm" type="button" aria-label="Fit the graph to the view">
|
|
133
137
|
<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 5.5v-3h3M10.5 2.5h3v3M13.5 10.5v3h-3M5.5 13.5h-3v-3"/></svg><span class="tb-words">Fit</span>
|
|
134
138
|
</button>
|
|
135
139
|
</div>
|
|
@@ -139,6 +143,7 @@
|
|
|
139
143
|
<div id="timeline" role="tabpanel" aria-labelledby="view-timeline" hidden></div>
|
|
140
144
|
<div id="list" role="tabpanel" aria-labelledby="view-list" hidden></div>
|
|
141
145
|
<div id="models" role="tabpanel" aria-labelledby="view-models" hidden></div>
|
|
146
|
+
<div id="map" role="tabpanel" aria-labelledby="view-map" hidden></div>
|
|
142
147
|
<div id="first-run" class="first-run" hidden></div>
|
|
143
148
|
<div id="terminal-wait" class="terminal-wait" role="status" hidden></div>
|
|
144
149
|
<div id="dock" role="region" aria-label="Waiting for approval" hidden></div>
|
|
@@ -64,3 +64,17 @@ function clock(ms) {
|
|
|
64
64
|
const two = (n) => String(n).padStart(2, '0');
|
|
65
65
|
return `${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Is the server older than the files it serves? The page is read from disk on every load; the server keeps the
|
|
70
|
+
* code it started with. After an update the two disagree, and a view the old server has no route for answered
|
|
71
|
+
* 404 (seen as "Not read — HTTP 404" on the Map view). /api/health says `code.changed` when the server's files
|
|
72
|
+
* changed since it started; a server with no `code` at all predates the field, and is older than this page.
|
|
73
|
+
* Returns the sentence to show, or null.
|
|
74
|
+
*/
|
|
75
|
+
export const RESTART_NOTE = 'Studio was updated after this server started, so it is running the old code. Restart it: stop it where it runs (Ctrl-C), then /crew-studio.';
|
|
76
|
+
export function staleServerNote(health) {
|
|
77
|
+
if (!health || typeof health !== 'object') return null;
|
|
78
|
+
if (!health.code || typeof health.code !== 'object') return RESTART_NOTE;
|
|
79
|
+
return health.code.changed === true ? RESTART_NOTE : null;
|
|
80
|
+
}
|