crewforth 3.1.0-rc.2 → 3.1.0-rc.3
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 +40 -0
- package/kit/agents/crew-commit-agent.md +5 -2
- package/kit/eval/doctor.sh +26 -0
- package/kit/eval/smoke-test.sh +69 -2
- package/kit/hooks/context-usage.sh +1 -1
- package/kit/hooks/guard-bash.sh +36 -5
- package/kit/studio/server/index.js +18 -0
- package/kit/studio/server/lib/graph.js +110 -11
- package/kit/studio/server/lib/permissions.js +11 -3
- package/kit/studio/server/lib/pricing.js +63 -0
- package/kit/studio/server/lib/usage.js +144 -0
- package/kit/studio/web/app.js +158 -37
- package/kit/studio/web/approvals.js +20 -1
- package/kit/studio/web/canvas.js +95 -20
- package/kit/studio/web/chat.js +1 -0
- package/kit/studio/web/convo.js +4 -2
- package/kit/studio/web/dock.js +1 -0
- package/kit/studio/web/graph-plan.js +67 -12
- package/kit/studio/web/index.html +1 -0
- package/kit/studio/web/inspect.js +14 -1
- package/kit/studio/web/list-plan.js +4 -4
- package/kit/studio/web/list.js +2 -0
- package/kit/studio/web/names.js +33 -0
- package/kit/studio/web/style.css +48 -10
- package/kit/studio/web/timeline-plan.js +5 -5
- package/kit/studio/web/timeline.js +10 -4
- package/kit/studio/web/usage.js +77 -0
- package/package.json +1 -1
package/kit/studio/web/canvas.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
plan, sequence, crowdFolds, fitZoom, stateOf, ZOOM, GROUPINGS, DENSITIES,
|
|
15
15
|
} from './graph-plan.js';
|
|
16
16
|
import { agentStatus } from './nav.js';
|
|
17
|
+
import { roleName, shownType, hoverOf } from './names.js';
|
|
17
18
|
|
|
18
19
|
// How long an arriving wire takes to draw itself toward its new card. Short on purpose: this is a status panel,
|
|
19
20
|
// and anything a viewer has to wait through is a cost they pay on every spawn.
|
|
@@ -84,6 +85,7 @@ export class Canvas {
|
|
|
84
85
|
this.mapRects = new Map(); // drawn item id -> its box on the minimap
|
|
85
86
|
this.seq = new Map(); // id -> arrival number (see graph-plan.js)
|
|
86
87
|
this.open = new Map(); // group id -> the viewer's own fold
|
|
88
|
+
this.aside = false; // an agent group opens into the next column, not downward
|
|
87
89
|
// Ids that arrived on the last poll. A wire into one of these draws itself; every other wire is left alone,
|
|
88
90
|
// so opening a group of 105 does not set 105 animations running at once.
|
|
89
91
|
this.newborn = new Set();
|
|
@@ -139,11 +141,53 @@ export class Canvas {
|
|
|
139
141
|
this.mapEl.append(this.mapSvg);
|
|
140
142
|
|
|
141
143
|
this.legendEl = this.#legend();
|
|
144
|
+
this.spendEl = this.#spend();
|
|
142
145
|
this.tipEl = mk('div', 'cv-tip');
|
|
143
146
|
this.tipEl.setAttribute('role', 'tooltip');
|
|
144
147
|
this.tipEl.hidden = true;
|
|
145
148
|
|
|
146
|
-
this.root.append(this.viewport, this.emptyEl, this.mapEl, this.legendEl, this.tipEl);
|
|
149
|
+
this.root.append(this.viewport, this.emptyEl, this.mapEl, this.legendEl, this.spendEl, this.tipEl);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** What the session spent: time, tokens and an estimate of the cost. Closed, it stays closed until asked for. */
|
|
153
|
+
#spend() {
|
|
154
|
+
const box = mk('div', 'cv-legend cv-spend');
|
|
155
|
+
box.setAttribute('aria-label', 'Session summary');
|
|
156
|
+
const head = mk('div', 'cv-legend-head');
|
|
157
|
+
head.append(mk('span', 'cv-label', 'Session'));
|
|
158
|
+
const close = mk('button', 'cv-x');
|
|
159
|
+
close.type = 'button';
|
|
160
|
+
close.setAttribute('aria-label', 'Close the session summary');
|
|
161
|
+
close.innerHTML = MARK.close;
|
|
162
|
+
close.addEventListener('click', () => this.showSpend(false));
|
|
163
|
+
head.append(close);
|
|
164
|
+
this.spendRows = mk('dl', 'cv-spend-rows');
|
|
165
|
+
box.append(head, this.spendRows);
|
|
166
|
+
this.spendOpen = readFlag('crewforth-studio-spend') !== 'closed';
|
|
167
|
+
box.hidden = true; // until there is a session's usage to show
|
|
168
|
+
return box;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
showSpend(on) {
|
|
172
|
+
this.spendOpen = Boolean(on);
|
|
173
|
+
writeFlag('crewforth-studio-spend', on ? 'open' : 'closed');
|
|
174
|
+
this.spendEl.hidden = !on || !this.spendCount;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The rows to show: [label, value, note], from usage.js `summaryRows`. An empty list hides the box. */
|
|
178
|
+
setSpend(rows) {
|
|
179
|
+
const sig = JSON.stringify(rows ?? []);
|
|
180
|
+
if (sig === this.spendSig) return;
|
|
181
|
+
this.spendSig = sig;
|
|
182
|
+
this.spendCount = (rows ?? []).length;
|
|
183
|
+
this.spendRows.replaceChildren(...(rows ?? []).flatMap(([label, value, note]) => {
|
|
184
|
+
const dd = mk('dd', null, value ?? '');
|
|
185
|
+
dd.title = note ?? '';
|
|
186
|
+
const dt = mk('dt', null, label);
|
|
187
|
+
dt.title = note ?? '';
|
|
188
|
+
return [dt, dd];
|
|
189
|
+
}));
|
|
190
|
+
this.spendEl.hidden = !this.spendOpen || !this.spendCount;
|
|
147
191
|
}
|
|
148
192
|
|
|
149
193
|
/** What the lines and the two marks mean. Shown until it is closed, and then not again unless asked for. */
|
|
@@ -196,7 +240,7 @@ export class Canvas {
|
|
|
196
240
|
this.root.addEventListener('pointerdown', (e) => {
|
|
197
241
|
if (e.button !== 0) return; // right/middle must not pan
|
|
198
242
|
const t = e.target;
|
|
199
|
-
if (t.closest?.('.cv-node') || t.closest?.('.cv-minimap') || t.closest?.('.cv-legend')) return;
|
|
243
|
+
if (t.closest?.('.cv-node') || t.closest?.('.cv-minimap') || t.closest?.('.cv-legend')) return; // the summary box is a .cv-legend too
|
|
200
244
|
panning = { px: e.clientX, py: e.clientY, x: this.view.x, y: this.view.y };
|
|
201
245
|
this.root.setPointerCapture(e.pointerId);
|
|
202
246
|
this.root.classList.add('cv-panning');
|
|
@@ -303,7 +347,7 @@ export class Canvas {
|
|
|
303
347
|
drawnAs: this.lastPlan?.density ?? null,
|
|
304
348
|
stacked: Boolean(this.lastPlan?.stacked),
|
|
305
349
|
drawn: this.drawn,
|
|
306
|
-
focus: this.focusId ? (this.nodes.get(this.focusId)?.agentType
|
|
350
|
+
focus: this.focusId ? (roleName(this.nodes.get(this.focusId)?.agentType) || this.focusId) : null,
|
|
307
351
|
groups: (this.lastPlan?.items ?? []).filter((it) => it.kind === 'group').length,
|
|
308
352
|
};
|
|
309
353
|
}
|
|
@@ -384,12 +428,14 @@ export class Canvas {
|
|
|
384
428
|
}
|
|
385
429
|
|
|
386
430
|
expandAll() {
|
|
387
|
-
|
|
431
|
+
// Beside itself an agent group opens one at a time, by its own card; "all" is then every other kind of group.
|
|
432
|
+
const mine = (it) => it.kind === 'group' && !(this.aside && it.type === 'type');
|
|
433
|
+
for (const it of this.lastPlan?.items ?? []) if (mine(it)) this.open.set(it.id, true);
|
|
388
434
|
// Opening one level can reveal groups inside it; open those too.
|
|
389
435
|
for (let i = 0; i < 4; i += 1) {
|
|
390
436
|
const p = this.#plan();
|
|
391
437
|
let more = false;
|
|
392
|
-
for (const it of p.items) if (it
|
|
438
|
+
for (const it of p.items) if (mine(it) && !it.open) { this.open.set(it.id, true); more = true; }
|
|
393
439
|
if (!more) break;
|
|
394
440
|
}
|
|
395
441
|
this.sessionY = null;
|
|
@@ -443,6 +489,7 @@ export class Canvas {
|
|
|
443
489
|
#plan() {
|
|
444
490
|
return plan(this.#visibleNodes(), {
|
|
445
491
|
group: this.group, density: this.density, seq: this.seq, open: this.open, pinned: this.pinned, sessionY: this.sessionY,
|
|
492
|
+
aside: this.aside,
|
|
446
493
|
});
|
|
447
494
|
}
|
|
448
495
|
|
|
@@ -569,7 +616,7 @@ export class Canvas {
|
|
|
569
616
|
|
|
570
617
|
#drawItem(it, liveCells) {
|
|
571
618
|
let el = this.els.get(it.id);
|
|
572
|
-
const shape = it.kind === 'group' ? `group:${it.
|
|
619
|
+
const shape = it.kind === 'group' ? `group:${it.boxed ? `open:${it.grid ? 'grid' : 'rows'}` : 'folded'}`
|
|
573
620
|
: `${it.kind}:${it.compact ? 'chip' : 'card'}`;
|
|
574
621
|
// A card that changes what it IS (an agent that becomes a chip, a group that opens) is rebuilt; one that only
|
|
575
622
|
// changes what it says is updated in place.
|
|
@@ -627,7 +674,7 @@ export class Canvas {
|
|
|
627
674
|
p.head.append(p.tile, p.name, mk('span', 'cv-fill'), p.sub, p.fold);
|
|
628
675
|
p.bar = mk('div', 'cv-bar');
|
|
629
676
|
p.count = mk('span', 'cv-sub');
|
|
630
|
-
if (it.
|
|
677
|
+
if (it.boxed) {
|
|
631
678
|
el.classList.add('cv-open');
|
|
632
679
|
el.append(p.head, p.bar);
|
|
633
680
|
} else {
|
|
@@ -701,7 +748,8 @@ export class Canvas {
|
|
|
701
748
|
#fillSession(el, it) {
|
|
702
749
|
const n = it.node;
|
|
703
750
|
const st = this.sessionState;
|
|
704
|
-
const
|
|
751
|
+
const bg = n.backgroundNow?.length ?? 0;
|
|
752
|
+
const sig = JSON.stringify([n.gitBranch, n.cwd, n.turns, n.tokens, st?.word, st?.tone, bg]);
|
|
705
753
|
if (el.sig === sig) return;
|
|
706
754
|
el.sig = sig;
|
|
707
755
|
const p = el.parts;
|
|
@@ -712,7 +760,10 @@ export class Canvas {
|
|
|
712
760
|
p.name.textContent = n.gitBranch || shortPath(n.cwd) || n.sessionId || '';
|
|
713
761
|
const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
|
|
714
762
|
if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} ctx`);
|
|
763
|
+
// What the session has running that is not an agent: commands sent to the background.
|
|
764
|
+
if (bg) bits.push(`${bg} in background`);
|
|
715
765
|
p.sub.textContent = bits.join(' · ');
|
|
766
|
+
p.sub.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
|
|
716
767
|
el.title = 'Click to inspect this session';
|
|
717
768
|
el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
|
|
718
769
|
}
|
|
@@ -720,7 +771,7 @@ export class Canvas {
|
|
|
720
771
|
#fillAgent(el, n, compact) {
|
|
721
772
|
const st = this.#statusOf(n);
|
|
722
773
|
const who = compact ? this.#identity(n) : this.#fillTile(el.parts.tile, n);
|
|
723
|
-
const type = n.kind === 'workflow' ? (n.workflowId ?? 'workflow run') : (n
|
|
774
|
+
const type = n.kind === 'workflow' ? (n.workflowId ?? 'workflow run') : shownType(n);
|
|
724
775
|
const task = n.kind === 'workflow' ? `${n.members ?? 0} agents` : (n.description ?? '');
|
|
725
776
|
const dim = this.dimmed(n);
|
|
726
777
|
const sig = JSON.stringify([type, task, st.word, st.tone, st.state, who.source, dim, n.status]);
|
|
@@ -735,6 +786,9 @@ export class Canvas {
|
|
|
735
786
|
el.classList.toggle('cv-dim', dim);
|
|
736
787
|
p.name.textContent = type;
|
|
737
788
|
p.task.textContent = task;
|
|
789
|
+
// Both are cut to the card's width; hovering says them whole, and the type as it is declared.
|
|
790
|
+
p.name.title = n.kind === 'workflow' ? '' : (n.agentType ?? '');
|
|
791
|
+
p.task.title = task;
|
|
738
792
|
if (compact) {
|
|
739
793
|
p.dot.dataset.tone = st.tone ?? 'none';
|
|
740
794
|
p.word.textContent = st.word;
|
|
@@ -752,19 +806,26 @@ export class Canvas {
|
|
|
752
806
|
const p = el.parts;
|
|
753
807
|
const counts = it.counts;
|
|
754
808
|
const says = Object.entries(counts).map(([s, c]) => `${c} ${agentStatus(s).word}`).join(' · ');
|
|
755
|
-
const sig = JSON.stringify([it.label, says, it.open, it.type, it.status]);
|
|
809
|
+
const sig = JSON.stringify([it.label, says, it.open, it.boxed, it.type, it.status]);
|
|
756
810
|
if (el.sig !== sig) {
|
|
757
811
|
el.sig = sig;
|
|
758
812
|
el.dataset.state = stateOf(it.status);
|
|
759
813
|
el.dataset.status = it.status;
|
|
760
814
|
el.dataset.group = it.type;
|
|
761
815
|
el.dataset.open = String(Boolean(it.open));
|
|
816
|
+
// Open beside itself: the card stays a card and its agents are the next column.
|
|
817
|
+
el.dataset.aside = String(Boolean(it.open && !it.boxed));
|
|
762
818
|
p.name.textContent = it.label;
|
|
819
|
+
p.name.title = it.real ?? '';
|
|
763
820
|
if (it.type === 'run') { p.tile.innerHTML = MARK.run; p.tile.title = 'Workflow run'; }
|
|
764
821
|
else this.#fillTile(p.tile, it.type === 'type' ? { agentType: it.agentType } : (it.of ?? {}));
|
|
765
|
-
|
|
766
|
-
p.
|
|
767
|
-
p.
|
|
822
|
+
// Several agents of one type: the number is a badge beside the role. Any other group says how many it holds.
|
|
823
|
+
p.sub.textContent = it.type === 'type' ? `× ${it.count}` : `${it.members.length} ${it.members.length === 1 ? 'agent' : 'agents'}`;
|
|
824
|
+
p.sub.classList.toggle('cv-times', it.type === 'type');
|
|
825
|
+
p.fold.innerHTML = it.boxed ? MARK.down : MARK.chevron;
|
|
826
|
+
// Read aloud, the badge is part of the name.
|
|
827
|
+
const said = it.type === 'type' ? `${it.label} × ${it.count}` : it.label;
|
|
828
|
+
p.fold.setAttribute('aria-label', `${it.open ? 'Fold' : 'Open'} ${said}`);
|
|
768
829
|
p.fold.setAttribute('aria-expanded', String(Boolean(it.open)));
|
|
769
830
|
// The bar: how the group went, in the order the eye wants it.
|
|
770
831
|
const order = ['done', 'running', 'starting', 'failed', 'killed', 'stopped', 'stale', 'ended'];
|
|
@@ -776,11 +837,11 @@ export class Canvas {
|
|
|
776
837
|
return seg;
|
|
777
838
|
}));
|
|
778
839
|
p.bar.title = says;
|
|
779
|
-
if (!it.
|
|
780
|
-
el.title = it.open ? '' : `Click to show ${it.members.length} agents
|
|
781
|
-
el.setAttribute('aria-label', `${
|
|
840
|
+
if (!it.boxed) p.count.textContent = says;
|
|
841
|
+
el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
|
|
842
|
+
el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
|
|
782
843
|
}
|
|
783
|
-
if (!it.
|
|
844
|
+
if (!it.boxed) return;
|
|
784
845
|
|
|
785
846
|
for (const cell of it.cells) {
|
|
786
847
|
liveCells.add(cell.id);
|
|
@@ -816,7 +877,7 @@ export class Canvas {
|
|
|
816
877
|
const st = this.#statusOf(n);
|
|
817
878
|
const who = it.grid ? this.#identity(n) : this.#fillTile(c.parts.tile, n);
|
|
818
879
|
// Inside a group of one type the type is the group's name, so a member is told apart by its task.
|
|
819
|
-
const text = it.type === 'type' ? (n.description || n
|
|
880
|
+
const text = it.type === 'type' ? (n.description || shownType(n) || n.id) : shownType(n);
|
|
820
881
|
const dim = this.dimmed(n);
|
|
821
882
|
const csig = JSON.stringify([text, st.word, st.tone, st.state, dim, this.selected === n.id]);
|
|
822
883
|
if (c.sig === csig) continue;
|
|
@@ -826,9 +887,10 @@ export class Canvas {
|
|
|
826
887
|
c.classList.toggle('cv-dim', dim);
|
|
827
888
|
c.classList.toggle('cv-selected', this.selected === n.id);
|
|
828
889
|
c.parts.name.textContent = text;
|
|
890
|
+
c.title = hoverOf(n);
|
|
829
891
|
c.parts.dot.dataset.tone = st.tone ?? 'none';
|
|
830
892
|
c.parts.word.textContent = st.word;
|
|
831
|
-
c.setAttribute('aria-label', `${n
|
|
893
|
+
c.setAttribute('aria-label', `${shownType(n)}, ${st.word}${n.description ? `, ${n.description}` : ''} (${who.title})`);
|
|
832
894
|
}
|
|
833
895
|
}
|
|
834
896
|
|
|
@@ -986,11 +1048,23 @@ export class Canvas {
|
|
|
986
1048
|
const it = this.lastPlan?.items.find((x) => x.id === groupId);
|
|
987
1049
|
if (!it || it.kind !== 'group') return;
|
|
988
1050
|
this.open.set(groupId, !it.open);
|
|
1051
|
+
// Beside itself, one agent's tasks at a time: a second column of them from two groups reads as one list.
|
|
1052
|
+
if (!it.open && it.type === 'type' && this.aside) {
|
|
1053
|
+
for (const other of this.lastPlan.items) if (other.kind === 'group' && other.type === 'type' && other.id !== groupId) this.open.set(other.id, false);
|
|
1054
|
+
}
|
|
989
1055
|
this.sessionY = null;
|
|
990
1056
|
this.#persist();
|
|
991
1057
|
this.#redraw();
|
|
992
1058
|
}
|
|
993
1059
|
|
|
1060
|
+
/** Whether an agent group opens into the next column (a window with room) or downward (a narrow one). */
|
|
1061
|
+
setAside(on) {
|
|
1062
|
+
if (this.aside === Boolean(on)) return;
|
|
1063
|
+
this.aside = Boolean(on);
|
|
1064
|
+
this.sessionY = null;
|
|
1065
|
+
if (this.lastPlan) this.#redraw({ fit: true });
|
|
1066
|
+
}
|
|
1067
|
+
|
|
994
1068
|
/** Drop the selection without pretending a card was clicked. */
|
|
995
1069
|
clearSelection() {
|
|
996
1070
|
this.selected = null;
|
|
@@ -1141,7 +1215,8 @@ export class Canvas {
|
|
|
1141
1215
|
if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} tokens`);
|
|
1142
1216
|
if (n.errors) bits.push(`${n.errors} ${n.errors === 1 ? 'error' : 'errors'}`);
|
|
1143
1217
|
this.tipEl.replaceChildren(
|
|
1144
|
-
mk('strong', null, n
|
|
1218
|
+
mk('strong', null, shownType(n)),
|
|
1219
|
+
mk('div', 'cv-sub cv-real', n.agentType ?? ''),
|
|
1145
1220
|
mk('div', null, n.description || 'No task recorded'),
|
|
1146
1221
|
mk('div', 'cv-sub', bits.length ? bits.join(' · ') : 'No numbers yet'),
|
|
1147
1222
|
);
|
package/kit/studio/web/chat.js
CHANGED
|
@@ -375,6 +375,7 @@ class Pane {
|
|
|
375
375
|
node.sig = sig;
|
|
376
376
|
const text = el('span', 'deleg-text');
|
|
377
377
|
text.append(el('span', 'deleg-type', card.type), el('span', 'deleg-task', card.task));
|
|
378
|
+
text.title = [card.real, card.task].filter(Boolean).join('\n');
|
|
378
379
|
const parts = [];
|
|
379
380
|
const tile = card.agentId ? this.hooks.tileOf?.(this.id, card.agentId) : null;
|
|
380
381
|
if (tile) parts.push(tile);
|
package/kit/studio/web/convo.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// The same shapes come from two places: the server's reading of a transcript (history, and sessions Studio only
|
|
4
4
|
// watches) and the live stream of a session started here. Both go through these functions, so a tool row reads
|
|
5
5
|
// the same whichever way it arrived.
|
|
6
|
+
import { roleName } from './names.js';
|
|
6
7
|
|
|
7
8
|
// How much of a tool's output the page keeps for a live result: the same tail the server keeps for history.
|
|
8
9
|
const RESULT_TAIL = 4096;
|
|
@@ -76,7 +77,8 @@ export function outputOf(block) {
|
|
|
76
77
|
export function delegationCard(block, nodes) {
|
|
77
78
|
const node = block.id ? (nodes ?? []).find((n) => n.kind === 'agent' && n.toolUseId === block.id) ?? null : null;
|
|
78
79
|
return {
|
|
79
|
-
type: block.subagentType ?? node?.agentType ?? 'agent',
|
|
80
|
+
type: roleName(block.subagentType ?? node?.agentType ?? 'agent'),
|
|
81
|
+
real: block.subagentType ?? node?.agentType ?? null,
|
|
80
82
|
task: block.label ?? node?.description ?? '',
|
|
81
83
|
agentId: node?.id ?? null,
|
|
82
84
|
status: node?.status ?? null,
|
|
@@ -121,7 +123,7 @@ export function refusals(denials, wasAllowed, resultOf = () => null) {
|
|
|
121
123
|
export function reminderOf(waiting) {
|
|
122
124
|
const list = waiting ?? [];
|
|
123
125
|
if (!list.length) return null;
|
|
124
|
-
const who = list[0].agentType
|
|
126
|
+
const who = list[0].agentType ? roleName(list[0].agentType) : 'This session';
|
|
125
127
|
const more = list.length - 1;
|
|
126
128
|
return `${who} is waiting for approval${more > 0 ? `, and ${more} more ${more === 1 ? 'request is' : 'requests are'}` : ''}`;
|
|
127
129
|
}
|
package/kit/studio/web/dock.js
CHANGED
|
@@ -184,6 +184,7 @@ export class Dock {
|
|
|
184
184
|
p.word.textContent = 'Needs you';
|
|
185
185
|
|
|
186
186
|
p.who.textContent = asker(item);
|
|
187
|
+
p.who.title = item.agentType ? `${item.agentType} — show on the canvas` : 'Show on the canvas';
|
|
187
188
|
p.tool.textContent = item.toolName;
|
|
188
189
|
const where = this.where(item);
|
|
189
190
|
p.where.hidden = !where;
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
// view, so zooming cannot move a card.
|
|
16
16
|
// 3. Colour is not decided here at all. A box carries its status; the stylesheet turns status into colour.
|
|
17
17
|
|
|
18
|
+
import { typeOf, roleName, shownType } from './names.js';
|
|
19
|
+
|
|
18
20
|
/* ------------------------------------------------------------- measures --- */
|
|
19
21
|
|
|
20
22
|
export const SIZE = {
|
|
@@ -68,7 +70,6 @@ const SESSION = 'session';
|
|
|
68
70
|
|
|
69
71
|
/* --------------------------------------------------------------- groups --- */
|
|
70
72
|
|
|
71
|
-
const typeOf = (n) => n.agentType ?? 'unknown agent';
|
|
72
73
|
const byArrival = (seq) => (a, b) => (seq.get(a.id) ?? 0) - (seq.get(b.id) ?? 0);
|
|
73
74
|
|
|
74
75
|
/** Counts by status, in the order a bar draws them. */
|
|
@@ -132,8 +133,11 @@ export function sequence(nodes, seq = new Map()) {
|
|
|
132
133
|
*
|
|
133
134
|
* At `auto` density, once the picture is too tall (`stack`), siblings of one type fold into a group from
|
|
134
135
|
* AUTO.stackAt whatever the grouping is, except under `none`, which means none.
|
|
136
|
+
*
|
|
137
|
+
* `aside(id)` says a type group is open beside itself: its members are then items of their own, one column to the
|
|
138
|
+
* right of the group's card, and what they spawned is a column further on.
|
|
135
139
|
*/
|
|
136
|
-
function itemsOf(nodes, { group, density, seq, stack }) {
|
|
140
|
+
function itemsOf(nodes, { group, density, seq, stack, aside }) {
|
|
137
141
|
const byId = new Map(nodes.map((n) => [n.id, n]));
|
|
138
142
|
const kids = new Map();
|
|
139
143
|
for (const n of nodes) {
|
|
@@ -158,7 +162,7 @@ function itemsOf(nodes, { group, density, seq, stack }) {
|
|
|
158
162
|
// An agent's children as one group.
|
|
159
163
|
if (group === 'parent' && list.length >= 2 && byId.get(parentId)?.kind === 'agent') {
|
|
160
164
|
const g = groupItem(`kids:${parentId}`, 'parent', list, anchor, via, depth, seq, {
|
|
161
|
-
label: `${
|
|
165
|
+
label: `${shownType(byId.get(parentId))} → ${list.length}`, of: byId.get(parentId),
|
|
162
166
|
});
|
|
163
167
|
items.push(g);
|
|
164
168
|
for (const m of list) place(m.id, g, m.id, depth + 1);
|
|
@@ -176,10 +180,18 @@ function itemsOf(nodes, { group, density, seq, stack }) {
|
|
|
176
180
|
for (const [t, members] of types) {
|
|
177
181
|
if (members.length < stackAt) continue;
|
|
178
182
|
const g = groupItem(`type:${parentId}:${t}`, 'type', members, anchor, via, depth, seq, {
|
|
179
|
-
|
|
183
|
+
// The role is the name and the number is a badge beside it: `count` is drawn apart from `label`.
|
|
184
|
+
label: roleName(t), count: members.length, real: t, agentType: members[0].agentType ?? null,
|
|
180
185
|
});
|
|
181
186
|
items.push(g);
|
|
182
|
-
|
|
187
|
+
g.aside = Boolean(aside?.(g.id));
|
|
188
|
+
for (const m of members) {
|
|
189
|
+
stacked.add(m.id);
|
|
190
|
+
if (!g.aside) { place(m.id, g, m.id, depth + 1); continue; }
|
|
191
|
+
const item = { id: m.id, kind: 'agent', node: m, parent: g, via: null, depth: depth + 1, seq: seq.get(m.id) ?? 0, member: true };
|
|
192
|
+
items.push(item);
|
|
193
|
+
place(m.id, item, null, depth + 2);
|
|
194
|
+
}
|
|
183
195
|
}
|
|
184
196
|
|
|
185
197
|
for (const n of list) {
|
|
@@ -229,6 +241,8 @@ function groupItem(id, type, members, anchor, via, depth, seq, extra) {
|
|
|
229
241
|
* @param opts.open Map groupId -> boolean, the viewer's own folds; a group not in it takes its default
|
|
230
242
|
* @param opts.pinned Map itemId -> {x, y}, positions the viewer set by hand
|
|
231
243
|
* @param opts.sessionY where the session card was put, to keep it there; omitted on a fresh layout
|
|
244
|
+
* @param opts.aside true when an open type group puts its agents in the next column instead of growing
|
|
245
|
+
* downward; a narrow window leaves it off
|
|
232
246
|
* @returns { items, edges, width, height, drawn, density, sessionY }
|
|
233
247
|
*/
|
|
234
248
|
export function plan(nodes, opts = {}) {
|
|
@@ -240,6 +254,7 @@ export function plan(nodes, opts = {}) {
|
|
|
240
254
|
|
|
241
255
|
// A type group starts folded: it is the same thing several times. A run or an agent's children start open.
|
|
242
256
|
const isOpen = (g) => (open.has(g.id) ? open.get(g.id) : g.type !== 'type');
|
|
257
|
+
const aside = opts.aside ? (id) => open.get(id) === true : null;
|
|
243
258
|
|
|
244
259
|
// What is drawn, and how many agents of it the viewer reads one by one: a card, a row or a chip each.
|
|
245
260
|
const drawFrom = (items) => {
|
|
@@ -254,7 +269,8 @@ export function plan(nodes, opts = {}) {
|
|
|
254
269
|
let count = 0;
|
|
255
270
|
for (const it of shown) {
|
|
256
271
|
if (it.kind === 'agent' || it.kind === 'run-card') count += 1;
|
|
257
|
-
|
|
272
|
+
// A group open beside itself has its agents among the items already.
|
|
273
|
+
else if (it.kind === 'group' && !it.aside) count += isOpen(it) ? it.members.length : 1;
|
|
258
274
|
}
|
|
259
275
|
return { shown, count };
|
|
260
276
|
};
|
|
@@ -271,7 +287,9 @@ export function plan(nodes, opts = {}) {
|
|
|
271
287
|
continue;
|
|
272
288
|
}
|
|
273
289
|
it.open = isOpen(it);
|
|
274
|
-
|
|
290
|
+
// `boxed` is the group drawn as a container with its members inside. Open beside itself it stays a card.
|
|
291
|
+
it.boxed = it.open && !it.aside;
|
|
292
|
+
if (!it.boxed) { it.w = SIZE.card.w; it.h = SIZE.card.h; it.cells = null; continue; }
|
|
275
293
|
const n = it.members.length;
|
|
276
294
|
const grid = compact || n > G.rowsMax;
|
|
277
295
|
const cols = grid ? Math.min(G.colsMax, Math.max(1, Math.ceil(n / G.rowsMax))) : 1;
|
|
@@ -349,13 +367,13 @@ export function plan(nodes, opts = {}) {
|
|
|
349
367
|
|
|
350
368
|
// Auto, in the order it reaches for things: nothing while the picture is short enough to read; siblings of one
|
|
351
369
|
// type folded together when it is not; chips if it still is not.
|
|
352
|
-
let pass = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: false }));
|
|
370
|
+
let pass = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: false, aside }));
|
|
353
371
|
let compact = asked === 'compact';
|
|
354
372
|
let stacked = false;
|
|
355
373
|
let box = lay(pass.shown, compact);
|
|
356
374
|
if (asked === 'auto' && box.height > AUTO.tallest) {
|
|
357
375
|
if (group !== 'none' && group !== 'type') {
|
|
358
|
-
const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true }));
|
|
376
|
+
const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true, aside }));
|
|
359
377
|
if (folded.shown.some((it) => it.kind === 'group' && it.type === 'type')) { pass = folded; stacked = true; box = lay(pass.shown, false); }
|
|
360
378
|
}
|
|
361
379
|
if (box.height > AUTO.tallest) { compact = true; box = lay(pass.shown, true); }
|
|
@@ -400,7 +418,7 @@ export function plan(nodes, opts = {}) {
|
|
|
400
418
|
|
|
401
419
|
return {
|
|
402
420
|
items: drawnItems, edges, width: Math.max(width, SIZE.padX * 2), height: Math.max(height, SIZE.padY * 2),
|
|
403
|
-
drawn, density, group, stacked, sessionY: sessionY ?? null,
|
|
421
|
+
drawn, density, group, stacked, aside: Boolean(aside), sessionY: sessionY ?? null,
|
|
404
422
|
};
|
|
405
423
|
}
|
|
406
424
|
|
|
@@ -432,6 +450,42 @@ export function fitZoom(width, height, paneW, paneH) {
|
|
|
432
450
|
return Math.min(ZOOM.max, Math.max(ZOOM.min, k));
|
|
433
451
|
}
|
|
434
452
|
|
|
453
|
+
/* --------------------------------------------------------------- settle --- */
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* What the transcript cannot say and the live session can.
|
|
457
|
+
*
|
|
458
|
+
* An agent started in the background can sit inside one tool call for twenty minutes and write nothing. The
|
|
459
|
+
* server, reading files, calls that `stale` or `ended`; it cannot tell it from an agent whose session was closed
|
|
460
|
+
* under it. The machine can: while the session is running, an agent whose transcript stops in the middle of a
|
|
461
|
+
* turn is working. So is a command sent to the background that no notice has ended.
|
|
462
|
+
*
|
|
463
|
+
* @param graph the server's { nodes, stats }
|
|
464
|
+
* @param live true when the machine reports the session as running; anything else changes nothing
|
|
465
|
+
* @returns the graph to draw: the same object when nothing in it needed saying differently
|
|
466
|
+
*/
|
|
467
|
+
export function settle(graph, live) {
|
|
468
|
+
const nodes = graph?.nodes ?? [];
|
|
469
|
+
const quiet = (n) => n.kind === 'agent' && n.background && n.midTurn && (n.status === 'stale' || n.status === 'ended');
|
|
470
|
+
const session = nodes.find((n) => n.kind === 'session');
|
|
471
|
+
const commands = live === true ? (session?.backgroundCommands ?? []) : [];
|
|
472
|
+
if (live !== true || !nodes.some(quiet)) {
|
|
473
|
+
if (!session || (session.backgroundNow ?? []).length === commands.length) {
|
|
474
|
+
return session ? { ...graph, nodes: nodes.map((n) => (n === session ? { ...n, backgroundNow: commands } : n)) } : graph;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const byStatus = { ...(graph.stats?.byStatus ?? {}) };
|
|
478
|
+
const out = nodes.map((n) => {
|
|
479
|
+
if (n === session) return { ...n, backgroundNow: commands };
|
|
480
|
+
if (live !== true || !quiet(n)) return n;
|
|
481
|
+
byStatus[n.status] -= 1;
|
|
482
|
+
if (!byStatus[n.status]) delete byStatus[n.status];
|
|
483
|
+
byStatus.running = (byStatus.running ?? 0) + 1;
|
|
484
|
+
return { ...n, status: 'running', quiet: true };
|
|
485
|
+
});
|
|
486
|
+
return { ...graph, nodes: out, stats: graph.stats ? { ...graph.stats, byStatus } : graph.stats };
|
|
487
|
+
}
|
|
488
|
+
|
|
435
489
|
/* ------------------------------------------------------------ attention --- */
|
|
436
490
|
|
|
437
491
|
/**
|
|
@@ -445,11 +499,12 @@ export function attention(nodes, waiting = new Set()) {
|
|
|
445
499
|
const needs = agents.filter((n) => waiting.has(n.id)).sort((a, b) => when(b) - when(a));
|
|
446
500
|
const failed = agents.filter((n) => !waiting.has(n.id) && stateOf(n.status) === 'failed').sort((a, b) => when(b) - when(a));
|
|
447
501
|
return [
|
|
448
|
-
...needs.map((n) => ({ id: n.id, tone: 'waiting', name: typeOf(n), says: 'waiting for you' })),
|
|
502
|
+
...needs.map((n) => ({ id: n.id, tone: 'waiting', name: shownType(n), real: typeOf(n), says: 'waiting for you' })),
|
|
449
503
|
...failed.map((n) => ({
|
|
450
504
|
id: n.id,
|
|
451
505
|
tone: 'fail',
|
|
452
|
-
name:
|
|
506
|
+
name: shownType(n),
|
|
507
|
+
real: typeOf(n),
|
|
453
508
|
says: [n.status, n.errors ? `${n.errors} ${n.errors === 1 ? 'error' : 'errors'}` : null,
|
|
454
509
|
n.workflow ? `in ${n.workflow}` : null].filter(Boolean).join(' · '),
|
|
455
510
|
})),
|
|
@@ -133,6 +133,7 @@
|
|
|
133
133
|
<div id="timeline" role="tabpanel" aria-labelledby="view-timeline" hidden></div>
|
|
134
134
|
<div id="list" role="tabpanel" aria-labelledby="view-list" hidden></div>
|
|
135
135
|
<div id="first-run" class="first-run" hidden></div>
|
|
136
|
+
<div id="terminal-wait" class="terminal-wait" role="status" hidden></div>
|
|
136
137
|
<div id="dock" role="region" aria-label="Waiting for approval" hidden></div>
|
|
137
138
|
</main>
|
|
138
139
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Functions of the server's data, with no page in them: the four tiles, what the agent is doing right now, which
|
|
4
4
|
// skills it applied, who delegated to it. The rule they share is the panel's own: a value that was not read is
|
|
5
5
|
// written "Not measured", with why — never as 0 and never as an empty list.
|
|
6
|
+
import { roleName } from './names.js';
|
|
6
7
|
|
|
7
8
|
const NOT_MEASURED = 'Not measured';
|
|
8
9
|
|
|
@@ -87,13 +88,25 @@ export function skillsOf(detail) {
|
|
|
87
88
|
return seen;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The commands a session has running in the background, as lines: what runs, and for how long.
|
|
93
|
+
* `backgroundNow` is empty unless the session is live (graph-plan.js `settle`), so a session that is over lists none.
|
|
94
|
+
*/
|
|
95
|
+
export function backgroundLines(session, now) {
|
|
96
|
+
return (session?.backgroundNow ?? []).map((c) => ({
|
|
97
|
+
id: c.id,
|
|
98
|
+
what: `${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`,
|
|
99
|
+
age: c.startedAt != null && now >= c.startedAt ? fmtDuration(now - c.startedAt) : null,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
/** Who handed this agent its work: the node it hangs from. */
|
|
91
104
|
export function delegatedBy(n, nodes) {
|
|
92
105
|
const p = (nodes ?? []).find((x) => x.id === n.parentId) ?? null;
|
|
93
106
|
if (!p) return null;
|
|
94
107
|
if (p.kind === 'session') return { id: p.id, text: `Session${p.gitBranch ? ` · ${p.gitBranch}` : ''}` };
|
|
95
108
|
if (p.kind === 'workflow') return { id: p.id, text: `Workflow run · ${p.workflowId ?? p.id}` };
|
|
96
|
-
return { id: p.id, text: p.agentType
|
|
109
|
+
return { id: p.id, text: p.agentType ? roleName(p.agentType) : p.id };
|
|
97
110
|
}
|
|
98
111
|
|
|
99
112
|
/** The report, or the plain statement that there is none yet and why. */
|
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
// needs someone is always at the top. A status this panel has no word for is not put into one of the four: it
|
|
5
5
|
// gets a section of its own, under its own name, after them.
|
|
6
6
|
import { stateOf } from './graph-plan.js';
|
|
7
|
-
|
|
8
|
-
const typeOf = (n) => n.agentType ?? 'unknown agent';
|
|
7
|
+
import { typeOf, shownType } from './names.js';
|
|
9
8
|
|
|
10
9
|
/** "8m", "1h 4m", "40s": how long, in as little room as a row has. */
|
|
11
10
|
export function short(ms) {
|
|
@@ -28,13 +27,14 @@ function rowOf(n, now) {
|
|
|
28
27
|
// The count the transcript gave. An agent can fail without a tool error; then there is none to report.
|
|
29
28
|
if (n.errors) bits.push(`${n.errors} ${n.errors === 1 ? 'error' : 'errors'}`);
|
|
30
29
|
if (n.status !== 'failed') bits.push(n.status);
|
|
31
|
-
return { id: n.id, node: n, type: typeOf(n), line: bits.join(' · ') || (n.description ?? ''), aside: null };
|
|
30
|
+
return { id: n.id, node: n, type: shownType(n), real: typeOf(n), line: bits.join(' · ') || (n.description ?? ''), aside: null };
|
|
32
31
|
}
|
|
33
32
|
const live = state === 'live' || state === 'waking';
|
|
34
33
|
return {
|
|
35
34
|
id: n.id,
|
|
36
35
|
node: n,
|
|
37
|
-
type:
|
|
36
|
+
type: shownType(n),
|
|
37
|
+
real: typeOf(n),
|
|
38
38
|
line: n.description ?? '',
|
|
39
39
|
// How long it has been going. With no start recorded there is nothing to count from, and nothing is shown.
|
|
40
40
|
aside: state === 'waking' ? 'starting' : live && n.startedAt != null ? short(now - n.startedAt) : null,
|
package/kit/studio/web/list.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// own three answers, so in this view the approval dock is not needed and is not shown.
|
|
5
5
|
import { sections, wants } from './list-plan.js';
|
|
6
6
|
import { Press } from './press.js';
|
|
7
|
+
import { hoverOf } from './names.js';
|
|
7
8
|
import { remaining, asker, allowSessionLabel, VERDICTS } from './approvals.js';
|
|
8
9
|
|
|
9
10
|
function mk(tag, cls, text) {
|
|
@@ -146,6 +147,7 @@ export class List {
|
|
|
146
147
|
if (tile) row.append(tile);
|
|
147
148
|
const text = mk('span', 'ls-text');
|
|
148
149
|
text.append(mk('span', 'ls-name', r.type), mk('span', 'ls-line', r.line));
|
|
150
|
+
row.title = hoverOf(r.node);
|
|
149
151
|
row.append(text);
|
|
150
152
|
if (r.aside) row.append(mk('span', 'sub ls-aside', r.aside));
|
|
151
153
|
const chev = mk('span', 'ls-chev');
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// What an agent is called on screen.
|
|
2
|
+
//
|
|
3
|
+
// An agent's type is a file name: `crew-test-expert`, `general-purpose`. Drawn as it is, a column of cards reads
|
|
4
|
+
// "crew-…-expert" twelve times and the word that tells them apart is cut off first. The views show the role
|
|
5
|
+
// instead ("Test", "General Purpose"); the type itself is in the inspector and on hover, and nothing is renamed
|
|
6
|
+
// anywhere but on screen. Keys, ids and comparisons use the type.
|
|
7
|
+
|
|
8
|
+
// A word that is not written the way capitalising its first letter would write it.
|
|
9
|
+
const WORD = { devops: 'DevOps' };
|
|
10
|
+
|
|
11
|
+
/** The type as the transcript has it; an agent with none is said to have none. */
|
|
12
|
+
export const typeOf = (n) => n?.agentType ?? 'unknown agent';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `crew-backend-expert` → Backend · `crew-session-manager` → Session Manager · `general-purpose` → General Purpose.
|
|
16
|
+
* Crewforth's own prefix and its `-expert` / `-agent` suffix go; what is left is the role. A name that is not
|
|
17
|
+
* made of hyphenated words (it has a space, or nothing to split) is returned as it came.
|
|
18
|
+
*/
|
|
19
|
+
export function roleName(type) {
|
|
20
|
+
if (typeof type !== 'string' || !type || /\s/.test(type)) return type ?? '';
|
|
21
|
+
const crew = type.startsWith('crew-');
|
|
22
|
+
const core = crew ? type.slice(5).replace(/-(expert|agent)$/, '') : type;
|
|
23
|
+
if (!core) return type;
|
|
24
|
+
return core.split('-').filter(Boolean)
|
|
25
|
+
.map((w) => WORD[w] ?? (w[0].toUpperCase() + w.slice(1)))
|
|
26
|
+
.join(' ');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The name a node is drawn with. */
|
|
30
|
+
export const shownType = (n) => roleName(typeOf(n));
|
|
31
|
+
|
|
32
|
+
/** What hovering an agent says: the type as it is declared, and its task in full, since both are cut on screen. */
|
|
33
|
+
export const hoverOf = (n) => [n?.agentType ?? null, n?.description ?? null].filter(Boolean).join('\n');
|