crewforth 3.1.0-rc.4 → 3.1.0-rc.6
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 +59 -0
- package/README.md +1 -1
- package/kit/AGENT_TEMPLATE.md +48 -19
- package/kit/CLAUDE.md +13 -0
- package/kit/agents/crew-backend-expert.md +8 -0
- package/kit/agents/crew-commit-agent.md +8 -0
- package/kit/agents/crew-database-expert.md +8 -0
- package/kit/agents/crew-devops-expert.md +8 -0
- package/kit/agents/crew-frontend-expert.md +8 -0
- package/kit/agents/crew-performance-expert.md +8 -0
- package/kit/agents/crew-planner.md +14 -0
- package/kit/agents/crew-privacy-agent.md +10 -2
- package/kit/agents/crew-review-agent.md +8 -0
- package/kit/agents/crew-security-expert.md +11 -4
- package/kit/agents/crew-session-manager.md +10 -3
- package/kit/agents/crew-test-expert.md +8 -0
- package/kit/eval/doctor.sh +16 -0
- package/kit/eval/smoke-test.sh +898 -11
- package/kit/hooks/agent-outcome.sh +416 -0
- package/kit/hooks/guard-agent-model.sh +819 -0
- package/kit/hooks/guard-bash.sh +26 -1
- package/kit/hooks/guard-schedule.sh +7 -2
- package/kit/hooks/guard-write.sh +31 -0
- package/kit/hooks/prompt-approval.sh +55 -5
- package/kit/settings.json +54 -0
- package/kit/skills/crew-loosen/SKILL.md +28 -0
- package/kit/studio/server/hooks/studio-gate.sh +90 -4
- package/kit/studio/server/hooks/studio-host.mjs +92 -0
- package/kit/studio/server/index.js +86 -7
- package/kit/studio/server/lib/agent-origin.js +44 -0
- package/kit/studio/server/lib/graph.js +142 -10
- package/kit/studio/server/lib/model-outcomes.js +287 -0
- package/kit/studio/server/lib/palette.js +5 -18
- package/kit/studio/server/lib/permissions.js +260 -17
- package/kit/studio/server/lib/session.js +41 -1
- package/kit/studio/server/lib/usage.js +103 -25
- package/kit/studio/web/app.js +251 -37
- package/kit/studio/web/approvals.js +53 -2
- package/kit/studio/web/ask.js +137 -0
- package/kit/studio/web/canvas.js +220 -59
- package/kit/studio/web/chat.js +19 -2
- package/kit/studio/web/convo.js +14 -1
- package/kit/studio/web/dock.js +30 -9
- package/kit/studio/web/graph-plan.js +66 -18
- package/kit/studio/web/icons/builtin.svg +1 -1
- package/kit/studio/web/index.html +11 -4
- package/kit/studio/web/inspect.js +33 -1
- package/kit/studio/web/keys.js +29 -0
- package/kit/studio/web/list-plan.js +8 -3
- package/kit/studio/web/list.js +35 -7
- package/kit/studio/web/models-plan.js +167 -0
- package/kit/studio/web/models.js +127 -0
- package/kit/studio/web/names.js +84 -0
- package/kit/studio/web/style.css +134 -15
- package/kit/studio/web/timeline-plan.js +8 -0
- package/kit/studio/web/timeline.js +36 -10
- package/kit/studio/web/usage.js +53 -12
- package/package.json +1 -1
package/kit/studio/web/app.js
CHANGED
|
@@ -23,9 +23,13 @@ import { NewSession } from './newsession.js';
|
|
|
23
23
|
import { Timeline } from './timeline.js';
|
|
24
24
|
import { List } from './list.js';
|
|
25
25
|
import { defaultView, narrowWarning } from './list-plan.js';
|
|
26
|
+
import { Models } from './models.js';
|
|
27
|
+
import { cardOf, cardLines, workSteps } from './models-plan.js';
|
|
26
28
|
import { fitLevel, runsOver } from './toolbar-fit.js';
|
|
27
|
-
import { roleName, shownType } from './names.js';
|
|
28
|
-
import {
|
|
29
|
+
import { roleName, shownType, modelFamily } from './names.js';
|
|
30
|
+
import { shortcutOf } from './keys.js';
|
|
31
|
+
import { SESSION_MODES } from './convo.js';
|
|
32
|
+
import { fmtDuration, fmtTokens, tiles, timeLine, rightNow, skillsOf, delegatedBy, reportOf, backgroundLines, modelLines, TABS as INSPECTOR_TABS, TAB_WORD } from './inspect.js';
|
|
29
33
|
|
|
30
34
|
const FLEET_POLL_MS = 2000;
|
|
31
35
|
const SESSION_POLL_MS = 5000;
|
|
@@ -67,11 +71,14 @@ const el = {
|
|
|
67
71
|
tbZoom: document.getElementById('tb-zoom'),
|
|
68
72
|
tbZoomIn: document.getElementById('tb-zoom-in'),
|
|
69
73
|
tbFit: document.getElementById('tb-fit'),
|
|
74
|
+
tbReset: document.getElementById('tb-reset'),
|
|
70
75
|
canvasEl: document.getElementById('canvas'),
|
|
71
76
|
timeline: document.getElementById('timeline'),
|
|
72
77
|
viewGraph: document.getElementById('view-graph'),
|
|
73
78
|
viewTimeline: document.getElementById('view-timeline'),
|
|
74
79
|
viewList: document.getElementById('view-list'),
|
|
80
|
+
viewModels: document.getElementById('view-models'),
|
|
81
|
+
models: document.getElementById('models'),
|
|
75
82
|
list: document.getElementById('list'),
|
|
76
83
|
firstRun: document.getElementById('first-run'),
|
|
77
84
|
stageNote: document.getElementById('stage-note'),
|
|
@@ -289,6 +296,10 @@ const chat = new Chat(el.chat, {
|
|
|
289
296
|
(outcomes.get(sessionId) ?? []).some((o) => o.toolUseId === toolUseId && o.outcome.startsWith('allowed'))
|
|
290
297
|
|| (owned.get(sessionId)?.alwaysAllowed ?? []).includes(toolName),
|
|
291
298
|
onContinue: (pane, button) => continueHere(button, pane.id),
|
|
299
|
+
// The mode in the strip: the three a session can be started in, and the one it is in now is marked.
|
|
300
|
+
onMode: (pane, button, now) => openMenu(button, SESSION_MODES.map((m) => ({
|
|
301
|
+
label: m.label, title: m.title, checked: m.mode === now, run: () => setSessionMode(pane.id, m.mode),
|
|
302
|
+
}))),
|
|
292
303
|
onTerminal: (pane, button) => offerTerminal(button, pane.id),
|
|
293
304
|
},
|
|
294
305
|
});
|
|
@@ -438,17 +449,37 @@ function paintToolbar(st) {
|
|
|
438
449
|
tbValue.group.textContent = GROUP_WORD[st.group] ?? st.group;
|
|
439
450
|
// The grouping is one choice for both views: the Timeline's rows follow the graph's.
|
|
440
451
|
timeline.setGroup(st.group);
|
|
441
|
-
|
|
452
|
+
// When Auto has turned the cards into chips the button says so: the picture did not fit this pane as cards.
|
|
453
|
+
tbValue.density.textContent = st.density === 'auto' && st.drawnAs === 'compact' ? 'Auto \u00b7 compact' : (DENSITY_WORD[st.density] ?? st.density);
|
|
442
454
|
// Auto is a choice the canvas makes; say which one it made, and why.
|
|
443
455
|
el.tbDensity.title = st.density === 'auto' && st.drawnAs
|
|
444
456
|
? `Auto: ${st.drawnAs}${st.stacked ? ', agents of one type folded together' : ''}. `
|
|
445
|
-
+ `Cards stay cards while the picture
|
|
457
|
+
+ `Cards stay cards while the picture fits this pane at ${Math.round(AUTO.readable * 100)}% or larger; choose Comfortable to keep them anyway.`
|
|
446
458
|
: '';
|
|
447
459
|
const pct = `${Math.round(st.zoom * 100)}%`;
|
|
448
460
|
el.tbZoom.textContent = pct;
|
|
449
461
|
el.tbZoom.setAttribute('aria-label', `Zoom ${pct}. Back to 100%`);
|
|
450
|
-
|
|
451
|
-
el.
|
|
462
|
+
// Enabled only while a card has been moved: with none there is nothing to reset.
|
|
463
|
+
if (el.tbReset) el.tbReset.disabled = !(st?.moved > 0);
|
|
464
|
+
paintFoldButtons();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/* Expand all and Fold all act on the view that is open: the graph's groups, the Timeline's, the List's sections.
|
|
468
|
+
Each is enabled only while it has something left to do there, so the pair always says what is true. */
|
|
469
|
+
const foldTarget = () => (view === 'timeline' ? timeline : view === 'list' ? list : null);
|
|
470
|
+
function foldState() {
|
|
471
|
+
const t = foldTarget();
|
|
472
|
+
if (t) return t.foldState();
|
|
473
|
+
const st = canvas.state();
|
|
474
|
+
return { groups: st.groups, canExpand: st.canExpand, canFold: st.canFold };
|
|
475
|
+
}
|
|
476
|
+
function paintFoldButtons() {
|
|
477
|
+
// The canvas reports its state while it is being built, before the view and the other two views exist; a
|
|
478
|
+
// `let` or `const` that has not been reached yet throws on any read, `typeof` included.
|
|
479
|
+
let s;
|
|
480
|
+
try { s = foldState(); } catch { return; }
|
|
481
|
+
el.tbExpand.disabled = !s.canExpand;
|
|
482
|
+
el.tbFold.disabled = !s.canFold;
|
|
452
483
|
}
|
|
453
484
|
|
|
454
485
|
// Made before the canvas: the canvas reports its state while it is being built, and the toolbar passes the
|
|
@@ -461,7 +492,8 @@ const timeline = new Timeline(el.timeline, {
|
|
|
461
492
|
onSelect: (n) => { if (n?.kind === 'agent') loadDetail(n.id, n.status); },
|
|
462
493
|
onShowOnGraph: (n) => { setView('graph', n.id); },
|
|
463
494
|
onOpenConversation: () => { if (current) openConversation(current); },
|
|
464
|
-
|
|
495
|
+
// A group folded or opened by hand changes what Expand all and Fold all have left to do, at once.
|
|
496
|
+
onChange: (st) => { paintTimelineBar(st); paintFoldButtons(); },
|
|
465
497
|
fmt: { duration: fmtDuration, tokens: fmtTokens },
|
|
466
498
|
});
|
|
467
499
|
// Bars are placed on the server's clock: the timestamps they are drawn from are that machine's.
|
|
@@ -480,7 +512,7 @@ const canvas = new Canvas(document.getElementById('canvas'), {
|
|
|
480
512
|
: { label: 'Focus on this branch', run: () => canvas.setFocus(n.id) });
|
|
481
513
|
items.push({ label: 'Copy agent id', run: () => copyText(n.id, 'Copied the agent id') });
|
|
482
514
|
}
|
|
483
|
-
if (at.pinned) items.push({ label: 'Reset layout', run: () =>
|
|
515
|
+
if (at.pinned) items.push({ label: 'Reset layout', run: () => resetLayout() });
|
|
484
516
|
if (!items.length) return;
|
|
485
517
|
openMenu({ getBoundingClientRect: () => ({ left: at.x, bottom: at.y }) }, items);
|
|
486
518
|
},
|
|
@@ -506,7 +538,25 @@ pickFrom(el.tbGroup, GROUP_WORD, () => canvas.state().group, (g) => canvas.setGr
|
|
|
506
538
|
canvas.setAside(!railBand.matches);
|
|
507
539
|
railBand.addEventListener('change', () => canvas.setAside(!railBand.matches));
|
|
508
540
|
pickFrom(el.tbDensity, DENSITY_WORD, () => canvas.state().density, (d) => canvas.setDensity(d));
|
|
509
|
-
|
|
541
|
+
// Show: by state, and by the model the work ran on. The two are separate choices and both hold at once.
|
|
542
|
+
let showModel = null;
|
|
543
|
+
function setShowModel(family) {
|
|
544
|
+
showModel = family;
|
|
545
|
+
canvas.setModelFilter(family);
|
|
546
|
+
timeline.setModelFilter(family);
|
|
547
|
+
list.render?.();
|
|
548
|
+
tbValue.show.textContent = [SHOW[show].word, family ? `${family[0].toUpperCase()}${family.slice(1)}` : null].filter(Boolean).join(' \u00b7 ');
|
|
549
|
+
}
|
|
550
|
+
el.tbShow.addEventListener('click', (e) => {
|
|
551
|
+
e.stopPropagation();
|
|
552
|
+
const items = Object.entries(SHOW).map(([key, w]) => ({ label: w.word, checked: show === key, run: () => { setShow(key); setShowModel(showModel); } }));
|
|
553
|
+
const families = [...new Set((lastNodes ?? []).filter((n) => n.kind === 'agent').map((n) => modelFamily(n.model)).filter(Boolean))].sort();
|
|
554
|
+
if (families.length) {
|
|
555
|
+
items.push({ note: 'Model' }, { label: 'Every model', checked: showModel === null, run: () => setShowModel(null) },
|
|
556
|
+
...families.map((f) => ({ label: `Only ${f[0].toUpperCase()}${f.slice(1)}`, checked: showModel === f, run: () => setShowModel(f) })));
|
|
557
|
+
}
|
|
558
|
+
openMenu(el.tbShow, items);
|
|
559
|
+
});
|
|
510
560
|
// The toolbar is measured, not assumed: it gives up a step at a time until what it shows fits (toolbar-fit.js).
|
|
511
561
|
// Every step is tried from the first, so a toolbar that has room again takes its words back.
|
|
512
562
|
function fitToolbar() {
|
|
@@ -556,13 +606,21 @@ el.tbOptions.addEventListener('click', (e) => {
|
|
|
556
606
|
}
|
|
557
607
|
openMenu(el.tbOptions, items);
|
|
558
608
|
});
|
|
559
|
-
el.tbExpand.addEventListener('click', () => canvas.expandAll());
|
|
560
|
-
el.tbFold.addEventListener('click', () => canvas.foldAll());
|
|
609
|
+
el.tbExpand.addEventListener('click', () => { (foldTarget() ?? canvas).expandAll(); paintFoldButtons(); });
|
|
610
|
+
el.tbFold.addEventListener('click', () => { (foldTarget() ?? canvas).foldAll(); paintFoldButtons(); });
|
|
561
611
|
el.tbZoomOut.addEventListener('click', () => canvas.zoomBy(1 / 1.2));
|
|
562
612
|
el.tbZoomIn.addEventListener('click', () => canvas.zoomBy(1.2));
|
|
563
613
|
el.tbZoom.addEventListener('click', () => canvas.zoomTo(1));
|
|
564
614
|
el.tbFit.addEventListener('click', () => canvas.fit());
|
|
565
615
|
|
|
616
|
+
/* Reset layout: the cards moved by hand go back where the layout puts them, and for a moment that can be undone. */
|
|
617
|
+
function resetLayout() {
|
|
618
|
+
const was = canvas.resetLayout();
|
|
619
|
+
if (!was) return;
|
|
620
|
+
toast('Layout reset', { label: 'Undo', run: () => { if (canvas.restoreLayout(was)) toast('Layout restored'); } });
|
|
621
|
+
}
|
|
622
|
+
el.tbReset.addEventListener('click', resetLayout);
|
|
623
|
+
|
|
566
624
|
/* --------------------------------------------------------------- views
|
|
567
625
|
The same session two ways: where its agents are, and when they were. The choice of what is selected, how it is
|
|
568
626
|
grouped and what is shown is one choice, kept across the switch. */
|
|
@@ -571,13 +629,65 @@ let view = 'graph';
|
|
|
571
629
|
|
|
572
630
|
// The List: the same agents by what they need from the reader. A waiting request is a card with its own three
|
|
573
631
|
// answers there, so the dock stands down while the List is the view.
|
|
632
|
+
/* Which model did which class of work in this project (Crewforth's own record), and the holds that could be lowered.
|
|
633
|
+
Read only: lowering a hold is a Crewforth command the user runs in their own session, never a request from here. */
|
|
634
|
+
let modelsData = null;
|
|
635
|
+
let modelsFor = null;
|
|
636
|
+
let modelsAt = 0;
|
|
637
|
+
const models = new Models(el.models, {
|
|
638
|
+
onCopy: (text) => navigator.clipboard.writeText(text).then(() => toast('Copied — send it in your own session to lower the hold'), () => toast(`Not copied — select it: ${text}`)),
|
|
639
|
+
});
|
|
640
|
+
let modelsBusy = false;
|
|
641
|
+
async function loadModels(force = false) {
|
|
642
|
+
if (!current) return;
|
|
643
|
+
// One request at a time: a slow answer is not asked for again by the next tick, and an answer for a session the
|
|
644
|
+
// viewer has left is dropped.
|
|
645
|
+
if (modelsBusy) return;
|
|
646
|
+
if (!force && modelsFor === current && Date.now() - modelsAt < MODELS_POLL_MS) return;
|
|
647
|
+
const asked = current;
|
|
648
|
+
modelsAt = Date.now();
|
|
649
|
+
modelsBusy = true;
|
|
650
|
+
let data;
|
|
651
|
+
try { data = await getJson(`/api/models?session=${encodeURIComponent(asked)}`); } catch (e) { data = { measured: false, reason: e.message, classes: [], suggestions: [], byAgent: {} }; }
|
|
652
|
+
finally { modelsBusy = false; }
|
|
653
|
+
if (asked !== current) return;
|
|
654
|
+
const changed = JSON.stringify(data) !== JSON.stringify(modelsData) || modelsFor !== asked;
|
|
655
|
+
modelsFor = asked;
|
|
656
|
+
modelsData = data;
|
|
657
|
+
if (!changed) return;
|
|
658
|
+
models.setData(data);
|
|
659
|
+
if (inspectorNode?.kind === 'agent') paintInspector();
|
|
660
|
+
}
|
|
661
|
+
/* What the classes cost is read from the project's transcripts, which is the slow part: asked for apart, once
|
|
662
|
+
when the view opens and then once a minute while it stays open, and never twice at once. */
|
|
663
|
+
let costsBusy = false;
|
|
664
|
+
let costsFor = null;
|
|
665
|
+
let costsAt = 0;
|
|
666
|
+
async function loadCosts(force = false) {
|
|
667
|
+
if (!current || costsBusy) return;
|
|
668
|
+
if (!force && costsFor === current && Date.now() - costsAt < COSTS_POLL_MS) return;
|
|
669
|
+
const asked = current;
|
|
670
|
+
costsAt = Date.now();
|
|
671
|
+
costsBusy = true;
|
|
672
|
+
let costs = null;
|
|
673
|
+
try { costs = await getJson(`/api/models/cost?session=${encodeURIComponent(asked)}`); } catch { /* the cells keep saying they are being read */ }
|
|
674
|
+
finally { costsBusy = false; }
|
|
675
|
+
if (asked !== current) return;
|
|
676
|
+
costsFor = asked;
|
|
677
|
+
models.setCosts(costs);
|
|
678
|
+
}
|
|
679
|
+
const COSTS_POLL_MS = 60000;
|
|
680
|
+
const MODELS_POLL_MS = 5000;
|
|
681
|
+
|
|
574
682
|
const list = new List(el.list, {
|
|
575
683
|
tileOf: (n) => canvas.tileFor(n),
|
|
576
684
|
dimmed: (n) => canvas.dimmed(n),
|
|
577
685
|
// A row leads to the agent's detail: the inspector, which on a phone is a page of its own.
|
|
578
686
|
onSelect: (n) => { list.select(n.id); canvas.select(n.id); },
|
|
579
|
-
onDecide: (item, verdict) => decideRequest(item, verdict),
|
|
687
|
+
onDecide: (item, verdict, extra) => decideRequest(item, verdict, extra),
|
|
580
688
|
onLocate: (item) => { if (item.sessionId !== current) selectSession(item.sessionId); },
|
|
689
|
+
// A section folded or opened by hand changes what Expand all and Fold all have left to do, at once.
|
|
690
|
+
onFold: () => paintFoldButtons(),
|
|
581
691
|
});
|
|
582
692
|
list.now = () => serverNow();
|
|
583
693
|
|
|
@@ -603,13 +713,15 @@ function paintTimelineBar(st) {
|
|
|
603
713
|
*/
|
|
604
714
|
function setView(next, select = null, remember = true) {
|
|
605
715
|
const was = view;
|
|
606
|
-
view = ['graph', 'timeline', 'list'].includes(next) ? next : 'graph';
|
|
716
|
+
view = ['graph', 'timeline', 'list', 'models'].includes(next) ? next : 'graph';
|
|
607
717
|
// A view the window opened on by default is not a choice, and is not remembered as one.
|
|
608
718
|
if (remember) store.set('crewforth-studio-view', view);
|
|
609
719
|
const carried = select ?? (was === 'timeline' ? timeline.selected : was === 'list' ? list.selected : canvas.selected) ?? null;
|
|
610
720
|
el.canvasEl.hidden = view !== 'graph';
|
|
611
721
|
el.timeline.hidden = view !== 'timeline';
|
|
612
722
|
el.list.hidden = view !== 'list';
|
|
723
|
+
el.models.hidden = view !== 'models';
|
|
724
|
+
el.viewModels.setAttribute('aria-selected', String(view === 'models'));
|
|
613
725
|
el.viewGraph.setAttribute('aria-selected', String(view === 'graph'));
|
|
614
726
|
el.viewTimeline.setAttribute('aria-selected', String(view === 'timeline'));
|
|
615
727
|
el.viewList.setAttribute('aria-selected', String(view === 'list'));
|
|
@@ -617,6 +729,7 @@ function setView(next, select = null, remember = true) {
|
|
|
617
729
|
for (const c of document.querySelectorAll('.toolbar [data-view]')) c.hidden = !c.dataset.view.split(' ').includes(view);
|
|
618
730
|
queueFit();
|
|
619
731
|
el.stage.dataset.view = view;
|
|
732
|
+
paintFoldButtons();
|
|
620
733
|
// The List's cards carry the answers; the dock would be the same requests a second time.
|
|
621
734
|
dock.setSuppressed(view === 'list');
|
|
622
735
|
measureDock();
|
|
@@ -632,6 +745,12 @@ function setView(next, select = null, remember = true) {
|
|
|
632
745
|
} else if (view === 'list') {
|
|
633
746
|
list.select(carried && canvas.nodes.get(carried)?.kind === 'agent' ? carried : null);
|
|
634
747
|
list.render();
|
|
748
|
+
} else if (view === 'models') {
|
|
749
|
+
// The record is the project's, not the session's: it is read when the view opens and while it stays open.
|
|
750
|
+
models.render();
|
|
751
|
+
if (costsFor !== current) models.setCosts(null);
|
|
752
|
+
loadModels(true);
|
|
753
|
+
loadCosts(costsFor !== current);
|
|
635
754
|
} else {
|
|
636
755
|
canvas.fitIfUntouched();
|
|
637
756
|
if (carried && canvas.nodes.has(carried)) canvas.focus(carried);
|
|
@@ -648,24 +767,26 @@ function goTo(agentId) {
|
|
|
648
767
|
el.viewGraph.addEventListener('click', () => setView('graph'));
|
|
649
768
|
el.viewTimeline.addEventListener('click', () => setView('timeline'));
|
|
650
769
|
el.viewList.addEventListener('click', () => setView('list'));
|
|
770
|
+
el.viewModels.addEventListener('click', () => setView('models'));
|
|
651
771
|
el.tbFollow.addEventListener('click', () => timeline.setFollow(el.tbFollow.getAttribute('aria-checked') !== 'true'));
|
|
652
772
|
// "Out" is more time on screen, "in" is less: the same way round as the graph's zoom.
|
|
653
773
|
el.tbRangeOut.addEventListener('click', () => timeline.zoom(1));
|
|
654
774
|
el.tbRangeIn.addEventListener('click', () => timeline.zoom(-1));
|
|
655
775
|
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => requestAnimationFrame(() => timeline.render())).observe(el.timeline);
|
|
776
|
+
// 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.fitIfUntouched())).observe(el.canvasEl);
|
|
656
778
|
|
|
657
779
|
getJson('/api/palette')
|
|
658
780
|
.then((p) => {
|
|
659
781
|
canvas.setPalette(p);
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
// the panel is drawing "not measured" as a fact about the agents.
|
|
782
|
+
// With Crewforth's agents unread, a `crew-…` agent cannot be told from one that only carries the name. That has
|
|
783
|
+
// to be said out loud, or the panel is drawing "not measured" as a fact about the agents.
|
|
663
784
|
if (p && p.measured === false) {
|
|
664
785
|
document.body.dataset.paletteMeasured = 'false';
|
|
665
786
|
el.foot.textContent = `agent identity Not measured — ${p.reason}`;
|
|
666
787
|
}
|
|
667
788
|
})
|
|
668
|
-
.catch(() => { /* every agent is drawn
|
|
789
|
+
.catch(() => { /* every agent is drawn with the common mark, which is what is known */ });
|
|
669
790
|
|
|
670
791
|
let inspectorTab = 'overview';
|
|
671
792
|
let inspectorNode = null;
|
|
@@ -849,6 +970,54 @@ function paintAgentOverview(body, n, detail) {
|
|
|
849
970
|
}
|
|
850
971
|
body.append(grid);
|
|
851
972
|
|
|
973
|
+
// The task card the work started with, and how Crewforth recorded it going: the calls of this card, oldest
|
|
974
|
+
// first, each with what it ran on and what its verify command said; the last is this agent's.
|
|
975
|
+
if (n.kind === 'agent') {
|
|
976
|
+
if (modelsFor !== current) loadModels();
|
|
977
|
+
const row = modelsFor === current ? (modelsData?.byAgent?.[n.id] ?? null) : null;
|
|
978
|
+
const lines = cardLines(cardOf(detail?.prompt ?? null), row);
|
|
979
|
+
const work = workSteps(row);
|
|
980
|
+
if (lines.length) {
|
|
981
|
+
const dl = node('dl', 'icard');
|
|
982
|
+
for (const l of lines) dl.append(node('dt', null, l.label), node('dd', null, l.text));
|
|
983
|
+
const sec = isection('Task card', dl);
|
|
984
|
+
if (work.risk) { const tag = node('span', 'mv-tag', work.risk); tag.dataset.risk = work.risk; sec.children[0].append(tag); }
|
|
985
|
+
body.append(sec);
|
|
986
|
+
}
|
|
987
|
+
// No card and no line: an agent that is not a crew agent, and nothing is said about it here.
|
|
988
|
+
if (row || lines.length) {
|
|
989
|
+
const ol = node('ol', 'isteps');
|
|
990
|
+
work.steps.forEach((st, i) => {
|
|
991
|
+
const li = node('li');
|
|
992
|
+
const says = node('span', 'istep-says', st.text);
|
|
993
|
+
if (st.verify) { says.append(' \u00b7 verify'); const v = node('span', 'mv-tag', st.verify); v.dataset.tone = st.tone; says.append(v); }
|
|
994
|
+
if (st.escalated) { const e = node('span', 'mv-tag', 'escalated'); e.dataset.kind = 'escalated'; says.append(e); }
|
|
995
|
+
li.append(node('span', 'istep-n', String(i + 1)), says);
|
|
996
|
+
ol.append(li);
|
|
997
|
+
});
|
|
998
|
+
const sec = isection('Model and verify', ...(work.steps.length ? [ol] : []), ...(work.note ? [node('p', 'istep-note', work.note)] : []));
|
|
999
|
+
body.append(sec);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// What it ran on and what its call asked for, and what the model gate or a low-confidence report did to it.
|
|
1004
|
+
const models = modelLines(n, lastNodes);
|
|
1005
|
+
if (models.length) {
|
|
1006
|
+
const dl = node('dl', 'imodel');
|
|
1007
|
+
for (const m of models) {
|
|
1008
|
+
const dd = node('dd', null, m.text);
|
|
1009
|
+
if (m.note) dd.title = m.note;
|
|
1010
|
+
if (m.goto) {
|
|
1011
|
+
const go = node('button', 'ilink', 'show');
|
|
1012
|
+
go.type = 'button';
|
|
1013
|
+
go.addEventListener('click', () => goTo(m.goto));
|
|
1014
|
+
dd.append(' ', go);
|
|
1015
|
+
}
|
|
1016
|
+
dl.append(node('dt', null, m.label), dd);
|
|
1017
|
+
}
|
|
1018
|
+
body.append(isection('Model', dl));
|
|
1019
|
+
}
|
|
1020
|
+
|
|
852
1021
|
const gap = n.kind === 'agent' ? detailGap(n, detail) : null;
|
|
853
1022
|
const read = n.kind === 'agent' && !gap ? detail : null;
|
|
854
1023
|
|
|
@@ -1234,11 +1403,20 @@ function marked(text, cls) {
|
|
|
1234
1403
|
/* A short line that says something happened and goes away. Not a dialog: it
|
|
1235
1404
|
takes no focus and needs no answer. */
|
|
1236
1405
|
let toastTimer = null;
|
|
1237
|
-
function toast(text) {
|
|
1238
|
-
el.toast.
|
|
1406
|
+
function toast(text, action = null) {
|
|
1407
|
+
el.toast.replaceChildren(document.createTextNode(text));
|
|
1408
|
+
// One thing that can be done about what just happened, while the line is up: "Undo".
|
|
1409
|
+
if (action) {
|
|
1410
|
+
const b = document.createElement('button');
|
|
1411
|
+
b.type = 'button';
|
|
1412
|
+
b.className = 'toast-act';
|
|
1413
|
+
b.textContent = action.label;
|
|
1414
|
+
b.addEventListener('click', () => { el.toast.hidden = true; action.run(); });
|
|
1415
|
+
el.toast.append(' \u00b7 ', b);
|
|
1416
|
+
}
|
|
1239
1417
|
el.toast.hidden = false;
|
|
1240
1418
|
clearTimeout(toastTimer);
|
|
1241
|
-
toastTimer = setTimeout(() => { el.toast.hidden = true; }, 3200);
|
|
1419
|
+
toastTimer = setTimeout(() => { el.toast.hidden = true; }, action ? 6000 : 3200);
|
|
1242
1420
|
}
|
|
1243
1421
|
|
|
1244
1422
|
async function copyText(text, said) {
|
|
@@ -1500,7 +1678,7 @@ function sessionRow(project, sn) {
|
|
|
1500
1678
|
if (subText) row.append(marked(subText, 'sub srow-sub'));
|
|
1501
1679
|
// What it spent, once the server has read it: time · tokens · ~cost. Asked for a few rows at a time.
|
|
1502
1680
|
const spent = usageFor(sn);
|
|
1503
|
-
const line = usageLine(spent);
|
|
1681
|
+
const line = usageLine(spent, { live: sessionStatus(sn.sessionId, fleetData).known && sessionStatus(sn.sessionId, fleetData).key !== 'ended', now: serverNow() });
|
|
1504
1682
|
if (line) {
|
|
1505
1683
|
const u = node('span', 'sub srow-usage', line);
|
|
1506
1684
|
u.title = costNote(spent);
|
|
@@ -1951,10 +2129,19 @@ function stepAttention(by) {
|
|
|
1951
2129
|
// reading panel appear on every click there made choosing expensive. The
|
|
1952
2130
|
// conversation is opened from the session node on the canvas, which is the
|
|
1953
2131
|
// thing that represents it.
|
|
2132
|
+
// Where this session's agents are defined. Asked once per session; an answer for a session no longer shown is dropped.
|
|
2133
|
+
async function loadOrigins(sessionId) {
|
|
2134
|
+
canvas.setOrigins(null);
|
|
2135
|
+
let o = null;
|
|
2136
|
+
try { o = await getJson(`/api/agents?session=${encodeURIComponent(sessionId)}`); } catch { /* the mark says whose it is without it */ }
|
|
2137
|
+
if (current === sessionId) canvas.setOrigins(o?.measured ? o : null);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
1954
2140
|
function selectSession(sessionId) {
|
|
1955
2141
|
if (current === sessionId) return;
|
|
1956
2142
|
current = sessionId;
|
|
1957
2143
|
canvas.setSession(sessionId);
|
|
2144
|
+
loadOrigins(sessionId);
|
|
1958
2145
|
timeline.setSession(sessionId);
|
|
1959
2146
|
list.setSession(sessionId);
|
|
1960
2147
|
timeline.setFilter(null);
|
|
@@ -2039,7 +2226,7 @@ function paintPulse() {
|
|
|
2039
2226
|
pulse.detail.textContent = l.detail ? `· ${l.detail}` : '';
|
|
2040
2227
|
}
|
|
2041
2228
|
paintPulse();
|
|
2042
|
-
setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); }, 1000);
|
|
2229
|
+
setInterval(() => { paintPulse(); dock.tick(); timeline.tick(); list.tick(); paintStageNote(); if (view === 'models') { loadModels(); loadCosts(); } }, 1000);
|
|
2043
2230
|
|
|
2044
2231
|
/* -------------------------------------------------------------- home, keys */
|
|
2045
2232
|
|
|
@@ -2071,28 +2258,33 @@ document.addEventListener('keydown', (e) => {
|
|
|
2071
2258
|
if (!el.inspector.hidden) { canvas.clearSelection(); showInspector(null); }
|
|
2072
2259
|
return;
|
|
2073
2260
|
}
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
if (
|
|
2261
|
+
// A letter is a shortcut only when it is not being typed into a field and no modifier is held: Cmd+R is the
|
|
2262
|
+
// browser's reload, not "reset layout".
|
|
2263
|
+
const key = shortcutOf(e);
|
|
2264
|
+
if (key === null) return;
|
|
2265
|
+
if (key === '/') {
|
|
2078
2266
|
e.preventDefault();
|
|
2079
2267
|
setSideHidden(false);
|
|
2080
2268
|
el.filter.focus();
|
|
2081
|
-
} else if (
|
|
2269
|
+
} else if (key === '[') {
|
|
2082
2270
|
setSideHidden(true);
|
|
2083
|
-
} else if (
|
|
2271
|
+
} else if (key === ']') {
|
|
2084
2272
|
setSideHidden(false);
|
|
2085
|
-
} else if (
|
|
2273
|
+
} else if (key === 'g') {
|
|
2086
2274
|
setView('graph');
|
|
2087
|
-
} else if (
|
|
2275
|
+
} else if (key === 't') {
|
|
2088
2276
|
setView('timeline');
|
|
2089
|
-
} else if (
|
|
2277
|
+
} else if (key === 'l') {
|
|
2090
2278
|
setView('list');
|
|
2091
|
-
} else if (
|
|
2279
|
+
} else if (key === 'r') {
|
|
2280
|
+
if (view === 'graph') resetLayout();
|
|
2281
|
+
} else if (key === 'm') {
|
|
2282
|
+
setView('models');
|
|
2283
|
+
} else if (key === 'j') {
|
|
2092
2284
|
stepAttention(1);
|
|
2093
|
-
} else if (
|
|
2285
|
+
} else if (key === 'k') {
|
|
2094
2286
|
stepAttention(-1);
|
|
2095
|
-
} else if (
|
|
2287
|
+
} else if (key === '?') {
|
|
2096
2288
|
canvas.showLegend(true);
|
|
2097
2289
|
}
|
|
2098
2290
|
});
|
|
@@ -2189,13 +2381,27 @@ function paintApprovals() {
|
|
|
2189
2381
|
if (inspectorNode && inspectorTab === 'gates') paintInspector();
|
|
2190
2382
|
}
|
|
2191
2383
|
|
|
2192
|
-
|
|
2384
|
+
/** Ask for a session started here to be put in another mode. The strip changes when Claude Code says it has. */
|
|
2385
|
+
async function setSessionMode(sessionId, mode) {
|
|
2386
|
+
let res;
|
|
2387
|
+
try {
|
|
2388
|
+
const r = await fetch(api(`/api/owned/${encodeURIComponent(sessionId)}/mode`),
|
|
2389
|
+
{ method: 'POST', headers: { 'content-type': 'application/json', ...writeHeaders }, body: JSON.stringify({ mode }) });
|
|
2390
|
+
res = await r.json();
|
|
2391
|
+
} catch (e) {
|
|
2392
|
+
res = { ok: false, reason: e.message };
|
|
2393
|
+
}
|
|
2394
|
+
if (!res.ok) toast(`Mode not changed — ${res.reason ?? 'no reason given'}`);
|
|
2395
|
+
else pollOwned();
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
async function decideRequest(item, verdict, extra = {}) {
|
|
2193
2399
|
decided.set(item.key, verdict);
|
|
2194
2400
|
let res;
|
|
2195
2401
|
try {
|
|
2196
2402
|
const r = await fetch(
|
|
2197
2403
|
api(`/api/owned/${encodeURIComponent(item.sessionId)}/permissions/${encodeURIComponent(item.toolUseId)}`),
|
|
2198
|
-
{ method: 'POST', headers: { 'content-type': 'application/json', ...writeHeaders }, body: JSON.stringify({ verdict }) },
|
|
2404
|
+
{ method: 'POST', headers: { 'content-type': 'application/json', ...writeHeaders }, body: JSON.stringify({ verdict, ...extra }) },
|
|
2199
2405
|
);
|
|
2200
2406
|
res = await r.json();
|
|
2201
2407
|
} catch (e) {
|
|
@@ -2278,6 +2484,8 @@ function paintStageNote() {
|
|
|
2278
2484
|
stageNote.button.hidden = !off;
|
|
2279
2485
|
// The fleet can say a session ended, or came back, with no transcript changing.
|
|
2280
2486
|
if (lastGraph && sessionIsLive() !== lastLive) showGraph();
|
|
2487
|
+
else if (lastLive === true) paintSpend();
|
|
2488
|
+
paintFoldButtons();
|
|
2281
2489
|
paintTerminalWait();
|
|
2282
2490
|
}
|
|
2283
2491
|
|
|
@@ -2285,13 +2493,19 @@ function paintStageNote() {
|
|
|
2285
2493
|
* Draw the graph the stream last sent. What the server read from files is settled against what the machine says
|
|
2286
2494
|
* of the session first, so this is called again when that changes with no file changing.
|
|
2287
2495
|
*/
|
|
2496
|
+
/** The summary box, from the session on screen. A live session's time is counted to now, so this runs on the tick. */
|
|
2497
|
+
function paintSpend() {
|
|
2498
|
+
const usage = lastNodes?.find((n) => n.kind === 'session')?.usage ?? null;
|
|
2499
|
+
canvas.setSpend(summaryRows(usage, { live: lastLive === true, now: serverNow() }));
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2288
2502
|
function showGraph() {
|
|
2289
2503
|
if (!lastGraph) return;
|
|
2290
2504
|
lastLive = sessionIsLive();
|
|
2291
2505
|
const g = settle(lastGraph, lastLive);
|
|
2292
2506
|
canvas.render(g);
|
|
2293
|
-
canvas.setSpend(summaryRows(g.nodes.find((n) => n.kind === 'session')?.usage ?? null));
|
|
2294
2507
|
lastNodes = g.nodes;
|
|
2508
|
+
paintSpend();
|
|
2295
2509
|
timeline.setNodes(g.nodes);
|
|
2296
2510
|
list.setNodes(g.nodes);
|
|
2297
2511
|
paintAttention(g.nodes);
|
|
@@ -23,6 +23,54 @@ export class ServerClock {
|
|
|
23
23
|
now(localNow) { return localNow + this.offset; }
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// A plan is approved into one of these; the words are the buttons'.
|
|
27
|
+
export const PLAN_CHOICES = [
|
|
28
|
+
{ mode: 'acceptEdits', label: 'Approve \u00b7 accept edits', title: 'Approve the plan and go on in acceptEdits: file edits in the project are accepted without asking; every call is still shown here first.' },
|
|
29
|
+
{ mode: 'default', label: 'Approve \u00b7 ask each time', title: 'Approve the plan and go on in default: each call is asked about here.' },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a request asks of the viewer when the answer is not allow-or-deny.
|
|
34
|
+
* { kind: 'questions', questions: [{ question, header, options: [{ label, description }], multiSelect }] }
|
|
35
|
+
* { kind: 'plan', plan }
|
|
36
|
+
* Null for an ordinary tool call. Only what the tool's own input holds; nothing is added to it.
|
|
37
|
+
*/
|
|
38
|
+
export function askOf(r) {
|
|
39
|
+
const input = r?.input && typeof r.input === 'object' ? r.input : {};
|
|
40
|
+
if (r?.toolName === 'AskUserQuestion' && Array.isArray(input.questions)) {
|
|
41
|
+
return {
|
|
42
|
+
kind: 'questions',
|
|
43
|
+
questions: input.questions.filter((q) => typeof q?.question === 'string').map((q) => ({
|
|
44
|
+
question: q.question,
|
|
45
|
+
header: typeof q.header === 'string' ? q.header : '',
|
|
46
|
+
multiSelect: q.multiSelect === true,
|
|
47
|
+
options: (Array.isArray(q.options) ? q.options : []).filter((o) => typeof o?.label === 'string')
|
|
48
|
+
.map((o) => ({ label: o.label, description: typeof o.description === 'string' ? o.description : '' })),
|
|
49
|
+
})),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (r?.toolName === 'ExitPlanMode') return { kind: 'plan', plan: typeof input.plan === 'string' ? input.plan : '' };
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The answers a form holds, as the tool takes them: question text -> the chosen label, a list of labels for a
|
|
58
|
+
* question that takes several, or the viewer's own text. Null while any question has no answer.
|
|
59
|
+
* @param picked Map question -> Set of labels · @param typed Map question -> free text
|
|
60
|
+
*/
|
|
61
|
+
export function answersOf(ask, picked, typed) {
|
|
62
|
+
const out = {};
|
|
63
|
+
for (const q of ask?.questions ?? []) {
|
|
64
|
+
const own = (typed?.get(q.question) ?? '').trim();
|
|
65
|
+
const labels = [...(picked?.get(q.question) ?? [])];
|
|
66
|
+
if (own && !q.multiSelect) out[q.question] = own;
|
|
67
|
+
else if (q.multiSelect && (labels.length || own)) out[q.question] = own ? [...labels, own] : labels;
|
|
68
|
+
else if (!q.multiSelect && labels.length) out[q.question] = labels[0];
|
|
69
|
+
else return null;
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
26
74
|
/**
|
|
27
75
|
* Every request waiting in every session started here, oldest first: that is the order they will time out in.
|
|
28
76
|
*
|
|
@@ -45,7 +93,10 @@ export function queue(sessions, nameOf = () => null) {
|
|
|
45
93
|
agentType: r.agentType ?? null,
|
|
46
94
|
askedAt: r.askedAt,
|
|
47
95
|
// Null when the server did not say. The dock then shows no countdown rather than one it made up.
|
|
48
|
-
waitSeconds: typeof
|
|
96
|
+
waitSeconds: typeof r.waitSeconds === 'number' && r.waitSeconds > 0 ? r.waitSeconds
|
|
97
|
+
: (typeof s.gateWaitSeconds === 'number' && s.gateWaitSeconds > 0 ? s.gateWaitSeconds : null),
|
|
98
|
+
// What a question asks and what a plan says: the two requests that are not answered with allow or deny.
|
|
99
|
+
ask: askOf(r),
|
|
49
100
|
});
|
|
50
101
|
}
|
|
51
102
|
}
|
|
@@ -125,7 +176,7 @@ export function settled(before, after, decided, serverNowMs, slackMs = 2500) {
|
|
|
125
176
|
const verdict = decided?.get(r.key) ?? null;
|
|
126
177
|
let outcome;
|
|
127
178
|
if (verdict === 'deny') outcome = 'denied';
|
|
128
|
-
else if (verdict === 'allow') outcome = 'allowed';
|
|
179
|
+
else if (verdict === 'allow' || verdict === 'answer' || verdict === 'plan') outcome = 'allowed';
|
|
129
180
|
else if (verdict === 'always') outcome = 'allowed-session';
|
|
130
181
|
else if (r.waitSeconds != null && serverNowMs >= r.askedAt + r.waitSeconds * 1000 - slackMs) outcome = 'timed-out';
|
|
131
182
|
else outcome = 'answered-elsewhere';
|